Hooking a Plugin-Based Android App with Frida: ClassLoaders, Enums, and a VIP Paywall
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.
Hooking a Plugin-Based Android App with Frida: ClassLoaders, Enums, and a VIP Paywall
I recently got my hands on a video-streaming sample — video, live streams, and novels — where non-VIP users only get a 30-second preview. I’d been learning frida and wanted something real to practice on. Midway through I discovered the app is plugin-based, which is exactly where the interesting frida problems live. This post walks through the whole thing: three frida techniques that trip people up on plugin-based apps, with the VIP paywall as the running example.
Initial analysis
Starting from AndroidManifest.xml, I pulled the package name and looked at the activities:

Only four activities — a real app of this kind has far more. That was the first tell that this is just a shell and the actual logic is loaded dynamically elsewhere.
Opening the app showed two processes, and the activities at 2, 3, and 4 run in a plugin process. To confirm which process hosts video playback, I used dumpsys meminfo and opened a playback screen — video lives in the plugin process. At that point the real logic is already loaded, so I dumped the plugin process’s maps:
# cat /proc/7906/maps

The real logic is in plugin-shadow-apk-debug.apk, sitting in the app’s private files directory. The code confirms it: this is a plugin-based app built on Tencent’s open-source plugin framework, Shadow.
Locating the key code
With the real APK found, I needed to zero in on the code. The string search pointed at several candidate classes:


Analyzing each candidate one by one would be slow, so I let frida enumerate all loaded classes and match against them. Java.enumerateLoadedClasses(callbacks) lists every currently loaded class; callbacks needs onMatch and onComplete:
Java.perform(function () {
// the several classes found above
var key_class = ["com.facebook.plugin.widget.dkplayer.controller.PlayerVideoController",
"com.iqiyi.plugin.widget.dkplayer.controller.PlayerVideoController",
"com.facebook.plugin.widget.dkplayer.controller.VideoController",
"com.iqiyi.plugin.widget.dkplayer.controller.VideoController"]
Java.enumerateLoadedClasses({
"onMatch": function(name, handle) {
for (var i = 0; i < key_class.length; i++) {
if (key_class[i] == name) {
console.log(name);
}
}
},
"onComplete": function() {
console.log("success");
}
});
});
Result:
com.iqiyi.plugin.widget.dkplayer.controller.VideoController
success
The first line is the matched class actually in use; the second is the completion log.
VideoController analysis
Locating the string in the class:
public int setProgress() {
... ...
if (this.tryWatchTv != null && position > 0) { // if it's a trial watch
pos = (int) (((long) this.stopPlayTime) - position);
TextView textView = this.tryWatchTv;
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("剩余试看时间: "); // the string we saw
if (pos > 0) {
j = (long) pos;
}
stringBuilder.append(stringForTime(j));
textView.setText(stringBuilder.toString());
}
if (!this.isVip) { // different logic branches on the isVip flag
StringBuilder stringBuilder2 = new StringBuilder();
stringBuilder2.append("position = ");
stringBuilder2.append(position);
stringBuilder2.append(" showVipHintTime = ");
stringBuilder2.append(this.showVipHintTime);
LogHelper.i(stringBuilder2.toString());
if (position < ((long) this.showVipHintTime) || this.showVipHintTime <= 0) {
this.vipHintView.setVisibility(8);
} else {
this.vipHintView.setVisibility(0);
}
if (position >= ((long) this.stopPlayTime)) {
this.mMediaPlayer.pause();
}
}
... ...
}
The class branches on the isVip flag. Now see how isVip gets set:
public void setVip(boolean isVip) {
this.isVip = isVip;
this.tryWatchTv.setVisibility(this.isVip ? 8 : 0);
if (this.isVip) {
this.vipHintView.setVisibility(8);
}
}
There’s a setVip method that sets the flag — so I don’t need to trace every caller; they all funnel through here. Hook it.
Hooking setVip
var videoController = Java.use("com.iqiyi.plugin.widget.dkplayer.controller.VideoController");
videoController.setVip.implementation = function() {
console.log("hook setVip");
this.setVip(true);
};
Result:
{'type': 'error', 'description': 'Error: java.lang.ClassNotFoundException: Didn\'t find class "com.iqiyi.plugin.widget.dkplayer.controller.VideoController" on path: DexPathList[[dex file "InMemoryDexFile[cookie=[0, 3983850208]]", zip file "/system/framework/org.apache.http.legacy.boot.jar", zip file "/data/app/com.iqiyi.vider-U7T4aZIQOQyXS5iLBgsNGw==/base.apk"],nativeLibraryDirectories=[/data/app/com.iqiyi.vider-U7T4aZIQOQyXS5iLBgsNGw==/lib/arm, /data/app/com.iqiyi.vider-U7T4aZIQOQyXS5iLBgsNGw==/base.apk!/lib/armeabi-v7a, /system/lib]]', 'stack': 'Error: java.lang.ClassNotFoundException: Didn\'t find class "com.iqiyi.plugin.widget.dkplayer.controller.VideoController" on path: DexPathList[[dex file "InMemoryDexFile[cookie=[0, 3983850208]]", zip file "/system/framework/org.apache.http.legacy.boot.jar", zip file "/data/app/com.iqiyi.vider-U7T4aZIQOQyXS5iLBgsNGw==/base.apk"],nativeLibraryDirectories=[/data/app/com.iqiyi.vider-U7T4aZIQOQyXS5iLBgsNGw==/lib/arm, /data/app/com.iqiyi.vider-U7T4aZIQOQyXS5iLBgsNGw==/base.apk!/lib/armeabi-v7a, /system/lib]]\n at frida/node_modules/frida-java-bridge/lib/env.js:124\n at frida/node_modules/frida-java-bridge/lib/class-factory.js:400\n at frida/node_modules/frida-java-bridge/lib/class-factory.js:781\n at frida/node_modules/frida-java-bridge/lib/class-factory.js:90\n at frida/node_modules/frida-java-bridge/lib/class-factory.js:44\n at /script1.js:23\n at frida/node_modules/frida-java-bridge/lib/vm.js:11\n at frida/node_modules/frida-java-bridge/index.js:368\n at frida/node_modules/frida-java-bridge/index.js:318', 'fileName': 'frida/node_modules/frida-java-bridge/lib/env.js', 'lineNumber': 124, 'columnNumber': 1}
ClassNotFoundException — the class isn’t on the default classloader’s path. Of course not: in a plugin app, loading happens through the plugin framework’s own classloader.
Enumerating classloaders
Java.enumerateClassLoaders(callbacks) prints every loader:
Java.perform(function () {
Java.enumerateClassLoaders({
"onMatch": function(loader) {
console.log(loader);
},
"onComplete": function() {
console.log("success");
}
});
});
Result:

The real logic lives in plugin-shadow-apk-debug.apk, and the classloader backing it is com.tencent.shadow.core.loader.classloaders.PluginClassLoader.
Pointing the class factory at the right loader
Java.ClassFactory has a loader property — “read-only property providing a wrapper for the class loader currently being used.” It’s the current classloader’s wrapper, and swapping the loader means reassigning this field. Java.classFactory is the default factory, so Java.classFactory.loader is the one to change:
Java.perform(function () {
Java.enumerateClassLoaders({
"onMatch": function(loader) {
if (loader.toString().startsWith("com.tencent.shadow.core.loader.classloaders.PluginClassLoader")) {
Java.classFactory.loader = loader; // point the current class factory's loader at the one we need
}
},
"onComplete": function() {
console.log("success");
}
});
});
The final script
Java.perform(function () {
Java.enumerateClassLoaders({
"onMatch": function(loader) {
if (loader.toString().startsWith("com.tencent.shadow.core.loader.classloaders.PluginClassLoader")) {
Java.classFactory.loader = loader; // point the current class factory's loader at the one we need
}
},
"onComplete": function() {
console.log("success");
}
});
// note: we need Java.classFactory.use here
var videoController = Java.classFactory.use("com.iqiyi.plugin.widget.dkplayer.controller.VideoController");
videoController.setVip.implementation = function() {
console.log("hook setVip");
this.setVip(true);
};
});
The hook lands, and the “remaining time” counter is gone from the video.
Hooking an enum
Live streams and novels gate VIP differently — they compare an enum’s VIP field against "1":

The comparison:
if (TextUtils.equals("1", PluginEnum.VIP.getValue())) {...}
Testing enum behavior first
I wanted to hook the enum’s getValue, but I wasn’t fully sure how enums behave under frida, so I wrote a tiny test:
public enum TestEnum {
A("a"),
B("b"),
C("c");
private String value;
private TestEnum(String value) {
this.value = value;
}
public String getValue() {
return this.value;
}
}
javap on the class file:
Compiled from "TestEnum.java"
public final class TestEnum extends java.lang.Enum<TestEnum> {
public static final TestEnum A;
public static final TestEnum B;
public static final TestEnum C;
public static TestEnum[] values();
public static TestEnum valueOf(java.lang.String);
public java.lang.String getValue();
static {};
}
A, B, and C are static member variables of TestEnum. The smali confirms the same shape:
sget-object v3, Lcom/iqiyi/plugin/base/PluginEnum;->VIP:Lcom/iqiyi/plugin/base/PluginEnum;
invoke-virtual {v3}, Lcom/iqiyi/plugin/base/PluginEnum;->getValue()Ljava/lang/String;
VIP is a static member of com/iqiyi/plugin/base/PluginEnum, then getValue() is called on it. So the move is to hook PluginEnum.getValue() and check whether the receiver is VIP.
The enum hook
Java.perform(function () {
var pluginEnum = Java.classFactory.use("com.iqiyi.plugin.base.PluginEnum");
var String = Java.use("java.lang.String");
pluginEnum.getValue.implementation = function() {
var value = this.getValue();
if (this == "VIP") { // here `this` (or this.getString()) returns the static member name
var vip = String.$new("1");
this.setValue(vip); // call setValue to change the VIP value
return vip;
} else {
return value;
}
}
});
The complete script
Java.perform(function () {
Java.enumerateClassLoaders({
"onMatch": function(loader) {
if (loader.toString().startsWith("com.tencent.shadow.core.loader.classloaders.PluginClassLoader")) {
Java.classFactory.loader = loader;
}
},
"onComplete": function() {
console.log("success");
}
});
var videoController = Java.classFactory.use("com.iqiyi.plugin.widget.dkplayer.controller.VideoController");
videoController.setVip.implementation = function() {
console.log("hook setVip");
this.setVip(true);
};
var pluginEnum = Java.classFactory.use("com.iqiyi.plugin.base.PluginEnum");
var String = Java.use("java.lang.String");
pluginEnum.getValue.implementation = function() {
var value = this.getValue();
if (this == "VIP") {
var vip = String.$new("1");
this.setValue(vip);
return vip;
} else {
return value;
}
}
});
Summary
Finding the key code was the easy part. The frida side was where it got fiddly, especially if you’re new to plugin apps. The three techniques that matter here:
- Enumerating all loaded classes with
Java.enumerateLoadedClasses. - Enumerating classloaders and redirecting
Java.classFactory.loaderfor a plugin framework. - Hooking an enum type through its static members.
The classloader one is the real gotcha: on a plugin-based app, your hook fails with ClassNotFoundException not because the class is gone, but because you’re looking at it through the wrong loader.
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