新版梆梆检测绕过
0x00 前言
- 其实本来应该连dex解密流程一块讲的,但是上周一直在加班,没有时间写完,所以拆成两篇来写吧,这篇先只讲一下如何绕过frida检测,祈祷我下周不要再加班了
0x01 linker mini版
入手
IDA打开发现导入表空空如也,无需多言,先dump修复一份so,IDA打开之后发现只有寥寥数个函数,并且注册了constructor函数,那只好先去入口处看看了
__int64 sub_4780(){ _DWORD *j; // x2 void *libc_handle; // x0 int i; // [xsp-60h] [xbp-90h] int k; // [xsp-14h] [xbp-44h]
for ( i = 273; i != 70; i = 70 ) ; for ( j = (_DWORD *)((unsigned __int64)sub_4780 & 0xFFFFFFFFFFFFF000LL); *j != 0x464C457F; j -= 1024 )// ELF魔数 ; shell_base = (__int64)j; libc_handle = dlopen("libc.so", 2); g_scanf = (__int64 (*)(_QWORD, const char *, ...))dlsym(libc_handle, "sscanf"); for ( k = 273; k != 70; k = 70 ) ; dword_15040 = dword_15008; // 0x8000 dword_15064 = dword_1500C; // 0x1608 dword_15078 = dword_15010; // 0x877 dword_15030 = dword_15014; // 0x159000 dword_15088 = dword_15018; // 0x15B000 dword_15084 = dword_1501C; // 0x15C000 dword_1507C = dword_15020; // 0x1181DD return sub_33F8(); // 主加载流程}这是入口函数,上来就会被调用,其中0x464C457F是ELF魔数,这段代码在从自身页面开始往上扫描,直到扫描到ELF魔数,从而查找自身的基地址,然后拿到sscanf的地址,存到全局变量中。我也不晓得这一通操作意欲何为。前一个还可以理解,这么做会比较节省时间,至于sscanf,直接拿来用不就行了吗,他这里还查找这个符号然后保存有点没理解,有懂的大牛子可以解释一下
然后就是保存一些全局变量,都是写死的偏移或者秘钥信息,比较重要,后续解密时会用到。最后就是这个壳so最重要的函数了sub_33F8()
定位
建议如果要分析的话,先看看这个简单的项目吧,因为项目里so加载流程和这个壳不能说100%一样,但是也有90%是一样的 Yuuki/soLoader
这个函数有900多行,不算很复杂,而且核心函数一目了然。它调用了 mmap,dlopen,mprotect等函数,结合之前发现的,1.36M的so,反编译之后就几个函数,其他地方都是被加密的,那不难猜到这个函数的作用就是在运行时动态解密释放真正的so,然后真正的so再去对dex做解密等操作。如果是简单的upx壳,那么大概率不会使用到dlopen,因为upx只需要申请内存,解密payload即可,但是这里它调用了dlopen,还是在一个循环中调用的
do { while ( *v76 != 1 ) { v76 += 2; if ( v76 == v91 ) goto LABEL_186; } v92 = v76[1]; v76 += 2; *(&v123 + (int)v90++) = (__int64)dlopen((const char *)(v118 + v92), 2); }while ( v76 != v91 );这很明显就是在处理DT_NEEDED条目了,那么这个函数,基本上可以初步判断为一个简易的linker,职责是加载真正的so
那么我们当下的直接目标就是解密出真正的so,所以先来简单分析一下这个函数吧~,我只截取关键的部分,感兴趣的可以自己从头看看
v5 = fopen("/proc/self/maps", "r"); memset(v143, 0, 0x400u); v123 = 0; v122 = 0; memset(v142, 0, 0x100u); while ( fgets((char *)v143, 1024, v5) ) // 解析maps,根据shell_base判断是否为so自身,取出so路径 { g_scanf(v143, "%lx-%lx %s %s %s %s %s", &v122, &v123, v142, v142, v142, v142, so_path); if ( v122 <= shell_base && shell_base < v123 ) break; } fclose(v5); v102 = so_path; so_file_handle = open((const char *)so_path, 0x80000);这里反编译之后跟看源码没啥区别。先解析maps,根据上面拿到的 so 基地址来判断当前条目是否命中,命中取出 so 路径,为了拿到libDexHelper.so的路径。这里这么做是因为android加载so时,会从 /data/app/一串神秘代码/libs 下加载,而厂商的代码是写死的,他不知道这个神秘代码是啥就拿不到文件的磁盘路径,所以需要这样获取一下。当然我觉得直接在内存里操作也是可以的,不过这个看个人喜好了
拿到路径之后 ,它做了如下三件事情
pread(so_file_handle, &qword_15050, 0x14u, (unsigned int)dword_1507C);// 从0x1181DD读20字节dword_15080 = dword_15060;memset(so_path, 0, 64);qword_15068 = qword_15050;qword_15070 = qword_15058;pread(so_file_handle, so_path, 0x40u, (unsigned int)dword_15040);// 从0x8000读64字节((void (__fastcall *)(_QWORD *, _QWORD))sub_3184)(so_path, (unsigned int)dword_15040);// RC4解密64字节 7F454C4602010... 这是ELF头读了磁盘上的so的特定偏移的一些数据,这些偏移地址是写死的,上述初始化函数里提到过。其中 sub_3184是一个变种的RC4算法,秘钥用的是qword_15068,qword_15068来自 从0x1181DD读的20字节,所以这一块的逻辑就是,读取秘钥,读取要解密的64字节,调用解密函数,解密
解密出的数据如下:
7F454C460201010000000000000000000300B70001000000B0F8000000000000400000000000000000F71000000000000000000040003800080040001A001900
这完全验证了我们之前的猜想,这是一个标准的ELF头,所以这个函数的确是一个迷你的linker,并且我们已经拿到了ELF头的解密算法,接下来就是找剩下的数据是如何被加密的了
v36 = v35;v111 = v35;v37 = (unsigned __int8 *)mmap((void *)((v32 + v110) & 0xFFFFFFFFFFFFF000LL), v35, 3, 50, 0, 0);// 对每个 `PT_LOAD` 段做映射/拷贝v38 = v37;if ( v37 == (unsigned __int8 *)-1LL ) break;v39 = HIDWORD(v113);if ( lseek(SHIDWORD(v113), (unsigned int)v107 & 0xFFFFF000, 0) == -1 ) break;v112 = read(v39, v37, v36); // 读取每个段内容if ( (_DWORD)v112 == -1 ) break;if ( v28 != 1 ) { ((void (__fastcall *)(unsigned __int8 *, _QWORD, _QWORD))sub_2B1C)(v37, 0, (unsigned int)v112);// 循环调用xor解密函数,解密各个段 goto LABEL_124;}v40 = v133;v131 = 0;v132 = 30;v133[0] = 273;关键解密函数是sub_2B1C,这就是一个简单的xor加解密
char *__fastcall sub_2B1C(__int64 a1, int a2, int a3){ char *result; // x0 char *v4; // x3 char v5; // t1 int i; // [xsp-34h] [xbp-44h]
for ( i = 273; i != 70; i = 70 ) ; result = (char *)(a1 + a2); v4 = result; if ( a3 ) { do { v5 = *v4++; *(v4 - 1) = dword_15080 ^ v5; // xor 0x41 } while ( a3 > (unsigned __int64)(unsigned int)((_DWORD)v4 - (_DWORD)result) ); } return result;}秘钥是0x41,之前读20字节的时候读出来的,那么就可以静态解密出真正的so了。当然你也可以运行时动态dump,我看见有的朋友是直接在dlopen onLeave的时候扫描匿名可执行内存,然后直接dump的,这样也可以的,并且不用分析壳so的逻辑,建议大家这么做
分析到这里就可以解密出主so了,但是本着来都来的的心态,还是看看后面它做了哪些操作吧
v21 = (void *)(v12 & 0xFFFFF000);v22 = ((v17 + 4095) & 0xFFFFF000) - (unsigned int)v21;real_base = mmap(v21, v22, 3, 34, -1, 0); // 分配匿名内存,用于映射真正的soif ( mprotect(v38, v111, (*(_DWORD *)v29 >> 2) & 1 | *(_DWORD *)v29 & 2 | (4 * (*(_DWORD *)v29 & 1))) == -1 )// 恢复各个段的权限goto LABEL_131;if ( (*(_DWORD *)v29 & 2) != 0 ) break;v67 = (*(_DWORD *)v29 >> 2) & 1 | 2;v68 = (*(_DWORD *)v29 & 1) != 0 ? 4 : 0;v29 += 56LL;mprotect(v38, v111, v67 | v68);v30 = v25 == ++v27;((void (__fastcall *)(unsigned __int64, __int64, __int64, _QWORD, __int64 *, _QWORD, __int64))sub_2CD0)(// 一种重定位 v119, v118, v123, (unsigned int)v124, &v123, v90, v117); ((void (__fastcall *)(unsigned __int64, __int64, __int64, _QWORD, __int64 *, _QWORD, __int64))sub_2CD0)(// 另一种重定位 v93, v94, v129, (unsigned int)len, &v123, v90, v95); v57 = ((__int64 (__fastcall *)(unsigned __int64, const void *, _QWORD, __int64, __int64))sub_303C)(// 查找JNI_OnLoad v107, v106, (unsigned int)v115, v109, v110); if ( v80 != (void (__fastcall *)(__int64))-1LL && v80 ) v80(v57); for ( i = 0; (unsigned int)v74 > (unsigned int)i; ++i )// 调内层so的.init_array { v59 = *(void (**)(void))(v77 + 8 * i); if ( v59 != (void (*)(void))-1LL && v59 ) v59(); } memcpy((void *)(shell_base + (unsigned int)dword_15084), (const void *)v116, v63);// 把内层 SysV hash 拷到外层 `0x15C000` memcpy(v64, v62, (unsigned int)dword_15064);// 把内层 dynsym 拷到外层 `0x159000` memcpy(v65, v61, (unsigned int)dword_15078);// 把内层 dynstr 拷到外层 `0x15B000` close(SHIDWORD(v105));总结下来就是:
- 从 /proc/self/maps 定位当前 so 的真实文件路径
- 打开自身文件
- 读取尾部密钥材料
- 读取并解密内层 ELF 头
- 映射并解密包含 Program Header 的第一页
- 计算内层镜像范围并申请最终地址空间
- 逐段读取、XOR 解密、装载 PT_LOAD,完成 BSS
- 解析 PT_DYNAMIC
- dlopen 依赖库
- 用自定义解析器做重定位
- 定位内层 JNI_OnLoad
- 调用 DT_INIT 和 INIT_ARRAY
- 把内层 hash/dynsym/dynstr 回填到外层壳的预留区
- 返回,等待外部以后通过外层库视图解析到内层符号
感觉和我之前写的差不多,但是我把加密的步骤前置了 so加固玩具版
主so解密
from __future__ import annotations
import argparsefrom pathlib import Pathimport sys
PAYLOAD_OFFSET = 0x8000TAIL_OFFSET = 0x1181DDTAIL_SIZE = 0x14HEADER_SIZE = 0x40STREAM_KEY_SIZE = 16
def rc4_like_decrypt_header(enc_header: bytes, stream_key: bytes) -> bytes: if len(enc_header) != HEADER_SIZE: raise ValueError(f"expected {HEADER_SIZE:#x}-byte header, got {len(enc_header):#x}") if len(stream_key) != STREAM_KEY_SIZE: raise ValueError(f"expected {STREAM_KEY_SIZE} key bytes, got {len(stream_key)}")
sbox = list(range(256)) j = 0 key_index = 0
for i in range(256): j = (j + sbox[i] + stream_key[key_index]) & 0xFF sbox[i], sbox[j] = sbox[j], sbox[i] key_index = (key_index + 1) % STREAM_KEY_SIZE
out = bytearray(enc_header) i = 0 j = 0 for pos in range(len(out)): i = (i + 1) & 0xFF t = sbox[i] j = (j + t) & 0xFF sbox[i], sbox[j] = sbox[j], t out[pos] ^= sbox[(sbox[i] + t) & 0xFF]
return bytes(out)
def decrypt_libdexhelper_outer(outer_data: bytes) -> tuple[bytes, int]: min_size = TAIL_OFFSET + TAIL_SIZE if len(outer_data) < min_size: raise ValueError( f"file too small: need at least {min_size:#x} bytes, got {len(outer_data):#x}" )
tail = outer_data[TAIL_OFFSET : TAIL_OFFSET + TAIL_SIZE] stream_key = tail[:STREAM_KEY_SIZE] body_xor_key = tail[STREAM_KEY_SIZE]
payload = bytearray(outer_data[PAYLOAD_OFFSET:TAIL_OFFSET]) if len(payload) < HEADER_SIZE: raise ValueError("embedded payload is smaller than the encrypted header")
# The inner ELF body is XOR-encrypted with a single-byte key. for idx in range(HEADER_SIZE, len(payload)): payload[idx] ^= body_xor_key
# The first 0x40 bytes are encrypted separately with the RC4-like routine. payload[:HEADER_SIZE] = rc4_like_decrypt_header(payload[:HEADER_SIZE], stream_key)
return bytes(payload), body_xor_key
def build_default_output_path(input_path: Path) -> Path: if input_path.suffix: return input_path.with_name(f"{input_path.stem}.hidden.decrypted{input_path.suffix}") return input_path.with_name(f"{input_path.name}.hidden.decrypted.so")
def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Decrypt the hidden inner ELF from this protected libDexHelper.so sample." ) parser.add_argument("input", type=Path, help="Path to the encrypted outer libDexHelper.so") parser.add_argument( "-o", "--output", type=Path, help="Output path for the decrypted inner ELF. Defaults next to the input file.", ) return parser.parse_args(argv)
def main(argv: list[str]) -> int: args = parse_args(argv) input_path: Path = args.input output_path: Path = args.output or build_default_output_path(input_path)
outer_data = input_path.read_bytes() inner_data, xor_key = decrypt_libdexhelper_outer(outer_data)
if inner_data[:4] != b"\x7fELF": raise RuntimeError( "decryption completed, but the result does not start with ELF magic; " "double-check the sample matches this script." )
output_path.write_bytes(inner_data)
print(f"input : {input_path}") print(f"output: {output_path}") print(f"tail offset : {TAIL_OFFSET:#x}") print(f"payload offset: {PAYLOAD_OFFSET:#x}") print(f"body xor key : {xor_key:#x}") print(f"payload size : {len(inner_data):#x}") print(f"elf magic : {inner_data[:4].hex()}")
return 0
if __name__ == "__main__": try: raise SystemExit(main(sys.argv[1:])) except Exception as exc: print(f"error: {exc}", file=sys.stderr) raise SystemExit(1)运行一下,就要开始分析主so了捏
铺垫
我们后面在hook主so里的函数时需要使用主so的基地址+函数偏移,但是主so是被映射到它自己mmap的匿名内存上的,这里有很多方案,我们选择相对优雅的一种
之前分析出了
real_base = mmap(v21, v22, 3, 34, -1, 0); // 分配匿名内存,用于映射真正的so我们直接hook mmap,根据后面四个参数值进行过滤即可,如果噪音过多,可以根据映射大小进一步过滤。过滤完可以看到只这样的
[+] inner base = 0x7c01620000[+] mmap args = addr=0x0 len=0x134000 prot=0x3 flags=0x22 fd=-1 off=0x0 ra=0x7c00c21888var target_so = "libDexHelper.so";var linker_name = Process.pointerSize === 8 ? "linker64" : "linker";var call_constructors_offset = 0xb4900; // oriole / Pixel 6
var target_seen = false;var found = false;var mmap_hooked = false;
var outer_base = null;var outer_size = 0;
function hook_mmap() { if (mmap_hooked) return;
mmap_hooked = true; Interceptor.attach(Module.findExportByName("libc.so", "mmap"), { onEnter: function (args) { this.addr = args[0]; this.len = args[1]; this.prot = args[2].toInt32(); this.flags = args[3].toInt32(); this.fd = args[4].toInt32(); this.off = args[5].toUInt32(); this.ra = this.returnAddress; }, onLeave: function (retval) { if (found || retval.isNull() || outer_base == null) return;
let from_outer = this.ra.compare(outer_base) >= 0 && this.ra.compare(outer_base.add(outer_size)) < 0;
if ( from_outer && this.prot === 3 && this.flags === 0x22 && this.fd === -1 && this.off === 0 ) { found = true; console.log("[+] inner base =", retval); console.log( "[+] mmap args =", "addr=" + this.addr, "len=" + this.len, "prot=0x" + this.prot.toString(16), "flags=0x" + this.flags.toString(16), "fd=" + this.fd, "off=0x" + this.off.toString(16), "ra=" + this.ra ); } } });}
function hook_linker_call_constructors() { if (hook_linker_call_constructors.hooked) return; hook_linker_call_constructors.hooked = true;
let linker64_base_addr = Module.getBaseAddress(linker_name); if (!linker64_base_addr) return;
let call_constructors = linker64_base_addr.add(call_constructors_offset); Interceptor.attach(call_constructors, { onEnter: function (args) { if (!target_seen || mmap_hooked || found) return;
let secmodule = Process.findModuleByName(target_so); if (secmodule == null) return;
outer_base = secmodule.base; outer_size = secmodule.size; hook_mmap(); } });}
function hook_dlopen() { Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), { onEnter: function (args) { if (found) return;
this.fileName = args[0].isNull() ? "" : args[0].readCString(); if (this.fileName && this.fileName.indexOf(target_so) >= 0) { target_seen = true; hook_linker_call_constructors(); } } });}
hook_dlopen();如此我们便可以知道映射大小=len=0x134000的时候的返回值是我们要的,我们就可以写出更简洁的脚本
var found = false;
Interceptor.attach(Module.findExportByName("libc.so", "mmap"), { onEnter: function (args) { this.len = args[1].toUInt32(); }, onLeave: function (retval) { if (found || retval.isNull()) return;
if (this.len === 0x134000) { found = true; console.log("[+] inner base =", retval); } }});0x02 正文

蓝蓝一片非常之爽,.init_array里没有注册函数,但是JNI_OnLoad反编译之后居然有3000行,那暂时先不看了
先来明确一下目标吧
- 过frida检测
- 静态解密dex完成脱壳
到现在为止还没咋使用到frida,但是不用想,附加上去启动app包是会崩溃的。目前已知所有重要逻辑入口都在JNI_OnLoad,那直接来trace一下函数调用链吧
0x02.1 frida检测绕过
初步绕过
这里使用到oacia大佬的stalker_trace_so,建议大家优先使用这个工具,他可以记录所有IDA识别出来并且被主线程执行到的函数,帮我们快速定位大致范围
需要注意的是生成的脚本在trace时基地址要稍作修改,因为它的主so是动态释放的
var so_name = "libDexHelper.so";var inner_base = null;var trace_started = false;var found = false;var mmap_listener = null;
/* @param print_stack: Whether printing stack info, default is false.*/var print_stack = false;
/* @param print_stack_mode - FUZZY: print as much stack info as possible - ACCURATE: print stack info as accurately as possible - MANUAL: if printing the stack info in an error and causes exit, use this option to manually print the address*/var print_stack_mode = "FUZZY";
function addr_in_so(addr){ var process_Obj_Module_Arr = Process.enumerateModules(); for(var i = 0; i < process_Obj_Module_Arr.length; i++) { if(addr>process_Obj_Module_Arr[i].base && addr<process_Obj_Module_Arr[i].base.add(process_Obj_Module_Arr[i].size)){ console.log(addr.toString(16),"is in",process_Obj_Module_Arr[i].name,"offset: 0x"+(addr-process_Obj_Module_Arr[i].base).toString(16)); } }}
function hook_dlopen() { Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), { onEnter: function (args) { var pathptr = args[0]; if (pathptr !== undefined && pathptr != null) { var path = ptr(pathptr).readCString(); //console.log(path); if (path.indexOf(so_name) >= 0) { this.is_can_hook = true; } } }, onLeave: function (retval) { if (this.is_can_hook && !trace_started) { console.log("[+] mmap base = " + inner_base); console.log("[+] use mmap base = " + inner_base);
// note: you can do any thing before or after stalker trace so. if (inner_base != null) { if (mmap_listener != null) { mmap_listener.detach(); mmap_listener = null; console.log("[+] mmap hook detached"); } trace_so(); } } } } );}
function hook_mmap() { mmap_listener = Interceptor.attach(Module.findExportByName("libc.so", "mmap"), { onEnter: function (args) { this.len = args[1].toUInt32(); }, onLeave: function (retval) { if (found || retval.isNull()) return;
if (this.len === 0x134000) { found = true; inner_base = ptr(retval.toString()); console.log("[+] inner base = " + inner_base); } } });}
function trace_so(){ if (inner_base == null || trace_started) return;
trace_started = true; var times = 1; var module_base = inner_base; var pid = Process.getCurrentThreadId(); console.log("start Stalker!"); Stalker.exclude({ "base": Process.getModuleByName("libc.so").base, "size": Process.getModuleByName("libc.so").size }) Stalker.follow(pid,{ events:{ call:false, ret:false, exec:false, block:false, compile:false }, onReceive:function(events){ }, transform: function (iterator) { var instruction = iterator.next(); do{ var offset = instruction.address.sub(module_base).toUInt32(); var index = func_addr.indexOf(offset); if (index !== -1) { console.log("call" + times + ":" + func_name[index]) times = times + 1 if (print_stack) { if (print_stack_mode === "FUZZY") { iterator.putCallout((context) => { console.log("backtrace:\n"+Thread.backtrace(context, Backtracer.FUZZY).map(DebugSymbol.fromAddress).join('\n')); console.log('---------------------') }); } else if (print_stack_mode === "ACCURATE") { iterator.putCallout((context) => { console.log("backtrace:\n"+Thread.backtrace(context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n')); console.log('---------------------') }) }
else if (print_stack_mode === "MANUAL") { iterator.putCallout((context) => { console.log("backtrace:") Thread.backtrace(context, Backtracer.FUZZY).map(addr_in_so); console.log('---------------------') }) } } } iterator.keep(); } while ((instruction = iterator.next()) !== null); },
onCallSummary:function(summary){
} }); console.log("Stalker end!");}
hook_mmap();setImmediate(hook_dlopen,0);只看最后几条日志
call107:sub_402CCcall108:sub_3ED48call109:regfreecall110:usleepcall111:dlopencall112:dlsymcall113:sub_AED04call114:sub_AE270call115:sub_AEA58call116:mmapcall117:sub_AF28Ccall118:sub_AF4C8call119:sub_AEE3Ccall120:statcall121:dlclosecall122:sub_42868call123:sub_1304C直接hook sub_1304C试试看,当然这样不严谨,但是可以快速看看有没有效果
Spawned `com.moutai.mall`. Resuming main thread![Pixel 6::com.moutai.mall ]-> [+] inner base = 0x7c00ce9000[+] mmap hook detached[+] patched sub_1304C @ 0x7c00cfc04c[+] dlopen hook detachedProcess terminated[Pixel 6::com.moutai.mall ]->依旧崩了,但是这次的崩溃相较上一次正常的多,判断为不是被同一处检测点检测到了,把hook sub_1304C的 逻辑加上,再trace一次
function rep_sub1304C() { if (inner_base == null || sub1304c_replaced) return;
var target = inner_base.add(0x1304c); console.log("[+] replacing sub_1304C @ " + target);
Interceptor.replace(target, new NativeCallback(function (a0, a1, a2) { console.log( "[+] skip sub_1304C(flag=" + a0.toString() + ", code=0x" + a1.toString(16) + ", mask=0x" + a2.toString(16) + ")" ); return 0; }, 'int64', ['int64', 'uint32', 'int']));
sub1304c_replaced = true;}这次的日志的最后几行
call188:sub_24F18call189:.strcasecmpcall190:sub_105B8call191:sub_FE78call192:sub_40B04call193:sub_3DBB4call194:sub_3DF50call195:sub_406E8call196:sub_41838call197:sub_408C4Process terminated[Pixel 6::com.moutai.mall ]->初步看了一下,上面几个没啥可疑的,估计就是检测到了然后正常退出,这里由于最后几条函数并不是检测函数和退出函数,所以这里大概率不是同步的检测。或者还有一种可能,假设函数B是检函数,函数A调用B,然后根据B的返回结果决定是否退出App。当前trace脚本只能记录“执行流进入了哪些函数入口”,但没有记录“函数返回到了哪里”以及“调用者在拿到返回值后又执行了什么分支逻辑”,所以只能看到A和B都执行过,却不一定能定位到 A 在函数内部哪一条指令上完成了退出。
进一步绕过
搞这里也是时间有点晚了,我也懒得跟他废话了,直接用内核模块监控一下哪里给我app干崩了(上文已经分析出此处的崩溃是正常的退出)
[43097.547378] [Yuuki] === kill_monitor: kill (PID:19581 UID:10282 com.moutai.mall) sig=9 ===[43097.547388] [Yuuki] [UID 10282] #00 pc = 0x7c007720a4 ???!0x190a4[43097.547390] [Yuuki] [UID 10282] #01 pc = 0x7d41443600 [anon:scudo:primary_reserve]!0xabf7600[43097.547392] [Yuuki] === end backtrace (2 frames) ===[43097.547394] [Yuuki][43097.548342] [Yuuki] === kill_monitor: kill (PID:19581 UID:10282 com.moutai.mall) sig=9 ===[43097.548346] [Yuuki] [UID 10282] #00 pc = 0x7c00772424 ???!0x19424[43097.548348] [Yuuki] [UID 10282] #01 pc = 0x7d41443600 [anon:scudo:primary_reserve]!0xabf7600[43097.548351] [Yuuki] === end backtrace (2 frames) ===[43097.548353] [Yuuki]一共两处,IDA跳过去看看


两处都是直接svc call __NR_kill函数直接退的,果然是像我们上面猜测的那样,我们直接给它们patch掉就行了
var target_so = "libDexHelper.so";var inner_base = null;var found = false;var patched = false;var callbacks = [];var mmap_listener = null;var dlopen_listener = null;
function patchArm64MovX0Zero(addr, tag) { Memory.patchCode(addr, 4, function (code) { var writer = new Arm64Writer(code, { pc: addr }); writer.putMovRegReg("x0", "xzr"); writer.flush(); }); console.log("[+] patched " + tag + " @ " + addr + " (mov x0, xzr)");}
function applyPatches() { if (patched || inner_base == null) return;
var sub1304c = inner_base.add(0x1304c); var cb = new NativeCallback(function () { return 0; }, "int", []);
callbacks.push(cb); Interceptor.replace(sub1304c, cb); console.log("[+] patched sub_1304C @ " + sub1304c);
patchArm64MovX0Zero(inner_base.add(0x190a0), "kill syscall #1"); patchArm64MovX0Zero(inner_base.add(0x19420), "kill syscall #2");
patched = true;}
mmap_listener = Interceptor.attach(Module.findExportByName("libc.so", "mmap"), { onEnter: function (args) { this.len = args[1].toUInt32(); }, onLeave: function (retval) { if (found || retval.isNull()) return;
if (this.len === 0x134000) { found = true; inner_base = ptr(retval.toString()); console.log("[+] inner base = " + inner_base); if (mmap_listener) { mmap_listener.detach(); mmap_listener = null; console.log("[+] mmap hook detached"); } } }});
dlopen_listener = Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), { onEnter: function (args) { this.fileName = args[0].isNull() ? "" : args[0].readCString(); }, onLeave: function (retval) { if (patched) return;
if (this.fileName && this.fileName.indexOf(target_so) >= 0 && inner_base != null) { applyPatches(); if (dlopen_listener) { dlopen_listener.detach(); dlopen_listener = null; console.log("[+] dlopen hook detached"); } } }});直接注入启动,app完美运行,美美得吃了也是
但是这种方式并不是很优雅,毕竟不是每个人都能用内核模块。值得注意的是上面两个退出点都在JNI_OnLoad函数中,这就很值得注意了,正常检测逻辑怎么会放在这个函数中呢?我觉得只有傻*才会这样写代码,所以这里JNI_OnLoad只是起到一个最终的决策作用。具体来说就是JNI_OnLoad里通过无论是新建线程还是直接同步调用函数的方式,去启动检测函数,然后不在检测函里执行退出机制,而是拿到检测函数的结果,集中在JNI_OnLoad里进行处理。当然这些只是我的猜测,至于为什么这么做,可能是因为JNI_OnLoad经过了混淆,开发者觉得大多数逆向人员不会去优先看这个函数。事实也的确如此
有的朋友通过hook clone那套 + 处理sub_1304C也能绕过检测,但是我感觉这样比较麻烦
更一般的方式
我们继续来优雅(暴力)的解决刚刚的问题吧
已知退出是正常的退出,只是通过svc直接call内核函数而已。这里可以有一些大众的处理方案
- 直接trace指令,注册svc的callback,打印内核函数名和参数就行了,直接就能定位到第一现场。这个我是尝试了一下,确实是可行的
- 直接IDA扫所有svc指令,反向污点确定x8寄存器的值,根据系统调用号(Android arm64 系统调用号是存在x8寄存器的)进行过滤,然后全都patch掉
这里我们直接选择方案二,因为它足够简单,直接写个IDA脚本,帮我们生成对应的frida patch脚本代码,这里我只处理了 exit, exit_group,kill,tkill,tgkill,其他的退出方式并没有做处理,抛砖引玉感兴趣的大佬可以自行拓展
import osimport re
import idautilsimport ida_bytesimport ida_funcsimport ida_kernwinimport ida_segmentimport idc
TARGET_SYSCALLS = { 93: "exit", 94: "exit_group", 129: "kill", 130: "tkill", 131: "tgkill",}BACKTRACK_LIMIT = 20PATCH_FILE_NAME = "termination_patch_snippet.txt"
def log(msg=""): print(msg)
def is_exec_seg(seg): if seg is None: return False return bool(seg.perm & ida_segment.SEGPERM_EXEC)
def iter_exec_heads(): for seg_ea in idautils.Segments(): seg = ida_segment.getseg(seg_ea) if not is_exec_seg(seg): continue ea = seg.start_ea while ea < seg.end_ea: flags = ida_bytes.get_flags(ea) if ida_bytes.is_code(flags): yield ea ea = idc.next_head(ea, seg.end_ea)
def op_text(ea, n): text = idc.print_operand(ea, n) return text.strip() if text else ""
def mnem(ea): return idc.print_insn_mnem(ea).lower()
def normalize_reg(text): return text.lower().strip()
def is_x8_reg(text): reg = normalize_reg(text) return reg in ("x8", "w8")
def parse_imm_and_shift(text): """ Parse operand text forms like: #0x81 #0xb6a2,LSL#16 #123 Returns (imm, shift_bits). """ t = text.lower().replace(" ", "") m = re.match(r"#(?P<imm>-?(?:0x[0-9a-f]+|\d+))(?:,lsl#(?P<shift>\d+))?$", t) if not m: return None imm = int(m.group("imm"), 0) shift = int(m.group("shift") or 0) return imm, shift
def writes_x8(ea): return is_x8_reg(op_text(ea, 0))
def decode_syscall_from_backtrack(svc_ea, limit=BACKTRACK_LIMIT): """ Heuristically reconstruct x8 value by scanning backwards.
Supports common forms: MOV X8, #imm MOVZ X8, #imm[, LSL#n] MOVK X8, #imm[, LSL#n] ORR X8, XZR, #imm
Returns: { "sysno": int or None, "writer_eas": [ea...], # instructions contributing to x8 } """ func = ida_funcs.get_func(svc_ea) if not func: return {"sysno": None, "writer_eas": []}
cur = idc.prev_head(svc_ea, func.start_ea) seen = 0 pieces = [] writer_eas = []
while cur != idc.BADADDR and cur >= func.start_ea and seen < limit: seen += 1 insn_mnem = mnem(cur)
if writes_x8(cur): writer_eas.append(cur)
if insn_mnem in ("mov", "movz"): parsed = parse_imm_and_shift(op_text(cur, 1)) if parsed is None: return {"sysno": None, "writer_eas": writer_eas} imm, shift = parsed value = imm << shift for part_shift, part_imm in pieces: mask = 0xFFFF << part_shift value = (value & ~mask) | ((part_imm & 0xFFFF) << part_shift) return {"sysno": value, "writer_eas": writer_eas}
if insn_mnem == "movk": parsed = parse_imm_and_shift(op_text(cur, 1)) if parsed is None: return {"sysno": None, "writer_eas": writer_eas} imm, shift = parsed pieces.append((shift, imm)) cur = idc.prev_head(cur, func.start_ea) continue
if insn_mnem == "orr": src1 = normalize_reg(op_text(cur, 1)) parsed = parse_imm_and_shift(op_text(cur, 2)) if src1 == "xzr" and parsed is not None: imm, shift = parsed value = imm << shift return {"sysno": value, "writer_eas": writer_eas} return {"sysno": None, "writer_eas": writer_eas}
return {"sysno": None, "writer_eas": writer_eas}
cur = idc.prev_head(cur, func.start_ea)
return {"sysno": None, "writer_eas": writer_eas}
def nearby_insns(ea, before=10, after=2): items = [] cur = ea for _ in range(before): cur = idc.prev_head(cur, idc.get_segm_start(cur)) if cur == idc.BADADDR: break count = before + after + 1 while cur != idc.BADADDR and count > 0: items.append((cur, idc.GetDisasm(cur))) nxt = idc.next_head(cur, idc.get_segm_end(cur)) if nxt == idc.BADADDR or nxt == cur: break cur = nxt count -= 1 return items
def make_patch_lines(matches): lines = [] lines.append("// auto-generated by search_kill.py") lines.append("function patchArm64MovX0Zero(addr, tag) {") lines.append(" Memory.patchCode(addr, 4, function (code) {") lines.append(" var writer = new Arm64Writer(code, { pc: addr });") lines.append(' writer.putMovRegReg("x0", "xzr");') lines.append(" writer.flush();") lines.append(" });") lines.append(' console.log("[+] patched " + tag + " @ " + addr + " (mov x0, xzr)");') lines.append("}") lines.append("") for idx, item in enumerate(matches, 1): tag = "%s syscall(%d) #%d @ 0x%x" % ( item["sys_name"], item["sysno"], idx, item["svc_ea"], ) lines.append("// %s (%d)" % (item["sys_name"], item["sysno"])) lines.append('patchArm64MovX0Zero(inner_base.add(0x%x), "%s");' % (item["svc_ea"], tag)) return "\n".join(lines)
def write_snippet_file(text): out_dir = os.path.dirname(idc.get_idb_path()) or os.getcwd() out_path = os.path.join(out_dir, PATCH_FILE_NAME) try: with open(out_path, "w", encoding="utf-8") as fp: fp.write(text) return out_path except Exception as exc: log("[!] failed to write snippet file: %s" % exc) return None
def main(): matches = []
for ea in iter_exec_heads(): if mnem(ea) != "svc": continue
info = decode_syscall_from_backtrack(ea) if info["sysno"] not in TARGET_SYSCALLS: continue
func = ida_funcs.get_func(ea) func_name = idc.get_func_name(ea) if func else "<no_func>"
matches.append({ "svc_ea": ea, "func_start": func.start_ea if func else idc.BADADDR, "func_name": func_name, "writer_eas": list(reversed(info["writer_eas"])), "sysno": info["sysno"], "sys_name": TARGET_SYSCALLS[info["sysno"]], "nearby": nearby_insns(ea), })
if not matches: msg = "[*] no target termination syscall svc sites found" log(msg) ida_kernwin.info(msg) return
matches.sort(key=lambda x: (x["sysno"], x["svc_ea"]))
log("[*] found %d target termination syscall site(s)" % len(matches)) log("")
for idx, item in enumerate(matches, 1): log("=" * 72) log("[%d] svc @ 0x%x func=%s @ 0x%x %s (sysno=%d / 0x%x)" % ( idx, item["svc_ea"], item["func_name"], item["func_start"], item["sys_name"], item["sysno"], item["sysno"], )) if item["writer_eas"]: log(" x8 writers:") for w in item["writer_eas"]: log(" 0x%x %s" % (w, idc.GetDisasm(w))) else: log(" x8 writers: <unresolved>") log(" nearby:") for ea, text in item["nearby"]: mark = ">>" if ea == item["svc_ea"] else " " log(" %s 0x%x %s" % (mark, ea, text)) log("")
snippet = make_patch_lines(matches) out_path = write_snippet_file(snippet)
log("=" * 72) log("[*] bypass.js snippet:") log(snippet) log("") if out_path: log("[*] snippet saved to: %s" % out_path)
ida_kernwin.info("search_kill.py finished, found %d target termination svc site(s)" % len(matches))
if __name__ == "__main__": main()加上我们之前对 sub_1304C的处理,就可以完美绕过啦~
完整代码如下:
var target_so = "libDexHelper.so";var inner_base = null;var found = false;var patched = false;var callbacks = [];var mmap_listener = null;var dlopen_listener = null;
function patchArm64MovX0Zero(addr, tag) { Memory.patchCode(addr, 4, function (code) { var writer = new Arm64Writer(code, { pc: addr }); writer.putMovRegReg("x0", "xzr"); writer.flush(); }); console.log("[+] patched " + tag + " @ " + addr + " (mov x0, xzr)");}
function applyPatches() { if (patched || inner_base == null) return;
var sub1304c = inner_base.add(0x1304c); var cb = new NativeCallback(function () { return 0; }, "int", []);
callbacks.push(cb); Interceptor.replace(sub1304c, cb); console.log("[+] patched sub_1304C @ " + sub1304c);
// 精准的patch只需这两处 // patchArm64MovX0Zero(inner_base.add(0x190a0), "kill syscall #1"); // patchArm64MovX0Zero(inner_base.add(0x19420), "kill syscall #2");
// kill (129) patchArm64MovX0Zero(inner_base.add(0x17558), "kill syscall(129) #1 @ 0x17558"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x17614), "kill syscall(129) #2 @ 0x17614"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x176ec), "kill syscall(129) #3 @ 0x176ec"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x17a7c), "kill syscall(129) #4 @ 0x17a7c"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x17af0), "kill syscall(129) #5 @ 0x17af0"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x190a0), "kill syscall(129) #6 @ 0x190a0"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x1925c), "kill syscall(129) #7 @ 0x1925c"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x19420), "kill syscall(129) #8 @ 0x19420"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x25fb0), "kill syscall(129) #9 @ 0x25fb0"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x4f3a0), "kill syscall(129) #10 @ 0x4f3a0"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x4f5d4), "kill syscall(129) #11 @ 0x4f5d4"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x4febc), "kill syscall(129) #12 @ 0x4febc"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x55368), "kill syscall(129) #13 @ 0x55368"); // kill (129) patchArm64MovX0Zero(inner_base.add(0x56588), "kill syscall(129) #14 @ 0x56588");
patched = true;}
mmap_listener = Interceptor.attach(Module.findExportByName("libc.so", "mmap"), { onEnter: function (args) { this.len = args[1].toUInt32(); }, onLeave: function (retval) { if (found || retval.isNull()) return;
if (this.len === 0x134000) { found = true; inner_base = ptr(retval.toString()); console.log("[+] inner base = " + inner_base); if (mmap_listener) { mmap_listener.detach(); mmap_listener = null; console.log("[+] mmap hook detached"); } } }});
dlopen_listener = Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), { onEnter: function (args) { this.fileName = args[0].isNull() ? "" : args[0].readCString(); }, onLeave: function (retval) { if (patched) return;
if (this.fileName && this.fileName.indexOf(target_so) >= 0 && inner_base != null) { applyPatches(); if (dlopen_listener) { dlopen_listener.detach(); dlopen_listener = null; console.log("[+] dlopen hook detached"); } } }});0x03 小结
很早以前就读壳感兴趣,但是一直没有时间研究。这次特意选了安卓端最简单的壳进行分析。之前不懂为什么那么多国家相关的APP,以及一些大银行,都喜欢用这个的壳,明明比它强的壳还有很多。最近和朋友聊天时问了这个问题。他跟我说,很多东西不是技术层面的问题,而是法律层面的。恍然大悟了也是
这篇篇幅还是有点太短了,剩下的dex解密流程分析过两天回学校有空了再写吧ovo
使用到的一些工具以及文章: