TIOCSTI unrestricted terminal input injection with no killswitch
| Field | Value |
|---|---|
| ID | DF-0005 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N |
| CWE | CWE-840 Business Logic Errors; CWE-20 Improper Input Validation |
| File | sys/kern/tty.c |
| Lines | 1158-1174 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-06-29 |
| Reported | pending |
Summary
TIOCSTI is permitted for any local unprivileged user who holds their
controlling terminal open for read (the normal case β every interactive
session has /dev/tty). It injects an arbitrary byte into the tty input queue
via the line discipline's l_rint, enabling keystroke injection into any
other process sharing that terminal (e.g. a setuid program, a privileged
daemon, or a sandboxed child). Unlike Linux (dev.tty.legacy_tiocsti) and
recent OpenBSD, DragonFlyBSD provides no sysctl/capability to disable it, so
the injection primitive is always available.
Root cause
In ttioctl, the TIOCSTI case (sys/kern/tty.c:1158-1174) gates only the
fallback cases with caps_priv_check_td(SYSCAP_RESTRICTEDROOT):
case TIOCSTI: /* simulate terminal input */
if ((flag & FREAD) == 0 &&
caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT)) /* tty.c:1159-1161 */
{
...
return (EPERM);
}
if (!isctty(p, tp) &&
caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT)) /* tty.c:1166-1168 */
{
...
return (EACCES);
}
(*linesw[tp->t_line].l_rint)(*(u_char *)data, tp); /* tty.c:1173 */
A normal user satisfies both bypass conditions β they open /dev/tty or
their pts slave O_RDWR (so flag & FREAD is set) and isctty() is true
(sys/sys/tty.h) β so neither privilege check fires. Execution then reaches
l_rint, pushing the attacker-supplied byte into the raw queue exactly as if
it had been typed. There is no global enable/disable knob anywhere in the tree
(no sysctl gates TIOCSTI; the only gate is the FREAD/isctty test, which
a tty owner always passes).
Threat model & preconditions
- Attacker position: any local unprivileged user who can open their
controlling terminal (trivial β every interactive session has
/dev/tty). - Privileges gained or impact: terminal input injection across a
privilege/trust boundary. Bytes are read by whichever process next reads the
tty, including setuid-root utilities (e.g.
su/sudo/passwd-style prompts), privileged daemons attached to the pty, or sandboxed applications whose output the attacker can capture. Realistic outcomes include confused-deputy command injection into a privileged reader and sandbox escape via the controlling pty. - Required config or capabilities: none beyond a tty; default kernel.
- Reachability:
/dev/tty(tty_tty.ccttyioctlβVOP_IOCTLβttioctl), and anypts/ptmxslave the attacker owns.
Proof of concept
PoC source: findings/poc/DF-0005/tiocsti.c
Build & run
cc -o tiocsti findings/poc/DF-0005/tiocsti.c ./tiocsti "echo INJECTED_BY_TIOCSTI >/tmp/pwned" # as a non-root user, in a tty
Expected output
[+] injected 39 bytes into the tty input queue [+] they will be read by the next reader of this tty
When the consuming shell next reads, the injected command is processed as if
typed, creating /tmp/pwned. No EPERM/EACCES is returned. A
privilege-gain variant races the injection against a setuid program that reads
a password/command from the tty (program-specific).
Impact
A persistent, unmitigated input-injection primitive for any local user with a controlling terminal. The actual privilege gain depends on a victim program reading the injected bytes, which is why this is rated Low rather than higher. The absence of any disable knob means hardened/multi-user systems cannot neutralize it, unlike peer OSes.
Recommended fix
Add a knob to disable TIOCSTI system-wide (default-on for compatibility,
with the documented secure option of default-off), mirroring
dev.tty.legacy_tiocsti.
--- a/sys/kern/tty.c
+++ b/sys/kern/tty.c
@@ -105,6 +105,15 @@ MALLOC_DEFINE(M_TTYS, "ttys", "tty data structures");
+#ifdef TIOCSTI_DISABLE_DEFAULT
+static int tty_tiocsti_enable = 0;
+#else
+static int tty_tiocsti_enable = 1;
+#endif
+SYSCTL_INT(_kern, OID_AUTO, tty_tiocsti, CTLFLAG_RW, &tty_tiocsti_enable, 0,
+ "Enable TIOCSTI terminal input injection (0=deny)");
+
@@ -1158,6 +1167,11 @@ ttioctl(struct tty *tp, u_long cmd, void *data, int flag)
case TIOCSTI: /* simulate terminal input */
+ if (!tty_tiocsti_enable) {
+ lwkt_reltoken(&p->p_token);
+ lwkt_reltoken(&tp->t_token);
+ return (EPERM);
+ }
if ((flag & FREAD) == 0 &&
caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT))
{
This gives operators a single-knob way to neutralize the injection primitive
on multi-user or hardened systems while preserving historical behavior by
default. A stronger follow-up is to gate TIOCSTI behind SYSCAP_RESTRICTEDROOT
when kern.tty_tiocsti == 0.
References
sys/kern/tty.c:1158-1174βTIOCSTIhandling (privilege bypass).- Linux
dev.tty.legacy_tiocsti(commit / Documentation). - OpenBSD TIOCSTI restriction.
- CWE-840 Business Logic Errors; CWE-20 Improper Input Validation.
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/tty.c. - pending Reported to DragonFlyBSD security contact.
PoC verification
Evidence pack
findings/poc/DF-0005 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| tiocsti.c | trigger-source | self-contained TIOCSTI PoC: pty pair + unprivileged child claims ctty + injects via /dev/tty (no-EPERM proof, readback, downstream-shell execution) | 6.3 KB | view raw |
| build.sh | build-script | cc -Wall -o tiocsti tiocsti.c | 210 B | view raw |
| run.sh | run-script | ./tiocsti (no external tty required) | 293 B | view raw |
| build.log | build-log | final successful build, full output | 66 B | view raw |
| run.log | run-log | decisive run #1, full output (uid=1001, no EPERM, readback, downstream exec) | 406 B | view raw |
| run.2.log | run-log | stress run #2 (identical result) | 406 B | view raw |
| run.3.log | run-log | stress run #3 (identical result) | 406 B | view raw |
| env.txt | environment | uname, cc version, unprivileged uid, negative sysctl killswitch search, kernel source refs | 1.5 KB | view raw |
| VERDICT.md | verdict | full narrative: mechanism with path:line, evidence, fix rationale | 6.2 KB | β raw |
| fix.diff | suggested-fix | git-apply-able: add kern.tty_tiocsti sysctl killswitch to sys/kern/tty.c | 1.1 KB | view raw |
| README.md | readme | human-facing build/run/expected summary | 3.9 KB | β raw |
DF-0005 β PoC: TIOCSTI unrestricted terminal input injection (no killswitch)
Status: REPRODUCED on DragonFlyBSD master DEV (v6.5.0.1712.g89e6a-DEVELOPMENT).
Unprivileged uid=1001 can inject arbitrary bytes into a controlling terminal
via TIOCSTI; the injected bytes are consumed and executed by the next reader
of the tty. No EPERM/EACCES is returned and no sysctl/capability
killswitch exists to disable it.
The issue
In ttioctl (sys/kern/tty.c:1158-1173) the two
caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT) guards (tty.c:1160,1167)
are each gated on a condition a legitimate controlling-tty owner does not
meet:
| Guard | Gating condition | Value for ctty owner (O_RDWR open of /dev/tty) |
Result |
|---|---|---|---|
1st (tty.c:1159-1163) |
(flag & FREAD) == 0 |
FALSE β FREAD=0x0001 is set (fcntl.h:68) |
short-circuits β no EPERM |
2nd (tty.c:1165-1169) |
!isctty(p, tp) |
FALSE β isctty is true for the ctty (tty.h:216) |
short-circuits β no EACCES |
Control falls through to the inject sink:
(*linesw[tp->t_line].l_rint)(*(u_char *)data, tp); /* sys/kern/tty.c:1172 */
β¦which pushes the attacker byte into the tty input queue exactly as if typed.
A grep for tty_tiocsti / legacy_tiocsti across sys/ returns nothing, and
on the running guest sysctl kern.tty_tiocsti, kern.legacy_tiocsti, and
dev.tty.legacy_tiocsti all return "unknown oid" β there is no killswitch.
Build
On the DragonFlyBSD guest, as any user:
cc -Wall -o tiocsti tiocsti.c # or simply: ./build.sh
Run
As an unprivileged user (no external interactive terminal required β the PoC allocates its own pty pair, so it runs cleanly under non-interactive ssh):
./tiocsti # or inject a custom downstream command: ./tiocsti "echo custom_payload"
Expected output (bug present)
[*] uid=1001 euid=1001 pty slave=/dev/pts/0 [+] TIOCSTI ioctl succeeded (no EPERM/EACCES) -- guards at tty.c:1160,1167 bypassed for ctty owner [+] readback from /dev/tty (25 bytes): echo TIOCSTI_READBACK_OK echo TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL exit $ echo TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL <-- injected command, EXECUTED by the downstream shell $ exit [*] downstream sh exit status: 0
This proves all three properties:
- No
EPERM/EACCESfor unprivilegeduid=1001β both privilege guards bypassed for the ctty owner (the core claim). - Bytes reach the
l_rintsink (tty.c:1172) β the 25-byte injected line is read straight back out of/dev/tty. - Confused-deputy execution β a different downstream reader of the tty (a shell exec'd on the pty slave) reads the second injected line out of the input queue and executes it. This is the trust-boundary crossing that makes TIOCSTI dangerous (e.g. injecting into a setuid utility reading a password/command from the tty).
On a kernel patched with fix.diff, sysctl kern.tty_tiocsti=0 makes the
ioctl return EPERM and the PoC prints TIOCSTI DENIED: Operation not
permitted.
The run is deterministic (3/3 identical β see run.log, run.2.log,
run.3.log) and non-destructive (no panic; guest stays up). Concrete
privilege escalation would require a setuid/privileged tty-reader victim,
which is program-specific and out of scope for this Low finding β hence the
finding's Low severity is confirmed accurate.
Files
tiocsti.cβ self-contained trigger source.build.sh/run.shβ exact build/run commands.build.log,run.log,run.2.log,run.3.logβ full untrimmed outputs.env.txtβ guest uname, compiler, unprivileged uid, killswitch search.VERDICT.mdβ full mechanism walkthrough withpath:linecitations.fix.diffβ git-apply-ablekern.tty_tiocstikillswitch forsys/kern/tty.c.manifest.jsonβ machine-readable artifact catalog.
DF-0005 β VERDICT
REPRODUCED β TIOCSTI terminal input injection is unconditionally available to any unprivileged local user with their controlling tty open for read, with no sysctl/capability killswitch on DragonFlyBSD master DEV. Impact: leak:0 (info) class β the primitive itself is input-injection / confused-deputy execution, not memory corruption; rated Low because concrete privilege gain depends on a setuid/privileged tty-reader victim (program-specific). The finding's claim, severity, and cited line numbers are all confirmed accurate.
Mechanism (trigger β primitive β effect)
The unprivileged caller opens its controlling terminal (/dev/tty, or a pts
slave it owns) O_RDWR and issues ioctl(fd, TIOCSTI, &byte). In
sys/kern/tty.c the TIOCSTI case (tty.c:1158-1173) gates only the
fallback paths with caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT):
/* tty.c:1158 */ case TIOCSTI: /* simulate terminal input */
/* tty.c:1159 */ if ((flag & FREAD) == 0 &&
/* tty.c:1160 */ caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT))
/* tty.c:1161-1163 */ { ...; return (EPERM); }
/* tty.c:1165 */ if (!isctty(p, tp) &&
/* tty.c:1166 */ caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT))
/* tty.c:1167-1169 */ { ...; return (EACCES); }
/* tty.c:1172 */ (*linesw[tp->t_line].l_rint)(*(u_char *)data, tp); /* SINK */
A legitimate ctty owner defeats both guards:
- First guard bypassed β
flagis thef_flagof the open file (sys/sys/fcntl.h:68#define FREAD 0x0001); anO_RDWRopen hasFREADset, so(flag & FREAD) == 0is FALSE, the&&short-circuits, and the privilege check is never evaluated β noEPERM. - Second guard bypassed β
isctty(p, tp)is(p->p_session == tp->t_session && (p->p_flags & P_CONTROLT))(sys/sys/tty.h:216); for the process's own controlling terminal this is TRUE, so!isctty(p, tp)is FALSE β noEACCES.
Control therefore reaches the inject sink tty.c:1172, which calls the line
discipline's l_rint to push the attacker byte into the tty input queue
exactly as if it had been typed on the keyboard. Whatever process reads the
tty next β the caller itself, a sibling shell, a setuid utility prompting on
the tty, or a privileged daemon β receives and acts on the injected bytes.
There is no global enable/disable knob anywhere in the tree (a grep for
tty_tiocsti / legacy_tiocsti across sys/ returns nothing; on the running
guest sysctl kern.tty_tiocsti, kern.legacy_tiocsti, and
dev.tty.legacy_tiocsti all return "unknown oid"). Unlike Linux
(dev.tty.legacy_tiocsti, default flipping to off) and recent OpenBSD,
DragonFlyBSD gives operators no way to neutralize the primitive.
Evidence (decisive run, unprivileged uid=1001 maxx)
./tiocsti three times, identical result:
[*] uid=1001 euid=1001 pty slave=/dev/pts/0 [+] TIOCSTI ioctl succeeded (no EPERM/EACCES) -- guards at tty.c:1160,1167 bypassed for ctty owner [+] readback from /dev/tty (25 bytes): echo TIOCSTI_READBACK_OK echo TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL exit $ echo TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL <-- the injected command, EXECUTED by the downstream shell $ exit [*] downstream sh exit status: 0
This proves all three properties end-to-end:
- No
EPERM/EACCESfor unprivileged uid 1001 β both privilege guards bypassed for the ctty owner (the core claim). - Bytes reach the
l_rintsink β the 25-byte injected line is read straight back out of/dev/tty. - Confused-deputy execution β a different downstream reader of the tty
(a shell exec'd on the slave) reads the second injected line out of the
input queue and executes it (
TIOCSTI_EXEC_BY_DOWNSTREAM_SHELLis printed as the command's own output). This is the trust-boundary crossing that makes TIOCSTI dangerous (e.g. injecting into a setuid program reading a password/command from the tty).
The run is deterministic (3/3 identical) and non-destructive (no panic, guest stays up). A real privilege-escalation demo would require a setuid tty-reader victim, which is out of scope for this Low finding and program-specific.
PoC changes
The original tiocsti.c only printed "injected N bytes" and required an
external interactive terminal (/dev/tty open fails under non-interactive
ssh, since there is no controlling tty). I rewrote it to be fully
self-contained: it allocates its own /dev/ptmx pty pair, an unprivileged
child does setsid() + TIOCSCTTY to claim the slave as its controlling
terminal (putting it in the exact position the finding describes), then
exercises TIOCSTI on /dev/tty. The PoC now demonstrates the full chain
(no-EPERM + readback + downstream-shell execution) with no external
dependencies and runs cleanly under non-interactive ssh. Build/run commands
unchanged: cc -o tiocsti tiocsti.c then ./tiocsti.
Why this is not a false positive / not already fixed
I traced the exact data flow from attacker input to sink in sys/kern/tty.c
on master DEV (commit v6.5.0.1712.g89e6a-DEVELOPMENT, built
2026-06-29). The two caps_priv_check_td(SYSCAP_RESTRICTEDROOT) guards are
present but each is short-circuited by the (flag & FREAD) == 0 /
!isctty(p, tp) gating the finding describes β they protect only the
non-owner fallback cases (a process that opened the tty without FREAD, or
a tty that is not its controlling terminal). For the legitimate ctty owner
(the threat model) neither check fires, and no other gate (sysctl, capability,
compile-time option) exists in the tree. The primitive is real and unmitigated
on master.
Recommended fix
Add a kern.tty_tiocsti sysctl killswitch (default-on for compatibility, with
the documented secure option of default-off), mirroring Linux's
dev.tty.legacy_tiocsti. The full git-apply-able diff is in fix.diff; it
supersedes the finding markdown's proposal (which was sketch-only with
incorrect line offsets) β this one is line-accurate against the audited tree,
drops both tokens only on the deny path, and adds the SYSCTL registration with
a description string. A stronger follow-up (also noted) is to additionally
gate TIOCSTI behind SYSCAP_RESTRICTEDROOT when kern.tty_tiocsti == 0.
Confirmed kernel references
Detail
Exploit chain
none. TIOCSTI is a logic/input-injection class, not memory corruption -- there is no slab bucket, victim object, or heap-grooming chain. The primitive (push an arbitrary byte into the controlling terminal input queue via l_rint at tty.c:1172) is confirmed working for any unprivileged ctty owner; converting it to root requires a separate setuid tty-reader victim to read the injected bytes (program-specific, not demonstrated here). No kernel memory-corruption primitive is derivable.
Evidence (decisive lines)
[*] uid=1001 euid=1001 pty slave=/dev/pts/0 [+] TIOCSTI ioctl succeeded (no EPERM/EACCES) -- guards at tty.c:1160,1167 bypassed for ctty owner [+] readback from /dev/tty (25 bytes): echo TIOCSTI_READBACK_OK echo TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL exit $ echo TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL $ exit [*] downstream sh exit status: 0 RUN_EXIT=0 --- killswitch search (all negative => no killswitch) --- sysctl: unknown oid 'kern.tty_tiocsti' sysctl: unknown oid 'kern.legacy_tiocsti' sysctl: unknown oid 'dev.tty.legacy_tiocsti' (no tiocsti sysctl found anywhere)
PoC changes
Rewrote findings/poc/DF-0005/tiocsti.c to be fully self-contained. The original only printed 'injected N bytes' and required an external interactive terminal (/dev/tty open fails under non-interactive ssh, which has no controlling tty). The new version allocates its own /dev/ptmx pty pair, an unprivileged child does setsid()+TIOCSCTTY to claim the slave as its controlling terminal (the exact position the finding describes), then exercises TIOCSTI on /dev/tty. It demonstrates the full chain end-to-end with no external dependencies: (1) TIOCSTI succeeds with no EPERM/EACCES, (2) the injected line is read straight back from /dev/tty proving the bytes reached the l_rint sink, (3) a different downstream shell exec'd on the slave reads a second injected line out of the input queue and EXECUTES it (confused-deputy). Added
Verified recommended fix
Add a kern.tty_tiocsti sysctl killswitch (default 1 for historical compatibility, 0 to deny system-wide) mirroring Linux dev.tty.legacy_tiocsti. The full git-apply-able diff is in findings/poc/DF-0005/fix.diff (verified with git apply --check): it adds a static int tty_tiocsti_enable = 1; plus SYSCTL_INT(_kern, OID_AUTO, tty_tiocsti, ...) registration after the MALLOC_DEFINE in sys/kern/tty.c, and an if (!tty_tiocsti_enable) { lwkt_reltoken; return EPERM; } block at the top of the TIOCSTI case (sys/kern/tty.c:1158). This SUPERSEDES the finding markdown's proposal (which was a sketch with placeholder line offsets and no SYSCTL registration); a stronger follow-up is to additionally gate TIOCSTI behind SYSCAP_RESTRICTEDROOT when kern.tty_tiocsti == 0.
Verdict
REPRODUCED. The bug is real and unmitigated on DragonFlyBSD master DEV: in sys/kern/tty.c:1158-1173 the two caps_priv_check_td(SYSCAP_RESTRICTEDROOT) guards (tty.c:1160 gated on (flag & FREAD)==0, tty.c:1167 gated on !isctty(p, tp)) are each gated on a condition a legitimate controlling-tty owner does NOT meet -- an O_RDWR open of /dev/tty sets FREAD (fcntl.h:68) so (flag & FREAD)==0 is FALSE, and isctty is TRUE for the ctty (tty.h:216) so !isctty is FALSE -- so neither privilege check fires and the attacker byte reaches the inject sink tty.c:1172 (linesw[...].l_rint). Confirmed as unprivileged uid=1001 maxx: TIOCSTI returned success (no EPERM/EACCES), the injected 25-byte line was read straight back out of /dev/tty, and a downstream shell exec'd on the pty slave read and EXECUTED a second injected line (printed 'TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL'). Deterministic 3/3 identical runs. No killswitch exists: sysctl kern.tty_tiocsti / kern.legacy_tiocsti / dev.tty.legacy_tiocsti all return 'unknown oid' on the running guest and a grep across sys/ finds nothing. Impact is the input-injection / confused-deputy primitive itself (no direct kernel memory corruption / DoS / uid0 / info-leak), so the finding's Low severity is confirmed accurate; concrete privilege gain would require a setuid/privileged tty-reader victim which is program-specific and out of scope.