首页
社区
课程
招聘
[原创]2026腾讯游戏安全竞赛-决赛-安卓
发表于: 1天前 551

[原创]2026腾讯游戏安全竞赛-决赛-安卓

1天前
551

前言:写的比较流水账。看了其它佬的文章,发现比赛还是漏掉了n多得分点…

解gdc

.gdc 文件头格式仍是 MD5(16) | plain_len(8) | IV(16) | ciphertext和初赛的方法一样,依旧是hook open_and_parse_password,捕获到密钥仍是 ce4df8753b59a5a39ade58ac07ef947a3da39f2af75e3284d51217c04d49a061但这回没改加解密,解密模式是标准 AES-CFB

直接gdre解出来的gd长这个样子。像是魔改过了。这个关键字分布感觉是魔改了token序。

图片描述

打了个标准版的4.5.1-stable包,想直接diff看看差别,但……

图片描述中间还崩溃了几次。最后直到比赛结束也没diff完

直接人工diff。

对照着源码分析,看看是改了哪里。

一开始想在scan里去找,但scan里的映射有点怪,有几个token被映射到了同一个id。猜测可能是故意堵了这条路?于是打算从_binary_to_token慢慢扒parse。

根据常量字符串Identifier index out of bounds.定位到_binary_to_token偏移是0x147CF48。扔到在线文本比对工具里。

图片描述

左边是题目,右边是demo。可以看到明显有不同。题目这版魔改了解析token时的映射顺序。

对照着源码分析,这里能确定的是

7 = NAN = official 94;11/12 是 ANNOTATION/IDENTIFIER 这对;13/50 是LITERAL/ERROR 这对。

顺着找其他parse:

is_identifier0x1474F58,能确定4,5,6,7,12,60,61这一组对应IDENT / MATCH / WHEN / PI / TAU / INF / NAN

parse_variable0x144DE30,能确认 12 = IDENT = official 0295 = : = official 8438 = = = official 28

parse_signal0x144AB4C,能确认12 = IDENT = official 0288 = ( = official 7789 = ) = official 7890 = , = official 79

parse_enum0x144D16C,能确认12 = IDENT = official 0286 = { = official 7587 = } = official 7638 = = = official 28

parse_function_signature0x1453670,能确认94 = ... = official 8390 = , = official 7989 = ) = official 7897 = -> = official 86;lambda 分支再次给出95 = : = official 84

parse_function0x144BB10,能确认12 = IDENT = official 0288 = ( = official 77

parse_assert0x1458C5C,能确认88 = ( = official 7790 = , = official 7989 = ) = official 78

parse_for0x1456EC0,能确认12 = IDENT = official 0295 = : = official 8472 = in = official 61

parse_if0x14566E0,能确认52 = elif = official 4153 = else = official 4295 = : = official 84

parse_match0x145A044,能确认61 = when = official 5095 = : = official 841 = NEWLINE = official 88

parse_subscript0x1461064,能确认85 = ] = official 74

parse_call0x14616E8,能确认92 = . = official 8188 = ( = official 7789 = ) = official 78

parse_type_test0x146459C,能确认22 = not = official 12

...

最后归纳一下规律,魔改token到官方token的变换:

0   -> 0
1..10 -> +87
11..49 -> -10
50  -> 98
51..97 -> -11
98  -> 87
99  -> 99

写脚本,初赛解gdc脚本改一下就能用,然后再按这个规律置换一下。然后就能用gdre正常反编译了。`

trigger2

可以看到trigger2的算法全在gd层。

图片描述

图片描述

图片描述

变量名做了混淆。直接扔给大模型来读。也不需要啥额外提示词。有一个值得注意的点是碰撞放在了native层。

图片描述

图片描述

然后就是把方块传送过来。此处方法和初赛一样,依旧是先hook到libsec2026.soextension_init获取到关键api,然后classdb_get_method_bind +ptrcall动态调用godot原生方法递归遍历整个场景树,定位到小车和trigger2的位置后,set_global_position把方块传送到小车的位置。

图片描述

token=ee967a1e
flag=flag{sec2026_PART1_f8a5b7bd}

用到的脚本:

frida_dump_scene_tree.jsrun_dump_scene_tree.py dump场景树,scene_tree_dump.jsondump的结果。

run_trigger2_move.jsrun_trigger2_move.py 传送trigger2

token生成flag算法:token2flag2.c

flag生成token算法:flag2token2.c

这里说一下flag2token2.c的思路。

先看正向流程。脚本先把输入的 8 位十六进制字符串转成 4 个字节,然后把这 4 个字节拆成前后两个 2 字节分组,分别记成 lohi。程序把 "Sec2026_Godot" 作为轮密钥,一共跑 8 轮。每一轮先对右半块 hi 跑一遍轮函数,轮函数里先和密钥按轮次偏移异或,再乘 7 加上轮号,最后做一次左旋 3 位。轮函数输出以后,再和左半块 lo 做异或得到新的右半块 nr。然后执行 Feistel 交换,也就是 lo = hihi = nr。8 轮结束以后,把最终的 lo || hi 拼起来作为flag。

flag2token2.c 按完全相反的顺序逐步还原。把这 输入的位十六进制串解析成 4 个字节,再拆成当前的 lohi。这两个值对应的是加密 8 轮结束以后的左右半块。

接下来程序从第 7 轮倒着还原到第 0 轮。因为正向每一轮最后做的是 lo = hi_oldhi = lo_old ^ F(hi_old, round),所以逆的时候先把上一轮的右半块直接恢复成当前的左半块,也就是 prev_hi = lo。然后重新对 prev_hi 跑同一轮的轮函数,得到当时的 F(prev_hi, round)。有了这个值以后,再用当前右半块 hi 去异或它,就能把上一轮的左半块恢复出来,也就是 prev_lo = hi ^ F(prev_hi, round)。这样上一轮的两个分组就都拿回来了,接着把 lohi 更新成 prev_loprev_hi,继续处理前一轮。

8 轮全部还原以后,程序拿到的就是最初输入给脚本 _fe() 的 4 字节 token。最后把这 4 个字节重新转成 8 位十六进制字符串输出,就是原始 token。

trigger3

先尝试了直接调w7,屏幕正常出现flag。

然后直接把方块挪过来,没有flag。

题目提示要开启碰撞。直接翻文档。

图片描述

这里hook发现trigger3的monitorablemonitoringtrue,但trigger4的这两个是false。

场景树里可以看到它们还有子节点CollisionShape3D

图片描述

图片描述

这个应该是碰撞的实体。hook发现trigger3和trigger4的CollisionShape3D子节点的disabled都是true。

图片描述

尝试把disable设置成false,然后把trigger3传送到小车上,成功显示flag

图片描述

用到的脚本:frida_dump_trigger_flags.jsrun_dump_trigger_flags.py 打印这几个属性。

改标志位并传送Trigger3到小车:run_trigger3_enable_move.pyfrida_trigger3_enable_move.js

主动调用尝试几组输入输出:

10000000 -> 8ed9db184f1334737558461372eb675b
01000000 -> b658ed9e19340e7b393c02882f962881
00100000 -> d517864b8ab06e5d0761fb6e932bb8fb
00010000 -> ed39deccd83a3589dababb5394dedad1
00001000 -> ac58ec90cd65f495b4f828d1d8eeeaff
00000100 -> f4f840da8d1715dc42261f47a054d1b1
00000010 -> cb4104b4bee37b791bed907b3863e2e4
00000001 -> c91d8f3f436f77f22ca946d4d92e4528

这次的扩散非常强,不能跟初赛那样靠选择明文攻击来猜了。

这里仍然沿用初赛时的思路定位 Process 的入口,从 Godot GDExtension 的标准初始化流程入手。扩展库加载后,Godot 会调用 extension_init(get_proc, library, init_struct),其中get_proc 是扩展用来查询 Godot API 的入口。先 hook extension_init,把第一个参数 get_proc 替换成自己的 wrapper。这个 wrapper 内部仍然调用原始 get_proc,但当查询classdb_register_extension_class_method 时,把这个注册函数 hook 住。

classdb_register_extension_class_method 被调用时,读第三个参数,也就是 GDExtensionClassMethodInfo 结构。这个结构里能看到统一的 Godot 调用 wrapper,以及对应的 method_userdata。 dump method_userdata 可以看到它的 vtable,从 vtable 的 +0x18+0x20 两个槽位可以得到 Process 的两条实际调用桥接函数,分别是 libsec+0x9f5d8libsec+0x9c5f8

图片描述

继续分析这两个桥接函数,可以看到它们还不是最终业务逻辑,而是会继续读取 method_userdata + 0x50method_userdata + 0x58,并把这两个值作为下一层分发参数。运行时 dump 这两个字段可以看到,method_userdata + 0x50 = libsec+0x97704method_userdata + 0x58 = 0

图片描述

可以推断Process的入口就是0x97704。同理可得到Tick的入口0x9ad68

此处用到的脚本:frida_locate_process_entry.js run_locate_process_entry.py

逆向分析

ida打开能看到一些花指令插在代码段里。比如:

.text:00000000000A97B0                 LDR             X1, loc_A97A8
.text:00000000000A97B4                 BLR             X1
.text:00000000000A97B8                 MOV             X0, X1
.text:00000000000A97B8 ; ---------------------------------------------------------------------------
.text:00000000000A97BC                 DCD 0xA2F2FFF1
.text:00000000000A97C0 ; ---------------------------------------------------------------------------
.text:00000000000A97C0                 LDR             X23, [X7,X21]
.text:00000000000A97C4                 BR              X23

这里我是写了个ida python脚本把0xA2F2FFF1全都给nop了,一共82处应该是。用到的脚本:junk2nop.py

定位到process入口0x97704

图片描述

sub_A936C就是加密的主函数

图片描述

把初始token复制一份扩展到16字节。然后调用sub_A7900sub_A7194,然后进入一个比较友好的控制流平坦化。看入参,sub_A7900应该是用于构造208字节的上下文。其中最后16字节是特意复制进去的一块。

图片描述

这里ida识别不出来switch结构,需要手工添加一下。

图片描述

sub_A936C本身没什么内容,除了前面的函数调用,后面就是解密字符串,解出来是%02x,然后sub_AA6AC用它调用_vsprintf_chk用来写入flag。所以核心加密就在sub_A7194

图片描述

A7194的核心代码也不长。case0是每次对16大小的块做运算,v5是。case1拷贝计算结果,v6就是state+192,也就是每轮计算完把结果拷贝到这里。case2是终止条件。这里可以合理推断state前192字节是不变的S盒,最后16字节是每轮运算的state。

这里每轮就是调用sub_AA9B0sub_A8D44做计算。只要搞清楚这俩干了啥就ok了。

图片描述

AA9B0就是做异或。

图片描述

图片描述

相当于

void sub_AA9B0(uint8_t *dst, const uint8_t *src)
{
    for (int i = 0; i < 16; i++)
    {
        uint8_t x;
        if (i & 1)
            x = src[15 - i];   // 奇数位:倒着取
        else
            x = src[i];        // 偶数位:正着取
        dst[i] ^= x;
    }
}

接下来接着跟sub_A8D44。两个初始化函数。

图片描述

sub_A7944是和常量做异或

图片描述

翻译过来就是

void sub_A7944(uint8_t *a1)
{
    for (int i = 0; i < 16; i++)
        a1[i] ^= byte_58510[i];
}

sub_AAB64关键代码其实就这一行

图片描述

变量太多了不想一个一个跟了。直接复制粘贴扔给大模型分析。

图片描述

图片描述

所以这里化简完就是:

void sub_AAB64(uint8_t round, uint8_t *dst, const uint8_t *table)
{
    for (int i = 0; i < 16; i++)
    {
        int r = i / 4;
        int c = i % 4;
        dst[i] ^= table[16 * round + i] ^ (uint8_t)(c + 91 * round);
    }
}

往下看主体,sub_AAB64sub_A8D44的主循环中也被频繁调用

图片描述

直接整个扔给大模型

图片描述

所以主循环化简完就是:

void sub_A8D44(uint8_t *input, uint8_t *state)
{
    sub_A7944(input);              // input[0..15] ^= byte_58510[0..15]
    sub_AAB64(0, input, state);    // 第 0 轮混合

    for (int round = 1; round <= 10; round++)
    {
        sub_A82C8(input);
        sub_A8F00(input, round);
        sub_AADE8(input);
        sub_A6F20(input);
        sub_AAB64(round, input, state);
    }

    sub_AAB64(11, input, state);   // 最后一轮
    sub_A84A4(input);
}

如法炮制,先手工恢复一下switch-case,然后一个一个扔给大模型看:

图片描述

图片描述

sub_AADE8不用扔,代码十分简洁。

图片描述

图片描述

ok到这里已经逆完了。还差hook把S-box和byte_183700的内容抓出来,然后把代码拼凑到一起就是生成算法了。

写个frida脚本抓一下:run_dump_sbox_c_arrays.py

图片描述

最终token生成flag生成算法:token2flag3.c

flag生成token算法:flag2token3.c

这里说一下flag2token3.c的思路。

先看正向流程。原程序读入 8 字节输入以后,把这 8 字节复制两遍,拼成一个 16 字节状态,前半块和后半块完全相同。接着程序先对状态做两次固定异或,再做第 0 轮的轮密钥异或,然后进入主轮函数。主轮一共分成两部分,前 10 轮都执行同一套完整变换,最后 1 轮执行尾轮变换。完整轮里依次做 S 盒替换、4x4 状态矩阵顺时针旋转、轮掩码异或、行置换、列混合以及轮密钥异或;尾轮沿用相同框架,只是省掉了列混合。所有轮结束以后,程序再对结果异或一组固定常量,最后把 16 字节状态转成 32 位十六进制字符串输出。

flag2token3.c 按完全相反的顺序逐步还原。程序先把输入的 32 位十六进制串解析成 16 字节密文块,然后先去掉末尾那组固定异或。接着开始逆最后一轮,因为正向最后一轮的最后一步是加轮密钥,所以解密时先异或掉第 11 轮轮密钥,再依次执行逆行置换、逆轮掩码、逆旋转和逆 S 盒。做完这一轮以后,状态就回到了进入尾轮之前的样子。

然后从第 10 轮一路逆到第 1 轮。每一轮都先异或掉对应轮密钥,再执行逆列混合、逆行置换、逆轮掩码、逆旋转和逆 S 盒。

前 11 轮全部还原以后,程序再去掉最开头的第 0轮轮密钥,然后依次去掉最前面的两组固定异或。最后 16 字节状态被还原到token || token

trigger4

分析trigger3的时候已经发现trigger4有三个需要改的属性了。全改了然后传送过来,成功得到flag。

图片描述

此处用到的脚本:run_trigger4_enable_move.pyfrida_trigger4_enable_move.js

入口定位

这里比较奇怪的是,即便改了token,再次碰撞返回的flag也不变。推测可能flag在碰撞前或者初始化阶段就完成计算了。token本身没有往外发的操作,那么它应该是在启动时主动取了token,计算出flag,然后缓存在某处。

gd脚本基本只有两个操作,一个collided_with注册碰撞,一个调用native层的Tick()

Tick本身就很像反调试。他本身会在每一帧被调用,每超过十秒就更新一次qword_1834B8里的时间戳。

图片描述

既然能通过信号传flag给label,那接下来的思路就是找哪里调用了emit_signal

libgodot_android里找到collided_with,交叉引用只有一个函数0x25F6718。合理推断sub_3C58F78emit_signal,那么这个v86就是生成出的flag。

图片描述

往前追v86的来源

图片描述

跟进sub_3CACE6C是一个字符串构造函数。构造的结果是第一个参数。

sub_3CAD6D8是字符串加法,相当于v79 = v78 + v29

sub_3CAD53C也是字符串加法,相当于v80 = v79 + v77 = v78 + v29 +v77。区别是它的第二个参数是个内部字符串对象,sub_3CAD6D8第二个参数是c字符串。

向上跟踪,发现v78的来源s就是flag的前半段:

图片描述

v77的来源v81就是右括号

图片描述

那么v29就是flag了。

图片描述

v29来源是v27(v28)

图片描述v27是0xa9a7c。尝试hook验证一下,入参就是token。ok那么trigger4的flag计算的入口就是0xa9a7c

跟进看看,明示是VMP。

图片描述

图片描述

ok,vmp。根据过往经验,指望AI能还原vmp handler还是比较困难的。所以不看静态了,直接trace吧。

一开始想着库里可能涉及到游戏引擎的交互,要unidbg补环境的话巨大工程量,想直接真机trace,hook掉检测函数后,尝试qbdi trace了半天,trace了800w行左右就崩溃了。之前一度以为是踩到了暗桩,但最后原因似乎是内存占用太大了,logcat里看到lowmemorykiller: Kill 'com.tencent.ACE.gamesec2026.final'

最后还是选择用unidbg。没有暗桩,也不存在和外部的复杂的对象交互,基本不用补环境,直接开箱就能trace。

unidbg代码:ACE_final.java

这里trace的结果有3.7个G,就不打包了。上传到网盘:https://xxxxxxxxxxx 提取码:xxxxxx

trace算法还原

trace了35294934

输入 88b5ed2d,输出 f81de18110985738

010editor打开,从后往前推。

这里就是常规的trace分析思路,从后往前分析。找f81de18110985738没找到。找f81de181找到了。

图片描述

搜索这个地址,可以看到这处代码在整个过程中被频繁调用。并且flag的后半段10985738也是走的这里生成。这里看到的值110985738,多了一个十六进制位,可以推断原始代码应该是32位模加。

图片描述

接着往前追计算出f81de181的两个操作数0x5736fde40xa0e6e39d

图片描述

图片描述

接着往前追这四个中间值:0x3b5980950x658d63080xab408f960xfc767272

图片描述

图片描述

图片描述

图片描述

到这一步已经开始有一些奇怪的东西出现了。可以看到"add x21, x8, x9" x8=0xaabbccdd x9=0x84c2b9 => x21=0xab408f96这一步用的0xaabbccdd

搜一下aabbccdd,有956个结果,并且分布十分均匀。合理推断是轮计算用到的密钥。

这里看分布图的条数已经可以推断计算轮次了,最上面那条只有四条指令,也没有参与运算,应该是初始化。排除掉这根,是28轮。

图片描述

到这先总结一下,

f81de181
= 0x5736fde4 + a0e6e39d
= (ab408f96 ^ fc767272) + (3b598095 + 658d6308)
= ((aabbccdd + 84c2b9) ^ (a5b5789c ^ 59c30aee)) + ((b22f728e ^ 8976f21b) + (43b5dc62 + 21d786a6))

继续往前跟:

[19:53:49 188][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0x951d2164 x9=0x10985738 => x21=0xa5b5789c
[19:53:48 677][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0x33ad3cee x9=0x2615ce00 => x21=0x59c30aee
[19:53:50 219][libsec2026.so 0x13b3c8] [0225c91a] 0x1213b3c8: "lsr w2, w8, w9" w8=0x10985738 w9=0x5 => w2=0x84c2b9
[19:53:28 249][libsec2026.so 0x13cb3c] [220108ca] 0x1213cb3c: "eor x2, x9, x8" x9=0xada3618d x8=0xee16bdef => x2=0x43b5dc62
[19:53:16 576][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0x14dca33e x9=0xcfae368 => x21=0x21d786a6
[19:53:39 357][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0xaabbccdd x9=0x773a5b1 => x21=0xb22f728e
[19:53:37 828][libsec2026.so 0x13cb3c] [220108ca] 0x1213cb3c: "eor x2, x9, x8" x9=0x59ac3af5 x8=0xd0dac8ee => x2=0x8976f21b

发现0x10985738也在这里参与了运算,并且又一次看到了aabbccdd。这里还看到了右移操作。到这里已经非常像魔改Tea了。

设v0是10985738,v1是f81de181,那现在的计算

v1
= 0x5736fde4 + a0e6e39d
= (ab408f96 ^ fc767272) + (3b598095 + 658d6308)
= ((aabbccdd + 84c2b9) ^ (a5b5789c ^ 59c30aee)) + ((b22f728e ^ 8976f21b) + (43b5dc62 + 21d786a6))
= ((aabbccdd + (v0 >> 5)) ^ ((951d2164 + v0) ^ (33ad3cee + 2615ce00))) + (((aabbccdd + 773a5b1)) ^ (59ac3af5 ^ d0dac8ee)) + (ada3618d + ee16bdef))

搜一遍这一轮新增的变量,看看有没有key,发现33ad3ceeaabbccdd一样,也是956次调用,且分布均匀。这应该也是个密钥。

图片描述

标准Tea算法是32轮,c代码如下,对比着分析:

void encrypt(uint32_t* v, uint32_t* k) {
    uint32_t v0 = v[0], v1 = v[1], sum = 0, i;              
    uint32_t delta = 0x9e3779b9;                           
    uint32_t k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3];    
    for (i = 0; i < 32; i++) {                              
        sum += delta;
        v0 += ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1);
        v1 += ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3);
    }                                                       
    v[0] = v0; v[1] = v1;
}

这里的aabbccdd33ad3cee一个对应k2一个对应k3

现在v1没必要继续展开了,需要先看看后半段flag最后一轮的算法,来交叉对比确认sum_、上一轮的v1和k0 k1。

如法炮制,可以得到后半段flag:

[19:53:45 151][libsec2026.so 0x13b3c8] [0225c91a] 0x1213b3c8: "lsr w2, w8, w9" w8=0xa0e6e39d w9=0x7 => w2=0x141cdc7
[19:53:43 750][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0x951d2164 x9=0xa0e6e39d => x21=0x136040501
[19:53:42 803][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0xf95d664a x9=0xe6e39d0 => x21=0x107cba01a
[19:53:33 703][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0x12aa364c x9=0xcb1ac6 => x21=0x13755112
[19:53:32 649][libsec2026.so 0x13cb3c] [220108ca] 0x1213cb3c: "eor x2, x9, x8" x9=0xd0c4e7cd x8=0x523396ca => x2=0x82f77107
[19:53:23 120][libsec2026.so 0x13cb3c] [220108ca] 0x1213cb3c: "eor x2, x9, x8" x9=0x12ede559 x8=0x75fcbe66 => x2=0x67115b3f
[19:53:11 477][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0xffa89e37 x9=0xf6389ca5 => x21=0x1f5e13adc


[19:53:45 655][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0x12aa364c x9=0x141cdc7 => x21=0x13ec0413
[19:53:44 648][libsec2026.so 0x13cb3c] [220108ca] 0x1213cb3c: "eor x2, x9, x8" x9=0x36040501 x8=0x7cba01a => x2=0x31cfa51b
[19:53:34 246][libsec2026.so 0x13cb3c] [220108ca] 0x1213cb3c: "eor x2, x9, x8" x9=0x13755112 x8=0x82f77107 => x2=0x91822015
[19:53:23 643][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0x67115b3f x9=0xf5e13adc => x21=0x15cf2961b


[19:53:46 197][libsec2026.so 0x13cb3c] [220108ca] 0x1213cb3c: "eor x2, x9, x8" x9=0x13ec0413 x8=0x31cfa51b => x2=0x2223a108
[19:53:35 689][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0x91822015 x9=0x5cf2961b => x21=0xee74b630

[19:53:46 725][libsec2026.so 0x13b5f4] [1501098b] 0x1213b5f4: "add x21, x8, x9" x8=0x2223a108 x9=0xee74b630 => x21=0x110985738

可以确认12aa364cf95d664a也是k

v0 = 10985738
= 2223a108 + ee74b630
= (13ec0413 ^ 31cfa51b) + (91822015 + 5cf2961b)
= ((12aa364c + 0141cdc7) ^ (36040501 ^ 07cba01a)) + ((13755112 ^ 82f77107) + (67115b3f + f5e13adc))
= ((12aa364c + (a0e6e39d >> 7)) ^ ((951d2164 + a0e6e39d) ^ (f95d664a + 0e6e39d0))) + (((12aa364c + 00cb1ac6) ^ (d0c4e7cd ^ 523396ca)) + ((12ede559 ^ 75fcbe66) + (ffa89e37 + f6389ca5)))

这一轮计算中和v1共同用到了a0e6e39d951d2164那这俩一个是sum一个是上一轮v0。分别搜索来源,可以看到参951d2164运算的29e59c9f大量出现。

图片描述

图片描述

那么可以确定a0e6e39d是上一轮v1,最右侧的式子一直是加法展开,合理推断ee74b630就是上一轮v0。951d2164是这一轮的sum,29e59c9fdelta`

那么目前可以确定:

  • 28轮运算
  • delta=29e59c9f
  • 四个k分别是12aa364cf95d664aaabbccdd33ad3cee

化简一下两个式子:

v0 = 10985738
= 2223a108 + v0
= (13ec0413 ^ 31cfa51b) + v0
= ((12aa364c + 0141cdc7) ^ (36040501 ^ 07cba01a)) + v0
= ((k0 + (v1 >> 7)) ^ ((sum + v1) ^ (k1 + 0e6e39d0))) + v0

v1
= 0x5736fde4 + v1
= (ab408f96 ^ fc767272)  + v1
= ((k2 + 84c2b9) ^ (a5b5789c ^ 59c30aee)) + v1
= ((k2 + (v0 >> 5)) ^ ((sum + v0) ^ (k3 + 2615ce00))) + v1

ok那现在就差展开0e6e39d02615ce00了。

图片描述

图片描述

[19:53:42 291][libsec2026.so 0x139e74] [0221c99a] 0x12139e74: "lsl x2, x8, x9" x8=0xa0e6e39d x9=0x4 => x2=0xa0e6e39d0
[19:53:47 241][libsec2026.so 0x139e74] [0221c99a] 0x12139e74: "lsl x2, x8, x9" x8=0x10985738 x9=0x6 => x2=0x42615ce00

那么现在可以明确:

v0 = ((k0 + (v1 >> 7)) ^ ((sum + v1) ^ (k1 + v1 << 4))) + v0
v1 = ((k2 + (v0 >> 5)) ^ ((sum + v0) ^ (k3 + v0 << 6))) + v1

还差输入。尝试搜88b5ed2d、88b5、ed2d都没搜到。尝试按照初赛时搜字符串对应的hex6432646535623838搜到了。搜delta最早参与的非0加法,可以看到64326465参与了运算。

图片描述

那么到这基本可以确定最终加密算法就是

void encrypt(uint32_t* v) {
    uint32_t v0 = v[0], v1 = v[1], sum = 0, i;              
    uint32_t delta = 0x29e59c9f;                           
    uint32_t k0 = 0x12aa364c;
    uint32_t k1 = 0xf95d664a;
    uint32_t k2 = 0xaabbccdd;
    uint32_t k3 = 0x33ad3cee;    
    for (i = 0; i < 32; i++) {                              
        sum += delta;
        v0 = ((k0 + (v1 >> 7)) ^ ((sum + v1) ^ (k1 + v1 << 4))) + v0
        v1 = ((k2 + (v0 >> 5)) ^ ((sum + v0) ^ (k3 + v0 << 6))) + v1
    }                                                       
    v[0] = v0; v[1] = v1;
}

验证了几组token,没问题。

图片描述token生成flag算法:token2flag4.c

flag生成token算法:flag2token4.c

这里也说一下这个解密程序的思路。

解密程序就是把这 28 轮按完全相反的顺序逐轮还原。程序先读入 16 位十六进制串,再用 sscanf(input, "%8x%8x", &hi, &lo) 把前 8 位和后 8 位分别读出来。正向输出顺序是 v[1], v[0],所以这里要先执行 v[1] = hi,再执行 v[0] = lo

程序从第 28 轮开始往前还原到第 1 轮。每一轮先计算这一轮对应的 sumsum = i * delta

正向每一轮是先更新 v0,再用更新后的 v0 去更新 v1,所以逆的时候先还原v1再还原v0

核心算法其实就是:

void decrypt(uint32_t *v) { 
    uint32_t v0 = v[0], v1 = v[1], i; 
    uint32_t delta = 0x29e59c9f; 
    uint32_t k0 = 0x12aa364c; 
    uint32_t k1 = 0xf95d664a; 
    uint32_t k2 = 0xaabbccdd; 
    uint32_t k3 = 0x33ad3cee; 
    for (i = 28; i > 0; i--) { 
        uint32_t sum = i * delta; 
        v1 = v1 - (((k2 + (v0 >> 5)) ^ ((sum + v0) ^ (k3 + (v0 << 6))))); 
        v0 = v0 - (((k0 + (v1 >> 7)) ^ ((sum + v1) ^ (k1 + (v1 << 4))))); 
    } v[0] = v0; v[1] = v1; 
}

反调试

代码保护

代码保护这里主要有

代码段有junk code、控制流平坦化、vmp

还有不少这种字符串混淆,运行时解密:

图片描述

图片描述

图片描述

图片描述

反调试主要就是这里启的这仨检测线程。

图片描述

sub_9C654

这个函数比较直观,标准反调试做法,fork出子进程,子进程attach父进程,防止父进程再被其他进程attach。

图片描述

图片描述

这里他还调用了sub_95cc0,会读取父进程的硬件断点寄存器,并每轮最多在8个硬件断点槽里写{0, 7}。这里应该是干扰硬断。

图片描述

图片描述

总结一下,这里就是子进程attach父进程反调试,然后子进程向父进程的硬件断点槽里写{0, 7},干扰调试。

sub_9CDC4

这里的控制流平坦化强度明显高于trigger3那里遇到的了。比较难受的是直接D810解不掉,赛后有空看看log分析一下原因。

可以根据他的字符串和系统调用来推断一些功能。这里解出来的字符串有gum-js-loop gmain .. /proc/self/task /proc/self/task/%s/status

显然gum-js-loop gmain这俩是检测frida特征的。

先调用sub_99418,这里用到了/proc/self/fd /proc/self/fd/%s linjector

图片描述

图片描述

图片描述

图片描述

除了解密字符串外也没啥外部调用。只看系统调用也能猜个七七八八了。

opendir("/proc/self/fd")readdir() 遍历每个 fd 项,拼出完整路径,对每个条目做 lstat(),必要时再 readlinkat() 读出最终目标,最后检查链接目标里是否出现 linjector。只要命中,就直接走 exit_group

回到sub_9cdc4

图片描述

图片描述

图片描述

图片描述

这里跟踪字符串

图片描述

交叉引用

图片描述

图片描述

合理推断这里是在对比status中是否有gum-js-loop如果有就走到exit_group

那么这里的检测也很明确了,先看/proc/self/task/目录下有哪些tid,然后逐个读/proc/self/task/&lt;tid&gt;/status对比gum-js-loopgmain来检测是否存在frida的痕迹。

..推测是用来作为白名单的字符串,避免读到/proc/self/status

总结一下,一个是遍历/proc/self/fd下的每·个项,检查链接目标里是否出现 linjector。一个是读/proc/self/task/&lt;tid&gt;/status对比gum-js-loopgmain来检测frida痕迹。

sub_9B7D8

先休眠3s,然后setpriority(PRIO_PROCESS, 0, 19)降低当前线程优先级,然后取系统页大小,计算exit 所在代码页对齐基址,交叉引用还可以看到这里还把exit所在页的权限改成了可读可执行。

图片描述

这里还直接对比了exit前四个字节指令字,应该是检测对exit的inlinehook

图片描述

图片描述

然后调用

图片描述

这里相当于fd = openat(AT_FDCWD, "/proc/self/maps", O_RDONLY, 0); ,传入的v92就是/proc/self/maps。v29就是fd。交叉引用可以看到调用了这里。一个字节一个字节读里面的内容

图片描述

检测到换行后就调用sub_98564去分析这一行

图片描述

图片描述

这里能解出三个字符串 %lx-%lx %s frida /memfd:

解完字符串,结合这里的输入大致就能猜到这里在检测这/proc/self/maps里的一行是否有 frida hook的特征,以及获取这一行的起始地址、结束地址、权限字符串。

回到sub_9B7D8,这里从unk_1682E0拷20个字节然后和xmmword_58640异或,解出来就是libgodot_android.so,然后传递给sub_96A00。这里的第二个参数其实就是后面CRC校验的期望值。

图片描述

sub_96A00混淆程度比较轻,直接枚举当前进程所有已加载模块。找到libgodot_android.so后,sub_9EFB4继续遍历这个模块的 Elf64_Phdr 数组,并筛选满足:p_type == PT_LOAD p_flags & PF_X的 header。其实就是找 text 段。找到以后,把两个值写回上层上下文:

图片描述

然后调用sub_96A00。这里是个标准的CRC32,每处理1000字节左右会sleep一下,并写入一个时间戳。这个和Tick维护的是同一个变量。CRC计算后返回到上层对比。

图片描述

总结一下,sub_9B7D8这条线一个是检测exit入口的汇编,一个是检测/proc/self/maps里的frida痕迹,一个是计算libgodot_android.so的text段的crc防inline hook


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

最后于 1天前 被Kevin·编辑 ,原因:
收藏
免费 1
支持
分享
最新回复 (3)
雪    币: 104
活跃值: (8297)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
tql
1天前
1
雪    币: 225
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
太强了,我一开始也想用trace还原cff流程和最后VM的污点分析了,碍于对安卓工具不太了解不知道unidbg可以追
12小时前
1
雪    币: 34
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
盘子蛋糕 太强了,我一开始也想用trace还原cff流程和最后VM的污点分析了,碍于对安卓工具不太了解不知道unidbg可以追[em_006]
这题的vmp直接trace分析有点过于简单了,感觉出题人似乎并没考虑在这种解法上设置障碍?虽然能这么做出来,但不知道有没有分:(
6小时前
0
游客
登录 | 注册 方可回帖
返回