This page looks best with JavaScript enabled

腾讯游戏安全竞赛-客户端安全 2025 WriteUp

 ·  ☕ 7 min read · 👀... views

又到了一年一度的游戏安全竞赛,今年的出题人不是我,所以也以普通玩家的身份来做一下题目。

初赛

今年的赛题相较于前几年,会更偏向于CTF的形式。我自己做初赛大概花了4个小时左右,下面来分享一下我的解法。

首先拿到ACEFirstRound.exeACEDriver.sys,要求解得flag。按照正常的分析思路,先看Ring3部分,从输入开始往下理。

ACEFirstRound.exe

先挂调试,从main开始,发现调试运行时程序会抛异常,而且每次抛异常的地方都不太一样,怀疑是有多线程在反调
antidebug

在main函数之前注册反调试的方法不多,排除tls后,大概率就是注册了init_array函数(如全局类对象构造函数)。跟一下scrt_common_main_seh或者手动找一下init_array可以很容易的定位到sub_1400010D0,直接将这个函数ret即可。
register_antidebug.png

接下来可以正常的调试分析main函数,注意到sub_140001370内是在注册拉起驱动程序,并且拉起后程序会被Detach且无法打开句柄,认为是注册的时候驱动会清空掉三环的调试器并挂EPROCESS->Protection。对于这种情况可以调试三环的时候直接手动setIP跳掉注册函数,先动调分析完Ring3的逻辑。

后面的部分能看的出是用C++的STL并开了编译优化,动调往下走可以很容易看出是在check输入格式,要求以ACE_开头,且总长度大于等于20,分割出输入ACE_后的部分。sub_1400020D0是第一个算法函数,通过除以58和取余58等特征,能非常容易的识别出这是一个以abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ123456789为表的BASE58编码。在编码完成后转置结果并在开头添加上一个’@'

再往后,0x1400039A9的位置,是第二处加密,简单的异或了一下,伪代码如下

1
2
3
4
uint8_t xor_key[] = { 's', 'x', 'x' };
for (int i = 0; i < length; ++i) {
    enc[i] = buff[i] ^ xor_key[i % 3];
}

接下来,在0x140003AFA处,通过FilterSendMessage将结果发送到驱动,并且可以看出check也是在驱动完成的,由API的返回值告知Ring3 check结果。

ACEDriver.sys

然后分析驱动,可以看到驱动入口函数直接被加了V,且其中很多函数也都被加了TVMP的混淆。因为驱动体积非常小,只有68k,总共就没几个函数,所以可以暂时不管这些混淆,直接能定位到sub_140001000是算法函数,0x140001898在算法函数内挂了一个inlinehook。Dump一份运行时的驱动
dump

可以看到在TEA的中间部分挂出hook,运行了一段shellcode上的算法,有经验的话能一眼看出这是XTEA的片段。即题目通过inlinehook将算法改造成了一个TEA+XTEA的魔改算法。
shellcode

可以直接读汇编理解这个算法然后写解密,但这个操作有点吃“经济”,主播介绍一个不那么吃“经济和建模”的方法。可以看到sub_140001000是一个纯算法函数完全无外部依赖,意味着我们可以将函数体的所有opcode扣出来,和shellcode的opcode做一个merge生成一个bin文件,IDA分析这个bin文件得到伪代码。这边直接贴笔者merge好的opcode

48 8B C4 48 89 58 08 48 89 68 10 48 89 70 18 48 89 78 20 41 55 4C 8B EA 8B 1A 45 33 DB 8B 7A 04 4C 8B C1 8B 72 08 8B 6A 0C 44 8B 09 41 8D 53 20 44 8B 51 04 41 8B CA 45 8D 9B B9 79 37 9E C1 E9 05 41 8B C2 03 CF C1 E0 04 03 C3 33 C8 43 8D 04 13 33 C8 44 03 C9 41 8B C9 41 8B C1 C1 E0 04 C1 E9 05 33 C8 41 8B C3 48 C1 E8 0B 41 03 C9 83 E0 03 41 8B 44 85 00 41 03 C3 33 C8 44 03 D1 48 83 EA 01 75 B0 41 5D 48 8B 5C 24 08 48 8B 6C 24 10 48 8B 74 24 18 48 8B 7C 24 20 45 89 08 45 89 50 04 C3

将这段opcode用HEX工具(如010Editor)保存到bin文件中,然后用IDA打开分析,可以得到非常清晰的算法。这里我遇到了一个问题,我先用的IDA9.1打开bin文件,发现函数调用约定错了,指定了fastcall但还是识别成rdirsi传参,就像是识别成了elf文件一样。后面换用了IDA7.6调用约定才正常。
algorithm

搞清楚算法后,因为上层的调用函数都加了TVMP的混淆,我希望验证一下从r3的输入到算法函数的参数是否是匹配的。我随便构造了一个符合条件的输入"ACE_abcdefghijklmnop",完成Ring3的加密后,调用API传递给驱动的数据是

33 1B 39 44 12 40 1B 49 12 25 0C 4C 36 39 3C 0B 0B 33 14 30 3B 11 16

然后正常运行程序加载驱动,使用DBVM对驱动的0x140001034设置VT执行断点。
dbvm

即程序每次运行到这个地址的时候会被VT捕获,通过寄存器上的信息,我们可以得到算法的Key是{ ‘A’, ‘C’, ‘E’, ‘6’},以及输出到算法函数的v是0x33和0x1B。与我们Ring3传递到Ring0的数据是匹配的,即可以证明在Ring3到Ring0的sub_140001000算法函数之间没有再对输入做其他操作。
dbvm_result1

这里有一个非常非常关键的点,是DBVM触发的count,即VT断点的命中次数。可以看到count是32。我是在0x140001034位置挂的VT断点,这个位置对应于TEA的for循环开头,即这个位置其实是TEA的round。正常来说,命中这个位置的count应该是32*n,n是输入长度除以2,但是它只命中了32次,说明题目上层调用了算法函数加密后立即就做了check!我们可以利用这个点在上层做爆破(虽然题目说了不允许爆破),或者分析上层函数就可以拿到密文。

同样的,我们也能在这个函数末尾挂一个DBVM断点来验证我们的加密算法,我在0x140001094断点,可以看到也只命中了一次(再次验证我们的双字节check的想法),可以在r9d和r10d上得到加密后的密文值。
dbvm_result2

然后就是如何得到密文。可以看到算法函数sub_140001000的上层是被加了TVMP混淆的,按照前面的猜想,我们只需要去掉这一个函数的混淆就可以完成整道题目。它的上层函数是sub_140009B0A,分析这个函数的混淆发现其混淆模式非常的固定,基本就是两套模板做的间接跳转。比如以这个为例
obf
我们可以很容易的计算出这个gadget做的只是jmp,复杂化了地址计算hex(0x17DFA87AD + 0xFFFFFFFFC206185C) = ‘0x1000000014000a009’。并且这个地址其实也就是在下面的E8/E9之后。那么我们只需要将这个gadget全部nop掉,或者patch成一个jmp即可。

借助LazyIDA之类的工具,十分钟以内就可以手动去掉这个函数的混淆。可以知道0x140004060存储的就是最后的密文
check

exp

 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
#include <stdio.h>
#include <windows.h>
#include <stdint.h>

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) ^ (v0 >> 5)) + v0) ^ (sum + k[(sum >> 11) & 3]);
    } 
    v[0] = v0; v[1] = v1;
}

void decrypt(uint32_t* v, uint32_t* k) {
    uint32_t v0 = v[0], v1 = v[1], sum = 0xC6EF3720, 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++) {
        v1 -= (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + k[(sum >> 11) & 3]);
        v0 -= ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1);
        sum -= delta;
    }
    v[0] = v0; v[1] = v1;
}

unsigned int ans[42] = {
    0x0EC367B8, 0xC9DA9044, 0xDA6C2DEB, 0x88DDC9C3, 0x32A01575, 0x231DD0B4, 0x4B9E8A74, 0xD75D3E74,
    0xEAAB8712, 0xE704E888, 0xE01A31AC, 0xECAE205C, 0xA7BE7467, 0x0C6252A3, 0x1AEFEC4E, 0xC40DED44,
    0xC3C842CC, 0xDE4A0C0E, 0x7C24F3FC, 0x8FB8D001, 0x11153E6E, 0x530ED15C, 0xF4214811, 0xBEB517E0,
    0x63F91634, 0x4D96F8A5, 0xFE23EAC8, 0x2C607ADF, 0xCC43D85C, 0xFF186C5B, 0x8763E1A5, 0x9187BD58,
    0x87D1069B, 0xD7878D7B, 0x836E6B68, 0x55A0C63F, 0xD979FDB3, 0x3E524DEE, 0x7AB35C82, 0xA2F4DA8D,
    0x1708BA4C, 0x710653E6
};

int main()
{
    uint32_t key[] = { 'A', 'C', 'E', '6'};
    //uint32_t v[] = { 0x33, 0x1B };

    //encrypt(v, key);
    //printf("0x%X 0x%X\n", v[0], v[1]);    // 0x50A1C8D3 0x55E2345B

    //decrypt(v, key);
    //printf("0x%X 0x%X\n", v[0], v[1]);

    for (int i = 0; i < sizeof(ans) / 2; ++i) {
        decrypt(&ans[i*2], key);
    }

    uint8_t xor_key[] = { 's', 'x', 'x' };
    char text[42] = { 0 };
    for (int i = 0; i < 42; ++i) {
        text[i] = ans[i] ^ xor_key[i % 3];
    }
    printf("%s\n", text);   // @PksUn39kYj763ggA1HLBUCaWSZv4vs4CwSevAnQEs

    // reverse: sEQnAveSwC4sv4vZSWaCUBLH1Agg367jYk93nUskP


    // To BASE58
    // BOX: abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ123456789
    // We1C0me!T0Z0Z5GamESecur1t9*CTf
}
Share on

Qfrost
WRITTEN BY
Qfrost
CTFer, Anti-Cheater, LLVM Committer