Reverse Engineering a Java Equinox OSGi Application: From Encrypted Bundles to the Decryption Hook

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

Overview

Recently I analyzed a Java desktop Windows application built on the Equinox OSGi framework. Its file layout was sprawling, with layers of directories — the plugins directory alone held nearly 500 OSGi bundle JARs. While analyzing it, I found several core JARs were encrypted: the class file magic had changed from CAFEBABE to CAFEBEBA. I went looking for the usual Java-app decryption mechanisms — a Java agent, a native agent, or a custom ClassLoader that dynamically decrypts class files — and found none of them. Not a trace.

Encrypted class files with the CAFEBEBA magic

Neither the JVM launch config nor the process launch info showed any -javaagent:xxx.jar or -agentlib:xxx flag. I searched for a long time without any lead, and even suspected the software had patched the JVM itself to decrypt specific classes — but quick debugging plus file-version and signature checks ruled that out.

Stuck, and unable to find where the JARs/class files were decrypted, I fell back to dumping class files from the running Java process’s memory. The dump went smoothly — with JDK 11 you can use the built-in graphical HSDB tool — but I hit a problem: HSDB only dumps classes that have already been loaded and used; the untouched ones were still missing.

So I came up with a plan: inject my own code to dynamically load all the classes of the target encrypted JAR, then dump them with HSDB. In practice it wasn’t that simple. First, to dynamically load a class so it runs the software’s own decryption path, I needed the target JAR’s ClassLoader. I discovered that the OSGi framework doesn’t use the usual parent-delegation model — it uses a flexible, bundle-based mesh of class loaders. Second, injection: what form should the injection take to trigger dynamic loading?

After searching, I found that public material on reversing Equinox OSGi applications is almost nonexistent — only some development docs. I ended up exploring on my own and put together an OSGi Bundle injection scheme to dynamically load all classes of the target JAR, after which HSDB could dump them — indirectly obtaining the contents of the core encrypted JARs.

I still wasn’t satisfied, and out of curiosity I wanted to find how the software decrypted classes without a Java agent, a JVMTI agent, or a custom ClassLoader — or whether I’d missed something. After two grueling days of carpet-bombing every corner, I finally found the decryption site from a trace in one of my attempts. Unsurprisingly, it was also tied to the Equinox OSGi framework.

This article records that painful reverse-engineering journey, adding a bit of material to an area where almost no public RE documentation exists.

Equinox OSGi introduction

From the official site and related searches: OSGi is a modular-development framework aimed at solving the modularity shortcomings of traditional Java applications.

The basic unit in OSGi is the Bundle, packaged as a JAR but with extra information — for example, the MANIFEST.MF file declares the module’s dependencies, exported packages, and imported packages. In the app I analyzed, all bundles live under a plugins directory, all with a .jar extension, including many of the Equinox framework’s own bundles.

Bundles in the plugins directory

OSGi’s class-loading mechanism breaks the parent-delegation model and uses a more flexible approach: each bundle has its own class loader for its internal classes and resources. Bundles declare their dependencies and exposed packages through Import-Package and Export-Package in MANIFEST.MF. When a bundle needs a class, the OSGi framework looks it up from other bundles according to dependency relationships rather than delegating upward. Unzip a bundle and look at its MANIFEST.MF:

MANIFEST.MF with Import-Package/Export-Package

Equinox is an open-source OSGi framework implementation, originally developed as the modular runtime for the Eclipse IDE and later spun out. The other popular OSGi implementation is Apache Felix.

OSGi Bundle injection

With some knowledge of the Equinox framework and basic OSGi development, the “how do I inject to dynamically load all encrypted classes” question now had a direction: try injecting an OSGi Bundle, use the relevant APIs to get the ClassLoader of the bundle represented by the target encrypted JAR, and then dynamically load all its classes.

How to inject? I found the Equinox config.ini under a configuration directory, which holds various configuration properties:

# xxxxx config
eclipse.product=com.xxxxx.xxxxx.product.xxxxx
eclipse.application=com.xxxxx.xxxxx.application.xxxxx
osgi.splashPath=platform\:/base/plugins/com.xxxxx.xxxxx.product
org.eclipse.equinox.simpleconfigurator.configUrl=file\:org.eclipse.equinox.simpleconfigurator/bundles.info
equinox.use.ds=true
osgi.bundles.defaultStartLevel=4
org.eclipse.update.reconcile=false
osgi.bundles=reference\:file\:org.eclipse.equinox.simpleconfigurator_xxx.jar@1\:start,com.xxxxx.xxxxx.systemtexts@4\:start
eclipse.p2.data.area=C:/ProgramData/xxxx-xxx/data/XD_DT/p2
ds.delayed.keepInstances=true
eclipse.p2.profile=DefaultProfile
osgi.framework=file\:///C:/Program Files (x86)/xxxxx-xxx/xxxxx/bin/plugins/org.eclipse.osgi_xxx.jar
osgi.framework.extensions=reference\:file\:///C:/Program Files (x86)/xxxxx-xxx/xxxxx/bin/plugins/org.eclipse.equinox.updateconfigurator_xxx.jar,reference\:file\:///C:/Program Files (x86)/xxxxx-xxx/xxxxx/bin/plugins/org.eclipse.osgi.compatibility.state_1.1.100.xxx.jar
osgi.instance.area=C:/ProgramData/xxxx-xxx/data/XD_DT/instance
osgi.dataAreaRequiresExplicitInit=true

I searched and found a doc on the Eclipse Equinox site about config.ini, specifically osgi.bundles — it specifies the startup bundles and lets you set their start level.

Equinox config.ini documentation

That gave me the OSGi injection scheme: add my own bundle’s path to the osgi.bundles entry in config.ini, then drop it into the plugins directory — and the OSGi bundle is injected.

Developing the dynamic-loading bundle

Next, I built the bundle that dynamically loads the encrypted classes in Eclipse. After searching, I found the OSGi classes and methods I needed.

You can get all bundles via the BundleContext class’s getBundles method:

BundleContext.getBundles

Then filter for the target encrypted JAR’s bundle via the Bundle class’s getSymbolicName, and finally use findEntries and loadClass to get all class names and actively load them.

Bundle.findEntries / loadClass

Here’s the Activator.java code:

package com.my.osgi.bundle;

import java.beans.Beans;
import java.io.File;
import java.io.FileOutputStream;
import java.io.PrintWriter;
import java.net.URL;
import java.nio.file.Paths;
import java.util.Enumeration;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
import org.osgi.framework.FrameworkUtil;


public class Activator implements BundleActivator {

	private static BundleContext context;

	static BundleContext getContext() {
		return context;
	}

	public void start(BundleContext bundleContext) throws Exception {
		Activator.context = bundleContext;

	    Bundle[] bundles = bundleContext.getBundles();
	    Bundle targetBundle = null;
	    for (Bundle bundle : bundles) {
	        if (bundle.getSymbolicName().equals("com.xxx.bundle")) {
	            targetBundle = bundle;
	            break;
	        }
	    }

	    if(targetBundle != null ) {
		    Enumeration<URL> entries = targetBundle.findEntries("/", "*.class", true);
		    while (entries.hasMoreElements()) {
		        URL url = entries.nextElement();
		        if (url.getPath().endsWith(".class")) {
		            String className = url.getPath().replace("/", ".").replace(".class", "");
		            while (className.startsWith(".")) {
		            	className = className.substring(1);
		            }
		            targetBundle.loadClass(className);
		        }
		    }
	    }
	}

	public void stop(BundleContext bundleContext) throws Exception {
		Activator.context = null;
	}
}

I hit a few snags creating the Equinox OSGi Bundle project and adding the local bundle dependencies I needed, so I’ll share those too.

After downloading Eclipse, check whether it supports the Plug-in Development project type. If not, click Help → Install new software, select download.eclipse.org/eclipse/updates/latest, and install the Plug-in Development components (configure a proxy first if you can).

Installing Plug-in Development

After creating an OSGi Bundle project, if you need to add a dependency JAR to call methods in another bundle, open Windows → Preferences → Plug-in Development → Target Platform, edit the Running Platform, and add a local dependencies-bundles folder.

Target Platform configuration

Not done yet — click the OSGi Bundle project’s MANIFEST.MF, and in the dialog that appears click Dependencies to add the bundle you just added as a dependency. Without the previous step it won’t be found.

Dumping the encrypted classes with HSDB

Once the dynamic-loading bundle is injected, dumping is easy. For example with JDK 11, find jhsdb.exe under its bin directory and run jhsdb.exe hsdb to open the GUI. Enter the target Java process id, click Tools → Class Browser, filter the relevant class names, then click Create .class for all classes to dump all filtered classes. That gives you the class-file contents of the app’s core encrypted bundles — package them up and analyze with jadx.

HSDB Class Browser

Java hook attempts

Even though I could now obtain the encrypted bundle contents, I still wanted to find where the decryption happens. I did a massive carpet-bombing search — not just through JARs, but also through suspicious EXEs and DLLs — and finally found a trace during my Java-hook attempts.

Javassit agent

First I needed to confirm whether the bundle JARs might already have been decrypted by some native executable before the bundle classes were initialized. After some quick analysis, I chose to generate an agent with the javassist library to hook and dump the File passed to BundleFile during initialization.

javassit agent hooking BundleFile

The target was a constructor with one parameter, so I had to write some text-based code for javassist. It took a few tries to get running. Here’s the ClassFileTransformer implementation — compile it into a JAR and add -javaagent:xxx.jar to the JVM launch flags:

package com.debug;

import java.io.ByteArrayInputStream;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.util.logging.Logger;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;

public class BundleFileTransformer implements ClassFileTransformer {
    static {
        LOGGER = Logger.getLogger(BundleFileTransformer.class.getName());
        try {
            FileHandler fileHandler = new FileHandler("C:/Users/user/Documents/plugins/BundleFileTransformer.log", true);
            fileHandler.setFormatter(new SimpleFormatter());
            LOGGER.addHandler(fileHandler);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer) {

        if (!"org/eclipse/osgi/storage/bundlefile/BundleFile".equals(className)) {
            return classfileBuffer;
        }

        LOGGER.info("Transforming BundleFile class");

        try {
            ClassPool classPool = ClassPool.getDefault();
            CtClass ctClass = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));
            CtConstructor[] constructors = ctClass.getConstructors();

            for (CtConstructor constructor : constructors) {
                CtClass[] parameterTypes = constructor.getParameterTypes();
                if (parameterTypes.length == 1 && parameterTypes[0].getName().equals("java.io.File")) {
                    constructor.insertBefore(
                    "try {" +
                            "    java.io.File sourceFile = $1;" +
                            "    String fileName = sourceFile.getName();" +
                            "    java.util.logging.Logger.getLogger(\"" + BundleFileTransformer.class.getName() + "\").info(" +
                            "        \"bundle: \" + fileName);" +
                                "    if (fileName.contains(\"com.xxxx.xxxx.jar\")) {" +
                                "        java.io.File destDir = new java.io.File(\"C:/Users/user/Documents/plugins/\");" +
                                "        java.io.File destFile = new java.io.File(destDir, fileName);" +
                                "        java.io.InputStream in = new java.io.FileInputStream(sourceFile);" +
                                "        java.io.OutputStream out = new java.io.FileOutputStream(destFile);" +
                                "        byte[] buffer = new byte[1024];" +
                                "        int length;" +
                                "        while ((length = in.read(buffer)) > 0) {" +
                                "            out.write(buffer, 0, length);" +
                                "        }" +
                                "        in.close();" +
                                "        out.close();" +
                                "        java.util.logging.Logger.getLogger(\"" + BundleFileTransformer.class.getName() + "\").info(" +
                                "            \"Saved bundle file to: \" + destFile.getAbsolutePath());" +
                                "    }" +
                            "} catch (java.io.IOException e) {" +
                            "    java.util.logging.Logger.getLogger(\"" + BundleFileTransformer.class.getName() + "\").severe(" +
                            "        \"Error saving bundle file: \" + e.getMessage());" +
                            "}"
                    );
                    break;
                }
            }

            byte[] modifiedClass = ctClass.toBytecode();
            ctClass.detach();
            return modifiedClass;
        } catch (Exception e) {
            LOGGER.severe("Failed to transform BundleFile class: " + e.getMessage());
            return classfileBuffer;
        }
    }
}

I dumped the core encrypted bundle files used during initialization and compared them with the local ones — identical, meaning they had not been decrypted by any other code before initialization.

Later I put together an IDEA template for generating a Java agent with javassist, so you can modify it directly and save some configuration effort. If you’ve never done this before, remember to compile the JAR via the Maven panel → Lifecycle → Package.

Arthas

Next I used Alibaba’s open-source Java diagnostic tool, arthas, to attempt hooks and inspect call stacks of various methods. While analyzing the defineClass method that a custom ClassLoader uses for dynamic decryption, I saw some suspicious class methods.

Running stack java.lang.ClassLoader defineClass -n 500 gave, among others, this result — a long chain of class operations, but they all look like Equinox OSGi framework code:

ts=2025-03-29 06:21:23.302;thread_name=Work queue 0;id=49;is_daemon=false;priority=5;TCCL=org.eclipse.osgi.internal.framework.ContextFinder@776b83cc
    @org.eclipse.osgi.internal.loader.ModuleClassLoader.defineClass()
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.defineClass(ClasspathManager.java:632)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.findClassImpl(ClasspathManager.java:555)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.findLocalClassImpl(ClasspathManager.java:514)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.findLocalClass(ClasspathManager.java:501)
        at org.eclipse.osgi.internal.loader.ModuleClassLoader.findLocalClass(ModuleClassLoader.java:328)
        at org.eclipse.osgi.internal.loader.BundleLoader.findLocalClass(BundleLoader.java:392)
        at org.eclipse.osgi.internal.loader.BundleLoader.findClassInternal(BundleLoader.java:470)
        at org.eclipse.osgi.internal.loader.BundleLoader.findClass(BundleLoader.java:419)
        at org.eclipse.osgi.internal.loader.BundleLoader.findClass(BundleLoader.java:411)
        at org.eclipse.osgi.internal.loader.ModuleClassLoader.loadClass(ModuleClassLoader.java:150)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:522)
        at java.lang.ClassLoader.defineClass1(ClassLoader.java:-2)
        at java.lang.ClassLoader.defineClass(ClassLoader.java:1017)
        at org.eclipse.osgi.internal.loader.ModuleClassLoader.defineClass(ModuleClassLoader.java:276)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.defineClass(ClasspathManager.java:632)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.findClassImpl(ClasspathManager.java:555)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.findLocalClassImpl(ClasspathManager.java:514)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.findLocalClass(ClasspathManager.java:501)
        at org.eclipse.osgi.internal.loader.ModuleClassLoader.findLocalClass(ModuleClassLoader.java:328)
        at org.eclipse.osgi.internal.loader.BundleLoader.findLocalClass(BundleLoader.java:392)
        at org.eclipse.osgi.internal.loader.BundleLoader.findClassInternal(BundleLoader.java:470)
        at org.eclipse.osgi.internal.loader.BundleLoader.findClass(BundleLoader.java:419)
        at org.eclipse.osgi.internal.loader.BundleLoader.findClass(BundleLoader.java:411)
        at org.eclipse.osgi.internal.loader.ModuleClassLoader.loadClass(ModuleClassLoader.java:150)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:522)
        at java.lang.ClassLoader.defineClass1(ClassLoader.java:-2)
        at java.lang.ClassLoader.defineClass(ClassLoader.java:1017)
        at org.eclipse.osgi.internal.loader.ModuleClassLoader.defineClass(ModuleClassLoader.java:276)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.defineClass(ClasspathManager.java:632)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.findClassImpl(ClasspathManager.java:555)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.findLocalClassImpl(ClasspathManager.java:514)
        at org.eclipse.osgi.internal.loader.classpath.ClasspathManager.findLocalClass(ClasspathManager.java:501)
        at org.eclipse.osgi.internal.loader.ModuleClassLoader.findLocalClass(ModuleClassLoader.java:328)
        at org.eclipse.osgi.internal.loader.BundleLoader.findLocalClass(BundleLoader.java:392)
        at org.eclipse.osgi.internal.loader.BundleLoader.findClassInternal(BundleLoader.java:470)
        at org.eclipse.osgi.internal.loader.BundleLoader.findClass(BundleLoader.java:419)
        at org.eclipse.osgi.internal.loader.BundleLoader.findClass(BundleLoader.java:411)
        at org.eclipse.osgi.internal.loader.ModuleClassLoader.loadClass(ModuleClassLoader.java:150)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:522)
        at org.eclipse.osgi.internal.framework.EquinoxBundle.loadClass(EquinoxBundle.java:609)
	        ...

The Equinox ClassLoaderHook mechanism

The decryption site

Since I was doing a carpet-bombing search, I obviously didn’t let these suspicious classes go — even Equinox OSGi framework library code. I used HSDB to dump all class files in the process, including library code, and analyzed them.

While going through them one by one in jadx, I reached the decompiled ClasspathManager and my eyes lit up: its imports included import org.eclipse.osgi.internal.hookregistry.ClassLoaderHook;. ClassLoaderHook — that name is way too suspicious. Could it be related?

Looking at its cross-references, I found a class implementing it that overrides processClass, and inside it does AES decryption on a byte array — after first checking whether the third byte is (-66 & 0xff) => BE. Back at the start of this article, the encrypted class magic was CAFEBEBA, whose third byte is also BE!

ClassLoaderHook processClass override

AES decryption code

I quickly wrote a decryption script using the recovered key, ran AES decryption, and confirmed — this is the class-file decryption site.

The ClassLoaderHook mechanism

I tried searching for “Equinox OSGi”, “hookregistry”, “ClassLoaderHook”, and “processClass”, but the results were sparse — almost no useful references.

The Eclipse documentation has an Adaptor Hooks topic, with a section on Class Loading Hooks briefly mentioning that it adds capabilities to a bundle’s class loader — searching for native code, adding classpath entries to a bundle, creating class loaders, and modifying class bytecode.

I also found ClassLoadingHook.java in a GitHub repository:

ClassLoadingHook.java

But what I ran into was ClassLoaderHook, not ClassLoadingHook. You can also find the ClassLoaderHook.java implementation in the Eclipse official code repository.

ClassLoaderHook.java implementation

After more searching, I found the answer in a post on the Eclipse community forum. A user asked: they’d implemented some functionality using an Equinox hook (org.eclipse.osgi.baseadaptor.hooks.ClassLoadingHook), but they’d read in an update announcement that many Equinox hooks would no longer be supported — what should they do? A likely Equinox developer replied that most uses can be replaced with the new extension abstract class org.eclipse.osgi.internal.hookregistry.ClassLoaderHook.

Eclipse forum post about ClassLoaderHook migration

Clicking the update-announcement link the user gave leads to a site that only briefly mentions that many types of Equinox hooks had their internal implementation details changed and need migration, without specifics.

Update announcement about Equinox hook changes

In other words, Equinox OSGi’s dynamic class-decryption mechanism was updated from ClassLoadingHook to ClassLoaderHook, but because documentation is so scarce, search-engine hits alone rarely lead you to this answer.

Conclusion

This article records my reverse-engineering journey against a Java Equinox OSGi application, providing two ways to obtain the class contents of its encrypted bundles. In the end I determined that the software did not use the usual Java class-decryption implementations (Java agent, JVMTI agent, or custom ClassLoader decryption), but instead used the ClassLoaderHook mechanism provided by the Equinox OSGi framework. Public material on this topic is very scarce, so this article adds a complementary reference.

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch