Meet Constantine – Find Mythos-level vulnerabilities in your code. It proves them, patches them, PRs them back. Autonomously.

FreeBSoD: Leveraging Language Models to Find and Exploit Kernel Bugs (Part 2 of 2)

Isometric nested fortress walls with glowing red breach points, illustrating a FreeBSD jail escape and kernel exploit chain

Overview

In the first installment of this series, I walked through how I leveraged large language models to assist in identifying several vulnerabilities in the FreeBSD kernel, including a stack-based buffer overflow assigned CVE-2026-3038. This raised a natural follow-up question. Can language models effectively write exploits for memory corruption vulnerabilities?

This article explores that question. I’ll detail two exploit chains I developed that achieve a full escape from a FreeBSD jail environment. The first chain pairs a stack-based buffer overflow with a stack-based information leak to defeat both stack canaries and KASLR. The second takes a different path, combining a heap-based buffer overflow with an information disclosure primitive to bypass KASLR.

Along the way, I’ll discuss where LLM assistance proved genuinely useful, where it fell short, and what human expertise was still required to bridge the gap, with the broader goal of understanding how these tools are reshaping offensive security research.

Background

In part one of this series, I covered how I used Claude Code running on Opus 4.6 to identify vulnerabilities in the FreeBSD kernel. The methodology involved tasking the agent with deep research to build a database of known vulnerability patterns, then generating CodeQL and semgrep rules for variant hunting across the codebase. Candidates were triaged against a KASAN-instrumented FreeBSD VM that served as a feedback loop, letting the agent iterate on proof-of-concept reproducers until it confirmed or ruled out each bug. This process surfaced roughly eight vulnerabilities, most of which are still awaiting patches. The exception is CVE-2026-3038, a stack-based buffer overflow in the routing socket subsystem where a user-controlled sa_len field allows up to 127 bytes of attacker-controlled data to overflow a sockaddr_storage buffer on the kernel stack, reachable without privileges via RTM_GET.

Part one also documented several failure modes, sycophancy inflating findings, the agent loading custom kernel modules to complete a ROP chain it couldn’t build legitimately, and hallucinated exploitation paths that ignored preconditions. These challenges directly shaped how I approached the exploit development work covered in this article.

With the bugs in hand, the next question was whether LLM-assisted research could go beyond discovery and contribute meaningfully to turning these vulnerabilities into working exploits. That’s where we pick up.

What is a FreeBSD Jail?

FreeBSD jails are an OS-level isolation mechanism that partitions a running system into separate environments, each with its own filesystem root, process space, network configuration, and user accounts. A process running as root inside a jail is restricted from interacting with the host or other jails — in theory. VNET jails extend this by giving each jail its own virtualized network stack, which significantly expands the kernel attack surface available from within the jail.

That expanded attack surface is what makes VNET jails particularly interesting from an exploitation perspective. As discussed in part one, much of the networking code exposed to VNET jails predates the jail subsystem entirely and was written under the assumption that only a trusted root user on the host would invoke it. Both of the exploit chains I’ll walk through in this article target kernel functionality reachable from within a jail, with the end goal of breaking out of the jail’s isolation boundary to achieve full kernel code execution on the host.

A Note on Disclosure

Of the vulnerabilities leveraged in these chains, only the RTSock stack overflow (CVE-2026-3038) has been patched, fixed within a day of our report as FreeBSD-SA-26:05.route. The remaining issues were disclosed to the FreeBSD security team several months ago but are still awaiting remediation.

As I noted in part one, the FreeBSD security team is largely volunteer-driven and likely dealing with a significant volume of reports, given the pace of LLM-assisted vulnerability research, I suspect they’re receiving submissions from multiple security teams and model providers. Because of this, I’ve withheld specific vulnerability details for the unpatched issues and have described the techniques at a level that conveys the research without serving as a step-by-step reproduction guide.

Mitigations

Before walking through the exploit chains, it’s worth briefly covering the mitigations I need to defeat. FreeBSD on amd64 ships with the same baseline protections you’ll find across most modern operating systems.

KASLR randomizes the kernel’s base address at boot, so hardcoded offsets to kernel symbols like prison0, rootvnode, or ROP gadgets won’t work without first leaking a kernel text pointer. Both chains need to defeat KASLR before they can do anything useful.

Stack canaries place a random value between a function’s local variables and its saved return address. If a stack overflow corrupts the return address, it also corrupts the canary, and __stack_chk_fail fires before the function returns. This is specifically relevant to chain one, where we’re working with a stack-based buffer overflow.

SMEP and SMAP prevent the kernel from executing or reading userspace memory. Without bypassing these, a ret2usr attack, where we redirect kernel execution to shellcode mapped in userspace, would immediately fail. Both chains disable SMEP and SMAP by writing a modified value to the CR4 control register before pivoting to userspace shellcode.

Staged Exploit Development Pipeline

One of the most important lessons from this research was that you cannot hand a language model a vulnerability and ask it to produce a complete exploit in one shot. When we tried this early on, the model would attempt to generate a full ROP chain, shellcode, heap groom, and KASLR bypass all at once. The result was inevitably broken in multiple places simultaneously, and neither the model nor we could efficiently diagnose which part was wrong.

What worked was decomposing exploit development into a series of levels, where each level isolates exactly one new variable and builds on the proven foundation of the previous one:

Staged L0-L4 kernel exploit pipeline: trigger KASAN, control RIP, end-to-end exploit, bypass SMEP/SMAP, and full weaponization
Figure 1: The staged L0-L4 pipeline that lets the model isolate exactly one new variable per level.

This gives both the human and the model a clear feedback signal at each step. If Level 1 works but Level 2 crashes, the problem is in the shellcode or return path, not the overflow. If Level 3 regresses, the ROP gadgets are wrong. You’re never debugging the entire exploit at once. It also maps naturally to how you prompt the model, instead of “write an exploit for this vulnerability,” each level becomes a focused, well-scoped task that the model performs dramatically better on.

The screenshot below shows this in practice for the stack overflow chain with the agent validating each level in sequence along with a summary confirming the results of the stage.

Terminal output showing the agent validating Levels 1 through 3 of the rtsock jail escape chain, each marked VALIDATED
Figure 2: The agent confirming each level of the stack overflow chain in sequence, ending in a validated jail escape.

Stack-Based Buffer Overflow Chain

The first chain combines two vulnerabilities to escape a FreeBSD jail. The first is a stack-based information leak that defeats both the stack canary and KASLR, and the second is the RTSock stack overflow (CVE-2026-3038) used to achieve kernel code execution. We identified a vulnerability in a kernel subsystem accessible from within a VNET jail where an ioctl handler copies more data back to userspace than it should, leaking kernel stack contents including the stack canary and a return address at a known offset from the kernel base. The canary lets us survive the stack canary check in the overflow, and the return address gives us the kernel base to compute the addresses of every symbol and ROP gadget we need.

With those values in hand, the exploit constructs an RTM_GET routing message targeting the rtsock_msg_buffer() overflow described in part one, using an oversized RTAX_AUTHOR sockaddr with sa_len set to 255. The 127-byte overflow payload places the leaked canary at the correct offset, zeros the saved callee-save registers, and overwrites the return address with a short ROP chain that clears the SMEP and SMAP bits in CR4 and pivots to userspace shellcode.

The shellcode runs in ring 0 and handles three tasks. First, it properly releases a read-side critical section lock held by the calling code path, which would otherwise deadlock the kernel on return. Next, it modifies the current thread’s credentials to uid 0, reassigns the process’s prison to prison0 (escaping the jail), and points all directory references to rootvnode (escaping the jail’s filesystem). Finally, it restores SMEP/SMAP in the thread’s saved CR4 for context-switch safety and returns cleanly to userspace via the kernel’s doreti path. The result is a root shell on the host with full filesystem access.

Heap-Based Buffer Overflow Chain

The second chain takes an entirely different approach, exploiting a heap-based buffer overflow in a kernel subsystem accessible from within a VNET jail, combined with a type confusion technique we refer to as pipe-file confusion.

KASLR Defeat

This chain uses a separate information disclosure vulnerability to defeat KASLR. The vulnerability involves a kernel function that declares a structure on the stack without properly initializing it, then copies the full structure back to userspace with only a fraction of its fields populated. The uninitialized bytes contain kernel stack residue, including a kernel text pointer at a known offset that can be used to compute the kernel base. This method works unprivileged from within a jail.

The Heap Overflow

The heap overflow targets the UMA malloc-32768 zone. A kernel message-parsing interface allocates a response buffer in this zone, then copies decoded data into it. By crafting input whose decoded length exceeds the UMA item boundary (32,768 bytes), we can overflow into whatever allocation is adjacent in the slab. Every byte beyond the item boundary overwrites the adjacent allocation with attacker-controlled content.

The challenge is controlling what sits adjacent to the overflow. We solve this with a fork spray that grooms the malloc-32768 zone. Each child process grows its file descriptor table to 640 entries via dup2, which triggers fdgrowtable to allocate a new fdescenttbl landing in the target UMA zone.

The grooming is carefully sequenced. First, two-hundred “pre-drain” children exhaust the UMA per-CPU bucket caches. Without this step, subsequent allocations would be served from cached items scattered across kernel virtual address space rather than from contiguous slab pages. After the pre-drain, four-hundred spray children allocate fresh slabs from the VM, which are laid out contiguously in kernel memory. To trigger the overflow, we kill one child from the upper portion of the spray (freeing its slab), then immediately send the overflow message. The response buffer reclaims the freed slab, and the 16-byte overflow crosses the UMA item boundary into the adjacent child’s fd table.

UMA malloc-32768 zone diagram: fdescenttbl slabs groomed contiguously with a victim object and a 16-byte overflow into the adjacent target
Figure 3: Grooming the malloc-32768 zone so a 16-byte overflow crosses into the adjacent child's fd table.

FD Table Corruption

To understand what the overflow corrupts, it helps to look at how FreeBSD manages file descriptors. Each process has a struct filedesc containing a pointer fd_files to a separately-allocated fdescenttbl, a flexible array of filedescent entries, one per open file descriptor. The fdescenttbl is allocated via malloc() and lands in a UMA zone based on its size. With 640 entries at 48 bytes each plus an 8-byte header, it lands squarely in malloc-32768.

Diagram of struct fdescenttbl with fde_file[0] overwritten to point at an attacker-controlled struct pipe kernel address
Figure 4: The 16-byte overflow rewrites fde_file[0] with the kernel address of the attacker's pipe.

The 16-byte overflow payload targets the very beginning of the adjacent child’s fdescenttbl: fdt_nfiles is set to the expected value of 640 (so the kernel doesn’t detect corruption), and fde_file[0], the file pointer for fd 0, is overwritten with the kernel address of a pipe structure controlled by the attacker.

Pipe-File Confusion

This is where the type confusion comes in. Before the fork spray, the parent process creates a pipe and writes a carefully constructed fake fileops vtable into the pipe’s data buffer. The pipe’s kernel address is obtained via the KERN_PROC_FILEDESC sysctl, which exposes kernel addresses for a process’s open file descriptors. After the overflow rewrites fde_file[0] to point to the pipe, the kernel treats the pipe structure as a struct file whenever the corrupted child uses fd 0. Critically, the field at offset +16 in struct file is f_ops, the pointer to the file operations vtable, and the field at offset +16 in struct pipe is pipe_buffer.buffer is a pointer to the pipe’s data buffer. Since we control the pipe’s buffer contents, we control the vtable.

Type confusion diagram: struct file f_ops overlaps struct pipe pipe_buffer.buffer, pointing to a fake fileops vtable
Figure 5: Pipe-file confusion, where the attacker-controlled pipe buffer becomes a fake fileops vtable.

With the vtable pointer under our control, the next question is which file operation to hijack. Each fileops slot dispatches with a different set of registers populated from the syscall arguments, so the choice of slot determines what values we can pass to a gadget. We mapped the register state at dispatch for every slot using Claude:

Table mapping each fileops slot to its syscall and the amd64 registers controlled at dispatch, highlighting fo_chown and fo_stat
Figure 6: Register control at dispatch for each fileops slot, mapped out with Claude.

The fo_chown slot stood out: calling fchown(fd, uid, gid) places the uid argument in RSI with full 64-bit control over the value. Combined with a mov cr4, rsi; ret gadget, this gives us a single-syscall SMEP/SMAP disable, we just call fchown(0, 0x706e0, 0). For the second stage, fo_stat dispatches with RDI pointing to the file pointer (our pipe), and since SMEP is already off at that point, we can point it directly at userspace shellcode. All other slots are filled with a ret gadget as safe no-ops.

Flow diagram: fchown writes CR4 via a mov cr4, rsi gadget to disable SMEP/SMAP, then fstat jumps to ring 0 shellcode
Figure 7: A single fchown call disables SMEP/SMAP, then fstat pivots into userspace shellcode running in ring 0.

SMEP/SMAP Bypass and Trigger

The trigger sequence is carefully orchestrated to prevent CR4 from being restored by a context switch between the two syscalls. All spray children are pinned to CPU 0 and SIGSTOP’d. The parent then SIGCONTs one child at a time near the victim. When the corrupted child wakes, it calls fchown(0, 0x706e0, 0), the uid argument 0x706e0 is the CR4 value with SMEP/SMAP cleared, and the fake fo_chown gadget writes it directly to CR4. Immediately after, the child calls fstat(0), which dispatches through fo_stat and jumps to the shellcode running in ring 0.

Diagram of a shared struct ucred with cr_uid and cr_ruid zeroed and cr_prison repointed to the host's prison0
Figure 8: The shellcode zeros the shared credential's uid and reassigns its prison to the host's prison0.

Credential Escalation and Jail Escape

The shellcode for this chain is simpler than chain one since it doesn’t need to deal with epoch cleanup. It reads curthread from gs:0, follows the pointer chain to the thread’s ucred, and zeros cr_uid and cr_ruid. It then sets cr_prison to point to prison0, the host’s prison structure.

The one added complexity is reference counting. The exploit forks 600+ children that all share the same ucred structure. Overwriting cr_prison on this shared credential means every child exit will decrement prison0’s reference counts instead of the jail’s. The shellcode pre-bumps both pr_ref and pr_uref by 1,000 using atomic additions to absorb these decrements. The excess is harmless since prison0 is never freed.

Diagram of a shared struct pwd with all four directory vnodes overwritten to point at the host's rootvnode
Figure 9: Repointing every directory vnode to rootvnode escapes the jail's filesystem view entirely.

Finally, the shellcode updates all four directory pointers in the process’s pwd structure to point to rootvnode, the host’s root filesystem vnode. This breaks out of the jail’s filesystem view entirely and subsequent path lookups resolve against the host’s root, letting the process open any file on the host.

Diagram of struct filedesc pointing to a separately-allocated fdescenttbl that lands in a UMA zone based on its size
Figure 10: struct filedesc points to a separately-allocated fdescenttbl, sized to land in malloc-32768.

CR4 is not restored in the shellcode itself, since the code is executing from a userspace page via ret2usr, re-enabling SMEP would immediately fault. Instead, CR4 is naturally restored on the next context switch. If the exploit hasn’t succeeded after testing the ten nearest candidates, it assumes the overflow missed and retries with a fresh grooming pass, up to three attempts.

Noteworthy Failures

The biggest issue was the agent’s inability to decompose the problem on its own. Without the staged L0–L4 framework, it would attempt the entire exploit in a single pass and inevitably produce something broken in multiple places it couldn’t debug. This is what motivated the leveled approach.

The kernel module cheating incident described in part one happened when implementing chain two exploit chain. The agent ran into an issue where it couldn’t find an appropriate stack pivot chain to execute the return oriented programming (ROP) chain it was trying to build. Instead of concluding it needed to find another exploitation vector, it decided the best course of action was to develop a custom kernel module containing the ROP gadget it needed and then load it onto the system.

To fix the problem, I had to intervene and then instruct it to look into potential vectors related to controlling values within the file operations table which led us down the path of doing the confusion between a pipe buffer and a file operations structure to bypass SMEP/SMAP through modification of the CR4 register.

Context window compaction was a recurring problem. When conversation history was compressed, the agent would lose important context and sometimes go off track due to compaction. In many cases, the best course of action was to simply recognize the agent got stuck and clear the context window and then restart it again on performing the task with the appropriate context set.

The general takeaway is that we wouldn’t trust the model to one-shot a kernel exploit, though with a better harness and multi-agent framework that may change. What worked was human coaching with the model handling the mechanical work while we provided strategic direction.

Noteworthy Successes

The agent proved very strong at reasoning about kernel internals from source code. It read the FreeBSD UMA allocator implementation, understood per-CPU bucket cache behavior, and proposed the pre-drain strategy on its own. It independently identified file descriptor tables as a heap spray target for malloc-32768, worked out the fdgrowtable growth math, and found the KERN_PROC_FILEDESC sysctl for leaking pipe kernel addresses when we tasked it with brainstorming approaches.

Its debugging workflow was unexpected but effective. We gave it access to an LLDB MCP server for remote kernel debugging, but it ignored this entirely. Instead, it opted to run the exploit, wait for the crash, reboot, and analyze the generated crash dump. Not how a human would do it, but the feedback loop worked.

The agent also excelled at tedious, repetitive work including testing exploit reliability by running the exploit dozens of times against a target VM, porting across FreeBSD versions by looking up struct offsets and spinning up version-specific VMs in GCP, and the hours of offset correction and heap spray debugging that normally dominate kernel exploit development. Both chains were developed over a weekend where we could leave the agent running for a couple of hours, check back, course-correct, and repeat.

This compressed what could have likely been a one or two week project into a mostly hands off weekend project that consisted of periodically checking in with the agent as it worked through developing the exploit.

Conclusion

The question we set out to answer was whether LLM-assisted research could contribute meaningfully to exploit development, not just vulnerability discovery. My conclusion from this research is a resounding yes with a few minor caveats. The first being that in this scenario the model didn’t produce working jail escapes entirely autonomously on its own. It needed the proper framework and harness environment around it including the staged exploit generation framework. It also occasionally would need human intervention when it got stuck or would attempt to cheat or run into glitches when the context window went through compaction.

I believe that further work into harness engineering, custom compaction algorithms, incorporating multiple models, custom kernel exploitation skill development, etc. would significantly enhance the system and likely result in the system needing fewer corrections from a human operator. We’ve outlined here some of the key problems and future work would likely revolve around building an enhanced harness for the model to solve these issues.

In my opinion, the key takeaway from this research isn’t that language models will entirely replace exploit developers, it’s that a skilled exploit developer working alongside a specialized agent can develop exploits much faster than was previously possible.

If you’d like to discuss how Praetorian applies offensive research like this to harden the systems your business depends on, reach out for a conversation.

About the Authors

Adam Crosser

Adam Crosser

Adam is an operator on the red team at Praetorian. He is currently focused on conducting red team operations and capabilities development.

Catch the Latest

Catch our latest exploits, news, articles, and events.

Ready to Discuss Your Next Continuous Threat Exposure Management Initiative?

Praetorian’s Offense Security Experts are Ready to Answer Your Questions