最近在阅读《Android设备指纹攻防与风险环境检测》时发现,光靠书中思路和代码片段很难真正理解其中的细节,所以我打算自己动手写一个 Demo App,把签名校验这块完整实践一遍。
Java 层签名检测
Java 层通常使用 PackageManager 进行签名校验。
在 Android 9 及以上版本,签名可通过 PackageInfo -> signingInfo -> signatures 获取;以下版本则通过 PackageInfo -> signatures 获取。但无论是哪种方式,获取 PackageInfo 都会调用 getPackageInfo 这个 API:
package com.example.mysecurityapp;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.os.Build;
import android.os.Message;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class SignatureVerificationUtils {
public static final String expected_signature_hash = "74:0F:46:6F:D4:7E:F5:2A:38:10:CE:AB:92:F0:46:45:B9:4D:94:65:DF:F2:DC:D6:99:51:B5:86:7A:F6:10:E1";
public static String getAppSignatureHash(Context context, String packageName) {
try {
PackageManager pm = context.getPackageManager();
Signature[] signatures;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// PackageInfo用来储存package中的一些信息
PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES);
if (packageInfo == null || packageInfo.signingInfo == null) {
return null;
}
// 是否有多个签名者
if (packageInfo.signingInfo.hasMultipleSigners()) {
// 当前签名
signatures = packageInfo.signingInfo.getApkContentsSigners();
} else {
// 历史签名
signatures = packageInfo.signingInfo.getSigningCertificateHistory();
}
} else {
PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
if (packageInfo == null || packageInfo.signatures == null) {
return null;
}
signatures = packageInfo.signatures;
}
if (signatures != null && signatures.length > 0) {
return getSHA256(signatures[0].toByteArray());
}
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
return null;
}
private static String getSHA256(byte[] signatureBytes) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(signatureBytes);
byte[] digest = md.digest();
return bytesToHex(digest);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return null;
}
private static String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (int i = 0; i < bytes.length; i++) {
String hex = Integer.toHexString(0xFF & bytes[i]);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex.toUpperCase());
if (i < bytes.length - 1) {
hexString.append(":");
}
}
return hexString.toString();
}
public static boolean verifySignature(Context context) {
String signature = getAppSignatureHash(context, context.getPackageName());
return expected_signature_hash.equals(signature);
}
}
Android 9 引入了密钥轮换机制,因此废弃了 GET_SIGNATURES,转而推荐使用 GET_SIGNING_CERTIFICATES 获取证书。
但单纯依靠 Java 层检测并不安全:PackageManager 的 getPackageInfo 可能被 hook,用于比对的签名值也可能被篡改。
伪造 PackageInfo
在 MainActivity 中调用刚才写好的方法:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button button = findViewById(R.id.signature_java);
button.setOnClickListener(this);
textView = findViewById(R.id.signature_text);
}
@Override
public void onClick(View v) {
boolean verify_res = SignatureVerificationUtils.verifySignature(this);
if(verify_res == false)
{
textView.setText("签名校验失败");
}
else
{
textView.setText("签名校验成功");
}
}
对应用重新签名后,签名校验果然失败了。

接下来,尝试通过 hook PackageManager 返回正确的校验值:
function fakeSignatures()
{
var signature = Java.use("android.content.pm.Signature");
var mySignative = "308202e4308201cc020101300d06092a864886f70d01010b050030373116301406035504030c0d416e64726f69642044656275673110300e060355040a0c07416e64726f6964310b30090603550406130255533020170d3234313131363134343832385a180f32303534313130393134343832385a30373116301406035504030c0d416e64726f69642044656275673110300e060355040a0c07416e64726f6964310b300906035504061302555330820122300d06092a864886f70d01010105000382010f003082010a02820101009042f3b7fbe3164cac331f89ca5eefa713b17a3bc7385f7c740646feb823099717b5c83f4d466415e09cdc0aa1f195419b8b485c69c5da7c2a77b4252527188df70e99f842729e7e6aa6ed3b93e021035c740d53b2f8e65157b6994e4d054091b984c48a0b5161e63e7104e858e995117c884dfb8337e129b8b65af7ddbbca501261cd043c9b6105956b569f50d58c2b2fba2c81c30fbb022c30043f9ca13230d437b5d304cc6e4be6fa9e7ca85b8b4945093c5bdb13df5f1936ac25ad3c3a102b18aaaf118d8dc9e93fe8637ba37b09b232d3923eb257d451c7bb9da68a8ae189766a917f76ae0a864669656ee6f6c869553555d96e05b519e899d04a8b121d0203010001300d06092a864886f70d01010b050003820101002042005e9bdb007e94c01a6dc74bf56f16ddcd7a85b5407bdd8a20494c8949cbecff0ebf213e415fff05a8141c84273a79ce14387487ca1e449f1a18a50c38dd254dc3e8f29f987a0ba550e78572afa9bad0eabeac03609f74d6575456444fd3f5d35a94abf167b96ed3a774a12d0cebbfb7c9b6a7821e3c11ad940eed2c63647e9b7570e5aa609dbec0678d7ebba8acd4213c28dc5a01dfe6a1f438c5ee8c94ef573acd565334fe9287d74c94a934bc3959a9e7d69e7ac1074f8667a58b8f3e51e29564b164854610a983a4fdcc516b9b48726e6feee74247deb407e335ba411af80c5216d59815b689f398e13d4aa900f589960df72dd73e565d0b592480a0";
var fakeSignature = signature.$new(mySignative);
return Java.array('android.content.pm.Signature',[fakeSignature]);
}
function fakeSigningInfo()
{
var SigningDetails = Java.use("android.content.pm.SigningDetails");
var SigningInfo = Java.use("android.content.pm.SigningInfo");
var ArraySet = Java.use("android.util.ArraySet");
var fakeDetails = SigningDetails.$new(fakeSignatures(), 3, ArraySet.$new(), fakeSignatures());
return SigningInfo.$new(fakeDetails);
}
function patchPackageInfo(packageInfo)
{
if (packageInfo.signatures.value != null) {
packageInfo.signatures.value = fakeSignatures();
}
if (packageInfo.signingInfo.value != null) {
packageInfo.signingInfo.value = fakeSigningInfo();
}
return packageInfo;
}
function hookSignative()
{
var PackageManager = Java.use("android.app.ApplicationPackageManager");
var MessageDigest = Java.use("java.security.MessageDigest");
var getPackageInfoInt = PackageManager.getPackageInfo.overload('java.lang.String', 'int');
getPackageInfoInt.implementation = function(packageName, flags)
{
var packageInfo = getPackageInfoInt.call(this, packageName, flags);
patchPackageInfo(packageInfo);
console.log("getPackageInfo hooked: " + packageName + ", flags=" + flags + ", signingInfo=" + (packageInfo.signingInfo.value != null));
return packageInfo;
}
try {
var getPackageInfoFlags = PackageManager.getPackageInfo.overload('java.lang.String', 'android.content.pm.PackageManager$PackageInfoFlags');
getPackageInfoFlags.implementation = function(packageName, flags)
{
var packageInfo = getPackageInfoFlags.call(this, packageName, flags);
patchPackageInfo(packageInfo);
console.log("getPackageInfo hooked: " + packageName + ", flags=" + flags + ", signingInfo=" + (packageInfo.signingInfo.value != null));
return packageInfo;
}
} catch (e) {
}
}
function main()
{
Java.perform(function(){
console.log("hook start");
hookSignative();
});
}
setImmediate(main);
这段脚本 hook 了 getPackageInfo 方法,返回了伪造的 PackageInfo -> SigningInfo -> SigningDetails 值。

此时签名校验被成功绕过。那么,如何才能防止这种对 getPackageInfo 的 hook 呢?
构造 IPC 请求向系统服务获取应用签名
最直接的思路就是抛弃 getPackageInfo。我们可以通过反射获取 Binder 对象,直接与系统服务进行通信。
public static String IPCGetSignatureHash(Context context)
{
// 从对象池中获取两个 Parcel 对象
// Parcel 是 Android 中用于在不同组件之间传递数据的容器,它可以包含基本数据类型、对象引用以及实现了 Parcelable 接口的对象
// Parcel 的主要用途是进行进程间通信(IPC),特别是在使用 Binder 机制时
// _data 用于存放发送给系统服务的数据
// _reply 用于接收系统服务返回的数据结果
Parcel _data = Parcel.obtain();
Parcel _reply = Parcel.obtain();
try{
// 通过反射获取底层 IBinder 对象
// 获取 PackageManager 对象(ApplicationPackageManager 实例)
PackageManager packageManager = context.getPackageManager();
// 反射获取 ApplicationPackageManager 内部隐藏的 mPM 变量
// 获取 mPM 字段对象
Field mPMFile = packageManager.getClass().getDeclaredField("mPM");
mPMFile.setAccessible(true);
// get packageManager 中的 mPM
Object mPM = mPMFile.get(packageManager);
// mPM 是 IPackageManager 的 Proxy,直接通过 asBinder 获取 IBinder
IBinder mRemote = ((IInterface) mPM).asBinder();
if(mRemote == null)
{
Log.e("YvY_security","获取IBinder失败");
return null;
}
// 写入接口 Token,告诉系统调用的接口
_data.writeInterfaceToken("android.content.pm.IPackageManager");
// a1 : 包名
_data.writeString(context.getPackageName());
// Android 13 之前使用 writeInt,Android 13 及以上 flags 参数类型为 long
// a2 : 获取信息标志位
long flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)?134217728L:64L;
if(Build.VERSION.SDK_INT >= 33)
{
_data.writeLong(flags);
}
else
{
_data.writeInt((int)flags);
}
// a3 : userId
int userId = 0;
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1)
{
userId = android.os.Process.myUid() / 100000;
}
_data.writeInt(userId);
int transactCode = getTransactionCode();
// 调用 transact 触发底层 Binder 驱动通信
boolean _status = mRemote.transact(transactCode,_data,_reply,0);
if(!_status)
{
return null;
}
_reply.readException();
PackageInfo packageInfo = null;
if(_reply.readInt() != 0)
{
// 从数据包里还原出 PackageInfo 实例
packageInfo = PackageInfo.CREATOR.createFromParcel(_reply);
}
if(packageInfo != null)
{
byte[] signatureBytes = null;
// Android 9 以上
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && packageInfo.signingInfo != null)
{
// 如果是多重签名
if(packageInfo.signingInfo.hasMultipleSigners())
{
// 当前签名者的首个签名
signatureBytes = packageInfo.signingInfo.getApkContentsSigners()[0].toByteArray();
}
// 单一签名
else
{
// 历史证书
signatureBytes = packageInfo.signingInfo.getSigningCertificateHistory()[0].toByteArray();
}
}
else
{
// Android 9 以下使用 signatures 数组获取签名
signatureBytes = packageInfo.signatures[0].toByteArray();
}
if(signatureBytes != null)
{
return getSHA256(signatureBytes);
}
}
}catch (Throwable e)
{
Log.e("YvY_security","IPC signature check error", e);
}
finally {
_data.recycle();
_reply.recycle();
}
return null;
}
// Android 13 及以上无法通过反射获取隐藏 API,只能硬编码或使用第三方库解除限制
private static int getTransactionCode() {
int TRANSACTION_getPackageInfo = 0;
if(Build.VERSION.SDK_INT >= 33)
{
switch (Build.VERSION.SDK_INT)
{
case 33:
TRANSACTION_getPackageInfo = 3;
break;
// 添加自己的安卓版本
}
}
else
{
try {
Class<?> pkmIPCClazz = Class.forName("android.content.pm.IPackageManager$Stub");
Field field = pkmIPCClazz.getDeclaredField("TRANSACTION_getPackageInfo");
field.setAccessible(true);
TRANSACTION_getPackageInfo = field.getInt(null);
} catch (Throwable e) {
e.printStackTrace();
}
}
return TRANSACTION_getPackageInfo;
}
public static boolean verifySignature_IPC(Context context) {
String signature = IPCGetSignatureHash(context);
return expected_signature_hash.equals(signature);
}
放弃 getPackageInfo 意味着减少了 hook 点。实际运行这个只靠 IPC 的校验方法,你会发现它确实让普通 Java 层 hook 失效了。

但这种方法也并非无懈可击。
替换 PackageInfo.CREATOR
请看上面代码中的这一段:
if(_reply.readInt() != 0)
{
// 从数据包里还原出 PackageInfo 实例
packageInfo = PackageInfo.CREATOR.createFromParcel(_reply);
}
因为最终仍是靠 PackageInfo.CREATOR 将数据包解析为 PackageInfo 对象,所以攻击者完全可以利用反射替换掉这个静态变量,继续伪造签名信息。
hook 方案如下:
var TARGET_PACKAGE = "com.example.mysecurityapp";
function fakeSignatures() {
var mySignative = "...";
var Signature = Java.use("android.content.pm.Signature");
return Java.array("android.content.pm.Signature", [
Signature.$new(mySignative)
]);
}
function isFromIPCGetSignatureHash() {
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
var stack = Log.getStackTraceString(Exception.$new());
return stack.indexOf("com.example.mysecurityapp.SignatureVerificationUtils.IPCGetSignatureHash") !== -1;
}
function patchPackageInfoForIpc(packageInfo) {
if (packageInfo == null) {
return packageInfo;
}
var packageName = packageInfo.packageName.value;
if (packageName !== TARGET_PACKAGE) {
return packageInfo;
}
packageInfo.signatures.value = fakeSignatures();
if (packageInfo.signingInfo !== undefined) {
packageInfo.signingInfo.value = null;
}
return packageInfo;
}
function hookSignativeIPC() {
var PackageInfo = Java.use("android.content.pm.PackageInfo");
var ParcelableCreator = Java.use("android.os.Parcelable$Creator");
var originalCreator = PackageInfo.CREATOR.value;
var className = "com.example.mysecurityapp.PackageInfoCreatorProxy" + Date.now();
var ProxiedCreator = Java.registerClass({
name: className,
implements: [ParcelableCreator],
methods: {
createFromParcel: [{
returnType: "java.lang.Object",
argumentTypes: ["android.os.Parcel"],
implementation: function(source) {
var packageInfo = Java.cast(
originalCreator.createFromParcel(source),
PackageInfo
);
if (isFromIPCGetSignatureHash()) {
return patchPackageInfoForIpc(packageInfo);
}
return packageInfo;
}
}],
newArray: [{
returnType: "[Ljava.lang.Object;",
argumentTypes: ["int"],
implementation: function(size) {
return originalCreator.newArray(size);
}
}]
}
});
PackageInfo.CREATOR.value = ProxiedCreator.$new();
}
function main() {
Java.perform(function() {
console.log("hook start");
hookSignativeIPC();
});
}
setImmediate(main);
CREATOR 里包含两个方法:
createFromParcel(Parcel source):从二进制字节流里读取数据,拼装还原出一个完整的 Java 对象。
newArray(int size):用来创建该对象的数组。
var packageInfo = Java.cast(
originalCreator.createFromParcel(source),
PackageInfo
);
if (isFromIPCGetSignatureHash()) {
return patchPackageInfoForIpc(packageInfo);
}
return packageInfo;
在我们自己伪造的 CREATOR 中,拦截数据后便将 PackageInfo 进行替换。

当然,相应的防御手段也是有的。我们可以在 Java 代码中新增一个方法,专门检测 PackageInfo.CREATOR 是否被篡改。
// 检测替换PackageInfo.CREATOR
public static boolean checkIsPackageInfoReplace()
{
try{
Field creatorField = PackageInfo.class.getField("CREATOR");
creatorField.setAccessible(true);
// 反射获取creator对象
Object creator = creatorField.get(null);
if(creator != null)
{
// 系统默认的CREATOR加载器为BootClassLoader 但是替换后的通常不是
ClassLoader creatorClassloader = creator.getClass().getClassLoader();
// 获取PackageInfo的加载器 此加载器为BootClassLoader
ClassLoader sysClassloader = PackageInfo.class.getClassLoader();
if(creatorClassloader == null || sysClassloader == null)
{
return false;
}
// 比较地址 同一个类加载器加载的类 地址是唯一的
if(sysClassloader != creatorClassloader)
{
return true;
}
}
}
catch (Throwable e)
{
e.printStackTrace();
}
return false;
}
这时再运行完整的攻击脚本,该替换行为就会被检测到。

Native 层检测
先来写一个简单的 Native 检测。
native-lib.cpp
#include <jni.h>
#include <string>
#include <sstream>
#include <iomanip>
#include "Utils.h"
#include "android/log.h"
#define LOG_TAG "YvY_Security"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_mysecurityapp_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
std::string expected_signature = "308202e4308201cc020101300d06092a864886f70d01010b050030373116301406035504030c0d416e64726f69642044656275673110300e060355040a0c07416e64726f6964310b30090603550406130255533020170d3234313131363134343832385a180f32303534313130393134343832385a30373116301406035504030c0d416e64726f69642044656275673110300e060355040a0c07416e64726f6964310b300906035504061302555330820122300d06092a864886f70d01010105000382010f003082010a02820101009042f3b7fbe3164cac331f89ca5eefa713b17a3bc7385f7c740646feb823099717b5c83f4d466415e09cdc0aa1f195419b8b485c69c5da7c2a77b4252527188df70e99f842729e7e6aa6ed3b93e021035c740d53b2f8e65157b6994e4d054091b984c48a0b5161e63e7104e858e995117c884dfb8337e129b8b65af7ddbbca501261cd043c9b6105956b569f50d58c2b2fba2c81c30fbb022c30043f9ca13230d437b5d304cc6e4be6fa9e7ca85b8b4945093c5bdb13df5f1936ac25ad3c3a102b18aaaf118d8dc9e93fe8637ba37b09b232d3923eb257d451c7bb9da68a8ae189766a917f76ae0a864669656ee6f6c869553555d96e05b519e899d04a8b121d0203010001300d06092a864886f70d01010b050003820101002042005e9bdb007e94c01a6dc74bf56f16ddcd7a85b5407bdd8a20494c8949cbecff0ebf213e415fff05a8141c84273a79ce14387487ca1e449f1a18a50c38dd254dc3e8f29f987a0ba550e78572afa9bad0eabeac03609f74d6575456444fd3f5d35a94abf167b96ed3a774a12d0cebbfb7c9b6a7821e3c11ad940eed2c63647e9b7570e5aa609dbec0678d7ebba8acd4213c28dc5a01dfe6a1f438c5ee8c94ef573acd565334fe9287d74c94a934bc3959a9e7d69e7ac1074f8667a58b8f3e51e29564b164854610a983a4fdcc516b9b48726e6feee74247deb407e335ba411af80c5216d59815b689f398e13d4aa900f589960df72dd73e565d0b592480a0";
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_example_mysecurityapp_MainActivity_SignatureVerificationNative(JNIEnv *env, jclass clazz) {
const std::string path = Utils::getBaseApkPath();
if(path.empty())
{
LOGE("APK签名验证失败 原因: getBaseApkPath fail");
return JNI_FALSE;
}
int fd = open(path.c_str(),O_RDONLY | O_CLOEXEC);
if(fd < 0)
{
LOGE("APK签名验证失败 原因: open fd fail");
return JNI_FALSE;
}
std::vector<unsigned char> cert_stream = Utils::read_certificate(fd);
if(cert_stream.empty())
{
LOGE("APK签名验证失败 原因: read_certificate fail");
close(fd);
return JNI_FALSE;
}
std::stringstream stream;
stream << std::hex << std::setfill('0');
for(unsigned char byte : cert_stream)
{
stream << std::setw(2) << static_cast<int>(byte);
}
std::string APK_sign = stream.str();
jboolean is_valid = JNI_FALSE;
if(APK_sign == expected_signature)
{
is_valid = JNI_TRUE;
}
else
{
is_valid = JNI_FALSE;
}
close(fd);
return is_valid;
}
(诸如解析 v2、v3 签名的工具类函数,借助 AI 辅助生成即可)
libc 函数重定向
上述例子中,攻击者完全可以 hook open 函数,将参数中的文件路径替换为提前备份好的原始 apk 路径,这样一来签名校验就必定会成功。
hook 脚本如下:
const original_apk = "/data/user/0/com.example.mysecurityapp/files/app-debug.apk";
const target_so = "libmysecurityapp.so";
const original_apk_ptr = Memory.allocUtf8String(original_apk);
function isTargetFile(path)
{
if(!path) return false;
return (path.indexOf("base.apk") !== -1) && path != original_apk;
}
function isTargerCaller(context)
{
const caller = DebugSymbol.fromAddress(context.lr);
if(caller.moduleName.indexOf(target_so) !== -1)
{
return true;
}
return false;
}
function attachOpenLike(name,argsIndex)
{
const addr = Module.findExportByName("libc.so",name);
Interceptor.attach(addr,{
onEnter(args)
{
const pathPtr = args[argsIndex];
const path = Memory.readUtf8String(pathPtr);
if(isTargetFile(path) && isTargerCaller(this.context))
{
args[argsIndex] = original_apk_ptr;
console.log(name + "重定向成功");
}
}
});
}
function hookOpen() {
attachOpenLike("open", 0);
attachOpenLike("open64", 0);
attachOpenLike("__open_2", 0);
attachOpenLike("openat", 1);
attachOpenLike("openat64", 1);
}
function main() {
console.log("hook start");
hookOpen();
}
setImmediate(main);
由于编译保护可能会将 open 自动替换为 __open_2,所以这里不能只 hook open 这一个函数。以下是绕过效果。

为了防范对 libc 函数的这类 hook,我们可以考虑使用 SVC 指令代替。
在 ARM 架构中,SVC 指令用于从用户态切换到内核态,执行系统调用。
使用 SVC 指令比调用普通库函数更为安全:
#include <jni.h>
#include <string>
#include <sstream>
#include <iomanip>
#include <cerrno>
#include "Utils.h"
#include "android/log.h"
#define LOG_TAG "YvY_Security"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
namespace {
// 相对目录
constexpr int SVC_AT_FDCWD = -100;
// openat的系统调用号
constexpr int SVC_OPENAT_ARM64 = 56;
constexpr int SVC_OPENAT_ARM = 322;
int svc_openat(const char *path, int flags, int mode)
{
// arm64
#if defined(__aarch64__)
register long x0 asm("x0") = SVC_AT_FDCWD; // a1 : 目录文件描述符
register long x1 asm("x1") = reinterpret_cast<long>(path); // a2 : 文件路径的指针
register long x2 asm("x2") = flags; // a3 : 标志
register long x3 asm("x3") = mode; // a4 : 权限模式
register long x8 asm("x8") = SVC_OPENAT_ARM64; // x8寄存器存放系统调用号
asm volatile(
"svc #0"
: "+r"(x0)
: "r"(x1), "r"(x2), "r"(x3), "r"(x8)
: "memory");
return static_cast<int>(x0); // 将返回值转为int返回
#elif defined(__arm__)
register long r0 asm("r0") = SVC_AT_FDCWD;
register long r1 asm("r1") = reinterpret_cast<long>(path);
register long r2 asm("r2") = flags;
register long r3 asm("r3") = mode;
asm volatile(
"push {r7}\n"
"mov r7, %4\n"
"svc #0\n"
"pop {r7}"
: "+r"(r0)
: "r"(r1), "r"(r2), "r"(r3), "r"(SVC_OPENAT_ARM)
: "memory");
return static_cast<int>(r0);
#else
return open(path, flags, mode);
#endif
}
int open_apk_by_svc(const char *path, int flags)
{
int fd = svc_openat(path, flags, 0);
if (fd < 0) {
errno = -fd;
return -1;
}
return fd;
}
} // namespace
}
fd 替换
此时再去 hook open 函数难度就高了不少,但攻击者依然可以采用 fd 替换的方式来绕过签名校验。重打包后的应用 fd 指向 base.apk,只需将所指向的文件内容替换为原始 apk 即可。
const original_apk = "/data/user/0/com.example.mysecurityapp/files/app-debug.apk";
const target_so = "libmysecurityapp.so";
const target_symbol = "_ZN5Utils16read_certificateEi";
const O_RDONLY = 0;
const O_CLOEXEC = 0x80000;
const openPtr = Module.findExportByName("libc.so","open");
const dup2Ptr = Module.findExportByName("libc.so","dup2");
const closePtr = Module.findExportByName("libc.so","close");
const openFn = new SystemFunction(openPtr,"int",["pointer","int"]);
const dup2Fn = new SystemFunction(dup2Ptr,"int",["int","int"]);
const closeFn = new NativeFunction(closePtr,"int",["int"]);
const originalApkPtr = Memory.allocUtf8String(original_apk);
function hookFd()
{
const targetModule = Process.findModuleByName(target_so);
if(targetModule === null) return;
const target = targetModule.findExportByName(target_symbol);
Interceptor.attach(target,{
onEnter(args)
{
const realFd = args[0].toInt32();
// open函数打开原始apk 获得fakefd
const openResult = openFn(originalApkPtr,O_RDONLY | O_CLOEXEC);
const fakeFd = openResult.value;
// 使用dup2函数复制fakefd
const dup2Result = dup2Fn(fakeFd,realFd);
if(dup2Result.value < 0)
{
closeFn(fakeFd);
return;
}
this.shadowFd = fakeFd;
},
onLeave()
{
if(this.shadowFd >= 0)
{
closeFn(this.shadowFd);
console.log("替换成功");
}
}
});
}
function main() {
console.log("hook start");
// 轮询查找目标so文件
var timer = setInterval(function(){
var module = Process.findModuleByName(target_so);
if(module !== null)
{
hookFd();
clearInterval(timer);
}
},10);
}
setImmediate(main);
这里利用 dup2 函数替换了原始的 fd,此时 fd 指向的不再是当前应用的 base.apk,而是我们事先准备好的原始 apk,校验自然也就成功了。

同样,这种方法也有对应的防护手段。可以编写一个函数来判断 fd 是否已被替换。
// native-lib.cpp
if (!Utils::isExpectedFdPath(fd, path)) {
LOGE("APK签名验证失败 原因: fd path mismatch");
close(fd);
return 2;
}
// Utils.cpp
bool Utils::isExpectedFdPath(int fd, const std::string& expected_path)
{
if (fd < 0 || expected_path.empty()) {
return false;
}
char fd_link[64] = {};
std::snprintf(fd_link, sizeof(fd_link), "/proc/self/fd/%d", fd);
char resolved_path[PATH_MAX] = {};
ssize_t len = readlink(fd_link, resolved_path, sizeof(resolved_path) - 1);
if (len <= 0) {
return false;
}
resolved_path[len] = '\0';
return expected_path == resolved_path;
}
假如 fd 指向的路径已经不再是原始文件,那就证明已被重定向。
这里稍微修改了一下函数类型,如果检测到替换则返回 2,MainActivity 中的调用改为:
case "signature_native_button":
native_res = SignatureVerificationNative();
if(native_res == 1)
{
native_textView.setText("签名校验成功");
}
else
{
native_textView.setText("签名校验失败");
}
if(native_res == 2)
{
native_textView.setText("检测到fd被替换");
}
再次 hook 并观察日志。

对比 Inode 值
除检测 fd 路径外,还可以对比 Inode 的值。
Inode 好比每个文件的“身份证”,其内部不存储具体的数据,而是存储文件的元数据。每个文件都拥有一个唯一的 Inode 值。
bool Utils::isExpectedFdInode(int fd, const std::string& expected_path)
{
if(fd < 0 || expected_path.empty()) return false;
// stat64结构体储存文件系统元数据
struct stat64 fd_stat;
// 获取文件系统元数据
if(fstat64(fd,&fd_stat) != 0) return false;
// Linux 特殊的虚拟文件/proc/self/maps记录了当前 App 进程加载到内存里的所有模块和文件
std::ifstream maps_files("/proc/self/maps");
std::string line,dummy,path;
unsigned long long maps_inode;
// 逐行读取maps内容
while (std::getline(maps_files,line))
{
if(line.find(expected_path) == std::string ::npos) continue;
std::istringstream iss(line);
// maps格式通常为 : 地址 权限 偏移 设备号 Inode 路径
// 这里将line按流解析 直接获得第五个Inode
if(iss >> dummy >> dummy >> dummy >> dummy >> maps_inode >> path)
{
if(path == expected_path)
{
// 将当前fd的Inode与原始apk的Inode值进行比较
return maps_inode == static_cast<unsigned long long>(fd_stat.st_ino);
}
}
}
return false;
}
注释掉刚才的 fd 检测,加上这个对比 Inode 值的函数再运行看看。

成功检测到了异常。fd 路径检测可以通过 Magisk 挂载等方式解决,而比对其 Inode 编号的方式则更为直接,也更难绕过。
本文为看雪论坛精华文章,由 mb_wckjnnha 原创,转载请注明来自看雪社区。对 Android 底层攻防感兴趣的朋友,也欢迎常来云栈社区逛逛,里面有更多一线开发者的实战分享。