Blogs / Binary transformation

From PatchLang to PatchIR: A Reversible ELF Patch

A binary patch has to connect two very different descriptions of a program: the change an analyst intends and the bytes a processor will execute. HydIR keeps the intermediate decisions visible. A small PatchLang program becomes typed PatchIR, PatchIR becomes x86-64 code, and the code receives an explicit placement plan. The resulting PatchBundle records enough source, byte, boundary, and digest information to inspect the change and reverse it later.

This walkthrough uses hydir_stage_patch_portal from the checked-in PRISM fixture. The original function is deliberately only five bytes long:

hydir_stage_patch_portal:
    nop
    mov %rdi, %rax
    ret

Under the asserted SysV AMD64 prototype u64(u64,u64), RDI is arg0, RSI is arg1, and RAX carries the return value. The original function therefore returns its first argument.

Write the change in PatchLang

Open C ↔ PatchLang in Region Studio and replace the identity behavior with subtraction:

u64 delta = arg0 - arg1;
return delta;

HydIR's current PatchLang is intentionally small. It accepts u64 declarations, assignments, a final return, unsigned literals, the immutable inputs arg0 and arg1, parentheses, addition, and subtraction. Names must be defined before use, inputs cannot be assigned to, and statements after return are rejected. Parser errors carry replacement line and column positions.

The language is broader than the original scalar compatibility adapter. Region Studio and the v2 service compile the current typed expression tree, including nested addition and subtraction. The local hydirctl patch command still uses the narrower scalar-v1 adapter, which accepts only the expression shapes it can lower directly. Both routes fail closed when a parsed program exceeds their backend contract.

Preserve intent as typed PatchIR

The PatchLang frontend does not throw the source away after parsing it. It produces a typed statement list and attaches a source range to every statement and expression. For this edit, the PatchIR portion of the bundle contains two statements: a declare whose value is a typed subtract, followed by a return of delta.

{
  "statements": [
    { "kind": "declare", "name": "delta", "type": "u64", … },
    { "kind": "return", … }
  ],
  "inputs": ["rdi:u64(arg0)", "rsi:u64(arg1)"],
  "outputs": ["rax:u64(return)"]
}

HydIR also resolves local variables before code generation. In this example, return delta resolves back to arg0 - arg1. Keeping both the statements and the resolved return matters: the statements preserve what the analyst wrote, while the resolved expression gives the backend an unambiguous value to compile. Bundle validation reparses the PatchLang and checks that both forms still agree with it.

Check the binary boundary before changing bytes

PatchIR describes the new computation, but it does not by itself establish that the target region is safe to replace. Before placement, HydIR checks the exact input SHA-256, the named symbol and its file-backed .text extent, the little-endian x86-64 ELF format, the original scalar lift contract, relocations in the region, observed interior entries, and the return/stack facts.

The analyst must still make two explicit assertions: the target uses the u64(u64,u64) ABI and external control flow enters only at the function entry. HydIR rejects an interior entry it observes, but it does not claim to have found every possible indirect transfer in an arbitrary program.

Choose in-place bytes or a trampoline

The PatchIR compiler and placement pipeline first emits bounded x86-64 code. If those bytes fit in the original symbol, HydIR writes them into a copy of the ELF and pads the unused region with NOPs.

The PRISM portal is only five bytes, so its replacement does not fit. That forces the second placement strategy:

  1. Append a new executable, read-only ELF segment containing the compiled replacement.
  2. Replace the original entry with a five-byte relative jump to that code.
  3. Record the segment offset, virtual address, alignment, entry bytes, and replacement extent in the placement plan.

If the original region were shorter than the five-byte jump, or the new code were outside the signed 32-bit relative-jump range, HydIR would refuse the patch. It does not truncate either the old region or the replacement to make the operation appear successful.

Carry the evidence in a PatchBundle

The produced PatchBundle binds the transformation together. It includes the PatchLang document, typed PatchIR, original region bytes and digest, compiled bytes and digest, ABI boundary adapters, the placement plan, the original and patched ELF hashes, toolchain identity, and verification evidence. Re-importing the new ELF must succeed and retain the selected symbol's entry and extent.

The bundle deliberately reports stable_verified: false. Its structural evidence marks the original-region digest and patched-ELF re-import as passed, while differential behavior remains not_run. The boundary adapter also records unresolved physical live-outs and stack-alignment residues rather than quietly treating them as known.

Reversion uses the same evidence. HydIR first checks the complete patched-file digest and validates the placement metadata. It can then restore the original region and, for a trampoline patch, remove the appended executable layout. A modified binary or tampered bundle is rejected instead of being “best effort” reverted.

Try the complete flow

On Windows, the PRISM launcher builds the trusted fixture, prepares the analysis and patch artifacts, and opens Region Studio:

.\scripts\launch-prism-demo.cmd

Select hydir_stage_patch_portal, open C ↔ PatchLang, enter the two-line patch above, enable the trusted-fixture and entry-only assertions, and choose Compile + verify plan. The preview shows the typed statement count, byte delta, entry trampoline, appended RX segment, and structural evidence before any new ELF is written.

On Linux x86-64, execute only the checked-in trusted fixture when validating behavior. The PRISM program passes 72 and 1 to the portal: the original identity returns 72, while the subtraction patch returns 71 and deliberately changes the program's route. That observed change is useful evidence, but it remains separate from the PatchBundle's structural checks.

That separation is the point of the pipeline. PatchLang states the edit, PatchIR makes the edit inspectable, the placement plan explains where the bytes go, and the PatchBundle records what HydIR proved—and what it did not.