Files
whitemagic/openspec/changes/inject-and-assemble/design.md
T
2026-07-21 22:30:10 +02:00

4.0 KiB

Context

BlackMagic is a pure C# process manipulation library. It replaced FASM (native x86 assembler DLL) with hand-assembled byte[] stubs. Two capabilities were lost:

  • InjectAndExecuteEx: non-blocking remote thread that returns a handle without waiting.
  • Text-based assembly: build code payloads from pushad, mov eax, 0x1234, etc. instead of raw bytes.

Existing code:

  • BMThread.cs has blocking Execute(addr, param) → waits 10s, returns exit code.
  • BMThread.cs has CreateRemoteThread(addr, param) → returns SafeMemoryHandle?.
  • SInject.cs has InjectCode(addr, bytes) and InjectCode(bytes) → allocate + write.
  • BlackMagicTest/ uses xUnit, net8.0-windows, pure-logic tests (no process needed).

Goals / Non-Goals

Goals:

  • Add InjectAndExecuteEx(): inject code + create remote thread, return handle, do NOT wait.
  • Add AsmBuilder: pure C# x86 text assembler. Convert "pushad\nmov eax, 1\npopad"byte[].
  • Add SetPassLimit(int) on AsmBuilder for label resolution iteration control.
  • Add convenience overloads: InjectAndExecute(string asm), InjectAndExecuteEx(string asm).
  • TDD: write tests first for every public API surface.

Non-Goals:

  • Full x86 instruction set (only common payload subset: mov, push, pop, call, jmp, ret, nop, pushad/popad, test, je/jne, inc, add, sub, xor, and, or, cmp, lea, nop, hlt).
  • x64 text assembly (keep x86 only; x64 stubs remain hand-assembled byte[]).
  • Reimplementing FASM's directive system (org, use32, macros).
  • Native DLL dependency.

Decisions

D1: AsmBuilder lives in BlackMagic/Asm/AsmBuilder.cs

New directory Asm/ keeps assembler code isolated. Static class, no instance state needed — each Assemble() call is self-contained.

Alternatives considered:

  • Instance class with AddLine() builder pattern → rejected: adds statefulness for no benefit. Each payload is built fresh.
  • Put in Injection/ → rejected: assembler is generic, not injection-specific.

D2: Two-pass assembler (labels + bytes)

Pass 1: scan instructions, record label positions, emit bytes (reserving 4 bytes for near jumps). Pass 2: resolve label offsets, patch jump targets.

This handles forward references (jmp @skip before @skip: label is defined) without multiple iterations. SetPassLimit() caps the loop for safety but 2 passes is sufficient for all payload patterns.

Alternatives considered:

  • Single-pass → rejected: can't resolve forward jumps.
  • FASM-style multi-pass → overkill: payloads don't need complex expression evaluation.

D3: Instruction encoding via switch + helper methods

Each instruction maps to hand-coded byte emission. Helper methods: EmitU8(), EmitU32(), EmitModRM(), EmitSIB(). Same pattern as SInject.cs's existing BuildStub() methods.

Alternatives considered:

  • Lookup table / dictionary → rejected: instruction encoding is irregular (ModRM, SIB, displacement, immediate). Switch statements are clearer.

D4: InjectAndExecuteEx returns SafeMemoryHandle

Matches existing CreateRemoteThread() return type. Caller is responsible for disposing the handle. No auto-wait, no auto-close.

D5: TDD approach

Write xUnit tests in BlackMagicTest/AsmBuilderTests.cs first:

  • Each instruction: known input text → expected byte[] output.
  • Label resolution: forward jump, backward jump, multiple labels.
  • Error cases: unknown instruction, missing operand, invalid register.
  • SetPassLimit: verify iteration cap is respected.

Then implement AsmBuilder to make tests pass.

Risks / Trade-offs

  • Instruction subset: users may need an instruction not in the initial set. Mitigation: document supported instructions, add new ones incrementally.
  • Label complexity: relative jumps are limited to ±127 bytes (near) or ±2GB (far). Payloads rarely exceed this, but document the limit.
  • No runtime validation of payloads: the assembler produces bytes; it doesn't verify the result is safe to execute. This matches FASM's behavior — the assembler doesn't validate semantics.