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.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0005 Β· 16 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 |
| baseline_unpatched.log | baseline-run-log | Phase 8 'before' half: PoC on #0 unpatched kernel reproducing the bug (sysctl oid does not exist) | 542 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, Phase 8 fix validation | 8.8 KB | β raw |
| fix.diff | suggested-fix | git-apply-able: add kern.tty_tiocsti sysctl killswitch to sys/kern/tty.c (validated on #1) | 1.1 KB | view raw |
| fix_build.log | fix-build-log | full nativekernel build output for the single-fix #1 kernel (rc=0) | 5.6 MB | β download |
| fix_run.log | fix-run-log | Phase 8 'after' half: PoC on #1 kernel across 5 scenarios (default=1 works, =0 EPERM x3, toggle-back works) | 2.0 KB | view raw |
| README.md | readme | human-facing build/run/expected summary | 4.5 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view 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. Fix validated on a built-and-booted single-fix #1 kernel (see
fix_build.log, fix_run.log, and the "Fix validation" section of
VERDICT.md): default =1 preserves historical behavior; =0 blocks the
injection deterministically across 3 consecutive runs; toggle back to =1
restores it (round-trip OK).
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.baseline_unpatched.logβ Phase 8 "before": PoC on#0unpatched kernel.fix_build.logβ fullnativekerneloutput for the single-fix#1kernel.fix_run.logβ Phase 8 "after": PoC on#1across 5 scenarios.env.txtβ guest uname, compiler, unprivileged uid, killswitch search.VERDICT.mdβ full mechanism walkthrough withpath:linecitations + fix validation.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.
FIX VALIDATED (fixed). A single-fix kernel built from fix.diff (adds a
kern.tty_tiocsti sysctl killswitch, default-on for compatibility) closes the
primitive: with kern.tty_tiocsti=0 TIOCSTI returns EPERM and no byte reaches
the l_rint sink; with the default =1 historical behavior is preserved and
the sysctl round-trips cleanly. See "Fix validation" below.
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.
Fix validation (Phase 8 β built + booted a single-fix kernel)
The fix.diff was validated end-to-end on a single-fix kernel built from the
audited /usr/src tree.
Build. Applied cleanly with patch -p1 (both hunks at the expected
offsets β sysctl+variable after MALLOC_DEFINE(M_TTYS,...) at tty.c:105,
killswitch at the head of the TIOCSTI case at tty.c:1158). Built with
make -j6 nativekernel KERNCONF=X86_64_GENERIC β rc=0, no warnings/errors
on the patched tty.c (-Werror). kernel.stripped overwritten onto the
bare /boot/kernel/kernel (the loader's boot name). Full build log in
fix_build.log.
Before/after contrast (the decisive evidence):
| Kernel | kern.tty_tiocsti |
TIOCSTI result | Bytes reach l_rint sink? |
|---|---|---|---|
#0 unpatched baseline |
(oid does not exist) | succeeds | yes (readback + exec) |
#1 single-fix, default |
1 (compat) |
succeeds | yes (compat preserved) |
#1 single-fix, gated |
0 |
EPERM | no β denied at head of case |
#1 single-fix, toggled |
1 again |
succeeds | yes (round-trip OK) |
The gated state was confirmed deterministic across 3 consecutive PoC runs (all
EPERM, all no-injection). The guest stays up after every run (no panic, no
wedge) β the fix is purely a behavior gate. Full before/after PoC output is in
baseline_unpatched.log (the #0 reproduction) and fix_run.log (the #1
five-scenario run: default β gated Γ3 β toggle-back).
Fix verdict: FIXED. The single-fix kernel closes the primitive exactly as
intended β operators can now neutralize TIOCSTI system-wide via
sysctl kern.tty_tiocsti=0 (returns EPERM, no injection), while default-on
preserves historical behavior and the sysctl round-trips cleanly. The finding's
"no killswitch" claim is fully resolved.
Patch versions:
- unpatched baseline: DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (sha256 5dc83dacβ¦)
- single-fix kernel: DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 17:36:19 UTC 2026 (sha256 76151248β¦)
Fix verification
fixedVALIDATED the fix. Baseline #0 (sysctl oid absent): TIOCSTI succeeds, bytes injected, downstream shell executes the injected command (confused-deputy confirmed). Single-fix #1 kernel: with the default kern.tty_tiocsti=1 the historical behavior is preserved (injection works for compat); with kern.tty_tiocsti=0 the ioctl returns EPERM and no byte reaches l_rint (deterministic across 3 consecutive runs); toggling back to =1 restores the injection (round-trip OK). Build rc=0, no warnings on the patched tty.c (-Werror). The fix closes the primitive exactly as designed -- operators can now neutralize TIOCSTI system-wide while default-on preserves compatibility. fix_status=fixed.
baseline #0: [+] TIOCSTI ioctl succeeded (no EPERM/EACCES) / [+] readback from /dev/tty (25 bytes): echo TIOCSTI_READBACK_OK / TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL (downstream sh exit 0) patched #1 kern.tty_tiocsti=1 (default): identical to baseline (compat preserved, exit 0) patched #1 kern.tty_tiocsti=0 (FIX): [!] TIOCSTI DENIED: Operation not permitted -- primitive is gated (exit 1, x3 runs identical) patched #1 toggle back to =1: injection works again (exit 0)
Confirmed kernel references
Detail
Exploit chain
unprivileged TIOCSTI terminal input injection (confused-deputy): any local user who owns a controlling tty can push arbitrary bytes into its input queue via l_rint (sys/kern/tty.c:1172), which are then read and acted on by the next reader of the tty (e.g. a setuid utility prompting on the tty, a privileged daemon, or a sandboxed child). No memory-corruption primitive, hence no slab-grooming/exploit-chain development -- the realistic impact ceiling is program-specific confused-deputy command injection, consistent with the Low rating. Concrete privilege gain would require a specific privileged tty-reader victim (out of scope for this finding).
Evidence (decisive lines)
baseline (#0, unpatched): [+] 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 / TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL / [*] downstream sh exit status: 0 patched #1, kern.tty_tiocsti=1 (default): identical to baseline (compat preserved) patched #1, kern.tty_tiocsti=0 (gated, 3 runs): [!] TIOCSTI DENIED: Operation not permitted -- primitive is gated / [*] downstream sh exit status: 1 (no readback, no exec) patched #1, toggle back to =1: injection works again (round-trip OK)
PoC changes
fix.diff unchanged from prior session (validated as-is: both hunks applied at expected offsets tty.c:104 and tty.c:1166 on /usr/src, compiled clean with -Werror, build rc=0). Added to the evidence pack: baseline_unpatched.log (#0 reproduction), fix_build.log (full nativekernel output, 35496 lines, rc=0), fix_run.log (Phase 8 five-scenario run on #1). Updated VERDICT.md with a 'Fix validation' section documenting the before/after contrast, and manifest.json with fix_status/fix_kernel_uname and the new artifact entries. tiocsti.c unchanged (code_hash matches prior session).
Verified recommended fix
Add a kern.tty_tiocsti sysctl killswitch (default-on=1 for compatibility) to sys/kern/tty.c: a static int tty_tiocsti_enable=1 registered via SYSCTL_INT(_kern,OID_AUTO,tty_tiocsti,CTLFLAG_RW,...) after the MALLOC_DEFINE(M_TTYS) block, plus an early 'if (!tty_tiocsti_enable) { lwkt_reltoken(&p->p_token); lwkt_reltoken(&tp->t_token); return (EPERM); }' at the head of the TIOCSTI case (sys/kern/tty.c:1158). Validated on a built-and-booted single-fix #1 kernel: kern.tty_tiocsti=0 deterministically returns EPERM and blocks injection, while =1 preserves historical behavior and round-trips cleanly. The full git-apply-able diff lives in findings/poc/DF-0005/fix.diff; it supersedes the finding markdown's sketch-level proposal (which had incorrect line offsets) with a line-accurate version that drops both tokens only on the deny path.
Verdict
REPRODUCED on the unpatched #0 baseline (sysctl kern.tty_tiocsti does not exist): the self-contained PoC, run as unprivileged uid=1001 maxx, succeeds in injecting bytes via TIOCSTI into the controlling tty -- the 25-byte 'echo TIOCSTI_READBACK_OK' line is read straight back out of /dev/tty, and a second injected line ('echo TIOCSTI_EXEC_BY_DOWNSTREAM_SHELL') is consumed and EXECUTED by a downstream shell exec'd on the pty slave (the confused-deputy proof). No EPERM/EACCES is returned because both caps_priv_check_td(SYSCAP_RESTRICTEDROOT) guards in sys/kern/tty.c:1158-1173 are short-circuited: (flag & FREAD)==0 is FALSE for an O_RDWR open of /dev/tty, and !isctty(p,tp) is FALSE for the controlling terminal. The finding's claim, severity (Low), and cited line numbers are all confirmed accurate. FIX VALIDATED: the fix.diff adds a kern.tty_tiocsti sysctl killswitch (default 1=compat); a single-fix #1 kernel built from it cleanly closes the primitive -- with kern.tty_tiocsti=0 the ioctl returns EPERM and no byte reaches l_rint (deterministic across 3 consecutive runs), while =1 preserves historical behavior and the sysctl round-trips cleanly (toggle back to 1 restores the injection).
No comments yet.