逆向工程挑战:beVX加密服务的私钥提取

本文详细解析了如何通过逆向工程挑战beVX的加密服务,利用设计漏洞访问私钥存储位置,构建Oracle机制逐位泄露私钥,最终实现完整密钥提取的过程。

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
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计