beVX挑战:手术台上的逆向工程
日期
2018年3月11日
作者
Axel “0vercl0k” Souchet
分类
逆向工程
标签
逆向工程, beVX
引言
大约两周前,我的朋友mongo挑战我解决SSD团队为OffensiveCon2018(2月在柏林举办的安全会议)设置的一个逆向工程谜题。挑战二进制文件可在此处下载,原始推文在此。
在这个挑战中,你的任务是逆向工程一个提供某种加密服务的二进制文件,并需要检索一个私钥(即flag)。还提供了一个运行挑战的远程服务器供你进行攻击。这看起来很有趣,因为它不同于常见的keygen-me类型的逆向工程挑战。
不幸的是,在远程服务器运行时我没有机会玩这个(组织者在收到三位获胜者的解决方案后关闭了服务器)。不过,很酷的是,你可以轻松地制作自己的服务器在家玩……这就是我最终所做的。
我认为这个挑战足够有趣,并且我也想更定期地写作,所以这里有一个小文章描述我如何滥用服务器来获取私钥。希望你不觉得太无聊 :-)。
目录:
- 引言
- 在家玩
- 侦察
- 找到针:访问密钥材料
- 弯曲针:构建Oracle
- 热泄漏
- 结论
在家玩
在我开始带你了解我的解决方案之前,这里有一个非常简单的方法让你在家设置。你只需要在此处下载二进制文件,并创建一个假的加密库,导出加密/解密例程以及密钥材料(private_key / private_key_length):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
uint32_t number_of_rows = 16;
uint32_t private_key_length = 32;
uint8_t private_key[32] = { 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 1, 1, 0 };
const uint64_t k = 0xba0bab;
uint64_t decrypt(uint64_t x) {
printf("decrypt(%" PRIx64 ") = %" PRIx64 "\n", x, x ^ k);
return x ^ k;
}
uint64_t encrypt(uint64_t y) {
printf("encrypt(%" PRIx64 ") = %" PRIx64 "\n", y, y ^ k);
return y ^ k;
}
|
上述文件可以使用以下命令编译:
1
|
$ clang++ -shared -o lib.so -fPIC lib.cc
|
将生成的lib.so共享库文件放在与挑战相同的目录中,应该足以使其正常运行。你甚至可以通过socat将其挂接到套接字,模拟需要攻击的远程服务器:
1
|
$ socat -vvv TCP-LISTEN:31337,fork,reuseaddr EXEC:./cha1
|
如果一切按广告宣传的那样工作,你现在应该能够远程与挑战交互,并在连接时看到以下菜单:
1
2
3
4
5
6
7
8
9
10
|
Please choose your option:
0. Store Number
1. Get Number
2. Add
3. Subtract
4. Multiply
5. Divide
6. Private Key Encryption
7. Binary Representation
8. Exit
|
现在去玩得开心吧 :)
侦察
当我开始看一个挑战时,我总是花时间多了解一点它周围的故事。这既给了我方向,也帮助我识别陷阱。例如在这里,故事告诉我我们有一个秘密要外泄,将分析重点放在与这个秘密交互/管理的代码上听起来是个好主意。这个挑战也被宣传为逆向工程任务,所以我并没有真正期望任何pwning。我在寻找逻辑缺陷、设计问题或非常受限的内存损坏类型的问题。
一旦base64解码,二进制文件是一个10KB(小)未受保护的ELF64。二进制文件是PIE,并从名为lib.so的文件中导入一堆数据/函数,我们无法访问该文件。根据我们得到的故事,我们可以预期密钥材料和加密/解密例程都存储在那里。
1
2
3
4
|
extern:0000000000202798 ; _QWORD __cdecl decrypt(unsigned __int64)
extern:0000000000202798 extrn _Z7decryptm:near ; DATA XREF: .got.plt:off_202020↑o
extern:00000000002027A0 ; _QWORD __cdecl encrypt(unsigned __int64)
extern:00000000002027A0 extrn _Z7encryptm:near ; DATA XREF: .got.plt:off_202028↑o
|
尽管挑战似乎使用C++和STL,但反汇编/反编译的代码非常易读,所以不需要太多时间就能多了解这个东西在做什么。
根据菜单所说,它看起来像一个数字存储;不管那是什么意思。快速逆向工程的getter和setter函数,我们更多地了解了什么是数字。首先,每个存储在存储中的数字(number_t)在插入存储时被加密,在检索出存储时被解密。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
uint64_t write_number_to_store(number_t *number2write, uint64_t value, bool encrypted)
{
uint64_t encrypted_val = value;
if(encrypted) {
encrypted_val = encrypt(value);
}
size_t bitidx = 31LL;
do
{
uint8_t curr_encrypted_val = encrypted_val;
encrypted_val >>= 1;
number2write->bytes[bitidx--] = curr_encrypted_val & 1;
} while ( bitidx != -1 );
return encrypted_val;
}
|
有趣的是,函数的第三个参数允许你将一个明文数字写入存储,但显然在挑战中没有使用……哦好吧 :)
一旦数字被加密,它们还会通过一个非常简单的转换进行编码:每个位被写入一个字节(0或1)。由于存储的数字是32位整数,自然存储每个数字需要32字节。
1
2
3
|
00000000 number_t struc ; (sizeof=0x20)
00000000 bytes db 32 dup(?)
00000020 number_t ends
|
在看了其他选项之后,结合上述内容,很容易恢复部分保持存储全局状态的结构(state_t)。存储的最大容量为32个槽,存储的当前大小存储在某类状态变量的低5位(2**5 = 32)中。此时我开始起草结构state_t:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
00000000 state_t struc ; (sizeof=0x440, align=0x8)
00000000 numbers number_t 32 dup(?)
00000400 pkey dq ?
00000408 size db ?
00000409 db ? ; undefined
0000040A db ? ; undefined
0000040B db ? ; undefined
0000040C db ? ; undefined
0000040D db ? ; undefined
0000040E db ? ; undefined
0000040F db ? ; undefined
00000410 x dw ?
00000412 xx db 38 dup(?)
00000438 xxx dq ?
00000440 state_t ends
|
私钥加密功能看起来比其他功能更复杂。但就我而言,它对你之前存储的数字进行“算术”运算:一个称为消息,一个称为密钥。
在真正开始寻找问题之前,我需要回答两个问题:
通过查看存储初始化代码,我们可以回答第一个问题。private_key的内容被放入存储的槽number_of_rows + 2中。之后,存储的大小被设置为number_of_rows。这个操作的结果是——假设所有与存储交互的命令都有适当的边界检查——用户无法直接访问密钥。
找到针:访问密钥材料
幸运的是,代码不多,所以审计每个命令很容易。所有命令乍一看都很好地进行了清理。每次应用程序请求一个槽索引时,它都会在使用前根据存储大小进行边界检查。如果你试图访问一个越界的槽,它甚至会抛出一个越界异常。以下是除法操作的示例(number_store是全局状态,NumberOfNumbers是一个掩码,提取大小字段的低5位以计算存储的当前大小):
1
2
3
4
5
6
7
8
9
10
11
12
|
const uint32_t NumberOfNumbers = 0x1F;
case Divide:
arg1_row = 0LL;
arg2_row = 0LL;
result_row = 0LL;
std::cout << "Enter row of arg1, row of arg2 and row of result" << std::endl;
std::cin >> arg1_row;
std::cin >> arg2_row;
std::cin >> result_row;
store_size = number_store->size & NumberOfNumbers;
if(arg1_row >= store_size || arg2_row >= store_size || result_row >= store_size)
goto OutOfRange;
|
不过有一个陷阱。如果我们更仔细地查看与存储大小字段交互的每个代码实例,会发现一些有点奇怪的事情。
在上面的屏幕截图中,你可以看到高亮的交叉引用看起来有点奇怪,因为它实际上是通过设置第三位(0b1000)来改变大小。如果我们拉取这个函数的代码,可以看到以下内容:
1
2
3
4
5
6
7
8
9
10
11
|
case PrivateKeyEncryption:
number_store->size |= 8u;
msg_row = 0uLL;
key_row = 0uLL;
std::cout << "Enter row of message, row of key" << std::endl;
std::cin >> msg_row;
std::cin >> key_row;
store_size = number_store->size & NumberOfNumbers;
if(msg_row >= store_size || key_row >= store_size) {
number_store->size &= 0xF7u;
std::cout << "Row number is out of range" << std::cout;
|
我最初完全忽略了这个细节,因为这个位在错误时被正确清除(使用0xF7掩码)。这个位听起来也被用作启动或停止加密过程的开关。我可以清楚地看到它在加密循环中使用,如下所示:
1
2
3
4
5
6
7
8
9
|
while(number_store->size & 8) {
// do stuff
std::cout << "Continue Encryption? (y/n)" << std::endl;
std::cin >> continue_enc;
if(continue_enc == 'Y' || continue_enc == 'y') {
// do encryption..stuff
} else if(continue_enc == 'n' || continue_enc == 'N') {
number_store->size &= 0xF7u;
}
|
问题是,由于这个位与存储大小的第5位重叠,设置它也意味着我们现在可以访问从索引0到槽0x10|8=0x18的槽。如果前面有点混乱,考虑以下C结构:
1
2
3
4
5
6
7
|
union {
struct {
size_t x : 3;
size_t bit3 : 1;
} s1;
size_t store_size : 5;
} size = {};
|
正如我们之前所说,密钥材料存储在槽number_of_rows + 2 = 0n18中。
1
2
3
4
5
6
7
8
9
10
11
12
|
__int64 realmain(struct_buffer *number_store) {
nrows = number_of_rows;
pkey_length = private_key_length;
pkey = &number_store->numbers[number_of_rows + 2];
is_pkey_empty = private_key_length == 0;
number_store->pkey = pkey;
if(!is_pkey_empty) {
memmove(pkey, &private_key, pkey_length);
}
number_store->pkey->bytes[pkey_length - 1] |= 1u;
number_store->size = nrows & 0x1F | number_store->size & 0xE0;
// ...
|
酷豆子,我想我们现在有办法让应用程序与包含私钥的槽交互,这听起来像是……进展,对吧?
弯曲针:构建Oracle
能够通过私钥加密功能访问密钥很好,但到目前为止还没有给我们太多。我们需要更多地了解这个功能在做什么,然后才能想出滥用它的方法。在花了一些时间逆向工程和调试之后,我将它的逻辑分解为以下步骤:
- 用户输入消息的槽和密钥的槽(这两个槽中的一个或两个都可以是私钥槽),
- 存储在密钥槽中的数字被复制到全局状态中;在一个我称为keycpy的字段中,
- 全局状态中的另一个字段被初始化为1;我称这个为magicnumber,
- 实际的加密过程包括:将magicnumber自乘,如果当前密钥字节是1,则将其乘以消息槽中的数字(你之前输入的)。如果当前密钥字节是0,则不会发生额外的事情(见下文),
- 一旦加密完成或用户停止,结果的magicnumber被存储回消息槽(覆盖其先前的内容)。
美化后的代码看起来像这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
while(number_store->size & 8) {
// do stuff
std::cout << "Continue Encryption? (y/n)" << std::endl;
std::cin >> continue_enc;
if(continue_enc == 'Y' || continue_enc == 'y') {
number_store->magicnumber *= number_store->magicnumber;
if(number_store->keycpy[idx] == 1) {
uint64_t msg = 0;
read_number_from_store(&number_store->numbers[msg_slot & 0x7F], &msg);
number_store->magicnumber *= msg;
}
} else if(continue_enc == 'n' || continue_enc == 'N') {
number_store->size &= 0xF7u;
}
}
|
你可能已经想到,我们基本上有两条途径(技术上三条我猜……但一条显然没用 :-D)。要么我们将私钥作为消息加载,要么将其作为密钥参数加载。
如果我们做前者——基于加密逻辑——我们最终无法真正控制magicnumber的计算方式。请记住,存储中的数字都使用encrypt函数加密,当密钥从存储中检索时,它不会被解密(这不是正常的获取操作),而只是memcpy到keycpy字段,如下所示:
1
|
memmove(number_store->keycpy, &number_store->numbers[keyslot], 32);
|
所以即使我们可以在存储中插入一个已知值,我们也不会真正知道它加密后会是什么样子。
如果我们将私钥作为密钥加载,我们现在就有了……一个Oracle!由于用户可以在任何时候停止解密过程,攻击可以如下工作(假设你想泄漏私钥的一个字节):
- 将值3加载到槽0中,
- 使用私钥加密功能,密钥槽18(私钥写入的位置)和消息槽0(我们加载值3的地方),
- 根据密钥当前字节的值,magicnumber的值可能是(1*1)3=3或(11)=1。如果用户停止加密,则这个数字被写入存储的槽0中,
- 获取槽0中的值。如果值是3,则密钥字节是1,否则是0。
遵循这个小配方允许我们泄漏位n,一旦完成,允许你进一步推进加密一轮并泄漏位n+1……依此类推。
这很棒,但在正确执行攻击之前,我们还需要解决两个小细节。
在实际加密之前运行的代码会扫描keycpy并跳过任何前导零。这意味着,例如,如果密钥是0b00010101,我们上面描述的实际加密逻辑将在跳过前三个前导零之后开始。为了知道有多少个这样的零存在,我们可以触发私钥加密功能并加密……直到不能再加密(每个数字只有32字节,所以最多你得到32轮)。你只需要计算你经历了多少轮,与32的差就是前导零的数量。
第二个小细节是,从技术上讲,我们不知道私钥在远程服务器上存储在哪个槽中(记住,共享库没有提供给我们)。这意味着我们需要以某种方式找出这一点。以下是我们所知道的:
- 密钥存储在number_of_rows + 2,
- 存储的大小初始化为number_of_rows。
如果我们结合这两个事实,我们可以尝试读取从第一个槽到最后一个槽的每一个槽。第一次,它以“越界”异常停止时,你就有了你的number_of_rows :-)
哦,顺便说一下,还记得我之前提到的第三个愚蠢的可能性吗?将私钥作为消息和密钥的槽使用,最终会……覆盖私钥本身,所以没什么用。
热泄漏
以下是我丑陋的Python攻击实现:
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
|
# Axel '0vercl0k' Souchet - 3-March-2018
import sys
import socket
host = ('192.168.1.41', 31337)
def recv_until(c, s):
buff = ''
while True:
b = c.recv(1)
buff += b
if s in buff:
return buff
return None
def addn(c, r_n, n):
recv_until(c, '8. Exit\n')
c.send('0\n%d\n%d\n' % (r_n, n))
def readn(c, r_n):
recv_until(c, '8. Exit\n')
c.send('1\n%d\n' % r_n)
recv_until(c, 'Result is ')
res = c.recv(1024).splitlines()
return int(res[0], 10)
def main():
r_key = 18
r_oracle = 0
# first step is to find out how many 0's the key starts with,
# to do so we ask for an encryption where the key is the pkey,
# and we encrypt until we cannot and we count the number of
# 'Continue Encryption?'. 32 - this number should give us the
# number of 0s
n_zeros = 32
c = socket.create_connection(host)
addn(c, r_oracle, 1337)
recv_until(c, '8. Exit\n')
c.send('6\n%d\n%d\n' % (r_oracle, r_key))
recv_until(c, 'Continue Encryption? (y/n)\n')
for _ in range(32):
c.send('y\n')
n_zeros -= 1
if 'Continue Encryption? (y/n)' not in c.recv(1024):
break
if n_zeros > 0:
print 'Found', n_zeros, '0s at the start of the key'
leaked_key = [ 0 ] * n_zeros
v_oracle = 3
# now we can go ahead and leak the key
|