腾讯游戏安全竞赛2026-安卓赛道决赛-反注入分析

两只羊 Lv3

前言

结果非常意外,我竟然获得了今年安卓的优秀奖,好听点是腾讯游戏安全竞赛2026安卓赛道决赛第5名!

比较遗憾的就是对反调试反注入这一块没有分析好,再加上很多分析过程都依赖AI,导致最后还是排名偏低,不过也是非常满意了。

对于FLAG生成的部分,网上已经有很多优秀的WP了,包括第一名Matriy师傅的,让我受益匪浅,因此在这里只对我比赛最为困惑的且学习颇多的地方,注入检测进行复盘

image-20260505215210855

多线程检测

在平常的APP分析中,当Frida注入程序崩溃后,我第一件想到的事情就是去Hook pthread_create或者clone这两个线程创建相关的函数,这里就不再赘述了,相信问一下AI都能给出结果

对libsec2026.so分析,定位到0x99094处,存在着三处检测

image-20260505215746345

按照以往的经验,尝试把线程回调函数设置为一个空函数,直接暴力不执行各种检测就好了。

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
function nop_64(addr) {
Memory.protect(addr, 4 , 'rwx');
var w = new Arm64Writer(addr);
w.putRet();
w.flush();
w.dispose();
}

function hook_clone(soname) {
console.log("start")
var clone_addr;
try {
var libc = Process.getModuleByName('libc.so');
clone_addr = libc.findExportByName('clone');
} catch (e) {
console.error("[-] 无法获取 libc.so 模块或 clone 函数: " + e);
return;
}

if (!clone_addr) {
console.error("[-] 未找到 clone 函数地址");
return;
}

Interceptor.attach(clone_addr, {
onEnter: function(args) {
if (!args[3].isNull()) {
try {
var addr = args[3].add(96).readPointer();
var module = Process.findModuleByAddress(addr);
if (module !== null) {
var so_base = module.base;
var offset = addr.sub(so_base);
if (so_name.indexOf(soname) >= 0) {
//nop_64(addr);
}
}
} catch (e) {
// 静默处理,忽略由于读取无效指针引发的报错
}
}
},
onLeave: function(retval) {
}
});
}

setImmediate(hook_clone, "libsec2026.so");

但程序在一段时间后依然崩溃,事情肯定没有这么简单。

这时候我想起过之前的一种思路,在线程函数里塞入和互斥锁相关的操作,如果直接不执行,就会导致死锁,而且在函数中我也看到有很多处调用了pthread_mutex_lock,但后来发现其实似乎并没有太大关系

父子进程检测

0x9C654可以看到fork,waitpid等操作,这里非常明显能看出来就是程序fork出一个子进程并进行附加,这样调试器就没有办法再attach上去调试了

image-20260505215949751

fd与线程检测

0x9CDC4在进行间接跳转混淆的处理后,可以看到经过一个CFF魔改,CFG已经不成样了,但正如AI时代的发展,人类的阈值也在不断被提高,甚至控制流平坦化都眉清目秀的,这里就不再处理了,能看就行

image-20260505221632744

通过字符串解密,这里是读取了/proc/self/task/%s/status,检查是否有gum-js-loop和gmain,典型的特征线程检测

image-20260506105043402

在0x99418,读取了/proc/self/fd/,通过readlink获取每个fd指向的真实目标

image-20260506105637382

检查是否出现了特有文件的描述符linjector的字样

image-20260506105710296

页权限修改

通过mprotect把exit函数的内存页权限修改为不可写,因为此处在多线程中,直接去hook,也可能出现条件竞争的情况,可能Frida刚设置完可写,这边又设置回去了

image-20260506110104154

Maps检测

这也是个非常经典的套路了,通过读取/proc/self/maps

image-20260506110655576

然后检查加载的库里有没有frida的特征,通常Frida注入后会读到像这样的痕迹/memfd:frida-agent-64.so (deleted)

image-20260506110730771

godot运行库crc校验

sub_96A00这里开始有很多互斥锁的操作,看的我心慌,先是解密出了libgodot_android.so,盲猜要做校验

image-20260506111306577

通过sub_9AF98进行了一波CRC校验

image-20260506111431785

心跳检验

在sub_9AF98中,除了CRC校验,还有一个非常重要的点

通过clock_gettime的系统调用,获取了当前时间,并通过ts = v23.tv_nsec / 1000 + 1000000 * v23.tv_sec;转化

image-20260506111549429

在GDExtension的onTick函数中,也在实时监测着心跳,如果发现差值大于10000000,也就是10s,程序直接走向崩溃

因此我们要手动在Frida中,用setInterva不断向这块内存注入心跳,不然程序肯定崩溃

image-20260506111726573

process_vm_readv系统调用校验

就算绕过了前面所有的监测点,只要hook了PROCESS_IMPL函数0x97704,程序一段时间后被杀,严重怀疑是因为存在校验

如果真的有地方对这段内存进行了校验,用stackplz下个读取断点尝试一下

1
./stackplz -p 10997 --brk 0x97704:r --brk-lib libsec2026.so --brk-len 1 --stack

但非常可惜的是,命中结果为0

image-20260505224839416

在定位到libgodot_android.so的0x10B9E4C后,可以惊讶地发现,这里从libsec2026.so的0x95C70开始读取0xCA210到buffer,居然还会对进行一波校验!

但为什么stackplz抓不到这个读的操作呢,问题就出在process_vm_readv

image-20260505231324006

来看看process_vm_readv这个系统调用的功能

process_vm_readv 是 Linux 3.2+ 引入的系统调用,用于直接在两个进程的用户空间之间传输数据,无需经过内核缓冲区,从而减少一次数据拷贝,提升进程间通信性能。它的反向操作是 process_vm_writev,用于写入远程进程内存。

核心特性

  • 零拷贝优化:数据直接在进程地址空间间传输,避免内核中转。
  • 基于 iovec:支持一次调用传输多个不连续的内存区域。
  • 权限控制:需要相同有效 UID 或 CAP_SYS_PTRACE 权限,并且目标进程必须运行中。
  • 非原子性:多段传输可能部分成功,需要检查返回值。

woc,也就是说,它的拷贝发生在内核页,因此不会产生目标进程用户态 watchpoint/BRK 事件,而常规的硬件断点只能抓像LDRB W14, [X12],LDR X8, [X9]这样的内存读写,因此根本抓不到,个检测思路我也是第一次见,也算是又学到妙妙技巧了。

当最后发现校验不通过时,直接就进入销毁的函数了,0x10BA0A4对应源码中的void Main::cleanup(bool p_force)

所以去hook exit根本没有用,整个核心进程的生命周期已经结束了

image-20260505231524660

而且这里的调用还不是通过BL这样的正常函数调用,而是通过BR直接跳转,导致我根本抓不到这里的调用栈!

image-20260505232221930

而非常可惜的是,我的ebpf tracepoint诊断脚本,虽然hook了process_vm_readv,但根本没有将参数解析

image-20260506112328763

这就是现在大概的逻辑

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
struct user_iovec64 {
u64 iov_base;
u64 iov_len;
};

static __always_inline void decode_process_vm_readv(struct syscall_state *state) {
struct user_iovec64 iov = {};
s32 status;
u64 local_cnt = state->args[2];
u64 remote_cnt = state->args[4];

if (state->args[1] != 0 && local_cnt != 0) {
status = bpf_probe_read_user(&iov, sizeof(iov),
(const void *)state->args[1]);
state->decoded.status[0] = status;
if (status == 0) {
state->decoded.mask |= DECODE_PVM_LOCAL_IOV0;
state->decoded.values[DEC_PVM_LOCAL_BASE] = iov.iov_base;
state->decoded.values[DEC_PVM_LOCAL_LEN] = iov.iov_len;
state->decoded.values[DEC_PVM_LOCAL_PARSED] = 1;
}
}

...
}

现在这条日志,就能非常清楚地记录了

1
[ 9139.465] EXIT  pid=10360 tid=10412 comm=VkThread        process_vm_readv(270) ret=827920 pid=10360 lvec=0x7c0ea30e58 liovcnt=1 rvec=0x7c0ea30e48 riovcnt=1 flags=0x0 local_iov_parsed=1/1 local_iov[0]={base=0xb400007c10a68df0 len=0xca210} -> "00:00 0                              [anon:System property context nodes]"+0xb3fffffc8155edf0..0xb3fffffc81629000 map=0x7f8f519000-0xffffffffffffffff off=0xf000 prot=0x0 remote_iov_parsed=1/1 remote_iov[0]={base=0x7c11ec3c70 len=0xca210} -> "/data/app/~~HBS2UqCzNaLwfLuACuXyfQ==/com.tencent.ACE.gamesec2026.final-Ei05wgsojgSTR69gZPEWbw==/lib/arm64/libsec2026.so"+0x95c70..0x15fe80 map=0x7c11e2e000-0x7c11f8e310 off=0x0 prot=0x5
  • 标题: 腾讯游戏安全竞赛2026-安卓赛道决赛-反注入分析
  • 作者: 两只羊
  • 创建于 : 2026-05-05 21:50:56
  • 更新于 : 2026-05-06 11:28:09
  • 链接: https://twogoat.github.io/2026/05/05/腾讯游戏安全竞赛2026安卓赛道-反注入分析/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论