Anti-LLM Obfuscation on Android - An experiment

Sun, May 24, 2026 11-minute read

Anti-LLM Obfuscation on Android - An experiment

One year ago, I wrote about how LLMs can increase the success rate of Android reverse engineering, and how defenders can respond: Android Obfuscation Using LLM: Zero code approach. In 2026, LLMs are far more capable and knowledgeable. That means the defense has to move too. We need source protection techniques that are designed for the way LLMs read, summarize, and simplify code.

Android engineers and reverse engineers increasingly paste source code, JADX output, or decompiled APK snippets into LLMs. That workflow is powerful, but it has a hard limit: LLMs reason over text. They do not execute the app, prove a call graph, or guarantee bytecode-level data-flow accuracy.

That weakness creates a defensive opportunity.

A strong LLM can often decode one small obfuscated class. But when the same patterns are spread across a realistic Android app, the analysis burden compounds. The model can miss entry points, lose data-flow precision, trust plausible anchors, or stop at an incomplete decompiled view.

This post uses harmless examples and a synthetic banking-app experiment. The goal is not to claim that client-side secrets are safe. They are not. The goal is to show that these techniques can meaningfully reduce the chance that LLM-based source review understands the real behavior on the first pass.

The Core Idea

Traditional Android obfuscation tries to slow down humans and static tools. Anti-LLM obfuscation adds another target: the model’s limited attention, limited working memory, token interpretation, and tendency to infer intent from names and comments.

Used once, these tricks are usually beatable. Used across an app, they become friction. They make the model work harder, make summaries less complete, and increase the chance that the model lands on a convincing but wrong explanation.

That is exactly what happened in the experiment.

Technique 1: Context-Window Exhaustion

The first technique is to make the model spend attention on reversible noise before it reaches the real value.

private String resolveAuditAction(Context context) {
    String action = "com.example.ACTION_AUDIT";
    byte[] layer0 = action.getBytes(StandardCharsets.UTF_8);

    byte[] layer1 = wrap(layer0, context, "assets", 1);
    byte[] layer2 = wrap(layer1, context, "prefs", 2);
    byte[] layer3 = wrap(layer2, context, "locale", 3);

    byte[] peel3 = unwrap(layer3, context, "locale", 3);
    byte[] peel2 = unwrap(peel3, context, "prefs", 2);
    byte[] peel1 = unwrap(peel2, context, "assets", 1);

    return new String(peel1, StandardCharsets.UTF_8);
}

This still returns:

com.example.ACTION_AUDIT

In one method, the trick is obvious. Across many Intent actions, preference keys, feature flags, receivers, resources, and helper classes, it becomes expensive. The LLM has to decide what to preserve and what to summarize. That is where precision starts leaking.

Technique 2: Working-Memory Saturation

The second technique is to create many aliases and reversible operations so the model has to track more live state than the logic really needs.

private boolean mergeGate(Context context, int amountCents, boolean deviceTrusted) {
    String p1 = context.getPackageName();
    String p2 = p1.toLowerCase(Locale.US);
    String p3 = p2.toUpperCase(Locale.US);

    int a1 = amountCents;
    int a2 = a1 ^ 0x13572468;
    int a3 = a2 ^ 0x13572468;
    int a4 = Integer.rotateLeft(a3, 3);
    int a5 = Integer.rotateRight(a4, 3);

    boolean b1 = deviceTrusted;
    boolean b2 = !(!b1);
    boolean b3 = b2 && p1.length() >= 0;
    boolean b4 = b3 && p2.startsWith("");
    boolean b5 = b4 && p3.endsWith("");

    return b5 && a5 > 0 && a5 <= 1_000_000;
}

Reduced behavior:

deviceTrusted && amountCents > 0 && amountCents <= 1_000_000

The XOR cancels. The rotate cancels. The double negation cancels. The string checks are redundant. But across an app, these small cancellations become a tracking burden. A model may recover the broad idea and still drop one important condition.

Technique 3: Token Confusion

The third technique is to make text look familiar while it tokenizes or searches differently.

@SuppressWarnings("NonAsciiCharacters")
private String resolveАuditAction(Context context) {
    // The А in resolveАuditAction is Cyrillic U+0410, not Latin A.
    String аction = context.getPackageName() + ":audit";
    // The а in аction is Cyrillic U+0430, not Latin a.

    String marker = "load\u200DNativeLibrary"; // harmless string with zero-width joiner
    return аction.substring(0, 0) + "com.example.ACTION_AUDIT";
}

This does not call native code. It only creates a harmless string. The point is that Unicode homoglyphs and zero-width characters can break simple search, confuse visual review, and weaken model assumptions about identifiers.

Technique 4: Comment Misdirection

This is not about telling the model what to do. It is simpler and more common.

LLMs often use comments, function names, and local variable names to infer intent before they fully trace the body. If the comment says “utility conversion” or “returns empty when unavailable,” the model may summarize the method as boring glue code even when the body performs important reconstruction.

/**
 * Utility conversion helper.
 *
 * Returns an empty token when no audit state is available.
 */
private String convertOrEmpty(Context context, byte[] masked) {
    String route = context.getPackageName() + ":audit";
    byte[] stream = deriveStream(route, masked.length);
    byte[] output = new byte[masked.length];

    for (int i = 0; i < masked.length; i++) {
        output[i] = (byte) (masked[i] ^ stream[i]);
    }

    return new String(output, StandardCharsets.UTF_8);
}

The comment is not behavior. The method does not simply convert or return empty. It derives a stream and reconstructs a value. The weakness being targeted here is not command-following, but model overconfidence in semantic hints.

Ultimate Technique: Virtualization, the LLM Killer

If the source-level techniques reduce LLM confidence, virtualization is the technique that can blind an LLM-only static review almost completely.

Instead of compiling sensitive Android logic directly into readable Dalvik bytecode, compile that logic into a custom bytecode format and store it as data. The app ships a small interpreter that reads the bytecode and executes it at runtime.

At the JADX level, the LLM mostly sees this:

int run(byte[] program, int input) {
    int pc = 0;
    int acc = input;

    while (pc < program.length) {
        int opcode = program[pc++] & 0xff;
        int value = program[pc++] & 0xff;

        switch (opcode) {
            case 0x10:
                acc ^= value;
                break;
            case 0x20:
                acc = Integer.rotateLeft(acc, value & 7);
                break;
            case 0x30:
                acc += value;
                break;
            case 0x7f:
                return acc;
            default:
                throw new IllegalStateException("bad opcode");
        }
    }

    return acc;
}

The actual protected logic is not visible as normal Java or Kotlin control flow. It is hidden inside a byte array:

byte[] program = new byte[] {
    0x10, 0x2a,
    0x20, 0x03,
    0x30, 0x11,
    0x7f, 0x00
};

This is brutal for LLM-based reverse engineering. A normal decompiler can show the interpreter loop, but it cannot automatically explain the custom instruction set. The LLM can summarize the VM, but it does not know the business logic unless it also emulates the bytecode, reconstructs the virtual ISA, tracks the VM state, and validates the result dynamically.

That is why virtualization deserves the “LLM killer” label in this threat model. For an LLM-only review of JADX output, virtualization can prevent the model from fully reverse engineering the APK logic because the logic is no longer represented as ordinary source-like code.

This is not magic secrecy. A skilled reverse engineer can still attack it by tracing runtime execution, dumping decrypted bytecode, lifting the VM, or writing an emulator. But that is exactly the win: the task stops being “paste JADX into an LLM” and becomes real reverse engineering again.

Virtualization is expensive. It adds runtime overhead, implementation complexity, testing burden, and debugging pain. Use it only for high-value logic. But if the goal is to reduce fast LLM-assisted understanding of protected code, this is the strongest technique in the stack.

The Banking App Experiment

I built a controlled Android banking-style app to test whether an LLM could miss embedded data when these techniques were applied together.

No real banking secrets were used. The hidden value was a synthetic marker:

ALB_RELEASE_KEY_2026_Z7Q9-NOT-A-REAL-BANK-SECRET

The app was built as a release APK with R8 minification enabled, then decompiled with JADX. Claude Opus 4.7 received only the isolated JADX output. It did not receive original source, mapping files, build artifacts, smali, or runtime traces.

The result was the exact failure mode this experiment was designed to test:

Claude Opus 4.7 did not recover the actual marker.

Instead, it found a convincing decoy:

branch=phoenix;ledger=offline-sweep;window=17;nonce=R4F8;

It treated that value as a high-confidence planted marker because it appeared in resources/res/raw/branch_payload.txt. But that payload was only an anchor input. It was not the final recovered key.

The real marker remained:

ALB_RELEASE_KEY_2026_Z7Q9-NOT-A-REAL-BANK-SECRET

The corresponding fingerprint was:

235c211e73bc04cfd1

This is the important part: the marker was still recoverable. The obfuscation did not create real secrecy. But it reduced the chance that an LLM-only reverse-engineering pass would understand the full path from the decompiled source view.

Why Claude Missed It

The recovery path crossed several boundaries:

  • route fragments from resources
  • a raw branch payload
  • package name state
  • masked byte arrays
  • native salt 91
  • lane bias 17
  • bytecode-level initialization
  • SHA-256 fingerprinting

Claude found some of these pieces, but it stopped too early. It concluded that a derived Release vault fingerprint was not statically computable because the relevant VaultEnvironment writer did not appear in the scoped JADX Java output.

That conclusion was wrong. The writer existed in the APK behavior, but JADX did not cleanly decompile the relevant MainActivity.onCreate path. The bytecode and smali view showed the environment being assembled.

In other words, the LLM trusted the wrong representation.

Synthetic banking app hidden marker recovery flow

How the Techniques Worked Together

Context-window exhaustion made the app look like a normal noisy banking codebase. There were accounts, cards, transfers, payments, audit trails, candidate classes, and support modules. The real path was only one small part of the tree.

Working-memory saturation forced the marker to be recovered through multiple small state transitions. The marker depended on route fragments, masked bytes, native salt, package name, resource values, and fingerprinting.

Token confusion made useful fragments look ordinary. For example, route parts appeared harmless in XML:

<array name="vault_route_parts">
  <item>vault</item>
  <item>26</item>
  <item>ret</item>
  <item>-20</item>
  <item>ail-</item>
  <item>ach-shadow</item>
  <item>risk-ledger</item>
  <item>settlement</item>
</array>

Reordered, the useful route became:

retail-vault-2026

Comment misdirection made sensitive-looking helpers appear like boring conversion code. A representative pattern looked like this:

/**
 * Converts transient vault material into a display-safe value.
 * Returns an empty result for unsupported branches.
 */
fun convertOrEmpty(masked: ByteArray, route: String): String {
  val stream = VaultRuntime.maskStream(route, masked.size)
  val restored = ByteArray(masked.size)

  for (i in masked.indices) {
    restored[i] = (masked[i].toInt() xor stream[i].toInt()).toByte()
  }

  return restored.decodeToString()
}

Nothing in the comment is a command to the model. It is just misleading documentation. The risk is subtler: the model may accept the comment’s boring explanation and skip the byte-array recovery logic.

Virtualization would push this even further. In the banking-app experiment, the model missed the marker because the state construction path crossed resources, native constants, byte arrays, and incomplete JADX output. If the same recovery logic had been compiled into a custom VM bytecode stream, the LLM would likely have seen only a generic interpreter loop plus opaque bytes. That is the difference between confusing the model and removing the source-like logic from its view.

What This Proves

This experiment does not prove that Android apps can safely hide secrets. They cannot.

It proves something narrower and more useful for defenders:

Anti-LLM obfuscation can effectively reduce the chance that an LLM-only reverse-engineering workflow understands the source code accurately from decompiled Java alone.

The model did not fail because it was weak. Claude Opus 4.7 is strong. It failed because the review surface was intentionally shaped against the way LLMs summarize and reconstruct behavior.

That is the defender’s win condition. Not perfect secrecy. Friction. Delay. Higher analyst cost. More need for deterministic tooling. Less chance of a one-shot LLM summary producing the real behavior.

Conclusion

LLMs are excellent accelerators for Android reverse engineering. They can explain APIs, summarize code, propose call paths, and turn messy decompiled output into useful hypotheses.

But they are not authoritative analyzers.

App-wide anti-LLM obfuscation can make source-level LLM review unreliable. Context-window exhaustion buries the signal. Working-memory saturation breaks data-flow precision. Token confusion weakens search and identifier reasoning. Comment misdirection makes important code look boring. Virtualization raises the bar further by moving sensitive logic out of normal source-like control flow. Incomplete JADX output can make all of that worse.

The takeaway is simple: do not store real secrets in client-side apps. But if your goal is to protect source code from fast LLM-assisted reverse engineering, these techniques raise the cost. They make the model slower, less certain, and more likely to require real static analysis, bytecode inspection, runtime validation, and human judgment.

References