ANT-2026-5DFBSQR9 · freebsd/freebsd-src
use-after-free high
Severity Claude high · Security research firm - · Maintainer high
Anthropic's analysis of this finding, sealed at approval.
ANT-2026-5DFBSQR9: Local privilege escalation via tty_drop_ctty t_session UAF + pmap_pkru_update_range PDPE walk
In sys/kern/tty.c, tty_drop_ctty() clears the session→tty pointers and decrements the refcount but never clears tp->t_session; because s_ttyp is already NULL, sess_release() skips tty_rel_sess(), so when the session struct is freed tp->t_session is left dangling. Closing the pty master later walks tty_rel_gone → ttydisc_modem → tty_signal_sessleader, which dereferences tp->t_session->s_leader and ultimately calls knote() on a klist inside that pointer — an attacker-controllable indirect call. Separately, pmap_pkru_update_range() in sys/amd64/amd64/pmap.c checks X86_PG_V but not PG_PS on PDPEs, so a 1GB SHM_LARGEPAGE mapping causes the kernel to treat a gigabyte of user data as page-table entries, yielding an arbitrary-physical-page read/partial-write and an address oracle for the user page's DMAP kernel VA. Chaining the two gives a nobody-level local attacker a known-address gigabyte of controlled kernel-visible memory plus a control-flow hijack, demonstrated end-to-end to uid=0 on stock 14.4-RELEASE.
Target
Project: freebsd/freebsd-src
Version: FreeBSD 14.4-RELEASE GENERIC amd64 (tested); still present on main and releng/15.0; tty bug introduced in 1b50b999f9b5 (2019-11-30), pmap bug in e7a9df16e65 (2019-02-20)
Location: tty_drop_ctty() in sys/kern/tty.c; pmap_pkru_update_range() in sys/amd64/amd64/pmap.c
Technical Details
Bug 1: tty_drop_ctty() sets session->s_ttyp/s_ttyvp/s_ttydp = NULL and decrements t_sessioncnt but does not set tp->t_session = NULL; the normal cleanup path (sess_release → tty_rel_sess) is gated on s_ttyp being non-NULL, so it never runs and the tty retains a pointer into a freed malloc-128 session struct. Bug 2: at the PDPE level pmap_pkru_update_range() tests only (*pdpe & X86_PG_V) and falls through to pmap_pdpe_to_pde(); for a 1GB superpage the PDPE has PG_PS set and its frame-number field is the physical base of user data, which the kernel then dereferences and rewrites (bits [62:59]) as if it were a page directory and page tables.
Reproduction
- (Oracle) shm_open2(SHM_ANON, …, SHM_LARGEPAGE) with lp_psind=2, ftruncate to 1GB, mmap at a 1GB-aligned VA; write a fake PDE and sentinel into the page; call sysarch(AMD64_SET_PKRU) on a 4KB sub-range and probe candidate physical bases until the sentinel's bits [62:59] change, revealing the page's physical address and thus its DMAP kernel VA.
- Build fake struct proc / knlist / knote / filterops and a ROP stack inside the 1GB page at the now-known kernel virtual address.
- posix_openpt() for a pty master, open the slave, clear CLOCAL on the slave.
- Fork; child does setsid(), re-opens the slave, ioctl(TIOCSCTTY), ioctl(TIOCNOTTY), _exit(0) — freeing the session while tp->t_session still points at it.
- Refill the freed malloc-128 session slot (AF_UNIX bind() with a sized sockaddr_un, raced against the close on a second CPU) so that the bytes at offset 8 (s_leader) point to the fake proc.
- Close the pty master: ttydev_close → tty_rel_gone → ttydisc_modem(tp,0) → tty_signal_sessleader → dereference stale tp->t_session->s_leader → PROC_LOCK → kern_psignal → tdsendsignal → knote(p->p_klist,…) → attacker-controlled kn->kn_fop->f_event(kn,…) → stack pivot → ROP → root.
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Suggested Fix
tty: in tty_drop_ctty(), after SESS_UNLOCK(session) and while still holding the tty lock, add tp->t_session = NULL; before decrementing t_sessioncnt. pmap: in pmap_pkru_update_range(), after the existing X86_PG_V check on *pdpe, add an if ((*pdpe & PG_PS) != 0) branch that updates the PKU bits on the PDPE itself and continues to the next 1GB region instead of descending — mirroring the existing PDE-level PG_PS handling. Patches for both are included in the report.
Acknowledgement
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-5DFBSQR9.
Reference: ANT-2026-5DFBSQR9
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
Dates from discovery through public reveal.
- 2026-03-18 Sent to maintainer
- 2026-03-18 Maintainer acknowledged
- 2026-04-20 Patch released
- 2026-05-14 Reported to tracker
- 2026-06-02 Publicly revealed
SHA-3-512 hash:
edab0a66ee66870f8897e8e685ed52d5389cbc4d93db3a778bcd0c791aae829f1fefe819736950cb82dc00450bad2c325c3b2989ee0e103ce3c32837bd07fa7f
Committed 2026-03-19 16:33 PT
Revealed 2026-06-02 11:00 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-5DFBSQR9",
"bug_class": "Use-After-Free / Type Confusion",
"claude_severity": "high",
"commit_sha": null,
"created_at": "2026-05-14T22:41:38+00:00",
"description": "In sys/kern/tty.c, tty_drop_ctty() clears the session→tty pointers and decrements the refcount but never clears tp->t_session; because s_ttyp is already NULL, sess_release() skips tty_rel_sess(), so when the session struct is freed tp->t_session is left dangling. Closing the pty master later walks tty_rel_gone → ttydisc_modem → tty_signal_sessleader, which dereferences tp->t_session->s_leader and ultimately calls knote() on a klist inside that pointer — an attacker-controllable indirect call. Separately, pmap_pkru_update_range() in sys/amd64/amd64/pmap.c checks X86_PG_V but not PG_PS on PDPEs, so a 1GB SHM_LARGEPAGE mapping causes the kernel to treat a gigabyte of user data as page-table entries, yielding an arbitrary-physical-page read/partial-write and an address oracle for the user page's DMAP kernel VA. Chaining the two gives a nobody-level local attacker a known-address gigabyte of controlled kernel-visible memory plus a control-flow hijack, demonstrated end-to-end to uid=0 on stock 14.4-RELEASE.",
"discovered_at": "2026-03-19T23:33:25+00:00",
"location": "tty_drop_ctty() in sys/kern/tty.c; pmap_pkru_update_range() in sys/amd64/amd64/pmap.c",
"poc_sha256": null,
"preimage_version": 1,
"project": "freebsd/freebsd-src",
"reproduction": [
"1. (Oracle) shm_open2(SHM_ANON, …, SHM_LARGEPAGE) with lp_psind=2, ftruncate to 1GB, mmap at a 1GB-aligned VA; write a fake PDE and sentinel into the page; call sysarch(AMD64_SET_PKRU) on a 4KB sub-range and probe candidate physical bases until the sentinel's bits [62:59] change, revealing the page's physical address and thus its DMAP kernel VA.",
"2. Build fake struct proc / knlist / knote / filterops and a ROP stack inside the 1GB page at the now-known kernel virtual address.",
"3. posix_openpt() for a pty master, open the slave, clear CLOCAL on the slave.",
"4. Fork; child does setsid(), re-opens the slave, ioctl(TIOCSCTTY), ioctl(TIOCNOTTY), _exit(0) — freeing the session while tp->t_session still points at it.",
"5. Refill the freed malloc-128 session slot (AF_UNIX bind() with a sized sockaddr_un, raced against the close on a second CPU) so that the bytes at offset 8 (s_leader) point to the fake proc.",
"6. Close the pty master: ttydev_close → tty_rel_gone → ttydisc_modem(tp,0) → tty_signal_sessleader → dereference stale tp->t_session->s_leader → PROC_LOCK → kern_psignal → tdsendsignal → knote(p->p_klist,…) → attacker-controlled kn->kn_fop->f_event(kn,…) → stack pivot → ROP → root."
],
"technical_details": "Hi --\n\nI believe I've found two vulnerabilities in FreeBSD that can be\n\nchained together to yield privilege escalation from nobody to full\n\nkernel control.\n\nI used an LLM to find both of these bugs, but I validated the bugs\n\nmyself and wrote this email and believe everything in it to be\n\ncorrect. I've validated the bugs on a fresh machine with the attached\n\nPoCs.\n\nThe first bug is a use-after-free. The TIOCNOTTY ioctl handler,\n\ntty_drop_ctty() in sys/kern/tty.c, detaches the calling process from\n\nits controlling terminal. It clears the session-to-tty pointers\n\n(s_ttyp, s_ttyvp, s_ttydp) and decrements the tty's session reference\n\ncount, but it does not clear the tty-to-session pointer, tp->t_session.\n\nNormally that pointer is cleared later by tty_rel_sess(), which\n\nsess_release() calls when the session's refcount hits zero. But\n\nsess_release() only calls tty_rel_sess() if s_ttyp is still set,\n\nand tty_drop_ctty() just cleared it. So when the last reference to\n\nthe session drops and the struct is freed, tp->t_session remains\n\npointing into freed heap. Any later code path that reads\n\ntp->t_session->s_leader (there are several) is a controlled-deref\n\nprimitive.\n\nI've attached a PoC that reproduces this crash.\n\nThe second bug is a page-table-walk type confusion. When a process\n\nmaps a 1GB superpage (via shm_open2 with SHM_LARGEPAGE), the hardware\n\nmapping for it is a PDPE (a third-level page table entry) with both\n\nthe Present bit and the Page Size bit set. The Page Size bit is what\n\ndistinguishes \"this PDPE is a terminal 1GB mapping\" from \"this PDPE\n\npoints at a page directory.\" pmap_pkru_update_range() in\n\nsys/amd64/amd64/pmap.c walks a process's page tables to update the\n\nprotection-key bits on a range of mappings. For the PDPE level, it\n\nchecks the Present bit but not the Page Size bit, and falls through\n\nto pmap_pdpe_to_pde(), which interprets the PDPE's physical-address\n\nfield as a pointer to a page directory. For a 1GB superpage, that\n\nphysical-address field is the base of the 1GB of userspace data the\n\nprocess just mapped. The kernel proceeds to read that gigabyte of\n\nuser-controlled memory as page table entries, and for every entry with\n\nthe Present bit set, rewrites its protection-key bits in place. That\n\n\"entry\" can be made to live at any physical address the attacker\n\nchooses via the fake PDE's frame-number field — the kernel thinks\n\nit's updating a page table page, but the target can be anything in\n\nRAM.\n\nI've attached a second PoC that reproduces this crash.\n\nNeither bug requires any elevated capability to trigger. TIOCNOTTY is\n\navailable to any process with a tty. shm_open2 with SHM_LARGEPAGE and\n\nthe sysarch(AMD64_SET_PKRU) call that reaches pmap_pkru_update_range\n\nare both permitted under default resource limits and are explicitly\n\nwhitelisted under Capsicum. The pmap_pkru path, though, does require\n\nthat the CPU has Protection Keys; any Intel CPU since Skylake or AMD\n\nsince Zen 3 should qualify.\n\nI've also attached two PoCs that, when run as nobody on a stock\n\nFreeBSD build, each demonstrate uid=0 access:\n\n- The first PoC chains both bugs: the UAF for a control-flow hijack,\n\n and the type confusion as an address oracle. It needs a CPU with\n\n Protection Keys (Skylake+/Zen 3+), ~4GB free RAM for a 1GB\n\n contiguous allocation, and at least two CPUs for a race.\n\n- The second PoC uses only the UAF, but it needs a nobody-\n\n writable tmpfs mount, which isn't default.\n\nBoth PoCs give an unprivileged local process root on 14.4-RELEASE\n\nGENERIC amd64.\n\nfirst. A malicious process creates a new session (with setsid) to\n\nopen the slave side of a pseudo-terminal, makes that tty its\n\ncontrolling terminal (TIOCSCTTY), then immediately disowns it with\n\n(TIOCNOTTY). When that process exits, its session is freed. The pty's\n\nt_session is now a stale pointer. Closing the pty master triggers\n\ntty_rel_gone, then ttydisc_modem, and then tty_signal_sessleader,\n\nwhich runs the following code:\n\n // sys/kern/tty.c, tty_signal_sessleader()\n\n if (tp->t_session != NULL && tp->t_session->s_leader != NULL) {\n\n p = tp->t_session->s_leader; // freed heap, offset 8\n\n PROC_LOCK(p); // lock cmpxchg on p+0x140\n\n kern_psignal(p, sig);\n\n PROC_UNLOCK(p);\n\n }\n\nkern_psignal calls tdsendsignal, which, after only a zombie-state\n\ncheck, calls knote() on p->p_klist. knote() walks the list and for\n\neach entry calls kn->kn_fop->f_event(kn, hint). If the freed session\n\nslot has been refilled such that s_leader points at attacker-controlled\n\nmemory, there's a direct path from PROC_LOCK to an indirect call with\n\na controlled function pointer, with the first argument pointing into\n\nattacker controlled memory.\n\nThe first PoC works by taking this write, and using it to stack pivot\n\nand then ROPing its way to code execution in the kernel.\n\nThe pmap bug can help make this exploit simpler. The vulnerable\n\ncode is as follows:\n\n // sys/amd64/amd64/pmap.c, pmap_pkru_update_range()\n\n pdpe = pmap_pml4e_to_pdpe(pml4e, va);\n\n if ((*pdpe & X86_PG_V) == 0) { // checks Present\n\n ... continue; // does NOT check PG_PS\n\n }\n\n ...\n\n pde = pmap_pdpe_to_pde(pdpe, va); // treats PDPE as PD pointer\n\n ptpaddr = *pde; // *pde is inside the 1GB page\n\n ...\n\n if ((ptpaddr & PG_PS) != 0) { // checks PG_PS here, one\n\n ... // level too late\n\n }\n\n ...\n\n for (ptep = pmap_pde_to_pte(pde, va); ... ) {\n\n pte = *ptep;\n\n if ((pte & X86_PG_V) == 0) continue; // user data, bit 0 decides\n\n newpte = (pte & ~X86_PG_PKU_MASK) | X86_PG_PKU(keyidx);\n\n *ptep = newpte; // writes bits [62:59]\n\n }\n\nThe PDE-level PG_PS check is correct; it's the PDPE-level check\n\nthat's missing. With a 1GB mapping at the right virtual address, a\n\nsysarch(AMD64_SET_PKRU) call on a 4KB range inside that mapping\n\nwill read a quadword the attacker wrote at a chosen offset in the\n\n1GB page as a \"PDE,\" follow its physical-address bits to any physical\n\npage in the machine, and then read and modify quadwords in that\n\nphysical page as \"PTEs.\"\n\nThe write primitive itself (bits [62:59] of any odd quadword in\n\nphysical RAM) is awkward to exploit directly. What makes this bug\n\nuseful here is not the write itself but what you can learn from\n\nwhere it lands: point the fake PDE at successive candidate\n\n1GB-aligned physical addresses, put a distinctive sentinel at offset\n\nzero of the 1GB page, fire the sysarch, and then check if the\n\nsentinel's protection-key bits changed. When they do, you've found\n\nthe physical address of your own 1GB page, and the kernel virtual\n\naddress is just DMAP_MIN_ADDRESS ORed with that physical address.\n\nYou now have a gigabyte of attacker-controlled memory at a known\n\nkernel virtual address, which is exactly what the tty bug's exploit\n\nchain needs.\n\nTwo proofs of concept are attached. Both run as nobody, both end with\n\nroot reading /etc/master.passwd. They differ only in how they solve\n\nthe \"known kernel address of controlled memory\" problem.\n\nI've provided additional descriptions of the PoCs at the end of this\n\nemail; they're not important in order to understand the bug but I've\n\nprovided them in case you're curious. I spent less time validating the\n\nLLM's claims here so there may be some errors.\n\nThere's a relatively simple fix for both bugs. For the tty one, the\n\none-line version is to clear tp->t_session in tty_drop_ctty after\n\ndropping the session lock but while still holding the tty lock:\n\n--- sys/kern/tty.c\n\n+++ sys/kern/tty.c\n\n@@ tty_drop_ctty @@\n\n session->s_ttyp = NULL;\n\n session->s_ttyvp = NULL;\n\n session->s_ttydp = NULL;\n\n SESS_UNLOCK(session);\n\n+ tp->t_session = NULL;\n\n tp->t_sessioncnt--;\n\n p->p_flag &= ~P_CONTROLT;\n\nI think this is what tty_rel_sess() would have done, had\n\nsess_release() reached it.\n\nFor the pmap bug, I'm less sure what to do. I think that it should\n\nwork to check PG_PS on the PDPE the same way the function already\n\ndoes for the PDE one level down, but I'm out of my depth in my\n\nsystems knowledge here and may be misunderstanding things;\n\nnevertheless, here's a patch that does stop this exploit on my\n\nmachine:\n\n--- sys/amd64/amd64/pmap.c\n\n+++ sys/amd64/amd64/pmap.c\n\n@@ pmap_pkru_update_range @@\n\n pdpe = pmap_pml4e_to_pdpe(pml4e, va);\n\n if ((*pdpe & X86_PG_V) == 0) {\n\n va_next = (va + NBPDP) & ~PDPMASK;\n\n if (va_next < va)\n\n va_next = eva;\n\n continue;\n\n }\n\n+ if ((*pdpe & PG_PS) != 0) {\n\n+ va_next = (va + NBPDP) & ~PDPMASK;\n\n+ if (va_next < va)\n\n+ va_next = eva;\n\n+ newpde = (*pdpe & ~X86_PG_PKU_MASK) | X86_PG_PKU(keyidx);\n\n+ if (newpde != *pdpe) {\n\n+ *pdpe = newpde;\n\n+ changed = true;\n\n+ }\n\n+ continue;\n\n+ }\n\nBoth bugs were introduced in 2019 if my sleuthing is correct. The tty\n\nbug was introduced in 1b50b999f9b5 (2019-11-30, the commit that\n\nimplemented TIOCNOTTY), and the pmap bug in e7a9df16e65 (2019-02-20,\n\nthe commit that added PKU support). I've checked both are still\n\npresent on main and releng/15.0.\n\nHappy to provide more detail, or answer any other questions.\n\nNicholas\n\nAdditional details on the PoCs:\n\nlpe.c uses no second bug. The KERN_PROC_PID sysctl (available to any\n\nuser on their own processes) returns several raw kernel pointers in\n\nstruct kinfo_proc — ki_paddr is the process's own struct proc*,\n\nki_args is its struct pargs*. struct pargs is the process's argv as a\n\ncontiguous buffer, allocated from general malloc with a size controlled\n\nby the argv length. Fork children with ~240-byte argv strings, and each\n\nchild's pargs lands in the malloc-256 bucket at an address the parent\n\ncan read via sysctl. On a quiet system these allocations are adjacent\n\nat a 256-byte stride. The exploit tiles its fake kernel structures\n\n(the fake proc, knlist, knote, filterops and the ROP stack) across\n\nseven such adjacent slots, encoding the bytes as the argv strings\n\nthemselves. Every internal pointer in the fake structures is computed\n\nfrom addresses leaked via sysctl from the exploit's own children. The\n\nplacement is made deterministic with a relay-execve technique: each\n\nchild first holds a dummy slot, then reads the real payload from a\n\npipe and re-execs itself, so the free of the old pargs and the alloc\n\nof the new one happen inside a single execve syscall and hit the same\n\nper-CPU allocator slot. The 128-byte session refill (for the dangling\n\ntp->t_session) uses bind() on an AF_UNIX socket with a sockaddr_un\n\nsized to land in malloc-128; this needs a tmpfs mount — the UFS\n\nsoftdep worklist structures (dirrem, bmsafemap, diradd) share\n\nmalloc-128 and overwrite the slot between the bind and the trigger.\n\nunified_exploit.c uses the pmap_pkru bug as the address oracle. The\n\n1GB page's self-probed DMAP address is the \"known kernel VA,\" and the\n\nfake structures are built in the first few kilobytes of that page.\n\nThe session refill is done by racing close(pty_master) against a\n\nbind() spammer thread: sys_bind's getsockaddr malloc is transient\n\n(freed before bind returns), but it's live for the whole of\n\nuipc_bindat's namei and VOP_CREATE. On a two-CPU system, pin the\n\nbind spammer to the CPU whose per-CPU cache the freed session went to,\n\nclose the pty from the other CPU, and the read of tp->t_session lands\n\ninside one of the spammer's transient windows. No tmpfs needed, but\n\nthe race window is narrow on real hardware.\n\nTo be very explicit about the tty trigger, since both PoCs share it:\n\n 1. posix_openpt() → master fd, ptsname/open → slave fd.\n\n 2. Clear CLOCAL on the slave (else ttydisc_modem short-circuits\n\n and never reaches tty_signal_sessleader).\n\n 3. Fork. Child: setsid(), open the slave again, ioctl(TIOCSCTTY)\n\n to make it controlling, ioctl(TIOCNOTTY) to drop it, _exit(0).\n\n 4. tty_drop_ctty clears the session's s_ttyp. Child's exit drops\n\n the last session ref. sess_release sees s_ttyp==NULL, skips\n\n tty_rel_sess. Session freed. tp->t_session dangling.\n\n 5. Parent: fill the freed malloc-128 slot (either PoC's method).\n\n 6. Parent: close the master fd. ttydev_close → tty_rel_gone →\n\n ttydisc_modem(tp, 0) → tty_signal_sessleader(tp, SIGHUP) →\n\n tp->t_session->s_leader → attacker pointer → PROC_LOCK →\n\n kern_psignal → tdsendsignal → knote(p->p_klist, ...) → pivot.\n\nTo be very explicit about the pmap_pkru trigger:\n\n 1. shm_open2(SHM_ANON, ..., SHM_LARGEPAGE) with lp_psind=2 (1GB).\n\n 2. ftruncate to 1GB, mmap at a 1GB-aligned userspace VA.\n\n 3. In the 1GB page at the byte offsets the kernel will read as\n\n page table indices: write a fake PDE at offset (outer_idx * 8)\n\n with X86_PG_V set and a candidate physical address in the\n\n frame-number bits. Write a sentinel at offset (inner_idx * 8)\n\n with X86_PG_V set.\n\n 4. sysarch(AMD64_SET_PKRU) on a 4KB range at 1g_va + delta, where\n\n delta encodes the outer_idx and inner_idx in the normal\n\n vaddr-to-pagetable-index bitfields.\n\n 5. The kernel reads your 1GB page as a PD, reads your fake PDE,\n\n follows it to the candidate physical page, reads and rewrites\n\n quadwords there.\n\n 6. If the candidate physical page is your own 1GB page, the\n\n sentinel at offset (inner_idx * 8) has its bits [62:59]\n\n rewritten. Check it from userspace. If it changed, that\n\n candidate is your physical address.",
"title": "Local privilege escalation via tty_drop_ctty t_session UAF + pmap_pkru_update_range PDPE walk",
"vendor_severity": null
}