How I Found a $113,337 AF_ALG Linux Local Privilege Escalation Before Copy Fail

Table of Contents

In 2025, I found an AF_ALG vulnerability in the Linux kernel that allowed an ordinary user to escalate privileges to root. This is a retrospective on how I found CVE-2025-39964 and how we developed the exploit, before Copy Fail drew wider attention to AF_ALG in 2026.

Muhammad Alifa Ramdhan discovered CVE-2025-39964 while conducting research at STAR Labs. Credit also goes to his colleague Billy Jheng Bing-Jhong, who helped complete the exploit chain. Ramdhan wrote this article for publication on IDNSEC. The vulnerability was responsibly disclosed to Linux kernel maintainers and used as a Google kernelCTF submission, earning a $113,337.00 USD reward.

AF_ALG is a Linux kernel feature that exposes an API for cryptographic encryption and decryption. A userspace program interacts with the API, and the kernel performs the requested operation.

The vulnerability occurs while AF_ALG handles input from a userspace program. By understanding how that mechanism works, an out-of-bounds access can be exploited to turn an ordinary user into root. The same vulnerability can also be used to escape a Docker container and obtain root on the host. As it turned out, the vulnerable code had been present in Linux since around 2011.

Linux Kernel Attack Surface

Readers may remember Copy Fail, disclosed in 2026 and later recognized alongside DirtyFrag for Best Privilege Escalation Bug at the 2026 Pwnie Awards. It also uses AF_ALG, but the bug in this article is different. Copy Fail is a straight-line logic flaw in the AEAD path, while CVE-2025-39964 is a race between writers sharing an AF_ALG socket. I found this race while reviewing the source around September 2025, before Copy Fail was disclosed.

I work in vulnerability research and spend a great deal of time looking for vulnerabilities in operating systems, including the Linux kernel. At the time, my goal was to use the finding for kernelCTF, a Google program that rewards researchers who can demonstrate an LPE exploit against the latest stable Linux kernel.

To develop a Linux kernel LPE exploit, a researcher first has to audit code and subsystems that form the kernel’s attack surface. For example, the well-known Dirty COW vulnerability was in the mm subsystem, while Dirty Pipe was found in fs/pipe.

While looking for the next interesting attack surface to audit, I found AF_ALG. The first thing that caught my attention in its documentation was that AF_ALG can be reached directly from unprivileged userspace through the socket API. From a kernel exploitation perspective, this is attractive because no privilege or special configuration is required to reach the kernel code. What made it even more interesting to me was that, as far as I could determine, no previous kernelCTF submission had used a vulnerability in AF_ALG as its entry point.

Interacting with AF_ALG

To interact with AF_ALG, a userspace program calls socket(2) to obtain an AF_ALG socket file descriptor and bind(2) to select an algorithm. The following selects AES-CBC through the AF_ALG API.

int tfmfd = socket(AF_ALG, SOCK_SEQPACKET, 0);
struct sockaddr_alg sa = {
      .salg_family = AF_ALG,
      .salg_type = "skcipher", /* symmetric key cipher */
      .salg_name = "cbc(aes)", /* AES in CBC mode */
};
bind(tfmfd, (struct sockaddr *)&sa, sizeof(sa));

For AES-CBC, the kernel also needs an AES key, which can be set with setsockopt().

unsigned char key[32] = {0}; /* 256 bit key */
setsockopt(tfmfd, SOL_ALG, ALG_SET_KEY, key, sizeof(key));

After setting the key, the program calls accept(), which returns another file descriptor ready for encryption or decryption operations.

int opfd = accept(tfmfd, NULL, 0);

The program performs an encryption or decryption operation by calling sendmsg() on opfd. In the message header’s control field, it can supply the IV and the requested operation, along with the bytes to process.

char cbuf[CMSG_SPACE(sizeof(__u32)) +
          CMSG_SPACE(sizeof(struct af_alg_iv) + 16)] = {0};

struct iovec iov = {
    .iov_base = buf,
    .iov_len = 0x1000,
};

struct msghdr msgh = {
    .msg_iov = &iov,
    .msg_iovlen = 1,
    .msg_control = cbuf,
    .msg_controllen = sizeof(cbuf),
};

struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msgh);
cmsg->cmsg_level = SOL_ALG;
cmsg->cmsg_type = ALG_SET_OP;
cmsg->cmsg_len = CMSG_LEN(sizeof(__u32));
*(__u32 *)CMSG_DATA(cmsg) = ALG_OP_ENCRYPT;

cmsg = CMSG_NXTHDR(&msgh, cmsg);
cmsg->cmsg_level = SOL_ALG;
cmsg->cmsg_type = ALG_SET_IV;
cmsg->cmsg_len = CMSG_LEN(sizeof(struct af_alg_iv) + 16);

struct af_alg_iv *alg_iv = (void *)CMSG_DATA(cmsg);
alg_iv->ivlen = 16;
memset(alg_iv->iv, 0x01, 16);

ssize_t n = sendmsg(opfd, &msgh, MSG_MORE);

This first sendmsg() call initializes the IV, selects encryption with ALG_OP_ENCRYPT, and supplies 0x1000 bytes for the kernel to process. The MSG_MORE flag tells the kernel that the input is not yet complete, so the program may call sendmsg() again and append more data to the previous input. When a later sendmsg() call omits MSG_MORE, the kernel stops waiting for additional input. The program can then call read(), recv(), or recvmsg() on opfd to request the encryption and obtain its result.

AF_ALG Internals

I use Linux kernel v6.12.44 as the reference for this analysis because it was the version on which I performed the bug hunting and exploit development. The source discussed below is available in crypto/af_alg.c, crypto/algif_skcipher.c, and include/crypto/if_alg.h.

Before going deeper into the source, there is one important point to understand. Calling sendmsg() on AF_ALG does not immediately perform the encryption. The kernel first collects the user-supplied data in a TX scatter-gather list. Only when the user calls recvmsg() does the kernel create a crypto request and use the previously collected data as input.

The flow can be simplified as follows: ![AF_ALG data flow from sendmsg until ciphertext is returned to userspace](/blog/2026/images/AF_ALG Linux Local Privilege Escalation Before Copy Fail-af-alg-data-flow.svg “AF_ALG data flow”)

For an skcipher algorithm, the initial sendmsg() handler is skcipher_sendmsg(). It obtains the IV size for the selected cipher and forwards the entire input-handling process to af_alg_sendmsg().

unsigned int ivsize = crypto_skcipher_ivsize(tfm);
return af_alg_sendmsg(sock, msg, size, ivsize);

This means that most of the machinery that receives user input, allocates buffers, and retains state across sendmsg() calls resides in this function:

int af_alg_sendmsg(struct socket *sock, struct msghdr *msg,
                   size_t size, unsigned int ivsize)

The msg parameter holds the user data and control messages, size is the length of the input, and ivsize is the IV size required by the selected cipher. AES-CBC uses a 16-byte IV regardless of whether the key is AES-128, AES-192, or AES-256.

At the beginning of the function, AF_ALG processes the control messages through af_alg_cmsg_send(). This is where the kernel obtains information such as ALG_OP_ENCRYPT, ALG_OP_DECRYPT, the IV, and the associated-data length for AEAD. After validating this metadata, AF_ALG begins placing the user data into buffers owned by the socket.

The Context Attached to opfd

Each opfd has a context of type struct af_alg_ctx. This context retains the data and operation state even when the program calls sendmsg() multiple times.

The object relationship can be simplified as follows: ![Relationship between opfd, alg_sock, af_alg_ctx, tsgl_list, and scatterlist](/blog/2026/images/AF_ALG Linux Local Privilege Escalation Before Copy Fail-opfd-context.svg “Object relationship on opfd”)

struct af_alg_ctx has many fields, but only a few are necessary to understand this vulnerability:

Field Purpose
tsgl_list Linked list containing the TX scatter-gather lists for user input
used Total number of input bytes currently stored by the kernel
more Indicates that the user will send more data through MSG_MORE
merge Indicates that the last page has spare space and the next input can be appended to it
init Indicates that the operation metadata has been initialized
enc Selects whether the operation is encryption or decryption

These are not local variables that disappear when sendmsg() returns. They remain in the shared context for as long as the opfd is in use. The outcome of one sendmsg() call therefore affects how the next call is handled.

How the Input Is Stored

The user data is not stored in one large contiguous buffer. AF_ALG divides it across memory pages, with each portion represented by a struct scatterlist.

For example, 10 KB of input on a system with 4 KB pages can be stored as follows: ![A 10 KB input split into three memory pages and scatterlist entries](/blog/2026/images/AF_ALG Linux Local Privilege Escalation Before Copy Fail-input-pages.svg “Splitting input across memory pages”)

Each scatterlist entry records the page, the starting offset within that page, and the data length. The kernel does not need a physically contiguous 10 KB region because the Crypto API can consume the list of locations.

The scatterlist entries are grouped in struct af_alg_tsgl objects:

struct af_alg_tsgl {
        struct list_head list;
        unsigned int cur;
        struct scatterlist sg[];
};

The sg field is the scatterlist array, while cur records how many entries are populated. If cur is 3, the valid entries are sg[0], sg[1], and sg[2]. The last scatterlist currently in use is therefore obtained with:

sg = sgl->sg + sgl->cur - 1;

Under normal conditions, with cur = 3, this expression returns sg[2]. This simple calculation becomes an important part of the vulnerability.

A single struct af_alg_tsgl can hold only a limited number of entries, determined by MAX_SGL_ENTS. When the final object’s array is full, af_alg_alloc_tsgl() allocates a new af_alg_tsgl, initializes its cur to 0, and appends it to ctx->tsgl_list.

The data retained by the context therefore looks roughly like this:

![A tsgl_list containing multiple af_alg_tsgl objects and scatterlist entries](/blog/2026/images/AF_ALG Linux Local Privilege Escalation Before Copy Fail-tsgl-list.svg “The tsgl_list structure”)

Processing Inside af_alg_sendmsg()

After processing the control messages, af_alg_sendmsg() loops while input remains to be copied from the user. There are two broad paths.

If the last page is full, or there is no page available, the kernel allocates a new page. It then copies data with memcpy_from_msg(), initializes the scatterlist entry, increments sgl->cur, and adds the number of successfully copied bytes to ctx->used.

If the last page is not full, AF_ALG does not need to allocate a new one immediately. The remaining space can hold data from the next sendmsg() call. This condition is recorded in ctx->merge.

Suppose the user sends only 2 KB into a page with a 4 KB capacity:

Contents of the last page Size
Data already copied 2048 bytes
Remaining space 2048 bytes
Total PAGE_SIZE 4096 bytes

Because space remains, ctx->merge becomes true. The next sendmsg() call enters this branch:

if (ctx->merge) {
        sgl = list_entry(ctx->tsgl_list.prev,
                         struct af_alg_tsgl, list);
        sg = sgl->sg + sgl->cur - 1;
        /* append data into the last page */
}

In this context, ctx->merge = true means that the next data can be appended to the final scatterlist entry.

After the copy, AF_ALG records whether the call used MSG_MORE in ctx->more. A true value means the user has not finished the input for this operation. A later sendmsg() call can continue the same input without resending all of the metadata.

The normal af_alg_sendmsg() flow can be summarized as follows:

![Normal af_alg_sendmsg flow when merge is active or a new scatterlist entry is required](/blog/2026/images/AF_ALG Linux Local Privilege Escalation Before Copy Fail-sendmsg-flow.svg “Normal af_alg_sendmsg flow”)

This is how AF_ALG accepts input across several calls without allocating a fresh page for every small addition.

The merge branch assumes that the last af_alg_tsgl has at least one entry to append to. I wrote the condition as follows.

ctx->merge == true  =>  last_sgl->cur > 0

If this condition holds, sgl->cur - 1 is safe because cur cannot be zero. I wanted to test whether it still held when two threads called sendmsg() on the same opfd.

When Two Threads Call sendmsg() Concurrently

af_alg_sendmsg() calls lock_sock() before modifying the context. At first glance, this appears to serialize multiple sendmsg() calls. When the send buffer is full, however, the function can enter af_alg_wait_for_wmem() and wait for space to become available.

Inside af_alg_wait_for_wmem(), the wait is performed through the sk_wait_event() macro:

if (sk_wait_event(sk, &timeout, af_alg_writable(sk), &wait)) {
        err = 0;
        break;
}

The socket unlock and relock are not directly visible when reading only that function. They live inside the implementation of sk_wait_event(). Reduced to the relevant operations, the macro performs:

release_sock(__sk);
/* wait_woken() while the condition is false */
lock_sock(__sk);
/* evaluate the condition again */

While a thread sleeps waiting for the send buffer to become writable, the socket lock is deliberately released. This is necessary so another operation can consume data and free space. When the thread wakes, it reacquires the lock before af_alg_sendmsg() continues.

As a result, two threads can both have unfinished sendmsg() calls on the same opfd. They do not modify the context at exactly the same time because the socket lock still protects it. The second thread can nevertheless wake up to a different context state from the one it saw before waiting.

I used the following initial condition:

last_sgl->cur = MAX_SGL_ENTS - 1
ctx->merge    = false
send buffer   = full

Two threads then call sendmsg() and both block in af_alg_wait_for_wmem(). After some buffered data is released, the first thread wakes. The final af_alg_tsgl still has one free entry, so this thread uses it. I make the copied input shorter than PAGE_SIZE, leaving the final page partially empty and setting ctx->merge to true.

In the following diagram, tail.cur denotes the cur field of the final af_alg_tsgl. The field is not stored directly in af_alg_ctx, and the final object is reached through the last entry in ctx->tsgl_list.

The two-thread interleaving looks like this:

![Two AF_ALG writers break the invariant between ctx merge and tail cur](/blog/2026/images/AF_ALG Linux Local Privilege Escalation Before Copy Fail-concurrent-writers.svg “Two writers and the changing af_alg_ctx state”)

After Thread A finishes, but before Thread B continues, the state is:

last_sgl->cur = MAX_SGL_ENTS
ctx->merge    = true

The second thread is still on the wait path. Once buffer space becomes available, it resumes after the writable-memory check. Because the final af_alg_tsgl is now full, af_alg_alloc_tsgl() allocates a new object and appends it to tsgl_list.

The new object begins with:

new_last_sgl->cur = 0

For the second thread, I provide an invalid userspace address so that memcpy_from_msg() fails. The newly allocated page is released and the function exits through its error path. The new af_alg_tsgl nevertheless remains the final list entry, while the ctx->merge value created by the first thread remains true.

For the first time, the context reaches this state:

ctx->merge       = true
last_sgl->cur    = 0

This is the state that previously appeared impossible.

On the next sendmsg() call, ctx->merge makes the kernel retrieve the final scatterlist entry:

sg = sgl->sg + sgl->cur - 1;

Because sgl->cur is zero, the calculation becomes:

sg = sgl->sg + 0 - 1
   = &sgl->sg[-1]

The pointer no longer refers to an element in the sg[] array. It points to memory immediately before the array. When memcpy_from_msg() uses fields from this fake scatterlist, it produces the out-of-bounds access at the root of CVE-2025-39964.

What I find interesting is that the line performing sgl->cur - 1 is correct as long as the earlier invariant always holds. The bug is not simply a missing check on one user-controlled input. It exists because the interleaving of two threads and an error path allows shared state to enter a combination the code never expected.

At this point, I could produce a KASAN crash:

BUG: KASAN: slab-out-of-bounds in af_alg_sendmsg
Read of size 8 by task exploit

A crash does not automatically mean that the vulnerability can provide root access. The next questions were: what memory lies immediately before sg[], how much of it can be controlled, and can this out-of-bounds access be converted into a useful exploitation primitive?

From Out-of-Bounds Access to Arbitrary Write

After obtaining the KASAN crash, the first thing I needed to understand was the position of sg[-1] relative to the object in use. On the kernel and configuration I exploited, the two structures were laid out as follows:

struct af_alg_tsgl
    offset 0x00: list_head       (16 bytes)
    offset 0x10: cur             (4 bytes)
    offset 0x14: padding         (4 bytes)
    offset 0x18: sg[0]

sizeof(struct scatterlist) = 0x20

Because sg[0] starts at offset 0x18, the position of sg[-1] is:

offset sg[0] - sizeof(struct scatterlist)
= 0x18 - 0x20
= -0x8

In other words, the fake scatterlist begins eight bytes before the af_alg_tsgl object.

Each af_alg_tsgl is allocated as a 4096-byte object. If the allocator places another object immediately before the vulnerable af_alg_tsgl, sg[-1].page_link reads the final eight bytes of that preceding object, at offset 0xff8.

Position Interpreted as
Previous object + 0xff8 sg[-1].page_link
First eight bytes of af_alg_tsgl.list.next sg[-1].offset and sg[-1].length
af_alg_tsgl object + 0x18 The real sg[0]

This is where the out-of-bounds access begins to become useful. I heap-spray allocations of the same size and fill the end of the preceding object with controlled data. The goal is to make the eight bytes at offset 0xff8 become the value that the kernel later reads as sg[-1].page_link.

The page_link field in struct scatterlist is used to obtain a struct page. The vulnerable path then computes the copy destination approximately as follows:

dest = page_address(sg_page(sg)) + sg->offset + sg->length;
memcpy_from_msg(dest, msg, len);

Controlling sg[-1].page_link lets us influence the page returned by sg_page(sg). But sg[-1].offset and sg[-1].length are read from the list.next pointer at the beginning of the current af_alg_tsgl. They reflect a linked-list address rather than values we supplied in the sprayed page_link. Those fields also contribute to the destination, so setting page_link alone does not tell us the exact address that receives the copy.

Conceptually, the exploit progresses as follows:

![Exploitation stages from a negative sg index to overwriting core_pattern and executing as root](/blog/2026/images/AF_ALG Linux Local Privilege Escalation Before Copy Fail-exploit-progression.svg “CVE-2025-39964 exploit progression”)

In practice, turning control over page_link into an arbitrary write is not as simple as filling it with the address of core_pattern. The kernel uses struct page and vmemmap addresses, while KASLR prevents an unprivileged user from knowing those addresses directly.

The Usercopy Property Behind memcpy_from_msg()

Credit for this usercopy trick and for finishing the exploit chain goes to my coworker Bing-Jhong Billy Jheng. He noticed that the memcpy_from_msg() path gave us a way to probe the computed destination even though the linked-list pointer contributes to sg[-1].offset and sg[-1].length.

At first, trying many possible page_link values sounds dangerous. If the computed address is invalid and the kernel uses an ordinary memcpy(), one incorrect guess could cause a kernel page fault and crash the system. Such an oracle would not be very useful because every failed attempt would require a kernel restart.

This vulnerable path does not use an ordinary memcpy(). It writes through memcpy_from_msg():

err = memcpy_from_msg(page_address(sg_page(sg)) +
                      sg->offset + sg->length,
                      msg, len);

The implementation of memcpy_from_msg() forwards the copy to copy_from_iter_full():

static inline int memcpy_from_msg(void *data, struct msghdr *msg, int len)
{
        return copy_from_iter_full(data, len, &msg->msg_iter) ? 0 : -EFAULT;
}

Because the data in msg_iter originates in userspace, this path eventually reaches copy_from_user_iter(), which calls raw_copy_from_user() on Linux v6.12.44:

if (access_ok(iter_from, len)) {
        to += progress;
        res = raw_copy_from_user(to, iter_from, len);
}

access_ok() checks the userspace source address. It does not check the destination to, which in this case comes from sg[-1].

On the x86-64 target I used, raw_copy_from_user() uses a routine with exception handling for faults at either end of the copy. It also permits SMAP access during usercopy. When to points to a writable userspace page, the copy succeeds. When it points to an unmapped address, the exception handler returns the number of bytes left uncopied instead of causing a kernel oops.

memcpy_from_msg() converts an incomplete copy into -EFAULT. From userspace, the result provides an oracle.

Computed destination Copy result sendmsg() result Kernel state
Falls on a mapped userspace page Succeeds Returns the number of bytes sent Keeps running
Falls on an unmapped address Usercopy catches the fault Fails with EFAULT Keeps running

We can therefore keep testing guesses without crashing the kernel on each miss. The PoC copies one byte. sendmsg() returns 1 for a valid destination, or -1 with errno == EFAULT for an invalid one.

We then map a large virtual address range in userspace and vary page_link in a controlled way. The oracle tells us whether the resulting copy lands on a mapped page without crashing on misses. Narrowing down that mapped destination reveals the page, and the observed copy identifies its offset despite the contribution from list.next. With that position known, the exploit accounts for the within-page offset and shifts page_link toward the kernel page containing core_pattern.

The full kernelCTF exploit write-up for CVE-2025-39964 covers the address mapping, oracle-based search, mincore() checks, and the calculation needed to reach the target page.

For this article, the most important point is the relationship between the bug and the resulting primitive:

sg[-1] makes the kernel read page_link from a controllable heap object. That value is then used to compute the destination passed to memcpy_from_msg(), converting an out-of-bounds metadata read into a kernel write primitive.

Why Target core_pattern?

core_pattern controls how the kernel handles a core dump. If its value begins with a pipe character (|), the kernel executes the program specified after it and sends the core dump to that program.

Using the arbitrary write, the exploit replaces core_pattern with a command that points back to the exploit binary. It then deliberately crashes a child process. The kernel executes that binary as the core-dump handler with root privileges.

In the kernelCTF environment I targeted, this primitive provided more than a local privilege escalation from an ordinary user to root. Because AF_ALG can be reached from inside a container and the exploited kernel belongs to the host, the same path can escape a Docker container and obtain root on the host.

I chose to explain the exploit to this depth because readers still need to understand why the crash is exploitable. Details such as each spray size, the allocator grooming order, address-range values, and binary-search calculations would bury the discovery story in implementation detail. Readers who want to reproduce the exploit can follow those parts more directly in exploit.md and the PoC source.

How the Bug Was Fixed

The vulnerable skcipher code can be traced back to commit 8ff590903d5f, crypto: algif_skcipher - User-space interface for skcipher operations, which shipped with Linux 2.6.38 in 2011. Affected kernels remained vulnerable until the fix in 2025, subject to vendor backports and configuration. The bug had been in this code for about 14 years when I found it.

The main problem was not merely the absence of a cur == 0 check. That condition was a consequence of two writers sharing one af_alg_ctx and resuming their operations after the context state changed along the wait path.

The upstream patch therefore fixes the problem by giving a writer exclusive ownership. While one sendmsg() is in progress, another writer on the same socket receives -EBUSY:

lock_sock(sk);
if (ctx->write) {
        release_sock(sk);
        return -EBUSY;
}
ctx->write = true;

Before the function returns, it sets ctx->write back to false. The socket lock may still be released while waiting for memory, but another thread can no longer begin a second write against the same context.

I think this fix also explains the root cause very clearly. The old implementation implicitly assumed that only one writer was building the input. The patch turns that assumption into a rule the kernel actually enforces. The complete change is available in commit crypto: af_alg - Disallow concurrent writes in af_alg_sendmsg.

The following demo runs the local privilege escalation exploit in the kernelCTF environment. The exploit triggers the AF_ALG race condition, constructs an arbitrary kernel write, and obtains root access from an unprivileged user.

![CVE-2025-39964 local privilege escalation demo on kernelCTF](/blog/2026/images/AF_ALG Linux Local Privilege Escalation Before Copy Fail-kernelctf-lpe-demo.gif “kernelCTF LPE exploit demo”)

The kernelCTF submission earned a reward of $113,337.00 USD after the vulnerability’s impact was demonstrated with a working LPE exploit against the kernelCTF target.

Closing: Finding a Vulnerability and Building an Exploit

The finished exploit can make the work look straightforward. My notes tell a messier story. I spent time understanding the code and testing the race before I had a useful crash, then spent much longer turning that crash into a reliable exploit.

The research began long before there was a KASAN crash. I had to choose an attack surface reachable by an unprivileged user, understand how AF_ALG stored its input, and build a model of the state that should always remain valid. From there, the first question was not “how do I get root?” but “does merge really always mean that a final SG entry exists?”

The KASAN crash gave me a starting point for the exploit. Triggering the race consistently, laying out heap objects, and controlling page_link took further work. Billy’s observation about the usercopy path gave us an oracle: a bad destination could return EFAULT while leaving the kernel running. We used it to locate the copy before directing the write toward core_pattern.

Both parts mattered. The source review exposed an assumption about the shared state. The exploit work showed how breaking that assumption could lead to a reproducible root compromise. I wanted to share the steps between those two points, because the path from a suspicious line of code to a working exploit is where much of the research happens.