Android Signature Verification: An Offense-Defense Walkthrough
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.
Android Signature Verification: An Offense-Defense Walkthrough
Where it started
Back when I was a beginner — 2022 — I hit a wall I couldn’t climb: a certain packer’s signature check. That planted a seed of grudging obsession. From then on I read everything I could find, built the fundamentals first, and worked from development into reverse engineering.
Basic signature verification — building the Java-layer theory
private boolean doNormalSignCheck() {
String trueSignMD5 = "7d1e7be834bb349eb0694c524353ba3c";
String nowSignMD5 = "";
try {
PackageInfo packageInfo = getPackageManager().getPackageInfo(
getPackageName(),
PackageManager.GET_SIGNATURES);
Signature[] signs = packageInfo.signatures;
if (signs != null && signs.length > 0) { // check signs is not empty
byte[] signature = signs[0].toByteArray();
String signBase64 = Base64.encodeToString(signature, Base64.DEFAULT).trim();
nowSignMD5 = md5(signBase64);
Log.d(TAG, "doNormalSignCheck: " + nowSignMD5);
}
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
return trueSignMD5.equals(nowSignMD5);
}
At this point, if I just hook getPackageInfo() to return my wrapped packageInfo, wouldn’t that solve it? But I wanted to understand the whole process. In normal reverse engineering you just hook the key function and print the stack — the flow reveals itself.
So let’s do it. Find the PackageInfo constructor in the AOSP source and hook it with Xposed:
XposedHelpers.findAndHookConstructor(classLoader.loadClass("android.content.pm.PackageInfo"),Parcel.class, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
dumpStackTrace();
super.afterHookedMethod(param);
}
});
The printed stack:
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.content.pm.PackageInfo----PackageInfo.java----29----<init>
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.content.pm.PackageInfo$1----PackageInfo.java----519----createFromParcel
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.content.pm.PackageInfo$1----PackageInfo.java----516----createFromParcel
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.content.pm.IPackageManager$Stub$Proxy----IPackageManager.java----4901----getPackageInfo
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.content.pm.PackageManager----PackageManager.java----9207----getPackageInfoAsUserUncached
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.content.pm.PackageManager----PackageManager.java----113----access$100
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.content.pm.PackageManager$2----PackageManager.java----9220----recompute
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.content.pm.PackageManager$2----PackageManager.java----9217----recompute
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.app.PropertyInvalidatedCache----PropertyInvalidatedCache.java----562----query
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.content.pm.PackageManager----PackageManager.java----9235----getPackageInfoAsUserCached
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.app.ApplicationPackageManager----ApplicationPackageManager.java----236----getPackageInfoAsUser
2025-02-19 11:46:25.628 22548-22548 风控 com.calvin.sigcheck I android.app.ApplicationPackageManager----ApplicationPackageManager.java----213----getPackageInfo
2025-02-19 11:46:25.629 22548-22548 风控 com.calvin.sigcheck I com.calvin.sigcheck.MainActivity----MainActivity.java----370----doNormalSignCheck
From the stack, hooking any of these methods lets me swap the signature info and bypass the check.
This is our Java-layer theory foundation.
Learning from the pros
MT’s signature-kill — https://github.com/L-JINBIN/ApkSignatureKillerEx
Key code:
private static void killPM(String packageName, String signatureData) {
Signature fakeSignature = new Signature(Base64.decode(signatureData, Base64.DEFAULT));
Parcelable.Creator<PackageInfo> originalCreator = PackageInfo.CREATOR;
Parcelable.Creator<PackageInfo> creator = new Parcelable.Creator<PackageInfo>() {
@Override
public PackageInfo createFromParcel(Parcel source) {
PackageInfo packageInfo = originalCreator.createFromParcel(source);
if (packageInfo.packageName.equals(packageName)) {
if (packageInfo.signatures != null && packageInfo.signatures.length > 0) {
packageInfo.signatures[0] = fakeSignature;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (packageInfo.signingInfo != null) {
Signature[] signaturesArray = packageInfo.signingInfo.getApkContentsSigners();
if (signaturesArray != null && signaturesArray.length > 0) {
signaturesArray[0] = fakeSignature;
}
}
}
}
return packageInfo;
}
@Override
public PackageInfo[] newArray(int size) {
return originalCreator.newArray(size);
}
};
try {
findField(PackageInfo.class, "CREATOR").set(null, creator);
} catch (Exception e) {
throw new RuntimeException(e);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
HiddenApiBypass.addHiddenApiExemptions("Landroid/os/Parcel;", "Landroid/content/pm", "Landroid/app");
}
try {
Object cache = findField(PackageManager.class, "sPackageInfoCache").get(null);
//noinspection ConstantConditions
cache.getClass().getMethod("clear").invoke(cache);
} catch (Throwable ignored) {
}
try {
Map<?, ?> mCreators = (Map<?, ?>) findField(Parcel.class, "mCreators").get(null);
//noinspection ConstantConditions
mCreators.clear();
} catch (Throwable ignored) {
}
try {
Map<?, ?> sPairedCreators = (Map<?, ?>) findField(Parcel.class, "sPairedCreators").get(null);
//noinspection ConstantConditions
sPairedCreators.clear();
} catch (Throwable ignored) {
}
}
What this does: use reflection to replace the CREATOR variable, which effectively hooks createFromParcel and swaps the signature info. And from the foundation above, createFromParcel really does exist, right next to the constructor — the elegance of that trick is worth admiring.
Then it hooks libc’s open-related functions:
JNIEXPORT void JNICALL
Java_bin_mt_signature_KillerApplication_hookApkPath(JNIEnv *env, __attribute__((unused)) jclass clazz, jstring apkPath, jstring repPath) {
apkPath__ = (*env)->GetStringUTFChars(env, apkPath, 0);
repPath__ = (*env)->GetStringUTFChars(env, repPath, 0);
xhook_register(".*\\.so$", "openat64", openat64Impl, (void **) &old_openat64);
xhook_register(".*\\.so$", "openat", openatImpl, (void **) &old_openat);
xhook_register(".*\\.so$", "open64", open64Impl, (void **) &old_open64);
xhook_register(".*\\.so$", "open", openImpl, (void **) &old_open);
xhook_refresh(0);
}
That neatly implements file IO redirection.
And there’s more. LSPatch is also strong:
Key code:
private static void proxyPackageInfoCreator(Context context) {
Parcelable.Creator<PackageInfo> originalCreator = PackageInfo.CREATOR;
Parcelable.Creator<PackageInfo> proxiedCreator = new Parcelable.Creator<>() {
@Override
public PackageInfo createFromParcel(Parcel source) {
PackageInfo packageInfo = originalCreator.createFromParcel(source);
replaceSignature(context, packageInfo);
return packageInfo;
}
@Override
public PackageInfo[] newArray(int size) {
return originalCreator.newArray(size);
}
};
XposedHelpers.setStaticObjectField(PackageInfo.class, "CREATOR", proxiedCreator);
try {
Map<?, ?> mCreators = (Map<?, ?>) XposedHelpers.getStaticObjectField(Parcel.class, "mCreators");
mCreators.clear();
} catch (NoSuchFieldError ignore) {
} catch (Throwable e) {
Log.w(TAG, "fail to clear Parcel.mCreators", e);
}
try {
Map<?, ?> sPairedCreators = (Map<?, ?>) XposedHelpers.getStaticObjectField(Parcel.class, "sPairedCreators");
sPairedCreators.clear();
} catch (NoSuchFieldError ignore) {
} catch (Throwable e) {
Log.w(TAG, "fail to clear Parcel.sPairedCreators", e);
}
}
Notice the same approach? Both replace the CREATOR variable to hook createFromParcel. Elegant code, reflection-based, and very stable.
Then the SO-layer IO redirection:
LSP_DEF_NATIVE_METHOD(void, SigBypass, enableOpenatHook, jstring origApkPath, jstring cacheApkPath) {
auto sym_openat = SandHook::ElfImg("libc.so").getSymbAddress<void *>("__openat");
auto r = HookSymNoHandle(handler, sym_openat, __openat);
if (!r) {
LOGE("Hook __openat fail");
return;
}
lsplant::JUTFString str1(env, origApkPath);
lsplant::JUTFString str2(env, cacheApkPath);
apkPath = str1.get();
redirectPath = str2.get();
LOGD("apkPath %s", apkPath.c_str());
LOGD("redirectPath %s", redirectPath.c_str());
}
Also hooks libc.so’s open functions to do IO redirection.
So the signature-bypass flow is now fully clear. Break or detect any step in this flow and the bypass fails.
The offense-defense battle begins
Check the signature file inside the local APK:
private byte[] signatureFromAPK() {
try (ZipFile zipFile = new ZipFile(getPackageResourcePath())) {
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (entry.getName().matches("(META-INF/.*)\\.(RSA|DSA|EC)")) {
InputStream is = zipFile.getInputStream(entry);
CertificateFactory certFactory = CertificateFactory.getInstance("X509");
X509Certificate x509Cert = (X509Certificate) certFactory.generateCertificate(is);
return x509Cert.getEncoded();
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
This approach has different effects against MT’s bypass across Android versions, because the IO redirection mysteriously stops working.
Some bypass tools replace the Application. We can grab the Application and run some checks:
public Application getmyApplication() {
Class<?> aaaaaaa = null;
try {
aaaaaaa = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = aaaaaaa.getDeclaredMethod("currentActivityThread");
Object activityThread = currentActivityThreadMethod.invoke(null);
Application application =(Application) getObjectField(activityThread, "mInitialApplication");
Log.d(TAG, "ActivityThread get Application: "+application.getClass().getName());
return application;
} catch (Exception e) {
Log.d(TAG, "error: "+e.getMessage());
return null;
}
}
This grabs the Application in a deeper way. There are even deeper approaches — reflecting LoadedApk for the unique singleton Application — but for cross-version compatibility I’ll settle for this.
Run a simple check on the Application (and you can go deeper):
private boolean checkApplication(){
Application nowApplication = getmyApplication();
String trueApplicationName = "com.calvin.sigcheck.MYapp";
String nowApplicationName = nowApplication.getClass().getName();
Log.d(TAG, "checkApplication: "+trueApplicationName+" "+nowApplicationName);
return trueApplicationName.equals(nowApplicationName);
}
That completes a simple check of the APK entry point.
In MT’s early bypass versions, it replaced mPM. Let me write a check for that too:
private boolean checkPMProxy(){
String truePMName = "android.content.pm.IPackageManager$Stub$Proxy";
String nowPMName = "";
try {
// the proxied object is PackageManager.mPM
PackageManager packageManager = getPackageManager();
Field mPMField = packageManager.getClass().getDeclaredField("mPM");
mPMField.setAccessible(true);
Object mPM = mPMField.get(packageManager);
// get the class name
nowPMName = mPM.getClass().getName();
} catch (Exception e) {
e.printStackTrace();
}
// a changed class name means it's been proxied
return truePMName.equals(nowPMName);
}
To some degree this can also detect a virtual environment — early VirtualApp versions also used reflection hooks for app cloning.
Everything above targets old versions. Now let’s analyze the bypass we just saw and work out targeted defenses. Our goal: detect whether Creator has been replaced.
Another researcher shared a clever approach — comparing the classloader’s attributes:
private boolean checkCreator3(){
try {
Field creatorField = PackageInfo.class.getField("CREATOR");
creatorField.setAccessible(true);
Object creator = creatorField.get(null);
int i = creator.hashCode();
Log.d(TAG, "checkCreator3: hashcode :"+i);
if (creator != null) {
ClassLoader creatorClassloader = creator.getClass().getClassLoader();
ClassLoader sysClassloader = ClassLoader.getSystemClassLoader();
if (creatorClassloader == null || sysClassloader == null) {
return false;
}
// the system one is bootclassloader
// user-created ones are pathclassloader
// if equal, the system one has been replaced
if (sysClassloader.getClass().getName().
equals(creatorClassloader.getClass().getName())) {
return false;
}
return true;
}
} catch (Throwable e) {
Log.d(TAG, "checkCreator3: "+e);
}
return false;
}
But in my testing, LSP’s bypass doesn’t get caught by this.
No problem — let’s keep building. Following the same idea — check whether CREATOR was changed — any subtle difference works. I’ll demo one; the rest I leave to whoever comes next:
private boolean checkCreator(){
String trueName = "android.content.pm.PackageInfo$1";
String nowName = "";
try {
Object mPM = PackageInfo.CREATOR;
// get the class name
nowName = mPM.getClass().getName();
} catch (Exception e) {
e.printStackTrace();
}
return trueName.equals(nowName);
}
Here we grab the CREATOR class name — if it differs, it’s been replaced.
But what if this check itself gets hooked? Most bypass tools don’t hook the app heavily. If it is hooked, we reflect other attributes:
private boolean checkCreator2(){
Field[] declaredFields = null;
try {
Object mPM = PackageInfo.CREATOR;
// get the class name
declaredFields = mPM.getClass().getDeclaredFields();
} catch (Exception e) {
e.printStackTrace();
}
return declaredFields.length == 0;
}
That anti-detects LSP-packed APKs.
Let’s go further. Can we verify our own code in memory? Yes — I wrote a memory-verification post long ago, but the approach got criticized at the time, and it really only worked in debug builds.
Now I keep exploring that approach. From the AOSP classloader’s loading path, we get the app’s own loaded DEX and check it.
I hit a lot of problems along the way — all solved. Environment here is Android 12; I don’t guarantee compatibility on other versions.
The first problem is Android’s reflection restrictions. This code I adapted from another researcher’s post:
bool setApiBlacklistExemptions(JNIEnv* env) {
/*=================use ZygoteInit instead of VMRuntime here, same effect==================*/
jclass zygoteInitClass = env->FindClass("com/android/internal/os/ZygoteInit");
if (zygoteInitClass == nullptr) {
ALOGE("not found class");
env->ExceptionClear();
return false;
}
jmethodID setApiBlackListApiMethod =
env->GetStaticMethodID(zygoteInitClass,
"setApiBlacklistExemptions",
"([Ljava/lang/String;)V");
if (setApiBlackListApiMethod == nullptr) {
env->ExceptionClear();
setApiBlackListApiMethod =
env->GetStaticMethodID(zygoteInitClass,
"setApiDenylistExemptions",
"([Ljava/lang/String;)V");
}
if (setApiBlackListApiMethod == nullptr) {
ALOGE("not found method");
return false;
}
jclass stringCLass = env->FindClass("java/lang/String");
jstring fakeStr = env->NewStringUTF("L");
jobjectArray fakeArray = env->NewObjectArray(
1, stringCLass, NULL);
env->SetObjectArrayElement(fakeArray, 0, fakeStr);
env->CallStaticVoidMethod(zygoteInitClass,
setApiBlackListApiMethod, fakeArray);
env->DeleteLocalRef(fakeStr);
env->DeleteLocalRef(fakeArray);
ALOGD("fakeapi success!");
return true;
}
jint JNI_OnLoad(JavaVM* vm, void* reserved) {
UnionJNIEnvToVoid uenv;
uenv.venv = NULL;
jint result = -1;
JNIEnv* env = NULL;
ALOGD("JNI_OnLoad");
if (vm->GetEnv(&uenv.venv, JNI_VERSION_1_6) != JNI_OK) {
ALOGE("ERROR: GetEnv failed");
goto bail;
}
env = uenv.env;
if (!setApiBlacklistExemptions(env)) {
ALOGE("failed");
goto bail;
}
result = JNI_VERSION_1_6;
bail:
return result;
}
After lifting the reflection restriction via SO load, reflect to get the DEX code’s location:
public static long[] getLoadedDexPaths() {
long[] dexPaths;
try {
// get the current app's PathClassLoader
ClassLoader classLoader = MainActivity.class.getClassLoader();
if (classLoader == null) return null;
// reflect BaseDexClassLoader's pathList field
Field pathListField = ReflectHelper.getField(classLoader, "pathList");
Object pathList = pathListField.get(classLoader);
// reflect DexPathList's dexElements array
Field dexElementsField = ReflectHelper.getField(pathList, "dexElements");
Object[] dexElements = (Object[]) dexElementsField.get(pathList);
// iterate each Element to get the dex file path
for (Object element : dexElements) {
Field dexFileField = ReflectHelper.getField(element, "dexFile");
Object dexFile = dexFileField.get(element);
Log.d(TAG, "getLoadedDexPaths: "+dexFile.toString());
if (dexFile != null) {
Field fileField = dexFile.getClass().getDeclaredField("mInternalCookie");
Field[] declaredFields = dexFile.getClass().getDeclaredFields();
for (Field declaredField : declaredFields) {
// Log.d("TAG", "dexFile: "+declaredField.getName());
}
fileField.setAccessible(true);
long[] file = (long[]) fileField.get(dexFile);
return file;
}
}
} catch (Exception e) {
Log.e("DexUtils", "Error getting dex paths", e);
}
return null;
}
Then get the address in JNI. The address I get here is the DexFile address; its member variable points to the DEX. So pointer + 1 is the DEX’s address in memory. (That one confused me for a whole night!)
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_calvin_sigcheck_MainActivity_checkMemDex(JNIEnv *env, jobject thiz) {
// TODO: implement checkMemDex()
jclass jclass1= env->FindClass("com/calvin/sigcheck/MainActivity");
if (jclass1 == nullptr) {
__android_log_print(ANDROID_LOG_INFO, "checkMemDex", "jclass1 is null");
return false;
}
jmethodID jmethodID1 =env->GetStaticMethodID(jclass1, "getLoadedDexPaths", "()[J");
// 3. call the static method
jlongArray result = (jlongArray)env->CallStaticObjectMethod(jclass1, jmethodID1);
if (result == NULL) {
__android_log_print(ANDROID_LOG_INFO,"checkMemDexcheckMemDex","Failed to call getLoadedDexPaths\n");
return false;
}
int len = env->GetArrayLength(result);
// 2. create a local long array
long *nativeArray = new long[len];
// 3. copy elements from jlongArray to the local array
env->GetLongArrayRegion(result, 0, len, nativeArray);
long crc =0;
for (int i = 0; i < len; i++) {
if(nativeArray[i] != 0){
DexHeader * dexHeader = (DexHeader *) (*((long *)nativeArray[i] +1));
__android_log_print(ANDROID_LOG_INFO, "checkMemDex", "%x",dexHeader->checksum);
crc += dexHeader->checksum;
}
}
__android_log_print(ANDROID_LOG_INFO, "checkMemDex", "%ld",crc);
if(!(crc - 6376092432)){ // change the crc to your own here
__android_log_print(ANDROID_LOG_INFO, "checkMemDex", "crc is ok");
return true;
}
return false;
}
Honestly this isn’t complete — you could CRC the memory dynamically, but I only read the DEX header. If you have other ideas, I’m all ears.
Testing against NP’s strongest bypass (FancyBpysss): it does detect it! (Otherwise all this work failing against a one-click bypass would be embarrassing.)
Some bypass tools modify AppComponentFactory to initialize hooks. We can detect that too:
private boolean checkAppComponentFactory() {
String appComponentFactory = getmyAppComponentFactory();
if(appComponentFactory!=null){
Log.d(TAG, "detected AppComponentFactory: "+appComponentFactory);
return appComponentFactory.equals("androidx.core.app.CoreComponentFactory");
}
return false;
}
private String getmyAppComponentFactory(){
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return getmyApplication().getApplicationInfo().appComponentFactory;
}else {
return null;
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
The SVC part has already been covered thoroughly by another researcher, so here’s the code:
#define STR_HELPER(x) #x
#define STR(x) STR_HELPER(x)
intptr_t openAt(intptr_t fd, const char *path, intptr_t flag) {
#if defined(__arm__)
intptr_t r;
asm volatile(
#ifndef OPTIMIZE_ASM
"mov r0, %1\n\t"
"mov r1, %2\n\t"
"mov r2, %3\n\t"
#endif
"mov ip, r7\n\t"
".cfi_register r7, ip\n\t"
"mov r7, #" STR(__NR_openat) "\n\t"
"svc #0\n\t"
"mov r7, ip\n\t"
".cfi_restore r7\n\t"
#ifndef OPTIMIZE_ASM
"mov %0, r0\n\t"
#endif
: "=r" (r)
: "r" (fd), "r" (path), "r" (flag));
return r;
#elif defined(__aarch64__)
intptr_t r;
long syscall_number = 56;
asm volatile(
#ifndef OPTIMIZE_ASM
"mov x0, %1\n\t"
"mov x1, %2\n\t"
"mov x2, %3\n\t"
#endif
"mov x8, %4\n\t"
"svc #0\n\t"
#ifndef OPTIMIZE_ASM
"mov %0, x0\n\t"
#endif
: "=r" (r)
: "r" (fd), "r" (path), "r" (flag), "r" (syscall_number));
return r;
#else
return (intptr_t) syscall(__NR_openat, fd, path, flag);
#endif
}
extern "C"
JNIEXPORT jint JNICALL
Java_com_calvin_sigcheck_MainActivity_openAt(JNIEnv *env, jobject thiz,
jstring package_resource_path) {
const char* p = env->GetStringUTFChars(package_resource_path, 0);
__android_log_print(ANDROID_LOG_INFO, "openAt", "path=%s", p);
intptr_t fd = openAt(AT_FDCWD, p, O_RDONLY);
env->ReleaseStringUTFChars(package_resource_path, p);
return fd;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_calvin_sigcheck_MainActivity_checkFD(JNIEnv *env, jobject thiz, jint fd) {
// TODO: implement checkFD()
char fdPath[1024] = {0};
char buffer[1024] = {0};
sprintf(fdPath, "/proc/self/fd/%d", (int )fd);
int len = syscall(78, AT_FDCWD, fdPath, buffer, PATH_MAX);
if (len > 0) {
buffer[len] = '\0';
__android_log_print(ANDROID_LOG_INFO, "checkFD", "fd=%d, path=%s", (int )fd, buffer);
return env->NewStringUTF(buffer);
}
return env->NewStringUTF("failed");
}
private byte[] signatureFromSVC() {
int aa = openAt(getPackageResourcePath());
fd=aa;
try (ParcelFileDescriptor fd = ParcelFileDescriptor.adoptFd(aa);
ZipInputStream zis = new ZipInputStream(new FileInputStream(fd.getFileDescriptor()))) {
path = checkFD(aa);
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.getName().matches("(META-INF/.*)\\.(RSA|DSA|EC)")) {
CertificateFactory certFactory = CertificateFactory.getInstance("X509");
X509Certificate x509Cert = (X509Certificate) certFactory.generateCertificate(zis);
return x509Cert.getEncoded();
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
That basically completes the app’s signature verification. New angles and ideas are welcome.
Here I was too lazy to use my own SVC implementation.
Attack methods
First, IO redirection: the theory is solid — study it and you can hook open-related SVCs.
Second, Creator detection: since the hooks are all done via proxy replacement, they leave lots of traces. Intrusive hooking (Xposed-style) is best to avoid detection.
Do these well and you have a near-perfect signature bypass.
Using these theories I modified MT’s open-source bypass to implement SVC hooking, and tested it against the 2022 360 packer. Bypass complete — a satisfying answer to my 2022 self.
The tool implements SVC hooking but doesn’t hide Creator. The bypass stalls briefly, then completes; output goes to the download directory. The tool can’t be uploaded here, so it’s on GitHub:
I’ve also attached the signature-check app I wrote, unobfuscated, so you can decompile it. I hope you’ll use your own bypass tool to defeat it rather than just patching the UI.
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