Skip to content

PE

One entry about binfmt/pe.py here. Two more PE decisions live elsewhere because they are about the invariant rather than the reader: the ordinal import and export carve-out is under Evidence, opacity and verdicts, and .exe among the recognised suffixes is under Scanning, caching and layout.

A forwarder resolves the dependency it forwards to, not just its own name

Accepted, and it changes what a forwarding wrapper can hide.

The export reader tells a forwarder from a definition by where its "address" lands — back inside the export directory, where it is a string rather than code. Recording only the export's own name as imported, and never reading that string, leaves a .pyd exporting my_digest_init as a forwarder to libcrypto-3-x64.EVP_DigestInit_ex with neither the DLL nor the real symbol anywhere in the record:

own name only       -> needed: [],  matched_symbols: [], NO_CRYPTO_DETECTED, needs_human_review: false
forwarder resolved  -> needed: ['libcrypto-3-x64.dll'], matched_symbols: [EVP_DigestInit_ex/imported],
                       CONDITIONAL (openssl_linkage: system), needs_human_review: true

Without resolution, only a wrapper whose own export name happens to match the ruleset is caught, which is coincidence standing in for evidence — the exact shape the "unreadable means OPAQUE" invariant exists to rule out, except here nothing is even unreadable. The bytes sit in the object; a reader has only to ask for them.

How it is read. The forwarder string itself, OTHERDLL.Symbol or OTHERDLL.#Ordinal, is read through the same bounded string reader every other name in the file goes through, and split on its first dot. The DLL half never carries the file extension — the same convention NTDLL.RtlAllocateHeap uses — so .dll is appended to match what needed holds for every other dependency. The resolved DLL joins needed; the resolved symbol, when the string names one rather than an ordinal, joins matched_symbols as imported.

First dot, not last: splitting on the last dot misses a real shape. Splitting on the last dot, on the theory that nothing in the format forbids a dot in a DLL name, misses a real shape: MSVC hot/cold splitting produces symbol names like EVP_DigestInit_ex.cold, so a forwarder to libcrypto-3-x64.EVP_DigestInit_ex.cold last-dot-splits into a DLL name that matches nothing and a symbol cold that also matches nothing — reading the wheel clean, exactly the failure resolving forwarders exists to prevent.

A literal dot in a DLL name is not forbidden either, but it is not a shape a real Windows DLL name uses in practice, while a dot in the symbol half is documented compiler behaviour. First-dot is the bet that loses less evidence, not a reading the format makes certain.

Why the DLL, not just the symbol. Recording only the target symbol and leaving needed alone is weaker on the field that matters most: the posture function reads needed first, and the two dependency rules key on it, not on matched_symbols. The Windows loader resolves a forwarder exactly like an import at load time — it opens the target DLL before it can fail to find the symbol in it — so the dependency is real in exactly the sense needed means.

An ordinal-named forwarder reuses the ordinal-import token, not a new one. A forwarder to SOMEDLL.#123 loses the function name the same way an ordinal-bound import does, for the same reason: the loader opens the DLL regardless, so the dependency survives in needed and only the symbol is unrecoverable. The admission test finds nothing here that the ordinal-import carve-out does not already cover.

A forwarder string this reader cannot terminate is pe_export_incomplete, not a silent gap. Past the name cap or the object's name budget, the string reader returns nothing the same as for any other name, and the object is marked incomplete rather than reported as forwarding to nothing.

What was rejected. Recording only the target symbol. Inventing a new token for the ordinal-forwarder case, which would duplicate the ordinal-import token for no reason the ruleset could tell apart. And recording both candidate splits — unioning the matches from both halves and both candidate DLL names into needed — rejected as overkill for a case that is, by the corpus this reader was measured against, vanishingly rare, against a real cost: doubling needed and matched_symbols cardinality for every multi-dot forwarder, cutting against the same record-size discipline the cap module exists to hold.

What it costs. Resolving the string is what introduces a forwarder that fails to resolve (pe_export_incomplete), where reading nothing gives a silent, wrong NO_CRYPTO_DETECTED.

And the split direction has a real, if judged unlikely, failure mode: a forwarder whose DLL half genuinely embeds a literal dot splits wrong, the way a last-dot split gets the .cold case wrong. The shape is a dotted name whose first segment is not one the ruleset recognises on its own, the way .NET's native shims are named — a forwarder to System.Security.Cryptography.Native.OpenSsl.CryptoNative_EvpDigestUpdate first-dot splits to System and loses the rest, where last-dot would recover the symbol. Nothing in the PE format rules either shape out, so this is a bet made in the direction the evidence says loses less: MSVC hot/cold splitting is default-on compiler behaviour for any MSVC-built wrapper, while a CPython extension forwarding into a dotted native-shim family is a narrower shape.

Full entry