Obliterating Zelix Klass Master

2026-10-26
Styles Foundation

Authors: cubk, EscapistKid, Inkarro, mckuhei
Contact: cubk80@gmail.com
Source code published at: https://github.com/StylesFoundation/OpenZKM
Online demo: https://zkm.cubk.cn

Abstract

Using Zelix KlassMaster (ZKM) itself as the example, this article starts from its anti-debugging mechanism, takes apart ZKM's obfuscations layer by layer, and gives the corresponding approach for undoing each one. It dissects ZKM's anti-debugging, Reference Obfuscation, Method Parameter Changes and Method Parameter Obfuscation, constant encryption, and Flow and Exception Obfuscation, presents a complete deobfuscation approach based on runtime replay, and also analyzes the implementation details of some of ZKM's hidden checks and mechanisms.

Introduction

1

Code after obfuscation by Zelix Klass Master

Zelix KlassMaster, ZKM for short, is the best Java obfuscator on the market. Many companies use it to protect their applications against reverse engineering and tampering. The tool is extremely stable, can handle almost any project, and reliably produces obfuscated code that runs correctly. It offers advanced Flow Obfuscation, String Encryption, Integer Constant Encryption, Method Parameter Obfuscation and Reference Obfuscation, as well as Method Parameter Changes: to decrypt the constants in any method, you have to fully trace the chain of parameter encryption from program start all the way to that method.

Buying ZKM

1

ZKM costs USD 290 for a small team and USD 585 for a large team. You can also download a trial version, which is limited to obfuscating at most two methods per class. Out of respect for Zelix Pty Ltd, I nonetheless bought a genuine license at full price.

All the reverse engineering in this article was done on this licensed copy. If Zelix Pty Ltd has any objection to this, you are welcome to ban me. But I kindly ask that you not file DMCA requests against this article or any material I have published.

1

Name Mapping

Obfuscated class names come up frequently below. The mapping is as follows:

Obfuscated name Our name (unofficial)
bff MasterKeyFactory
grr KeyFactoryBase
c55 FallbackKeyFactory
nnn InvokeDispatcher

Processing Flow

ZKM's obfuscations interlock with one another. Deobfuscation is not a linear process, and the steps must be carried out in order

Master Key (Unordering)Reference Obfuscation +Method Parameter Changes(iterative)Method Parameter ObfuscationDecrypt constantsException ObfuscationFlow ObfuscationErase junk parameters

Reference Obfuscation and Method Parameter Changes depend on each other: decrypting Invoke Dynamic requires evaluating the methods' extra parameters, while tracing the extra parameters requires first seeing the call relationships clearly. The two have to be advanced iteratively.

Decryption Mechanism

1

If you start ZKM with any debugging argument, it immediately throws a Given final block not properly padded. Such issues can arise if a bad key is used during decryption exception, and you will not be able to decrypt any constants.

This is because ZKM's anti-debugging is built entirely on ZKM's decryption mechanism. Every class has a long field that takes part in all of the obfuscation computations; we call it the master key.

It is assigned in the static block. Example assignment code:

java
a = bff.a(6580990762181691679L, -6655629830431801878L, MethodHandles.lookup().lookupClass()).a(159845932320165L) ^ 123909089922007L;

Here bff is the decryptor, which we call MasterKeyFactory; it implements grr. grr is the decryptor base, an interface, and the other implementation of this interface is FallbackKeyFactory.

So this line can be read as

java
// MethodHandles.lookup().lookupClass() gets the current Class, used for the Class Initialize Order computation
a = MasterKeyFactory.deriveKeyFactoryForClass(6580990762181691679L, -6655629830431801878L, MethodHandles.lookup().lookupClass()).decrypt(159845932320165L) ^ 123909089922007L;

The structure of KeyFactoryBase is as follows

java
// KeyFactory Base
public interface grr {
    public void a(grr var1); // <- setChild

    public int[] b();

    public long a(long var1); // <- decrypt

    public void b(long var1);

    public boolean b(grr var1);
}

Deriving the Key

This lives in MasterKeyFactory, but the dispatching doesn't actually happen here; it is only an entry point for the Classes to use

java
// @l: Master Key Seed
// @l2: Fallback Key Seed
// @object: Current Class
public static grr a(long l, long l2, Object object) {
    c55.a(l > 0L); // Junk Code
    grr grr2 = bff.d(l); // Create Master
    grr grr3 = bff.d(l2); // Create Fallback
    grr grr4 = c55.a(grr2, grr3); // Dispatch via FallbackKeyFactory
    if (object != null) { // Also Junk Code
        m.add(object); 
    }
    return grr4;
}

Go to FallbackKeyFactory and look at the a method

java
static grr a(grr grr2, grr grr3) {
    grr grr4 = a.b(grr2, grr3); // a = INSTANCE, there is only one FallbackKeyFactory, b is the method below
    return grr4;
}

private grr b(grr grr2, grr grr3) {
    grr grr4 = (grr) this.b.get(grr2); // Look up the table by key; valid Root Factories are all registered in advance
    if (grr4 == null) {
        grr4 = this;                   // Not found → return FallbackKeyFactory (itself)
    }
    grr grr5 = this.b.put(grr3, grr2); // Register the FallbackKeyFactory
    return grr4;
}

this.b, i.e. the list of legitimate Root Factories, is populated in the <init> block, and the KeyFactories registered there are MasterKeyFactory instances

1

Finally this MasterKeyFactory is returned to the program, which can then go on to decrypt

Decrypting the Master Key

Back in MasterKeyFactory: after the KeyFactory has been obtained, execution enters a decryption method that takes a long as its parameter

java
// decrypt
// @l: decryptKey
@Override
public long a(long l) {
    long l2;
    // A very old-school arbitrary bit shuffle that computes a value from the current KeyFactory's Seed
    // Also, I noticed that the 8 and 55 here are hardcoded
    long l3 = this.a(8, 55); 
    this.a = l2 = this.a ^ (l ^ this.d); // seed = seed ^ decryptKey ^ key: the Seed is rolled with the Seed passed in by the current class, so the next computation yields a different value
    if (this.c != null) { // Check whether there is a Child; if so, roll the Seed in the Child as well
        this.c.a(l); // Roll the Child with the key passed in
    }
    return l3;
}

private long a(int n, int n2) {
    return bff.a(
        this.a, // seed
        n, // 8
        n2, // 55
        this.b, // <- a random offset table specific to this KeyFactory
        this.j // <- Junk Code
    );
}

private static long a(long l, int n, int n2, int[] nArray, long[] lArray) {
    int n3;
    long l2 = 0L;
    int n4 = nArray.length;
    for (int i = 0; i < n4; ++i) {
        long l3 = l & k[i];
        n3 = nArray[i];
        if (l3 == 0L) continue;
        if (n3 > 0) {
            l3 >>>= n3;
        } else if (n3 < 0) {
            l3 <<= ~n3 + 1;
        }
        l2 |= l3;
    }
    n4 = 64;
    long l4 = l2;
    int n5 = n4 - 1 - n2;
    if (n5 > 0) {
        l4 <<= n5;
    }
    if ((n3 = n + n4 - 1 - n2) > 0) {
        l4 >>>= n3;
    }
    return l4;
}

About FallbackKeyFactory

Back to the KeyFactory derivation code: if the Class doesn't match an originally registered Root Factory (typically when it is called externally, outside its original context), a FallbackKeyFactory is derived.

java
private grr b(grr grr2, grr grr3) {
    grr grr4 = (grr) this.b.get(grr2); // Look up the table by key; valid Root Factories are all registered in advance
    if (grr4 == null) {
        grr4 = this;                   // Not found → return FallbackKeyFactory (itself)
    }
    grr grr5 = this.b.put(grr3, grr2); // Register the FallbackKeyFactory
    return grr4;
}

FallbackKeyFactory has no Child rolling mechanism. You can in fact use FallbackKeyFactory to decrypt Master Keys that have no Class Initialization Order, but decrypting even once with FallbackKeyFactory pollutes the KeyFactory registry, so the others can no longer be decrypted.

What the Anti-Debugging Does

1

The anti-debugging code actually lives in the <init> call of MasterKeyFactory. This code is protected by control flow and string obfuscation; simplified, it looks like this:

java
// Get the full list of JVM arguments via reflection
Class<?> runtimeMXBeanClass = Class.forName("java.lang.management.RuntimeMXBean");
Method getPlatformMXBeans = Class.forName("java.lang.management.ManagementFactory").getMethod("getPlatformMXBeans", Class.class);
Object runtimeMXBean = ((List<?>) getPlatformMXBeans.invoke(null, runtimeMXBeanClass)).get(0);
Method getInputArguments = runtimeMXBeanClass.getMethod("getInputArguments");
Collection<String> jvmArgs = (Collection<String>) getInputArguments.invoke(runtimeMXBean);
// Check for forbidden arguments
String xdebugFlag = "-Xdebug";
String xrunjdwpFlag = "-Xrunjdwp";
String agentlibJdwpFlag = "-agentlib:jdwp";
String agentpathFlag = "-agentpath:";
String javaagentFlag = "-javaagent:";
for (String arg : jvmArgs) {
    if(arg.equals(xdebugFlag) || arg.startsWith(xrunjdwpFlag) || arg.startsWith(agentlibJdwpFlag) || arg.startsWith(agentpathFlag) || arg.startsWith(javaagentFlag))
    {
        if (bff.n > 1) { // Corrupt the constant in MasterKeyFactory so the computation goes wrong
            if (bff.n != 17) {
                bff.n--;
            } else {
                bff.n = 10;
            }
        } else {
            bff.n = 17;
        }
    }
}

If any of these debugging arguments are detected, the arithmetic constant in MasterKeyFactory is silently corrupted, and you will not be able to derive keys correctly.

The Child Mechanism and Class Initialization Order

1

Description of Class Initialization Order

java
// setChild
@Override
public void a(grr grr2) {
    if (this != grr2) { // Prevent it from becoming its own child.
        if (this.c == null) { // Check whether there is a Child; if there is no Child, set the Child
            this.c = grr2;
        } else {
            this.c.a(grr2); // If there is already a Child, set it as the Child's Child, which means Children can be nested indefinitely
        }
    }
}

During the KeyFactory initialization phase, many such relationships are set up in a large amount of generated, hardcoded initialization code, so once a KeyFactory is called, all of its Children are rolled along with it.

This is how Class Initialization Order works: in the configuration file you can specify that class A must be loaded before class B, and the classes must then be loaded in that order, otherwise the Master Key cannot be decrypted. Inheritance has the same effect: since a superclass is always initialized before its subclasses, if you initialize (call the static of) the subclass first, the KeyFactory will derive the wrong key because the Child was not rolled.

On a normal JVM this is not a problem, because the initialization order ZKM generates is correct to begin with, and inheritance guarantees that superclasses are initialized before subclasses. But as soon as you invoke a single class's decryption via reflection, or execute in a virtual machine in an arbitrary order, the Child may not have been rolled yet, and the derived key will be wrong.

1

Setting the initialization order during the initialization phase

Decrypting All Master Keys (Unordering)

Having analyzed ZKM's Master Key computation logic, we set out to handle this computation: compute the Master Keys of all Classes in one go, so that later decryption can't fail to produce the correct logic because of an incorrect initialization order.

Core Idea

In the analysis above we mentioned that the Master Key computation depends on a lot of runtime state:

  • The KeyFactories that have already been created
  • The object mappings in the registry
  • The reference chains between KeyFactories

If you call decrypt directly without handling this state, you may wrongly get the FallbackKeyFactory and decrypt incorrect data.

So we designed an approach that interprets the decryption runtime in an isolated virtual machine environment, restores the Factory objects and the registry state step by step, and then performs the decryption according to the dependencies between objects.

NoYesVM initializes the runtimeGet the decryption objectregistryLook up the target object byMaster SeedAnalyze object reference chainsand execution orderCall the original decryptionmethodKeep the runtime state changesAll constants decryptedOverwrite the encryptedinstructions with the computedconstants

Runtime Replay

We use a Java bytecode interpreter (hereafter LittleVM) to load the ClassNodes and provide a complete runtime environment. LittleVM maintains class initialization state, static fields and object instances on its own, so the environment changes produced by a single decryption can be observed.

java
private static Replay create(Map<String, ClassNode> targets, ClassProvider classes, Layout layout, boolean shortcut) {
    // Gather all the ClassNodes the runtime needs and hand them to LittleVM
    final Map<String, ClassNode> allowed = new HashMap<>(HostMetadata.get().getClasses());
    for (String name : layout.runtimeClasses)
        allowed.put(name, targets.get(name));
    
    final VMOptions.Builder options = VMOptions.builder()
            .runtimeEnvironment(RuntimeEnvironment.ofClasses(allowed))
            .runtimeCallOnly(true) // Only allow calls to legitimate Runtime methods, to prevent sandbox escape
            .defaultOnBlock(false) // Interrupt and save the context when a forbidden method is called
            .automaticClassInitialization(true); // Initialize the runtime environment automatically

    // Performance optimization: I found that the bit-shuffling method is identical in the Factories generated by every ZKM version, so when LittleVM reaches this method it hands it over to the native JVM to speed things up
    if (shortcut)
        options.nativeMethod(layout.permute.owner, layout.permute.name, layout.permute.desc, Replay::permute);

    final LittleVM vm = LittleVM.builder()
            .addClasses(targets.values())
            .classResolver(classes::getClassNode)
            .hostFallback(HostFallbackPolicy.PLATFORM_ONLY)
            .options(options.build())
            .build();
    try {
        return new Replay(vm, layout);
    } catch (RuntimeException exception) {
        vm.close();
        throw exception;
    }
}

Restoring ZKM's Runtime State After Building the LittleVM Environment

Replay first initializes the class that holds the Fallback object, then reads its static singleton and its internal registry

java
final Ref field = layout.fallbackInstance;
completed(vm.ensureInitialized(field.owner));
// ZKM's FallbackFactory object
singleton = Objects.requireNonNull(completed(vm.readStaticField(field.owner, field.name, field.desc)), "fallback singleton");
// The Map that holds the decryption object mappings
pairs = Objects.requireNonNull(readField(layout.pairMap, singleton), "pair map");

This step amounts to letting ZKM's decryption Factory finish starting up inside LittleVM

Handling the Runtime Registry

As we mentioned, the Master Key computation depends on runtime state: even if two decryption calls have the same input key, the decryption objects they actually get may differ if the registry is in different states when they run.

So we first try to find the target object in the runtime registry, i.e. masterSeed -> create(masterSeed) -> object -> pairs.get(object) -> Factory Object

create(masterSeed) produces the object needed to query the registry, and ConcurrentHashMap.get() then finds the currently registered decryption object. Put simply, we use the Master Seed to simulate creating a Factory, then look it up in the table.

Decryption Readiness

We divide decryption calls into two categories:

  • Ready: a valid decryption object can already be found in the current registry
  • Pending: no object can be found for now; other operations are needed to advance the runtime state
java
for (Site site : pending) {
    // Simulate creation from the Master Seed and look it up, as described above
    final Object target = peek(site.masterSeed);

    // If an object was found and it is not the FallbackFactory
    if (target != null && target != singleton) {
        ready.add(site);
        targets.put(site, target);
    }
}

Example

Suppose there are three decryption sites A, B and C, and the initial registry is as follows:

SeedA -> FactoryA
SeedB -> null
SeedC -> null

At this point only site A meets the conditions for execution, so we use LittleVM to emulate decryption site A. While decryption site A executes, ZKM may modify the registry, which then lets me find the objects for other decryption sites.

The registry might then look like this:

SeedA -> FactoryA
SeedB -> FactoryB
SeedC -> null

Decryption site B can then enter the Ready state in the next round, and in this way the order in which we obtain objects is guaranteed.

Note: this step doesn't mean we have handled all of the ordering. As I said above, so far we have only guaranteed the order in which objects are obtained, while ZKM's decryption objects can form reference chains of their own.

Walking the Object Reference Chain

First we need to read the field in the current object that points to the next decryption object.

java
private Object childOf(Object object) {

    // children is an IdentityHashMap that caches the references between objects
    if (children.containsKey(object))
        return children.get(object);

    final Ref field = layout.childFields.get(value.getType());
    final Object child = field == null ? null : readField(field, object);
    children.put(object, child);
    return child;
}

With this we can walk down the reference chain of decryption objects.

Determining Object Reachability

Getting an object's reference chain is not enough; we also need to determine whether one object can reach another along the reference chain.

java
private boolean reaches(Object from, Object target) {
    final Set<Object> seen = Collections.newSetFromMap(new IdentityHashMap<>());
    seen.add(from);

    boolean found = false;
    for (Object current = childOf(from); current != null; current = childOf(current)) {
        if (!seen.add(current) || seen.size() > CHAIN_GUARD)
            throw new IllegalStateException("Cyclic or excessive chain");
        found |= current == target;
    }

    return found;
}

Suppose we have:

A -> B -> C

Then:

reaches(A, B) = true
reaches(A, C) = true
reaches(B, C) = true

reaches(C, A) = false

reaches also detects cyclic references and overly long reference chains, so the traversal can't get stuck in an infinite loop.

Determining Execution Order by Parent Count

Once we have the reference relationships, we count, within the current Ready set, how many paths lead to each Factory object.

For example:

A -> B -> C

The corresponding result is

Object Count Order
A 0 1
B 1 2
C 2 3

We execute the objects with the fewest Parents first, which lets us decrypt all of the Master Keys one after another in the right order.

Bypassing the Anti-Debugging

When LittleVM reaches the anti-debugging code, it trips the call protection and interrupts the emulation, returning ExecutionStatus.BLOCKED and saving the execution context. When the context is determined to be a fetch of vmArgs, we push an empty list onto the stack to bypass the anti-debugging check.

You can also bypass the anti-debugging with the following code

java
final Field jvm = ManagementFactory.getRuntimeMXBean().getClass().getDeclaredField("jvm");
jvm.setAccessible(true);
final Object object = jvm.get(ManagementFactory.getRuntimeMXBean());
final Field vmArgs = object.getClass().getDeclaredField("vmArgs");
vmArgs.setAccessible(true);
vmArgs.set(object, new ArrayList<>());

1

Once all Master Keys are decrypted, they are written back into each method through the field references. All local variables can now be evaluated from context, which lays the groundwork for decrypting the obfuscated computations later on.

Reference Obfuscation

1

Reference Obfuscation turns ordinary calls into Invoke Dynamic calls, which are then routed to the target methods and fields by Bootstrap methods that ZKM generates. An example Bootstrap method:

java
private static CallSite b(MethodHandles.Lookup lookup, String string,MethodType methodType) {
    MutableCallSite mutableCallSite = new MutableCallSite(methodType);
    try {
        mutableCallSite.setTarget(MethodHandles.explicitCastArguments(MethodHandles.insertArguments(cfr_ldc_0().asCollector(Object[].class, methodType.parameterCount()), 0, lookup, mutableCallSite, string), methodType));
    }
    catch (Exception exception) {
        throw new RuntimeException("com/zelix/_a" + " : " + string + " : " + methodType.toString(), exception);
    }
    return mutableCallSite;
}

static MethodHandle cfr_ldc_0() {
    try {
        return MethodHandles.lookup().findStatic(_a.class, "b", MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/invoke/MutableCallSite;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/Object;", null));
    }
    catch (NoSuchMethodException | IllegalAccessException except) {
        throw new IllegalArgumentException(except);
    }
}

An obfuscated call site looks like this in bytecode:

ldc 5425719289198799004L
lload j2
invokedynamic n (Ljava/lang/Object;Ljava/lang/Object;JJ)Lcom/zelix/v6; .handle H_INVOKESTATIC com/zelix/nnn.a (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; .args .end

Compared with the original method, there are two extra long inputs, used to decrypt the Invoke Dynamic call; cfr_ldc_0 here points to the current class's Dispatcher method.

There are two kinds of these: one decrypts strings and numbers within the current class, which we call ConstantDispatcher; the other calls methods out of a very large registry class, which we call InvokeDispatcher

Constant Dispatcher

Decrypting Strings

java
private static Object b(MethodHandles.Lookup lookup, MutableCallSite mutableCallSite, String string, Object[] objectArray) {
    int n = (Integer)objectArray[0];
    long l = (Long)objectArray[1];
    String string2 = _a.b(n, l);
    MethodHandle methodHandle = MethodHandles.constant(String.class, string2);
    mutableCallSite.setTarget(MethodHandles.dropArguments(methodHandle, 0, Integer.TYPE, Long.TYPE));
    return string2;
}

Decrypting Numbers

java
private static int c(MethodHandles.Lookup lookup, MutableCallSite mutableCallSite, String string, Object[] objectArray) {
    int n = (Integer)objectArray[0];
    long l = (Long)objectArray[1];
    int n2 = h0.c(n, l);
    MethodHandle methodHandle = MethodHandles.constant(Integer.TYPE, n2);
    mutableCallSite.setTarget(MethodHandles.dropArguments(methodHandle, 0, Integer.TYPE, Long.TYPE));
    return n2;
}

Invoke Dispatcher

A typical call:

java
nnn.a<"k">(var10007, -7487940343502911222L, var7);

Here a is the Invoke Dispatcher Bootstrap Method, which uses the two longs to look up a table and obtain the target method/field.

java
public static MethodHandle a(MethodHandles.Lookup lookup, MutableCallSite mutableCallSite, String string, MethodType methodType, long l, long l2) {
    // Decode the first character in the Invoke Dynamic, which tells us the call type
    int n = string.charAt(0) ^ (int)l2 & 7;
    MethodHandle methodHandle = null;
    Field field = null;
    Method method = null;
    try {
        // Field-related symbols
        if (n == 104 || n == 108 || n == 113 || n == 110) {
            // Resolve the field
            field = nnn.c(l, l2);
            Class<?> clazz = field.getDeclaringClass();
            String string2 = field.getName();
            Class<?> clazz2 = field.getType();
            methodHandle = n == 104 ? lookup.findGetter(clazz, string2, clazz2) : (n == 108 ? lookup.findSetter(clazz, string2, clazz2) : (n == 113 ? lookup.findStaticGetter(clazz, string2, clazz2) : lookup.findStaticSetter(clazz, string2, clazz2)));
        } else {
            // Resolve the method
            method = nnn.d(l, l2);
            Class<?> clazz = method.getDeclaringClass();
            String string3 = method.getName();
            MethodType methodType2 = MethodType.methodType(method.getReturnType(), method.getParameterTypes());
            methodHandle = n == 111 ? lookup.findVirtual(clazz, string3, methodType2) : (n == 109 ? lookup.findStatic(clazz, string3, methodType2) : lookup.findSpecial(clazz, string3, methodType2, clazz));
        }
        return MethodHandles.dropArguments(methodHandle, methodType.parameterCount() - 2, Long.TYPE, Long.TYPE);
    }
    catch (Exception exception) {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(exception.getClass().getName()).append(" : ").append(field != null ? field.toString() : (method != null ? method.toString() : " null ")).append(" : ").append(exception.toString());
        throw new RuntimeException(stringBuilder.toString());
    }
}

public static Method d(long l, long l2) {
    // Decrypt an index from the two longs; this index is used to look up the method table
    int n = nnn.a(l, l2);
    // Look up the object in the method table
    Object object = a[n];
    // Check the object's type: if it is a String, it is in class/method name/Desc format and has to be parsed first; otherwise return the method directly
    if (object instanceof String) {
        Class<?>[] classArray;
        Class clazz;
        Class clazz2;
        Class[] classArray2;
        int n2;
        String string;
        Class clazz3;
        block10: {
            String string2 = b[n];
            int n3 = string2.indexOf(8);
            clazz3 = nnn.b(Long.parseLong(string2.substring(0, n3), 36), 0L);
            int n4 = string2.indexOf(8, ++n3);
            string = string2.substring(n3, n4);
            int n5 = -1;
            int n6 = n4;
            do {
                ++n5;
                ++n6;
            } while ((n6 = string2.indexOf(8, n6)) > -1);
            n2 = n5 - 1;
            classArray2 = new Class[n2];
            clazz2 = null;
            n6 = n4 + 1;
            for (int i = 0; i < n5; ++i) {
                int n7 = string2.indexOf(8, n6);
                clazz2 = nnn.b(Long.parseLong(string2.substring(n6, n7), 36), 0L);
                if (i < n2) {
                    classArray2[i] = clazz2;
                }
                n6 = n7 + 1;
            }
            clazz = clazz3;
            do {
                if ((classArray = nnn.a(clazz, string, clazz2, n2, classArray2)) != null) {
                    nnn.a[n] = classArray;
                    return classArray;
                }
                if (clazz.getName().equals("java.lang.Object")) break block10;
            } while ((clazz = clazz.getSuperclass()) != null);
            clazz = nnn.b(5620362211605642L, 0L);
        }
        clazz = clazz3;
        while (true) {
            if ((classArray = clazz.getInterfaces()) != null) {
                for (int i = 0; i < classArray.length; ++i) {
                    Method method = nnn.b(classArray[i], string, clazz2, n2, classArray2);
                    if (method == null) continue;
                    nnn.a[n] = method;
                    return method;
                }
            }
            if (clazz.getName().equals("java.lang.Object")) break;
            if ((clazz = clazz.getSuperclass()) != null) continue;
            clazz = nnn.b(5620362211605642L, 0L);
        }
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("NoSuchMethodException in ").append(clazz3.getName()).append(' ').append(clazz2.getName()).append(' ').append(string).append('(');
        int n8 = 0;
        while (n8 < n2) {
            stringBuffer.append(classArray2[n8].getName());
            if (++n8 >= n2) continue;
            stringBuffer.append(", ");
        }
        stringBuffer.append(')');
        throw new RuntimeException(stringBuffer.toString());
    }
    return (Method)object;
}

The Object[] table it queries is filled with the Owner, Name and Descriptor of every method/field during initialization

1

The Easiest Way to Restore It

If you have far too much free time, you are welcome to reimplement all of the logic I described above and actually decrypt every reference to achieve static decryption, but I actually used a much easier approach

Tip

IMPL_LOOKUP is a private static field in MethodHandles.Lookup. Its lookupClass is Object and its access mode is TRUSTED; it is the Lookup used internally by the JDK and has no reflection restrictions whatsoever.

In fact, all MasterKeys have now been decrypted, which means every local variable can be evaluated as well, so restoring this is quite simple: it only takes emulation.

Of the two longs, one is usually written out directly, and the other is computed by XOR with the parameter injected by Parameter Changes.

Use those two longs to obtain the MethodHandle. Note that the last instruction of InvokeDispatcher is MethodHandles.dropArguments: to drop the two trailing longs, it wraps the original handle in a BoundMethodHandle, with the original handle stored in its argL0 field, so you first need to extract the original MethodHandle via reflection.

java
if (handle.getClass().getName().startsWith("java.lang.invoke.BoundMethodHandle$Species_L")) {
    Field original = handle.getClass().getDeclaredField("argL0"); // <- argL0 is the original MethodHandle
}

Then use IMPL_LOOKUP's revealDirect to obtain a MethodHandleInfo; the information it provides is enough to rebuild a MethodInsnNode with ASM.

getMethodType contains the Method Parameters, from which the Descriptor can be worked out.

Once the MethodInsnNode has been rebuilt, use it to replace the original InvokeDynamicInsnNode, and all Reference Obfuscation is fully decrypted.

1

Method Parameter Obfuscation

ZKM has a Method Parameter Obfuscation feature that turns a method's parameters into an Object[], and every call is changed to assemble an Object Array as well. Combined with an obfuscation setting like overloadaggressively, it generates large numbers of methods whose parameters and names are exactly the same, differing only in return type, just to make things painful. So even after decrypting the Invoke Dynamic, you can't infer anything from the names either.

1

Clearly, accessing the parameters follows a fixed pattern

dup → iconst → aaload → checkcast → *unbox

1

If there is an unboxing instruction such as invokevirtual java/lang/Integer.intValue ()I, the type is the unboxed primitive type; if there is no unboxing, the type is the checkcast Type. The parameter order is determined entirely by the array index and has nothing to do with the order of the instructions.

Example:

aaload
**checkcast com/zelix/o**
astore v6
dup
iconst_1
aaload
checkcast java/lang/Integer
**invokevirtual java/lang/Integer.intValue ()I**
istore i4
dup
iconst_2
aaload
checkcast java/lang/Long
**invokevirtual java/lang/Long.longValue ()J**
lstore j2
dup
iconst_3
aaload
checkcast java/lang/Integer
**invokevirtual java/lang/Integer.intValue ()I**
istore i5
dup
iconst_4
aaload
checkcast java/lang/Integer
**invokevirtual java/lang/Integer.intValue ()I**
istore i7
pop

From this we can infer that the original Descriptor is (Lcom/zelix/o;IJII)V, and the method parameters can be repaired.

Handling the Local Variable Table

After the parameters are repaired, the method body still reads the parameters through the local variables they were unpacked into, whereas it should now read the parameters directly. So the local variable table also has to be rearranged.

In an instance method, this always occupies slot 0, and the parameters follow in order starting from slot 1, with long and double taking two slots each. Proceeding this way, all parameter references can be repaired.

There is also a special case where the parameter is not referenced at all, because ZKM turns even no-argument methods like ()V into ones that take an Object[] input; these can be restored directly to ().

Parameter Inheritance

When ZKM applies parameter obfuscation, it turns this method's parameters into Object[] across the entire inheritance table, so the superclass or interface must also contain an i([Ljava/lang/Object;)V. We therefore need to build a ClassHierarchy, after which the parameters of the Override methods across the whole inheritance or implementation table can be inferred from the individual methods that have concrete code.

Repairing References

Method call sites also have Object[] boxing code that needs to be handled. Example bytecode

aload this
aload v6
iload i4
iload i5
iload i7
lload j10
sipush 31703
ldc 4810934370959675205L
lload j2
lxor
invokedynamic t (IJ)Ljava/lang/String; .handle H_INVOKESTATIC com/zelix/_a.b (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; .args .end
bipush 6
anewarray java/lang/Object
dup_x1
swap
iconst_5
swap
aastore
dup_x2
dup_x2
pop
invokestatic java/lang/Long.valueOf (J)Ljava/lang/Long;
iconst_4
swap
aastore
dup_x1
swap
invokestatic java/lang/Integer.valueOf (I)Ljava/lang/Integer;
iconst_3
swap
aastore
dup_x1
swap
invokestatic java/lang/Integer.valueOf (I)Ljava/lang/Integer;
iconst_2
swap
aastore
dup_x1
swap
invokestatic java/lang/Integer.valueOf (I)Ljava/lang/Integer;
iconst_1
swap
aastore
dup_x1
swap
iconst_0
swap
aastore
ldc -6992759839369705212L
lload j2
invokedynamic n (Ljava/lang/Object;Ljava/lang/Object;JJ)V .handle H_INVOKESTATIC com/zelix/nnn.a (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; .args .end

Corresponding Java code

java
Object[] objectArray3 = new Object[6];
objectArray3[5] = _a.b("t", (int)31703, (long)(0x42C3DF522AC4CF45L ^ l));
objectArray3[4] = l4;
objectArray3[3] = n3;
objectArray3[2] = n2;
objectArray3[1] = n;
objectArray3[0] = o2;
nnn.a("n", (Object)this, (Object)objectArray3, (long)-6992759839369705212L, (long)l);

The stack contents before anewarray are the original arguments, so a reference is repaired by deleting every instruction from the array length (bipush 6) up to just before the call instruction, and replacing the call's Descriptor with the repaired one.

Changes in ZKM 27.0.0

To defeat automatic recognition, newer versions of ZKM leave out unboxing and checkcast wherever possible

If a parameter is used only once in the method and its destination accepts an Object, such as List.add, ZKM does not generate a checkcast instruction.

Example:

java
public static void putValue(Typed arg) {
    list.add(arg);
}

After 27.0.0 obfuscation it becomes

java
public static void putValue(Object[] arg) {
    list.add(arg[0]);
}

With the checkcast left out, the Typed type can no longer be inferred from the current method body alone.

Although the checkcast in the current method is left out, the Caller Method may perform a checkcast at the start of the method because it uses the variable in several places, or the variable may be stored into a local variable in the Caller Method whose context gives it a definite type.

Alternatively, if a method is called from three places and tracing upward reveals that the argument types all implement some interface or extend some class, then the parameter is clearly that superclass or interface; a scoring mechanism can be used to compute the most likely type.

Inlining

After the values are written back, operations on the extra parameter become a fixed lload of a value, which can be folded directly, based on the stack, into a direct reference instead of a local variable. This way everything before each call is restored to constants, laying the groundwork for decrypting the constants.

Method Parameter Changes

Besides Method Parameter Obfuscation (which turns parameters into Object[]), ZKM has another feature called Method Parameter Changes, which assigns each method an extra long parameter. This parameter takes part in the obfuscation computations inside the method, and its value is XORed when the next method is called.

This means you have to trace every call completely, working down from the main method, to compute the value of each method's long key. The biggest obstacle here is lambda methods. ZKM adds the extra parameter to lambda implementation methods as well: the caller pushes it onto the stack as an argument when creating the lambda, and the descriptor of LambdaMetafactory's bsmArgs[1] (the Handle of the implementation method) also carries the extra parameter. Both places need to be repaired.

In some cases it mutates into an even nastier form

1

Instead of passing a long directly, it passes several short, int and char values and then assembles the method's long from them: the char supplies the low 16 bits, the int the middle 32 bits and the short the high 16 bits. The bytecode is

iload i1
i2l
bipush 48
lshl
iload i2
i2l
bipush 32
lshl
bipush 16
lushr
lor
iload i4
i2l
bipush 48
lshl
bipush 48
lushr

For this pattern, you first need to reconstruct the original long from the types passed in and fold the computation before you can continue building the call trace.

Once Reference Obfuscation has been decrypted, as long as lambdas are handled properly, the call structure is easy to build. Getting the xor value it applies to the parameter lets you compute the long value of each method; you can then write the values back and erase the extra parameters, since after write-back the extra parameters are no longer referenced.

Decrypting Constants

Obviously, once Invoke Dynamic and Parameter Changes have been decrypted, ZKM's string and number obfuscation looks pretty pathetic. Since the Invoke Dynamic has been decrypted, it is now just a very simple (II)Ljava/lang/String; or (IJ)I.

All you need to do is take the two arguments on the stack before the call and emulate this decryption method, and it hands back the plaintext. Replace the MethodInsnNode with an ldc of the result and the string decryption is done; numbers work the same way.

1

String obfuscation without Invoke Dynamic

Flow Obfuscation

1

ZKM's Flow Obfuscation uses opaque predicates protected by Invoke Dynamic to make fake jumps (goto). It's just that the decompiler is too dumb and handles goto poorly, so the decompiled output comes out as a huge mess. If it recognized them as ifeq rather than goto, the method would look like this

java
public double j(Object[] args) {
    long key = (Long) args[0];
    long k1 = key ^ 51690899946897L;
    double result = 1.0;
    ph p = (ph) this.x(0);
    // Opaque predicate used for fake jumps; it returns null
    Object flag = nnn.a("i", -3574749935301550702L, key);

    try {
        if (flag == null) { // True
            if (!(p instanceof ls)) {
                return result;
            }
        }

        List list = ((ls) p).k(new Object[]{k1})
        int n = list.size();

        if (key > 0) { // Control flow via Parameter Changes: checks whether the Parameter is positive or negative
            if (flag == null) { // True
                if (n <= 0) {
                    return result;
                }
                n = parse(new Object[]{(String) list.get(list.size() - 1)});
            }
        }

        if (flag == null) {// True
            if (n == 0) {
                n = this.l();
                if (flag == null) { // True
                    if (n == 0) {
                        result *= 0.1;
                        return result;
                    }
                    n = list.size();
                }
            } else {
                n = list.size();
            }
        }

        if (n <= 1) {
            return result;
        }

        double t = result * 0.25;
        if (key < 0) { // False
            return t;
        }
        result = t;

        if (flag != null) { // False
            result *= 0.1;
        }
        return result;
    } catch (x1 e) {
        throw nnn.a("i", e, -2926417976333327871L, key);
    }
}

It's actually simple, isn't it? As long as you can get the Parameter plus the value of this Invoke Dynamic, you can easily tell the conditions apart and strip out the fake logic.

Opaque Predicates

1

Opaque predicate fake jump

An opaque predicate method is actually very simple. It usually returns an int, a boolean or a String, and there is a setter, which is used for initialization in the static block of the opaque predicate class.

1

Confusingly, references to this setter are generated elsewhere as well, but from what I've observed, the generated references are all in unreachable places, creating the illusion that the opaque predicate field can change. In reality it never changes at all, and what you get directly is the final value. This is the conclusion I reached after analyzing many samples.

1

Forms of Fake Jumps

Overall, ZKM's control flow fake jumps come in three forms, each with obvious characteristics, and removing the corresponding instructions cleans them up.

These conditions may also be randomly inverted into real jumps, so the conditions must be evaluated for a perfect cleanup; you can't decide based only on whether there is a jump below.

Fake jumps on int/boolean checks

1 1

Fake jumps on ifnull/ifnonnull Object checks

1

Fake jumps on the sign of the long key method parameter

1

This kind is usually linked with Parameter Changes; if you can't evaluate every Parameter, you can't remove it correctly

Exception Obfuscation

1

ZKM inserts large numbers of try catch blocks into the code, and what they throw is an Invoke Dynamic, but the method it actually points to is something completely meaningless:

java
// Yes, it really looks like this
private static Exception a(Exception exception) {
    return exception;
}

If you see something like this, the try catch block is junk inserted by ZKM and can simply be deleted.

Details

Beyond deobfuscation, some of the details in ZKM are also well worth looking into.

Hidden Checks

ZKM contains many hidden checks to stop you from tampering with or cracking it.

License Information Tampering Detection

When ZKM runs, it writes a log, and the log contains your license information.

1

Example of the license information in the log file

After running, it reads the log back and checks whether the Hash of this license information matches a pre-generated Hash (presumably injected automatically when ZKM is compiled). If they don't match, the hidden check is triggered.

1

ZKM's code that reads the log

Once the hidden check is triggered, it inserts Labels and GOTOs in the wrong places, causing the obfuscation to go wrong, and the obfuscated output will not run

1

prefixWithSpace = !licenseVerified;

Puzzlingly, if this hidden check is triggered, it adds a space at the start of one particular log line. I believe this is so that, if you take an error log caused by the triggered check to their staff for support, they can tell that the crash was caused by modified License information.

Time Synchronization Detection

If you crack the expiry check of the trial version or of certain special licenses by redirecting currentTimeMillis, it can detect this, because it reads the modification timestamp of the log file to determine whether the time has been tampered with.

Of course, this approach does not protect against changing the system time; it only prevents ZKM alone from being fed the wrong time.

1

Obfuscation-Time Detection

Besides the detection at startup, there is also detection during obfuscation, which is likewise a complete set of checks

1

The code inside that if(false) is logic exclusive to the trial version

The check does not fire every time: at the start of each obfuscation run it calls random.nextInt(23), and only fires if the result is 0, which makes it harder for you to tell whether your fix has worked.

Once this check fires, the inheritance table is not built, so the inheritance relationships in the obfuscated files are not handled, and all inherited names end up inconsistent, leaving the output unusable.

Watermark

1

1

ZKM writes its own version number into the constant pool of the output Classes, and the trial version also adds an E marker.

How AutoReflectionâ„¢ Is Implemented

ZKM's Name Obfuscation (also called the Renamer) handles reflection almost perfectly, including all kinds of string concatenation.

It works by building a mapping table of original name SHA512 -> obfuscated name and proxying all reflection methods: the original name passed in is hashed, and if an obfuscated name can be found, the call is automatically redirected to it and the Field or Method is returned.

1

ZKM itself also uses this mechanism in many places. It XOR-encrypts every instance of the string com.zelix.klassmaster.KlassMaster with java/lang/Object as the key, then accesses it through the Auto Reflection Wrapper. So there is a lot of reflective access inside ZKM as well; developers can use reflection without any worries, and Auto Reflection takes care of it.

At the same time, because only the SHA512 of each original name is stored, you cannot work out all of the original names through Auto Reflection.

Acknowledgements

Disclaimer

This article is my retrospective on the reverse engineering of ZKM I did in 2025. Now that we are in the LLM era there are of course more elegant solutions, so this content is for reference only. And don't forget that the strongest LLM at the time was Claude Opus 4.0.

Comments

Loading comments…