feat(vm): [Phase 1 - 4] Dalvik VM interpreter — Interpreter Basic, Core Instruction Set, Method Dispatch & Android API Stubs#2
Open
haeter525 wants to merge 20 commits intoev-flow:mainfrom
Conversation
- vm/decoder.py: walk_method() static walk adapter over DalvikDisassembler DecoderTables type alias, MethodNotFound exception - cli/cmd_trace.py: dextrace trace subcommand with JSON envelope output matching cmd_disasm schema (version/format/source/method/instructions) stderr-only errors, exit codes 0/1/3 - cli/main.py: wire trace subcommand - tests/fixtures/samples/p1_const_return.dex: Lp1;->main()I fixture (const/16 v0, 42; return v0) built by tools/gen_p1_fixture.py - tests/test_vm_trace_p1.py: 9 tests — unit + CLI integration Verification: result['instructions'][0]['mnemonic'] == 'const/16' ✓ 59/59 tests pass, 0 regressions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
wrap_method() now catches ValueError/struct.error from DexResolver and DalvikDisassembler and re-raises as DexParseError. cmd_trace.py catches DexParseError and prints [ERROR] message, exits 3. Before: python traceback to stderr, exit 1 After: [ERROR] malformed DEX: File too small to contain a valid DEX header, exit 3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
apk_reader.read_file() raises KeyError when the entry doesn't exist. The old `if not dex_bytes` guard was dead code — the exception happens before it. Fix: check list_entries() before reading. Before: KeyError traceback to stderr, exit 1 After: [ERROR] APK does not contain classes.dex, exit 3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ISSUE-002: malformed DEX must raise DexParseError (not raw ValueError traceback) ISSUE-003: APK without classes.dex must exit 3 with [ERROR] (not KeyError) Found by /qa on 2026-04-11 Report: .gstack/qa-reports/qa-report-dextrace-2026-04-11.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add dextrace run subcommand: executes a method in the Dalvik VM interpreter with static analysis semantics (no JNI, no heap) - Add vm/engine.py: step-budget interpreter loop with opcode dispatch - Add vm/handlers/: arithmetic, branch, compare, field, move, array, type_conv instruction handlers - Add vm/state.py, call_frame.py, register_file.py, errors.py, int_ops.py - Add p2 fixture (Fib recursive DEX) + gen_p2_fixture.py - Add Phase 2 tests: test_vm_run_p1, test_vm_run_p2, tests/vm/ unit suite Review fixes (from /review): - decoder.py: widen DexParseError catch to include IndexError, KeyError, OverflowError — real malformed DEX triggers opcode table KeyError - cmd_trace.py: add is_file() guard to prevent IsADirectoryError traceback - cmd_trace.py: stage BadZipFile catch, flags filter, resolve() path fix, REVIEW-001 regression test (non-ZIP APK → exit 3) - decoder.py: remove unused DecoderTables type alias + dead imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract load_dex_bytes() to shared cli/_io.py and use it from both cmd_trace and cmd_run. dextrace run now accepts .apk files, extracts classes.dex, and returns clean errors when the APK is malformed or missing classes.dex. Previously gave "expected a .dex file, got: '.apk'". Also fixes ISSUE-005: add is_file() guard to cmd_run so a directory named *.dex gives exit 1 (user error) instead of exit 3 (parse error) with an internal OSError traceback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ISSUE-004: APK files rejected with wrong error in dextrace run ISSUE-005: directory-as-input gives wrong exit code (3 instead of 1) Found by /qa on 2026-04-11 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- cmd_run.py: suppress too-many-return-statements/branches on run() - move.py: suppress unused-argument on handle_nop (intentional no-op) - register_file.py: suppress protected-access in snapshot() (same class) - gen_p1/p2_fixture.py: rename HEADER_SIZE→header_size, _uleb128 out→buf - test_handlers.py: move imports to module top-level, remove unused imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gen_p1 and gen_p2 intentionally share boilerplate structure as standalone tools building different DEX test fixtures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- cmd_run.py: add --dump-regs flag to print non-zero register values after execution; add _print_registers() helper - engine.py: save final VMState after run(); expose final_registers property for post-execution register inspection - gen_p1_fixture.py: change method from ()I/return to ()V/return-void so Phase 1 demo exercises const/16 + return-void with --dump-regs - Regenerate p1_const_return.dex with updated bytecode and proto - Update all tests to use new entry sig Lp1;->main()V and void semantics Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ClassHierarchy: topological vtable construction from class_def_items - ObjectHeap: handle-based new-instance allocation - DalvikVM: invoke-virtual vtable dispatch, null receiver + vtable miss errors - trace_sink callback for --verbose: new-instance and invoke-virtual [INFO] lines - p3_inheritance.dex fixture: Base/Mid/Main hierarchy for vtable dispatch verification - 3 new trace_sink tests: new-instance trace, invoke-virtual dispatch trace, silent default Verification: dextrace run tests/fixtures/samples/p3_inheritance.dex --entry 'Lp3/Main;->entry()I' → return: 2
…OR] messages DESIGN.md specifies: [ERROR] null receiver: ... Previous output: [ERROR] VM error: null receiver: ... Engine error messages are already DESIGN.md-formatted. Drop the spurious 'VM error: ' and 'not implemented: ' infixes from cmd_run.py error handlers.
ObjectHeap entries become (class_desc, value) tuples with a new get_value() accessor so String handles can carry their Python str payload — required for stubs that capture string args (e.g. the SMS phone/message text). ClassHierarchy gains has_class() so external Android types (SmsManager, etc.) fall through to the external-miss policy instead of raising vtable-miss for an unknown class.
Adds vm/android_stubs/ — a Dict[sig, StubCallable] registry keyed by the full method signature, with a StubResult tagged union (VOID / Value / Wide / ObjectRef) so stubs can return any Dalvik result shape without leaking heap concerns. SMS stub family covers Ahmyth's IoC capture path: - SmsManager.getDefault() → ObjectRef to a fresh handle - SmsManager.sendTextMessage(...) → VOID; records destAddr/scAddr/text 13 unit tests cover registry contract, StubResult variants, arg materialization, and the SMS happy + null-receiver paths.
Wire the Android stub REGISTRY into invoke dispatch (sits in front of both vtable resolution and the external-miss path). Adds a Policy C external-miss policy: void misses are silent no-ops, non-void misses raise DexTraceNotImplementedError with full sig + pc. --strict-stubs escalates void misses to errors as well. Also accept str args to vm.run(): each is materialized on the heap as a Ljava/lang/String; handle so stubs can read it back via heap.get_value. Heap.reset() and api_calls.clear() at run() entry keep consecutive runs isolated. invoke-virtual on a class outside the parsed DEX (no-stub external) now falls through to external-miss instead of raising vtable miss. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… (P4) --arg now accepts strings and auto-parses ints (decimal or 0x-hex); '+'-prefixed values stay as strings so phone-number IoCs aren't silently downgraded. --args takes a JSON list for explicit mixed typing. --trace prints captured stub-call entries as JSON (also embedded under 'api_calls' in --json output). --strict-stubs flips the engine's external-miss policy to error on void misses too. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an integration test that runs Ahmyth's SMSManager.sendSMS(String,String)Z and asserts: - returns 1 on the linear no-throw path - api_calls captures SmsManager.getDefault() then sendTextMessage(...) - the analyst-supplied phone + message text reach the stub args - heap+api_calls reset between consecutive vm.run() calls - --strict-stubs and default policy both raise on non-stubbed APIs Sample is fetched lazily by tests/fixtures/p4_ahmyth_fetcher.py with SHA256 lock: $DEXTRACE_AHMYTH_APK → ~/.cache/dextrace/p4_ahmyth.apk → MalwareBazaar download. FetcherError → pytest.skip so offline runs stay green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DESIGN.md gains the P4 [ERROR] format for unknown Android APIs (full sig + pc) and the corresponding decision-log entry explaining why analysts need that level of detail to either skip, file, or stub. TODOS.md tracks scope intentionally cut from P4/P5/P6 MVPs: StringBuilder/Uri/static fields (P4.1), IOSocket URL demo (P4.2), try/catch dispatch (P5.1), const-string heap migration (P5.2), richer ExecutionTrace (P5.3), invoke-interface/polymorphic/custom (P5.4), coverage compare (P5.5), taint tracking (P6.1), --explain (P6.2). Each entry names the unblocking sample and the smallest scope. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Key Changes
Phase 1 - Interpreter Basic
Execution Engine
engine.py:DalvikVMmain loop with call-frame stack and invoke handlingerrors.py: DalvikVM runtime error typescmd_run.py: newdextrace runsubcommand to run the Dalvik VM interpreter_io.py: shared DEX/APK loader; handles APK with missingclasses.dexand malformed DEX inputRegister File
register_file.py: Dalvik register file (v0–v15) with bounds checkingint_ops.py: integer arithmetic with overflow semanticsCall Stack & Frame
call_frame.py: per-call register snapshot/restore for callee isolationstate.py:VMStatedataclass withpending_resultlifecycle forinvoke/move-resultPhase 2 - Core Instruction Set Support
handlers/: arithmetic, array, branch, compare, field, move, type_conv opcode handlersPhase 3 - Method Dispatch
Vtable Dispatch
class_hierarchy.py:ClassHierarchy— builds vtable per class via DFS topological sort; provides O(1)resolve_virtual(class_desc, name, proto) → code_offengine.py:invoke-virtualhandler reads runtime class from heap, dispatches through vtable;new-instancehandler allocates object on heapdex_class_iter.py:iter_class_defs/iter_class_data_methods— low-level class_def and encoded_method iteration used by vtable builderObject Heap
heap.py:ObjectHeap— integer handle → class descriptor; handle 0 reserved as null; reset betweenrun()calls for isolationVerbose Trace
cmd_run.py:--verboseflag wirestrace_sinkintoDalvikVM; emits[INFO]messages fornew-instance,invoke-virtualdispatch, and entry executioncmd_run.py:--dump-regsflag prints non-zero register values after executionPhase 4 - Android API Stubs & IoC Capture
Stub Registry
vm/android_stubs/__init__.py:REGISTRYkeyed by static callee signature; return-type sentinels (VOID,Value,Wide,ObjectRef);StubCallableprotocol so each stub gets(args, heap, api_calls)and records its own trace entryvm/android_stubs/sms.py:Landroid/telephony/SmsManager;->getDefault()andsendTextMessage(...)— first concrete IoC-capturing stubsEngine Wiring
engine.py: stub dispatch sits in front of vtable resolution and the external-miss path; Policy C external-miss (void → silent no-op, non-void →DexTraceNotImplementedErrorwith full sig + pc);--strict-stubsescalates void misses to errors;vm.run()acceptsstrargs and materializes them asLjava/lang/String;heap handlesheap.py: per-handlevalueslot so stubs can read back analyst-supplied strings viaheap.get_value(handle)class_hierarchy.py:has_class()probe letsinvoke-virtualdistinguish in-DEX classes from external ones (external falls through to stub/external-miss instead of vtable miss)CLI
cmd_run.py:--argauto-parses ints (decimal or0x-hex) and falls back to string (+-prefixed values stay as strings so phone-number IoCs aren't silently downgraded);--args '[...]'for explicit JSON typing of mixed lists;--traceprints capturedapi_callsas JSON;--strict-stubsflips the engine's external-miss policyTests + Sample Fetcher
tests/test_vm_run_p4.py: runs Ahmyth'sSMSManager.sendSMS(String,String)Zend-to-end and asserts the captured trace contains the analyst-supplied phone + message texttests/fixtures/p4_ahmyth_fetcher.py: SHA256-locked lazy fetcher ($DEXTRACE_AHMYTH_APK→~/.cache/dextrace/p4_ahmyth.apk→ MalwareBazaar);pytest.skiponFetcherErrorso offline runs stay greenDocs
DESIGN.md: adds the P4[ERROR] unknown Android API: <sig> (pc=...)format and a decision-log entry explaining why analysts need full sig + pcTODOS.md: tracks scope intentionally cut from P4/P5/P6 MVPs (StringBuilder/Uri/static fields, IOSocket URL demo, try/catch dispatch, const-string heap migration,invoke-interface, taint tracking,--explain)How to use
Installation
Phase 1 — execute
const/16 v0, #42 → return-voidv0should be42voidPhase 2 — execute
recursive Fibonacci55Phase 3 — vtable dispatch through inheritance
Lp3/Main;->entry()allocates aLp3/Mid;object and callsfoo()viainvoke-virtualLp3/Base;->foo()I→Lp3/Mid;->foo()I(override), returning2Phase 4 — capture an Android RAT's SMS exfil call
Tests
pytest tests$DEXTRACE_AHMYTH_APK; otherwise the P4 integration test skips)