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.cshas blockingExecute(addr, param)→ waits 10s, returns exit code.BMThread.cshasCreateRemoteThread(addr, param)→ returnsSafeMemoryHandle?.SInject.cshasInjectCode(addr, bytes)andInjectCode(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)onAsmBuilderfor 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.