HeartAttACK: An SCTP HEARTBEAT ACK Control-Queue Use-After-Free

DZONERZY

Monday, 03 August, 2026

A heartbeat that outlived its own path

HeartAttACK hero image

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.

SCTP in 90 seconds

SCTP layer cake

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:

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 association, one transport per peer address

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 bug: a repair that forgot one tray

A bug cutting wires

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 DATA tray gets repaired, the CONTROL tray does not

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.

A trigger with no race

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:

The sequence, per flow:

  1. Enable SCTP AUTH and ASCONF on both ends (per-socket, remember - no privileges).
  2. Bind server to X, client to L1, connect, accept.
  3. Add Y to the server side of every established association - in one batch, so the victim transports cluster on shared slabs. That matters later.
  4. Add Z as the keeper path, then remove X. Server traffic now originates from Y.
  5. Briefly turn ASCONF off on the client, add ::1 to the endpoint, turn ASCONF back on. ::1 is now on the endpoint but not in the association.
  6. Remove 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.
  7. Demand a heartbeat to L1 (SPP_HB_DEMAND). It arrives from Y. The client builds the HEARTBEAT_ACK pointing at Y’s transport… and the suppression parks it.
  8. Remove 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.
  9. The client frees Y’s transport. Wait 750 ms for the RCU grace period.
  10. Ask the server to make 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().
The race-free trigger, per flow

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.

What the UAF buys: a known 32-bit write

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:

struct sctp_transport, the fields that matter

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.

Heap school, kernel edition

Heap school

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:

Buddy allocator orders

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:

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.

Two caches, same slot geometry, no shared slots

Three facts to hold onto:

  1. In-cache reuse. Free an object and its slot goes back on the slab’s free list; the next allocation from that same cache tends to get a recently freed slot. With buckets in play this only ever reunites you with same-cache objects - useless for confusing a transport into a message.
  2. A slab returns to the buddy only when ALL its slots are free - and even then SLUB likes keeping a few empty slabs in reserve (min_partial). You may have to “fill the reserve” with other empty slabs before it lets yours go.
  3. Once pages are back with the buddy, any cache - or any other page consumer - can buy them. That’s the door for cross-cache reuse: a block that held 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.

The per-CPU pageset and the bulk drain

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.

The overlap oracle: which flows hit controlled memory

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.

The marker becomes an attribution

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.

Life of a block: four lives, one address range

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.

The journey of one block

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 😔.

Slot algebra: aiming at f_mode

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
Slot algebra: byte 580 becomes f_mode

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 😀.

The final shuffle: PCP, 1.78 million files, and a free detector

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:

  1. Queue up to 12,000 disposable same-order buffers, then free the selected target buffers first, then fillers, until the pageset’s 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.
  2. Pre-forked file workers (each already holding 32,768 open files to exhaust existing free 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.
  3. One trigger worker per candidate flow - forked earlier, each holding only its own association, so if the stale flush Oopses on 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.
  4. After each batch, workers scan every descriptor with 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.

Pop a root shell

Shell popped

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:

HeartAttACK full exploit chain

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:

HeartAttACK demo run

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.

Every trick, and why it exists

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.

Blast radius

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).

Killing the bug

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.

The last heartbeat

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!