找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3621

积分

0

好友

483

主题
发表于 4 小时前 | 查看: 4| 回复: 0

最近在阅读《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 层检测并不安全:PackageManagergetPackageInfo 可能被 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 失效了。

IPC签名校验失败与绕过对比

但这种方法也并非无懈可击。

替换 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 里包含两个方法:

  1. createFromParcel(Parcel source):从二进制字节流里读取数据,拼装还原出一个完整的 Java 对象。
  2. newArray(int size):用来创建该对象的数组。
  var packageInfo = Java.cast(
    originalCreator.createFromParcel(source),
    PackageInfo
  );

  if (isFromIPCGetSignatureHash()) {
    return patchPackageInfoForIpc(packageInfo);
  }

  return packageInfo;

在我们自己伪造的 CREATOR 中,拦截数据后便将 PackageInfo 进行替换。

替换CREATOR后签名校验被绕过

当然,相应的防御手段也是有的。我们可以在 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;
    }

这时再运行完整的攻击脚本,该替换行为就会被检测到。

检测到CREATOR被替换

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 这一个函数。以下是绕过效果。

Native层签名校验失败对比

为了防范对 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校验

同样,这种方法也有对应的防护手段。可以编写一个函数来判断 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 并观察日志。

检测到fd路径不匹配日志

对比 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 值的函数再运行看看。

检测到Inode值不同

成功检测到了异常。fd 路径检测可以通过 Magisk 挂载等方式解决,而比对其 Inode 编号的方式则更为直接,也更难绕过。

本文为看雪论坛精华文章,由 mb_wckjnnha 原创,转载请注明来自看雪社区。对 Android 底层攻防感兴趣的朋友,也欢迎常来云栈社区逛逛,里面有更多一线开发者的实战分享。




上一篇:戴尔AI服务器收入暴增757%,上调全年指引至600亿美元
下一篇:Kimsuky APT组织滥用VS Code隧道窃密,伪造Webex会议攻陷韩国军政单位
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-5-30 08:26 , Processed in 0.725687 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表