首页
社区
课程
招聘
加固分析
发表于: 1天前 470

加固分析

1天前
470

由于时间和水平有限,本文会存在诸多不足,希望得到您的及时反馈与指正,多谢!


某加固的so分析,是32位的,so见附件。
整个流程可以理解为:.init_proc 解密自身 → JNI_OnLoad 搭环境 → 检测调试/注入 → 解密并注入 Dex → 装 IO Hook 做运行时透明解密
这篇文章按执行顺序,从 .init_proc 开始,把整个壳从头到尾拆一遍。


先看全局

从 so 被加载到 JNI_OnLoad 返回,整个执行链大概是这样的:

阶段 地址范围 目的
.init_proc(ELF 构造函数) linker 自动调用 解密 so 内部加密数据段,还原 JNI_OnLoad 的真实代码和配置
JNI_OnLoad 前置初始化 0x28ACC0x2A35A 8 步初始化:校验 JNI、注册方法、解码数据、分配上下文、探测版本、解析路径
检测链 sub_2A790 0x2A35A 处被调用 反调试检测 + Dex 注入,不通过就直接杀进程
防护引擎 sub_32AC0 sub_2A790 拉起来 IO Hook 全家桶:路径处理、完整性校验、装 Hook、Asset 重定向
收尾 0x2A35E0x2A712 ARM 装 syscall Hook / x86 修 ELF 头,返回 JNI_VERSION_1_4

接下来按执行顺序,从 .init_proc 开始往下拆。


.init_proc:一切的起点

System.loadLibrary("DexHelper") 触发 so 加载时,linker 会先执行 ELF 的 .init_proc(也就是 DT_INIT 指定的构造函数),这一步发生在 JNI_OnLoad 之前。
.init_proc 干的核心事情就是解密 so 自身的数据。如果你直接在 IDA 里静态看这个 so,会发现很多数据段和代码段的内容是乱的——那是因为它们在磁盘上是加密状态,只有 .init_proc 跑完之后才会被还原成真正的代码和数据。
这里用的是frida_dump——在运行时通过 Frida 把 .init_proc 解密后的 so 从内存里 dump 出来,再拿去 IDA 分析。这时候看到的 JNI_OnLoad 才是真正的逻辑。


JNI_OnLoad 的 8 步初始化

Step 1:入口判断

上来先 vm->GetEnv 拿 JNIEnv,拿不到就返回 -1,bye。然后有意思的是,它会读 ELF 的 e_machine 字段——如果发现是 x86,只注册一个简化方法 sl 就直接返回了。也就是说模拟器上这个壳基本不干活,只有 ARM/ARM64 才走完整防护流程。

Step 2:注册 JNI 方法

com/secneo/apkwrapper/H 这个类上绑了 6 个 native 方法,方法名全是混淆的:ghaghcgahshahegv。然后用了个比较骚的方式——通过 vtable 偏移间接调 GetJavaVM,把 JavaVM 指针缓存到全局变量 off_9F008。后面跨线程操作要靠这个。

Step 3:数据段写权限

/proc/self/maps 找目标数据段,没写权限就 mprotect 开一下。

Step 4:XOR 0xAC 解码

so 内部塞了一段加密数据,解码方式简单粗暴:每字节 XOR 0xAC。解出来按 token 分发——类型 5 是数值,6/7/8 是字符串。结果填到各种全局槽位里:函数指针、字符串指针、配置值。
另外它还会探测 ART 有没有 JNIException::~JNIException 这个符号,有的话就能拿到 ArtMethod::data_offset

Step 5:全局上下文

malloc 一个 1096 字节的结构体,魔数 0x6D6D21。里面塞了混淆类名、包名、SDK 版本号。
字符串去混淆:先取偶数位字节,然后按 [1,3,2] 循环步长逐个减。SDK Preview 版本号会多 1,专门做了修正。

Step 6:ART 版本探测(Android 12+)SDK ≥ 31 才走这段。先不走 Java API,直接尝试打开 APEX 包读 AndroidManifest.xmlversionCode。拿不到才退回反射调 getPackageInfo。拿版本号是为了算 ArtMethodThreadClassLinker 等内部结构的字段偏移,后面 Dex 注入要用。

Step 7:VM 类型 + 路径解析

判断 Dalvik 还是 ART :判断加载了 libdvm.so 还是 libart.so。三星 API25 和 YunOS 有专门的分支处理。
路径解析:从 ApplicationInfosourceDir,但它不完全信这个值——还会遍历 fd 5 到 127,用 readlink/proc/pid/fd/N 找真正在用的 APK 路径。找到后建 .cache 工作目录,再校验 /proc/self/cmdline 里的进程名。

Step 8:注册桥接方法

最后给 AW 类注册 hn(配置注入)和 pn(收尾),给 H 类注册 d(运行时字符串解密入口)。到这里 Java ↔ Native 的桥就搭好了。


检测链:sub_2A790

这是整个壳检测部分的部分,如果检测没有通过,直接杀死进程

检测全景

检测 方式 错误码
libc 是否被 patch sub_38FC0pthread_create 函数头 16 字节比对 0xB6A001DD
BlackDex 直接看 /data/data/top.niunaijun.blackdexa/ 存不存在 0xB6A002DD
Frida 遍历 /proc/self/task/*/status,找 gum-js-loopgmaingdbus 线程名 0xB6A080FF
linjector 注入 遍历 /proc/self/fdreadlink 看有没有指向 linjector 的 0xB6A080FF
APK 路径 sub_3FBB4 路径白名单校验 0xB6A0822D
签名 SigBlock 快速校验 + PackageInfo.signatures 0xB6A0822F
Dex 注入后签名 二次交叉校验 0xB6A0822E

Frida 检测:

  1. 线程名扫描:打开 /proc/self/task 目录,枚举所有 TID,逐个读 status 文件里的 Name: 字段。Frida 注入后会多出 gum-js-loop(JS 运行时主循环)、gmain(GLib 事件循环)、gdbus(D-Bus 通信)这些线程。
  2. fd 扫描:遍历 /proc/self/fd,对每个 fd 做 readlink,看链接目标有没有 linjector 这样的注入工具特征。
  3. 字符串混淆:检测关键词不是明文存在 so 里的。它有个专门的解码函数 sub_3913C,算法是 buf[i] += ~(i % 3),也就是按 (-1, -2, -3) 循环偏移。比如 "hwp.lv.nrpr" 解码后才是 "gum-js-loop"

绕过代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const KEYWORD_PATCHES = [
    { kw: 'gum-js-loop', rep: 'no-frida-thr' },
    { kw: 'gmain',       rep: 'dummy'         },
    { kw: 'gdbus',       rep: 'dummy'         },
    { kw: 'linjector',   rep: 'kworker00'     },
    { kw: 'frida',       rep: 'xxxxx'         },
];
 
Interceptor.attach(sub_3913C_addr, {
    onEnter(args) {
        this.buf = args[0];
        this.len = args[1].toInt32();
    },
    onLeave(_ret) {
        if (!this.buf || this.len <= 0) return;
        try {
            const decoded = this.buf.readCString();
            if (!decoded) return;
            for (const { kw, rep } of KEYWORD_PATCHES) {
                const idx = decoded.indexOf(kw);
                if (idx >= 0) {
                    const padded = rep.padEnd(kw.length, '\0').substring(0, kw.length);
                    Memory.writeUtf8String(this.buf.add(idx), padded);
                }
            }
        } catch (_) {}
    }
});

替换字符串必须等长,因为解码是原地操作,写多了会导致栈溢出。
sub_2A790 内部用 sub_24060(path, 0x80000) 打开 status 文件时带了 O_CLOEXEC,可能走的直接 svc syscall 绕过了 libc,所以还得在 read 层面做盲过滤:不管读的是什么文件,只要内容里出现 Frida 线程名就替换掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Interceptor.attach(Module.findExportByName('libc.so', 'read'), {
    onEnter(args) {
        this.buf  = args[1];
        this.size = args[2].toInt32();
    },
    onLeave(ret) {
        const n = ret.toInt32();
        if (n <= 0 || n > 4096) return;
        try {
            const content = this.buf.readCString(n);
            if (!content) return;
            let modified = content;
            let dirty = false;
            for (const sig of FRIDA_THREAD_SIGNATURES) {
                if (modified.includes(sig)) {
                    modified = modified.split(sig).join('kworker');
                    dirty = true;
                }
            }
            if (dirty) {
                const encoded = Memory.allocUtf8String(modified);
                Memory.copy(this.buf, encoded, modified.length);
                ret.replace(ptr(modified.length));
            }
        } catch (_) {}
    }
});

libc 完整性检测

sub_38FC0 做的事情很精确:通过 dlsym 拿到 pthread_create 的地址,读函数开头 16 字节,和预期的原始指令逐字节比对。如果你用 Frida 做了 inline hook,函数头必然被改成了跳板指令,这一步就会挂掉。
逆一下调用方的汇编可以确认返回值语义:R0 == 1 时触发 kill(检测到异常),R0 != 1 放行。所以绕过方式就是强制让它返回 0:

1
2
3
4
5
Interceptor.attach(sub_38FC0, {
    onLeave(ret) {
        if (ret.toInt32() !== 0) ret.replace(ptr(0));
    }
});

杀进程

不是调 exit() 或者 kill(),而是把 sp 和 lr 清零,然后跳到 0x97c
逆出来它的签名是 void loc_27FA0(int flag, int errorCode, int mask),比如线程名检测命中时调的是 loc_27FA0(256, 0xB6A080FF, 4095)
因为它内部会主动破坏栈帧,用 Interceptor.attachonEnter 返回后原始代码照跑,还是会崩。所以必须用 Interceptor.replace 完全接管

1
2
3
4
5
6
Interceptor.replace(loc_27FA0, new NativeCallback(
    function (flag, errorCode, mask) {
        // 直接 return,不执行 sp=0 的 kill 逻辑
    },
    'void', ['int', 'int', 'int']
));

策略位控制

检测链不是无脑全开的,配置里有几个 bit 控制:

  • bit12 & bit4 → 启动异步 worker
  • bit3 & bit2 → 装 Android log hook
  • bit3 → 深度反调试:ptrace attach/cont/detach 监控链 + 双 pipe fork 守护进程
    ptrace :壳自己 fork 出子进程然后 PTRACE_ATTACH 占住位置,其他 debugger 就没法再 attach 了。

Dex 注入:

检测全过了以后,就到了核心功能——把加密的 Dex 解出来注入虚拟机。

解密流程

  1. 找到 dexdata0:依次尝试 DexCache → mCookie → 反射 → 缓存文件 → 最后从 so 符号表里扒。兜底链路做得很全。
  2. 校验 dex 头尾,确认拿到的是对的。
  3. 分块解密:16 字节一块做变换,key 是动态派生的,还搞了元数据回溯和索引挂链。不是简单的 XOR 能搞定的。

注入虚拟机

这里分两条路线:
Dalvik(老设备):

  • loadDex 加载解密后的 Dex
  • 回填 mCookie
  • 扩容 dexElements 数组
    ART(主流):
  • 直接给 VerifyClassIsVerificationEnabled 打 patch,把类验证关掉
  • 这样注入的 Dex 即使签名不对也能跑

挂到 ClassLoader

  • SDK ≤ 25:简单粗暴,直接替换 pathList.dexElements
  • SDK > 25:扩展 base.apk 的 mCookie,用 sub_43204 做修正
    可选地还会 hook OpenDexFilesFromOatOpenMemoryOpen 来拦截 ART 的 Dex 加载流程。
    Dex 注入完之后,还要再做一次签名校验(错误码 0xB6A0822E),防止有人在注入过程中做了手脚。

sub_32AC0:

这个函数负责运行时的 IO 透明解密。

路径和完整性

先拼出 .cache 目录和 classes.dve 缓存路径。对目标文件算 MD5(16 字节),和缓存里的 24 字节校验块比较。不一致就清缓存重建。

资源规则解密

从 APK 的 assets/RES_RULE 读出规则文件,用 11 字节周期的 XOR key 解密。解出来的规则决定了哪些文件需要运行时解密。

RC4 密钥准备

密钥生成分两步:

  • 前 16 字节:dword_90E18dword_9F0B8
  • 后 16 字节:经过 dword_912CC 置换表变换
    最终凑成 32 字节的 RC4 KSA key,后面 IO Hook 解密用这个。

IO Hook

对 libc 做 inline hook,实现运行时透明解密:

被 Hook 的函数 替换函数地址 目的
__open / __openat 0x37FB0 / 0x38044 拦截 trace_marker 打开;把 fd 注册到跟踪表
mmap64 / mmap 0x380D8 / 0x38428 映射命中加密区间就 RC4 解密;大块(>0x20000)降级为 XOR 0xAA
close 0x38728 从跟踪表删 fd,调原始 close
read 对应 hook 被跟踪 fd 的数据做缓冲拷贝 + 变换
write / pread64 / pwrite64 对应 hook 被跟踪 fd 做变换;trace_marker 放过

打开文件时记录 fd,读写时自动解密,关闭时清理。应用层完全感知不到文件是加密的。
mmap 的处理:小块用 RC4,但超过 0x20000(128KB)的大块为了性能退化成简单的 XOR 0xAA。

厂商适配

  • 三星(标记 1)和小米 SDK>33(标记 2):额外注册 libbinderlibutilslibcutilslibartbase 的规则
  • Pixelbook / 展锐平台:通过扫 ELF 符号表定位 __close 来做 inline hook
  • SDK 19~23 华为/荣耀:有个 SharedPreferences 热修复,反射拿 ContextImpl.sSharedPrefs 强制重新加载
  • Perfetto HPROF(SDK ≥ 30):hook 掉 libperfetto_hprof.so 把 signal pipe 关了

Asset 重定向

从 APK 抽出 assets/baoef 放到 .cache/assets,然后按 SDK 版本 hook 不同入口:

  • SDK ≥ 30 → ZipAssetsProvider::OpenInternal
  • SDK 28~29 → ApkAssets::Open
  • SDK < 28 → AssetManager::open
    创建内存容器对象,命中时把读请求重定向过去。

JNI_OnLoad 收尾

ARM

  1. 在 libc 的代码段里扫描 ARM 指令特征(0xE1A0C007 这种),从 r7 赋值指令里提取 syscall 编号
  2. 定位到 openreadmmap2 的 syscall 桩地址
  3. 对这三个 syscall 装拦截 Hook,原函数指针存下来做蹦床
    这一步直接 hook 的是 syscall 入口。

x86

找到 libDexHelper.so 的映射位置,mprotect 开写权限,往头部写 3 个 dword 把故意破坏的 ELF 头修回来。防止直接 readelf 或者加载分析。

最后一步

不管 ARM 还是 x86,最终都收到 loc_2A64A,返回 JNI_VERSION_1_40x10004)。JNI_OnLoad 执行完毕,壳正式生效。
脚本这边需要处理 so 加载时序——libDexHelper.so 通过 android_dlopen_ext 加载,内部函数级 hook 必须等 dlopen 返回后才能装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
let dexHelperInternalsHooked = false;
function hookDexHelperInternals(modOverride) {
    if (dexHelperInternalsHooked) return true;
    const mod = modOverride || findModule('DexHelper');
    if (!mod) return false;
    const sub_38FC0      = resolveThumbAddress(mod, 0x38FC0, 'sub_38FC0');
    const sub_3913C_addr = resolveThumbAddress(mod, 0x3913C, 'sub_3913C');
    const sub_3FBB4      = resolveThumbAddress(mod, 0x3FBB4, 'sub_3FBB4');
    const loc_27FA0      = resolveThumbAddress(mod, 0x27FA0, 'loc_27FA0');
    if (!sub_38FC0 || !sub_3913C_addr || !sub_3FBB4 || !loc_27FA0) return false;
    // 在这里装上面提到的各个 Hook ...
    dexHelperInternalsHooked = true;
    return true;
}
 
if (!hookDexHelperInternals()) {
    Interceptor.attach(
        Module.findExportByName(null, 'android_dlopen_ext'),
        {
            onEnter(args) {
                try { this.soName = args[0].readUtf8String(); }
                catch (_) { this.soName = ''; }
            },
            onLeave(ret) {
                if (!ret.isNull() && this.soName
                    && this.soName.includes('DexHelper')) {
                    hookDexHelperInternals(
                        findLoadedModuleFromPath(this.soName, 'DexHelper')
                    );
                }
            }
        }
    );
}

注意所有偏移都是 ARM Thumb 指令集的,resolveThumbAddress 做了 +1 处理(Interwork 标记 bit0=1)。


关键全局变量

符号 用途
off_A80F8 全局上下文,1096 字节结构体
off_9F008 JavaVM 指针缓存
off_9F018 SDK 版本号
off_9F024 配置字节数组,控制防护开关
off_9EFD4 防护配置对象(C++ vtable)
off_A824C / A8250 / A8254 原始 open / read / mmap2 函数指针
dword_A8348 32 字节 RC4 key 材料
off_9F0B4 APK meta-data 条目区间数组
dword_A80F0 / A80F4 / A81B8 内存 AssetProvider 容器指针

总结

整个壳分四层防护,从外到内:
反调试 — 查 libc 完整性、扫 Frida 线程名和 fd、检测 BlackDex 安装路径、ptrace 占位 + fork 守护进程
签名校验 — APK SigBlock 快速校验 + PackageInfo.signatures 兜底,Dex 注入完还要交叉验一次
Dex 保护 — 数据段 XOR 0xAC 解码 → 16B 分块变换 + 动态 key → Dalvik 走 loadDex / ART 关验证 → 挂到 dexElements
IO 透明解密 — Hook open/read/mmap2 syscall;RC4 或 XOR 0xAA 运行时解密;Asset 读取内存容器重定向
厂商适配覆盖了三星、小米、华为、展锐、Pixelbook;SDK 版本从 19 覆盖到 33+;Dalvik 和 ART 都有对应路径。反调试不是玩具级别的字符串匹配,而是深入到 /proc 文件系统和 syscall 层面。


参考文章

https://bbs.kanxue.com/thread-273614-1.htm
https://bbs.kanxue.com/thread-280302-1.htm


传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

上传的附件:
收藏
免费 1
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回