etc.git


etc

~ scripts and random things
git clone https://github.com/djnnvx/etc.git
log | files | refs | archive

commit 0cfa7ee9f87781c81b7114b9c4eef532436bf410
parent 8dcb67e98d00e76b79e3319b8a0cc55ef0d3ef59
Author: djnn <[email protected]>
Date: Thu, 28 May 2026 17:51:41 +0900

feat(fuzzers): libnfs harnesses;D

Diffstat:
Afuzzing/libnfs/README.md | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/libnfs/cleanup.sh | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/libnfs/dictionaries/xdr.dict | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/libnfs/fuzz_mount.c | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/libnfs/fuzz_nfs3.c | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/libnfs/fuzz_nfs4.c | 28++++++++++++++++++++++++++++
Afuzzing/libnfs/fuzz_pdu.c | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/libnfs/gen_corpus.sh | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/libnfs/harness.h | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/libnfs/setup.sh | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afuzzing/libnfs/start-fuzzers.sh | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

11 files changed, 860 insertions(+), 0 deletions(-)

diff --git a/fuzzing/libnfs/README.md b/fuzzing/libnfs/README.md
@@ -0,0 +1,81 @@
+# libnfs fuzzing
+
+Fuzzing harnesses for **libnfs** (Ronnie Sahlberg's userspace NFSv3 / NFSv4 /
+MOUNT / NLM / NSM / PORTMAP / RQUOTA client). Same machine setup as the
+libksba/libusb harnesses: AFL++ (CLASSIC + laf-intel), a CmpLog sibling per
+harness, ASan + UBSan at `-O0 -g` with `signed-integer-overflow` and `shift`
+dropped (XDR's `(v << 8) | next` idiom is pure noise under those checks).
+
+Why libnfs:
+- Same profile as libksba. Hand-rolled binary parser, ~87% of commits by one
+  person, ubiquitous (VLC `nfs://`, Kodi, gvfs, QEMU block layer, mpd, fio),
+  **not on OSS-Fuzz**, no fuzz harness in-tree.
+- Attacker model: malicious NFS server. Victim's libnfs client (typically
+  invoked via `vlc nfs://attacker/x.mp4` style URLs) sends a request; the
+  attacker controls every reply byte.
+
+## Harnesses
+
+| harness     | drives                                      | reach                                  |
+|-------------|---------------------------------------------|----------------------------------------|
+| `fuzz_nfs3` | per-procedure `zdr_*_3res` decoders (21 of) | NFSv3 reply parsing (most common use)  |
+| `fuzz_nfs4` | `zdr_COMPOUND4res`                          | NFSv4 COMPOUND reply (biggest parser)  |
+| `fuzz_mount`| `zdr_mountres3`, `zdr_exports`, others      | MOUNT v3 - the auth-less first surface |
+| `fuzz_pdu`  | `libnfs_zdr_callmsg` / `libnfs_zdr_replymsg`| RPC framing layer above the decoders   |
+
+The first byte of each input picks the sub-decoder where applicable, so each
+corpus is shared across many procedures.
+
+## Usage
+
+```
+bash setup.sh            # deps + AFL++ + instrumented libnfs + harnesses + corpus
+bash start-fuzzers.sh    # 8 instances in tmux (master + CmpLog secondary per harness)
+bash start-fuzzers.sh --status
+bash cleanup.sh          # kill + remove artifacts (flags: --keep-corpus --keep-source ...)
+```
+
+`setup.sh` flags: `--skip-deps` (deps already installed), `--skip-afl` (use a
+system afl-clang-fast instead of building AFL++ from source).
+
+Each harness builds three ways: `H` (AFL persistent), `H.cmplog` (CmpLog), and
+`H.repro` (plain clang + ASan, reads files from argv) for crash triage:
+
+```
+./fuzz_nfs3.repro output/master_nfs3/crashes/id:000000*
+```
+
+## Target version
+
+`setup.sh` defaults to libnfs git tip (6.0.x). To pin to a release:
+
+```
+LIBNFS_TAG=libnfs-6.0.1 bash setup.sh
+```
+
+## Soft spots flagged before fuzzing (corpus is biased toward these)
+
+The libnfs parsing stack is XDR-shaped: tagged unions everywhere
+(accepted-vs-rejected reply, NFS3res ok-vs-fail, NFS4 resop4 by opcode).
+Classes of bug worth pre-seeding:
+
+- **Negative/huge length fields** through `zdr_bytes`, `zdr_array`,
+  `zdr_string`. XDR length fields are u32; some callers cast to int and
+  loop. Trigger: `0xffffffff` in length slot.
+- **Tag mismatch** in tagged unions: claim status=NFS3_OK then truncate the
+  expected `fattr3` payload, or claim a v4 opcode that doesn't match the
+  result body that follows.
+- **Linked-list cycles** in MOUNT `exports` / `groups` (each entry has a
+  "next" pointer; nothing in the decoder caps traversal depth).
+
+## Notes
+
+- libnfs's ZDR is a re-implementation of Sun XDR; it predates and replaces
+  glibc's `tirpc`. Bugs found here may or may not exist upstream in tirpc -
+  worth checking once a real crash lands.
+- `--without-libkrb5` is set at configure time to avoid the GSSAPI optional
+  dep. Krb5-protected NFS isn't reachable for the kind of attacker we care
+  about (server-controlled) since the krb5 wrap is verified before the inner
+  payload is fed to ZDR.
+- `--disable-utils --disable-examples`: skip the bundled tools - they bring
+  in unrelated link deps and we only need `libnfs.la`.
diff --git a/fuzzing/libnfs/cleanup.sh b/fuzzing/libnfs/cleanup.sh
@@ -0,0 +1,52 @@
+#!/bin/bash
+# cleanup.sh - kill fuzzers and remove artifacts.
+# Usage: bash cleanup.sh [--keep-corpus] [--keep-builds] [--keep-source] [--crashes-only]
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${0}")" && pwd)"
+cd "${SCRIPT_DIR}"
+
+SESSION="libnfs-fuzz"
+KEEP_CORPUS=0 KEEP_BUILDS=0 KEEP_SOURCE=0 CRASHES_ONLY=0
+
+for arg in "$@"; do
+    case "${arg}" in
+        --keep-corpus)  KEEP_CORPUS=1  ;;
+        --keep-builds)  KEEP_BUILDS=1  ;;
+        --keep-source)  KEEP_SOURCE=1  ;;
+        --crashes-only) CRASHES_ONLY=1 ;;
+        *) echo "[!] Unknown option: ${arg}" >&2 ;;
+    esac
+done
+
+log() { echo "[*] $*"; }
+ok()  { echo "[+] $*"; }
+
+tmux kill-session -t "${SESSION}" 2>/dev/null || true
+
+pkill -u "$(id -un)" -f "afl-fuzz.*fuzz_" 2>/dev/null || true
+for h in fuzz_nfs3 fuzz_nfs4 fuzz_mount fuzz_pdu; do
+    pkill -u "$(id -un)" -f "${h}" 2>/dev/null || true
+done
+
+[[ "${CRASHES_ONLY}" -eq 1 ]] && { ok "Processes killed."; exit 0; }
+
+if [[ "${KEEP_BUILDS}" -eq 0 ]]; then
+    for h in fuzz_nfs3 fuzz_nfs4 fuzz_mount fuzz_pdu; do
+        rm -f "${h}" "${h}.cmplog" "${h}.repro"
+    done
+    rm -rf build-cls build-cmplog build-plain
+    ok "Binaries and build dirs removed."
+fi
+
+if [[ "${KEEP_CORPUS}" -eq 0 ]]; then
+    rm -rf corpus/ output/
+    ok "Corpus and output removed."
+fi
+
+if [[ "${KEEP_SOURCE}" -eq 0 ]]; then
+    rm -rf libnfs-src/ AFLplusplus/ afl-build/
+    ok "Source trees removed."
+fi
+
+ok "Done."
diff --git a/fuzzing/libnfs/dictionaries/xdr.dict b/fuzzing/libnfs/dictionaries/xdr.dict
@@ -0,0 +1,73 @@
+# XDR / RPC / NFS dictionary tokens. AFL feeds these into havoc-mutator
+# inserts/replaces, helping it produce structurally-valid sub-encodings
+# that flip past the early length checks into the deeper parsers.
+
+# --- XDR primitives ---
+"\x00\x00\x00\x00"  # 0
+"\x00\x00\x00\x01"  # 1
+"\x00\x00\x00\x04"  # 4 (min string len for many fields)
+"\x00\x00\x00\x10"  # 16
+"\x00\x00\x00\x40"  # 64
+"\x00\x00\x01\x00"  # 256
+"\xff\xff\xff\xff"  # -1 / huge unsigned
+
+# --- RPC envelope ---
+"\x00\x00\x00\x02"  # RPC version 2
+"\x00\x00\x00\x00"  # CALL direction
+"\x00\x00\x00\x01"  # REPLY direction
+"\x00\x00\x00\x00"  # MSG_ACCEPTED
+"\x00\x00\x00\x01"  # MSG_DENIED
+
+# --- Program numbers ---
+"\x00\x01\x86\xa0"  # PORTMAP 100000
+"\x00\x01\x86\xa3"  # NFS     100003
+"\x00\x01\x86\xa5"  # MOUNT   100005
+"\x00\x01\x86\xa6"  # RQUOTA  100011
+"\x00\x01\x86\xb5"  # NLM     100021
+"\x00\x01\x86\xb7"  # NSM     100024
+
+# --- NFS3 errors (RFC1813) ---
+"\x00\x00\x00\x00"  # NFS3_OK
+"\x00\x00\x00\x01"  # NFS3ERR_PERM
+"\x00\x00\x00\x02"  # NFS3ERR_NOENT
+"\x00\x00\x00\x05"  # NFS3ERR_IO
+"\x00\x00\x00\x0d"  # NFS3ERR_ACCES
+"\x00\x00\x00\x14"  # NFS3ERR_NOTDIR
+"\x00\x00\x00\x1c"  # NFS3ERR_NOSPC
+
+# --- NFS4 ops (the high-value ones for COMPOUND fuzzing) ---
+"\x00\x00\x00\x03"  # OP_ACCESS
+"\x00\x00\x00\x09"  # OP_GETATTR
+"\x00\x00\x00\x0a"  # OP_GETFH
+"\x00\x00\x00\x0f"  # OP_LOOKUP
+"\x00\x00\x00\x11"  # OP_NVERIFY
+"\x00\x00\x00\x12"  # OP_OPEN
+"\x00\x00\x00\x16"  # OP_PUTFH
+"\x00\x00\x00\x17"  # OP_PUTPUBFH
+"\x00\x00\x00\x18"  # OP_PUTROOTFH
+"\x00\x00\x00\x19"  # OP_READ
+"\x00\x00\x00\x1a"  # OP_READDIR
+"\x00\x00\x00\x1b"  # OP_READLINK
+"\x00\x00\x00\x21"  # OP_SECINFO
+"\x00\x00\x00\x25"  # OP_VERIFY
+"\x00\x00\x00\x26"  # OP_WRITE
+
+# --- NFS file types ---
+"\x00\x00\x00\x01"  # NF3REG
+"\x00\x00\x00\x02"  # NF3DIR
+"\x00\x00\x00\x03"  # NF3BLK
+"\x00\x00\x00\x05"  # NF3LNK
+"\x00\x00\x00\x06"  # NF3SOCK
+"\x00\x00\x00\x07"  # NF3FIFO
+
+# --- AUTH flavours ---
+"\x00\x00\x00\x00"  # AUTH_NONE
+"\x00\x00\x00\x01"  # AUTH_SYS
+"\x00\x00\x00\x06"  # RPCSEC_GSS
+
+# --- common path tokens that show up in dirpath/name strings ---
+"/"
+"/tmp"
+"/data"
+".."
+"."
diff --git a/fuzzing/libnfs/fuzz_mount.c b/fuzzing/libnfs/fuzz_mount.c
@@ -0,0 +1,54 @@
+/*
+ * MOUNT protocol replies (NFSv3 mount path). The MOUNT protocol runs
+ * BEFORE any NFS authentication negotiation, so it's the first surface a
+ * malicious server can attack. mountres3 contains a variable-length
+ * list of supported auth flavours and a file handle, exports replies
+ * are linked lists of export entries with embedded group lists.
+ *
+ * The exports decoder in particular walks attacker-supplied linked-list
+ * chains; cycle-checks tend to be lax in legacy XDR codepaths.
+ */
+#include "harness.h"
+#include "libnfs-raw-mount.h"
+
+typedef bool_t (*mount_dec)(ZDR *, void *);
+
+#define DEC(name)  { #name, (mount_dec)zdr_##name, sizeof(name) }
+
+static const struct {
+    const char *name;
+    mount_dec fn;
+    size_t result_size;
+} decoders[] = {
+    DEC(mountres3),
+    DEC(exports),
+    DEC(mountlist),
+    DEC(mountres1),
+    DEC(MOUNT1MNTres),
+    DEC(MOUNT1DUMPres),
+};
+#define N_DEC (sizeof(decoders) / sizeof(decoders[0]))
+
+static int fuzz_one(const uint8_t *data, size_t size)
+{
+    if (size < 1)
+        return 0;
+    unsigned idx = data[0] % N_DEC;
+    const uint8_t *payload = data + 1;
+    size_t payload_size = size - 1;
+
+    ZDR zdr;
+    nfs_zdrmem_decode(&zdr, payload, payload_size);
+
+    void *res = calloc(1, decoders[idx].result_size);
+    if (!res) {
+        zdr_destroy(&zdr);
+        return 0;
+    }
+    if (decoders[idx].fn(&zdr, res)) {
+        zdr_free((zdrproc_t)decoders[idx].fn, (caddr_t)res);
+    }
+    free(res);
+    zdr_destroy(&zdr);
+    return 0;
+}
diff --git a/fuzzing/libnfs/fuzz_nfs3.c b/fuzzing/libnfs/fuzz_nfs3.c
@@ -0,0 +1,74 @@
+/*
+ * NFSv3 reply parsing. Attacker model: malicious NFS server. Victim's libnfs
+ * client (e.g. VLC's nfs:// handler, kodi, gvfs) sends a request; we replay
+ * the response payload directly through the per-procedure ZDR decoder.
+ *
+ * Cycle through the major NFS3 result types so a single corpus exercises
+ * GETATTR/READ/READDIR/LOOKUP/FSINFO/...; the first byte of the fuzz input
+ * selects which decoder runs (mod the number of decoders).
+ */
+#include "harness.h"
+#include "libnfs-raw-nfs.h"
+
+typedef bool_t (*nfs3_dec)(ZDR *, void *);
+
+#define DEC(name)  { #name, (nfs3_dec)zdr_##name, sizeof(name) }
+
+static const struct {
+    const char *name;
+    nfs3_dec fn;
+    size_t result_size;
+} decoders[] = {
+    DEC(GETATTR3res),
+    DEC(SETATTR3res),
+    DEC(LOOKUP3res),
+    DEC(ACCESS3res),
+    DEC(READLINK3res),
+    DEC(READ3res),
+    DEC(WRITE3res),
+    DEC(CREATE3res),
+    DEC(MKDIR3res),
+    DEC(SYMLINK3res),
+    DEC(MKNOD3res),
+    DEC(REMOVE3res),
+    DEC(RMDIR3res),
+    DEC(RENAME3res),
+    DEC(LINK3res),
+    DEC(READDIR3res),
+    DEC(READDIRPLUS3res),
+    DEC(FSSTAT3res),
+    DEC(FSINFO3res),
+    DEC(PATHCONF3res),
+    DEC(COMMIT3res),
+};
+#define N_DEC (sizeof(decoders) / sizeof(decoders[0]))
+
+static int fuzz_one(const uint8_t *data, size_t size)
+{
+    if (size < 1)
+        return 0;
+
+    unsigned idx = data[0] % N_DEC;
+    const uint8_t *payload = data + 1;
+    size_t payload_size = size - 1;
+
+    ZDR zdr;
+    nfs_zdrmem_decode(&zdr, payload, payload_size);
+
+    /* Calloc'd target struct so any uninitialised pointer fields are NULL;
+     * the decoder will overwrite the parts it parses. Leaks ignored. */
+    void *res = calloc(1, decoders[idx].result_size);
+    if (!res) {
+        zdr_destroy(&zdr);
+        return 0;
+    }
+
+    if (decoders[idx].fn(&zdr, res)) {
+        /* Decode succeeded - the post-decode free walks the same struct
+         * shape and exercises any boundary cases in zdr_free. */
+        zdr_free((zdrproc_t)decoders[idx].fn, (caddr_t)res);
+    }
+    free(res);
+    zdr_destroy(&zdr);
+    return 0;
+}
diff --git a/fuzzing/libnfs/fuzz_nfs4.c b/fuzzing/libnfs/fuzz_nfs4.c
@@ -0,0 +1,28 @@
+/*
+ * NFSv4 COMPOUND reply parsing. Largest and most stateful decoder in libnfs:
+ * a single COMPOUND4res contains a variable-length array of operation
+ * results, each tagged with an opcode that selects a different sub-decoder
+ * (nfs_resop4 = tagged union). Prime target for length-confusion and tag
+ * mismatch bugs.
+ *
+ * Reachable client-side via any NFSv4 mount; reachable through VLC/Kodi when
+ * the user is tricked into opening an nfs:// URL pointing at attacker's
+ * server.
+ */
+#include "harness.h"
+#include "libnfs-raw-nfs4.h"
+
+static int fuzz_one(const uint8_t *data, size_t size)
+{
+    ZDR zdr;
+    COMPOUND4res res;
+
+    nfs_zdrmem_decode(&zdr, data, size);
+    memset(&res, 0, sizeof(res));
+
+    if (zdr_COMPOUND4res(&zdr, &res)) {
+        zdr_free((zdrproc_t)zdr_COMPOUND4res, (caddr_t)&res);
+    }
+    zdr_destroy(&zdr);
+    return 0;
+}
diff --git a/fuzzing/libnfs/fuzz_pdu.c b/fuzzing/libnfs/fuzz_pdu.c
@@ -0,0 +1,61 @@
+/*
+ * RPC framing layer. Sits above the per-procedure zdr decoders, below the
+ * raw socket read. libnfs_zdr_replymsg parses the rpc_msg envelope (xid,
+ * direction, accepted-vs-rejected reply, auth verifier flavour, status).
+ *
+ * Reachable on every reply from a malicious server, before any
+ * NFS-procedure-specific code runs. Bugs here break confinement universally.
+ * libnfs_zdr_callmsg sits on the server-side parse path; rarer but exists
+ * (servers/rpcbind, etc.).
+ *
+ * The harness lets the first byte pick the direction (CALL vs REPLY) so a
+ * single corpus hits both framings.
+ */
+#include "harness.h"
+/* libnfs.h has to come first - it defines the EXTERN export macro that
+ * libnfs-raw.h depends on but does not itself provide. */
+#include "nfsc/libnfs.h"
+#include "nfsc/libnfs-raw.h"
+
+static int fuzz_one(const uint8_t *data, size_t size)
+{
+    if (size < 1)
+        return 0;
+    int as_call = (data[0] & 1);
+    const uint8_t *payload = data + 1;
+    size_t payload_size = size - 1;
+
+    ZDR zdr;
+    struct rpc_msg msg;
+    nfs_zdrmem_decode(&zdr, payload, payload_size);
+    memset(&msg, 0, sizeof(msg));
+
+    /* libnfs_accepted_reply (zdr.c:534) dispatches on status=SUCCESS by
+     * calling msg->body.rbody.reply.areply.reply_data.results.proc which is
+     * normally set by rpc_allocate_pdu when sending the request. In a stateless
+     * fuzzer we have no pending PDU; plug in the no-op decoder so the SUCCESS
+     * branch parses without dereferencing a NULL fn ptr. */
+    msg.body.rbody.reply.areply.reply_data.results.proc =
+        (zdrproc_t)libnfs_zdr_void;
+    msg.body.rbody.reply.areply.reply_data.results.where = NULL;
+
+    struct rpc_context *rpc = rpc_init_context();
+    if (!rpc) {
+        zdr_destroy(&zdr);
+        return 0;
+    }
+
+    if (as_call) {
+        if (libnfs_zdr_callmsg(rpc, &zdr, &msg)) {
+            /* nothing to free - the call body's auth fields aren't owned. */
+        }
+    } else {
+        if (libnfs_zdr_replymsg(rpc, &zdr, &msg)) {
+            /* same: reply envelope doesn't own its sub-buffers in this layer. */
+        }
+    }
+
+    zdr_destroy(&zdr);
+    rpc_destroy_context(rpc);
+    return 0;
+}
diff --git a/fuzzing/libnfs/gen_corpus.sh b/fuzzing/libnfs/gen_corpus.sh
@@ -0,0 +1,147 @@
+#!/bin/bash
+# gen_corpus.sh - mint XDR-shaped seeds for each harness.
+#
+# libnfs reply bytes are XDR (Sun RPC variant) encodings of nested structs.
+# Easiest path for real seeds: run rpcinfo/showmount/nfs-ls against an actual
+# NFS server and capture replies with tcpdump. Without that, this script
+# writes a handful of minimal-but-valid synthetic encodings so AFL has
+# something better than /dev/urandom to mutate from.
+#
+# Seeds are intentionally tiny - the goal is to stake out the boundaries of
+# each procedure's accepted-vs-rejected tag and let AFL grow them.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${0}")" && pwd)"
+cd "${SCRIPT_DIR}"
+
+mkdir -p corpus/nfs3 corpus/nfs4 corpus/mount corpus/pdu
+
+w32() { printf '%08x' "$1" | xxd -r -p; }   # 32-bit big-endian write
+
+# --- NFSv3 GETATTR3res (NFS3_OK + a small fattr3) ---
+# First byte of fuzz input picks the decoder; seed 0x0c = GETATTR3res table idx
+# (counts from the fuzz_nfs3.c order; FSINFO is index 18, READ is 5, etc.)
+#
+# fattr3 layout (per RFC1813): ftype(u32) mode(u32) nlink(u32) uid(u32) gid(u32)
+# size(u64) used(u64) rdev(specdata3=u64) fsid(u64) fileid(u64)
+# atime(nfstime3=u64) mtime ctime  = 80 bytes.
+#
+# GETATTR3res = status(u32=0 OK) + fattr3
+{
+    printf '\x0c'                   # idx=12 (GETATTR3res)
+    w32 0                           # NFS3_OK
+    w32 1                           # NF3REG
+    w32 33188                       # mode = 0100644 octal
+    w32 1                           # nlink
+    w32 1000                        # uid
+    w32 1000                        # gid
+    printf '\x00\x00\x00\x00\x00\x00\x00\x10'  # size=16
+    printf '\x00\x00\x00\x00\x00\x00\x00\x20'  # used=32
+    printf '\x00\x00\x00\x00\x00\x00\x00\x00'  # rdev
+    printf '\x00\x00\x00\x00\x00\x00\x00\x01'  # fsid
+    printf '\x00\x00\x00\x00\x00\x00\x00\x42'  # fileid
+    printf '\x00\x00\x00\x00\x00\x00\x00\x00'  # atime
+    printf '\x00\x00\x00\x00\x00\x00\x00\x00'  # mtime
+    printf '\x00\x00\x00\x00\x00\x00\x00\x00'  # ctime
+} > corpus/nfs3/getattr3_ok.bin
+
+# NFSv3 READDIR3res failure with post-op-attrs absent
+{
+    printf '\x0f'                   # idx=15 READDIR3res
+    w32 5                           # NFS3ERR_NOENT
+    w32 0                           # post_op_attr present=false
+} > corpus/nfs3/readdir3_noent.bin
+
+# NFSv3 FSINFO3res success with the standard 1<<20 properties
+{
+    printf '\x12'                   # idx=18 FSINFO3res
+    w32 0                           # NFS3_OK
+    w32 0                           # post_op_attr present=false
+    w32 1048576                     # rtmax
+    w32 1048576                     # rtpref
+    w32 4096                        # rtmult
+    w32 1048576                     # wtmax
+    w32 1048576                     # wtpref
+    w32 4096                        # wtmult
+    w32 4096                        # dtpref
+    printf '\x00\x00\x00\xff\xff\xff\xff\xff'   # maxfilesize
+    printf '\x00\x00\x00\x00\x00\x00\x00\x00'   # time_delta seconds
+    w32 0                           # time_delta nseconds
+    w32 0x1b                        # properties = LINK|SYMLINK|HOMOGENOUS|CANSETTIME
+} > corpus/nfs3/fsinfo3.bin
+
+# --- NFSv4 COMPOUND4res: minimal NFS4_OK status, empty tag, zero ops ---
+{
+    w32 0                           # status NFS4_OK
+    w32 0                           # tag length = 0
+    w32 0                           # array of resop4: count = 0
+} > corpus/nfs4/compound4_empty.bin
+
+# NFSv4 COMPOUND with one PUTROOTFH op
+{
+    w32 0                           # status NFS4_OK
+    w32 0                           # tag length = 0
+    w32 1                           # resop count = 1
+    w32 24                          # OP_PUTROOTFH = 24
+    w32 0                           # NFS4_OK
+} > corpus/nfs4/compound4_putrootfh.bin
+
+# --- MOUNT3 mountres3: ok + small file handle + 1 auth flavour (AUTH_SYS=1) ---
+{
+    printf '\x00'                   # idx=0 mountres3
+    w32 0                           # MNT3_OK
+    w32 8                           # fhandle3.len = 8
+    printf 'ABCDEFGH'               # fhandle bytes
+    w32 1                           # auth_flavors.len = 1
+    w32 1                           # AUTH_SYS
+} > corpus/mount/mountres3_ok.bin
+
+# MOUNT3 exports listing one export with no group restriction
+{
+    printf '\x01'                   # idx=1 exports
+    w32 1                           # 1 entry (XDR optional: present=1)
+    w32 5                           # dirpath length = 5
+    printf '/data'
+    printf '\x00\x00\x00'           # padding to 4-byte boundary
+    w32 0                           # groups: empty (present=0)
+    w32 0                           # next-entry present=0
+} > corpus/mount/exports_one.bin
+
+# --- PDU envelope: an accepted REPLY with SUCCESS body ---
+{
+    printf '\x00'                   # as_reply (bit 0 = 0)
+    w32 0x12345678                  # xid
+    w32 1                           # direction = REPLY
+    w32 0                           # reply_stat = MSG_ACCEPTED
+    w32 0                           # verf flavour = AUTH_NONE
+    w32 0                           # verf len = 0
+    w32 0                           # accept_stat = SUCCESS
+} > corpus/pdu/reply_ok.bin
+
+# A rejected REPLY (RPC_MISMATCH)
+{
+    printf '\x00'
+    w32 0xdeadbeef
+    w32 1                           # REPLY
+    w32 1                           # MSG_DENIED
+    w32 0                           # RPC_MISMATCH
+    w32 2 ; w32 2                   # low=2 high=2 (we speak RPCv2)
+} > corpus/pdu/reply_denied.bin
+
+# A CALL frame (server side) - PORTMAP NULL
+{
+    printf '\x01'                   # as_call (bit 0 = 1)
+    w32 0xcafebabe
+    w32 0                           # direction = CALL
+    w32 2                           # rpcvers
+    w32 100000                      # program = PORTMAP
+    w32 2                           # version
+    w32 0                           # procedure NULL
+    w32 0 ; w32 0                   # AUTH_NONE cred
+    w32 0 ; w32 0                   # AUTH_NONE verf
+} > corpus/pdu/call_pmap_null.bin
+
+ls -la corpus/nfs3/ corpus/nfs4/ corpus/mount/ corpus/pdu/
+echo "[+] Corpus generated. Drop additional real captures via:"
+echo "    sudo tcpdump -i any -w nfs.pcap port 2049"
+echo "    then split TCP payloads out (see README.md)."
diff --git a/fuzzing/libnfs/harness.h b/fuzzing/libnfs/harness.h
@@ -0,0 +1,69 @@
+/*
+ * harness.h - shared AFL++/libFuzzer entry boilerplate for the libnfs harnesses.
+ *
+ * Each fuzz_*.c defines:  static int fuzz_one(const uint8_t *data, size_t size);
+ *
+ * Build modes (set by setup.sh):
+ *   afl-clang-fast            -> persistent loop, reads from AFL shared memory
+ *   clang -DNFS_FUZZ_STANDALONE -> reads files from argv, for crash replay/triage
+ *   <libFuzzer>               -> LLVMFuzzerTestOneInput
+ */
+#pragma once
+
+#include <stddef.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "nfsc/libnfs-zdr.h"
+
+/* Initialise a ZDR memory stream in DECODE mode over the fuzz buffer.
+ * ZDR is libnfs's reimplementation of XDR. zdrmem_create takes a non-const
+ * char *, but in DECODE mode it only reads from the buffer. We cast away
+ * const-ness; the alternative is allocating + copying every iteration which
+ * murders persistent-mode throughput. */
+static inline void nfs_zdrmem_decode(ZDR *zdr, const uint8_t *data, size_t size)
+{
+    memset(zdr, 0, sizeof(*zdr));
+    zdrmem_create(zdr, (char *)(uintptr_t)data,
+                  size > 0xffffffff ? 0xffffffff : (uint32_t)size,
+                  ZDR_DECODE);
+}
+
+static int fuzz_one(const uint8_t *data, size_t size);
+
+#ifdef __AFL_FUZZ_TESTCASE_LEN
+__AFL_FUZZ_INIT();
+int main(void)
+{
+    __AFL_INIT();
+    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
+    while (__AFL_LOOP(10000))
+        fuzz_one(buf, (size_t)__AFL_FUZZ_TESTCASE_LEN);
+    return 0;
+}
+#else
+int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
+{
+    return fuzz_one(data, size);
+}
+#ifdef NFS_FUZZ_STANDALONE
+int main(int argc, char **argv)
+{
+    static unsigned char buf[1 << 20];
+    int i;
+    for (i = 1; i < argc; i++) {
+        FILE *f = fopen(argv[i], "rb");
+        size_t n;
+        if (!f)
+            continue;
+        n = fread(buf, 1, sizeof buf, f);
+        fclose(f);
+        fprintf(stderr, "[*] %s (%zu bytes)\n", argv[i], n);
+        fuzz_one(buf, n);
+    }
+    return 0;
+}
+#endif
+#endif
diff --git a/fuzzing/libnfs/setup.sh b/fuzzing/libnfs/setup.sh
@@ -0,0 +1,137 @@
+#!/bin/bash
+# setup.sh - bootstrap libnfs fuzzing environment.
+# Same machine specs as the libksba/libusb harnesses: AFL++ (from source or
+# system), CLASSIC+LAF instrumentation, a CmpLog sibling per harness, ASan+UBSan
+# at -O0 -g. Signed-int-overflow and shift dropped from UBSan - they're noise
+# in XDR codepaths that do `value = (value << 8) | next_byte`.
+# Usage: bash setup.sh [--skip-deps] [--skip-afl]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${0}")" && pwd)"
+cd "${SCRIPT_DIR}"
+
+AFL_REPO="https://github.com/AFLplusplus/AFLplusplus.git"
+AFL_BIN="${SCRIPT_DIR}/afl-build/usr/local/bin"
+
+LIBNFS_REPO="${LIBNFS_REPO:-https://github.com/sahlberg/libnfs.git}"
+LIBNFS_TAG="${LIBNFS_TAG:-master}"
+
+# UBSan noise filters (lessons from libksba + smoke-testing libnfs):
+#   signed-integer-overflow / shift  - XDR reads bytes into signed int and shifts
+#   function                         - table-dispatch decoders use (fn_t)cast
+#   alignment                        - zdr_u_int reads u32 at unaligned offset
+# All four are benign on x86_64 in practice and would flood AFL with noise.
+CFLAGS_FUZZ="-fsanitize=address,undefined -fno-sanitize=signed-integer-overflow,shift,function,alignment -g -O0 -fno-omit-frame-pointer"
+CFLAGS_FUZZ+=" -Wno-implicit-function-declaration -Wno-unused-function -Wno-unused-variable"
+
+HARNESSES="fuzz_nfs3 fuzz_nfs4 fuzz_mount fuzz_pdu"
+
+SKIP_DEPS=0
+SKIP_AFL=0
+for arg in "$@"; do
+    [[ "$arg" == "--skip-deps" ]] && SKIP_DEPS=1
+    [[ "$arg" == "--skip-afl"  ]] && SKIP_AFL=1
+done
+
+log() { echo "[*] $*"; }
+ok()  { echo "[+] $*"; }
+die() { echo "[!] $*" >&2; exit 1; }
+
+if [[ "${SKIP_DEPS}" -eq 0 ]]; then
+    log "Installing dependencies..."
+    sudo apt-get update -qq
+    sudo apt-get install -y \
+        build-essential git tmux python3 \
+        clang llvm lld pkg-config gdb cmake
+    ok "Dependencies installed."
+fi
+
+if [[ "${SKIP_AFL}" -eq 1 ]]; then
+    command -v afl-clang-fast > /dev/null 2>&1 || die "afl-clang-fast not found."
+elif [[ -x "${AFL_BIN}/afl-clang-fast" ]]; then
+    ok "AFL++ already built."
+    export PATH="${AFL_BIN}:${PATH}"
+else
+    log "Building AFL++..."
+    [[ -d "AFLplusplus" ]] || git clone --depth=1 "${AFL_REPO}" AFLplusplus
+    ( cd AFLplusplus && make source-only -j"$(nproc)" \
+        && make install PREFIX="${SCRIPT_DIR}/afl-build/usr/local" )
+    export PATH="${AFL_BIN}:${PATH}"
+    ok "AFL++ built."
+fi
+export PATH="${AFL_BIN}:${PATH}"
+
+if [[ -d "libnfs-src/.git" ]]; then
+    ok "libnfs-src present ($(cd libnfs-src && git describe --tags --always))."
+else
+    log "Cloning libnfs ${LIBNFS_TAG}..."
+    git clone "${LIBNFS_REPO}" libnfs-src
+    ( cd libnfs-src && git checkout -q "${LIBNFS_TAG}" )
+    ok "libnfs-src cloned."
+fi
+
+# libnfs ships both autotools and CMake build systems. CMake is shorter to
+# drive and produces a single libnfs.a (autotools fragments into one .la per
+# protocol subdir + a libtoolize dance for shared-lib symbol export). We
+# don't need shared libs, so CMake wins.
+build_lib() {
+    local dir="$1" cc="$2"; shift 2
+    log "  building ${dir} (CC=${cc})..."
+    rm -rf "${dir}"
+    mkdir -p "${dir}"
+    ( cd "${dir}" \
+      && env "$@" CFLAGS="${CFLAGS_FUZZ}" \
+            cmake -DCMAKE_C_COMPILER="${cc}" \
+                  -DBUILD_SHARED_LIBS=OFF \
+                  -DENABLE_UTILS=OFF \
+                  -DENABLE_EXAMPLES=OFF \
+                  -DENABLE_TESTS=OFF \
+                  "${SCRIPT_DIR}/libnfs-src" >/dev/null \
+      && env "$@" make -j"$(nproc)" >/dev/null )
+    [[ -f "${dir}/lib/libnfs.a" ]] || die "${dir}: libnfs.a not produced"
+}
+
+log "Building libnfs: instrumented (CLASSIC+LAF), CmpLog, and a plain-ASan copy for repro..."
+build_lib build-cls     afl-clang-fast AFL_LLVM_INSTRUMENT=CLASSIC AFL_LLVM_LAF_ALL=1
+build_lib build-cmplog  afl-clang-fast AFL_LLVM_INSTRUMENT=CLASSIC AFL_LLVM_LAF_ALL=1 AFL_LLVM_CMPLOG=1
+build_lib build-plain   clang
+ok "libnfs built."
+
+# CMake bundles everything into a single libnfs.a (protocol .o files are
+# linked into the top-level static archive). -lpthread is the only runtime
+# dep when krb5/gnutls are disabled.
+COMMON_LDLIBS="-lpthread"
+
+# Per-protocol headers live in their own subdirs (mount/, nfs/, nfs4/, ...).
+# The harness #includes them by basename and we add each as -I.
+HARNESS_INCS=(
+    -I "${SCRIPT_DIR}/libnfs-src/include"
+    -I "${SCRIPT_DIR}/libnfs-src/include/nfsc"
+    -I "${SCRIPT_DIR}/libnfs-src/mount"
+    -I "${SCRIPT_DIR}/libnfs-src/nfs"
+    -I "${SCRIPT_DIR}/libnfs-src/nfs4"
+    -I "${SCRIPT_DIR}/libnfs-src/portmap"
+)
+
+log "Building harnesses..."
+for h in ${HARNESSES}; do
+    log "  ${h}..."
+    AFL_LLVM_INSTRUMENT=CLASSIC AFL_LLVM_LAF_ALL=1 \
+        afl-clang-fast ${CFLAGS_FUZZ} "${HARNESS_INCS[@]}" \
+            "${h}.c" "build-cls/lib/libnfs.a" ${COMMON_LDLIBS} -o "${h}"
+    AFL_LLVM_INSTRUMENT=CLASSIC AFL_LLVM_LAF_ALL=1 AFL_LLVM_CMPLOG=1 \
+        afl-clang-fast ${CFLAGS_FUZZ} "${HARNESS_INCS[@]}" \
+            "${h}.c" "build-cmplog/lib/libnfs.a" ${COMMON_LDLIBS} -o "${h}.cmplog"
+    clang ${CFLAGS_FUZZ} -DNFS_FUZZ_STANDALONE "${HARNESS_INCS[@]}" \
+        "${h}.c" "build-plain/lib/libnfs.a" ${COMMON_LDLIBS} -o "${h}.repro"
+done
+ok "Harnesses built."
+
+if [[ -d corpus/nfs3 && "$(ls corpus/nfs3 2>/dev/null | wc -l)" -gt 0 ]]; then
+    ok "Corpus present."
+else
+    log "Generating corpus..."
+    bash gen_corpus.sh
+fi
+
+ok "Done. Run: bash start-fuzzers.sh"
diff --git a/fuzzing/libnfs/start-fuzzers.sh b/fuzzing/libnfs/start-fuzzers.sh
@@ -0,0 +1,84 @@
+#!/bin/bash
+# start-fuzzers.sh - launch libnfs fuzzing in tmux (8 instances across 4 harnesses).
+# Each harness gets a -M master (explore) and a -S secondary driven by its CmpLog
+# binary. Usage: bash start-fuzzers.sh [--rerun|--status]
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${0}")" && pwd)"
+cd "${SCRIPT_DIR}"
+
+AFL_BIN="${SCRIPT_DIR}/afl-build/usr/local/bin"
+[[ -d "${AFL_BIN}" ]] && export PATH="${AFL_BIN}:${PATH}"
+
+SESSION="libnfs-fuzz"
+OUTPUT="${SCRIPT_DIR}/output"
+CORPUS="${SCRIPT_DIR}/corpus"
+DICT="${SCRIPT_DIR}/dictionaries/xdr.dict"
+
+log() { echo "[*] $*"; }
+ok()  { echo "[+] $*"; }
+die() { echo "[!] $*" >&2; exit 1; }
+
+case "${1:-}" in
+    --status)
+        afl-whatsup "${OUTPUT}" 2>/dev/null || echo "Output: ${OUTPUT}"
+        exit 0 ;;
+    --rerun)
+        tmux kill-session -t "${SESSION}" 2>/dev/null || true ;;
+    "") ;;
+    *) die "Unknown option: ${1}" ;;
+esac
+
+command -v afl-fuzz > /dev/null 2>&1 || die "afl-fuzz not found. Run setup.sh first."
+for h in fuzz_nfs3 fuzz_nfs4 fuzz_mount fuzz_pdu; do
+    [[ -f "${h}" ]]        || die "${h} not found. Run setup.sh first."
+    [[ -f "${h}.cmplog" ]] || die "${h}.cmplog not found. Run setup.sh first."
+done
+tmux list-sessions 2>/dev/null | grep -q "${SESSION}" && \
+    die "Session '${SESSION}' already running. Use --rerun."
+
+sudo sysctl -w kernel.core_pattern=core > /dev/null 2>&1 || true
+echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor > /dev/null 2>&1 || true
+mkdir -p "${OUTPUT}"
+
+export AFL_SKIP_CPUFREQ=1
+export AFL_AUTORESUME=1
+export AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1
+export AFL_DISABLE_TRIM=1
+export AFL_KEEP_TIMEOUTS=1
+export ASAN_OPTIONS="abort_on_error=1:symbolize=0:detect_leaks=0:allocator_may_return_null=1:detect_stack_use_after_scope=1"
+export UBSAN_OPTIONS="print_stacktrace=0:halt_on_error=1"
+
+# role: -M/-S flag; kind: master|cmplog|sec; target+corpus subdir; schedule
+afl_cmd() {
+    local role="$1" kind="$2" target="$3" corpus_sub="$4" sched="${5:-fast}"
+    local cmplog=""
+    [[ "${kind}" == "cmplog" ]] && cmplog="-c ./${target}.cmplog -l 2AT"
+    echo "afl-fuzz ${role} -i '${CORPUS}/${corpus_sub}' -o '${OUTPUT}' -x '${DICT}' -t 1000 -p ${sched} -m none ${cmplog} -- './${target}'"
+}
+
+log "Starting session '${SESSION}'..."
+tmux new-session  -d -s "${SESSION}" -n "nfs3+nfs4" -x 220 -y 50
+tmux split-window -t "${SESSION}:0"   -h
+tmux split-window -t "${SESSION}:0.0" -v
+tmux split-window -t "${SESSION}:0.2" -v
+tmux new-window   -t "${SESSION}" -n "mount+pdu"
+tmux split-window -t "${SESSION}:1"   -h
+tmux split-window -t "${SESSION}:1.0" -v
+tmux split-window -t "${SESSION}:1.2" -v
+tmux new-window   -t "${SESSION}" -n "status"
+sleep 0.5
+
+send() { tmux send-keys -t "${SESSION}:${1}.${2}" "${3}" Enter; }
+
+send 0 0 "$(afl_cmd '-M master_nfs3' master fuzz_nfs3 nfs3 explore)"
+send 0 1 "$(afl_cmd '-S sec_nfs3'    cmplog fuzz_nfs3 nfs3 coe)"
+send 0 2 "$(afl_cmd '-M master_nfs4' master fuzz_nfs4 nfs4 explore)"
+send 0 3 "$(afl_cmd '-S sec_nfs4'    cmplog fuzz_nfs4 nfs4 rare)"
+send 1 0 "$(afl_cmd '-M master_mount' master fuzz_mount mount explore)"
+send 1 1 "$(afl_cmd '-S sec_mount'    cmplog fuzz_mount mount coe)"
+send 1 2 "$(afl_cmd '-M master_pdu'  master fuzz_pdu  pdu  explore)"
+send 1 3 "$(afl_cmd '-S sec_pdu'     cmplog fuzz_pdu  pdu  lin)"
+send 2 0 "watch -n 15 'afl-whatsup ${OUTPUT} 2>/dev/null || echo waiting...'"
+
+ok "Session started. Attach: tmux attach -t ${SESSION}"