DragonFlyBSD Kernel Audit
DF-2921 / livelock-evidence.txt
← back to finding ↓ download raw
DF-2921 — unprivileged permanent kernel livelock (unkillable) via signed
overflow in nvnode_pager_setsize()/nvtruncbuf()

OBSERVED ON GUEST (DragonFly 6.5-DEVELOPMENT #0, stock INVARIANTS kernel,
hammer2 root), triggered as unprivileged user "maxx":

  1. run:   /tmp/op2921b   (source: op2921.c)
     - ftruncate(fd, 0x7ffffffffffff000)   [extend, sparse, 8 EB]
     - pwrite 1 byte @ 0x7fffffffffffefff  [dirty the last 64K block]
     - ftruncate(fd, 0x7fffffffffff8000)   [truncate mid-64K-block]
  2. Process enters state R0 (running on cpu) inside the kernel and never
     returns:
       PID STAT   WCHAN  COMMAND
       1025 R0     -      /tmp/op2921x          <- first run, observed >5 min
  3. kill -9 has NO effect (syscall never returns; lwkt_yield() does not
     check signals).  Verified twice, >5 minutes each time.
  4. A second process touching the same file blocks forever behind the
     spinner's vp->v_token / vm_object lock:
       944 R0     /tmp/op2921b
       953 D5     /tmp/op2921b
  5. Even `rm` of the file hangs (serial console):
       [diagnostic] cache_lock_shared: rm blocked on 0xfffff8008f54dc00 "df2921.bin"
  6. Guest can no longer be shut down: three `shutdown -p now` attempts
     logged and all hung; guest only recoverable by killing QEMU.
  7. A forced crash dump was taken (debug.panic=1) after first wedging.

ROOT CAUSE (sys/kern/vfs_vm.c):
  - nvtruncbuf() line 149:
        info.truncloffset = length + (blksize - boff);
    with length = 0x7fffffffffff8000, H2 nblksize = HAMMER2_PBUFSIZE = 64K
    (hammer2_calc_logical() always returns HAMMER2_PBUFSIZE,
    sys/vfs/hammer2/hammer2_subr.c:276), boff = 0x8000:
        0x7fffffffffff8000 + 0x8000 = 0x8000000000000000  (signed overflow)
    -> truncloffset = INT64_MIN -> nvtruncbuf_bp_trunc_cmp() matches EVERY
    buffer on the vnode (including negative-loffset metadata buffers on
    UFS) -> all destroyed with B_INVAL (silently discards dirty buffers).
  - nvnode_pager_setsize() line 464:
        nobjsize = OFF_TO_IDX(truncboffset + blksize + PAGE_MASK);
    truncboffset = 0x7fffffffffff0000; + 0x10000 + 0xfff =
    0x8000000000000fff -> negative -> arithmetic >> 12 -> vm_pindex_t
    0xFFF8000000000000.
  - unmap loop line 486-495:
        pi = OFF_TO_IDX(length + PAGE_MASK);      /* ~ 2^51 */
        while (pi < nobjsize) {                   /* nobjsize ~ 2^63.9 */
            vm_page_lookup_busy_wait(...); ++pi; lwkt_yield();
        }
    ~1.8e19 iterations, each an RB-tree miss + yield -> years, while
    holding vp->v_token (acquired at nvtruncbuf:153) and the vm_object
    hold -> everything touching the vnode blocks forever.

REACHABILITY: unprivileged local user on any filesystem whose nvtruncbuf
block size can straddle a length within blksize of 2^63 (hammer2 always
passes 64K; tmpfs/ufs/nfs unreachable because their max file sizes are far
below; ext2fs/hpfs use the old vnode_pager_setsize path).  HAMMER2 imposes
no va_size clamp in setattr (hammer2_vnops.c:512-525) and ftruncate()
accepts any non-negative off_t.