English | 中文
Reverse-engineer JNI-native-obfuscated JARs back into readable Java
bytecode. Targets native-obfuscator
and its derivatives (e.g. j2cc) — anything that transpiles JVM bytecode
to C++ then re-invokes Java through the JNI from a packaged
.dll / .so.
Three complementary recovery paths:
| Path | Input | Approach |
|---|---|---|
| Dynamic | obfuscated jar + a runnable command | Attach a JVMTI agent, observe the JNI call stream, lift it back to JVM bytecode |
| Static | obfuscated jar + Ghidra | Locate the JNI method tables in the native blob, decompile each function, lift pseudo-C to JVM bytecode |
| Emulation | obfuscated blob (no run, no Ghidra) | Run the native code under a CPU emulator + mock JNI; recover the method table, dump decrypted constants, and call methods as pure-function oracles |
The dynamic/static paths emit a clean out.jar whose native methods now have
real bytecode bodies and whose loader / native-blob entries are stripped. The
emulation path recovers the C-only secrets the other two can't see (inlined
comparisons, the <clinit> string tables) and gives you an executable oracle.
License: GPLv3.
The best way to use this project is to load the bundled skill
(.claude/skills/j2c-deobfuscate)
into your favourite coding agent and let it do the work.
This project gives you a universal approach + tooling for the whole "transpile Java → C/C++ and call back via JNI" obfuscator family — but a universal approach unavoidably needs some adaptation to each specific target (reading a decompile, supplying per-method state, extending a harness, adding a profile). Today's AI agents handle exactly this kind of adaptation well.
So don't expect to just run the ready-made scripts by hand. Without that human/agent fix-up step, the results will be partial — not impressive. Hand the agent the skill and the target, and let it adapt the tools to the binary.
- JVMTI agent (
native/, C++). Loaded via-agentpath:. Subscribes toNativeMethodBind,MethodEntry,MethodExit,Exception,ExceptionCatchJVMTI events. - JNI function table swap. On
VMInitand everyThreadStart, the agent overwrites theJNIEnv->functionspointer with a copy whose ~80 entries are redirected through logging wrappers. Each wrapper delegates to the original function and records the call as a JSON line intrace.jsonl. VariadicCall*Methodflavours decode theirva_listagainst a per-class jmethodID descriptor cache. - Symbol-table propagation (
jvm/trace-to-bytecode/). The lifter walks the trace, classifies each jobject reference by what produced it (FindClass→ jclass,GetMethodID→ jmethodID, etc.), and emits the corresponding JVM op with fully resolved owner / name / desc. - SSA-style synthetic locals. Each jobject that the method reuses
across statements gets a synthetic local slot. The lifter emits
DUP + ASTORE <slot>after the producing JNI call andALOAD <slot>at every reuse site, so the recovered bytecode keeps real reference identity without re-deriving the value. - Operand-stack balancer. Tracks the live stack and inserts
POP/CHECKCAST/ACONST_NULLcorrections so the emitted sequence verifies under ASMCOMPUTE_FRAMES.
- Disassembly-level table discovery (
py/binary_introspect/,capstone). Walks the native blob's executable sections, locates everycall qword ptr [reg + 0x6B8](theRegisterNativesJNI vtable slot), backscans the preceding instructions for PC-relativeleas whose targets land in.text(= function pointers in theJNINativeMethod[]being built on the stack) plus the most recentmov <nMethods-reg>, imm(= the table size). - Ghidra decompiler (
ghidra/scripts/DumpFromManifest.java, Ghidra Headless). Reads the(class, method, fnAddr)triples frommanifest.jsonand runs Ghidra's p-code decompiler on each address, yielding a singleghidra-dump.jsonwith one pseudo-C body per method. - tree-sitter-c parse (
py/ast_matcher/,tree-sitter-c). Parses the pseudo-C, then walks the AST with a feature-flagged driver that recognisesenv->FnName(args)calls (rewritten from Ghidra's(**(code **)(*reg + 0xN))(...)form), JNI helper patterns, and exception-check guards. - Throw-reason inference. Native-obfuscator family obfuscators emit
"Cannot invoke X.Y.Z(args)"strings before every would-be Java call for use in runtime exception messages. The lifter extracts them as invoke hints and uses them as fallback (owner, name, args-desc) when symbol tracking can't resolve a jmethodID through obfuscator helpers. - Profile auto-detection. The active obfuscator's harvest strategy
(per-class vs shared-dispatch), throw-reason regex, and if-guard
skip rules all come from a :class:
Profileselected by scanning the binary against built-in detectors.
- Whole-blob emulation (
py/native_emulate/,unicorn). Maps the native blob (PE or ELF) into a CPU emulator and runs the obfuscated functions directly, under a mock JNI environment — a fakeJNIEnv/JavaVMwhose vtable slots trap back into Python. Because it executes the C, it observes what JNI tracing cannot: inlined comparisons (the check that never callsString.equals), the decrypted<clinit>string tables, and logic hidden behind control-flow flattening / MBA (the emulator runs the bytes; it doesn't try to structure them). recover— emulates the registrar (or readsJava_*exports /JNI_OnLoad) and capturesRegisterNativesto list every native method(name, sig, fnPtr). Fully automatic for native-obfuscator; j2cc needs the regc address (frombinary.json).strings— emulates a method and dumps its decrypted string constants (alphabets, secrets, messages) — the string table the other two paths leave as indexed accesses.call— oracle: invoke a recovered native method as a pure function, feed inputs, capture outputs. Turns "read 10k lines of flattened MBA" into input→output probing.- JVM-fixed JNI ABI is the foundation (
GetArrayLength= vtable index 171,RegisterNatives= 215,ExceptionCheck= 228, …), so the same engine generalizes across the family. Backends: x86-64 PE/Win64 and ELF/System-V. Seedocs/emulation-recovery.md.
- JSON pipeline. Every stage's input + output is a versioned JSON
artifact under
schemas/. - ASM (
org.objectweb.asm) drives all class-file emission.ClassWriter.COMPUTE_FRAMESis the verification gate; methods that trip stack-imbalance get a sentinel stub body and the jar still ships.
All three target the same input but trade off coverage versus accuracy:
| Dynamic | Static | Emulation | |
|---|---|---|---|
| Best fit | Binary is packed / VM-protected / has anti-debug — the JVMTI agent sits at the Java side of the wall so the native protection layer doesn't matter. | Binary is unprotected (e.g. straight native-obfuscator + zig c++ output). Ghidra can decompile each fnAddr directly. |
Logic is rewritten to pure C (comparisons / crypto / string tables), or the jar won't run and Ghidra can't structure the code, or you need the decrypted constants. |
| Requires | A runnable command line (java -jar ...) that exercises the obfuscated classes. |
Ghidra 11.x installed. | Just the blob + pip install unicorn. No JVM, no Ghidra. |
| Coverage | Only branches you actually run. | Every method registered through RegisterNatives. |
Method list + decrypted constants always; per-method behaviour via the oracle. |
| Accuracy | High — every opcode maps to an observed JNI call. | Best-effort — pattern matching on decompiled C, stubs on stack imbalance. | Exact (it executes the real code), but you reverse the algorithm from oracle I/O — it does not auto-emit bytecode. |
| Speed | Target run time + agent overhead. | Ghidra auto-analysis (minutes for ~1 MB blobs). | Fast — no Ghidra, no live JVM. |
- Static path correctness is best-effort. The lifter pattern-matches
Ghidra's pseudo-C; methods with control flow Ghidra didn't structure
cleanly produce stack-imbalanced bytecode that
class-rebuildersilently downgrades to a stub. The rest of the class still ships. - Dynamic path only sees branches the target actually executes. An
if/else where only the
ifran is recovered as if theelsedoesn't exist. Loop bodies show one iteration's worth of trace; concrete values get baked in as LDC unless flagged dynamic by the agent. - Pure-native control flow / arithmetic is invisible to both paths. When the obfuscator translates a computation that doesn't need a JVM round-trip (char-array manipulation, integer math) entirely to C++, no JNI calls happen, so neither dynamic tracing nor JNI-call-pattern matching sees anything. The recovered method body for these regions ends up empty or stubbed.
- AOT-translated logic is unrecoverable. Higher-end obfuscators detect Java code that doesn't require JVM cooperation (e.g. symmetric crypto, byte-array transformations, file I/O via POSIX/Win32) and emit it as straight native code instead of JNI-callback C++. That output has no JNI signature at all — both paths produce a stub for such methods. The only way back is reading the disassembly by hand.
- String literals in
<clinit>-decrypted tables remain unresolved. Each obfuscated class wraps its string constants in an XOR/rotate table decoded at class-load time. The lifter preserves the indexed accesses (Foo.a(0, 17)) verbatim instead of substituting values; running the cleaned jar's<clinit>once + snapshotting the table is on the roadmap (seedocs/ROADMAP.md).
Auto-generated from the actual snake end-to-end fixture in
e2e-test/snake/. Side-by-side syntax-highlighted comparisons of
original source vs Vineflower-decompiled recovery output for both
paths. Full catalogue in screenshots/README.md.
Static path — Snake.java end-to-end
Original snake source vs Vineflower-decompiled static-path output.
Capstone-based cache-table extraction binds every cclasses/cfields/
cmethods slot back to (owner, name, desc), then the lifter
pre-binds JNI param_2 to JVM local 0 so receivers render as this.
Dynamic path — Snake.java end-to-end
Same input via the JVMTI agent: every JNI call the obfuscated native code makes gets logged and lifted back to JVM bytecode. Bodies match javac output for the branches actually executed.
Static-path progression
Three stages of the static path on the same input — stub fallback, tier-2 unverified write, and the final state with cache-table + receiver binding. Each iteration adds another layer of semantic recovery.
JVMTI dynamic-path intermediates
How the dynamic path turns a runtime JNI-call stream into JVM bytecode:
the agent's trace.jsonl records, the per-method recovered/*.json
lifted bytecode artifact, and the connecting pipeline.
Two paths, same input: Board.java
The static path is faster and works offline but coverage depends on per-obfuscator pattern matching. The dynamic path requires running the target but produces near-pristine bytecode for any code path that actually executes during the trace.
Manual restoration · dynamic path
What a 10–15 minute hand pass over the dynamic auto-output looks like:
drop the SSA-slot Object varN = null; declarations, inline single-use
temporaries, replace trace-baked constants with the symbolic form, and
restore the branches the trace never executed. Workflow:
docs/manual-restoration.md.
Manual restoration · static path
Heavier human inference, but the intermediate artifacts keep it
grounded: recovered/*.json records the opcode sequence the lifter
extracted, and manifest.json.cacheTable resolves every ?.? to a
real (owner, name, desc) triple — even when the decompiler couldn't
render them.
# JVM modules
cd jvm && ./gradlew installDist
# Python workspace
cd py && uv sync --all-packages
# Native agent (only needed for the dynamic path)
cd native && JDK_HOME="$JAVA_HOME" bash build.sh
# Emulation path
cd py && .venv/Scripts/python -m pip install unicorn # or your venv's pippython -m j2c_dumper_cli.main recover \
path/to/obfuscated.jar \
-o path/to/clean.jar \
--run-cmd "java -jar path/to/obfuscated.jar"This chains:
parse-jar→classes.jsoninspect-binary(auto-extracts the native blob from the jar)merge-manifest→manifest.jsondynamic-traceruns the target with the JVMTI agent →trace.jsonltrace-to-bclifts torecovered/*.jsonrebuildemits the loader-stripped output jar
# 1. Parse jar + introspect binary as above (no --run-cmd needed)
python -m j2c_dumper_cli.main parse-jar in.jar -o classes.json
python -m j2c_dumper_cli.main inspect-binary natives.bin -o binary.json
python -m j2c_dumper_cli.main merge-manifest classes.json binary.json -o manifest.json
# 2. Run Ghidra headless against the native blob
<GHIDRA>/support/analyzeHeadless.bat <project-dir> proj \
-import natives.bin \
-scriptPath <repo>/ghidra/scripts \
-postScript DumpFromManifest.java manifest.json ghidra-dump.json
# 3. Lift the pseudo-C to bytecode + rebuild
python -m ast_matcher.cli ghidra-dump.json --manifest manifest.json -o recovered/
python -m j2c_dumper_cli.main rebuild --input in.jar --recovered recovered/ \
--manifest manifest.json -o out.jar# list native methods (entry points auto-discovered)
python py/native_emulate/j2c_emu.py recover natives.bin --binary-json binary.json
# dump a function's decrypted string constants (alphabet, secret, messages)
python py/native_emulate/j2c_emu.py strings natives.bin --fn 0x<addr>
# call a native method as a pure function (oracle)
python py/native_emulate/j2c_emu.py call natives.bin --fn 0x<addr> \
--arg-bytes "input" --static "v=@alphabet.txt"Full walkthrough: docs/emulation-recovery.md;
command reference + verified matrix: py/native_emulate/README.md.
Every stage has its own subcommand under j2c-dumper; see
python -m j2c_dumper_cli.main --help for the full list.
The project ships with two obfuscator profiles that auto-detect:
native_obfuscator— radioegor146/native-obfuscator + compatible derivativesj2cc— me.x150.j2cc (single sharedinitClassdispatch)generic— fallback when no profile matches; uses pure JNI-spec knowledge only
Custom variants can plug in a new profile without touching the main flow.
See docs/adding-obfuscator-profile.md.
The static path's lifter exposes every inference / matching step as a feature flag (throw-reason hint parsing, ExceptionCheck-guard skipping, symbol-table tracking, lookup-table resolution, etc.). Disable a flag when it misbehaves on a specific binary:
python -m ast_matcher.cli ghidra-dump.json -o recovered/ \
--disable use_throw_reason_invoke_hints \
--disable skip_native_exception_guards
python -m ast_matcher.cli --list-flags├── jvm/ Kotlin/ASM modules (Gradle multi-project)
│ ├── jar-parser/ input.jar → classes.json
│ ├── trace-to-bytecode/ manifest + trace.jsonl → recovered/*.json
│ ├── class-rebuilder/ input.jar + recovered/ → output.jar
│ └── common/ shared schema types
├── native/ C++ JVMTI agent (zig c++ build)
├── ghidra/scripts/ Ghidra headless scripts (Java)
├── py/ Python modules (uv workspace)
│ ├── jar_parser/ —
│ ├── binary_introspect/ .dll / .so / natives.bin → binary.json
│ │ ├── arch/ per-arch / ABI implementations
│ │ ├── jni_tables.py RegisterNatives table discovery
│ │ ├── profile.py obfuscator-variant profiles
│ │ └── stub_recovery.py synthesize stub bodies for unrecovered methods
│ ├── manifest_merge/ classes.json + binary.json → manifest.json
│ ├── ast_matcher/ pseudo-C → JVM bytecode
│ │ └── lifter/ driver + per-feature submodules
│ ├── j2c_dumper_cli/ top-level CLI orchestrator
│ ├── native_emulate/ emulation path: j2c_emu.py (Unicorn + mock JNI)
│ └── snippet_importer/ (optional) native-obfuscator cppsnippets ingestor
├── .claude/skills/ j2c-deobfuscate skill (agent playbook)
├── docs/ ARCHITECTURE.md, ROADMAP.md, profile guide, …
├── schemas/ JSON Schema for every artifact
└── tests/ e2e fixtures and pipeline tests
- ARCHITECTURE.md — module boundaries, pipeline, artifact schemas, extension points
- emulation-recovery.md — emulation path how-to
(+ command reference in
py/native_emulate/README.md) - manual-restoration.md — hand-cleaning recovered output
- ROADMAP.md — known limitations and planned work
- adding-obfuscator-profile.md — how to register a new obfuscator variant
- static-reverse-approach.md — design notes for the Ghidra-based path
.claude/skills/j2c-deobfuscate— the agent playbook (load this into your coding agent)
Released under GPL v3. See LICENSE.






