Tools: How I Analyzed the Linux Kernel's Deadliest Logic Bug: A Deep Dive into Dirty Pipe (CVE-2022-0847) (2026)

Tools: How I Analyzed the Linux Kernel's Deadliest Logic Bug: A Deep Dive into Dirty Pipe (CVE-2022-0847) (2026)

The Conceptual Backstory: Page Cache, Pipes, and splice()

1. The Page Cache: RAM as a Disk Mirror

2. The Pipe Buffer

3. The splice() Syscall: Zero-Copy Magic

Digging Into the Code: The Bug in lib/iov_iter.c

The Intersection of Two Commits

1. Commit 241699cd72a8 — October 2016

2. Commit f6dd975583bd — May 2020

Step-by-Step: How the Exploit Mechanics Worked

Stage 1: Polluting the Pipe Buffers

Stage 2: Draining the Pipe

Stage 3: Splicing File Data into the Pipe

Stage 4: Writing into the Pipe

Why Dirty Pipe Was So Dangerous

No Race Condition

No Classic Memory Corruption

High Reliability

Page Cache Impact

Dirty Pipe vs Dirty COW

The Upstream Fix

Key Developer Takeaways

1. Always Initialize Reused Structures

2. Flags Are Security Boundaries

3. Subsystem Interactions Matter

4. Logic Bugs Can Be More Reliable Than Memory Corruption

5. Defensive Coding Is Not Optional in Systems Programming

Exploit Discussion: Why I Will Not Weaponize It Here

Safe Validation: How to Check Exposure Without Exploiting the Machine

Mitigation: The Real Fix Is a Kernel Update

Reducing the Attack Surface

Containers: Do Not Forget the Host Kernel

Monitoring Sensitive Files

My Practical Takeaway for Security Engineers

Final Thoughts

References As developers, we often think of kernel exploits as highly complex assembly-level wizardry, heap grooming, or race-condition battles. But recently, I decided to sit down, pull up the Linux kernel source code, and trace the infamous Dirty Pipe vulnerability, CVE-2022-0847, line by line. What I found was mind-blowing: a simple, uninitialized struct member in the core memory-management path allowed an unprivileged local user to write into read-only files through the Page Cache. No race conditions.

No classic memory corruption.No heap spraying.

Just one stale flag in a reused kernel structure. This is my technical post-mortem and step-by-step code analysis of how this elegant logic bug worked. Before looking at the buggy code, we need to understand the three Linux kernel mechanisms that collided to create Dirty Pipe: To avoid slow disk reads, Linux keeps recently accessed file data in memory. This memory-backed representation is called the Page Cache. When multiple processes read the same file, for example /etc/passwd, the kernel does not necessarily load separate copies for every process. Instead, it can map those processes to the same physical memory page that represents the file's cached content. Normally, if a process tries to write to a page without write permission, the kernel's Copy-on-Write mechanism protects the original data: That is the expected contract. Dirty Pipe broke that contract. In Linux, a pipe is implemented as a circular ring of buffers represented internally by struct pipe_inode_info. Each slot in that ring is a struct pipe_buffer, defined in include/linux/pipe_fs_i.h: The important field is: When data is written to a pipe, the kernel may allocate page-sized buffers, usually 4 KB. If the write does not fill the whole page, the kernel can mark that buffer as mergeable by setting: That flag tells the kernel: New writes may be appended into the remaining space of this existing pipe buffer instead of allocating a new one. That behavior is perfectly valid for normal anonymous pipe pages. The problem appears when a pipe buffer stops pointing to a normal anonymous pipe page and starts pointing to a page from the Page Cache. The splice() system call is a Linux performance optimization. It moves data between file descriptors and pipes without copying data back and forth through user space. Instead of doing this: splice() can do something closer to this: That is powerful because it avoids unnecessary copying. But it also means a pipe buffer can reference a page that belongs to the Page Cache of a file. Internally, one of the relevant functions is: This function creates a pipe buffer that references the page containing file data. That is where the bug lived. When splice() is used to map file data into a pipe, the kernel executes code similar to this vulnerable version of copy_page_to_iter_pipe(): The missing line is the entire bug: buf->flags was never initialized or cleared. Because pipes are implemented as circular rings, the kernel reuses old pipe_buffer structures. If a previous operation left PIPE_BUF_FLAG_CAN_MERGE set, that stale flag could remain active when the same buffer slot was reused for Page Cache-backed file data. That means a buffer referencing a read-only file page could accidentally still look mergeable. That is the core of Dirty Pipe. One thing I found especially interesting is that Dirty Pipe was not born from one obviously dangerous commit. It came from the interaction of two separate changes: This introduced the new pipe-backed iov_iter subsystem and added copy_page_to_iter_pipe(). The function did not initialize buf->flags. At that time, this was not immediately exploitable because the dangerous merge flag did not exist yet. This added PIPE_BUF_FLAG_CAN_MERGE. Suddenly, an old uninitialized field became security-critical. That is the scary engineering lesson: A harmless-looking initialization bug can become a critical vulnerability years later when another subsystem evolves. At a high level, the exploit forced the kernel into a bad state: The result: the in-memory cached representation of a read-only file is modified. The disk file itself is not directly overwritten. The modification happens in the Page Cache. The first step is to fill the pipe. This causes the kernel to allocate pipe buffers and mark them mergeable. A simplified version looks like this: After this stage, the internal pipe buffer slots have been used and may contain PIPE_BUF_FLAG_CAN_MERGE. Next, the pipe is drained: Now the pipe is logically empty. But the kernel's internal pipe_buffer metadata is still there, ready to be reused. The stale flags may still exist in those reused slots. Then splice() is used to move data from a target file into the pipe without copying it through user space: Behind the scenes, the kernel creates a pipe buffer that references the file's Page Cache page. But because buf->flags was not cleared, the buffer may still have the old merge flag. Now we have a dangerous state: That should never happen. A subsequent write to the pipe is then treated as mergeable. The kernel thinks it is appending data into a normal anonymous pipe page. In reality, the buffer points to a file-backed Page Cache page. So the write lands inside the cached file page. That is why Dirty Pipe could modify the in-memory contents of files that the attacker should not have been able to write. Dirty Pipe was terrifying because it was not a fragile exploit. Dirty COW, CVE-2016-5195, depended on winning a race condition. Dirty Pipe did not. There was no timing window to win. This was not a buffer overflow or heap corruption bug. The kernel was following its own logic, but that logic was operating on stale state. Once the vulnerable state was created, the behavior was deterministic. The modification happened in memory through the Page Cache. That means the on-disk file might remain unchanged, but programs reading the file could observe the modified cached version. Dirty Pipe and Dirty COW are often compared because both involve unexpected writes related to file-backed memory. But the exploit style is very different. Dirty Pipe is a great reminder that not all dangerous vulnerabilities look like obvious memory corruption. Sometimes the bug is just one field that was not reset. The fix was surprisingly small. In the patched version, the kernel explicitly clears the flags when creating a new pipe buffer: A huge security impact. Analyzing Dirty Pipe gave me a stronger appreciation for defensive engineering in low-level systems. If a structure is reused, every stateful field should be explicitly initialized. Relying on previous state is dangerous. In kernel code, stale state is not just a bug. It can become a privilege escalation. A single bit can completely change how the kernel interprets memory. PIPE_BUF_FLAG_CAN_MERGE looked like a performance optimization flag, but in the wrong context it became a security boundary bypass. The original missing initialization existed for years. It became dangerous only after another feature introduced a new meaning for the stale field. This is why reviewing only the changed file is not enough. When adding new flags, modes, or state transitions, we should audit every path that creates, recycles, or reuses the structure. Dirty Pipe was not powerful because it crashed the kernel or corrupted random memory. It was powerful because the kernel's internal state machine became logically inconsistent. That kind of bug can be easier to exploit and harder to detect. In application code, forgetting to initialize a field may cause a weird UI bug or a failed request. In kernel code, it may let an unprivileged user modify read-only file content. That difference is why explicit initialization, careful invariants, and subsystem-level reviews are essential. At this point, it is tempting to drop a full copy-paste exploit and call the analysis complete. Dirty Pipe is not just an academic bug. It is a real local privilege escalation vulnerability that can be used to modify sensitive files, abuse SUID binaries, and turn limited local execution into root-level impact on vulnerable systems. So instead of publishing a weaponized exploit, I prefer to focus on the part that actually matters for experienced engineers: understanding the primitive, validating exposure safely, and reducing the blast radius. The important idea is this: Dirty Pipe gives an attacker a write primitive into the Page Cache under very specific conditions. That is enough to explain the risk without handing someone a ready-made privilege escalation chain. The first thing I would check is the running kernel version. Dirty Pipe affected Linux kernel versions starting from 5.8 and was fixed in patched kernel releases such as: The exact package version depends on the distribution, because vendors often backport security fixes without changing the upstream kernel version in an obvious way. That is why I do not rely only on uname -r in production. I also check the distribution security advisories and installed kernel changelog. On Debian or Ubuntu-based systems: On RHEL, Rocky, AlmaLinux, or Fedora-based systems: The goal here is not to exploit the host. The goal is to answer one operational question: Is this system running a kernel package that contains the Dirty Pipe fix? There is no clever application-level patch that fully fixes Dirty Pipe. The bug lives in the kernel. So the primary mitigation is simple: Or on RHEL-like systems: After rebooting, always verify the active kernel: Installing a fixed kernel is not enough if the machine is still booted into the vulnerable one. This is a common production mistake: the package is patched, the vulnerability scanner looks cleaner, but the running kernel is still old because nobody rebooted the host. Dirty Pipe requires local code execution. That local execution can come from many places: So while patching is the real fix, reducing local execution paths is still important. A few practical checks I usually care about: If a user does not need shell access, remove it. If an old account should no longer authenticate, lock it. None of this replaces patching. But it reduces the number of places an attacker can start from. One of the most important operational lessons from Dirty Pipe is that containers do not bring their own kernel. A container shares the host kernel. So if the host kernel is vulnerable, a containerized workload may still be dangerous, especially when combined with weak isolation, excessive capabilities, or sensitive host mounts. For production workloads, I would avoid patterns like this unless there is a very strong reason: A safer baseline looks more like this: Also be careful with host mounts: Those mounts can turn a local container compromise into a much more serious host-level problem. Dirty Pipe is a kernel bug, but real incidents usually happen through chains. The kernel bug is one link. Bad container isolation can be another. Dirty Pipe modifies data through the Page Cache, which makes the behavior unusual. Still, sensitive files are the obvious places defenders should care about: On Linux, auditd can help monitor write attempts and metadata changes: Then search the audit logs: For file integrity monitoring, tools like AIDE can also help: This is not a perfect Dirty Pipe detector. But it is part of a healthy defensive baseline. When I look at Dirty Pipe from a defender's perspective, I do not think the lesson is "learn the exploit and move on." The lesson is broader: The exploit is interesting. But the engineering lesson is more valuable. A single stale flag inside a reused kernel structure broke one of the assumptions Linux users rely on every day: read-only files should not be writable by an unprivileged process. That is the kind of bug that reminds me why low-level systems programming requires paranoia, not just correctness. Dirty Pipe is one of those vulnerabilities that looks almost too simple after you understand it. A stale flag survived inside a reused pipe buffer. That pipe buffer was later pointed at a Page Cache page. The kernel trusted the stale flag. For me, the most important lesson is this: Security bugs often live at the boundaries between correct subsystems. The Page Cache was doing its job. Pipes were doing their job. splice() was doing its job. But the transition between those systems carried stale state, and that stale state broke the security model. That is why kernel engineering is so fascinating — and so unforgiving. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

struct pipe_buffer { struct page *page; unsigned int offset, len; const struct pipe_buf_operations *ops; unsigned int flags; // <-- the field that matters here unsigned long private; }; struct pipe_buffer { struct page *page; unsigned int offset, len; const struct pipe_buf_operations *ops; unsigned int flags; // <-- the field that matters here unsigned long private; }; struct pipe_buffer { struct page *page; unsigned int offset, len; const struct pipe_buf_operations *ops; unsigned int flags; // <-- the field that matters here unsigned long private; }; unsigned int flags; unsigned int flags; unsigned int flags; PIPE_BUF_FLAG_CAN_MERGE PIPE_BUF_FLAG_CAN_MERGE PIPE_BUF_FLAG_CAN_MERGE file -> kernel buffer -> user space -> kernel pipe buffer file -> kernel buffer -> user space -> kernel pipe buffer file -> kernel buffer -> user space -> kernel pipe buffer file page cache -> pipe buffer reference file page cache -> pipe buffer reference file page cache -> pipe buffer reference copy_page_to_iter_pipe() copy_page_to_iter_pipe() copy_page_to_iter_pipe() static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes, struct iov_iter *i) { // ... validation steps ... struct pipe_inode_info *pipe = i->pipe; struct pipe_buffer *buf = &pipe->bufs[head & mask]; buf->ops = &page_cache_pipe_buf_ops; get_page(page); buf->page = page; buf->offset = offset; buf->len = bytes; // What is missing here? pipe->head = head + 1; return bytes; } static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes, struct iov_iter *i) { // ... validation steps ... struct pipe_inode_info *pipe = i->pipe; struct pipe_buffer *buf = &pipe->bufs[head & mask]; buf->ops = &page_cache_pipe_buf_ops; get_page(page); buf->page = page; buf->offset = offset; buf->len = bytes; // What is missing here? pipe->head = head + 1; return bytes; } static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes, struct iov_iter *i) { // ... validation steps ... struct pipe_inode_info *pipe = i->pipe; struct pipe_buffer *buf = &pipe->bufs[head & mask]; buf->ops = &page_cache_pipe_buf_ops; get_page(page); buf->page = page; buf->offset = offset; buf->len = bytes; // What is missing here? pipe->head = head + 1; return bytes; } buf->flags = 0; buf->flags = 0; buf->flags = 0; int p[2]; pipe(p); int capacity = fcntl(p[1], F_GETPIPE_SZ); char dummy = 'A'; for (int r = capacity; r > 0; ) { int n = r > sizeof(dummy) ? sizeof(dummy) : r; write(p[1], &dummy, n); r -= n; } int p[2]; pipe(p); int capacity = fcntl(p[1], F_GETPIPE_SZ); char dummy = 'A'; for (int r = capacity; r > 0; ) { int n = r > sizeof(dummy) ? sizeof(dummy) : r; write(p[1], &dummy, n); r -= n; } int p[2]; pipe(p); int capacity = fcntl(p[1], F_GETPIPE_SZ); char dummy = 'A'; for (int r = capacity; r > 0; ) { int n = r > sizeof(dummy) ? sizeof(dummy) : r; write(p[1], &dummy, n); r -= n; } for (int r = capacity; r > 0; ) { int n = r > sizeof(dummy) ? sizeof(dummy) : r; read(p[0], &dummy, n); r -= n; } for (int r = capacity; r > 0; ) { int n = r > sizeof(dummy) ? sizeof(dummy) : r; read(p[0], &dummy, n); r -= n; } for (int r = capacity; r > 0; ) { int n = r > sizeof(dummy) ? sizeof(dummy) : r; read(p[0], &dummy, n); r -= n; } int fd = open("/path/to/read-only-file", O_RDONLY); loff_t offset = 0; splice(fd, &offset, p[1], NULL, 1, 0); int fd = open("/path/to/read-only-file", O_RDONLY); loff_t offset = 0; splice(fd, &offset, p[1], NULL, 1, 0); int fd = open("/path/to/read-only-file", O_RDONLY); loff_t offset = 0; splice(fd, &offset, p[1], NULL, 1, 0); pipe_buffer.page -> file Page Cache page pipe_buffer.flags -> PIPE_BUF_FLAG_CAN_MERGE pipe_buffer.page -> file Page Cache page pipe_buffer.flags -> PIPE_BUF_FLAG_CAN_MERGE pipe_buffer.page -> file Page Cache page pipe_buffer.flags -> PIPE_BUF_FLAG_CAN_MERGE diff --git a/lib/iov_iter.c b/lib/iov_iter.c index b0e0acdf96c15e..6dd5330f7a9957 100644 --- a/lib/iov_iter.c +++ b/lib/iov_iter.c @@ -414,6 +414,7 @@ static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes, return 0; buf->ops = &page_cache_pipe_buf_ops; + buf->flags = 0; get_page(page); buf->page = page; buf->offset = offset; diff --git a/lib/iov_iter.c b/lib/iov_iter.c index b0e0acdf96c15e..6dd5330f7a9957 100644 --- a/lib/iov_iter.c +++ b/lib/iov_iter.c @@ -414,6 +414,7 @@ static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes, return 0; buf->ops = &page_cache_pipe_buf_ops; + buf->flags = 0; get_page(page); buf->page = page; buf->offset = offset; diff --git a/lib/iov_iter.c b/lib/iov_iter.c index b0e0acdf96c15e..6dd5330f7a9957 100644 --- a/lib/iov_iter.c +++ b/lib/iov_iter.c @@ -414,6 +414,7 @@ static size_t copy_page_to_iter_pipe(struct page *page, size_t offset, size_t bytes, return 0; buf->ops = &page_cache_pipe_buf_ops; + buf->flags = 0; get_page(page); buf->page = page; buf->offset = offset; uname -a uname -r uname -a uname -r uname -a uname -r apt list --installed | grep linux-image apt changelog linux-image-$(uname -r) apt list --installed | grep linux-image apt changelog linux-image-$(uname -r) apt list --installed | grep linux-image apt changelog linux-image-$(uname -r) rpm -q kernel rpm -q --changelog kernel | grep -i CVE-2022-0847 -A 5 rpm -q kernel rpm -q --changelog kernel | grep -i CVE-2022-0847 -A 5 rpm -q kernel rpm -q --changelog kernel | grep -i CVE-2022-0847 -A 5 sudo apt update sudo apt full-upgrade sudo reboot sudo apt update sudo apt full-upgrade sudo reboot sudo apt update sudo apt full-upgrade sudo reboot sudo dnf update kernel sudo reboot sudo dnf update kernel sudo reboot sudo dnf update kernel sudo reboot # Users with interactive shells cat /etc/passwd | grep -E '/bin/bash|/bin/sh|/bin/zsh' # Users with sudo-like access getent group sudo getent group wheel # Recently created users sudo awk -F: '$3 >= 1000 { print $1, $3, $6, $7 }' /etc/passwd # Users with interactive shells cat /etc/passwd | grep -E '/bin/bash|/bin/sh|/bin/zsh' # Users with sudo-like access getent group sudo getent group wheel # Recently created users sudo awk -F: '$3 >= 1000 { print $1, $3, $6, $7 }' /etc/passwd # Users with interactive shells cat /etc/passwd | grep -E '/bin/bash|/bin/sh|/bin/zsh' # Users with sudo-like access getent group sudo getent group wheel # Recently created users sudo awk -F: '$3 >= 1000 { print $1, $3, $6, $7 }' /etc/passwd sudo usermod -s /usr/sbin/nologin username sudo usermod -s /usr/sbin/nologin username sudo usermod -s /usr/sbin/nologin username sudo passwd -l username sudo passwd -l username sudo passwd -l username docker run --privileged ... docker run --privileged ... docker run --privileged ... docker run \ --read-only \ --cap-drop=ALL \ --security-opt no-new-privileges \ image-name docker run \ --read-only \ --cap-drop=ALL \ --security-opt no-new-privileges \ image-name docker run \ --read-only \ --cap-drop=ALL \ --security-opt no-new-privileges \ image-name -v /:/host -v /etc:/host/etc -v /var/run/docker.sock:/var/run/docker.sock -v /:/host -v /etc:/host/etc -v /var/run/docker.sock:/var/run/docker.sock -v /:/host -v /etc:/host/etc -v /var/run/docker.sock:/var/run/docker.sock /etc/passwd /etc/shadow /etc/group /etc/sudoers /root/.ssh/authorized_keys /etc/passwd /etc/shadow /etc/group /etc/sudoers /root/.ssh/authorized_keys /etc/passwd /etc/shadow /etc/group /etc/sudoers /root/.ssh/authorized_keys sudo auditctl -w /etc/passwd -p wa -k passwd_changes sudo auditctl -w /etc/shadow -p wa -k shadow_changes sudo auditctl -w /etc/group -p wa -k group_changes sudo auditctl -w /etc/sudoers -p wa -k sudoers_changes sudo auditctl -w /etc/passwd -p wa -k passwd_changes sudo auditctl -w /etc/shadow -p wa -k shadow_changes sudo auditctl -w /etc/group -p wa -k group_changes sudo auditctl -w /etc/sudoers -p wa -k sudoers_changes sudo auditctl -w /etc/passwd -p wa -k passwd_changes sudo auditctl -w /etc/shadow -p wa -k shadow_changes sudo auditctl -w /etc/group -p wa -k group_changes sudo auditctl -w /etc/sudoers -p wa -k sudoers_changes sudo ausearch -k passwd_changes sudo ausearch -k shadow_changes sudo ausearch -k group_changes sudo ausearch -k sudoers_changes sudo ausearch -k passwd_changes sudo ausearch -k shadow_changes sudo ausearch -k group_changes sudo ausearch -k sudoers_changes sudo ausearch -k passwd_changes sudo ausearch -k shadow_changes sudo ausearch -k group_changes sudo ausearch -k sudoers_changes sudo apt install aide sudo aideinit sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db sudo aide --check sudo apt install aide sudo aideinit sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db sudo aide --check sudo apt install aide sudo aideinit sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db sudo aide --check - The Page Cache - Pipe buffers - The splice() system call - The original page remains unchanged. - A private copy is created. - The process writes to that private copy. - The read-only backing file remains safe. - Prepare a pipe so all its internal buffer slots have PIPE_BUF_FLAG_CAN_MERGE set. - Drain the pipe so it becomes logically empty. - Use splice() to attach a read-only file's Page Cache page to a reused pipe buffer. - Because buf->flags was not cleared, the stale merge flag remains. - A later write to the pipe is merged into the Page Cache page. - an SSH account - a compromised web application - a CI/CD runner - an untrusted container workload - a shared development server - a low-privileged service user - patch kernels quickly - reboot after kernel updates - reduce local shell access - avoid over-privileged containers - monitor sensitive identity and privilege files - review code paths that recycle stateful structures - The Dirty Pipe Vulnerability - CM4all - Linux Kernel Dirty Pipe Exploitation Logic Bug Exploration - GitHub - Exploration of the Dirty Pipe Vulnerability - lolcads - Dirty Pipe Vulnerability CVE-2022-0847 - Tarlogic - An In-Depth Look at Pipe and Splice implementation in Linux kernel - Oracle Blogs - UPSTREAM: lib/iov_iter: initialize flags in new pipe_buffer - Gerrit - Dirty Pipe Vulnerability Detection Script - GitHub