β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0931

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 (or setgid) binary on an FFS filesystem.
  • Privileges gained or impact: Local privilege escalation to the credentials of the setuid binary's owner (root for setuid-root targets). The kernel's setuid-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 setuid binaries are root:wheel 4755). The precondition arises on systems 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.
  • Reachability: write(fd, controlled_buf_of_N_bytes, 0x100000000 + N) against the target. ssize_t nbyte is positive (bit 63 clear), so sys_write's (ssize_t)nbyte < 0 check (sys_generic.c:336) does not reject it. (Side note: that check is also missing its return 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.

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

Timeline

  • 2026-07-05 Discovered during automated audit.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0931 Β· 15 files
FileTypeDescriptionSize
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
README.md readme original PoC README (build/run instructions)
↓ download 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 /tmp is tmpfs β€” neither uses ffs_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; system setuid binaries are root:wheel 4755. The scenario arises with custom/admin setuid tools, group-writable setuid binaries, or permissive container/jail images.
  • small_eFault.c demonstrates the same bypass with only a 100-byte write (no 4 GiB needed), proving the int truncation is not required.
VERDICT.md verdict full narrative analysis
↓ download raw

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

  1. sys_write() (sys/kern/sys_generic.c:336) checks (ssize_t)nbyte < 0 but omits the return error; β€” a separate bug. For nbyte ~ 4 GiB (positive as ssize_t, bit 63 clear) the write proceeds regardless.

  2. ffs_write() (sys/vfs/ufs/ufs_readwrite.c) receives uio_resid as a size_t (64-bit unsigned, per sys/sys/_uio.h:69).

  3. Line 220: int resid is declared as 32-bit signed int. Line 271: resid = uio->uio_resid; silently truncates the 64-bit value.

  4. 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) uses rep movsb which copies the valid bytes before faulting. But uiomove() (sys/kern/kern_subr.c:148-149) breaks on the EFAULT before decrementing uio->uio_resid. So uio_resid stays at its original value even though attacker bytes reached the buffer cache.

  5. The dirty buffer (with attacker content) is bdwrite()'d to disk at ufs_readwrite.c:389 β€” this happens before the error check at line 391, so it is unconditional.

  6. Line 400: if (resid > uio->uio_resid && ...) β€” the ISUID-clearing check. Because uio_resid was 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)

  1. int resid β†’ size_t resid (type safety, as proposed).
  2. int orig_resid β†’ size_t orig_resid in ffs_read (defensive, same pattern).
  3. Add int xferred = 0; β€” set to 1 after VOP_BALLOC() succeeds (line 331), guaranteeing the buffer will be queued to disk.
  4. Change the ISUID/NOTE_WRITE/IO_SYNC checks from resid > uio->uio_resid to (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)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 12 11:10:52 UTC 2026 (sha256 kernel=336768d6d4fda955ff1d83a0f5510bfcec6408ba53931461170511e87214e79e)

Confirmed kernel references

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.