A write that only sets one bit can still change an object's lifetime. CVE-2022-0995 provides such a starting point in watch queue filtering: the useful target is a pipe's page-descriptor pointer, not a permission flag.
PageJack turns pointer corruption inside a small object into a page-level use-after-free (UAF), potentially reaching objects from other caches. This analysis is scoped to Linux 5.13 source and the case demonstration, separating each transition's prerequisites from claims of reliability.
One input, two bounds
watch_queue_set_filter counts valid entries, allocates the internal filter, then fills it. On x86-64, type_filter[2] occupies 16 bytes. The two loops nevertheless interpret its capacity as 128 bits and 1024 bits.
| Stage | Acceptance condition | x86-64 bound |
|---|---|---|
| Count entries for allocation | type < sizeof(type_filter) * 8 | 128 |
| Fill entries and bitmap | type < sizeof(type_filter) * BITS_PER_LONG | 1024 |
| Accepted only by the second loop | 128 ≤ type < 1024 | 896 candidate indices |
The upper endpoint is excluded. Entry-count and mask validation also apply; an index in this interval does not make every field freely writable.
Undercounting can overrun the allocated trailing entry array. Separately, __set_bit(q->type, wfilter->type_filter) sets a bit outside the bitmap. This is set-bit, not XOR: an existing 1 stays 1. Crossing the bitmap boundary does not necessarily cross the allocation boundary, so intervening fields and object sizes still matter.
A page descriptor is not a data address
This diagram shows pointer relationships, not actual kernel addresses or physical-page spacing:
flowchart TD A["Pipe A: corrupted page pointer"] --> P["The same struct page"] B["Pipe B: original page pointer"] --> P P --> D["Physical page holding data"] A -. "No normal get_page increment" .-> R["Tracked references below actual holders"]
pipe_buffer.page points to a struct page descriptor, not a byte array containing the pipe data. A single p | mask must produce another controlled page's descriptor address, with the changed bit initially clear, to create the required alias.
For illustration, a 0x1000 pointer difference spans 64 descriptors if descriptors are contiguous and 64 bytes each. With 4 KiB data pages, that page-number distance corresponds to 256 KiB, not one 4 KiB page. This is a units check, not an assumption about every memory model.
Layout, size class, and cache identity
Under the x86-64 natural-alignment layout discussed here, pipe_buffer occupies 40 bytes. private starts at offset 32, not offset 28 immediately after the four-byte flags field:
| Offset | Name | Type | Size |
|---|---|---|---|
| 0x00 | page | struct page * | 8 |
| 0x08 | offset | unsigned int | 4 |
| 0x0c | len | unsigned int | 4 |
| 0x10 | ops | const struct pipe_buf_operations * | 8 |
| 0x18 | flags | unsigned int | 4 |
| 0x1c | padding | 4 | |
| 0x20 | private | unsigned long | 8 |
A 16-entry array requests 640 bytes; two entries request 80 bytes. In the relevant SLUB configuration, these normally select the 1 KiB and 96-byte size classes. Matching size is only one prerequisite.
The watch filter uses GFP_KERNEL; pipe arrays use GFP_KERNEL_ACCOUNT. Accounting, configuration, and backports affect the actual cache. When allocations are separated into kmalloc-cg-*, matching size classes no longer imply adjacency. Pipe-capacity changes also require checking return values, ring slots, and current occupancy.
This offline model checks bounds, natural alignment, and pointer units. Addresses, descriptor size, and data-page size are illustrative; it does not read kernel memory:
import ctypes as C
class PipeBuffer(C.Structure):
_fields_ = [("page", C.c_uint64), ("offset", C.c_uint32),
("len", C.c_uint32), ("ops", C.c_uint64),
("flags", C.c_uint32), ("private", C.c_uint64)]
assert C.sizeof(PipeBuffer) == 40 and PipeBuffer.private.offset == 32
count_limit, fill_limit = 16 * 8, 16 * 64
mismatch = [t for t in range(fill_limit) if t >= count_limit]
assert (mismatch[0], mismatch[-1], len(mismatch)) == (128, 1023, 896)
p, mask = 0x10000000, 0x1000
assert p | mask == 0x10001000
assert (p | mask) | mask == p | mask
descriptor_size, data_page_size = 64, 4096
assert mask // descriptor_size == 64
assert 64 * data_page_size == 262144
assert (40 * 16, 40 * 2) == (640, 80)
print("range=[128,1024); entries=896; pipe_buffer=40; private=32")
print("illustrative descriptor delta: 64 pages, not one page")Releasing a buffer need not return its page
An alias created without an extra reference leaves the tracked count inconsistent with actual holders. The release path still determines when the page becomes available.
Linux v5.13 anon_pipe_buf_release includes a one-page cache:
if (page_count(page) == 1 && !pipe->tmp_page)
pipe->tmp_page = page;
else
put_page(page);With a count of one and an empty cache, the page first becomes pipe->tmp_page, rather than immediately returning to the buddy allocator. free_pipe_info eventually frees that cached page. Destroying the pipe differs from consuming one buffer or closing a descriptor while other references remain.
Prove alias creation, release by the normal holder, actual page return, and survival of the other access path separately. Without all four, page-level UAF remains an inference.
Cross-cache reuse adds more conditions
A returned page can be allocated for a different purpose. The case targets struct file, but opening a file does not necessarily allocate a fresh slab page. Existing free slots, slab order, per-CPU state, and unrelated activity influence reuse.
- 1
Adjacency and set-bit
Confirm that the write reaches the intended structure and leaves a valid pointer. Distinct pipe markers can test for an alias; absence of a crash is insufficient.
- 2
Actual page return
Verify release beyond temporary caching and other references, not merely a close operation.
- 3
Target-object reuse
Distinguish the target cache acquiring this page from the target object occupying a particular offset within it.
- 4
Matching write positions
Compare the pipe's write position with the target object's start plus field offset, including the pipe merge-write conditions.
The case uses an f_mode offset of 68, or 0x44. This belongs to that experimental build, not a universal Linux 5.13 ABI. Preloading 68 bytes alone does not locate the field: offset + len, the object's offset within the page, and the selected write branch also matter.
An internal mode change does not automatically settle every later file operation. Other state, file operations, and checks remain relevant. Record the field change and the resulting effect separately.
Demonstration evidence and the upstream fix
The demonstration starts with UID 1000. Several Bug not found attempts precede a report that one pipe contains another marker; file contents then change and the final UID is 0. Those failed attempts matter: the layout or alias conditions were not satisfied on every attempt.
| Observation | Supports | Still requires |
|---|---|---|
| Pipe markers become correlated | A candidate shared-data relationship | Excluding duplicate input and read-position effects |
| The tool reports a field change | Its corresponding stage was reached | Observing the object, page offset, and actual field |
| Changed contents and final UID 0 | The demonstrated run reached its end result | No extrapolation to cross-build success rates |
Upstream commit c993ee0f9f81caf5767a50d1faeba39a0dc82af2 constrains both loops and ties the bitmap declaration to the number of known types. This excerpt shows only the bound checks:
Jann Horn reported the bug and David Howells submitted the fix. Zhiyun Qian presented PageJack at Black Hat USA 2024, with contributions from Jiayi Hu, Jinmeng Zhou, Qi Tang, and Wenbo Shen. Jean Vincent demonstrated the Linux 5.13 case discussed here.
For deployment, check the distribution's fix or equivalent backport, watch queue configuration, and reachable entry points. The central lesson is that limited writes can gain leverage through pointers and lifetimes, but each transition needs evidence. The offline model checks arithmetic and layout; no kernel exploit was rerun.