Dirty Pipe (CVE-2022-0847) revisited: pipe_buffer.flags and the page cache LPE
Contents
TL;DR
Who’s affected Unpatched Linux kernels 5.8 and later. NVD lists the 5.10.102, 5.15.25, and 5.16.11 series ranges as in scope
What broke In the splice() path that puts a page-cache page into a pipe buffer, the reused pipe_buffer.flags was not cleared, so PIPE_BUF_FLAG_CAN_MERGE from a previous anonymous-pipe use persisted
What to check More than uname -r — look at your distro kernel package and whether CVE-2022-0847 was backported. Check the host kernel, not the container image
Dirty Pipe (CVE-2022-0847) is a bug where a single uninitialized bit in pipe_buffer.flags lets you overwrite the page cache of a read-only file.
The affected range is Linux kernels 5.8 and later that have not been patched.
It was fixed in 2022, but the entry-point structure matches the 2026 Copy Fail, Dirty Frag, Fragnesia, and DirtyDecrypt family.
The shared behavior is to corrupt the page cache copy in RAM rather than to rewrite the on-disk file.
Among these, Dirty Pipe doesn’t go through the network or any crypto subsystem.
The splice() path that puts a page-cache reference into a pipe buffer, plus a stale flag left over inside that pipe buffer — that’s all the kernel needs to let the write through.
Found through ZIP header bytes appearing in gzip logs
Dirty Pipe was found by Max Kellermann at CM4all in their log delivery code.
The entry point was a customer report that “the tail of the daily gzip log is corrupt.” The original writeup The Dirty Pipe Vulnerability still has the investigation log.
At first it was only the gzip CRC check that failed, and the symptom didn’t reproduce consistently for months.
They suspected hardware, but other hosts started getting CRC mismatches at the same offset too.
Staring at the broken bytes, a pattern emerged: the bytes just before the gzip footer were a ZIP local file header (50 4B 03 04 ...).
CM4all’s log delivery used splice to go from the file into a pipe, then zero-copy from the pipe into an HTTP response socket.
A ZIP fragment uploaded by a different customer had somehow ended up written to the tail of the gzip log.
What was being shared wasn’t the disk — it was the page cache and the pipe buffer inside the Linux kernel.
On closer look: after splice() had put a page-cache-backed page into a pipe buffer, the old PIPE_BUF_FLAG_CAN_MERGE bit was still set on that pipe buffer.
The next write() was being “appended” onto the page on top of the page cache.
That’s how one customer’s gzip file got a ZIP fragment from another.
The bug was first seen as “corrupted logs.”
That it could be turned into a privilege escalation came later — the initial symptom was data corruption.
What broke wasn’t the read-only file, it was the pipe buffer state
Linux holds file contents in RAM as the page cache.
read(), mmap(), and execve() see those cached pages.
Normally, a user-space write must never mix into a page belonging to a read-only file.
In Dirty Pipe, after a file’s page cache was referenced from a pipe buffer, that pipe buffer was treated as “still appendable.”
The bit used for that judgment is PIPE_BUF_FLAG_CAN_MERGE.
For a regular page from an anonymous pipe, you can append to the same page after the previous write.
For a page coming from the page cache, you must not.
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};
flags was the field at fault.
When copy_page_to_iter_pipe() constructs a new pipe buffer, it’s supposed to explicitly clear it here:
buf->ops = &page_cache_pipe_buf_ops;
buf->flags = 0;
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;
Before the fix, buf->flags = 0; was missing.
A pipe is a ring buffer, so each struct pipe_buffer slot gets reused.
The PIPE_BUF_FLAG_CAN_MERGE left over from when the slot was used for an anonymous pipe page survived into the next use as a page-cache reference.
The difference between page_cache_pipe_buf_ops and anon_pipe_buf_ops
A pipe buffer uses pipe_buf_operations to switch how the page is treated.
| ops | where the page came from | expected handling |
|---|---|---|
anon_pipe_buf_ops | a temp page allocated for write() into an anonymous pipe | tail-append and merge are fine |
page_cache_pipe_buf_ops | a file’s page cache handed in via splice() | reference only, no appending |
Pages from an anonymous pipe are “someone’s work buffer for the same pipe,” so PIPE_BUF_FLAG_CAN_MERGE is set to allow tail-appends for performance.
Pages backed by the page cache are “the in-RAM copy of the file itself,” so appending to them rewrites another file’s contents.
When a slot was switched from anonymous → page cache, ops was being replaced correctly.
But the write-side code looked at flags and its CAN_MERGE bit — not at ops — to decide what to do, so the missing flags clear was enough to cause misjudgment.
A 2016 initialization miss became an attack path when the 2020 flag was added
Dirty Pipe didn’t suddenly become dangerous from a single commit.
In 2016, commit 241699cd72a8 introduced the pipe-backed iov_iter and added copy_page_to_iter_pipe().
The flags initialization was missing from that point.
But with the flags that existed at the time, this did not translate into a serious write path.
In 2020, commit f6dd975583bd moved the merge check for anonymous pipe buffers onto PIPE_BUF_FLAG_CAN_MERGE.
At that point, the old initialization miss created a state where “a page-cache-backed page can also be appended to” became a misreading.
NVD’s CPE configuration also has Linux kernel 5.8 and later as in scope for CVE-2022-0847.
flowchart TD
A["create a pipe"] --> B["anonymous pipe buffer<br/>gets CAN_MERGE"]
B --> C["drain the pipe"]
C --> D["the same pipe_buffer slot<br/>gets reused"]
D --> E["splice references a readable file's<br/>page cache"]
E --> F["flags is not cleared"]
F --> G["write lands on the page cache<br/>as an append"]
The 2026 family had network crypto stages in front of the entry point.
Copy Fail entered through AF_ALG, Dirty Frag through ESP and RxRPC, Fragnesia through ESP-in-TCP, and DirtyDecrypt through RxGK.
Dirty Pipe doesn’t go through any of those — pipe and splice() state transitions alone get the write to the page cache.
Why it was easier to use than Dirty COW
Dirty Pipe gets compared to Dirty COW (CVE-2016-5195) a lot.
Both let you “write data derived from a file you shouldn’t be able to write,” but the attack character differs.
| viewpoint | Dirty COW | Dirty Pipe |
|---|---|---|
| main mechanism | Copy-on-Write race | stale pipe buffer flag |
| timing race | yes | no |
| write target | file-backed memory | page cache |
| representative constraint | stabilizing the race | page boundary and read permission |
No race stabilization is needed; in exchange, the shape of the write is restricted.
What you can write and what you can’t
Dirty Pipe’s write primitive has four constraints.
- You must be able to read the target file.
open(O_RDONLY)has to succeed. - The write start position cannot land exactly on a page boundary. You first have to splice with at least one byte read so that the pipe buffer has
offset > 0. - The write cannot cross page boundaries. You’re limited to one 4KB page per overwrite.
- You cannot extend the file size. You can only overwrite existing bytes.
Turned around, the target is “a file readable by all users where rewriting a few bytes inside a single page is enough for the attack to succeed.”
The original writeup gave two patterns.
Overwriting a setuid-root binary.
You corrupt the page cache for /usr/bin/su or /usr/bin/sudo, replacing the instructions just after the ELF header with shell-spawning code.
The next user who execve()s that binary runs your modified code with root.
The on-disk binary is untouched, and sha256sum still matches.
Overwriting the root password hash in /etc/passwd.
You replace the root password hash field (about 13 characters) with an empty string, then su - to root with no password.
That target satisfies all of “overwriting existing bytes,” “inside a page boundary,” and “world-readable.”
Modifications on the page cache don’t get written back to disk unless the page is marked dirty.
The CM4all writeup also says things go back to normal on reboot or cache eviction.
Put the other way, the compromise stands as long as the server is running.
File hash verification still passes, so detection needs host kernel-level access auditing or kprobe-level observation.
This is the same situation as in Copy Fail and Fragnesia — the on-disk file hash is fine, but the cache the kernel reads at runtime is dirty.
Rereading in 2026 changes what you need to check
Dirty Pipe itself was fixed in 2022.
The original writeup mentions Linux 5.16.11, 5.15.25, and 5.10.102 as the fixed versions.
NVD also has the affected range as 5.8 ≤ x < 5.10.102, 5.15 ≤ x < 5.15.25, and 5.16 ≤ x < 5.16.11.
That said, for real server verification, don’t look at upstream version numbers alone.
Distributions backport fixes.
An older version string can still have the CVE fix in it, and if the kernel package has been updated but the host hasn’t been rebooted, the running kernel is still the old one.
Verification looks roughly like this:
uname -r
cat /etc/os-release
On Debian/Ubuntu, look at installed kernels and changelogs:
apt list --installed 2>/dev/null | grep linux-image
apt changelog "linux-image-$(uname -r)" | grep -i CVE-2022-0847 -A 5
On RHEL/Fedora, follow the package changelog:
rpm -q kernel
rpm -q --changelog kernel | grep -i CVE-2022-0847 -A 5
For containers, look at the host kernel rather than the distribution inside the image.
Containers don’t have their own Linux kernel.
On CI runners, shared dev servers, and Kubernetes workers — places where someone else’s code runs on the same host kernel — a local privilege escalation reaches the host directly.
Concrete check patterns:
# Docker host
docker info | grep -i "kernel version"
# Kubernetes cluster (kernels can differ per node)
kubectl get nodes -o wide
# self-hosted GitHub Actions runner (hosted runners are managed by GitHub)
uname -r
# the kernel as seen from inside a container is the host's
docker run --rm alpine uname -r
In Kubernetes, each node can have a different kernel.
You can end up with just one node running an older kernel, and the moment a pod gets scheduled there, it’s in scope for Dirty Pipe.
Use kubectl get nodes -o wide to list kernel versions across all nodes.
GitHub Actions hosted runners are on patched Ubuntu LTS managed by GitHub, so they are not in scope for CVE-2022-0847.
Self-hosted runners use the host kernel you maintain yourself.
Even if the builder runs inside a docker container, the kernel is still the host’s — same point.
What remained after the fix was the bug class
The direct fix for Dirty Pipe is small.
Initialize flags when constructing a new pipe buffer.
That one line stopped the PIPE_BUF_FLAG_CAN_MERGE carry-over on this path.
But looking at the 2026 Copy Fail family, the problem that remains is in “which subsystem do you hand a page-cache-backed page to.”
splice() and zero-copy paths reduce copies for performance.
In return, if the receiving side can’t treat the page as “this might not be my work page, it might be a file’s cache,” the same pattern of vulnerability comes back at a different entry point.
Dirty Pipe: a stale pipe buffer flag.
Copy Fail: in-place crypto in AF_ALG.
Dirty Frag and Fragnesia: sk_buff frag and ESP processing.
DirtyDecrypt: the RxRPC/RxGK decryption path.