某转逆向分析(环境检测,sign值分析)
北海 发表于 广东 技术文章 871浏览 · 2024-12-12 15:03

本文仅作技术交流分享

正文

打开app,发现有环境风险提醒,说明肯定有环境检测代码。

下面先分析一下这里的环境检测的机制。
直接把apk扔到jadx里,没有发现加壳痕迹。通过多次尝试,最终发现搜索字段"检测到当前设备存在安全风险"


这是个root检测

然后在这个类中还意外发现了模拟器检测和网络环境检测


下面来分析一下root检测和网络检测。别问为什么不分析模拟器检测,问就是jadx没有成功把模拟器检测部分的代码反编译出来(QAQ)

root检测

可以看到这里先是使用了美团的热更新框架robust

https://tech.meituan.com/2016/09/14/android-robust.html

简单来说就是通过PatchProxy.proxy方法传入相关参数(包含 activity、this、changeQuickRedirect 等以及指定 FragmentActivity类型等)判断是否加载了补丁,如果加载了就return了,不执行后面的代码。由于这里有显示环境监测弹框,所以说明是执行了后续代码的。后面出现的PatchProxy.proxy多数同样是可以忽略的

后面的com.zhuanzhuan.module.util.safe.impl.UtilExport.SAFE.isRoot()是检测函数。

下面是关键代码解析:
boolean exists = new File("/system/app/Superuser.apk").exists();
尝试创建一个代表"/system/app/Superuser.apk"这个文件路径的File对象,并通过exists()方法来检查该文件在系统中是否实际存在。在Android系统中,Superuser.apk文件通常与设备获取 root 权限后的相关管理应用有关,如果这个文件存在,很可能意味着设备已经root了
for (String str : m54412a(new String[]{"/system/bin/sh", "-c", "type su"})) {... }
调用m54412a方法执行{"/system/bin/sh", "-c", "type su"}命令(该命令是用于查看su命令的相关情况),并遍历其返回的每一行结果字符串

if (StringsKt__StringsKt.contains$default((CharSequence) lowerCase, (CharSequence) "not found", false, 2, (Object) null)) {... }
检查转换后的小写字符串中是否包含"not found"这个子字符串,如果包含,说明通过"type su"命令执行的结果表明su命令未被找到,这种情况下将bool变量设置为Boolean.FALSE,意味着从这个命令执行角度来看,设备可能没有获取root权限相关的关键命令存在

if (!exists &&!bool.booleanValue()) {... }
这里进行综合判断,如果前面检查的Superuser.apk文件不存在(!exists为true),并且通过"type su"命令执行结果判断得出设备似乎也没有su命令(!bool.booleanValue()为true),那就需要进一步查看设备的Build.TAGS信息
String TAGS = Build.TAGS;
获取设备的Build.TAGS属性值,Build.TAGS中包含了一些设备构建相关的标签信息,常用于判断设备的一些特殊状态等情况
if (!StringsKt__StringsKt.contains$default((CharSequence) TAGS, (CharSequence) "test-keys", false, 2, (Object) null)) { return false; }
检查Build.TAGS中是否包含"test-keys"字符串,如果不包含,同样返回false,意味着设备没有 root 权限相关标识,从整体综合判断角度不符合root设备的特征
而如果前面的所有条件判断都通过了,最后就会返回true,表示判断设备是已经获取了root权限的状态

接着来看看m54412a函数


简单来说这个函数是创建一个空的ArrayList<string>用于存放结果。接着利用Runtime.getRuntime().exec(strArr)执行传入的命令,获取到Process对象后,从其标准输入流中逐行读取内容,只要读取到的行内容不为空,就添加到ArrayList里。之后再从错误输入流中按同样方式读取内容,不为空的行也添加到ArrayList中。最后再将ArrayList返回</string>

总结一下,这个isRoot()主要使用了以下两种检测设备是否root的方式。
文件检查方式:
首先通过new File("/system/app/Superuser.apk").exists()检查/system/app/Superuser.apk文件是否存在。在 Android 系统的环境下,Superuser.apk文件的存在通常与设备获取root权限后的管理应用有关,该文件的存在可能暗示设备已 root。
命令检查方式:
执行{"/system/bin/sh", "-c", "type su"}命令(通过m54412a函数来执行并获取结果),这个命令用于查看su命令的相关情况。su命令在类Unix系统(包括Android底层中是切换用户身份,特别是获取root权限的关键命令。
遍历命令执行后的结果字符串,将每个字符串转换为小写形式后,检查其中是否包含"not found"。如果包含,说明su命令可能不存在,将一个Boolean变量bool设为false;如果遍历完所有结果后bool仍为null,则将其设为true,以此来从su命令的角度判断设备是否可能具有 root 权限相关条件。

网络检测

跳转到com.zhuanzhuan.module.util.safe.impl.UtilExport.SAFE.networkProxy

进入m54413a


这个就很简单了,这里直接用System.getProperty("http.proxyHost")和System.getProperty("http.proxyPort")获取系统代理地址和端口。如果都获取到了就返回true,说明存在代理

sign值分析

抓一个搜索包

搜索zzreqsign,得到一个结果


点击进入

使用objection hook住这个类然后再搜索,看看生成请求是不是真的跟这里的方法有关。很幸运地发现这些方法大概率是跟zzreqsign相关的

编写代码hook getSign(Map<String, String> map)

function main(){
    Java.perform(function(){
        let SignUtil = Java.use("com.zhuanzhuan.sign.SignUtil");
        SignUtil.getSign.overload("java.lang.String","android.content.Context").implementation = function (str, context) {
            console.log("SignUtil.getSign is called: str= ",str," context= ",context);
            let result = this.getSign(str, context);
            console.log("SignUtil.getSign result: ",result);
            return result;
        }
    })
}
setImmediate(main);

对比一下result确实是zzreqsign

把signLib库拖到ida中

直接搜索getSign


由于是jni函数,所以直接修改第一个参数类型为JNIEnv*

下面简要分析一下该过程

数据准备阶段

// 1. 验证应用签名
app_sign_sha1 = get_app_sign_sha1(a1, a4);
if (!app_sign_sha1 || strcmp(app_sign_sha1, app_sing_str))
    return 0;

// 2. 获取输入数据
v9 = toBytes(a1, a3);                    // 将输入转换为字节数组
v10 = GetArrayLength(a1, v9);            // 获取数组长度
v28 = GetByteArrayElements(a1, v9, 0);   // 获取字节数组内容

// 3. 获取设备ID
DevID = getDevID(a1, a2);
v12 = toBytes(a1, DevID);                // 设备ID转字节数组
v14 = GetByteArrayElements(a1, v12, 0);  // 获取设备ID字节内容
v15 = GetArrayLength(a1, v12);           // 获取设备ID长度

数据混淆阶段

// 1. 分配内存
v16 = malloc(v10);  // 分配与输入数据等长的内存

// 2. 混淆算法
for (i = 0; i < v10; ++i) {
    v18 = v28[v13 % v10];        // 获取输入数据的循环值
    v19 = (v13 + 1) % v15;       // 计算设备ID的索引
    v13 += 2;                     // 步进值为2
    v16[i] = v14[v19] ^ v18;     // 使用异或运算混淆数据
}

MD5 计算阶段

// 1. 创建新的字节数组(长度为原数据长度+9)
v21 = NewByteArray(v27, v10 + 9);

// 2. 填充混淆后的数据
SetByteArrayRegion(v20, v21, 0, v10, v16);

// 3. 在末尾添加固定字符串"smiletozz"
SetByteArrayRegion(v20, v21, v10, 9, "smiletozz");

// 4. 计算最终的MD5值
MD5 = getMD5(v27, v21);

接着再看看md5是否有被魔改


进入获取MD5摘要的函数getDigestedBytes()


这又是一个jni函数,于是把第一个类型改成JNIEnv*,可以看到纯正的native函数写法

这段代码就等价于下面这段简单的java代码。这就是标准的实现MD5摘要的代码

public byte[] getDigestedBytes(byte[] input) {
    MessageDigest md = MessageDigest.getInstance("MD5");
    return md.digest(input);
}

于是,分析大致结束了

1 条评论
某人
表情
可输入 255