Production-oriented .NET 10 console application for static disassembly and first-pass triage of PE / ELF / Raw binaries, using either Capstone or iced-x86 as an interchangeable disassembly backend, with function discovery, normalized semantic analysis, and an optional AI pass (OpenRouter) over the results.
Build: 0 errors · Tests: 430/430 (Release, xUnit) · Target: net10.0 · Engines: Iced 1.21.0 + Gee.External.Capstone 2.3.0 (native Capstone 4)
Capstone Engine 5 - no. The best-maintained managed .NET binding,
Gee.External.Capstone
(v2.3.x, referenced in the .csproj), currently wraps the native Capstone 4
library — there is no mainstream managed wrapper for Capstone 5 published on
NuGet at the time of writing. I'm not going to pretend otherwise.
The code is architected so this is a non-issue going forward:
CapstoneDisassemblerEngine is fully isolated behind IDisassemblerEngine,
and all Capstone-specific API calls live in one file
(Disassembler/Capstone/CapstoneDisassemblerEngine.cs). If/when you want true
Capstone 5:
- Option A (recommended, given your P/Invoke background):
P/Invoke directly against a native
capstone.dll/libcapstone.sobuilt from the Capstone 5 source tree. The public C ABI (cs_open,cs_option,cs_disasm,cs_free, etc.) is stable between major versions, so this is a contained, well-scoped change — swap the internals ofCapstoneDisassemblerEngine, keep theIDisassemblerEnginecontract. - Option B: Track
Gee.External.Capstoneupstream and bump the package version once/if it ships a Capstone 5 build.
Native library probing is handled explicitly: framework-dependent builds probe
runtimes/<RID>/native/capstone.dll via deps.json, self-contained publish
places capstone.dll next to the exe. If the native is absent, the engine
fails cleanly with CapstoneNativeLibraryException:
Не найдена нативная библиотека capstone.dll — движок разбора Capstone недоступен.
Куда положить : рядом с BinaryAnalyzerCLI.exe
Где взять : runtimes/win-x64/native/capstone.dll или пересоберите (dotnet build -c Release)
No partial AnalysisResult is produced — the disassemble command aborts with a red diagnostic (Ghidra-style philosophy: full correct result or honest failure).
Everything else (iced-x86 engine, PE/ELF parsing, exporters, AI integration, CLI) has no such caveat and is fully functional.
BinaryAnalyzerCLI/ (81 .cs files: 62 prod + 19 test)
Core/
Interfaces/ IDisassemblerEngine, IAIProvider, IBinaryParser, IExporter,
IInstructionProvider, IFunctionDiscovery,
IFunctionInstructionStatisticsService
Models/ Architecture, BinaryFormat, InstructionInfo + InstructionSemantics,
OperandSemantics, NormalizedFlowControl, OperandKind/Access,
InstructionCategory, InstructionPrefixes, RegisterId,
BinaryFileInfo, AnalysisResult, FunctionInfo, FunctionSource,
FunctionInstructionStatistics, DecodingIdentity, RegionDescriptor,
ExecutableRegion, AppSettings, AiSettings, StringMatch, SuspiciousApiMatch
Disassembler/
Capstone/ CapstoneDisassemblerEngine + CapstoneSemanticAdapter
CapstoneNativeLibraryException (native-load diagnostics)
Iced/ IcedDisassemblerEngine + IcedSemanticAdapter
DisassemblerEngineFactory.cs (Resolve + ResolveForArchitecture + ResolveTypeForArchitecture)
Analysis/
BinaryFormatDetector.cs PE/ELF magic + machine-type detection
PeAnalyzer.cs PE header/section/import parsing
ElfAnalyzer.cs ELF32/64 header/section parsing (little/big endian)
RawAnalyzer.cs fallback for headerless/raw binaries
OnDemandInstructionProvider.cs streaming provider (ExecutableRegion[] + DecodingIdentity, O(1) retained)
FunctionInstructionService.cs on-demand re-decode of a function slice (256 KiB, deterministic replay)
FunctionDiscoveryService.cs entry-point + direct-call regex + prologue heuristics (push rbp / stp x29)
FunctionBoundaryResolver.cs group-by-region, EndAddress/EndByteAddress/InstructionCount, deterministic
FunctionInstructionStatisticsService.cs streaming statistics from normalized Semantics
StaticAnalysisService.cs orchestrates parse → regions → disassemble → estimator → provider → discovery
StringExtractor.cs ASCII + UTF-16LE
SuspiciousApiScanner.cs IOC / suspicious API signatures
AI/
OpenRouter/ OpenRouterProvider : IAIProvider
AiAnalysisService.cs bounded prompt context (4000 instr) from AnalysisResult
Export/
AssemblyExporter.cs streaming ASM.txt writer
HexExporter.cs streaming hex.txt writer
Config/
SettingsManager.cs loads/saves Settings.json + Ai.json
CLI/
Commands/ OpenCommand, SettingsCommand, DisassembleCommand,
ExportAsmCommand, ExportHexCommand, AiAnalyzeCommand,
FunctionsCommand, HelpCommand, ICommand
AppSession.cs current file / analysis state (single AnalysisResult)
CommandRouter.cs keyword → ICommand dispatch (longest match, try/catch)
ConsoleUI.cs colored output + in-place ProgressBar
Tests/ (19 files, 430 tests)
Helpers/StatisticsTestHelper.cs deterministic single-function PE/ELF builders
InstructionSemanticsTests.cs / Iced/Capstone/Arm/CrossEngine parity
FunctionDiscoveryTests.cs (F1 byte-slicing, boundaries, retrieval)
StatisticsBaselineTests.cs / FunctionInstructionSemanticStatisticsTests.cs
StatisticsIntegrationTests.cs / StatisticsPerformanceTests.cs
IdentityEngineResolutionTests.cs / CapstoneNativeFailureTests.cs
+ Elf/Pe/InstructionProvider/PreservedInvariants etc.
Program.cs DI container wiring + interactive REPL loop
Settings.json / Ai.json default config, copied to output on build
docs/C3_GATE.md final C.3 Statistics Migration gate document
Binary Open
↓ BinaryFormatDetector → PeAnalyzer / ElfAnalyzer / RawAnalyzer
BinaryFileInfo + ExecutableRegion[] (owned buffers)
↓ DisassemblerEngineFactory.ResolveForArchitecture → Iced / Capstone
OnDemandInstructionProvider(ExecutableRegion[] + DecodingIdentity{Engine,EffectiveArch,ChunkSize} + Count)
↓ DisassembleStreaming (256 KiB, resync, progress)
InstructionInfo + InstructionSemantics{IsValid, FlowControl, BranchTarget, Category, Prefixes, Op0..Op3}
↓ FunctionDiscovery (entry + call-target + prologue) → FunctionBoundaryResolver → FunctionInfo{Start,End,EndByte,Count}
AnalysisResult{FileInfo, Instructions:IInstructionProvider, EngineUsed(effective), Functions, EstimatedFunctionCount}
↓ FunctionInstructionService.GetInstructions(FunctionInfo) — fresh engine, 256 KiB, streaming to EndAddress
FunctionInstructionStatisticsService.ComputeStatistics — fully semantic:
total++ always; if (!IsValid) skip flow/category/memory
flow → switch(FlowControl) Call/IndirectCall→Call, Uncond/IndirectBranch→Jump, ConditionalBranch→Cond, Return→Ret
category → switch(Category) Arithmetic/Logical/Stack
memory → for k<OperandCount: IsMemory && Access!=None → hasMemory/isRead/isWrite → memOp/read/write/RW
ByteSize = EndByteAddress-Start+1; Complexity = 1+ConditionalBranchCount
↓ FunctionInstructionStatistics{Total,Call,Jump,Cond,Ret,Branch,MemOp/R/W/RW,Arith,Logical,Stack,ByteSize,Complexity}
↓
CLI (FunctionsCommand FullText, 256 KiB replay) / Export (BytesHex/FullText) / AI (FullText excerpt)
Design points relevant to your stated requirements:
- DI everywhere:
Program.cswires every service throughMicrosoft.Extensions.DependencyInjection; commands, engines, parsers and exporters are all resolved from the container, notnew'd ad-hoc. - Engine interchangeability + fallback: both engines implement
IDisassemblerEngineexactly;DisassemblerEngineFactoryresolves the active one fromSettings.json, with automatic fallback to Capstone for ARM/ARM64 since iced only covers x86/x64. The effective engine type (after fallback) is snapshotted intoDecodingIdentity/AnalysisResult.EngineUsed, so on-demand replay always resolves the engine that actually decoded the bytes (S14 fix). - Streaming / memory:
OnDemandInstructionProviderretains onlyExecutableRegion[]byte buffers +DecodingIdentity;InstructionInfoobjects are transient.DisassembleStreamingusesBufferedStream(256 KiB) with overlap, andFunctionInstructionServicere-decodes only the function's byte slice with the same chunk size — noList<InstructionInfo>is ever retained. Statistics stream once per function (Time O(N), retainedO(1)). Verified: per-iteration allocation ratio large/small 7.1× for 500× instructions (not retained list). - Normalized semantics: Every decoded instruction carries
InstructionSemantics(112 B inline value type, zero heap allocations per instruction,IsValid-first contract,RegisterIdnon-canonical per (Arch,Engine)). Adapters are the only place whereIced.Intel.*/Gee.External.Capstone.*types appear. Statistics, discovery, and future CFG/DataFlow consume semantics, neverMnemonic/Operandstext. Text remains the single source for display/export/AI. - Determinism & parity:
OnDemandInstructionProviderreplays identically;CrossEngineSemanticParityTestspins 62 x86 encodings and ARM smoke tests;where.exeIced vs Capstone control-flow identical;libCrossOff.so(ARM32) andGameAssembly.dll(x64) validated on real functions. - Progress reporting:
IProgress<double>flows from the engine throughStaticAnalysisServicetoConsoleUI.ProgressBar, rendered in-place as[████████░░] 80%. - Exceptions: parsers/engines catch and log malformed-input failures and degrade to best-effort metadata rather than crashing the session; the
CommandRouterwraps every command execution so one bad command can't kill the interactive loop. Missing native Capstone is a typed clean failure (see caveat above).
- .NET 10 SDK
- Internet access on first build (NuGet restore) for:
Iced,Gee.External.Capstone,Microsoft.Extensions.*
Note on System.CommandLine: it was intentionally left out. It's built for one-shot myapp <verb> <options> invocations, not the persistent BinaryAnalyzerCLI> REPL this spec calls for — using it would mean fighting its parser model rather than benefiting from it. The custom CommandRouter / ICommand pair in CLI/ fills that role instead and is trivial to extend. If you'd rather standardize on it for the initial-argument case (BinaryAnalyzerCLI.exe open malware.exe) specifically, that's a clean, isolated addition in Program.cs.
cd BinaryAnalyzerCLI
dotnet restore
dotnet build -c Release
dotnet test -c Release # 430/430dotnet run -c Release
# or, after publish:
dotnet publish -c Release -o out
./out/BinaryAnalyzerCLI # Linux/macOS
out\BinaryAnalyzerCLI.exe # WindowsEdit Ai.json (copied next to the built exe) and set a real ApiKey before using ai analys:
{
"Provider": "OpenRouter",
"ApiKey": "sk-or-v1-...",
"Model": "anthropic/claude-sonnet-5",
"Endpoint": "https://openrouter.ai/api/v1/chat/completions",
"Temperature": 0.2,
"MaxTokens": 4096
}Settings.json controls the disassembler engine, export directory, output format and whether AI features are enabled; it's also editable live via the settings command.
BinaryAnalyzerCLI.exe open malware.exe
BinaryAnalyzerCLI> settings
[1] Disassembler engine : Iced (Capstone 5 / iced-x86)
...
select> 1
[1] Capstone 5
[2] iced-x86
engine> 2
BinaryAnalyzerCLI> disassemble
Disassembling malware.exe using Iced engine...
Progress: [██████████████████████████████] 100%
Disassembly complete.
Engine used: Iced
Instructions decoded: 184,213
Estimated functions: 1,042
Strings extracted: 3,881
Suspicious API hits: 6
Suspicious API usage detected:
- VirtualAlloc [MemoryAllocation]
- CreateRemoteThread [ProcessInjection]
- LoadLibraryA [DynamicLoading]
...
BinaryAnalyzerCLI> functions
Functions: 104
# Start Address End Address Instructions Source Confirmations
------------------------------------------------------------------------------------------
1 0x140001300 0x140001327 26 entrypoint 1
...
BinaryAnalyzerCLI> export asm
Exported assembly listing to 'Exports/ASM.txt'.
BinaryAnalyzerCLI> export hex
Exported hex dump to 'Exports/hex.txt'.
BinaryAnalyzerCLI> ai analys
Entering AI analysis mode. Ask a question, or type 'back' to return.
BinaryAnalyzerCLI AI> Find potential vulnerabilities
Analyzing (this may take a few seconds)...
=== AI Analysis ===
...
00401000:
55
push rbp
00401001:
4889E5
mov rbp,rsp
OFFSET | BYTES | ASCII
------------------------------------------------------------------------
00000000 | 4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00 | MZ..............
Per-function FunctionInstructionStatistics is now derived only from InstructionInfo.Semantics (no Mnemonic/Operands parsing):
TotalInstructions— every yieldedInstructionInfo(including(bad))CallCount—FlowControl.Call+IndirectCall(coverscall rel32,call rax/[rax], ARMbl/blr)JumpCount—UnconditionalBranch+IndirectBranchConditionalBranchCount—ConditionalBranch(coversjcc,loop/jcxz/jecxz/jrcxz, ARMb.eq/cbz/tbnz)RetCount—Returnonly (notInterruptReturn)BranchCount→Jump + ConditionalMemoryOperationCount/Read/Write/ReadWrite— per-instructionOperandKind.Memory && Access!=None+HasRead/HasWrite(ConditionalReadWritecounts as both;lea/nop [mem]excluded viaAccess.None;rep movsbwith 2 mem operands aggregated as 1ReadWrite)ArithmeticInstructionCount/LogicalInstructionCount/StackInstructionCount—InstructionCategory(GetX8622/29/12 entries,GetArm/GetArm64scoped;cmp/adcx/shl/pushfqnow correctly counted)ByteSize—EndByteAddress - StartAddress + 1ComplexityEstimate→1 + ConditionalBranchCount(project-specific, not full CFG)
Validated on where.exe, kernel32.dll, GameAssembly.dll, libCrossOff.so (ARM32) with 62-encoding cross-engine parity.
dotnet test -c Release
# 430 tests: PE/ELF/Raw parsing, streaming, multi-section, unknown-arch,
# function discovery/boundaries/retrieval, semantic adapters (Iced/Capstone/ARM),
# cross-engine parity, statistics baseline (23) + semantic (35) + integration (9)
# + performance smoke (3), identity resolution, native-load failurePerformance smoke (Tests/StatisticsPerformanceTests.cs) asserts hot path contains no ToLowerInvariant/Contains('[')/ToList and that large/small allocation ratio stays bounded (streaming, not retained list).
- New disassembler engine: implement
IDisassemblerEngine, register it inProgram.cs, add a branch inDisassemblerEngineFactory. - New AI provider (OpenAI, local model, ...): implement
IAIProvider, swap theAddHttpClient<IAIProvider, ...>()registration inProgram.cs. - New export format: implement
IExporter, register asAddSingleton, add a CLI command that calls it (mirrorExportAsmCommand). - New container format (Mach-O, .NET/IL2CPP metadata, ...): implement
IBinaryParser, register alongsidePeAnalyzer/ElfAnalyzer. - New statistics: add a
Categoryvalue toInstructionCategoryTableand a counter inFunctionInstructionStatisticsServicethat reads it — no string work needed.