Writing a Class-Extraction Unpacker with Frida: FART's Active Invocation Chain, Reimplemented
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.
Writing a Class-Extraction Unpacker with Frida: FART’s Active Invocation Chain, Reimplemented
FART fixes packed dex at function granularity by modifying and compiling the Android system source into a custom “unpacking ROM”. The depth of FART’s active-invocation chain can be tuned to match when a given shell restores its code.
The question this post tries to answer: if we drop FART’s active-invocation chain entirely, can a plain Frida hook framework be enough to write an unpacker that solves class-extraction shells at function granularity?
FART’s unpacking points
There are two:
<clinit>—Execute=>dumpDexFileByExecute- other normal functions —
DexFile_dumpMethodCode=>myfartInvoke=>Invoke=>dumpArtMethod
And the active-invocation chain:
start fart
-> getClassloader (get the ClassLoader)
-> fartwithClassLoader (reflectively get mCookie)
-> loadClassAndInvoke (convert functions to ArtMethod via dumpMethodCode, wrap them into fake_Invoke args)
-> the system Invoke (call dumpArtMethod — the second unpacking point)
So the precondition of FART’s active invocation is: get the app’s ClassLoader, load every class through it, and from each class enumerate all of its methods (constructors and regular methods).
The hands-on part
Load every class under the app’s final ClassLoader
// load every class under the current ClassLoader
function load_all_class() {
if (Java.available) {
Java.perform(function () {
var DexFileclass = Java.use("dalvik.system.DexFile");
var BaseDexClassLoaderclass = Java.use("dalvik.system.BaseDexClassLoader");
var DexPathListclass = Java.use("dalvik.system.DexPathList");
Java.enumerateClassLoaders({
onMatch: function (loader) {
console.log("loader:",loader);
// exclude BootClassLoader and other unrelated ClassLoaders
if((loader.toString().indexOf("java.lang.BootClassLoader")==-1)&&(loader.toString().indexOf("zip")==-1) ){
try {
var BaseDexClassLoader_obj = Java.cast(loader, BaseDexClassLoaderclass);
var pathList = BaseDexClassLoader_obj.pathList.value;
var pathList_obj = Java.cast(pathList, DexPathListclass)
var ElementsArray = pathList_obj.dexElements.value;
console.log("ElementsArray---->",ElementsArray);
for (var index in ElementsArray) {
var element = ElementsArray[index];
try {
try{var dexfile = element.dexFile.value;}catch(e){}
var dexfileobj = Java.cast(dexfile, DexFileclass);
const classNames = [];
const enumeratorClassNames = dexfileobj.entries();
while (enumeratorClassNames.hasMoreElements()) {
var className = enumeratorClassNames.nextElement().toString();
classNames.push(className);
// load a specific class
if(className.indexOf("com.example.targetapp.activity.LoginActivity")>=0){
console.log("ClassName : " ,className);
loader.loadClass(className);
}
}
}catch(e){
console.log(e)
}
}
} catch (e) {
console.log(e);
}
}
},
onComplete: function () {
console.log("[*]load_all_class success!");
}
})
});
}
}
Enumerate every method (constructors included) of each class
// modify the core load call above to invoke load_all_methods
if(className.indexOf("com.example.targetapp.activity.LoginActivity")>=0){
console.log("ClassName : " ,className);
// every class must load all of its own methods
load_all_methods(loader,className);
}
// load every method under each class
function load_all_methods(loader,classname){
if (Java.available) {
Java.perform(function () {
// cast of the current class (the xxxx.class equivalent); hookClassCast is the returned value
var clazz = loader.loadClass(classname);
var ClassUse = Java.use("java.lang.Class");
var hookClassCast = Java.cast(clazz,ClassUse);
console.log("hookClassCast is :",hookClassCast);
console.log("------------------------------Constructors-------------------------------");
var Constructors = hookClassCast.getDeclaredConstructors();
console.log(Constructors);
console.log("---------------------------------Methods---------------------------------");
var Methods = hookClassCast.getDeclaredMethods();
console.log(Methods,"\n");
});
}
}
Now both Constructor and Method are printable. Next step: feed them into ArtMethod::Invoke for a kind of “virtual” invocation.
Dissecting FART’s active-invocation chain
DexFile_dumpMethodCode uses jobject2ArtMethod — this matters:
static void DexFile_dumpMethodCode(JNIEnv* env, jclass, jobject method) {
if(method!=nullptr)
{
ArtMethod* proxy_method = jobject2ArtMethod(env, method);
myfartInvoke(proxy_method);
}
return;
}
What actually happens is the method is passed to Invoke, which then calls dumpArtMethod — nothing “functional” runs before dumpArtMethod. So the depth of FART’s active-invocation chain is really determined by jobject2ArtMethod:
extern "C" ArtMethod* jobject2ArtMethod(JNIEnv* env, jobject javaMethod) {
ScopedFastNativeObjectAccess soa(env);
ArtMethod* method = ArtMethod::FromReflectedMethod(soa, javaMethod);
return method;
}
It uses mirror::ArtMethod::FromReflectedMethod to get the native ArtMethod pointer from the Java object:
ArtMethod* ArtMethod::FromReflectedMethod(const ScopedObjectAccessAlreadyRunnable& soa, jobject jlr_method) {
ObjPtr<mirror::Executable> executable = soa.Decode<mirror::Executable>(jlr_method);
DCHECK(executable != nullptr);
return executable->GetArtMethod();
}
Here’s the idea: iterate over every method and invoke each one. Since the arguments are missing (among other reasons), the invocation can never succeed — but we can hook the Java-layer .invoke method (the lower-case Java method) ahead of time, before load_all_class runs, because Java’s .invoke ultimately lands in InvokeMethod. So we hook that:
function hook_native() {
var module_libart = Process.findModuleByName("libart.so");
var symbols = module_libart.enumerateSymbols();
var ArtMethod_Invoke = null;
for (var i = 0; i < symbols.length; i++) {
var symbol = symbols[i];
var address = symbol.address;
var name = symbol.name;
var indexInvoke = name.indexOf("InvokeMethodERKNS");
if ( indexInvoke >= 0) {
console.log(name);
ArtMethod_Invoke = address;
console.log(ArtMethod_Invoke);
}
}
if (ArtMethod_Invoke) {
Interceptor.attach(ArtMethod_Invoke, {
onEnter: function (args) {
try {
console.log("soa is :",args[0],"javaMethod is :",args[1]); // print arg 0 (soa) and arg 1 (javaMethod)
} catch (error) {
console.log(error);
}
}, onLeave: function (retval) {
}
});
}
}
And the method invocation itself:
function load_all_methods(loader,classname){
if (Java.available) {
Java.perform(function () {
var clazz = loader.loadClass(classname);
var ClassUse = Java.use("java.lang.Class");
var hookClassCast = Java.cast(clazz,ClassUse);
console.log("hookClassCast is :",hookClassCast);
console.log("------------------------------Constructors-------------------------------");
var Constructors = hookClassCast.getDeclaredConstructors();
console.log(Constructors);
console.log("---------------------------------Methods---------------------------------");
var Methods = hookClassCast.getDeclaredMethods();
for(var i =0; i<Methods.length;i++){
var method = Methods[i];
method.setAccessible(true);
try{method.invoke(null,null);
console.log("method:",method,"is invoke ok!")}catch(e){console.log(e)}
}
console.log(Methods,"\n");
});
}
}
Every invocation lands in InvokeMethod, so the hook works. And from the printed values, the soa value is constant across calls.
Next we want the ArtMethod pointer for each Method. Where does InvokeMethod expose it? Reading the source, the keyword is GetArtMethod:
// hook GetArtMethod's return value
if (addr_GetArtMethod) {
Interceptor.attach(addr_GetArtMethod, {
onEnter: function (args) {
//console.log("hook GetArtMethod!");
}, onLeave: function (retval) {
console.log("java_ArtMethod_ptr:",retval);
}
});
}
}
That gives us the ArtMethod pointer for each Method. Note the ArtMethod pointer and the ArtMethod object are different things. Can we use this pointer directly for active invocation, per FART’s source?
// hook GetArtMethod's return value
if (addr_GetArtMethod) {
Interceptor.attach(addr_GetArtMethod, {
onEnter: function (args) {
//console.log("hook GetArtMethod!");
}, onLeave: function (retval) {
console.log("java_ArtMethod_ptr:",retval); // get the ArtMethod pointer
var artmethod_ptr = retval;
var myAutoDInvoke = new NativeFunction(addr_myfartInvoke, "void", ["pointer"]);
myfartInvoke(artmethod_ptr);
}
});
}
Calling myfartInvoke with that pointer does produce the corresponding .bin file — the pointer is correct. Swapping in FART’s dumpArtMethod instead works too:
// hook GetArtMethod's return value
if (addr_GetArtMethod) {
Interceptor.attach(addr_GetArtMethod, {
onEnter: function (args) {
//console.log("hook GetArtMethod!");
}, onLeave: function (retval) {
console.log("java_ArtMethod_ptr:",retval);
var artmethod_ptr = retval;
if(artmethod_maps.length==0){
artmethod_maps[0]=ptr(artmethod_ptr);
}else if(artmethod_maps[file]==undefined){
artmethod_maps[file]=ptr(artmethod_ptr);
}
file++;
var dumpArtMethod = new NativeFunction(addr_dumpArtMethod, "void", ["pointer"]);
console.log("dumpArtMethod_in :",dumpArtMethod );
dumpArtMethod_out(artmethod_ptr);
}
});
}
This didn’t quite meet the “pure Frida” bar, so treat it as a halfway point: it only calls one FART API.
The alternative: a Java-layer loadAllClass
Another route: walk every class and load it at the Java layer, which fixes the shells that fill code back into the dex at execution time (the “hole-occupying” type). I wrote a loadAllClass in Java that mirrors FART’s flow (I omit it here since it’s a direct port of the source), then base64-encoded the dex (it gets decrypted before the dex is created/loaded) and loaded it with DexClassLoader, constructing Object[] arguments for each method so the function can actually be called.
The dex side:
public static void loadAllClass(ClassLoader classLoader) {
int i;
Method[] methods;
Constructor<?> cons[];
Class clazz;
try {
Iterator Ir = EnumerateClass.getClassNameList(classLoader).iterator();
while(true) {
if(!Ir.hasNext()) {
return;
}
Object obj = Ir.next();
clazz = classLoader.loadClass(((String)obj));
methods = clazz.getDeclaredMethods();
cons = clazz.getDeclaredConstructors();
Log.d("Frida_UNPACK", "load class: " + clazz.getName());
i = 0;
while(i < methods.length){
Method method = methods[i];
Object[] objs = new Object[method.getParameterTypes().length];
Log.d("FRiDA_UNPACK", "try to load method: " + clazz.getName() + "-->" + method.getName());
method.invoke(null, objs); // only calling the method matters; constructors are less useful
Log.d("FRiDA_UNPACK", "success");
++i;
}
}catch(Throwable e){}
}
(loadAllClass here is a static method.)
The full Frida script
Omitting the base64 step for space:
var dex_maps = {};
// the unpacking point is DefineClass; you can pick another
function hook_dex(){
var openmemory = Module.findExportByName("libart.so", "_ZN3art11ClassLinker11DefineClassEPNS_6ThreadEPKcmNS_6HandleINS_6mirror11ClassLoaderEEERKNS_7DexFileERKNS9_8ClassDefE");
if(openmemory != undefined) {
console.log("DefineClass at" + openmemory);
Interceptor.attach(openmemory, {
onEnter: function (args) {
var dex_file= args[5];
var base = ptr(dex_file).add(Process.pointerSize).readPointer();
var size = ptr(dex_file).add(Process.pointerSize + Process.pointerSize).readUInt();
if (dex_maps[base] == undefined) {
dex_maps[base] = size;
console.log("hook_dex:", base, size);
}
},
onLeave: function (retval) {
}
});
}
}
function get_self_process_name() {
var openPtr = Module.getExportByName('libc.so', 'open');
var open = new NativeFunction(openPtr, 'int', ['pointer', 'int']);
var readPtr = Module.getExportByName("libc.so", "read");
var read = new NativeFunction(readPtr, "int", ["int", "pointer", "int"]);
var closePtr = Module.getExportByName('libc.so', 'close');
var close = new NativeFunction(closePtr, 'int', ['int']);
var path = Memory.allocUtf8String("/proc/self/cmdline");
var fd = open(path, 0);
if (fd != -1) {
var buffer = Memory.alloc(0x1000);
var result = read(fd, buffer, 0x1000);
close(fd);
result = ptr(buffer).readCString();
return result;
}
return "-1";
}
function loadClassAndDump(){
if(Java.available) {
Java.perform(function(){
var dexBase64 = "(base64 of the dex that calls all methods; omitted for space)";
var application = Java.use("android.app.Application");
var BaseDexClassLoader = Java.use("dalvik.system.BaseDexClassLoader");
var Base64 = Java.use("android.util.Base64");
var FileOutputStream = Java.use("java.io.FileOutputStream");
var DexClassLoader = Java.use("dalvik.system.DexClassLoader");
var reflectClass = Java.use("java.lang.Class");
if(application != undefined) {
var Context_class = Java.use("android.content.Context");
var currentApplication = Java.use("android.app.ActivityThread").currentApplication();
var context = currentApplication.getApplicationContext();
var castedContext = Java.cast(context,Context_class);
var classloader = castedContext.getClassLoader();
var filesDir = castedContext.getFilesDir();
var codeCacheDir = castedContext.getCodeCacheDir();
if(classloader != undefined) {
var dexbytes = Base64.decode(dexBase64, 0);
var dexpath = filesDir + "/emmm.dex";
var fout = FileOutputStream.$new(dexpath);
fout.write(dexbytes, 0, dexbytes.length);
fout.close();
console.log("write dex to " + dexpath);
var dexstr = dexpath.toString();
var cachestr = codeCacheDir.toString();
var dyndex = DexClassLoader.$new(dexstr, cachestr, cachestr, classloader);
console.log(dyndex.toString());
var EnumerateClass = dyndex.loadClass("com.example.EnumerateClass");
var castedEnumerateClass = Java.cast(EnumerateClass, reflectClass);
var methods = castedEnumerateClass.getDeclaredMethods();
// loadAllClass
var loadAllClass = undefined;
for(var i =0; i<methods.length;i++){
var method = methods[i];
method.setAccessible(true);
if(method.getName()=="loadAllClass"){
loadAllClass = method;
}
}
if(loadAllClass != undefined) {
console.log("loadAllClass: " + loadAllClass.toString());
var args = Java.array('Ljava.lang.Object;',[classloader]);
var classlist = loadAllClass.invoke(null , args);// static method, so call directly
console.log("start dump dex ");
for(var base in dex_maps) {
var size = dex_maps[base];
var magic = ptr(base).readCString();
if(magic.indexOf("dex") == 0) {
var process_name = get_self_process_name();
if (process_name != "-1") {
var dex_path = "/data/data/" + process_name + "/files/" + base.toString(16) + "_" + size.toString(16) + ".dex";
console.log("[find dex]:", dex_path);
var fd = new File(dex_path, "wb");
if (fd && fd != null) {
var dex_buffer = ptr(base).readByteArray(size);
fd.write(dex_buffer);
fd.flush();
fd.close();
}
}
console.log("write file to " + dex_path);
}
}
}
} else {
console.error("unable get classloader");
}
}
});
}
}
function main() {
hook_dex();
}
setImmediate(main);
Call loadClassAndDump() to kick off the active invocation + dump. A batch of already-repaired dex files comes out — no separate repair script needed (a small bonus).
For the “hole-occupying” type of extraction, this solves it cleanly. The “offset” type still needs C++ pointer assistance, which is Java’s weak spot.
All of this runs inside a single Frida environment (basically pure Java + JavaScript) with no cross-platform tooling — the dex-side helper is the only bridge, and that step can equally be folded back into Frida.
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