Monday, 03 August, 2026
This one starts with me reading the Linux SCTP code, ’cause that’s apparently what I do for fun now. SCTP is one of those protocol stacks that ships in pretty much every mainstream distro kernel (unless it’s deliberately compiled out - hardened appliance configs do exactly that), gets autoloaded the moment anyone creates the right socket, and yet almost nobody audits it with the same love TCP gets. It has multihoming, dynamic address reconfiguration, its own authentication layer… a whole state machine most people have never even seen. You know what my brain hears? Fresh attack surface 🥁.
What I came back with is a use-after-free in the SCTP control
queue with serious seniority. The parking behavior at its heart
first appeared in Linux 3.1 with 8a07eb0a50ae
(“sctp: Add ASCONF operation on the single-homed host”, 2011), and the
immediate-retransmission step my trigger rides on was first released in
Linux 3.3 via ddc4bbee6ef1.
That’s roughly fourteen years of exposure, and the whole machine is
still there in the Ubuntu 7.0.0-28-generic kernel I tested,
in the newer 7.0.0-30 source, and in upstream
master as of last week. A parked HEARTBEAT_ACK keeps a raw
pointer to a transport that an authenticated ASCONF DEL-IP
then frees, and the removal path repairs every queue except the one the
reply is actually sitting on.
And here’s the part that made me fall in love with it: the trigger needs no race. No threads, no timing window, no spray-and-pray at the syscall level. Just a fixed sequence of ordinary, unprivileged socket operations that walk the protocol into a corner where the bug simply… exists. The rest of the work - and oh boy, is there rest - is turning one known 32-bit write into root.
The full chain is data-only: no ROP, no kernel
shellcode, no function-pointer overwrite, no KASLR leak. It rewrites the
32-bit mode field of a valid struct file - two of those
bits do the talking - then patches the PAM policy for su,
and lets a legitimate setuid binary do the elevating. I named it
HeartAttACK, ’cause the whole bug lives inside a
HEARTBEAT_ACK - and yes, I promise the branding stops here, the rest is
pure allocator pain.
The tested target, for the record:
Linux ubuntu 7.0.0-28-generic #28-Ubuntu SMP PREEMPT_DYNAMIC
Package: linux-image-7.0.0-28-generic 7.0.0-28.28
Architecture: x86-64, KASLR enabled, stock Ubuntu Desktop VM
Grab a coffee. This is a long one, and we’re going all the way from
“what is a slab” to uid=0.
Quick background so we’re on the same page. SCTP is the Stream Control Transmission Protocol - reliable like TCP, but with a twist that matters for us: multihoming. One association can span several IP addresses on each side, so if one path dies the traffic just moves to another. The vocabulary you need:
struct sctp_transport. Peer advertises three addresses,
your association keeps three transports. Remember this object, it’s the
victim of the whole story.A HEARTBEAT asks “are you alive on this address?” and the peer answers with a HEARTBEAT_ACK along the same path. ASCONF is the dynamic address reconfiguration machinery from RFC 5061: it lets an established association add addresses, delete addresses, or ask for a new primary path on the fly. Since rewriting an association’s addresses is security-sensitive, the exploit enables SCTP AUTH and ASCONF per socket - two setsockopt calls, no capability needed, no global sysctl touched.
One last piece of plumbing: the kernel keeps two outbound
trays per association. DATA chunks go on
out_chunk_list; everything else - heartbeats,
acknowledgements, address changes - goes on
control_chunk_list. Two trays, one flush engine. Keep the
two trays in mind, ’cause the whole bug is that one of them gets cleaned
up and the other one doesn’t.
The vulnerability is a lifetime contract with one missing clause, and it’s built from three pieces that are each perfectly reasonable on their own.
Piece one: the reply remembers its path. When a
heartbeat arrives, sctp_make_heartbeat_ack() builds the
reply and copies the incoming chunk’s transport pointer into it -
protocol-logical, ’cause the ACK should go back where the heartbeat came
from:
/* net/sctp/sm_make_chunk.c:1186-1213 */
retval = sctp_make_control(asoc, SCTP_CID_HEARTBEAT_ACK, 0, paylen,
GFP_ATOMIC);
...
if (chunk)
retval->transport = chunk->transport;The struct comment spells out the intent and the danger in one breath:
/* include/net/sctp/structs.h:621-625 */
/* For an inbound chunk, this tells us where it came from.
* For an outbound chunk, it tells us where we'd like it to go.
*/
struct sctp_transport *transport;This is a raw pointer. No reference count is taken,
and sctp_chunk_destroy() releases none. The chunk just
remembers an address. Being a control chunk, it gets queued on
control_chunk_list by sctp_outq_tail().
Piece two: the parking lot. There’s a corner case in the ASCONF state machine for “you asked me to remove the association’s last local address”. The kernel can’t honor that immediately - an association with no local address is a broken association - so it records the deletion as pending and sets a flag:
/* net/sctp/socket.c:827-858 */
asoc->asconf_addr_del_pending = kzalloc_obj(...);
...
asoc->src_out_of_asoc_ok = 1;While that flag is set, the control flush skips every non-ASCONF control chunk:
/* net/sctp/outqueue.c:879-903 */
list_for_each_entry_safe(chunk, tmp, &ctx->q->control_chunk_list, list) {
...
if (ctx->asoc->src_out_of_asoc_ok &&
chunk->chunk_hdr->type != SCTP_CID_ASCONF)
continue;
...
}The key word is continue. Skipped doesn’t mean destroyed
- the chunk stays queued, transport pointer included. So if a
HEARTBEAT_ACK gets built while this flag is up, it’s
parked: alive, queued, and holding a raw pointer, for
as long as I want. No race, no timing. The protocol state itself is the
parking brake. (This exact state machine is the 2011 single-homed-host
feature from the intro - RFC 5061 section 5.3 forbids sourcing packets
from a not-yet-acknowledged address, so parking everything but ASCONF is
the intended behavior. The bug is what happens to parked chunks
later.)
Piece three: the repair that misses the control
tray. Now the peer asks to delete an address with an
authenticated DEL-IP. The kernel finds the transport for it and calls
sctp_assoc_rm_peer(), which is genuinely careful code - it
fixes the primary path, the active path, retransmission state, migrates
transmitted chunks… and walks the DATA tray nulling stale pointers:
/* net/sctp/associola.c:572-579 */
list_for_each_entry(ch, &asoc->outqueue.out_chunk_list, list)
if (ch->transport == peer)
ch->transport = NULL;
asoc->peer.transport_count--;
...
sctp_transport_free(peer);Does it look weird, eh?? That’s out_chunk_list - the
DATA tray. There is no equivalent loop over
control_chunk_list. The parked HEARTBEAT_ACK keeps
its pointer, the transport gets freed (via RCU, so the actual
kfree() lands a grace period later - waiting 750 ms is
plenty), and the chunk is now a dangling pointer with a queue node
around it.
The fire. Later, a successful ASCONF acknowledgement resolves the pending deletion, clears the flag, and immediately retransmits:
/* net/sctp/sm_make_chunk.c:3514-3517 */
if (no_err && asoc->src_out_of_asoc_ok) {
asoc->src_out_of_asoc_ok = 0;
sctp_transport_immediate_rtx(asoc->peer.primary_path);
}The flush finally reaches the parked chunk and does
new_transport = chunk->transport - a pointer to freed
memory. The first thing it touches is
new_transport->state, and a bit deeper,
sctp_packet_config() does this beautiful little line:
/* net/sctp/output.c:73-86 */
packet->vtag = vtag;packet is embedded in the transport at offset 576,
vtag sits 4 bytes into it. Net effect:
*(uint32_t *)(freed_transport_address + 580) = peer_verification_tag;
A controlled-offset 32-bit write into freed kernel memory, with a value I know (each association’s peer tag is readable through the kernel’s own socket diagnostics interface - it’s a protocol field, not a secret). I can’t choose the value, but with 240 associations I have 240 known random values to shop from. That single line is the entire weapon. Everything else in this post exists to answer two questions: where does the write land, and how do I make it land somewhere useful.
Fun bit of history before we move on: a 2018
syzbot report hit the same flush sink through the DATA queue, and
the fix - df132eff4638
- is exactly the out_chunk_list repair loop you saw above.
Read it again: the fix walked one tray. The bug I’m describing
is the adjacent omission, eight years later. There’s also CVE-2025-23142,
another transport UAF at the same sink, but that one needs a two-thread
race around sendmsg() and a dropped socket lock - mine has
no threads, no lock-drop window, and a parked control chunk outside the
path they protected. Close neighbors, different bug.
Building the parked state is a fixed sequence of socket calls. Each
exploit flow (the real thing runs 240 of them) uses four IPv4 loopback
addresses plus ::1:
X: initial server address, used to establish the
association.Y: server address whose client-side transport becomes
the UAF victim.Z: surviving server address that keeps the association
routable.L1: the client’s association address, target of the
demanded heartbeat.::1: endpoint-only client address, there to manufacture
the corner case.The sequence, per flow:
X, client to L1, connect,
accept.Y to the server side of every established
association - in one batch, so the victim transports cluster on shared
slabs. That matters later.Z as the keeper path, then remove X.
Server traffic now originates from Y.::1 to the
endpoint, turn ASCONF back on. ::1 is now on the endpoint
but not in the association.L1 from the client. That’s the association’s
last local address, so the kernel records the pending deletion and sets
src_out_of_asoc_ok = 1. The parking brake engages.L1 (SPP_HB_DEMAND).
It arrives from Y. The client builds the HEARTBEAT_ACK
pointing at Y’s transport… and the suppression parks it.Y on the server. The authenticated DEL-IP
travels to the client over Z - important detail, ’cause
deleting the transport your ASCONF arrived on is a different
bug that got fixed in July 2026 (9b2854f86f0b);
I deliberately route around that fix.L1 the primary path. The
successful ASCONF_ACK clears suppression, the flush fires, and the stale
chunk walks through sctp_outq_select_transport() into
sctp_packet_config().
Every step is an ordered socket operation with an explicit wait. I first proved the lifetime violation under KASAN, which gave the clean three-act evidence every UAF dreams of:
BUG: KASAN: slab-use-after-free in sctp_outq_select_transport
Read of size 4 ... by task sctp_ctrlq_tran
allocated by: sctp_transport_new
freed by: sctp_transport_destroy_rcu
used by: sctp_process_asconf_ack -> sctp_transport_immediate_rtx
-> sctp_outq_select_transport
Allocation, free, use - same object, synchronously triggerable, from
a UID 1001 task with zero capabilities, no namespaces, and every SCTP
sysctl left at its stock value (addip_enable=0,
auth_enable=0 - the per-socket options don’t need them). On
a stock Ubuntu install the sctp module autoloads when you
create the socket, so the only setup is… creating a socket.
So the primitive is a 4-byte write at freed_object +
580, with a value I know but don’t choose. Before dreaming
about targets, there’s a practical problem: the flush reads the
dead transport before it writes anything.
new_transport->state at offset 348 is the first read
(that’s the KASAN hit), and the packet path pokes at several more
fields. Blindly replacing the transport with random bytes means the
kernel dereferences garbage and Oopses long before the vtag write.
The fix is to make the replacement object a survivable fake
transport - just enough correctly-placed fields to walk the
exact call path to packet->vtag and then get out:
The two pointer fields the path dereferences
(packet.transport and the auth chunk pointer) get aimed at
0xfffffe0000000000 - the x86-64 read-only IDT alias in the
CPU entry area. It’s an architecture-fixed, always-mapped, readable
address. That’s the reason the whole exploit needs no KASLR
leak: nothing in the chain ever requires a randomized kernel
address. It’s also one of the reasons the final binary is x86-64-only,
but hey, honesty in advertising.
One caveat before the heap gymnastics: this fake transport is a
calibration device, a narrow state machine for one exact call
path - not a general impersonation of a transport. Later, when the same
stale pointers fire into struct file memory, the kernel
will misread fields and it can Oops. That’s what the
process isolation is for. Patience, young padawan.
The exploit is 10% SCTP and 90% allocator, so here’s everything you need in five minutes. If you speak SLUB fluently, skip ahead; if not, stay with me, ’cause the whole chain is four mental models.
Pages: the atom of memory. The kernel manages RAM in pages - 4096 bytes each on this machine. Every allocation, no matter what it’s for, is ultimately made of whole pages.
The buddy allocator: the land office. When kernel code wants pages, it asks the buddy allocator, which hands out blocks of physically consecutive pages in powers of two. The size is called the order:
Think of the buddy as a land office that only sells plots of 1, 2, 4, 8… acres. Free a 4-acre plot and the neighboring 4-acre plot is also free? They merge back into an 8-acre plot. That’s its whole job: track free power-of-two blocks, split on demand, merge on free. Mind you, merging only works for aligned buddies - the two free 4-acre plots must be the matching halves of one 8-acre plot. Two arbitrary order-1 blocks can never be stitched into an order-2, and which pairs merge is the allocator’s decision, not yours.
SLUB: the housing developer. Most kernel objects are tiny - 100 to 1000 bytes - so spending a whole 4 KiB page per object would waste most of it. Between “I need an object” and the buddy sits SLUB:
CONFIG_RANDOM_KMALLOC_CACHES hardening, several randomized
variants named kmalloc-rnd-*-1k) and a dedicated
filp cache for struct file.CONFIG_SLAB_BUCKETS=y - set on the tested kernel - my SysV
messages don’t come from the generic cache at all; they get their own
dedicated msg_msg-1k cache.With the numbers from this exploit:
struct sctp_transport = 704 bytes -> kmalloc-rnd-*-1k slot
msg_msg (48 B header + 608 B text) -> msg_msg-1k slot (dedicated cache!)
struct file = 184 bytes -> filp cache, 192-byte stride
Read that again, ’cause it shapes the whole exploit: messages and transports have the same 1 KiB slot geometry but live in different caches. Slots never mix across caches - the only way memory travels from one building to the other is as whole slabs, handed back to the buddy and bought again.
Three facts to hold onto:
min_partial). You may have to “fill the reserve”
with other empty slabs before it lets yours go.sctp_transport objects can come back as a
msg_msg-1k slab, then as raw socket-buffer pages, then as a
slab of struct file objects. Same physical RAM, new owner,
new meaning. Heap hardening separates object types into caches, but
physical pages must eventually be reusable - and every single transition
you’re about to see is a whole-slab handoff through that boundary.The PCP lists: the loading dock. One more layer.
Freed pages don’t go straight back to the global buddy lists; each CPU
has a per-CPU pageset - a small loading dock with
separate shelves per order and migrate type. The dock keeps one
count and one high watermark for the
whole pageset (that’s what /proc/zoneinfo shows), and when
count crosses high, a bulk drain
(free_pcppages_bulk()) returns a batch of blocks to the
buddy - starting from the shelf you just freed to.
Why you care: an order-3 block parked on the order-3
shelf is invisible to the filp cache asking for
order-1 blocks - the dock doesn’t split. It only
becomes available after a drain. And since the drain starts from the
freed-to shelf, flooding the dock with order-0 pages crosses the same
watermark but drains the wrong shelf. The exploit frees
matching-order buffers, then re-reads /proc/zoneinfo and
verifies the target blocks actually left the dock.
And the vocabulary for the rest of the post: a spray is allocating many similar objects; grooming is arranging allocations and frees to steer reuse; a reclaim is landing a controlled object in freed victim memory; an oracle is anything observable that tells you what happened. That’s it. School’s out.
After the frees, every stale transport pointer points at an address
inside a block that’s about to change owners. Time for the first
reclaim. Quick SysV primer, ’cause it’s the exploit’s workhorse:
msgget() makes a queue, msgsnd() allocates a
struct msg_msg (48-byte kernel header plus your bytes) and
queues it, msgrcv() consumes it. Header plus 608 bytes of
text is 656 bytes, landing in the dedicated msg_msg-1k
cache - same 1 KiB slot geometry as the dead transports, different
building, courtesy of SLAB_BUCKETS.
So the very first reclaim is already a full cross-cache handoff, and
three moves set it up. The victim transports were allocated in one tight
batch, with X retired before Y was
armed, so no live server-side transport survives interleaved on a victim
slab - once DEL-IP lands, those slabs are entirely free and go
back to the buddy. A 4,096-message warm-up holds the msg_msg cache’s
existing partial slabs hostage, so the probes that follow land on fresh
ones. Then 16,384 probe messages flood in, forcing
msg_msg-1k to buy fresh slabs from the buddy - some of them
the exact blocks that just held transports. And because both caches tile
their slabs into identical 1 KiB slots, old slot byte 580 is new slot
byte 580 - text offset 532 inside my message.
Every message is a forged survivable transport from the previous
section, with the marker 0x41414141 sitting at slot offset
580. Now fire all the stale flows once. Each dangling pointer writes its
flow’s peer tag at +580 of whatever now lives at its old address. Then
read every message back with MSG_COPY - a lovely Linux
extension (needs CONFIG_CHECKPOINT_RESTORE, combined with
IPC_NOWAIT) that copies the Nth message of a queue
without removing it. A non-destructive heap read,
courtesy of the kernel itself.
If message #9137 now shows 0x7f3ab2c1 instead of the
marker, and that’s flow #41’s tag, you know something precious:
stale pointer #41 lands inside message #9137. Flows
whose write went somewhere unknown are simply never promoted to the next
stage - the write already happened, but the safe fake transport made it
harmless, and they’re excluded from everything dangerous. I called this
the message safety filter, and it’s the difference between a calibrated
exploit and a very elaborate kernel crasher.
Knowing “flow #41 → message #9137” isn’t enough for the endgame, ’cause you can only return memory to the buddy a whole slab at a time, and I need to know exactly which physical block and which 1 KiB slot each pointer names. So the block goes on a little journey through four identities - two slab caches, one raw page allocation, one more slab cache - and every hop is measured, never assumed. Nothing here is a guarantee under arbitrary allocator behavior - it’s observation and calibration, which is the whole trick.
Empty the target slabs. The spray placed only four
messages per queue, so removing a small window of queues around each
captured message frees entire local slab windows while keeping most of
the 16,384-message spray alive. First, though, delete 64
distant queues: those empty slabs feed SLUB’s
min_partial reserve habit, so the target empty
slabs are the ones that actually make it back to the buddy. Allocator
bracing - use unrelated frees to make the allocator’s policy state
predictable around your target.
Reclaim with giants. A pressure worker holds ~20,000
same-order socket buffers as ballast (while keeping 1 GiB of
MemAvailable free), occupying competing free blocks. Then
96 big datagrams, each with a payload of block_size - 352
bytes, so the kernel’s linear sk_buff head comes back as
exactly one order-2 (or order-3) page-allocator allocation - not a slab,
raw pages bought straight from the buddy - repeating the
forged-transport template every 1024 bytes, marker at +580.
Read the giants. Fire the surviving flows again,
then recv(..., MSG_PEEK) on every big buffer - another
non-destructive peek. If buffer #17’s slot #5 now shows flow #41’s tag,
the map is complete:
stale pointer #41 -> physical block of buffer #17, byte 5*1024 (+580)
Flow → exact physical block → exact 1 KiB slot → known 32-bit write value. Measured at runtime, end to end.
If walking blocks between caches sounds familiar, it should - SLUBStick (USENIX Security ’24) and the PCPLOST paper (NDSS ’26) systematized cross-cache reuse and PCP massaging, and DirtyCred popularized going after credential-bearing objects instead of code pointers. What’s mine here is the target-specific plumbing: object-content oracles instead of timing side channels, selective queue release, and an observed (not assumed) PCP bulk drain. Standing on the shoulders of giants, then tripping over a heartbeat.
Two war stories from this stage, ’cause they almost broke me. First:
my initial build hardcoded order-2 slabs, which held on small guests and
silently failed on a 16-vCPU, 16 GiB desktop VM - SLUB’s CPU-dependent
minimum-object math picks order 3 there (32 slots over 8 pages). The fix
was reading the real cache geometry from /sys/kernel/slab
at runtime, with a fallback that reproduces the kernel’s calculation.
Second: at order 3, AF_UNIX fragmented my 32 KiB send and gave me a
smaller linear head, so MSG_PEEK saw nothing -
mapped 0/N captured transports, a console message I now see
in my nightmares. NETLINK_USERSOCK supplied the contiguous
order-3 head on this kernel. Heap exploitation is 10% courage and 90%
removing things you assumed… and yes, I said the same sentence in the
MASKerade post, and it’s still true 😔.
Now, the target. I wanted a place where 4 known bytes flip a security
decision, and I found the friendliest one in the kernel:
struct file. Every open() creates one; your
integer fd is just a handle to it. At offset 4 sits f_mode,
the kernel-internal bitmask of what this open instance may do, and the
write path checks exactly two of its bits:
/* fs/read_write.c:668-688 in the target build */
ssize_t vfs_write(struct file *file, const char __user *buf,
size_t count, loff_t *pos)
{
if (!(file->f_mode & FMODE_WRITE))
return -EBADF;
if (!(file->f_mode & FMODE_CAN_WRITE))
return -EINVAL;
...
}FMODE_WRITE is bit 1, FMODE_CAN_WRITE is
bit 18. Open /etc/pam.d/su with O_RDONLY and
you get a perfectly valid file object without those bits. If the stale
vtag write lands on f_mode and its value happens to contain
both bits, write() through the existing,
legitimate descriptor succeeds. The inode, credentials, path, ops
table - all real, all consistent, all created by a legal
open(). To be precise: the vtag replaces all 32
mode bits, not just the two I want - I merely filter for
candidates where the two required ones come out set, and the tested
regular-file path tolerated the random extras. (That’s one of the
reasons this binary isn’t automatically portable - more in the honesty
section.)
Note the subtlety: this is f_mode, not
f_flags. Editing a userspace copy of O_RDONLY
would do nothing; the exploit rewrites the live kernel object’s
capabilities.
So, when does the write land on f_mode? The stale write
hits slot * 1024 + 580 within its old slab’s bytes. Once
those pages become a filp slab, file objects tile them
every 192 bytes, with f_mode 4 bytes into each. A slot is
useful when the offsets line up over the whole filp slab:
(slot * 1024 + 580) mod filp_slab_bytes == (file_index * 192) + 4
Concrete example from the 16-vCPU machine (order-3 source slab,
order-1 filp slab = 8192 bytes): slot 0’s write lands at
byte 580, file object #3 starts at 3×192 = 576, and its
f_mode is at 580. Bullseye. And the modulo is over the
whole filp slab, not a 4 KiB page - an earlier
version of the exploit used a 4 KiB modulo and picked wrong offsets on
the second page. Topology awareness means both caches, source and
destination.
Second filter: the write value is the flow’s tag, and I need both bits set. For uniformly random 32-bit tags that’s about 1 in 4. With 240 flows, you normally get several (flow, block, slot) candidates passing both tests. Abundance through numbers 😀.
Remaining problem: the selected blocks are currently SKB heads, and
freeing them only parks them on a CPU pageset shelf - where the order-1
filp cache can’t touch them. So:
count crosses the high watermark
computed from /proc/zoneinfo. The drain starts from the
order shelf being freed and evicts oldest-first, so my victims are first
off the dock. Then re-read the counters and abort if no drain is
observed - better to fail clean than to fire the last trigger
blind.filp slots) start opening read-only
copies of /etc/pam.d/su - up to ~445,000 each. Small
topologies use one worker; the order-3 path pre-forks four, for about
1.78 million struct file objects combined
while each worker stays under the 524,288 hard
RLIMIT_NOFILE. The filp cache needs so many
new slabs that it buys the just-drained blocks from the buddy.struct file fields misread as transport fields, it kills a
disposable child, not the parent (panic_on_oops=0 required,
which is the stock setting). Released in batches of four.write(fd, "", 0). A zero-length write changes no file data,
but the VFS still validates both mode bits - a free, side-effect-free
“did this fd become writable?” detector. First descriptor that passes
writes the real payload and fsync()s it.The payload is 161 bytes at the start of
/etc/pam.d/su:
auth sufficient pam_permit.so
account sufficient pam_permit.so
session sufficient pam_permit.so
################################################################
If you’ve never met PAM: it’s the pluggable authentication framework,
and su reads its policy from exactly this file.
pam_permit.so is the module whose own
manual page warns that it’s very dangerous - it always says yes.
Three lines of it, padded with a comment wall to a newline at byte
161.
The winning worker reports its descriptor index through shared memory, and the parent simply execs:
execl("/bin/su", "su", "root", "-c", "id; exec /bin/sh -p", NULL);su is setuid root and does its normal job: consult PAM,
read /etc/pam.d/su, get three pam_permit
yeses, establish UID 0 credentials, spawn the command. The exploit never
touches current->cred, never hijacks an instruction
pointer, never asks the kernel to execute one byte of attacker code.
It’s a two-stage data-only chain:
kernel file-mode corruption -> PAM policy write -> legitimate su transition
Before the demo, the whole chain in one picture, ’cause I know that was a lot:
And the run, straight from the evidence directory (2-vCPU guest,
order-2 path, abridged to the juicy lines). Note the baseline:
su fails before, works after:
--- baseline ---
Password: su: Authentication failure
--- result ---
[+] target flow=183 buffer=9 slot=15 vtag=0x5f87bcd3
[+] target flow=184 buffer=10 slot=12 vtag=0xafcf56bb
[+] target flow=217 buffer=11 slot=0 vtag=0x588cbbce
[+] target flow=203 buffer=11 slot=11 vtag=0x5cade593
[exploit 10/12] pre-fork 22 attributed trigger tasks
[exploit 11/12] drain free order-2 blocks before victim release
[+] held 20000 order-2 pressure SKBs; MemAvailable=1189788 kB
[+] order-2 PCP bulk drain: count 7646/19739 -> 17855/19739 after 3025 buffers
[+] reclaim attributed blocks with 350000 PAM filps
[+] triggered 4/22 attributed flows
[+] PAM policy written through corrupted descriptor 156495
[exploit 12/12] execute command through passwordless su
uid=0(root) gid=0(root) groups=0(root)
0 0 4294967295
--- oops-count ---
4
--- panic-count ---
0
Read that last bit again: four process-context Oopses absorbed by
disposable trigger children, zero panics, root shell delivered. That’s
the honest price of firing a stale pointer at struct file
memory - and the reason the batching and the pre-fork isolation exist.
From an unprivileged login to uid=0, no password, no SUID
binary modified or planted, no kernel code execution. The kernel even
kept KASLR on the whole time - it just didn’t matter.
And because a root shell is better seen than told, here’s the
recorded run, from unprivileged login to uid=0:
The 20-second time bomb. My favorite bug of the
whole project isn’t the UAF, it’s what happened after my first
successful run: root shell popped… and the VM panicked roughly 20
seconds later. The crash stack ran in softirq context through
sctp_make_heartbeat_ack - the stale associations still had
their periodic heartbeat timers running, which
eventually flushed more control traffic through memory that had already
changed type. In interrupt context, my process isolation couldn’t save
anyone. The fix was one line’s worth of concept: disable periodic
heartbeats on both endpoints with SPP_HB_DISABLE right
after association creation. The demanded heartbeat
(SPP_HB_DEMAND) still works fine, and that’s all the
trigger needs. After that: shell held for 120 seconds, VM alive past
213, no delayed panic. Sometimes the difference between a PoC and a
reliable exploit is remembering to turn off the protocol’s own pacemaker
😀.
Honesty corner, ’cause it matters. The trigger is deterministic - race-free, ordered, repeatable. The full chain is calibrated, not mathematically deterministic: allocator state depends on background activity, CPU count, and history. The oracles and pressure logic shrink that uncertainty a lot (an earlier order-2 tuning window went 10/10, and the final build passed on 2, 8 and 16 vCPU guests, including consecutive 16-vCPU runs), but this is still a resource-heavy allocator exploit with environment-dependent success and residual crash risk. An Oops-tainted kernel is not a healthy kernel even when the shell works. Heap exploitation against a live system is a reliability game, not a mathematical proof - anyone telling you otherwise is selling something.
That was a lot of machinery, so here’s the whole thing mapped to the problem each piece solves:
| Problem | Technique |
|---|---|
| UAF write crashes before doing anything useful | Forged “survivable transport” in msg_msg/SKB payloads - safe values at every field the flush reads |
| Don’t know which stale pointer points where | Per-flow vtags as unique IDs + non-destructive reads
(MSG_COPY, MSG_PEEK): two oracles, “which
flows hit controlled memory” then “which exact page+slot” |
| A slab returns to the buddy only when fully free, and SLUB keeps reserves | Contiguous victim batching + windowed release (4 messages/queue);
delete 64 distant queues first to fill the min_partial
reserve |
| Other free blocks compete for your pages | ~20k ballast buffers of matching order; 32,768 pre-opened files per
worker to exhaust old filp slots |
| Freed big blocks are stuck on a PCP shelf | Compute frees needed from /proc/zoneinfo, free victims
first (oldest-first drain favors them), verify the drain, abort
otherwise |
| Different machines use different slab orders | Read cache geometry from /sys/kernel/slab at runtime;
switch buffer type (AF_UNIX vs netlink) and worker count
accordingly |
| Firing the last trigger can Oops | One disposable child per flow, batches of 4,
panic_on_oops=0 |
| You can’t choose the 32-bit value | 240 flows → 240 known values; keep only tags containing both
required bits; align the fixed +580 offset onto f_mode by
slot algebra |
| Kernel mitigations everywhere | Nothing to defeat: data-only write into kernel-allocated memory; no code execution, no user-memory tricks, no address leak (the one fixed address is an architecture constant, not KASLR-derived) |
The one-sentence summary: park a heartbeat reply so it outlives its
transport, free the transport, then walk the freed block through a
measured chain of whole-slab reuses - message slab → socket buffer →
open-file slab - so the kernel’s own “write the verification tag”
instruction lands on the permission field of a file you hold open, and
let su cash it in.
Unprivileged local user to UID 0 on a stock Ubuntu install: game over
for the box - read any file, own any process, persist. Suggested
severity High, CVSS 3.1 7.0
(AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H) - AC:H because the
full LPE depends on allocator calibration and topology-specific reuse;
the bare deterministic UAF trigger is considerably simpler, though it
still wants unprivileged SCTP with working per-socket AUTH/ASCONF
negotiation - and the full chain wants the whole requirement list from
the reachability paragraph below.
The vulnerable lifetime chain is common SCTP code, not Ubuntu-specific, and the machinery has been there since 2011. Confirmed or verified present in:
| Source | Commit / version | Result |
|---|---|---|
Ubuntu 7.0.0-28.28 |
3f1376b65587b38560ae10b8d8d97f6918b81802 |
KASAN trigger confirmed, full LPE demonstrated |
Ubuntu 7.0.0-30.30 |
d974a4063f5c03c13b4f241a9ab511750e0b9f12 |
Vulnerable paths confirmed by source comparison |
Upstream master (2026-07-31) |
6269cc6f52c68f6f5474f86e94256c81d1ff24cb |
Vulnerable paths remain present |
Ubuntu’s generic config sets CONFIG_IP_SCTP=m on amd64,
arm64, armhf, ppc64el, riscv64 and s390x, and the module autoloads on
socket creation, so “SCTP is loaded” is the default state of a stock
install the moment anyone asks. None of which means every kernel on
Earth is reachable: SCTP can be compiled out or blacklisted, and the
exploit adds its own requirements on top. The flaw itself is
not architecture-specific; my exploit is x86-64-only (the fixed
IDT alias) and topology-aware rather than version-agnostic - it
self-detects allocator geometry, but structure offsets are baked for the
tested build. Porting starts with pahole on the target’s
BTF, not with running my binary and hoping.
Reachability, to be crystal clear: UID 1001, all capability sets
zero, no user/net namespace, stock sysctls, AppArmor enabled and
unbothered. The requirements are unprivileged SCTP sockets, per-socket
AUTH/ASCONF options, NETLINK_SOCK_DIAG, SysV IPC with
MSG_COPY (CONFIG_CHECKPOINT_RESTORE), readable
/sys/kernel/slab and /proc/zoneinfo, and
enough fd/process headroom. Public-source review through 2026-07-31
found no exact duplicate of this bug - only the kernel security team and
the CNA can rule out a private report.
CWE-416 (use after free), with a side of CWE-672 (operation on a resource after expiration).
The narrow defect is the missing control-queue lifetime repair in
sctp_assoc_rm_peer(): every queued control chunk whose
transport == peer must be accounted for before the peer’s
last reference drops. The interesting question is how, ’cause
blindly NULLing those pointers deserves protocol review - a
HEARTBEAT_ACK is supposed to return to the path the heartbeat came from,
and rerouting it to the active path can violate SCTP semantics.
Reasonable policies:
The reference model is the attractive one for exactly the reason this post exists: non-owning pointers across deferred queues are how lifetime bugs are born.
Regression coverage should run the exact ordered sequence - park
HEARTBEAT_ACK for Y, DEL-IP Y over surviving Z, wait through RCU
destruction, complete the pending ASCONF, flush - under KASAN, with
lockdep and refcount diagnostics, and assert no queued chunk can
dereference Y after removal. And as defense in depth, the flush path
could treat chunk->transport->dead as a drop signal
even outside this specific queue, ’cause a valid removal path was also
“guaranteed” to repair everything here.
It feels right that this story ends where it started: with a
heartbeat. This one had everything I love about kernel work: a one-line
omission hiding in a 2018 fix’s blind spot, a protocol feature (the
parking brake) that turned a split-second lifetime gap into an
all-you-can-wait buffet, and a chain where every layer demanded its own
side quest - an IDT alias standing in for a KASLR leak, verification
tags moonlighting as object IDs, a SysV queue doubling as a heap
microscope, and a zero-length write() as the world’s
politest exploit detector. The final boss wasn’t a mitigation; it was a
heartbeat timer killing my own shell 20 seconds after I won.
Huge respect to the SCTP maintainers, ’cause the surrounding code is
genuinely careful - sctp_assoc_rm_peer() repairs half a
dozen pointer families, and that’s exactly why the one missing queue
survived since 2011. The full technical writeup with the complete
stage-by-stage details lives in the research notes, and the PoC will be
published once coordinated disclosure runs its course.
If you take one thing home: when a repair path walks one list, grep for the lists it doesn’t. Happy hunting 😀.
Have fun and happy hacking!