β¬’ DragonFlyBSD Kernel Audit
← dashboard
DF-0005

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.c cttyioctl β†’ VOP_IOCTL β†’ ttioctl), and any pts/ptmx slave 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.

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 β€” TIOCSTI handling (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
FileTypeDescriptionSize
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
README.md readme human-facing build/run/expected summary
↓ download 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:

  1. No EPERM/EACCES for unprivileged uid=1001 β†’ both privilege guards bypassed for the ctty owner (the core claim).
  2. Bytes reach the l_rint sink (tty.c:1172) β†’ the 25-byte injected line is read straight back out of /dev/tty.
  3. 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 with path:line citations.
  • fix.diff β€” git-apply-able kern.tty_tiocsti killswitch for sys/kern/tty.c.
  • manifest.json β€” machine-readable artifact catalog.
VERDICT.md verdict full narrative: mechanism with path:line, evidence, fix rationale
↓ download raw

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:

  1. First guard bypassed β€” flag is the f_flag of the open file (sys/sys/fcntl.h:68 #define FREAD 0x0001); an O_RDWR open has FREAD set, so (flag & FREAD) == 0 is FALSE, the && short-circuits, and the privilege check is never evaluated β†’ no EPERM.
  2. 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 β†’ no EACCES.

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:

  1. No EPERM/EACCES for unprivileged uid 1001 β†’ both privilege guards bypassed for the ctty owner (the core claim).
  2. Bytes reach the l_rint sink β†’ the 25-byte injected line is read straight back out of /dev/tty.
  3. 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_SHELL is 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.

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 . Build/run commands unchanged. Also added build.sh, run.sh, VERDICT.md, fix.diff, manifest.json, env.txt, build.log, run.log + run.2.log/run.3.log (3 stress runs).

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.