ARchive old spec

This commit is contained in:
kbe
2026-07-21 22:30:10 +02:00
parent 0380705e76
commit 184dec86ca
17 changed files with 550 additions and 191 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-21
@@ -0,0 +1,74 @@
## 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.
@@ -0,0 +1,23 @@
## Why
BlackMagic replaced FASM for code-payload generation but lost two useful capabilities:
1. **Non-blocking remote execution** (`InjectAndExecuteEx`): FASM's managed wrapper returned a thread handle without waiting. BlackMagic only has blocking `Execute()`. For DLL injection, a non-blocking variant avoids hanging when the target is slow to load.
2. **Text-based assembly**: FASM allowed building code payloads from assembly text (`AddLine("pushad")`). BlackMagic requires hand-assembled `byte[]`. For prototyping, debugging, and one-off code payloads, text assembly is faster to write and easier to review. A managed assembler eliminates the native FASM DLL dependency while keeping the ergonomic benefit.
## What Changes
- Add `InjectAndExecuteEx()` to `BlackMagic` and `BMThread`: inject code then create a remote thread without waiting, returning the thread handle.
- Add `AsmBuilder` class: pure C# x86 text assembler that converts instruction text to `byte[]` machine code. Supports common payload instructions (mov, push, pop, call, jmp, ret, nop, pushad/popad, test, je, jne, inc, add, sub, xor, etc.).
- Add `InjectAndExecute(string asm)` and `InjectAndExecuteEx(string asm)` overloads that accept assembly text, assemble via `AsmBuilder`, then inject+execute.
- Add `SetPassLimit()` to `AsmBuilder` for label resolution iteration control.
## Capabilities
### New Capabilities
- `non-blocking-execute`: Non-blocking remote thread creation that returns a handle without waiting for exit.
- `text-assembler`: Pure C# x86 text assembler converting assembly source to byte arrays without native dependencies.
### Modified Capabilities
<!-- None — additive features only. -->
@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: InjectAndExecuteEx creates remote thread without waiting
`BlackMagic.InjectAndExecuteEx(IntPtr startAddress, IntPtr parameter)` injects code at `startAddress` into the opened process, creates a remote thread with `parameter`, and returns the thread handle immediately without waiting for the thread to exit.
#### Scenario: successful non-blocking execution
- **WHEN** a process is open and `InjectAndExecuteEx(addr, param)` is called with a valid code address
- **THEN** a remote thread is created in the target process and a valid `SafeMemoryHandle` is returned
#### Scenario: no process open
- **WHEN** no process is open and `InjectAndExecuteEx(addr, param)` is called
- **THEN** `null` is returned
### Requirement: InjectAndExecuteEx single-parameter overload
`BlackMagic.InjectAndExecuteEx(IntPtr startAddress)` calls `InjectAndExecuteEx(startAddress, IntPtr.Zero)`.
#### Scenario: parameter-less non-blocking execution
- **WHEN** `InjectAndExecuteEx(addr)` is called with a valid address
- **THEN** the thread is created with parameter `IntPtr.Zero`
### Requirement: InjectAndExecuteEx from assembly text
`BlackMagic.InjectAndExecuteEx(string asm)` assembles the text via `AsmBuilder`, allocates remote memory, writes the bytes, calls `InjectAndExecuteEx` on the allocated address, and returns the thread handle.
#### Scenario: execute assembly text non-blocking
- **WHEN** `InjectAndExecuteEx("nop")` is called with a process open
- **THEN** the text is assembled to bytes, written to remote memory, a thread is started, and the handle is returned
#### Scenario: assembly failure
- **WHEN** `InjectAndExecuteEx("invalidinstruction")` is called
- **THEN** `ArgumentException` is thrown with the assembly error
### Requirement: InjectAndExecute from assembly text (blocking convenience)
`BlackMagic.InjectAndExecute(string asm)` assembles the text, allocates remote memory, writes the bytes, calls `Execute` (blocking, 10s timeout), and returns the exit code.
#### Scenario: execute assembly text blocking
- **WHEN** `InjectAndExecute("mov eax, 42\nret")` is called with a process open
- **THEN** the text is assembled, injected, executed, and the thread exit code is returned
@@ -0,0 +1,84 @@
## ADDED Requirements
### Requirement: AsmBuilder assembles x86 instruction text to byte array
`AsmBuilder.Assemble(string source)` parses x86 assembly text and returns the corresponding `byte[]` machine code.
#### Scenario: single instruction
- **WHEN** `AsmBuilder.Assemble("nop")` is called
- **THEN** the result is `[0x90]`
#### Scenario: multiple instructions
- **WHEN** `AsmBuilder.Assemble("pushad\npopad")` is called
- **THEN** the result is `[0x60, 0x61]`
#### Scenario: instruction with immediate operand
- **WHEN** `AsmBuilder.Assemble("mov eax, 1")` is called
- **THEN** the result is `[0xB8, 0x01, 0x00, 0x00, 0x00]`
### Requirement: AsmBuilder supports register operands
Supported registers: `eax`, `ecx`, `edx`, `ebx`, `esp`, `ebp`, `esi`, `edi` (and 8-bit: `al`, `cl`, `dl`, `bl`, `ah`, `ch`, `dh`, `bh`).
#### Scenario: register-to-register move
- **WHEN** `AsmBuilder.Assemble("mov eax, ecx")` is called
- **THEN** the result is `[0x89, 0xC8]` (mov eax, ecx encoding)
#### Scenario: register encoding
- **WHEN** registers are used in instructions
- **THEN** each register maps to its correct 3-bit encoding (eax=0, ecx=1, edx=2, ebx=3, esp=4, ebp=5, esi=6, edi=7)
### Requirement: AsmBuilder supports labels and jumps
Labels are defined with `@name:` and referenced with `jmp @name` or `je @name`. Forward and backward references are resolved in a second pass.
#### Scenario: forward jump
- **WHEN** `AsmBuilder.Assemble("jmp @skip\nnop\n@skip:\nret")` is called
- **THEN** the jump skips exactly over the `nop` (2 bytes) and lands on `ret`
#### Scenario: backward jump
- **WHEN** `AsmBuilder.Assemble("@loop:\nnop\njmp @loop")` is called
- **THEN** the jump targets the earlier label correctly
#### Scenario: multiple labels
- **WHEN** multiple labels are used in one source
- **THEN** each label resolves to its correct byte offset
### Requirement: AsmBuilder SetPassLimit controls iteration
`AsmBuilder.SetPassLimit(int limit)` sets the maximum number of assembly passes for label resolution. Default is 10. If the limit is exceeded before all labels resolve, `InvalidOperationException` is thrown.
#### Scenario: default pass limit
- **WHEN** no `SetPassLimit` is called
- **THEN** the assembler uses 10 passes maximum
#### Scenario: custom pass limit
- **WHEN** `SetPassLimit(20)` is called
- **THEN** the assembler uses 20 passes maximum
#### Scenario: pass limit exceeded
- **WHEN** forward references cannot resolve within the pass limit
- **THEN** `InvalidOperationException` is thrown with label resolution details
### Requirement: AsmBuilder reports clear errors
Unknown instructions, missing operands, and invalid register names produce `ArgumentException` with the line number and offending text.
#### Scenario: unknown instruction
- **WHEN** `AsmBuilder.Assemble("xyzw")` is called
- **THEN** `ArgumentException` is thrown mentioning line 1 and "xyzw"
#### Scenario: missing operand
- **WHEN** `AsmBuilder.Assemble("mov")` is called (no operands)
- **THEN** `ArgumentException` is thrown mentioning missing operand
### Requirement: AsmBuilder supported instruction set
The following x86 instructions are supported:
- **Data movement**: `mov`, `push`, `pop`, `pushad`, `popad`, `lea`
- **Arithmetic**: `add`, `sub`, `inc`, `dec`, `xor`, `and`, `or`, `cmp`, `test`
- **Control flow**: `jmp`, `je`, `jne`, `call`, `ret`, `nop`, `hlt`
#### Scenario: all instructions produce valid bytes
- **WHEN** each supported instruction is assembled individually
- **THEN** it produces the correct x86 machine code encoding
@@ -0,0 +1,44 @@
## 1. AsmBuilder — Core (TDD: tests first)
- [ ] 1.1 Create `BlackMagicTest/AsmBuilderTests.cs` with tests for single-instruction assembly: `nop``[0x90]`, `pushad``[0x60]`, `popad``[0x61]`, `ret``[0xC3]`, `hlt``[0xF4]`
- [ ] 1.2 Create `BlackMagic/Asm/AsmBuilder.cs` with `Assemble(string source)` entry point and instruction table. Implement `nop`, `pushad`, `popad`, `ret`, `hlt` to make step 1.1 tests pass
- [ ] 1.3 Add tests for `mov reg, imm32` (eax, ecx, edx, ebx, esp, ebp, esi, edi) — each register maps to `0xB8 + reg` encoding with 4-byte little-endian immediate
- [ ] 1.4 Implement `mov reg, imm32` in AsmBuilder to make step 1.3 tests pass
- [ ] 1.5 Add tests for `mov reg, reg` (register-to-register via ModRM byte 0xC0 + (src<<3 | dst))
- [ ] 1.6 Implement `mov reg, reg` to make step 1.5 tests pass
- [ ] 1.7 Add tests for `push reg` (`0x50 + reg`), `pop reg` (`0x58 + reg`)
- [ ] 1.8 Implement `push reg`, `pop reg` to make step 1.7 tests pass
- [ ] 1.9 Add tests for `add reg, imm8` (`0x83 /0` with sign-extended byte), `sub reg, imm8` (`0x83 /5`), `xor reg, reg` (`0x31 /r`), `and reg, imm8`, `or reg, imm8`, `cmp reg, imm8` (`0x83 /7`)
- [ ] 1.10 Implement `add`, `sub`, `xor`, `and`, `or`, `cmp` to make step 1.9 tests pass
- [ ] 1.11 Add tests for `inc reg` (`0x40 + reg`), `dec reg` (`0x48 + reg`), `test reg, reg` (`0x85 /r`)
- [ ] 1.12 Implement `inc`, `dec`, `test` to make step 1.11 tests pass
- [ ] 1.13 Add tests for `jmp @label`, `je @label`, `jne @label` with forward and backward references
- [ ] 1.14 Implement two-pass label resolution in AsmBuilder (pass 1: record label offsets, pass 2: patch jump targets). Make step 1.13 tests pass
- [ ] 1.15 Add tests for `call @label` (near call, E8 rel32) and `nop` multi-instruction sequences
- [ ] 1.16 Implement `call` to make step 1.15 tests pass
- [ ] 1.17 Add tests for error cases: unknown instruction → `ArgumentException` with line number, missing operand → `ArgumentException`, invalid register → `ArgumentException`
- [ ] 1.18 Implement error reporting to make step 1.17 tests pass
## 2. AsmBuilder — Pass Limit
- [ ] 2.1 Add tests for `SetPassLimit()`: default is 10, custom value is respected, exceeded limit throws `InvalidOperationException`
- [ ] 2.2 Implement `SetPassLimit(int limit)` and pass-limit enforcement in AsmBuilder to make step 2.1 tests pass
## 3. InjectAndExecuteEx — Non-blocking Execution
- [ ] 3.1 Add tests for `BlackMagic.InjectAndExecuteEx(IntPtr, IntPtr)`: returns `null` when no process open, returns valid handle when process open (mock or integration)
- [ ] 3.2 Implement `InjectAndExecuteEx(IntPtr startAddress, IntPtr parameter)` in `BMThread.cs` to make step 3.1 tests pass
- [ ] 3.3 Add test for single-parameter overload: `InjectAndExecuteEx(IntPtr)` passes `IntPtr.Zero`
- [ ] 3.4 Implement `InjectAndExecuteEx(IntPtr startAddress)` overload in `BMThread.cs`
## 4. Convenience Overloads (text → inject → execute)
- [ ] 4.1 Add tests for `InjectAndExecute(string asm)`: assembles text, allocates remote memory, writes bytes, executes, returns exit code. Test assembly failure → `ArgumentException`
- [ ] 4.2 Implement `InjectAndExecute(string asm)` in `BMInject.cs` to make step 4.1 tests pass
- [ ] 4.3 Add tests for `InjectAndExecuteEx(string asm)`: assembles text, allocates remote memory, writes bytes, returns thread handle. Test assembly failure → `ArgumentException`
- [ ] 4.4 Implement `InjectAndExecuteEx(string asm)` in `BMInject.cs` to make step 4.3 tests pass
## 5. Build Verification
- [ ] 5.1 Run full test suite: `"C:\Program Files\dotnet\dotnet.exe" test BlackMagicTest/BlackMagicTest.csproj` — all tests pass
- [ ] 5.2 Run full solution build: `"C:\Program Files\dotnet\dotnet.exe" build BlackMagic.slnx` — zero errors