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-91 — after 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
/etcthat 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. seccompprofiles that dropsplice/teein 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
sysctlfolklore 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.txtcmpequal to.bak;dst.txthasPIPEWRITE(writable dest)write(O_RDONLY)→errno=9(EBADF)- Crash:
./ro_write m→SIGSEGVerror 7store toPROT_READMAP_SHARED - ASan does not report the segfault
- Out of scope: setuid image rewrite,
/etc/passwd, pipeCAN_MERGEtrigger
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