native-obfuscator reads a JAR, transpiles selected method bodies into JNI C++, and replaces those
methods with native stubs. The JVM then dispatches into the generated shared library.
The default generator is typed CFG IR (--codegen=ir). Unsupported methods keep their original
bytecode. --codegen=legacy is gone. --ir-lower=eval and --backend=interpreter exist and are
off by default; they are not a support claim. Java 8 is the only version this project has called
fully supported.
Important
This tool transpiles bytecode to native. It does not pack binaries and does not, by itself, hide algorithm identity from analysis. Use a blacklist/whitelist — transpiling a whole application JAR (for example a game client) is usually the wrong default.
- How it works
- Current status
- IR runtime
- Self-hosting
- Codegen modes
- Quick start
- Prerequisites
- Usage
- Zig toolchain
- C++ SDK (generated JARs)
- Repository layout
- IR compiler internals
- Building and tests
- Documentation
- Contributing
- Issues
- Load + filter —
NativeObfuscator.processstreams the JAR, parses classes with ASM intoClassNodes, and appliesClassMethodFilter(blacklist/whitelist plus@Native/@NotNative). - Bytecode preprocessing —
IndyPreprocessorandLdcPreprocessorlowerinvokedynamicand handle/type constants; the class is re-serialized withCOMPUTE_MAXS | COMPUTE_FRAMESso frames are authoritative before codegen. Classes that still containjsr/retafter a failed inline useCOMPUTE_MAXSonly (ASM cannot compute frames for those instructions). - Per-method codegen —
NativeObfuscator's method loop is the dispatch point. Each selected method goes to the typed CFG IR path (IrMethodCompiler) or, when--backend=interpreteradmits it, the in-process interpreter (InterpreterMethodEmitter, default off). Interpreter misses retry IR. IR misses restore the original method bytecode.--ir-lower=evalis a lowering inside the IR compiler (InterpreterStreamStrategy); it is not the interpreter backend. Eval misses retry direct IR. - Class assembly —
ClassSourceBuilder,CMakeFilesBuilder,MainSourceBuilder, andStringPoolwrite thecpp/tree and rewrite the selected methods intonativestubs. - Runtime helpers —
native_jvm.{cpp,hpp}andstring_pool.{cpp,hpp}are copied alongside the generated sources. - Native build — CMake, or Zig when
--use-zigis set. This is a separate step; the tool does not compile binaries by itself in the default flow.
At runtime, the loader class loads the shared library and the JVM dispatches the native stubs into
it over JNI:
sequenceDiagram
participant App as java -jar output.jar
participant JVM as JVM
participant Lib as Generated shared library
App->>JVM: start main class
JVM->>JVM: loader class runs System.load(native0/...)
JVM->>Lib: register generated methods (__ngen_register_methods)
JVM->>Lib: invoke native stub method
Lib->>JVM: JNIEnv callbacks (fields, methods, objects)
Lib-->>JVM: return value or pending exception
A longer visual walkthrough lives in
docs/architecture/overview.md.
Recorded on master after
#118/#119
and the follow-up landings through
#456. Active goal:
docs/architecture/current-goal.md. Status detail:
docs/architecture/project-status.md.
| Topic | What is true |
|---|---|
| Active goal | Method-body codegen is IR. The snippet path is deleted. Remaining unsupported shapes restore original bytecode |
| Default generator | ir (typed CFG). --codegen=legacy is removed |
| IR coverage | typed CFG through phase 20 plus LCMP, IF_ACMP*, monitors / synchronized, preprocessor-lowerable invokedynamic, proven ConstantDynamic (class and interface), raw MethodHandle / MethodType LDC, primitive Class LDC, and well-formed jsr/ret inlining. Unsupported methods restore original bytecode |
| Classfile metadata | Input major versions are preserved (Java 8 floor only). Nest / record / sealed attributes are no longer wiped by forcing version 52 |
| Java baseline | Historical README claim remains: Java 8 is the only version this project has ever called fully supported. 9+ and Android stay experimental |
| JDK 17 IR fixtures | 11 --release 17 programs matched HotSpot stdout on one Linux x86-64 VM under --codegen=ir. That is not a product “supports JDK 17” badge |
| JDK 21 IR fixtures | 6 --release 21 programs matched on one Linux VM after the local-type split. Not “supports JDK 21” |
| JDK 25 IR fixtures | 4 --release 25 programs matched on one Linux VM (Temurin 25.0.4.1+1). 20/21 IR; one hybrid constructor left in Java; JEP 472 warning on every transformed run. Not “supports JDK 25” |
| ClassicTest IR admission | 108/108 methods admitted on the phase-18 corpus (admission ≠ behavioral E2E) |
| Native-access packaging | Output JARs emit Enable-Native-Access: ALL-UNNAMED (java -jar). Classpath still needs --enable-native-access=ALL-UNNAMED. Not “supports JDK 25” |
| C++ SDK | NativePrimitives + NativeStrings in generated JARs. Not a shipped standalone product SDK |
| Interpreter | --backend=interpreter default off (cpp). ISA v4: static int/long plus references and a first exception table (ATHROW). No NEW, invoke, or fields. Not a protection product |
| Shared evaluator | --ir-lower=eval is default off (direct) and applies only to successfully built IR methods in its narrow integer slice. Not ship-ready |
| Reader / analysis bar | Unmet. Live IR and opcode artifacts were recovered by unaided readers in the recorded evals |
| Performance | Latest three-mode run: docs/benchmarks/results-ir-vs-legacy-phase19.md. Not a portable speedup. Prefer a whitelist |
| Self-hosting | One-machine two-generation run recorded below. Not CI, not a support badge |
Default --codegen=ir C++ does the following. None of it is a HotSpot speedup.
- Hoist class, field, and method IDs at method entry
- Skip redundant
ExceptionCheckpolls - Buffer own-class non-volatile
intinstance fields - Pin
int[]withGetIntArrayElements - Skip unused
lookup/ local-ref bookkeeping; instance methods still resolve the declaring class --ir-direct-native=on(off by default): same-class static IR calls become C++ calls instead ofCallStatic*Method. Deep recursion then uses the C stack and may abort instead of throwingStackOverflowError- Benchmark harness:
mixed-pricingandthin-pricingare checksum/regression kernels, not speedup evidence.BENCH_DIRECT_NATIVE=onturns on direct calls for a bench run
Recorded 2026-09-02 on one macOS arm64 machine (Temurin 25, Apple clang as gcc/g++). This is
local evidence that the IR pipeline can compile its own compiler, not a portable performance
claim and not a “supports every class” badge.
Whitelist: by/radioegor146/**. Blacklist: by/radioegor146/compiletime/** (loader templates
copied into generated JARs). No extra classes were dropped to make the run pass.
| Generation | How it was produced | Own classes processed | Methods restored | Then used to transpile product benches |
|---|---|---|---|---|
| gen1 | Unobfuscated obfuscator.jar transpiles itself |
211 | 3 | All five kernels stayed on IR |
| gen2 | gen1 transpiles the same unobfuscated obfuscator.jar |
211 | same 3 | All five kernels stayed on IR |
The three restored methods were already fail-closed leftovers (not new exclusions):
InterpreterStreamStrategy$Serializer.<init>(Lby/radioegor146/ir/IrMethod;)VInterpreterStreamStrategy.validate(Lby/radioegor146/ir/IrMethod;)VZigTarget.<clinit>()V
gen2 --help ran. The benches it emitted were compiled and run; checksums matched the original
plain-JVM JAR for integer-loop, string-concat-hash, recursion, mixed-pricing, and
thin-pricing. IF_ACMPEQ / IF_ACMPNE lower through JNI IsSameObject (C++ pointer equality
on two local refs is not Java identity).
How the flags interact for each selected method:
flowchart TD
A["Method selected for transpilation"] --> B{"--backend interpreter and ISA admits?"}
B -- "yes" --> C["InterpreterMethodEmitter"]
B -- "no" --> D{"IR admits the method?"}
D -- "no" --> E["Restore original bytecode"]
D -- "yes" --> F{"--ir-lower ?"}
F -- "direct (default)" --> G["DirectCppStrategy: structured C++"]
F -- "eval (default off)" --> H{"Evaluator admits?"}
H -- "yes" --> I["Shared evaluator lowering"]
H -- "no" --> G
C --> J["Generated C++ into cpp/ tree"]
G --> J
I --> J
--backend=interpreter is a separate, default-off switch (--backend=cpp is the default): a narrow
in-process interpreter at ISA v4 (static int/long, references, ATHROW/exception table; no
NEW, invokes, or fields). It is not a protection product.
# 1. Transpile
java -jar native-obfuscator.jar app.jar out/
# 2. Compile the generated C++ (recorded IR builds used CC=gcc CXX=g++)
cd out/cpp && cmake . && cmake --build . --config Release
# 3. Copy the shared library from build/libs/ into the loader dir printed on stdout (native0/)
# 4. Run
java -jar out/app.jarOr replace steps 2–3 with the built-in Zig toolchain (--use-zig).
- JDK — JDK 8 is enough for the historical path. The current test/E2E work was also run with
newer JDKs (for example 21) as the host compiler. You still need a JDK with
jni.hwhen compiling generated C++. - CMake — cmake.org or your distro package.
- C/C++ toolchain — MSVC or MinGW on Windows;
g++on Linux/macOS. Default Clang in some environments cannot linklibstdc++; the recorded IR E2E usedCC=gcc CXX=g++. - Optional: Zig for
--use-zig(see below). - Optional for the full test suite: Krakatau (
krak2) onPATH.
Usage: native-obfuscator [-ahV] [--debug] [--codegen=<mode>]
[--ir-lower=<lowering>] [--backend=<backend>]
[-b=<blackListFile>] [--custom-lib-dir=<dir>]
[-l=<librariesDirectory>] [-p=<platform>]
[--plain-lib-name=<libraryName>] [-w=<whiteListFile>]
[--use-zig] [--zig-targets=<targets>]
[--zig-path=<file>] [--jdk-home=<file>]
[--zig-install-dir=<dir>]
<jarFile> <outputDirectory>
| Argument | Meaning |
|---|---|
<jarFile> |
Input JAR |
<outputDirectory> |
Where the transformed JAR and cpp/ tree are written |
-l |
Directory of dependent libraries (optional, recommended) |
-p |
hotspot (default), std_java, or android |
--codegen |
ir (default; only remaining value) |
--ir-lower |
direct (default) or eval |
--native-intrinsics |
safe (default), off, or fast. Replaces selected JDK calls (String.length/hashCode/charAt/isEmpty, System.arraycopy, Math.abs/min/max for int/long). fast also replaces Integer/Long bitCount and numberOfLeadingZeros. File I/O is never rewritten. |
--backend |
cpp (default) or interpreter (narrow int/i64/reference slice; default off) |
--ir-cf-obf |
off (default) or basic. basic inserts always-true fake branches, flattens IR control flow through a dispatcher, and permutes non-entry blocks. Skipped for --ir-lower=eval and --backend=interpreter. |
--ir-direct-native |
off (default) or on. When on, same-class invokestatic of another IR-transpiled static method becomes a direct C++ call instead of CallStatic*Method. Skips synchronized, <init>, <clinit>, interfaces, and virtual/interface calls. Omits the extra Java native frame. Java-to-native entries are unchanged. Deep same-class recursion uses the C stack and may abort instead of throwing StackOverflowError. |
-a |
Enable @Native / @NotNative annotation processing |
-w / -b |
Whitelist / blacklist files |
--plain-lib-name |
Library name for LoaderPlain when you ship natives separately or for Android |
--custom-lib-dir |
Directory inside the JAR for packed libraries (default printed as native0/ unless overridden) |
--debug |
Also write a non-executable debug JAR |
--use-zig |
Compile generated C++ with Zig and pack the shared libraries |
--zig-targets |
Comma-separated Zig targets (default host) |
--zig-path |
Zig executable (overrides installed / PATH) |
--jdk-home |
JDK with include/jni.h (defaults to JAVA_HOME) |
--zig-install-dir |
Where Zig was installed (default ~/.native-obfuscator/zig/) |
--codegen=ir is the default and only remaining generator. Its direct lowering remains the
default; --ir-lower=eval selects a narrow shared evaluator lowering and retries direct IR on a
miss. Unsupported methods restore original bytecode (including invokedynamic) instead of being
left with internal preprocessor markers. --backend=interpreter is separately opt-in and default
off; interpreter misses retry IR.
hotspot— HotSpot internals; works with many existing obfuscators, including some stack-trace checks.std_java— fewer JVM internals; intended to be more portable across JVMs.android— noDefineClassfor hidden methods. Stack-based string/name schemes that need those hidden methods will not work.
Maven coordinates: com.github.radioegor146.native-obfuscator:annotations:master-SNAPSHOT
(add JitPack).
@Native— include the class or method@NotNative— skip a method inside a@Nativeclass@Native(lowering=…),@Native(intrinsics=…),@Native(backend=…),@Native(cfObfuscation=…),@Native(directNative=…)— override--ir-lower,--native-intrinsics,--backend,--ir-cf-obf, and--ir-direct-nativefor that class or method. Defaults areINHERIT(CLI). Method values win over class values.-astill selects which methods are nativized; these attributes apply whenever a selected method or its class carries@Native.
The annotations JAR also ships by.radioegor146.nativeobfuscator.NativePrimitives
and NativeStrings. You can call them from ordinary Java (they have JDK
fallbacks). After native obfuscation, calls from nativized methods are replaced
with the generated C++ implementations. File I/O is never rewritten.
Whitelist/blacklist win over annotations.
Format:
<class>
<class>#<method name>#<method descriptor>
mypackage/myotherpackage/Class1
mypackage/myotherpackage/Class1#doSomething!()V
mypackage/myotherpackage/Class1$SubClass#doOther!(I)V
Wildcards: * is one /-separated segment; ** is any remaining segments.
-
java -jar native-obfuscator.jar <input.jar> <output-dir> -
Optional evaluator: add
--ir-lower=eval -
cmake .in the generatedcpp/directory (recorded IR builds usedCC=gcc CXX=g++) -
cmake --build . --config Release -
Copy the shared library from
build/libs/into the loader path printed on stdout (native0/by default), named like:x64-windows.dll x64-linux.so x86-windows.dll x64-macos.dylib arm64-linux.so arm64-windows.dll -
java -jar <output.jar>
Omit --plain-lib-name if you want natives packed into the JAR after you copy them into that loader
directory.
The loader classes call System.load/System.loadLibrary, which
JEP 472 makes restricted operations. On JDK 24+ an unenabled call
warns by default, and a future release may turn that into an error. The output JAR is written with
Enable-Native-Access: ALL-UNNAMED in META-INF/MANIFEST.MF, so a java -jar <output.jar> launch
grants native access to the unnamed module without extra flags (any more specific value already
present in the input manifest is preserved). A classpath launch does not honor that manifest
attribute and still needs the flag:
java --enable-native-access=ALL-UNNAMED -cp <output.jar> <main-class>
This is a packaging convenience, not a claim that JDK 25 is supported.
Steps 2–5 can be replaced with --use-zig (no CMake / no host compiler; cross-compilation is built
in).
java -jar native-obfuscator.jar install-zig [--version <x.y.z>] [--install-dir <path>] [--force]
Downloads an official Zig release (SHA-256 verified) into ~/.native-obfuscator/zig/ by default.
java -jar native-obfuscator.jar --use-zig \
[--zig-targets x64-windows,x64-linux,arm64-linux] \
[--jdk-home <path-to-jdk>] \
<input.jar> <output-dir>
Known targets include x64-linux, x64-windows, x64-macos, arm64-linux, arm64-windows,
arm64-macos, x86-linux, x86-windows, arm32-linux, and host.
Depend on the annotations artifact and call
by.radioegor146.nativeobfuscator.NativePrimitives / NativeStrings from Java.
Generated JARs also still pack the older by.radioegor146.sdk names as
deprecated delegates. This is not a separately versioned product SDK.
Primitives (see docs/sdk/v1-status.md):
abiVersion()sha256(byte[])hmacSha256(byte[] key, byte[] message)aes256GcmEncrypt/aes256GcmDecrypt(32-byte key, 12-byte nonce, 16-byte tag; do not reuse a nonce with the same key)constantTimeEquals(byte[], byte[])
Strings: Java-compatible UTF-16 length, hashCode, and concat. Recorded string benches were
slower than HotSpot.
| Path | Role |
|---|---|
obfuscator/ |
The CLI and transpiler (by.radioegor146.*, ir/, interpreter/, zig/, runtime sources) |
annotations/ |
@Native / @NotNative plus NativePrimitives / NativeStrings, consumable via JitPack |
sdk/ |
Deprecated by.radioegor146.sdk delegates, packed into generated JARs |
docs/ |
Status, design, benchmark, and review documents — start at docs/README.md |
The default --codegen=ir path builds a typed control-flow graph (i32/i64/f32/f64/reference
values) from the preprocessed ASM tree (AsmToIr, CfgBuilder, with well-formed jsr/ret
inlined first), lowers it through a strategy (DirectCppStrategy by default, or
InterpreterStreamStrategy when --ir-lower=eval), and emits structured C++ via IrCppEmitter.
--backend=interpreter is a separate NativeObfuscator path (InterpreterMethodEmitter), not an
IR-compiler lowering. Methods with unsupported constructs raise UnsupportedIrConstructException
and restore original bytecode.
Design and status: docs/architecture/ir-compiler.md ·
docs/architecture/current-goal.md ·
docs/architecture/project-status.md.
./gradlew assemble # skip tests
./gradlew build # assemble + full suite (needs krak2 for some cases)
Focused IR suite used during the integration:
CC=gcc CXX=g++ ./gradlew :obfuscator:test \
--tests by.radioegor146.ir.IrCompilerTest \
--tests by.radioegor146.CodegenModeTest
ClassicTest-style fixtures live under obfuscator/test_data/. Some of that corpus comes from
huzpsb/JavaObfuscatorTest.
CI runs the "Main pipeline" workflow
(.github/workflows/main.yml) on JDK 8/11/17/21/25 across
Ubuntu/macOS/Windows.
Start at docs/README.md.
| Doc | Role |
|---|---|
| Architecture overview | Visual walkthrough of the pipeline (bilingual) |
| Current goal | Replacement landed: IR is the default generator; snippet path deleted |
| Project status | What landed on master, what did not, what must not be claimed |
| IR compiler | Typed CFG design |
| IR phase 18 | Primitive arrays and MULTIANEWARRAY |
| JDK 17 IR runtime repair | Version / indy / invokeExact |
| SDK v1 | Java API and C ABI |
| Benchmarks | How to run the harness; do not invent numbers |
| Historical options brief | Pre-landing maintainer snapshot (now superseded as current status) |
See CONTRIBUTING.md. Short version: read
docs/architecture/current-goal.md first, gate changes on
executed tests, and never inflate status claims.
Open an issue on this repository, or contact the original author at re146.dev.