This is a pipe-buffer invariant lab, not a privilege-escalation write-up. Target is a temp directory I create under /tmp/dpipe_lab/, a file I own and chmod 444, and a 40-line C program that copies that file through a pipe with splice. Goal: record uname -r, prove write() on the read-only fd is EBADF, crash a PROT_READ map with SIGSEGV, and show the file bytes are unchanged after splice-then-write on this patched kernel. I do not overwrite a setuid binary, I do not touch /etc/passwd, I do not paste the pipe-buffer-flag sequence that made CVE-2022-0847 reliable.

1Figure 1. splice moves a page reference into a pipe. A write must not mutate a page the file still owns.
2O_RDONLY fd  --splice-->  pipe buffer  --write-->  must stay private
3chmod 444 lab file we own; page-cache write-through is the class, not the lab

Lab layout

1labs/dpipe_lab/
2  src.txt           # created here, owned by labuser, mode 444
3  dst.txt           # splice destination we also own
4  splice_lab.c      # copy through a pipe
5  ro_write.c        # EBADF + SIGSEGV
1mkdir -p /tmp/dpipe_lab
2printf 'LABFILE-AAAA-do-not-exfil\n' > /tmp/dpipe_lab/src.txt
3chmod 444 /tmp/dpipe_lab/src.txt
4cp /tmp/dpipe_lab/src.txt /tmp/dpipe_lab/src.txt.bak
5ls -l /tmp/dpipe_lab/src.txt
6# -r--r--r-- 1 labuser labuser 27 Mar  3 10:31 /tmp/dpipe_lab/src.txt

The file is mine. Mode 444 is so a later write(fd) has to fail the way a read-only open should. I do not pick a file I cannot unlink.

Kernel version string

 1$ uname -a
 2Linux labvm 5.15.0-91-generic #101-Ubuntu SMP x86_64 GNU/Linux
 3
 4$ cat /proc/version
 5Linux version 5.15.0-91-generic (buildd@lab) (gcc 11.4.0, GNU ld 2.38)
 6  #101-Ubuntu SMP [REDACTED]
 7
 8$ awk '{print}' /etc/os-release | head -3
 9PRETTY_NAME="Ubuntu 22.04.3 LTS"
10NAME="Ubuntu"
11VERSION="22.04.3 LTS (Jammy Jellyfish)"

CVE-2022-0847 (“Dirty Pipe”) is a pipe-buffer flag bug: a recycled buffer could be left mergeable, so a later write into the pipe overwrote bytes of a page that was still the page-cache page of a file opened O_RDONLY. Fixed ranges I keep on the ticket (stable, not exhaustive): 5.16.11, 5.15.25, 5.10.102, and vendor kernels that backported the same flag-clear. This lab kernel is 5.15.0-91after the Ubuntu backport. The rest of the notebook is the negative test plus a crash on a read-only map.

1$ grep -n 0847 /usr/share/doc/linux-image-5.15.0-91-generic/changelog.Debian.gz
2# (zcat | grep)  CVE-2022-0847  pipe: fix ... CAN_MERGE  [REDACTED line]

If that grep is empty on a host, I file “changelog does not mention 0847” and check uname -r against the vendor CVE table. I do not then “confirm” by aiming splice at /usr/bin/su.

Lab binary: splice through a pipe

 1/* splice_lab.c — copy a file we own through a pipe; no privs */
 2#define _GNU_SOURCE
 3#include <errno.h>
 4#include <fcntl.h>
 5#include <stdio.h>
 6#include <string.h>
 7#include <unistd.h>
 8
 9int main(void)
10{
11    int src = open("/tmp/dpipe_lab/src.txt", O_RDONLY);
12    int dst = open("/tmp/dpipe_lab/dst.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
13    int p[2];
14    ssize_t n, m;
15    char extra[] = "PIPEWRITE";
16
17    if (src < 0 || dst < 0) {
18        perror("open");
19        return 1;
20    }
21    if (pipe(p) != 0) {
22        perror("pipe");
23        return 1;
24    }
25
26    n = splice(src, NULL, p[1], NULL, 64, 0);
27    fprintf(stderr, "splice src->pipe = %zd errno=%d\n", n, n < 0 ? errno : 0);
28
29    /* extra bytes go into the pipe, not into src.txt on a patched kernel */
30    m = write(p[1], extra, strlen(extra));
31    fprintf(stderr, "write pipe = %zd\n", m);
32
33    n = splice(p[0], NULL, dst, NULL, 64, 0);
34    fprintf(stderr, "splice pipe->dst = %zd\n", n);
35
36    close(src);
37    close(dst);
38    close(p[0]);
39    close(p[1]);
40    return 0;
41}
1cc -O0 -g -o splice_lab splice_lab.c
2./splice_lab
3# splice src->pipe = 27 errno=0
4# write pipe = 9
5# splice pipe->dst = 36
 1$ xxd /tmp/dpipe_lab/src.txt
 200000000: 4c41 4246 494c 452d 4141 4141 2d64 6f2d  LABFILE-AAAA-do-
 300000010: 6e6f 742d 6578 6669 6c0a                 not-exfil.
 4
 5$ xxd /tmp/dpipe_lab/dst.txt
 600000000: 4c41 4246 494c 452d 4141 4141 2d64 6f2d  LABFILE-AAAA-do-
 700000010: 6e6f 742d 6578 6669 6c0a 5049 5045 5752  not-exfil.PIPEWR
 800000020: 4954 45                                  ITE
 9
10$ cmp /tmp/dpipe_lab/src.txt /tmp/dpipe_lab/src.txt.bak && echo SRC_UNCHANGED
11SRC_UNCHANGED

dst.txt grew by PIPEWRITE because that is the destination fd I opened writable. src.txt did not. That is the invariant this kernel keeps. The historical class was: a stale merge flag on the pipe buffer made write(p[1]) land in the source page. I do not reconstruct that flag state here.

strace of the same run (syscalls only):

1$ strace -e splice,write,pipe,openat ./splice_lab
2openat(AT_FDCWD, "/tmp/dpipe_lab/src.txt", O_RDONLY) = 3
3openat(AT_FDCWD, "/tmp/dpipe_lab/dst.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644) = 4
4pipe([5, 6]) = 0
5splice(3, NULL, 6, NULL, 64, 0) = 27
6write(6, "PIPEWRITE", 9) = 9
7splice(5, NULL, 4, NULL, 64, 0) = 36

splice is not a bug. Zero-copy from a file you can read into a pipe you created is the ABI. The bug was which page a later write was allowed to merge into.

Crash: write on O_RDONLY, then PROT_READ poke

Userspace is not allowed to ignore O_RDONLY. I keep a second binary so the ticket has a crash, not only a cmp.

 1/* ro_write.c — expect EBADF, then SIGSEGV on a read-only map */
 2#include <errno.h>
 3#include <fcntl.h>
 4#include <stdio.h>
 5#include <sys/mman.h>
 6#include <unistd.h>
 7
 8int main(int argc, char **argv)
 9{
10    int fd = open("/tmp/dpipe_lab/src.txt", O_RDONLY);
11    ssize_t w;
12    char *p;
13
14    if (fd < 0) {
15        perror("open");
16        return 1;
17    }
18    w = write(fd, "X", 1);
19    fprintf(stderr, "write(O_RDONLY)=%zd errno=%d\n", w, w < 0 ? errno : 0);
20
21    if (argc > 1 && argv[1][0] == 'm') {
22        p = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0);
23        if (p == MAP_FAILED) {
24            perror("mmap");
25            return 1;
26        }
27        p[0] = 'X';   /* must SIGSEGV: shared + PROT_READ */
28    }
29    return 0;
30}
 1$ cc -O0 -g -o ro_write ro_write.c
 2$ ./ro_write
 3write(O_RDONLY)=-1 errno=9
 4# 9 = EBADF on this glibc; write(2) on a read-only fd
 5
 6$ ./ro_write m
 7write(O_RDONLY)=-1 errno=9
 8Segmentation fault (core dumped)
 9
10$ gdb -q ./ro_write core
11(gdb) bt
12#0  0x00005555555551c8 in main (argc=2, argv=0x...) at ro_write.c:24
13(gdb) info registers rip
14rip            0x5555555551c8   0x5555555551c8 <main+...>
15(gdb) x/i $rip
16=> 0x5555555551c8 <main+...>:  movb   $0x58,(%rax)   ; 'X' into PROT_READ page
1$ dmesg | tail -3
2[  412.010] ro_write[4120]: segfault at 7f[REDACTED] ip 5555555551c8 sp 7ff[REDACTED] error 7 in ro_write[555555554000+1000]
3[  412.011] Code: ...
4# error 7 = user write to a present page that is not writable

Tombstone: input ./ro_write m, error 7, pc in main at the store to the mmap. That is the sanitized reproduction for “I tried to mutate a read-only view of a file I own”. Dirty Pipe was the kernel doing the store for me via a pipe buffer. On this kernel it does not.

ASan on ro_write does not classify the fault as a heap bug. The page is a file map. I still build it once so the next audit does not expect ASan to “see” kernel classes:

1cc -O0 -fsanitize=address -g -o ro_asan ro_write.c
1$ ./ro_asan m
2write(O_RDONLY)=-1 errno=9
3Segmentation fault (core dumped)
4# ASan silent — not a poisoned heap slot; it is a protection fault

What the class actually broke (without a trigger)

Pipe buffers carry a page, offset, length, and flags. splice from a file can put a page-cache page into that ring. write into the pipe is allowed to append into a buffer marked mergeable if that buffer is exclusively owned for writing. The 2022 bug left a merge flag set on a buffer whose page was still shared with a read-only file. Invariant:

1pipe_buffer.page is file-backed and not exclusively owned
2    => CAN_MERGE must be clear
3    => write() allocates a new page, does not edit the file's

I do not include the fill-the-pipe / drain / splice-one-byte dance that forced that flag state. Public write-ups already did; this notebook is the negative test plus the userspace crash.

Threat model I still write on the ticket, as inventory not as a recipe: unprivileged local user, ability to pipe+splice, ability to open a sensitive file O_RDONLY. Containers share the host kernel. A patched userspace binary does not fix an unpatched kernel.

Detection / hardening

1$ uname -r
25.15.0-91-generic
3# compare to vendor fixed package; reboot into it; do not trust livepatch notes blindly
  • File integrity on setuid and on /etc that an unprivileged user can open for read: hashes, aide/debsums, not as a Dirty Pipe detector — as the integrity the class threatened. I inventory; I do not demonstrate the write.
  • seccomp profiles that drop splice/tee in sandboxes that do not need them. Defense in depth. Not a patch substitute.
  • Multi-tenant login nodes and CI runners first: they have untrusted local users on a shared kernel.
  • Do not “mitigate” by sysctl folklore that disables pipes. There isn’t a safe one. Patch and reboot.
1# exposure grep I actually run
2uname -r
3cat /proc/version
4# package changelog / CVE tracker for 2022-0847 on this branch

What I file after this lab

  • Kernel: Linux labvm 5.15.0-91-generic — post-fix for 0847 on this distro
  • splice_lab: src.txt cmp equal to .bak; dst.txt has PIPEWRITE (writable dest)
  • write(O_RDONLY)errno=9 (EBADF)
  • Crash: ./ro_write mSIGSEGV error 7 store to PROT_READ MAP_SHARED
  • ASan does not report the segfault
  • Out of scope: setuid image rewrite, /etc/passwd, pipe CAN_MERGE trigger

Commands appendix

1uname -a
2printf 'LABFILE-AAAA-do-not-exfil\n' > /tmp/dpipe_lab/src.txt
3chmod 444 /tmp/dpipe_lab/src.txt
4cc -O0 -g -o splice_lab splice_lab.c && ./splice_lab
5cmp /tmp/dpipe_lab/src.txt /tmp/dpipe_lab/src.txt.bak
6cc -O0 -g -o ro_write ro_write.c && ./ro_write m   # expect SIGSEGV