Add the import-layer repair and the Themida absorption

references/iat-virtualization-and-call-site-rewriting.md records the two edits one
protector family makes to a native image: the import slot stops holding an address and
the indirect call that read it becomes a relative call to a stub. It carries the pairing
rule -- a site's target equals a slot's value -- the three placements of the filler byte
in a six-byte rewrite and the refusal when there is none, the divergence between builds
that modify the code section and builds that only redirect the table, the small set of
imports redirected twice, the choice between repairing, extracting a payload, observing
and reporting, and the limits an automatic unpacker inherits from its own dump.

scripts/iat_callsite_restore.py implements the mechanical half offline: inventory the
slots from three sources, pair by value, decide the six-byte window per site, refuse an
ambiguous slot and a site whose sixth byte is the next instruction, rewrite, then
re-decode every written site from the file it wrote. It is report-only unless --out is
given, and it does not unpack: a virtualised entry has no site list to restore.

Registered in both symptom tables, the routing, reference and script indexes, the
capability registry as call_site_restore, and the coverage, route-inventory and evidence
tables. dynamic-debugging.md gains the instrument rule the second write-up demonstrates
(a target that runs at full speed and dies when stopped has a perturbation detector),
memory-and-dumps.md gains the call-site step of a rebuild, environment.md gains the
compatibility-shim trap, and protectors-and-packers.md gains one recognition row plus a
pointer rather than the detail, because that file makes no product-level claims.

The target-behaviour claims stay unverified: no protected sample was analysed here. What
is observed is the tool's behaviour on a generated image -- both filler positions, both
refusals, both displacement forms -- and what the source material states about itself.
FINDINGS F16 records the defect this pass produced, a slot's magnitude read as its
meaning, with the test that fails without the fix. .gitignore gains ref/, which was not
covered by the existing rule because that rule carries a leading dot.
This commit is contained in:
root
2026-10-27 16:29:59 +08:00
parent af9603c985
commit e5b45d9072
19 changed files with 1281 additions and 2 deletions
@@ -0,0 +1,75 @@
# Extension: absorbing an import-virtualisation pass
What this pass covered, what it verified externally, and what it deliberately did not assert. The
material was three public write-ups of one protector family plus the source tree of an open-source
unpacker for it, read locally. No URL and no author is recorded here: neither was opened in this
session, and a citation that was not verified is a claim dressed as a reference.
The organising question is the one that decides what can be written down at all: **what is
mechanically true of an import layer once a protector rewrites it, and what belongs only to the
build the writer happened to have?**
## What was verified externally
| Fact | How it was established | Grade |
|---|---|---|
| The import layer is changed in two places, not one: the slot stops holding an address, and the call that read it becomes a relative call | Two write-ups describe the same pair of edits on the same product family, on different builds | `unverified` as target behaviour; `observed` that the sources say it |
| A repair can pair a site to a slot by comparing the site's computed target address with the table's values, then write an indirect call back | Read in the unpacker's fixup routine: it builds an address-to-slot map from the table, reads the relative target, and writes `FF 15`/`FF 25` | `observed` (of the source) |
| A newer generation computes each slot's value from a per-slot constant by arithmetic that is itself virtualised, so the handle-to-API mapping cannot be patched out | The same source states it in a comment at the point where it chooses to trace instead of patch | `observed` (of the source) |
| That unpacker repairs one anti-dump type only, and states that a target virtualising other regions will very likely still crash | Comment at the top of its anti-dump fixer | `observed` (of the source) |
| A dump of a target whose entry is virtualised does not run, and the tool says so rather than shipping it | Its own documentation | `observed` (of the documentation) |
| A working dump of such a target can be bound to the machine and session it was taken on, because some anti-dump checks compare module base addresses | The same documentation | `observed` (of the documentation) |
| A DLL dump loses relocation information | The same documentation | `observed` (of the documentation) |
| Targets from the late 2000s may only unpack on the OS generation they were built for | The same documentation | `observed` (of the documentation) |
| The operating system applies compatibility fixes keyed to an executable's path, so a program that crashed once can behave differently later with identical bytes | The same documentation names the mechanism and the store | `observed` (of the documentation); the store itself was not read here |
| One later build leaves the first code section untouched and only redirects the table | A write-up, on one build | `unverified` |
| A small, fixed set of runtime-owned imports is redirected twice | A write-up, on one build and one compiler | `unverified` |
| The filler byte of the 6-to-5 byte rewrite appears after the call, before it, and not at all, in different builds | Two write-ups plus the unpacker's handling of a site whose call is not at offset 0 | `unverified` |
| One write-up's version and date are internally inconsistent — it dates the build to a year, then calls the same build more than a decade old | Read here, in the source text | `observed` |
The last row is the reason the material was absorbed as mechanism only. A source that is wrong about
its own subject in one place can be wrong in another, and the wrong part does not announce itself.
## What this changed in the skill
- **New reference**, `references/iat-virtualization-and-call-site-rewriting.md`: the two-edit model,
the pairing rule, the six-byte window and its three cases, the version-divergence table, the
double-redirected set, the deliverable decision (repair / extract / observe / report), the limits
an automatic unpacker inherits, and the verification rules.
- **Entry point**: five symptom rows and one route row, because "the import table parses but holds
handles" and "a dump dies at its first imported call" are not findable from the existing rows.
- **`references/protectors-and-packers.md`**: one recognition row and a short section. The file states
that it makes no product-level claims, so the mechanism stayed there and the detail did not.
- **`references/memory-and-dumps.md`**: call sites are part of a dump, not a detail of the import
table, so the rebuild step now says that repairing the slots can restore a table nothing reads.
- **`references/dynamic-debugging.md`**: the instrument rule the second write-up actually
demonstrates — a target that runs at full speed and dies when stopped has a perturbation detector,
and the response is a different breakpoint family, not more hiding options.
- **`references/environment.md`**: the compatibility-shim fact, because it changes what "it ran
once" means and it lives in neither file.
- **`scripts/iat_callsite_restore.py`** with a unit and a CLI layer, registered in the capability
registry as `call_site_restore`.
## What was deliberately not asserted
- **No address, register name, opcode listing or script from any single build.** Those are the parts
of the source material that stop being true first, and the skill already carries a rule that a
build-level claim travels in the same sentence as the hash it was observed on.
- **No claim that the script works on a real protected sample.** It was exercised against a
generated image whose three layout cases were written on purpose, so its rows are fixture-observed
and say so. The route it implements is `unverified` against a protector until someone runs it.
- **No claim that repairing the call sites produces a working binary.** Virtualisation, anti-dump and
self-integrity checks are separate problems and the reference says so in its own last section.
- **No claim that the filler byte is always a NOP.** One of the three placements has no filler, which
is exactly why the script refuses that case instead of writing five bytes and a guess.
- **No product-strength opinion**, and none of the numeric claims from the write-ups (counts of
redirected imports, section growth, timings) survived into the reference.
## Why this route is probed rather than declared
The repair is arithmetic over a file, so it can be tested without a target: a generated image, three
sites with deliberately different neighbours, and a slot range the import directory does not
describe. That is what the tests do, and it is why the script ships with refusals rather than
fallbacks — every fallback in this area (pick the first matching slot, overwrite the sixth byte, skip
a site silently) produces a file that loads and misbehaves later, which is the failure shape this
repository exists to make visible.
+24
View File
@@ -154,6 +154,30 @@ on this host rather than an error.
*Fix and check:* long values wrap onto continuation lines instead of being cut. A test asserts a long
value survives into the output intact.
## Repair-script defects
**F16. A slot's magnitude was read as its meaning, so an ordinary import table looked protected.**
*Symptom:* the import-layer repair's first version classified every slot whose value was below the
image base as a handle, and its verdict rule turned that class into `needs-trace` — a
protected-image verdict for a file that was never protected. Nothing about the output looked wrong:
the classes were counted, the verdict followed from them, and the report was internally consistent.
*Root cause:* an unbound import table holds RVAs, and an RVA is also a number below the image base.
The classifier inferred intent from magnitude instead of testing whether the value resolves to
something in the image, or whether a call targets it. The same mistake in the other direction would
have called a real handle an address.
*How it surfaced:* while reading `peutil.py`'s import walk to build the slot inventory. The reader
returns the table's entries as they are in the file, which is what made "below the base" visibly
ambiguous rather than obviously a handle.
*Fix and check:* the classes are positions (`zero`, `below_image`, `in_image`, `above_image`) and the
verdict follows from the pairing rather than from a class. The regression is
`tests/unit/test_iat_callsite_restore.py::test_a_clean_image_reports_no_pairs`, which asserts
`no-pairs` on a generated unbound image — the case the old rule reported as `needs-trace`. The
reference states the correction in prose, because the same inference is easy to repeat by hand.
## Gate, harness and lint defects
**F11. The hard index-row limit was computed and then unable to fail anything.**