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的看官,如果有错误也希望路过的大佬斧正。
谢谢指纹师傅的素材。
(本文的几道题目稍后加在附件里)