声明:本文仅用于安全技术研究与教学,所有分析均在隔离测试环境完成,不涉及任何真实业务数据。
这次把 某商业级(com.mobile.demo_target) 从加壳到加密算法完整啃了一遍。真机走 SysTrace 定制 ROM,用 unidbg 离线模拟,主要做了这几件事:
最终拿到:可直接丢进 IDA 的干净脱壳文件 payload_clean_full.so(103 函数、9185 字符串);加密链路全部跑通(#239 AES-128 key + #238 AES/CBC);VMP 解释器 16 个 opcode 语义和 W28 状态链都还原了;libDexHelper 的反篡改也绕过了。
目标 App 具备多层反调试(libantitrace.so 反跟踪、内核级检测、/proc/self/maps 扫描),常规的 "root + frida" 方案会被直接检测。
本项目全程排除 Frida,真机侧使用 SysTrace 定制 ROM(AOSP 10 深度定制,内置 Dobby 注入、SandHook/Xposed 能力、ROM 打桩与 VMP 对抗工具),配合 unidbg 做离线模拟。两条路线各司其职:
【整体环境与数据流示意】
lib/arm64-v8a/ 下 60 个 .so。按安全厂商分类:
【demo_target_libs 库矩阵(关键 so)】
结论:这是一个 某企业版加固 + 多家 SDK 混合 的典型某商业级 App——libDexHelper.so/libdexjni_target.so/libbangcle_risk.so/libsmkernel.so 均属同一套某加固体系(com.secneo.apkwrapper 即某企业版特征包名)。主加密逻辑在 libdexjni_target.so,libDexHelper.so 负责反篡改,libtongdun.so 等为独立第三方风控。
首次注入 hook 后 App 异常退出。从 tombstone 分析:
【tombstone 0x79c (logcat 实测)】
关键点:sp/lr/pc 全为 0 且 pc=0x79c —— 栈完全被清零后跳转到 0x79c,是典型的扫描 maps 后主动自杀(非随机崩溃)。
问题根因:
区分两种检测:① 环境检测(ro.debuggable=1、su 存在、USB 调试)触发的是 Activity.finish() 闪退(进程仍在后台);② 注入检测(maps 中发现未授权库)触发的是 0x79c 空指针自杀(进程直接崩溃)。本文 0x79c 属于后者。
应对思路:让注入的 so 在 maps 中"不可疑"。
对抗环境检测的核心工具是 setup_stealth。
它集成了 5 类反检测技术:
① 属性物理覆写(绕过属性 API)
加固壳会绕过 __system_property_get 直接 mmap 读取 /dev/__properties__ 的物理内存(属性树),常规 setprop 无效。setup_stealth 反向利用这一点:
关键点:序列锁协议(写前置 serial 奇数位、写后置偶数位 + 内存屏障),保证并发读属性时不撕裂。
② Ghost Root 提权(setresuid 1337)
syscall(__NR_setresuid, 1337, 1337, 1337) 触发定制内核 kernel/sys.c 的 setresuid hook,以 UID 1337 获得内核级能力,绕过 SELinux 对属性文件写入的限制。
③ shadow 属性重定向
setresuid(1337,1337,1342) 启用内核 fs/namei.c 的 shadow 属性重定向:把 /dev/__properties__/xxx 的读取重定向到 /dev/__properties__/xxx.real(setup_stealth 先备份原属性为 .real),实现"读真值、写假值"分离——壳读到的永远是伪装值,系统内部用真实值。
④ 完整属性伪装集(20+ 个)
精确伪装成 Pixel 1 (marlin) Android 10 的出厂指纹,覆盖 root/调试/bootloader 全部检测面。
⑤ Zygote 重启(清 Java 层属性缓存)
setprop ctl.restart zygote 重启 Zygote,让 Java 层 SystemProperties 缓存重新读取伪装后的属性——否则 Java 与 Native 层属性不一致会被壳识破。
配套关系:setup_stealth 是集成执行器;其属性覆写核心逻辑在 resetprop_ult.c(独立版单工具)中复用;fake_build_prop.h 定义内核态 /proc/fake_build_prop 的伪装内容(配合内核 namei.c 的 build.prop 重定向)。
执行依赖(设备侧):/data/local/tmp/setup_stealth(属性伪装二进制)、/data/local/tmp/send_cmd(HWBP 控制)、内核侧 hwbp_ghost.ko(已加载)。
涉及文件:setup_stealth.c、resetprop_ult.c、fake_build_prop.h、hide_root.c
壳检测 /system/xbin/su 存在即自杀退出(com.secneo.apkwrapper.H)。
su 的隐藏用的是 nsenter 进 Zygote 挂载命名空间,拿空 tmpfs 盖住 /system/xbin,App 就看不到 su 了。
涉及文件:setup_stealth.c、hide_root.c
注入靠 ptrace_inject32.c 做 ARM64 ptrace 注入。App 第一次启动基本都会因为内部逻辑/网络初始化闪退(Bad file descriptor),采用双击启动策略:
【真机注入 + App 稳定运行(logcat 实测)】
关键点:Displayed targetMainActivity: +641ms —— 改名 libanalytics.so 后 0x79c 不再触发,App 稳定进入主界面。
涉及文件:ptrace_inject32.c、start_demo_target.sh、ghost_cold_sniper.c
libDexHelper.so 是某企业版反篡改核心(arm64 版 1MB、32 函数)。IDA 实证其与 libdexjni_target.so 是同一壳框架(相同 273/70/261/21 状态机混淆、相同 RC4 引擎、相同 libc 跳板表):
四重检测面(IDA 交叉确认):
对抗演进:
对抗原理:
结论:libDexHelper 的静态 patch 成本极高(多层状态机混淆 + 自校验 + 自解密),对抗重点在运行时注入规避而非修改其代码——通过"属性一致性 + 名称规避 + 匿名执行"组合拳完整绕过。
用户态反调试绕不过去,干脆搞了内核硬件断点(HWBP)驱动:
【内核模块加载日志(GhostHWBP,dmesg 实测)】
关键点:驱动经 /proc/hwbp_cmd 控制,对 App 所有线程统一布防;reg_offset=0x145404 是 libart.so 中 RegisterNatives 符号偏移(由 hwbp_resolver 用 readelf 解析符号表得到)。
涉及文件:hwbp_ghost.c、hwbp_resolver.c、hwbp_ghost.ko
某企业版壳把真实 DEX 解密后加载到匿名内存。脱壳 = 找到解密后的 DEX 段并 dump。核心思路是 Zero-Break 读取:不 attach、不 ptrace、不动调试寄存器,仅通过 /proc/<PID>/mem + pread 带外读取,避免触发壳的调试检测。
关键技术(dex_dumper 演进):
难点与关键点:
解密时机:Bangcle DEX 解密需 8~12 秒(libDexHelper 加载后),必须在解密完成瞬间 dump,过早抓不到、过晚内存被复用。配套 launch_and_dump.sh 等待 libDexHelper 加载完成(grep libDexHelper /proc/PID/maps)+ 12s 后立即 dump。
双击启动:App 首次启动必然闪退(壳初始化检测),第二次启动才正常解密。launch_and_dump.sh 实现 LAUNCH#1(预期崩溃)→ 等 PID 重启 → LAUNCH#2(正常解密)→ 12s 后 dump。
SELinux:dump 前必须 setenforce 0,否则 /proc/PID/mem 读取被拒。
magic 被抹:Bangcle 可能抹掉 DEX magic 字节,v3 用 header 签名(header_size=0x70 + endian_tag=0x12345678)识别并恢复 magic。
多段:DEX 可能分散在多个匿名段(含 ashmem/memfd),v2 三层策略确保全捕获,再合并。
checksum/签名修复:dump 出的 DEX 头部被壳篡改,fix_dex_checksum_full.py 重算后才能被 jadx 识别。修复偏移(DEX 头布局):
核心工具:
【dumped_dex 产物(真机 dump + checksum 修复后)】
反编译产出:payload_2_full_fixed.dex → 2070 类(主业务);payload_6_fixed.dex → 198 类(加密/HTTP 核心)。
完整还原了 App 的加密体系:
注:mic(MAC) 消息认证码的生成/校验算法本次未深入——它属请求完整性保护,独立于 #239/#238 的加密链路。MAC 缺失不影响 AES 加解密的算法复现,但构造真实请求时需另行还原(常见 HMAC-SHA256,本文未覆盖)。
注:RSA 用于加密 AES Key 传给服务端(非对称密钥交换)。本文未深入 RSA 公钥的来源与存放——若公钥硬编码在 Java 层则易被替换,若由 Native 白盒保护则更安全;此边界本次未评估。
EncryptWithECB 是里面唯一纯 Java 不走 JNI 的方法(AES/ECB/PKCS5Padding),可以直接 Hook;其余核心加密全在 JNI 派发里(#232~#247)。
服务端信息:
libdexjni_target.so 98% 内容为加密数据:
【sub_13F8 / sub_1184 / sub_CD0 关键逻辑(IDA 反编译摘要)】
标准 RC4(256 字节 S-box + 16 字节 key + PRGA XOR 写回),每次调用解密 ≤0x40 字节分块执行。关键参数:
调用链:
RC4 key 是顺着 xrefs 追 sub_AC0(pread)的 offset 参数找到的,key 就在文件偏移 0x2E1E00:
【0x2E1E00 处 key hexdump(原始 so 磁盘实测)】
pread(fd, &qword_13050, 0x14, 0x2E1E00) 读出的前 16 字节即 key,与 qword_13068 完全一致。
key = e0670dd6...
在 sub_13F8 中发现壳实现了独立的 ELF 重定位解析器:
解密产物:libdexjni_decrypted_dump.so(3.1MB,内存 dump 版)含 80,708 个明文字符串 与全套重定位数据;
与 7.7 的 payload_clean_full.so(9185 个 IDA 已识别字符串)是不同文件,统计口径不同。
最初尝试在 unidbg 中手动执行:
问题:sub_CD0 运行后自动注册的 Natives 字典: {}(空)、FULL_HEAD_HEX 全 0——JNI_OnLoad 从未真正执行。
原因:手动拆分调用链绕过了壳的完整流程。正确做法是让 sub_13F8 完整跑完,再触发 JNI_OnLoad。
运行 sub_13F8 时遇到未处理系统调用:
这是壳的自定义 mmap2 系统调用(反模拟检查)。 测试代码中的 SecneoUnpacker.java,注册该 svc 即可让 sub_13F8 继续执行:
注册后 sub_13F8 完整执行并返回!mmap 分配出解密目标段。
【svc 63505 注册 + sub_13F8 执行日志(unidbg 实测)】
关键点:sub_13F8 内部经自定义 mmap2(svc 63505)分配 seg0/seg1 两个 RWX 段,解密产物写在这里(不是 module.base+0x13090)——这是此前静态 dump 基址错误的根因。
payload 每次运行 mmap 地址都是随机的(0x12963000/0x12a47000 这种),硬编码不行,只能从 memoryMap 里动态找:
"静态分析 dump"基址错误的根因。
payload 使用定制 JNIEnv 布局,与 unidbg 标准表不同:
【JNIEnv 表 patch 代码(DemotargetUnpack_final.java 实测)】
配套:svcMap.put(63505, ...) 注册自定义 mmap2(见 7.2)、0x101A8 改分支跳主状态机、0x90F8 canary NOP。
payload 的 SDK 版本检查使用 ADRP 跳转表 dispatch,但 ADRP 重定位缺失(sub_13F8 只重定位 GOT 数据,没修 ADRP 页偏移),导致跳转表全 0 → BR 到错误地址。
状态机靠改两条指令绕过去:
同时修复状态机跳转表基址(X23 = seg0 + 0x9120)。
经过上述修正,JNI_OnLoad 完整执行,10 个 JNI 方法全部注册:
【RegisterNatives 全部注册日志(unidbg 实测)】
10 个方法全部注册成功,cL 入口在 RWX@0x12973690。
DemotargetDumpFull.java 从正确基址连续 dump,产出 payload_clean_full.so:
IDA 打开验证:103 函数、9185 字符串、entry 0x2D80,cL 函数体可反编译。
【payload_clean_full.so 全貌(IDA survey 实测)】
IDA 中 cL 函数体可正常反编译(103 个已识别函数之一),证明脱壳彻底、无残留加密。
注册成功后,构造 Object[] 参数调用 cL:
【cL(#239) 执行 JNI 日志(unidbg 实测关键行)】
完整链:239 → KeyGenerator.getInstance("AES") + init(128) + generateKey().getEncoded(),即 AES-128。
unidbg 完整记录了 cL 内部执行序列:
结论:generateAESKey(#239) = 标准 KeyGenerator.getInstance("AES") + init(128) + generateKey().getEncoded(),即 AES-128 随机密钥。
运行结果:
注:真实环境为 SecureRandom 随机值。unidbg 中通过 AbstractJni mock 了 KeyGenerator/String.intern/Proxy.isProxyClass 等 unidbg 不支持的方法。
cL 函数体调用 loc_10950——一个 VMP 数据混淆型字节码解释器:
【VMP 16-opcode 跳转表反汇编(payload_clean_full.so @0x10950)】
关键点:16 个 opcode 统一模式 byte[X25+addr] ^= key,addr/key 由 W28 混淆算术派生——VMP 保护的是 cL 参数解析与 methodID 分发(Instruction Stealing),非 AES 算法本身。
关键结论:VMP 保护的是 cL 的参数解析与 methodID 分发(Instruction Stealing 型),非 AES 算法本身。算法 = 标准 KeyGenerator。
16 opcode 语义表(IDA 实测反汇编归纳):
W28 状态链符号化(4 类更新原语):ADD/SUB(仿射,op2/3/7/11/12/14)、EOR(仿射,op5/6/7/12)、LSL/AND(位混淆,op4/8/6)、MUL/MADD(非线性,op0/4/13)。addr/key 均 = f(W28,W19,const)。
opcode 10 详解(全解释器核心,JNI 方法调用指令):
语义:opcode 10 = methodID 查表(qword_2E2580) → JNIEnv 槽位调用 → 按返回类型(12 case)写 VM 寄存器文件。cL(#239) 的 KeyGenerator.getInstance("AES") 等 JNI 调用完成。
AESUtil.aesEncry(byte[] bArr, String str, String str2) 经 JniLibtarget.cL(AESUtil.class, bArr, str, str2, 238) 进入 Native。算法链已由四路证据交叉锁定:
结论:aesEncry(#238) = Cipher.getInstance("AES/CBC/PKCS5Padding") + SecretKeySpec(key) + IvParameterSpec(str2) + doFinal(明文),返回 Base64。与 #237 aesDncode 构成加解密配对;#239 generateAESKey 提供 16 字节 key。
| 环境 |
作用 |
| SysTrace 定制 ROM(真机) |
反检测对抗、真机 hook、DEX 内存 dump |
| unidbg(宿主机) |
Native loader 模拟执行、全自动脱壳、算法复现 |
| 加固/安全库 |
作用 |
libDexHelper.so |
某企业版 反篡改核心(扫描 maps + checksum) |
libdexjni_target.so |
某企业版 加密 loader(本文主角) |
libsmkernel.so / libbangcle_risk.so / libnllvm1623735669.so |
某 加固内核 / 风险检测 / LLVM 混淆(同属某体系) |
libantitrace.so |
反跟踪(ptrace 检测等) |
libtongdun.so |
第三方风控 |
libHKESipCryptor.so / libSwipeLockCryptor.so / libSipCryptor.so / libcfcaMLog.so |
某商业级专用加密/CFCA 国密 |
| # |
检测机制 |
证据 |
后果 |
| 1 |
环境检测 |
ro.debuggable=1、su 存在、USB 调试(Bangbang/Secneo wrapper) |
finish() 闪退 |
| 2 |
maps 扫描 |
WeexJSBridgeThr 线程例行扫 /proc/self/maps 找未授权库 |
0x79c 空指针自杀 |
| 3 |
可执行段 checksum |
对 libc.so 等可执行映射做完整性校验 |
篡改即 0x79c |
| 4 |
第三方风控 SDK 检测 |
环境/注入检测 |
|
| 类 |
关键方法 |
JNI ID |
AESUtil |
generateAESKey() |
#239 |
AESUtil |
Encrypt/Decrypt/aesDncode/aesEncry |
#234/#232/#237/#238 |
EncryptUtil |
Decrypts/EncryptUtil/Encrypts |
#243~#246 |
EncryptUtil |
EncryptsGM(国密) |
#247 |
RsaUtil |
RSA/ECB/PKCS1Padding |
常量 |
| 函数 |
作用 |
sub_13F8 |
主加载器(自定义 Dynamic Linker) |
sub_1184 |
RC4 解密引擎 |
sub_CD0 |
JNI_OnLoad 查找与调用 |
| 问题 |
表现 |
修复 |
[0x30] 入口检查 |
payload 期望返回 0 的成功检查 |
code hook 在 0x2E00 强制 W0=0 |
[0x480] GetStaticFieldID |
SDK_INT 查询失败 |
反射改 impl 表返回 23 |
[0x4B0] GetStaticIntField |
SDK 值获取失败 |
同上返回 23 |
| opcode |
地址 |
操作模式 |
next |
含svc |
| 0 |
0x109EC |
纯状态更新(无 X25 读写) |
0xC |
否 |
| 1 |
0x109D0 |
1 次 X25 字节 XOR |
0xC |
否 |
| 2 |
0x10A30 |
7 次 X25 字节 XOR |
0xD |
否 |
| 3 |
0x10AC8 |
4 次 X25 字节 XOR |
0xB |
否 |
| 4 |
0x10B44 |
3 次 X25 字节 XOR |
重分发 |
否 |
| 5 |
0x10BAC |
条件分支(W28 更新) |
1 |
否 |
| 6 |
0x10C0C |
纯状态更新 |
1 |
否 |
| 7 |
0x10C2C |
4 次 XOR + svc 外部调用 |
6 |
是 |
| 8 |
0x10CE0 |
5 次 X25 字节 XOR |
0x95? |
否 |
| 9 |
0x10D90 |
操作 X29 栈区(寄存器文件) |
? |
否 |
| 10 |
0x10DC8 |
JNI 方法调用指令(160 条,见下) |
9 |
否 |
| 11 |
0x11048 |
5 次 X25 字节 XOR |
0xE |
否 |
| 12 |
0x110E8 |
X25 字节 XOR |
7 |
否 |
| 13 |
0x1116C |
3 次 XOR |
3 |
否 |
| 14 |
0x111D0 |
6 次 X25 字节 XOR(addr/key 均为 W28 仿射函数) |
8 |
否 |
| 15 |
0x1127C |
结束指令:哨兵校验 → 弹栈 RET(失败走 sub_2C80) |
— |
否 |
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 3天前
被kudos5566编辑
,原因: