~/posts/binary/cve-2022-0995-pagejack-page-lifetimes.md

PageJack: out-of-bounds set-bit and page lifetimes

Trace CVE-2022-0995 from inconsistent bounds to page-pointer aliases. Separate descriptor layout, temporary page caching, actual release, and cross-cache reuse.

date[31:24]
read[23:16]
6 min
cat[15:8]
Binary
Contents
  1. 0x00One input, two bounds
  2. 0x01A page descriptor is not a data address
  3. 0x02Layout, size class, and cache identity
  4. 0x03Releasing a buffer need not return its page
  5. 0x04Cross-cache reuse adds more conditions
  6. 0x05Demonstration evidence and the upstream fix
  7. 0x06References

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.

Counting and filling disagree3 rows
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:

pipe_buffer x86-64 layoutx86-64
OffsetNameTypeSize
0x00pagestruct page *8
0x08offsetunsigned int4
0x0clenunsigned int4
0x10opsconst struct pipe_buf_operations *8
0x18flagsunsigned int4
0x1cpadding4
0x20privateunsigned long8
sizeof(struct pipe_buffer) = 0x28 (40 bytes) · padding 4

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:

pagejack_model.pypython
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:

anon_pipe_buf_release.cc
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.

From pointer corruption to a field effect4 steps
  1. 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. 2

    Actual page return

    Verify release beyond temporary caching and other references, not merely a close operation.

  3. 3

    Target-object reuse

    Distinguish the target cache acquiring this page from the target object occupying a particular offset within it.

  4. 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.

What each observation establishes3 rows
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:

watch_queue.c+2 −2
if (tf[i].type >= sizeof(wfilter->type_filter) * 8)
if (tf[i].type >= WATCH_TYPE__NR)
...
if (tf[i].type >= sizeof(wfilter->type_filter) * BITS_PER_LONG)
if (tf[i].type >= WATCH_TYPE__NR)

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.

References

NORMAL~/posts/binary/cve-2022-0995-pagejack-page-lifetimes.md§--
0%en