This page looks best with JavaScript enabled

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

 ·  ☕ 12 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
}

决赛

决赛的赛题我有参与设计,所以我这边也就不给出一份特别详细的题解了。这边只介绍我在题目设计层面的一些想法。

同一个出题人在设计同类型的题目难免会有思路上的交集,单Driver无三环程序交互通信,check注册表影响DriverEntry返回值,注册表传递check flag…这些其实都不算是陌生的姿势了,历年NepCTF的Qriver上都有这些操作的影子。在同类型的题目上从头到尾全部是新的设计这几乎是不可能的,所以这种情况也是难以避免的,但我觉得对于做题者来说,是公平的,因为之前的也都是公开的赛事。甚至可以说,这些操作姿势在历史的比赛中已经公开出现过,为什么还不会做(?

然后再说VMP,每年都会因为题目加了强虚拟化壳而引发争议。实际上每一年,每一道加了强虚拟化壳的题目,其关键函数,或者说需要分析的函数都是不会加虚拟化的。也就是说,通过对部分函数添加虚拟化来构造出一个预期的解,参赛的选手需要具备预期的能力才能串联相关的逻辑链并成功解题。或者更直白的说,加了虚拟化的函数都是不需要分析的!Ok,那肯定有的同学要杠,为什么一定要加强虚拟化壳,不可以不加吗。那我问你,在游戏安全中,分析实际商业化软件或者外挂中,这些程序它们也都是加了强虚拟化壳,但通常情况下他们也会因为性能考虑而无法对所有函数开启虚拟化混淆功能,在实际环境中对这些样本分析时,也是要通过一些行为以及没有添加保护的函数抽丝剥茧的分析。游戏安全竞赛不是CTF,考察的是参赛者在实际游戏安全场景中的综合能力。当然,不排除有的选手真的很强,真的可以直接把虚拟化混淆的函数还原,并快速秒题。我们认为他能够在竞赛中以这样“暴力”的方式解题,他同样也可以在真实的对抗环境中还原虚拟化混淆并获得更为准确的分析结论,这样的大牛给他高分又何妨

然后在算法上,我的出题风格一向是崇尚算法从简,我不希望选手把大量的时间浪费在解决各种各样的套娃算法上,应该用更多的精力关注聚焦Windows相关的能力。但是初赛的TEA算法又设计的太简单了,以至于很多人直接通过一些gadget猜到了算法然后快速秒题。所以这次竞赛做了一些不一样的事,我觉得一个做安全的同学,如果在懂安全的基础上,还懂一点算法是更好的。所以设计了一个简单的算法,期望做题者能够在理解算法的情况下对其优化并解得对应的Key。但是考虑到部分大手子对Windows非常精通但是真的不懂算法,不可能因为这个点把他直接卡死,所以我们精心设计了参数并在DbgPrint里给出了hint,题目运行约12个小时也会自动输出正确的Key。也就是懂算法的同学可以通过理解算法并优化的方式秒解得Key,但是不懂算法的同学也可以通过浪费一些做题时间来成功进入下一关。

Key生成算法

下面来解析一下这个Key生成算法。这种理解算法+优化的题型其实在以往的CTF都有出现,其通常可以分为两类:题目运行一个模拟算法但由于时间复杂度过高,甚至不能在比赛时间内跑出来,需要理解算法的情况下优化(比如本题);题目在模拟级数、积分等操作但由于精度的原因不能正确计算得到flag(如2021强网杯-ezmath

先给出算法的源代码

 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
typedef struct {
    int n;
    int k;
    uint64_t left_result;  // 存储左子树结果
    uint64_t right_result; // 存储右子树结果
    int state;             // 0:未展开 1:左完成 2:右完成
} StackFrame;

uint64_t modified_pascal_iterative(int target_n, int target_k) {
    std::stack<StackFrame> call_stack;
    uint64_t final_result = 0;

    if (!target_n)
    {
        DbgPrint("This step will take a long time to run, maybe 12 hours (depending on your machine), maybe you have a better way?");
    }
    else
    {
        // 初始化栈
        call_stack.push({ target_n, target_k, 0, 0, 0 });

        while (!call_stack.empty()) {
            StackFrame& current = call_stack.top();

            // 基础情形处理
            if (current.k == 0 || current.k == current.n) {
                final_result = 1;
                call_stack.pop();
                continue;
            }

            // 状态机处理
            switch (current.state) {
            case 0: // 初始状态,展开左子树
                current.state = 1;
                call_stack.push({ current.n - 1, current.k, 0, 0, 0 });
                break;

            case 1: // 左子树计算完成
                current.left_result = final_result;
                current.state = 2;
                call_stack.push({ current.n - 1, current.k - 1, 0, 0, 0 });
                break;

            case 2: // 右子树计算完成
                current.right_result = final_result;
                // 计算结果并返回
                final_result = current.left_result
                    + current.right_result
                    + (current.n % 5);
                call_stack.pop();
                break;
            }
        }
    }

    return final_result;
}

这个算法其实就是一个魔改后的杨辉三角,无记忆的模拟算法导致时间复杂度过高(O2^n),同时模拟递归调用栈防止真递归爆栈。只要能理解这是一个杨辉三角的模拟算法就能非常简单的写出其递推公式

$$
C(n,k) =
\begin{cases}
1 & \text{if } k = 0 \text{ or } k = n \
C(n-1,k) + C(n-1,k-1) + (n \bmod 5) & \text{other}
\end{cases}
$$

有了这个递推表达式后,可以马上写出动态规划or记忆化搜索的优化版算法。这里给出动态规划的解法,直接优化到Onk多项式级时间复杂度

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def optimized(n, k):
    dp = [[0]*(k+1) for _ in range(n+1)]
    for i in range(n+1):
        for j in range(min(i,k)+1):
            if j == 0 or j == i:
                dp[i][j] = 1
            else:
                dp[i][j] = dp[i-1][j] + dp[i-1][j-1] + (i%5)
    return dp[n][k]

N = 44
K = 22
print(optimized(N, K))

在选手们的WriteUp中可以看到,不乏有同学是通过分析还原函数以及数据结构,将优化后的算法提交给GPT分析,由GPT给出优化后的算法。这个也属于预期解,因为现在LLM的能力太强大了,几乎一般的算法都可以做到识别理解,但为了防止GPT直接秒了这一部分,可以看到在驱动程序内的栈模拟特意使用了std::stack,也就是如果选手不能够逆向出这个函数的数据结构,直接将IDA分析出来的伪函数代码复制到GPT内,是无法识别的。

VT分析

这道题实际上是通过VT的各种能力(指令自陷、Hook指令、EPTHook等)来改变程序原本的执行流程,来实现动静态分析不一致的加密flag。这边不详细介绍每一部分的加密是如何实现的,只介绍一下整体的、我预期的解题逻辑链。

首先因为题目本身加了VMP,预期肯定是要先加载驱动程序后,DUMP一份然后修复IAT分析。当然看选手的WP的时候,看到某大佬直接纯静态硬撕,而且甚至把大部分的逻辑都分析清楚了,确实太顶。拿到修复好IAT的Dump后,如果有VT经验的,其实是可以直接识别出来这个使用的是hv框架。如果没有相关的分析经验,可以看到有同学用lumina识别到了部分函数符号,是hv命名空间的,然后就去搜索相关的函数找到hv框架参数,再编译一份用bindiff还原符号。对于解这道题来说,不管是分析加密算法以及VT流程,还是解决第五问的问题,识别出hv框架都是非常必要的。

最后来说说第五问,如果能顺利完成前四问,不难理解第五问的问题就是如何检测VT,当然,检测VT的手段太多了,所以加了限制条件,提供的检测手段必须要能够检测到题目,并且要相对准确。hv框架实现的非常好,处理掉了非常多的东西,所以很多检测VT的手段在hv框架上是无效的。所以这道题的真正考点是,如何才能够检测到hv框架的VT。这里补充一下,很多同学提交的题解里都有执行时间检测方法。通过VT Hook了特定指令(cpuid、rdmsr等)从而造成在有无Host的情况下执行相同数量的指令耗时不同来做时间差检测。如果同一参赛者给出了不同的解法但原理上均为指令的运行时间差异,会视为一种解法。

Share on

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