0x00 前言
本文提到的vm是ctf裏的vm,最近vm還是很熱門的,最近的虎符,de1ctf,再到網鼎杯都有vm的身影,但是vm的知識在網上挺散的(我只找到了綠盟那篇比較系統),vm的wp也相對比較簡單,所以身爲小白的我就在這裏歸納一下網上大佬們的文章,和我的思考和學習總結,希望可以給各位看官一些幫助。
參考文章:
《加密與解密》第四版 P592 ~P593
0x01 簡單結構
《加密與解密》“中說ctf_vm不是VMware,,它類似於 P-CODE ,將一系列指令解釋成bytecode 後放到一個解釋引擎中執行,從而對軟件進行保護。”簡單來說,就是先設立一個一個不重複的標誌(一個字節就是一個標誌),然後每個標誌通過分發器(dispatcher),尋找到對應的解釋(handler),之後把這些標誌通過不同的排列組合,並把組合後的標誌存到一個固定的地方(這個大數組就是opcode),而組合後的邏輯與將保護程序邏輯相同,實現替換,從而進行保護。
一下是圖解:
說明:在ctf_vm中 分發器(dispatcher) 與解釋器 (handler)通常是用switch和case實現的,通常有如下結構
switch(opcode)
{
case 1: // handler1
......
break;
case 2: // handler2
......
break;
case 3: // handler3
......
break;
}
爲了更好的理解,我們來看一道類似vm思想的題(僞虛擬機)南郵的一道ctf
本文着重看vm保護,其他本文不再贅述。進入vm_protect。
一般分爲兩個部分,在分發器之前(紅框),會先對一些輔助的值進行賦值(這個下文具體講解),第二部分(紫框)就是具體的分發,和每一個handler,
在這道僞虛擬機裏
1)handler的含義非常好理解,所以也沒有什麼必要去理解紅框裏輔助的值,
2)本道題所保護的flag算法只是最簡單的+ - * /,所以沒必要通過opcode的順序自己來還原(15000個也不可能自己還原)
小結:本題並沒有逆向虛擬機最重要的東西(翻譯handler含義,翻譯所保護代碼的含義),放在這是爲了初步認識ctf_vm的結構。
放上師傅寫的腳本(拿idapython跑一下):
adr1 = 0x6010C0
adr2 = 0x601060
flag =[]
for i in range(0,24*4,4):
print "%x"%Dword(adr2+i)
flag.append(Dword(adr2+i))
for i in range(14997,-1,-3):
a = Byte(adr1+i)
b = Byte(adr1+i+1)
c = Byte(adr1+i+2)
if(a == 1):
flag[b] -= c
flag[b] = flag[b]&0xff
if(a == 2):
flag[b] += c
flag[b] = flag[b] & 0xff
if(a == 3):
flag[b] ^= c
flag[b] = flag[b] & 0xff
if(a == 4):
flag[b] /= c
flag[b] = flag[b] & 0xff
if(a == 5):
flag[b] ^= flag[c]
flag[b] = flag[b] & 0xff
print(''.join(map(chr,flag)))
flag: nctf{Embr4ce_Vm_j0in_R3} 嘿嘿嘿,很簡單吧,我們來進入下一個階段。
0x02 網鼎_2020
具體的wp可以看這位師傅寫的:網鼎杯2020 僞虛擬機逆向 wp
咱們直接看vm_protect的部分:
v10 = 0;
v9 = 0;
v8 = 0;
v7 = 0;
v6 = 0;
while ( 1 )
{
result = v10;
if ( v10 >= size )
return result;
switch ( opcode[v10] )
{
case 1:
v4[v7] = v5;
++v10;
++v7;
++v9;
break;
case 2:
v5 = opcode[v10 + 1] + flag[v9];
v10 += 2;
break;
case 3:
v5 = flag[v9] - LOBYTE(opcode[v10 + 1]);
v10 += 2;
break;
case 4:
v5 = opcode[v10 + 1] ^ flag[v9];
v10 += 2;
break;
case 5:
v5 = opcode[v10 + 1] * flag[v9];
v10 += 2;
break;
case 6:
++v10;
break;
case 7:
if ( v4[v8] != opcode[v10 + 1] )
{
printf("what a shame...");
exit(0);
}
++v8;
v10 += 2;
break;
case 8:
flag[v6] = v5;
++v10;
++v6;
break;
case 10:
read(flag);
++v10;
break;
case 11:
v5 = flag[v9] - 1;
++v10;
break;
case 12:
v5 = flag[v9] + 1;
++v10;
break;
default:
continue;
}
}
}
這是道比上一到正經一些的僞虛擬機的題:(爲啥說是“僞”,它沒有棧,沒有調度)
1)博主一般拿到vm的題,通常會大膽猜一下輔助參數(博主喜歡這麼叫,hhh)的含義(這題沒啥說的,下文具體講述)
先瞄一眼是參數還是數組,再通過每一個每一個case和調試大致推出其中的意思(真正的棧虛擬機會複雜的多)
v10 --- opcode的index
v9 v6----- flag 的 index
v8 v7 -----v4 的index
v4 估計是最後比較的數組或是參與算法的數組
2)推測出輔助參數的意義後,我們就可以去分析每一個case的含義,此題,非常明顯直接告訴咱了(棧虛擬機可就要肝一會兒了)
3)之後就需要在拿到的一大堆opcode裏區分出,哪些是一個handler(case組成),哪些是真實操作的數據,哪些是調度(這道題裏貌似沒有這個)。這就需要IDA動態調試(https://bbs.pediy.com/thread-247830-1.htm)。
真實的數據:case裏沒有的 ; 在調試時,dispatcher每次分配時跳過的,或者一堆循環,一大片相同的操作,估計就是對數組的操作,那肯定就有真實數據嘛。
區分每一個handler:這個如果題目沒有特別的暗示,就需要動調,找出一些case組合起來的一些結構,會有參考意義。
例如這題:4, 0x10, 3, 5, 1, 這樣類似的一套就是一個handler
調度:,多數就是順着來的,頂多是跳轉,也幾乎是順着來的。(也有可能是我菜,沒見過(手動狗頭))
4)之後確定這些handler,數據,(調度)之後,就要結合動調,分析,整理出所保護的程序(有的題就開始讀彙編了)
(此題具體的wp就去看那位師傅寫的吧.)
這道題也可以用angr一把梭出來,會在別的博客裏寫道。
噠噠!現在已經對ctf_vm的基礎框架有了一些瞭解,下面我們來見識真正的棧虛擬機逆向。
IDA動調連接不上問題:
有時候,在ida動調的時候會出現:參數,ip啥的,都對但就是 長時間響應不了連接不上的問題,如下解決:
果斷把虛擬機關上然後
等它還原完就可以連接了。
0x03 棧虛擬機
這就是真正ctf裏有難度的虛擬機了。整體的架構和上文的題相差不多,但因爲有了棧的加入,就會使題目多了“億”點點細節。
在看具體ctf題之前,我們不妨正向來了解一下虛擬機保護技術https://www.cnblogs.com/findumars/p/5636492.html。
虛擬機(CPU)的體系架構可分爲3種,基於堆棧的(Stack based),基於寄存器的(Register based)和3地址機器。我們只講述基於堆棧的虛擬機架構(Stack based);這種架構的虛擬機需要頻繁操作堆棧,其使用的虛擬寄存器(虛擬的eax、ebx等等)保存在堆棧中;每個原子指令的handler都需要push、pop。
ctf通常的.exe程序,是基於寄存器,堆棧一般只是在函數傳遞參數時使用,而在vm保護中用來替代所保護程序的程序,則是基於堆棧的虛擬機架構,所以,在我們還原case,還原handler或者在還原程序邏輯的時候,要始終貫徹用棧操作的思想
既然棧操作,就少不了彙編的一些常用指令:push pop add sub jmp(jcc合適一些) cmp
我們可以先參考一下上述文章中指令的實現,再接下來的逆向中會大有好處。
Vadd: ;virtual add
Mov eax,[esp+4] ;取源操作數
Mov ebx,[esp] ;取目的操作數
Add ebx,eax ;
Add esp,8 ;把參數從堆棧中刪掉,平衡堆棧
Push ebx ;把結果壓入堆棧
而原有的add命令的參數,我們需要翻譯爲 push 命令 。根據push 的對象不同,需要不同的實現:
vPushReg32: ;寄存器入棧 。esi指向字節碼的內存地址
Mov eax,dword ptr[esi] ;從僞代碼(字節碼)中獲得寄存器在VMcontext結構中的偏移地址
Add esi,4 ;VMcontext結構保存了各個寄存器的值。該結構保存在堆棧內。
Mov eax,dowrd ptr [edi +eax] ;得到寄存器的值。edi指向VMcontext結構的基址
Push eax ;壓入堆棧
Jmp VMDispatcher ;任務完成,跳回任務分派點
vPushImm32: ;立即數入棧
Mov eax,dword ptr[esi] ;字節碼,不用翻譯就是了
Add esi,4
Push eax ;立即數入棧
Jmp VMDispatcher
有Push指令了,也得有Pop指令:
vPopReg32:
Mov eax,dword,ptr[esi] ;從僞代碼(字節碼)中獲得寄存器在VMcontext結構中的偏移地址
Add esi,4
Pop dword ptr [edi+eax] ;彈回寄存器
Jmp VMDispatcher
Add esi,eax
轉換爲虛擬機的指令如下:
vPushReg32 eax_index
vPushReg32 esi_index
Vadd
vPopReg32 esi_index ;不彈eax_index,它作爲返回結果保存在堆棧裏
Vjmp:
Mov esi,dword ptr [esp] ;jmp的參數在堆棧中
Add esp,4 ;平衡堆棧
Jmp VMDispatcher
Vcall:
Push all vreg ;所有的虛擬的寄存器 (維護在堆棧中)
Pop all reg ;彈出到真實的寄存器中(保存虛擬機的運行結果)
Push 返回地址 ;可以讓Call調用完成後,把控制權歸還給虛擬機。詳細見原書第480頁。
Push 要調用的函數地址
Retn
下面我們來看 hgamectf_第四周_happyVM(一點也不happy)
載到ida裏:
根據上述的經驗,迅速的找到,opcode, vm保護的函數,和輔助參數位置
這裏的輔助參數要特別說明一下,棧虛擬機有大量棧的操作,所以輔助參數並不像上一題一樣只是簡單的index,輔助參數,多數變成了 vm_stack(操作的棧),vm_eip(偏移),vm_esp(棧的esp),vm_input(輸入),vm_code(opcode),vm_block(計數器),和一些其他參數(有可能是參數,或者數組),而這些參數通常存放在__bss段(因爲bss存放程序中未初始化的或者初始化爲0的全局變量和靜態變量),我們可以從此入手,大致猜一波 ,進入sub_4007B5:
交叉引用一下(按x):都在bss段
結合整個程序綜合分析:(這些判斷也需要根據下文每一個case裏的調試,整理判斷出來的)
602070是申請堆,八九不離十就是vm_stack(操作的棧)
602084:通過簡單分析,很明顯這就是 vm_eip
602078: 就告訴你了 它是 vm_input
result = *(vm_eip + vm_code) = opcode(這個結構非常常見)
602083:
和棧搞位置,試着調試,確定83爲vm_esp。
602085:根據調試分析,這是一個寄存器(負責儲存cmp留下的結果)
602080 81 82 ,爲三個暫時存放的內存位置 vm_var
之後我們按順序來分析每一個case:進入 vm_protect
判斷操作數是否爲兩個(算他是個人,這樣判斷opcode中的常數就有依據了)
之後分析每一個case
case的含義不再白給,就拿case 0舉例;
進入sub_400A4F
可以拿上文正向虛擬機保護技術的代碼做參考,綜合上文bss段推斷出的輔助參數,分析出這是push操作,
所以case 0爲 push opcode
同理,你會分析出 push pop call add jmp je xor,exit, ret等基礎操作,再通過調試分析出每個case的含義:(以下的opcode1,2是一個意思)(這是指紋師傅的分析整理)
Case 0: push opcode2
Case 1: push reg80
Case 2: push reg81
Case 3: push reg82
Case 4: pop reg80
Case 5: pop reg81
Case 6: pop reg82
Case 7: add reg80, reg81
Case 8: add reg80, opcode2
Case 9: add reg81, opcode2
Case A: add reg82, opcode2
Case B: sub reg80, reg81
Case C: sub reg80, opcode2
Case D: sub reg81, opcode2
Case E: sub reg82, opcode2
Case F: xor reg80, reg81
Case 0x10: cmp reg80, reg81
Case 0x11: call opcde2
Case 0x12: ret
Case 0x13: jmp opcode2
Case 0x14: je opcode2
Case 0x15: push input[reg82]
Case 0x16: pop input[reg82]
Case 0x17:exit
你會發現,現在已經不是上一題,每個case還是僞c語言,而是僞彙編了。(哎~~,挺禿然的)
好了咱們接着上題的套路走:分析opcode (找常數,找handler, 找調度)
1) 因爲這題第一個switch已經告訴你了哪些是兩個操作數,所以常數很好找.
2) 找handler && 找調度,每一個case就是一個,但是要注意,此段程序裏有exit (0x17),ret(0x12),所以會幾句case形成一段,之後jmp到別的段,形成調度,但即使是調度,通常也是按循序排的,比如我這一段jmp到9,之後exit了,9的位置就緊挨着exit。(經驗是這樣的)。
附上對opcode的分析(依然是指紋師傅的):
start
11 2D
0 22
05
10
14 09
17
addr9
0 32
05
03
11 16
06
00 16
5
11 16
17
fun16
e 1
15
4
f
1
16
2
0 0
4
3
5
10
14 2B
5
9 3
13 16
addr2B:
5
12
fun2D:
15
04
10
14 36
A 1
13 2d
addr36:
03
04
12
緊接着翻譯程序邏輯(我上文說的讀彙編就是這個意思)
start:
call 2D //2D函數返回輸入flag的長度
push 22
pop reg81
cmp reg80 reg81 //判斷flag長度是否爲0x22
je 9
exit
addr9:
push 32
pop reg81
push reg82
call 16 //對輸入進行第一次異或運算,傳入的參數爲0x32
pop reg82
push 16
pop reg81
call 16 //對輸入進行第二次異或運算,傳入的參數爲0x16
exit
fun 16:
sub reg82, 1 //reg82最初的值爲0x22,然後一直遞減
push input[reg82]
pop reg80
xor reg80, reg81
push reg80
pop input[reg82]
push reg81
push 0
pop reg80
push reg82
pop reg81
cmp reg80, reg81 //當reg82值遞減到0的時候,跳出函數
je 2B
pop reg81
add reg81, 3
jmp fun16
addr2B:
pop reg81
ret
fun 2D: //函數返回輸入flag的長度
push input[reg82]
pop reg80
cmp reg80, reg81
je 36
add reg82, 1
jmp 2D
addr 36:
push reg82
pop reg80
ret
邏輯是首先判斷輸入的長度是否爲0x22,然後兩次調用異或函數對輸入進行處理。(就這麼個玩意兒!!!)
最後腳本:
arr = [132, 131, 157, 145, 129, 151, 215, 190, 67, 114, 97, 115, 115, 12, 106, 112, 115, 17, 72, 44, 52, 51, 49, 54, 35, 52, 62, 92, 35, 78, 23, 17, 25, 89]
flag = ""
reg81 = 0x16
for i in range(len(arr))[::-1]:
arr[i] = arr[i] ^ reg81
reg81 += 3
reg81 = 0x32
for i in range(len(arr))[::-1]:
flag += chr(arr[i] ^ reg81)
reg81 += 3
print flag[::-1]
flag: hgame{3Z_VM_W0NT_5T0P_UR_PR0GR355}
虎符 vm
ok,最近的虎符的vm就是相似的難度:
如果各位看官沒玩夠,可以再看一下虎符的題,
wp:虎符ctf -- vm虛擬機逆向 (令則寫的這篇很仔細了)
0x04 總結
從入門到入土,
希望我的總結可以幫助到想學習ctf vm的看官,如果有錯誤也希望路過的大佬斧正。
謝謝指紋師傅的素材。
(本文的幾道題目稍後加在附件裏)