PE builder devlog: growing headers without moving RVAs
The PE builder used to stop when there was no room for another section header. That was a safe failure, but it rejected files whose virtual layout was otherwise usable. This change adds a bounded way to grow the headers without moving the original code to new RVAs. Along the way, the regression fixtures exposed a more serious problem: clearing a directory that did not physically exist could overwrite the section table.
The failure I wanted to remove
Adding a protected runtime means appending sections and adding their section-table records. The original path relied on spare bytes between the existing table and the first raw section. Once that space was exhausted, the builder returned the familiar “no safe header slack” failure. The transformation itself was not necessarily unsupported; the file just had nowhere to put the additional record.
I kept the scope deliberately narrow. Existing instructions, references, and exception metadata were already expressed in image-relative addresses. Moving those addresses would turn a header-space fix into a much larger rewriting problem. The new contract therefore keeps the original section RVAs, virtual sizes, and permissions fixed, while allowing file-backed content to move later in the file.
This is still a bounded rebuild path. It does not try to repair every unusual PE that the Windows loader might accept.
Separate the image layout from the file layout
The insertion point is the original SizeOfHeaders. The append planner carries a header_growth value in each SectionAppendPlan, and planned raw placements account for that insertion before emission. Growth is file-aligned. The writer then re-derives the plan instead of accepting a caller-supplied layout at face value.
The small example below uses illustrative values to show the address contract; it is not an extracted fixture.
| Field | Before | After inserting 0x200 bytes |
|---|---|---|
| SizeOfHeaders | 0x400 | 0x600 |
| .text VirtualAddress | 0x1000 | 0x1000 |
| .text PointerToRawData | 0x400 | 0x600 |
| RVA of an instruction | 0x1120 | 0x1120 |
| File offset of that instruction | 0x520 | 0x720 |
The same instruction remains at the same RVA. A tool reading the file, however, must now find its bytes at a different offset. That distinction affects more than the new section table: initialized-section raw pointers and debug payload file pointers need translation, while mapped RVAs must stay unchanged. The original suffix, including overlay bytes, moves with the insertion.
The implementation does not infer application-specific file offsets stored in arbitrary data. A program that opens its own image and interprets a private overlay format needs separate compatibility analysis.
The short-header bug was a different kind of failure
During this work, a short Optional Header fixture reproduced an out-of-bounds Security Directory clear. The cleanup code knew the conventional position of directory entry 4, but that did not mean the input physically contained entry 4. On a short header, those presumed directory bytes could already belong to the section table.
Debug cleanup had the same category of hazard at directory entry 6. A missing entry must be treated as absent, not as eight zero bytes waiting to be written at a fixed address.
// Illustrative preconditions, not a verbatim implementation.
slot_begin = directory_base + directory_index * 8
slot_end = checked_add(slot_begin, 8)
require declared_directory_count > directory_index
require slot_end <= optional_header_end
require slot_end <= output_file_size
clear_directory_slot(slot_begin)The rebuilt path now checks both declaration and physical capacity. Preflight rejects a directory count that claims more entries than the Optional Header can hold. Hygiene also rejects truncated output even when the image has no Debug Directory to clean up. These checks matter on ordinary no-growth files as well as on files that need insertion.
Header growth does not manufacture additional Optional Header directory slots. If the protection path requires exception metadata and the input has no room for the required directory entry, the correct outcome is still rejection.
One normalized source view for every later writer
A second source of mistakes was downstream code comparing the rebuilt file with the original file as though their raw offsets still matched. Entry patches, original-body erasure, static-string transformation, and debug hygiene all needed a consistent answer to “where are the original bytes in this output layout?”
source_image_for_output reconstructs an original-content snapshot in the expanded layout and reparses it. The compiler creates that view once and uses it across later writers. Direct hygiene callers receive the same normalization if they still pass the pre-expansion source.
This is especially important for debug cleanup. CodeView removal should compare the relocated source record with the correct output record; CET-related metadata that must be retained should be checked at its relocated position. Comparing old file offsets against new ones can make a well-intentioned cleanup overwrite unrelated data.
patch_rva retains a separate boundary: the entire patch must fit the original initialized RVA range. It validates the output’s representation of the original mapping, translates to a file offset, adjusts for insertion where applicable, and checks the output span before copying. A patch crossing the header insertion is rejected. This verifies a layout relationship; it is not authentication of every byte in the file.
Tests needed both a working program and a hostile layout
The execution fixture makes a valid repository image header-tight by filling its spare header space with BSS section records. That gives a meaningful before/after result: the frozen builder rejects the tight DLL, while the new pipeline produces an output that executes. The documented run compares 128 protected DLL calls with the original and also launches header-tight and protected PE32/x64 executables, including SDK string checks.
A separate boundary fixture builds an independent byte-level PE. It exercises raw-plus-BSS append, mapped and file-only debug pointers, certificate-directory clearing, the normalized source snapshot, forged plans, forged output mappings, and patch bounds. It is useful precisely because it does not depend on the compiler’s own image builder to create every malformed case.
The short Optional Header cases are structural/parser tests. They should not be counted as additional programs successfully loaded by Windows. Likewise, synthetic certificate bytes test directory handling; they do not prove that a transformed file retained a valid Authenticode signature. The signature directory is cleared because the original signature cannot remain valid after changing the signed image.
The first full regression run still failed
The first Release pass caught two test assumptions that no longer matched the supported contract: an assertion expecting the old capacity rejection, and a transformation fixture with no serialized PE container. Both were corrected without weakening the production checks. The static-string transformation smoke now uses a real PE container and reparses the result; the lightweight section model remains appropriate for reader fuzzing.
Compatibility outside the new path needed a control as well. Sixteen ordinary CLI outputs that did not require header growth remained byte-identical to the frozen baseline. That is a stronger answer to “did this perturb the normal path?” than simply observing that the expanded fixture now passes.
| Recorded check | Result | Meaning |
|---|---|---|
| Protected DLL differential | 128 calls match | Behavior on the controlled fixture |
| No-growth CLI control | 16 byte-identical outputs | Existing output stability for that set |
| Corrected Release regression | 142/142 · 76.35 s | Recorded full-suite result |
| Corrected Debug regression | 142/142 · 441.04 s | Recorded full-suite result |
Those durations belong to the test suites, not to a protection operation. The suites also retain known HARDENING_REJECTED findings. A green regression verifies the implementation contract; it does not establish resistance to reverse engineering.
What remains deliberately unsupported
The growth path rejects layouts that require coupled file/RVA relocation, unsupported alignment, occupied header slack, collisions with original section RVAs, header-overlapping raw sections, inconsistent ranges, and legacy COFF structures that it does not relocate. The documented section-count limit is 96. Those are explicit support boundaries, rather than silent guesses about where unknown metadata ought to move.
The useful result of this change is small but concrete: adding supported sections no longer requires pre-existing header slack, and short-header cleanup cannot treat section-table bytes as nonexistent directory entries. General protected ValueCFG lowering, signing-environment validation, and the known fast-recovery cases remain separate work.
Evidence note: results above are transcribed from the project’s retained header-growth report and checked against the relevant implementation paths. This article revision did not rerun the protector or its historical regression suites.
References
Microsoft PE format specification · Kernel runtime development log