setuid/setgid bit bypass in ffs_write via int truncation of size_t uio_resid
| Field | Value |
|---|---|
| ID | DF-0931 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N |
| CWE | CWE-681 Incorrect Conversion between Numeric Types |
| File | sys/vfs/ufs/ufs_readwrite.c (compiled into ffs_vnops.c) |
| Lines | 220, 271, 400-401 |
| Area | vfs |
| Confidence | likely |
| Discovered | 2026-07-05 |
| Reported | pending |
| Known CVE | none |
| CVE match | dfly_specific |
Summary
ffs_write() declares int resid and assigns it from size_t uio->uio_resid
(DragonFlyBSD made uio_resid an unsigned size_t, see sys/_uio.h:69).
For any write(2)/writev(2) where the total transfer length exceeds
INT_MAX, resid is silently truncated to its low 32 bits. The
post-loop security check
if (resid > uio->uio_resid && cr_uid != 0) ip->i_mode &= ~(ISUID|ISGID)
then compares a small positive int (promoted to a small size_t)
against the still-huge uio_resid, evaluating to false even though
disk blocks were genuinely modified. The kernel therefore fails to
clear the setuid/setgid bits on a write by a non-root user,
defeating the documented defense-in-depth control (comment block at
ufs_readwrite.c:395-399).
Root cause
sys/vfs/ufs/ffs_vnops.c:89 does #include "ufs_readwrite.c",
pulling ffs_read/ffs_write into ffs_vnops.c's translation unit;
they are registered as .vop_read/.vop_write in ffs_vnode_vops at
ffs_vnops.c:73,76.
In ffs_write(), sys/vfs/ufs/ufs_readwrite.c:220 declares:
int blkoffset, error, extended, flags, ioflag, resid, size, xfersize;
β resid is int. Line 271: resid = uio->uio_resid; truncates
size_t to int. Line 400:
if (resid > uio->uio_resid && ap->a_cred && ap->a_cred->cr_uid != 0)
ip->i_mode &= ~(ISUID | ISGID);
C usual arithmetic conversions promote the int left operand to
size_t for the comparison. For nbyte = 0x100002000 (4 GiB + 8192),
resid truncates to 8192, and after one block is written
uio_resid becomes 0x100000000, so 8192 > 0x100000000 is false
and the bits are not cleared β even though 8192 attacker-controlled
bytes were queued to disk via cluster_write/bdwrite at
ufs_readwrite.c:380,389.
The same broken comparison at line 402 also suppresses the
NOTE_WRITE kqueue event, and at lines 408-409 corrupts the
IO_UNIT rollback arithmetic.
Threat model & preconditions
- Attacker position: Local, non-root, with write permission to a
setuid(orsetgid) binary on an FFS filesystem. - Privileges gained or impact: Local privilege escalation to the
credentials of the
setuidbinary's owner (root forsetuid-root targets). The kernel'ssetuid-clearing-on-write control exists precisely to prevent this primitive; the truncation bypasses it. - Required config or capabilities: This is not the default
DragonFlyBSD configuration (system
setuidbinaries areroot:wheel 4755). The precondition arises on systems with custom / adminsetuidtools, group-writablesetuidbinaries, or permissive container/jail images. Once the precondition is met, the attack is deterministic and trivial. - Reachability:
write(fd, controlled_buf_of_N_bytes, 0x100000000 + N)against the target.ssize_t nbyteis positive (bit 63 clear), sosys_write's(ssize_t)nbyte < 0check (sys_generic.c:336) does not reject it. (Side note: that check is also missing itsreturn error;, but that is a separate bug β it would not block this attack even if fixed.)
Proof of concept
PoC source: findings/poc/DF-0931/suid_bypass.c
Build & run
cc -O2 -o suid_bypass suid_bypass.c ./suid_bypass /path/to/group-writable-setuid-binary
Expected output
write returned -1 errno=14 (Bad address) target mode=4555 ISUID=PRESERVED (BUG)
hexdump -C /path/to/binary | head shows the 'A' pattern at offset 0
(attacker-controlled bytes were written to disk and the ISUID bit was
preserved). In a full exploit, replace the placeholder payload with a
minimal self-contained ELF that re-exec's a root shell, then
execv() the modified binary to obtain uid=0.
Impact
Local privilege escalation from a non-root user (with write access to a
setuid-root binary) to root. The defense-in-depth control that exists
specifically to prevent this primitive is bypassed.
Recommended fix
Make resid wide enough to hold any uio_resid. Minimal one-line type
change:
--- a/sys/vfs/ufs/ufs_readwrite.c
+++ b/sys/vfs/ufs/ufs_readwrite.c
@@ -217,7 +217,8 @@ int
off_t osize;
off_t nsize;
int seqcount;
- int blkoffset, error, extended, flags, ioflag, resid, size, xfersize;
+ int blkoffset, error, extended, flags, ioflag, size, xfersize;
+ size_t resid;
struct thread *td;
With size_t resid, the assignment resid = uio->uio_resid; (line 271)
is lossless, and the comparisons on lines 400/402/411 become correct
size_t-vs-size_t comparisons; the IO_UNIT rollback arithmetic on
lines 408-409 also becomes type-correct. No other call sites need
changes because resid is local to ffs_write.
The same one-line change should also be applied defensively to
int orig_resid in ffs_read (ufs_readwrite.c:68) β
size_t orig_resid β to fix the atime-accounting logic for
nbyte>INT_MAX, though that path has no security impact.
Defense-in-depth: the syscall layer in sys/kern/sys_generic.c
(sys_read at line 130 and sys_write at line 336) contains a
separate, unrelated bug where if ((ssize_t)uap->nbyte < 0) error = EINVAL;
is missing a return error; β that should be fixed too, but it is not
what enables this finding (nbyte ~ 4 GiB is positive as ssize_t and
would pass even a correct check).
References
sys/sys/_uio.h:69βsize_t uio_resid(made unsigned).sys/vfs/ufs/ufs_readwrite.c:395-399β the comment documenting the defense-in-depth control this bug defeats.sys/kern/sys_generic.c:336-337β the unrelated missingreturn(not the cause of this finding).
Timeline
- 2026-07-05 Discovered during automated audit.
- pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0931 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| suid_bypass.c | trigger-source | minimal 4GiB+pagesz EFAULT trigger β confirms ISUID preserved | 2.9 KB | view raw |
| small_eFault.c | trigger-source | 100-byte EFAULT variant β proves int truncation is not required for the bypass | 1.1 KB | view raw |
| exploit.c | exploit-chain | full chain: hand-built ELF64 (setuid(0)+setgid(0)+execve) payload + truncation write -> uid0 | 9.3 KB | view raw |
| build.sh | build-script | compiles all three PoC binaries | 221 B | view raw |
| run.sh | run-script | runs the full exploit chain (requires FFS setup) | 1005 B | view raw |
| build.log | build-log | final successful build output | 13 B | view raw |
| run.log | run-log | decisive unpatched-kernel run: ISUID preserved + uid=0(root) | 1.2 KB | view raw |
| fix_build.log | build-log | full single-fix kernel build output (35K lines) | 5.6 MB | β download |
| fix_run.log | run-log | fixed-kernel validation: ISUID cleared on all test cases | 934 B | view raw |
| fix.diff | suggested-fix | corrected fix: size_t resid/orig_resid + xferred tracking for partial-write detection (supersedes finding proposal) | 1.7 KB | view raw |
| env.txt | environment | guest uname, cc version, FFS setup | 321 B | view raw |
| VERDICT.md | verdict | full narrative analysis | 7.5 KB | β raw |
| README.md | readme | original PoC README (build/run instructions) | 3.0 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-0931 β PoC: setuid-bit bypass in ffs_write
Summary
ffs_write() (sys/vfs/ufs/ufs_readwrite.c) fails to clear the ISUID/
ISGID bits when a non-root user writes to a setuid-root binary on an FFS
filesystem and the write faults partway through (EFAULT after a partial
copyin). This allows a non-root attacker to replace the content of a
setuid-root binary while keeping the setuid bit, then execv() the
modified binary to obtain uid=0.
Full unprivilegedβroot escalation demonstrated on the default
X86_64_GENERIC (#0) kernel. See VERDICT.md for the detailed analysis.
Root cause
The post-write ISUID-clearing check at ufs_readwrite.c:400 uses
resid > uio->uio_resid to detect "were any bytes written to disk?"
When uiomove() returns EFAULT after a partial copyin (some bytes
copied, then unmapped page hit), it breaks without decrementing
uio->uio_resid (kern_subr.c:148-149). The dirty buffer (with attacker
bytes) is still bdwrite()'d to disk (ufs_readwrite.c:389), but the
uio_resid accounting doesn't reflect it, so the check evaluates to false
and ISUID is preserved.
The finding's proposed root cause (int resid truncation) is a real
type-safety defect but is not sufficient to explain the bypass β the
same ISUID preservation occurs with size_t resid and even with a 100-byte
write. The truncation is a contributing factor; the fundamental issue is
the uiomove-on-EFAULT accounting gap.
Build & run
Lab setup (simulates the realistic precondition)
# As root on the guest β create an FFS filesystem with a writable # setuid-root binary (the realistic admin-misconfiguration scenario): dd if=/dev/zero of=/var/tmp/ffs.img bs=1m count=64 vnconfig -c vn0 /var/tmp/ffs.img newfs /dev/vn0 mkdir -p /mnt/ffs mount -t ufs /dev/vn0 /mnt/ffs cp /bin/sh /mnt/ffs/target chown root:<attacker-gid> /mnt/ffs/target chmod 04775 /mnt/ffs/target
Build
./build.sh # or: cc -O2 -o suid_bypass suid_bypass.c && cc -O2 -o exploit exploit.c
Run (full escalation chain)
# As the unprivileged attacker: ./exploit /mnt/ffs/target # writes ELF payload, ISUID preserved echo id | /mnt/ffs/target # exec: setuid(0) -> /bin/sh -> runs id # Expected on vulnerable kernel: uid=0(root) gid=0(wheel)
Minimal trigger (no escalation, just demonstrates ISUID preservation)
./suid_bypass /mnt/ffs/target # Expected: "target mode=4775 ISUID=PRESERVED (BUG)"
Notes
- The guest's root filesystem is HAMMER2 and
/tmpis tmpfs β neither usesffs_write. An FFS mount must be created explicitly. - The precondition (non-root user with write access to a
setuid-root binary on FFS) is not the default DragonFlyBSD configuration; systemsetuidbinaries areroot:wheel 4755. The scenario arises with custom/adminsetuidtools, group-writablesetuidbinaries, or permissive container/jail images. small_eFault.cdemonstrates the same bypass with only a 100-byte write (no 4 GiB needed), proving theinttruncation is not required.
DF-0931 β VERDICT
Verdict: REPRODUCED (uid0 escalation achieved on unpatched; fix validated)
The finding is real and exploitable. A non-root user with write access to a
setuid-root binary on an FFS filesystem can modify the binary's content on
disk without the kernel clearing the ISUID bit, then execv() the
modified binary to obtain uid=0. The full unprivileged β root chain was
demonstrated end-to-end on the default X86_64_GENERIC (#0) kernel.
However, the finding's root-cause analysis is incomplete: the int
resid truncation alone does not cause the ISUID bypass β the real root cause
is that uiomove() returns EFAULT without decrementing uio->uio_resid
after a partial copyin, so the post-write check resid > uio->uio_resid
evaluates to false for any write that faults partway through (even a
100-byte write), not just 4 GiB+ writes. The proposed one-line type fix (int
resid β size_t resid) was verified to be insufficient β ISUID is still
preserved after applying it. A corrected fix that also tracks whether any
buffer was queued for write was authored and validated.
Mechanism
The vulnerability path
-
sys_write()(sys/kern/sys_generic.c:336) checks(ssize_t)nbyte < 0but omits thereturn error;β a separate bug. Fornbyte ~ 4 GiB(positive asssize_t, bit 63 clear) the write proceeds regardless. -
ffs_write()(sys/vfs/ufs/ufs_readwrite.c) receivesuio_residas asize_t(64-bit unsigned, persys/sys/_uio.h:69). -
Line 220:
int residis declared as 32-bit signedint. Line 271:resid = uio->uio_resid;silently truncates the 64-bit value. -
The write loop (line 290) calls
uiomovebp()βuiomove(). When the user buffer spans an unmapped page boundary,std_copyin(sys/platform/pc64/x86_64/support.s:290) usesrep movsbwhich copies the valid bytes before faulting. Butuiomove()(sys/kern/kern_subr.c:148-149) breaks on theEFAULTbefore decrementinguio->uio_resid. Souio_residstays at its original value even though attacker bytes reached the buffer cache. -
The dirty buffer (with attacker content) is
bdwrite()'d to disk atufs_readwrite.c:389β this happens before the error check at line 391, so it is unconditional. -
Line 400:
if (resid > uio->uio_resid && ...)β the ISUID-clearing check. Becauseuio_residwas never decremented: - Unpatched (int resid):resid= low 32 bits (e.g.pagesz).pagesz > (4 GiB + pagesz)β false β ISUID preserved. - Finding's proposed fix (size_t resid):resid= full value.(4 GiB + pagesz) > (4 GiB + pagesz)β false (equal) β ISUID still preserved.
Why the type change alone is insufficient
The comparison resid > uio_resid detects "did uio_resid decrease?" β which
is the kernel's proxy for "were any bytes written to disk?" This proxy fails
whenever uiomove returns an error without updating uio_resid, because the
partially-copied bytes are invisible to the accounting. This was confirmed
empirically: on a kernel with size_t resid only, both a 4 GiB+ EFAULT write
and a 100-byte EFAULT write preserve ISUID.
Confirmed broader impact
The ISUID bypass works with any write size that EFAULTs partway β not
just > INT_MAX. The int truncation is a type-safety defect worth fixing,
but the security bypass is caused by the uiomove-on-EFAULT accounting gap.
This makes the vulnerability easier to trigger than the finding suggests (no
4 GiB write needed).
Exploit chain (demonstrated uid=0)
| Step | Action | Result |
|---|---|---|
| Setup (root) | FFS mount + cp /bin/sh /mnt/ffs/target; chmod 04775; chgrp <attacker> |
setuid-root, group-writable target on FFS |
| Stage 1 (maxx) | ./exploit /mnt/ffs/target β writes a hand-built minimal ELF64 (setuid(0)+setgid(0)+execve("/bin/sh") shellcode, 179 bytes) via write(fd, buf, 4GiB+4096) |
ELF payload on disk, ISUID preserved |
| Stage 2 (maxx) | echo id \| /mnt/ffs/target β exec the modified binary |
uid=0(root) gid=0(wheel) |
Bucket / primitive class: Not a heap/slab corruption β this is a logic bypass of a defense-in-depth file-permission control. The "primitive" is the ISUID bit surviving a content modification by a non-root writer. The escalation is direct: write attacker ELF β exec β root. No slab grooming, no ROP, no info leak needed.
Precondition (realistic): A non-root user with write permission to a
setuid-root binary on an FFS filesystem. Not the default DragonFlyBSD
configuration (system setuid binaries are root:wheel 4755), but arises with
custom/admin setuid tools, group-writable setuid binaries, or permissive
container/jail images. Once the precondition is met, the attack is
deterministic and trivial.
PoC files
| File | Purpose |
|---|---|
suid_bypass.c |
Minimal trigger: 4 GiB+pagesz write, confirms ISUID preserved |
small_eFault.c |
Variant trigger: 100-byte write with EFAULT, proves truncation isn't required |
exploit.c |
Full chain: hand-built ELF64 payload + truncation write β uid0 |
Fix
Finding's proposed fix (INSUFFICIENT)
Change int resid β size_t resid at ufs_readwrite.c:220. This fixes the
type-safety issue but does not close the ISUID bypass because
uiomove still doesn't decrement uio_resid on partial copyin error.
Corrected fix (in fix.diff, VALIDATED)
int residβsize_t resid(type safety, as proposed).int orig_residβsize_t orig_residinffs_read(defensive, same pattern).- Add
int xferred = 0;β set to 1 afterVOP_BALLOC()succeeds (line 331), guaranteeing the buffer will be queued to disk. - Change the ISUID/NOTE_WRITE/IO_SYNC checks from
resid > uio->uio_residto(xferred || resid > uio->uio_resid).
This catches partial writes where buffers reached disk but uio_resid wasn't
decremented. The fix is minimal, targeted, and adds no new locking or
complexity.
Fix validation (Phase 8)
| Test | Unpatched #0 |
Fixed #1 |
|---|---|---|
| Huge write EFAULT (4 GiB+pagesz) | ISUID preserved (BUG) | ISUID cleared β |
| Small write EFAULT (100 bytes) | ISUID preserved (BUG) | ISUID cleared β |
| Normal write (8 KiB) | ISUID cleared | ISUID cleared β (no regression) |
| Full exploit chain | uid=0(root) |
uid=1001(maxx) β defeated β |
Fixed kernel: 6.5-DEVELOPMENT #1: Sun Jul 12 11:10:52 UTC 2026
SHA256: 336768d6d4fda955ff1d83a0f5510bfcec6408ba53931461170511e87214e79e
Kernel references (confirmed)
sys/vfs/ufs/ufs_readwrite.c:220βint residdeclaration (type bug)sys/vfs/ufs/ufs_readwrite.c:271βresid = uio->uio_resid;(truncating assignment)sys/vfs/ufs/ufs_readwrite.c:290β write loop entrysys/vfs/ufs/ufs_readwrite.c:329-332βVOP_BALLOC+ error check (wherexferredis set)sys/vfs/ufs/ufs_readwrite.c:356βuiomovebpcall (where EFAULT originates)sys/vfs/ufs/ufs_readwrite.c:389βbdwrite(bp)unconditional buffer writesys/vfs/ufs/ufs_readwrite.c:400-401β ISUID/ISGID clearing check (the bypass)sys/kern/kern_subr.c:148-149βuiomovebreaks on error before decrementinguio_residsys/platform/pc64/x86_64/support.s:290-328βstd_copyin(partial copy viarep movsbthen fault)sys/sys/_uio.h:69βsize_t uio_resid(the source type)sys/kern/sys_generic.c:336-337β missingreturn error;(separate bug, not the cause)
Fix verification
fixedVALIDATED the corrected fix. The PoC was reproduced on the unpatched #0 baseline (ISUID preserved after write, uid=0 achieved via exec) and does NOT reproduce on the single-fix #1 kernel (ISUID is correctly cleared on all test vectors: huge 4GiB+ EFAULT write, small 100-byte EFAULT write, and normal write; the full exploit chain exits with uid=1001 not uid=0). The finding's original proposed fix (int resid -> size_t resid only) was separately built and verified INSUFFICIENT -- ISUID remained preserved -- confirming the corrected fix's xferred tracking is the essential component. No regressions: normal writes still clear ISUID correctly.
baseline #0: target mode=4775 ISUID=PRESERVED (BUG) -- write delivered ELF payload to disk, exec yielded uid=0(root) / patched #1: target mode=775 ISUID=cleared (safe) -- huge EFAULT write, small 100-byte EFAULT write, and normal write ALL clear ISUID; exec stays uid=1001(maxx) -- exploit defeated
Confirmed kernel references
- sys/vfs/ufs/ufs_readwrite.c:220
- sys/vfs/ufs/ufs_readwrite.c:271
- sys/vfs/ufs/ufs_readwrite.c:290
- sys/vfs/ufs/ufs_readwrite.c:329
- sys/vfs/ufs/ufs_readwrite.c:356
- sys/vfs/ufs/ufs_readwrite.c:389
- sys/vfs/ufs/ufs_readwrite.c:400
- sys/kern/kern_subr.c:148
- sys/platform/pc64/x86_64/support.s:290
- sys/sys/_uio.h:69
- sys/kern/sys_generic.c:336
Detail
Exploit chain
This is a logic-bypass privilege escalation, not heap corruption -- the 'primitive' is the ISUID bit surviving a content modification by a non-root writer. Chain: (1) Setup: admin places a group-writable setuid-root binary on FFS (realistic precondition: custom/admin setuid tools, group-writable binaries, permissive containers). (2) Stage 1: attacker opens target O_WRONLY, calls write(fd, elf_payload_page, 4GiB+4096). copyin copies 4096 attacker bytes into the buffer cache, then EFAULTs on the guard page. uiomove returns EFAULT WITHOUT decrementing uio_resid. The dirty buffer is bdwrite'd to disk unconditionally. Post-write check 'resid > uio_resid' is false -> ISUID NOT cleared. (3) Stage 2: attacker execv's the target -- kernel honors ISUID, runs the 179-byte ELF payload with root creds -> uid=0(root) shell. No slab grooming, ROP, or info leak needed.
Evidence (decisive lines)
UNPATCHED #0: target mode=4775 ISUID=SET before write / write returned -1 errno=14 (Bad address) / target mode=4775 ISUID=PRESERVED after write / Payload on disk: 7f 45 4c 46 (valid ELF) / Stage 2: maxx BEFORE: uid=1001(maxx) / maxx AFTER exec /mnt/ffs/target: uid=0(root) gid=0(wheel) groups=0(wheel) / Chain result: uid=0(root) = UNPRIV -> ROOT ESCALATION ACHIEVED
PoC changes
Added exploit.c (full uid0 chain: hand-built ELF64 payload with setuid(0)+setgid(0)+execve('/bin/sh') shellcode, written via the 4GiB+pagesz truncation write). Added small_eFault.c (100-byte EFAULT variant proving int truncation is not required for the bypass). Added build.sh and run.sh. Wrote VERDICT.md with full root-cause analysis. Authored fix.diff (corrected, supersedes finding proposal). The original suid_bypass.c trigger was unchanged.
Verified recommended fix
The finding's proposed fix (int resid -> size_t resid) is NECESSARY but INSUFFICIENT -- it fixes the type-safety issue but does NOT close the ISUID bypass because uiomove doesn't decrement uio_resid on partial copyin EFAULT. The corrected fix in fix.diff adds: (1) size_t resid and size_t orig_resid (type safety), (2) an 'int xferred' flag set to 1 after VOP_BALLOC succeeds (guaranteeing the buffer will reach disk), and (3) changes the ISUID/NOTE_WRITE/IO_SYNC checks from 'resid > uio->uio_resid' to '(xferred || resid > uio->uio_resid)'. SUPERSIDES finding proposal: the finding's one-line type change alone was empirically verified to leave ISUID preserved.
Verdict
REPRODUCED with full uid0 escalation. The setuid-bit bypass in ffs_write is real and exploitable: a non-root user with write access to a setuid-root binary on FFS can write attacker-controlled bytes to the binary while the ISUID bit is preserved, then execv() the modified binary to obtain uid=0. Demonstrated end-to-end on the default X86_64_GENERIC (#0) kernel: maxx (uid 1001) wrote a 179-byte hand-built ELF64 payload (setuid(0)+setgid(0)+execve('/bin/sh') shellcode) via write(fd, buf, 4GiB+4096) -- the kernel preserved ISUID (mode stayed 4775) and delivered the payload to disk -- then exec'd the target, yielding uid=0(root) gid=0(wheel). CRITICAL CORRECTION to the finding's root cause: the int resid truncation at ufs_readwrite.c:220 is a real type-safety defect but is NOT the sole cause of the bypass. The actual root cause is that uiomove (kern_subr.c:148-149) breaks on copyin EFAULT BEFORE decrementing uio->uio_resid, so the post-write check 'resid > uio_resid' evaluates false for ANY partial write that faults -- even a 100-byte write (confirmed with small_eFault.c). The finding's proposed one-line type fix (int resid -> size_t resid) was built, booted, and verified INSUFFICIENT: ISUID is still preserved after applying it. An improved fix adding xferred tracking was authored and validated.
No comments yet.