The shape of a JNI pipeline
A request built across the Java native interface has recognisable stages. Java collects values, assembles a string or a byte array, and calls a native method. The native method transforms the input, using C or C++ code, and returns a result. Java then attaches that result to the request and an HTTP client sends it.
Nothing about that arrangement is hidden in principle. The difficulty is that the transformation happens in compiled code with no names, and the description of the request is split between a DEX file and a shared object. Read each side on its own and you get two partial pictures.
Find the entry point before the algorithm
Native methods are reachable from Java even when the library is stripped, so start on the Java side. A declared method with no body marks a boundary. So does a library loaded through a static initialiser, which tells you which file to open.
With the library in hand, resolve the mapping in one of two ways: an exported symbol whose name encodes the declaring class and method, or a registration table populated at load time that lists names and addresses. Where neither is obvious, hooking the declaration on the Java side and watching which addresses execute gives you the same answer.
Instrument both sides of the call
Set up two views of the same function and compare what each one shows. The gap between them is where the transformation happens.
- On the Java side, log the arguments and the returned value, together with the call site, so you know which stage of the flow it belongs to.
- In the library, record the memory the function reads and the memory it writes.
- Place the native call next to the captured request. If the native result appears in the traffic, the boundary you found is on the critical path.
The comparison also reveals work done elsewhere. A native method that returns a value nobody sends is part of integrity checking or telemetry, which matters when you later decide what to reproduce.
Conversion details that look like cryptography
A large share of JNI difficulty comes from marshalling rather than from the algorithm. These problems produce output that looks wrong in ways that suggest a broken scheme.
- Encoding. A Java string is a sequence of UTF-16 code units. Passing it into native code as a modified UTF-8 variant, then treating the result as raw bytes, can change the content for any character outside the ASCII range.
- Sign and width. Code that reads a byte as signed and code that reads it as unsigned produce different numbers. A digest comparison that fails everywhere usually has a width or sign mismatch behind it.
- Reference lifetime. Local references are valid only for the duration of the call. Code that keeps one for later use reads freed memory and returns unstable values.
- Copied arrays. Reading an array into a buffer and releasing it at the wrong moment changes what the algorithm sees, which is easy to misread as non-deterministic behaviour.
- Calling back into Java. A native function that invokes Java code can create a loop in the call graph, and a hook that stops at the first native frame will miss half the work.
Read the transformation from observations
Once the boundary is instrumented, build a description of the function instead of a listing of it. Record the input and the output for many calls, then test the properties that distinguish common primitives: output length, sensitivity to single bit changes, and whether two calls with identical input give identical output. Those properties route the work to the right kind of implementation, and they hold regardless of how the native code is written.
Keys deserve their own search. A native signing routine often holds its key as data in the same object and loads it at start, so it appears in the process image rather than in any request. Capture the value that the routine uses, and check whether it changes between installs or builds, because a per-install key changes the client you deliver.
Rebuild and compare
Implement the transformation in the target language using the same inputs the app passes. Compare the two outputs over a set of samples, then confirm that the server accepts a request carrying your value. Differences at this stage trace back to the boundary rather than to the algorithm in most cases.
When the comparison holds and the server agrees, the split between Java and native code stops mattering. What remains is a documented step in a request pipeline and a function your client can call.
Related work
Reviewed 28 September 2026 · SReverse research desk