某加密企业版 Frida 检测绕过 前言 故事的起因源自于我们英语老师用这个软件签到,就想着能不能 … 嘿嘿 [手动doge]
正巧最近想学习一下安卓加固壳
效仿 OrientalGlass 大佬的搞法,把我平时收藏的那些逆向相关的优质文章总结成一个 AI 知识库,以供 AI 一把梭哈,效果很可观
工具:
Pixel 6A
Frida 17.8.3 (因为我root机用16版本容易崩,需注意API兼容问题)
Codex (用的GPT5.5,不容易报cyber)
无名侠的 IDA NO MCP
目标APK:cn.unipus.cloud
声明:
本文所述内容仅为技术研究与学习交流之目的, 所分析的 App 版权归其所属公司所有
作者未对任何 App 进行非法篡改, 破解, 数据窃取或商业利用, 亦不鼓励或支持任何违反法律法规的行为
读者不得将本文内容用于任何非法用途, 由此产生的一切法律责任由使用者自行承担
请遵守《中华人民共和国网络安全法》及相关法律法规
事先准备 互联网上关于该壳的信息搜集
目标APK
Root机环境下能否正常使用目标APK
Frida 一把梭 先 hook 一下 dlopen,定位一下反 Frida 逻辑大概在哪个文件里面
每次 dlopen enter前可以尝试 sleep 两秒应对检测逻辑处决时间滞后问题
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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 const SYMBOLS = ["dlopen" , "__loader_dlopen" , "android_dlopen_ext" ];const LOOKUP_MODULES = ["libc.so" , "libdl.so" , "linker64" , "linker" ];const SLEEP_SECONDS = 2 ;let enterCount = 0 ;function cstr (p ) { try { return p.isNull () ? null : p.readCString (); } catch (_) { return "<bad-cstr>" ; } } function addrName (p ) { const m = Process .findModuleByAddress (p); return m ? m.name + "+0x" + p.sub (m.base ).toString (16 ) : p.toString (); } function exportByName (name ) { for (const moduleName of LOOKUP_MODULES ) { const m = Process .findModuleByName (moduleName); if (m === null ) continue ; const p = m.findExportByName (name); if (p !== null ) return p; } return Module .findGlobalExportByName (name); } function hook (name ) { const p = exportByName (name); if (p === null ) { console .log ("[miss] " + name); return ; } Interceptor .attach (p, { onEnter (args ) { enterCount++; this .name = name; this .path = cstr (args[0 ]); this .flags = args[1 ]; this .caller = addrName (this .returnAddress ); if (enterCount > 1 ) { console .log ("[dlopen:pre-sleep] " + "count=" + enterCount + " tid=" + Process .getCurrentThreadId () + " fn=" + name + " caller=" + this .caller + " path=" + this .path + " flags=" + this .flags + " sleep=" + SLEEP_SECONDS + "s" ); Thread .sleep (SLEEP_SECONDS ); } console .log ("[dlopen:enter] " + "tid=" + Process .getCurrentThreadId () + " fn=" + name + " caller=" + this .caller + " path=" + this .path + " flags=" + this .flags ); }, onLeave (retval ) { console .log ("[dlopen:leave] " + "fn=" + this .name + " ret=" + retval + " path=" + this .path ); } }); console .log ("[hook] " + name + " @ " + p); } setImmediate (function ( ) { SYMBOLS .forEach (hook); });
定位到处决逻辑处于 libexec.so 中
知道了处决逻辑的位置后,想办法去处理上游的检测逻辑
修复 libexec.so IDA 打开 libexec.so 瞟一眼
发现大段被加密,疑似标准/轻改 UPX
可以通过纯静态分析 .init_proc 函数一路追踪过去,基本上一路交给 AI 就可以完成
这里推荐用动态dump的方式,用 Frida attach 先 dump 出 so ,再通过 Sofixer 修复so结构,这里可以用这位佬的脚本自动完成
https://github.com/lasting-yang/frida_dump
一次不行就多试几次,在检测逻辑轮询的空窗期把 so dump 出来
trace 接下来就通过 Frida 定位处决逻辑,顺着处决点摸到上游的检测逻辑
可以直接让 AI 写 trace 脚本
这边推荐可以先用一些开源的IDA插件先把trace脚本生成出来后再让 AI 改
https://github.com/oacia/stalker_trace_so
在该样例中有一些小 bug 导致无法正常执行
1 2 3 主要原因是原版本的 Stalker 日志写在 transform() 里 console.log,那是 block 编译阶段,不是运行到函数时;所以看起来 trace 不准或没效果 另一个问题是Process.getModuleByName(so_name) 在 libexec.so 还没加载时会直接抛异常
让 AI 修一下就行
如果处决逻辑有滞后导致定位处决函数难以精确,可以让AI改一下,在 trace 脚本每一个函数执行前中加入一段 sleep 逻辑,如果 sleep 逻辑本身容易导致程序崩溃
可以参照 OrientalGlass 文章提过中的思路,进行二分查找
基本上能够收敛到处决函数;随后针对关键返回值写 hook 脚本记录调用者、返回值和状态位,再结合静态分析向上追溯检测点位
若仍然难以定位,可以尝试通过 KPM 模块或者 eBPF程序 进行 trace 工作,对比Frida trace函数链是在哪里出现了路径分叉,再往上追溯检测逻辑,对于部分的反调试逻辑有奇效
最终定制化脚本 确认的关键函数对应关系:
偏移
IDA 函数/位置
在脚本里的意义
0x64440
sub_64440
JNI 调 Debug.isDebuggerConnected();返回 1 会写状态并表示检测命中。脚本 hook 后非零返回改 0。
0x5cc98
落在 sub_5C9E8 内
note 里标为 gate 链 ops[0x00] thunk;IDA 当前不是独立函数起点。脚本仍按该入口尝试 hook。
0x5ccf8
sub_5CCF8
构造期 VM 调度器:调用 sub_D9304(..., 0x102770, ...) 跑 VM 字节码,然后用 sub_DDF94 取结果。返回 1 表示 gate 检测命中。
0xddf94
sub_DDF94
VM 结果栈提取器。它也被 JNI_OnLoad 用来返回 JNI_VERSION,所以脚本只在 caller 落在 0x5ccf8..0x5ce00 时改 0,避免把 JNI 初始化弄坏。
0x5dfe4
sub_5DFE4
A-table 的 A[7],直接 syscall 打开/读取 /proc/self/status,解析 TracerPid。这是 spawn 根因链里的关键早期检测。
0x63c10
sub_63C10
A-table 的 A[4],读取 /proc/self/wchan,识别 sys_epoll、ptrace_stop,返回 0/1/2。
0x78b98
sub_78B98
A-table 的 A[31],字符串比较 helper;在 A[7] 里用于匹配 TracerPid 行名。
0x62d80
sub_62D80
maps/Frida 检测器,扫描 /proc/self/maps 风格内容和可执行段特征。脚本直接 patch 成 mov w0,#0; ret。
0x6fcb4
sub_6FABC 内失败块
第一组终止 caller。脚本把块内入口/BL 位置改成 B,跳过写状态、调用 sub_5C9E8 和清零路径。
0x712cc
sub_70F80 内失败块
第二组终止 caller。IDA 反编译失败,但 process 里 CFG 证据显示它有多入口,脚本分别 patch 三个入口/BL 点跳到继续路径。
0x40c2c
sub_40BF4 内返回地址
raw-clone mmap 安全窗口标识。只有 caller 是 libexec.so+0x40c2c、length 0x800000、flags 0x20022 时才批量安装 hook/patch。
0x4f464
sub_4F408 内 pthread_create caller
晚期同步窗口:当这里创建线程,且 start routine 是 0x4f1b0 时,再补一次关键 hook/patch。
0x4f1b0
sub_4F1B0
watchdog/回调包装线程入口;执行 sub_4F508() 后调用参数对象里的回调。它是终止前稳定出现的线程链入口。
0xeefe0
全局数据 off_EEFE0
全局状态指针槽。脚本从这里追到 state,再持续清 state+0x338、+0x1d1、+0x33e。其中 +0x338 是 VM 会写成检测状态 17 的标记。
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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 "use strict" ;const PROBE_MODE = "smart" ;const TARGET_MODULE = "libexec.so" ;const RETURN_ZERO = 0x52800000 ;const RET = 0xd65f03c0 ;const STATE_SLOT = 0xeefe0 ;const DDF94 _OFFSET = 0xddf94 ;const CALLERS_TO_OVERRIDE = [0x5ccf8 ]; const HOOK_TARGETS = [0x64440 , 0x5cc98 , 0x5ccf8 , 0x5dfe4 , 0x63c10 , 0x78b98 ];const PATCH_TARGETS = [0x62d80 ];let seq = 0 , mod = null , mc = 0 , modMc = 0 , callsDone = false , statePtr = null ;const hooked = {};function ev (e, d ) { send (Object .assign ({ event : e, mode : PROBE_MODE , s : ++seq }, d || {})); }function mo (a ) { try { const m = Process .findModuleByAddress (a); return m ? { n : m.name , o : Number (a.sub (m.base )) } : null ; } catch (e) { return null ; } }function isInside (a, m ) { return a.compare (m.base ) >= 0 && a.compare (m.base .add (m.size )) < 0 ; }function keepClean ( ) { if (!mod) return ; if (!statePtr || statePtr.isNull ()) { try { const ss = mod.base .add (STATE_SLOT ).readPointer (); if (!ss.isNull ()) { const st = ss.readPointer (); if (!st.isNull ()) statePtr = st; } } catch (e) {} return ; } try { if (statePtr.add (0x338 ).readU32 () !== 0 ) { statePtr.add (0x338 ).writeU32 (0 ); ev ("CL" ); } if (statePtr.add (0x1d1 ).readU8 () !== 0 ) statePtr.add (0x1d1 ).writeU8 (0 ); if (statePtr.add (0x33e ).readU8 () !== 0 ) statePtr.add (0x33e ).writeU8 (0 ); } catch (e) {} } function hookOne (off ) { const k = "h" + off.toString (16 ); if (hooked[k] || !mod) return ; try { const a = mod.base .add (off); const p = a.readU32 (); if (p === 0 || p === 0xffffffff || p === RETURN_ZERO ) return ; Interceptor .attach (a, { onEnter ( ) { keepClean (); if (off === DDF94 _OFFSET) this ._ret = mo (this .returnAddress ); }, onLeave (rv ) { const v = rv.toUInt32 (); if (v !== 0 ) { let override = true ; if (off === DDF94 _OFFSET && this ._ret ) { const is5cc = (this ._ret .o >= 0x5ccf8 && this ._ret .o < 0x5ce00 ); if (!is5cc) override = false ; } if (override) { rv.replace (ptr (0 )); } else { ev ("SKIP" , { off : "0x" + off.toString (16 ), rv : v, ret : this ._ret ? this ._ret .n + "+0x" + this ._ret .o .toString (16 ) : "?" }); } } keepClean (); } }); hooked[k] = true ; } catch (e) {} } function patchOne (off ) { const k = "p" + off.toString (16 ); if (hooked[k] || !mod) return ; try { const a = mod.base .add (off); if (a.readU32 () === RETURN_ZERO ) { hooked[k] = true ; return ; } Memory .patchCode (a, 8 , function (c ) { const w = new Arm64Writer (c, { pc : a }); w.putInstruction (RETURN_ZERO ); w.putInstruction (RET ); w.flush (); }); hooked[k] = true ; } catch (e) {} } function patchCallers ( ) { if (callsDone || !mod) return ; try { [{ b : 0x6fcb4 , p : [{ i : 0 , v : 0x14000007 }, { i : 2 , v : 0x14000005 }] }, { b : 0x712cc , p : [{ i : 0 , v : 0x1400000a }, { i : 3 , v : 0x14000007 }, { i : 5 , v : 0x14000005 }] }] .forEach (function (c ) { c.p .forEach (function (p ) { const a = mod.base .add (c.b + p.i * 4 ); if (a.readU32 () === p.v ) return ; Memory .patchCode (a, 4 , function (code ) { const w = new Arm64Writer (code, { pc : a }); w.putInstruction (p.v ); w.flush (); }); }); }); callsDone = true ; } catch (e) {} } const mp = Module .findGlobalExportByName ("malloc" ) || Module .findGlobalExportByName ("__libc_malloc" );if (mp) Interceptor .attach (mp, { onEnter ( ) { mc++; if (!mod) { const m = Process .findModuleByName (TARGET_MODULE ); if (m) { mod = m; modMc = mc; ev ("F" ); } return ; } keepClean (); if (mc <= modMc + 1 ) return ; HOOK_TARGETS .forEach (function (o ) { hookOne (o); }); hookOne (DDF94 _OFFSET); PATCH_TARGETS .forEach (function (o ) { patchOne (o); }); if (!callsDone && Object .keys (hooked).length >= 6 ) patchCallers (); }}); function hookRaw ( ) { Interceptor .attach (Module .findGlobalExportByName ("mmap" ), { onEnter (args ) { this .cl = mo (this .returnAddress ); this .nl = Number (args[1 ]); this .fl = args[3 ].toInt32 (); keepClean (); }, onLeave (rv ) { if (!this .cl || this .cl .n !== TARGET_MODULE || this .cl .o !== 0x40c2c || this .nl !== 0x800000 || this .fl !== 0x20022 || rv.equals (ptr ("-1" ))) return ; if (!mod) mod = Process .findModuleByName (TARGET_MODULE ); if (mod) { HOOK_TARGETS .forEach (function (o ) { hookOne (o); }); hookOne (DDF94 _OFFSET); PATCH_TARGETS .forEach (function (o ) { patchOne (o); }); patchCallers (); } keepClean (); } }); } function hookPth ( ) { Interceptor .attach (Module .findGlobalExportByName ("pthread_create" ), { onEnter (args ) { keepClean (); const c = mo (this .returnAddress ); if (!c || c.n !== TARGET_MODULE || c.o !== 0x4f464 ) return ; const m = Process .findModuleByName (TARGET_MODULE ); if (!m || !args[2 ].equals (m.base .add (0x4f1b0 ))) return ; if (!mod) mod = m; if (mod) { HOOK_TARGETS .forEach (function (o ) { hookOne (o); }); hookOne (DDF94 _OFFSET); PATCH_TARGETS .forEach (function (o ) { patchOne (o); }); patchCallers (); } keepClean (); } }); } if (Process .arch !== "arm64" ) throw new Error ("arm64" );hookRaw (); hookPth ();ev ("A" , { pid : Process .id });setImmediate (function ( ) { ev ("R" , { pid : Process .id }); });
之后无论是动态拿DEX或者进行其他的 hook 操作就方便多了
静态提取DEX 对于这类加固而言,磁盘占用大且信息熵高的文件就很有可能是被加密的DEX文件
在示例中,这类文件如下
assets/ijiami.dat
assets/ijiami.ajm
然后让 AI 辅助沿字符串和 xref 追踪,可以逐步定位 DEX 解密逻辑,对于现在的大模型能力来说不是什么难事
对于那种密钥常量之类的,如果限定了大小,场景合适,可以尝试暴力穷举,用文件魔数什么的进行约束
最终静态分析结论:
本样本里, ijiami.dat 负责恢复 8 个结构合法但仍处于方法抽空状态的 DEX;ijiami.ajm 则保存 142,672 条方法正文记录,可按 marker 原位回填这些 DEX 中被默认返回/NOP 骨架替换的方法体
这里容易踩坑: ijiami.dat 解出的 DEX 虽然能过 dexdump 结构校验,但并不代表方法正文已经完整恢复。建议额外扫描 debug_info_off 异常 marker、默认返回/NOP 骨架,以及业务类中是否大量保留 try 块但首条即 return
不主动提,大模型很容易停在 能打开DEX 的阶段
参考文章 [原创]某加固最新版frida检测绕过-trace一把嗦(续)-Android安全-看雪安全社区|专业技术交流与安全研究论坛
某密企业版加固反调试与VMP分析 - 吾爱破解 - 52pojie.cn