2025-12-03   5 min   windows, kernel, memory, reverse engineering

Reading process memory through a driver, honestly

My tooling reads other processes’ memory through a kernel driver. The user-mode side has a primitive that looks like every other read primitive: address, length, buffer, returns a bool. That signature hid three bugs for months, and all three had the same root cause.

The primitive lied by being helpful

A multi-page read, say 64 KiB, walks the pages of the target and copies each one. A page that is not resident cannot be copied without paging it in, which a driver reading a foreign process should not do. So the driver did the friendly thing: it zero-filled that page and carried on. The call returned true.

Single-page reads were always correct. The trouble only started when a caller asked for more than one page and trusted the bool.

Bug 1: every pattern scan missed

A pattern scanner read code regions in 64 KiB blocks and searched them. After a game update it reported no hits for signatures I could see with my own eyes in the disassembler at those exact addresses.

I spent half a day on the theory that the code was encrypted or mutated at runtime, because that is what “the bytes are not there” usually means. They were there. The scanner’s block had a non-resident page somewhere in the middle, the read came back true with a hole of zeros, and the signature happened to sit in the hole.

Bug 2: the object table had 1121 of 460836 entries

An object-table walker read the table in 2 MB chunks, 512 pages each, and checked the first eight bytes of the buffer for zero as a sanity test. First page fine, test passes, the other 511 pages were zeros.

announced   460836
collected     1121   (8 chunks x 128 = one page per chunk)
distinct        21

This one never looked like a read failure. It looked like flakiness. The same build would find a class one run, two classes the next, none the third. The historical numbers were already the symptom: “200 classes across 470k objects” in an engine that has thousands of classes. After the fix:

collected   283079
distinct      8590

Bug 3: the wrong type is also a lie

Not a paging problem, but the same shape. A batch reader takes a destination and reads sizeof(destination) bytes. My snapshot types use vector3<double>, 24 bytes. The engine stores vector3<float>, 12 bytes. Handing the double destination straight to the batch read 24 bytes and reinterpreted float bit patterns as doubles:

        as f32 (truth)   as f64 (what was read)
x       -7142.67         9.6e+18
y         449.34         2.57e-05
z          36.03         5.3e-315

The consumers close to the value kept working, because matrices and entity positions were read as floats elsewhere. Only the radar and a distance filter fell over, with every blip clamped to the edge and every distance around 2.4e17 metres. That read as “the entity list is incomplete”, and the search went half a day in the direction of the entity walk.

What the API looks like now

The fix for the paging bugs is a second primitive that does what the first one pretended to do:

// reads [addr, addr+len) page by page, splitting at page boundaries
// (not every 0x1000 from addr). pages that fail stay zeroed.
// returns the number of pages that were actually read.
std::size_t read_paged(std::uint64_t addr, void* out, std::size_t len);

Three properties matter. It splits at page boundaries, not every 4096 bytes from the start, otherwise a read that begins mid-page straddles two pages per step and the accounting is wrong. It never claims more than it did: the return value is the number of pages that came back. And the caller can tell “never there” from “there and empty”, because a page that failed is reported as failed instead of dressed up as content.

Every bulk read over a code region or a large table now goes through it. The scanner reports hits and the count of pages it could not see. The object walker reports announced, collected, and distinct, and a gap between the first two is a finding, not noise.

For the type bug, the rule is mechanical: a batch destination has the width of the field in the target, and conversion to the type I want happens after the read. Reviewing a batch means asking, for each entry, whether sizeof(dest) equals the field width in the target. It usually is not the field width.

Dumps inherit the lie

One more consequence. A full image dump made with the old primitive had 69.5 % zero pages in .text: 36,720 of 52,805. Whole source files’ worth of functions were missing, while their neighbours were intact. Loaded into a disassembler that looks like heavy code mutation or anti-tamper. It was a combination of pages that genuinely were not resident and pages the reader silently skipped, and the old dump cannot tell you which.

So before an analysis session on a dump, check whether the target address is a zero page. If it is, dump again with the honest reader. Do not keep searching the old one.

The principle

A read primitive has one job, and it is not “return a buffer”. It is to tell the caller what it knows. Success with zeros is the worst possible answer, because it is indistinguishable from the truth and it costs a day each time you meet it.