看雪CTF.TSRC 2018 团队赛 第四题 『盗梦空间』 解题思路

发布者:Editor
发布于:2018-12-23 17:48



截止今天(12月9日)中午12:00,《盗梦空间》的攻击时间停止,五支队伍攻击成功!


从下图可以看到,中午放题搬砖狗哭哭继续稳居第一位,tekkens、金左手、pizzatqul紧随其后。 A2更是首次突出重围,跻身前五。



最新赛况战况一览


《盗梦空间》一题中,团队 中午放题搬砖狗哭哭,以50850s的速度夺得第一!


第四题攻击结束后,攻击团队Top10也发生了比较大的变动,最新排行榜如下:



战势风起云涌,看来,想要维持Top10的排名,需要相当功力的。



同时,也意味着,任何队伍都有可能是一匹黑马,


下一个黑马,会是你吗?



第四题 点评


crownless:

盗梦空间的主程序采用了诸多混淆方式:Constant blinding、垃圾指令、空循环、跳转到计算出来的地址、自修改代码,让我们不能仅用一种工具分析程序,同时考验了参赛者的动态调试和静态分析的功底。



第四题 出题团队简介


出题团队: 雨落星沉  


业余安全爱好者,前几年玩dota时经常遇到全图挂,在好奇心的驱使下在网络上寻找全图挂的原理。由此来到了看雪论坛,被论坛中奇妙的计算机底层技术吸引。

但由于没有受过系统的训练,知识结构比较零散,水平也在入门边缘徘徊。一年前接触的看雪CTF,感觉实战是提高水平的捷径,就成为了CTF中的常客。


团队中的其他人都是CTF中的大佬,有的出过CTF的入门视频教程,有的是CTF个人排行榜的前十。在团队中希望能互相交流技术,向大佬们学习。


参赛的题目被我命名为Transformer,主要是因为里面用到了一些代码的变形混淆技术,就像变形金刚。中间也借鉴了变形金刚中的一句经典的台词"One shall stand and one shall fall."。然而此次出题略显仓促,从构思到实现,用了10天左右。最后只在选手的手里存活了10余个小时。如果以后还有机会参赛,应该会全面提高混淆的强度吧。





第四题 设计思路


主要设计思路:


一、手工patch main函数之前的vc运行库,加入反调试代码,如果没有发现调试器就Patch自身,跳转至正确的验证处。否则就陷入无法求解的死胡同。


二、修改TEA算法流程,对编译出来的机器码进行了混淆处理,生成内联汇编形式的C语言文件。


混淆流程:


1、处理函数开头和结尾,将push ebp;mov ebp,esp转义。

2、转义部分关键指令(shr,shl)

3、将出现的常数以常数生成器代替

4、花指令

5、反调试

6、延时(防止爆破)

7、跳转混淆

8、随机自解密



附件中的其他文件:


original_opcode:


混淆前的modified_tea_encrypt的机器码

gen_asm_c.py:

混淆代码

tea_asm.c:

混淆后的modified_tea_encrypt的C源码(内联汇编形式)

candidate.txt:

花指令文件

tea_encrypt.c:

modified_tea_encrypt的源码

tea_decrypt.c:

modified_tea_decrypt的源码



求解方法:


1、编写去混淆脚本,写出modified_tea_decrypt函数

2、去除反调试,寻找到真正的验证逻辑


真正的验证逻辑为:


char *key = "One shall stand and one shall fall.";

modified_tea_encrypt(arr,(uint*)key,0x67452301,0xEFCDAB89,0x98BADCFE,0x10325476,0x7380166f,0x4914b2b9,0x172442d7,0xda8a0600,(uint*)M1,0xdeadbeef);

return (arr[0]==0x87654321 && arr[1]==0x12345678);

去混淆后,写出modified_tea_decrypt()

char *key = "One shall stand and one shall fall.";

arr[0]==0x87654321;

arr[1]==0x12345678;

modified_tea_decrypt(arr,(uint*)key,0x67452301,0xEFCDAB89,0x98BADCFE,0x10325476,0x7380166f,0x4914b2b9,0x172442d7,0xda8a0600,(uint*)M1,0xdeadbeef);


此时bin2hex(arr)就是正确的验证码。


原文完整链接:


https://bbs.pediy.com/thread-247772.htm



第四题 盗梦空间 解题思路


本题解析依然由看雪论坛 Riatre 原创。



周末了,稍微有一点时间,尽量详细的写了一下自己解题的过程。看着长,但实际上并不复杂,希望大家不要被吓到。


当然,我猜其他人并不是用解混淆的方法做的,他们的方法可能更值得追求省心省力的各位学习 :)


为了照顾行文逻辑,下面贴出代码可能不完整,完整代码见附件。同时代码带有不少调试痕迹,比较乱,凑合看一下吧。


观察


题目(又²)给出了一个 32 位控制台 Windows 应用程序 transformer.exe,其有 824 KB。


运行一下:


N:\pediy\4>transformer.exe



Plz input your serial:0123456789ABCDEF



Wrong serial! You should try a bit harder!


请按任意键继续. . .


看起来还是一个经典的输入序列号,输出对错的题目。注意到输入到输出之间有一个约1秒的停顿。



初步分析


在 IDA Pro 中加载该可执行文件之后,到处乱点点,乱标标,很容易定位到 00401AF0 处为 main 函数:


int main(int argc, const char *argv[]) {

printf("Plz input your serial:");

scanf("%32s", serial);

if ( CheckSerial(serial) )

printf("Congratulations! You have found the correct serial!\n");

else

printf("Wrong serial! You should try a bit harder!\n");

system("pause");

return 0;

}


其中调用的 CheckSerial 函数在 00401150,其 (表面上的) 逻辑大致为:


BOOL CheckSerial(char *serial) {

int block[2] = {0};

if ( strlen(serial) != 16 )

return 0;

for ( i = 0; i < 16; ++i ) {// input is uppercase hex

if ( (serial[i] < '0' || serial[i] > '9') && (serial[i] < 'A' || serial[i] > 'F') )

return 0;

}

for ( i = 0; i < 8; ++i ) {// u32(endian='big')

if ( serial[i] < '0' || serial[i] > '9' )

block[0] += (serial[i] - '7') << (28 - 4 * i);

else

block[0] += (serial[i] - '0') << (28 - 4 * i);

if ( serial[i + 8] < '0' || serial[i + 8] > '9' )

block[1] += (serial[i + 8] - '7') << (28 - 4 * i);

else

block[1] += (serial[i + 8] - '0') << (28 - 4 * i);

}

ObfuscatedCompute(block, "One shall stand and one shall fall.",

0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476,

0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600,

mat0, 0xDEADBEEF);

if ( IsDebuggerPresent() ) { // Fake?

ABloodyChainOfLoadLibraryAndGetProcAddressLikelyUsedForAntiDebug();

return block[0] == 0x87654321 && block[1] == 0x12345678;

} else { // Real?

int explode4[16] = {0};

int mat0[16] = {0, 0, 0, 0, 10, 15, 2, 13, 10, 8, 6, 15, 0, 5, 3, 0};

int mat1[16] = {16, 0, 16, 14, 3, 10, 6, 0, 0, 7, 13, 6, 4, 7, 0, 2};

int mat2[16] = {6, 16, 0, 12, 0, 11, 16, 12, 8, 12, 12, 0, 6, 0, 11, 13};

int mat3[16] = {16, 14, 13, 14, 4, 0, 0, 16, 5, 0, 0, 3, 5, 16, 14, 16};

int mem[16] = {0};

int expected_answer[16] = {222105, 494358, 443201, 423901, 310311, 700114, 629640, 620483, 301566, 676368, 606711, 605590, 149250, 339264, 304846, 301296};

for ( i = 0; i < 8; ++i ) {

explode4[2 * i] = ((signed int)*((unsigned __int8 *)&block[0] + i) >> 4) + 1;

explode4[2 * i + 1] = (*((_BYTE *)&block[0] + i) & 0xF) + 1;

}

mat0[0] = explode4[0]; mat0[3] = explode4[1]; mat0[15] = explode4[2]; mat0[12] = explode4[3];

mat1[1] = explode4[4]; mat1[7] = explode4[5]; mat1[14] = explode4[6]; mat1[8] = explode4[7];

mat2[2] = explode4[8]; mat2[11] = explode4[9]; mat2[13] = explode4[10]; mat2[4] = explode4[11];

mat3[5] = explode4[12]; mat3[6] = explode4[13]; mat3[10] = explode4[14]; mat3[9] = explode4[14];

MatrixMul(mat0, mat1, mem);

MatrixMul(mem, mat2, mem);

MatrixMul(mem, mat3, mem);

for ( i = 0; i < 4; ++i ) for ( j = 0; j < 4; ++j )

if ( expected_answer[4 * i + j] != mem[4 * i + j] )

return 0;

return 1;

}

}

表面上看起来这是一个分两部分的问题,第一部分的入口在 00401C70 处,是一个约 400K 大的明显加了混淆的函数。用调试器大概观察一下其行为,可以发现其是将输入进行了变换,并且写了 mat0 的前 4 个值,输入了几组值“感受”了一下,大抵是个 block cipher 吧。第二部分是把第一部分的输入拆开之后填进四个 4x4 的矩阵的固定位置,再乘起来,然后验证结果。注意到里面还有一个疑似 typo (explode4[14] 用了两次,而 [15] 没用到),这就非常可疑,一开始觉得是题目没有出好,这个问题的规模又比较小,总之先瞎找一组解试试呗,将 mat0 中的两个由混淆过的函数填充的值也设为变量。掏出一个 SMT Solver 问问它怎么看(相关代码见附件):


$ python part2-z3.py

unsat

Proof:

unit-resolution(not-or-elim(mp(mp(mp(mp(mp(mp(asserted(0 +

(0 +

(184 + 6*x7 + 60)*6 +

(0 +

10*x4 +

<...略...>


结果它并不想理我,并丢出来一个说这问题没解的证明。仔细盯着自己的代码看了一会儿确认没写错。


也就是说它真的没解,那这是怎么回事呢?看来这个程序中还隐藏着更多的秘密。仔细观察可以发现那个很不自然的 if (IsDebuggerPresent()) 之前有两条长的很奇怪的废指令:


.text:004016B8 B8 91 48 BD CA mov  eax, 0CABD4891h

.text:004016BD B8 EA AC DB DA mov  eax, 0DADBACEAh

.text:004016C2 FF 15 00 A0 4A 00 call ds:IsDebuggerPresent

.text:004016C8 85 C0  test eax, eax


难道程序中有奇怪的地方带自修改?拿 x32dbg 简单看了一下(被混淆的函数中有读 PEB 的反调试,x32dbg 自带的隐藏调试器可过),这里似乎并没有神秘问题,可以走到。难道是有奇怪的地方用其他方式做了检测不到调试器才会进行的自修改?程序结尾有一个 system("pause") 会停下,正好提供了一个附加上去的时机,于是在这个时候附加上去看一下:


00DB16B8| B8 91 48 BD CA       | mov eax,CABD4891 |

00DB16BD  |

EB 0D| jmp transformer.DB16CC                       | ; --------------

00DB16BF| AC                   | lodsb| ; junk         |

00DB16C0 | DB DA                | fcmovnu st(0),st(2)| ; junk         |

00DB16C2| FF 15 00 A0 E5 00    | call dword ptr ds:[<&IsDebuggerPresent>] | ;              |

00DB16C8| 85 C0                | test eax,eax | ;              |

00DB16CA| 74 50                | je transformer.DB171C| ;              |

00DB16CC| E8 4F 1A 06 00       | call transformer.E13120 | ; <-------------


果然变了,也就是说实际上第二部分执行的检查逻辑是 block[0] == 0x87654321 && block[1] == 0x12345678 的那个。


本节充分说明了我有多菜,这种简单的反调试 trick 想必各路高手们都是秒过吧。



反混淆


第二部分的谜题看起来像是解开了(还不能石锤,因为不知道混淆过的函数里到底做了什么,也没有直接目击自修改现场,万一它执行过之后又把自己改回去了呢?),接下来处理第一部分被混淆的函数。我们先观察一下这个混淆是咋回事,大致翻一翻,可以发现其中有很多个这样的 pattern,有趣的地方直接原地用注释标出了:


; 以下是一段经过了 constant blinding 和加入无用垃圾指令(主要是一些影响 EFLAGS 的代码)的有用代码

.text:00401C70 F9                                      stc

.text:00401C71 29 C1                                   sub     ecx, eax

.text:00401C73 BF 58 2C 69 B3                          mov     edi, 0B3692C58h

.text:00401C78 81 EF 17 B1 A8 15 sub     edi, 15A8B117h

.text:00401C7E 81 EF 2C C5 85 8B                       sub     edi, 8B85C52Ch

.text:00401C84 8B 8C 7D CE 93 8A DB                    mov     ecx, [ebp+edi*2-24756C32h]

; <... 略 ...>

.text:00401D3A BF 7D 32 58 3F                          mov     edi, 3F58327Dh

.text:00401D3F 81 EF A6 CB 29 53 sub     edi, 5329CBA6h

; --------------------------------------------------------------------------------------------------

; 以下地址不连续的地方是省略了中间的垃圾指令,为了便于阅读不再显式标明。

.text:00401D45 50 push    eax

.text:00401D55 53 push    ebx

.text:00401D59 51 push    ecx

.text:00401D63 64 A1 18 00 00 00 mov     eax, large fs:18h     ; TEB

.text:00401D6D 8B 40 30 mov     eax, [eax+30h]        ; p_PEB

.text:00401D75 0F B6 40 02 movzx   eax, byte ptr [eax+2] ; 读 PEB 里的 IsBeingDebugged

.text:00401D82 BB 00 00 00 00 mov     ebx, 0

; 一个空循环

.text:00401D87                         loc_401D87:

.text:00401D87 43 inc     ebx

.text:00401D9D 83 C3 01 add ebx, 1

.text:00401DA4 81 FB 4C 20 0F 00 cmp     ebx, 0F204Ch

.text:00401DAA 72 DB                                   jbshort loc_401D87

.text:00401DAC E8 00 00 00 00 call    $+5

.text:00401DB1 5E                                      pop     esi

.text:00401DB2 01 C6 add esi, eax           ; IsBeingDebugged 参与地址计算

; 一个经过了 constant blinding 的小常数

.text:00401DB4 81 C6 93 A5 E4 5B add esi, 5BE4A593h

.text:00401DBA 81 C6 D1 FB 19 67  add esi, 6719FBD1h

.text:00401DC0 81 C6 5B 18 64 69  add esi, 6964185Bh

.text:00401DC6 81 C6 0F F4 64 EA add esi, 0EA64F40Fh

.text:00401DCC 81 C6 65 52 38 E9 add esi, 0E9385265h

.text:00401DD2 FF E6                                   jmp     esi                ; 根据 IsBeingDebugged 的值计算跳到哪,兼顾反调试和对付静态分析工具的控制流分析

; <... 略 ...>

; ---------------------------------------------------------------------------------------------------

.text:00401DE4 B9 F2 00 00 00 mov     ecx, 0F2h          ; 跳到了这

.text:00401DE9 E8 00 00 00 00 call    $+5

.text:00401DEE 58 pop     eax

.text:00401DEF 83 C0 71 add eax, 71h

.text:00401DF2 83 E8 0A                                sub     eax, 0Ah

.text:00401DF5 02 08  add cl, [eax]          ; 读当前地址后面一些地方的内存

.text:00401DF7 83 E8 0B                                sub     eax, 0Bh

.text:00401DFA 2A 08 sub     cl, [eax]

.text:00401DFC 83 E8 0D                                sub     eax, 0Dh

.text:00401DFF 02 08  add cl, [eax]

.text:00401E01 83 E8 09 sub     eax, 9

.text:00401E04 2A 08 sub     cl, [eax]

.text:00401E06 83 E8 0E                                sub     eax, 0Eh

.text:00401E09 2A 08 sub     cl, [eax]

.text:00401E0B 83 E8 0E                                sub     eax, 0Eh

.text:00401E0E 2A 08 sub     cl, [eax]

.text:00401E10 83 E8 0A                                sub     eax, 0Ah

.text:00401E13 02 08  add cl, [eax]

.text:00401E15 83 E8 0F                                sub     eax, 0Fh

.text:00401E18 32 08 xor     cl, [eax]

.text:00401E1A 83 E8 07 sub     eax, 7

.text:00401E1D 02 08  add cl, [eax]

.text:00401E1F 83 E8 0E                                sub     eax, 0Eh

.text:00401E22 32 08 xor     cl, [eax]

.text:00401E24 E8 00 00 00 00 call    $+5

.text:00401E29 58 pop     eax

.text:00401E2A 83 C0 36 add eax, 36h

.text:00401E2D 83 C0 09 add eax, 9

.text:00401E30 28 08 sub     [eax], cl          ; 修改当前地址后面一些地方的内存

.text:00401E32 83 C0 08 add eax, 8

.text:00401E35 00 08  add [eax], cl

.text:00401E37 83 C0 07 add eax, 7

.text:00401E3A 28 08 sub     [eax], cl

.text:00401E3C 83 C0 0Eadd eax, 0Eh

.text:00401E3F 28 08 sub     [eax], cl

.text:00401E41 83 C0 0Badd eax, 0Bh

.text:00401E44 00 08  add [eax], cl

.text:00401E46 83 C0 0Fadd eax, 0Fh

.text:00401E49 00 08  add [eax], cl

.text:00401E4B 83 C0 0Fadd eax, 0Fh

.text:00401E4E 28 08 sub     [eax], cl

.text:00401E50 83 C0 0Aadd eax, 0Ah

.text:00401E53 28 08 sub     [eax], cl

.text:00401E55 83 C0 08 add eax, 8

.text:00401E58 30 08 xor     [eax], cl

.text:00401E5A 83 C0 0Aadd eax, 0Ah

.text:00401E5D 30 08 xor     [eax], cl

.text:00401E5F 59 pop     ecx

.text:00401E60 5B                                      pop     ebx

.text:00401E61 58 pop     eax

; ---------------------------------------------------------------------------------------------------

; 这后面即为刚刚的代码修改过的正常代码,再后面跟着一些同样模式的代码。

有趣的是,正常代码执行完之后没有将其再反变换回去,也没有在任何地方记录变换是否发生过,也就是说这些代码只能从头到尾被执行一次。

也说明这段被混淆的代码里面很可能没有复杂的循环之类的控制流逻辑 :) 否则生成混淆的时候就要精确分析出后继是否已经被变换过了,而这至少是困难的。因此我们的解混淆代码不妨就先假设里面没什么 if for,胡乱写一写试试。


总结一下这里面的混淆方式:


Constant blinding

垃圾指令

空循环,估计是用来对付一些比较慢的模拟器的

跳转到计算出来的地址,反调试 + 混淆静态分析工具的控制流分析

自修改代码

首先明确目标:我们希望将这段代码反混淆到可以用 Hex-Rays 分析的程度。


其中 1、2 只要我们掏一个模拟器或者最好是带符号执行引擎的静态分析框架出来,对我们的反混淆就不会有影响。而 Hex-Rays 自带十分强大的常量折叠和死代码消除,因此对我们最终的分析也不会有影响。


3 可能就是拿来克我们的,但它(可能是故意)加的比较弱,所有的空循环形式都一样,结尾一定是一个连起来的 jb + call .+5,中间无垃圾指令,因此 72??E800000000 就成为了一个很好的特征,直接查找替换成 9090E800000000 即可,存为 transformer-noloop.exe。


而我们注意到,3、4、5 总是在一起出现,且乍一看总是出现在真正干活的代码之前,解密这段真正干活的代码。并且这一块总是以三个连续的 pop ecx; pop ebx; pop eax 结尾,中间没有垃圾指令。这成为了很好的一个特征。因此我们考虑监控程序的执行,做以下事情:


记录所有自修改的位置和值。


记录 push eax / push ebx / push ecx 的位置。


遇到 pop ecx; pop ebx; pop eax 三连击的时候,nop 掉当前位置和上一个 push eax / push ebx / push ecx 之间的指令。


考虑到 1 中需要监控所有的内存写,最方便的 instrument 方法可能还是直接拿起一个模拟器来跑这个程序,于是选择用 Unicorn Engine 开干。(当然考虑到这里面的自修改的指令形式都比较单一,直接弄个 OllyScript 之类的东西应该也行。)


import pefile

from unicorn import *

from unicorn.x86_const import *

import struct

import collections

import gdt # https://github.com/unicorn-engine/unicorn/issues/522

def p32(x):

return struct.pack('<I', x)

def load_pe(path):

STACK_LIMIT, STACK_BASE = 0x1170000, 0x1180000

pe = pefile.PE(path)

IMAGE_BASE = pe.OPTIONAL_HEADER.ImageBase

SIZE_OF_IMAGE = pe.OPTIONAL_HEADER.SizeOfImage

mapped_image = pe.get_memory_mapped_image(ImageBase=IMAGE_BASE)

mapped_size = (len(mapped_image) + 0x1000) & ~0xFFF

uc = Uc(UC_ARCH_X86, UC_MODE_32)

uc.mem_map(IMAGE_BASE, mapped_size)

uc.mem_write(IMAGE_BASE, mapped_image)

uc.mem_map(STACK_LIMIT, STACK_BASE-STACK_LIMIT)

uc.mem_write(STACK_LIMIT, '\xdd' * (STACK_BASE-STACK_LIMIT))

uc.reg_write(UC_X86_REG_ESP, STACK_BASE-0x800)

uc.reg_write(UC_X86_REG_EBP, STACK_BASE-0x400)

return uc

def init_tib_peb(uc):

TEB, PEB, GDT = 0x7fded000, 0x7fdee000, 0

uc.mem_map(TEB, 0x1000)

uc.mem_map(PEB, 0x1000)

uc.mem_write(TEB + 0x18, p32(TEB))

uc.mem_write(TEB + 0x30, p32(PEB))

uc.mem_write(PEB + 2, '\x00')

g = gdt.IGdt(uc, GDT, 0x1000)

g.Setup(TEB)

def push(uc, val):

esp = uc.reg_read(UC_X86_REG_ESP)

uc.reg_write(UC_X86_REG_ESP, esp - 4)

uc.mem_write(esp - 4, p32(val))

def setup_call(uc, ret, *arguments):

for x in arguments[::-1]:

push(uc, x)

push(uc, ret)

nop_range = []

patches = []

last_instr_addr = collections.defaultdict(lambda: 0)

TO_RECORD = (

('\x50', 'push eax'),

('\x53', 'push ebx'),

('\x51', 'push ecx'),

('\x58', 'pop eax'),

('\x5b', 'pop ebx'),

('\x59', 'pop ecx'),

)

def hook_code(uc, address, size, user_data):

instr = uc.mem_read(address, size)

if instr == '\xff\xe6': # jmp esi

print 'Jmp -> %08x' % uc.reg_read(UC_X86_REG_ESI)

for op, name in TO_RECORD:

if instr == op:

last_instr_addr[name] = address

break

if last_instr_addr['pop ecx'] == address - 2 and last_instr_addr['pop ebx'] == address - 1 and last_instr_addr['pop eax'] == address:

# pop ecx; pop ebx; pop eax sequence

print 'Nop between %08X and %08X' % (last_instr_addr['push eax'], address)

nop_range.append((last_instr_addr['push eax'], address))

def hook_memory_access(uc, access, address, size, value, user_data):

if access == UC_MEM_WRITE:

if address >= 0x00401C70 and address < 0x4B1000:

print('Patching %08x to %s (%d bytes)' % (address, value, size))

patches.append((address, size, value))

# Setup emulation

p_block = 0x52520000

p_mat0 = 0x52521000

EXIT_ADDRESS = 0xfffff000

uc = load_pe('transformer-noloop.exe')

init_tib_peb(uc)

uc.mem_map(p_block, 0x1000)

uc.mem_map(p_mat0, 0x1000)

uc.mem_map(EXIT_ADDRESS, 0x1000)

uc.hook_add(UC_HOOK_CODE, hook_code)

uc.hook_add(UC_HOOK_MEM_WRITE, hook_memory_access)

setup_call(uc, EXIT_ADDRESS, p_block, 0x4B1000, 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600, p_mat0, 0xDEADBEEF)

uc.emu_start(0x00401C70, EXIT_ADDRESS)

# Writeback

pe = pefile.PE('transformer-noloop.exe')

done = {}

for a, s, v in patches:

if a not in done:

pe.set_bytes_at_rva(a - 0x400000, chr(v))

done[a] = v

else:

if done[a] != v:

print 'Conflict patch: %08x %d -> %d, ignoring...' % (a, s, v)

for l, r in nop_range:

pe.set_bytes_at_rva(l - 0x400000, '\x90' * (r - l + 1))

pe.write('transformer-deobf-1.exe')

本来做好了要手调处理一下里面之前没有暴露出的问题(比如条件跳指令)之类的准备,但运行一下,出乎意料的是,看起来被混淆的函数中真的没有什么正常代码中的跳转指令,至少直到返回之前都没有。


在调试器里观察一下被解混淆的函数的输出,发现和解混淆之前是一样的,说明这一步做的OK。


遗憾的是,这里做完之后,我们发现代码还是难以直接在 Hex-Rays 中看懂,由于栈帧分析不正确,Hex-Rays 基本没有办法识别函数参数、局部变量和在此基础上进行合理的死代码消除。稍微观察之后发现这个函数是使用 ebp 作为 frame pointer 的,我们在 IDA Pro 里按 Alt+P 编辑函数,选上 BP based frame试试:


.text:00401C70 ; Attributes: bp-based frame

.text:00401C70

.text:00401C70 sub_401C70      proc near               ; CODE XREF: sub_401150+560↑p

.text:00401C70

.text:00401C70 var_7FB3C458    = dword ptr -7FB3C458h

.text:00401C70 var_7F802E58    = dword ptr -7F802E58h

.text:00401C70 var_7F5D631E    = dword ptr -7F5D631Eh

.text:00401C70 var_7F22FF9C    = dword ptr -7F22FF9Ch

.text:00401C70 var_7E66DBE5    = dword ptr -7E66DBE5h

.text:00401C70 var_7E5C5872    = dword ptr -7E5C5872h

.text:00401C70 var_7E36A138    = dword ptr -7E36A138h

< ...略... >

很遗憾,由于 Hex-Rays 依赖 IDA Pro 本体进行的栈帧分析,而 IDA Pro 本体并不是一个 decompiler,没有常量折叠等功能,形如如下的带 constant blinding 的访问函数参数的代码成功被识别错了:


.text:00401C73  mov  edi, 0B3692C58h

.text:00401C78  sub  edi, 15A8B117h

.text:00401C7E  sub  edi, 8B85C52Ch

.text:00401C84  mov  ecx, [ebp+edi*2+var_24756C32] ; mov  ecx, [ebp+edi*2-24756C32h]


看来我们必须手动解除这样的指令中的混淆。注意到这样的指令的形式都比较单一,既然我们已经模拟了这个程序,不妨糙一把,在执行到这样的指令的时候手动将发现是常数的寄存器的值换进去:(要是一开始用了个带符号执行的静态分析框架就好了,这里可以写的更鲁棒,可以直接看符号执行的结果判断ebp加上的值是不是常数):


from capstone import *

from keystone import *

stack_access_simplify_candidates = collections.defaultdict(lambda: [])

def hook_code_simplify(uc, address, size, user_data):

instr = next(cs.disasm(instr, address))

ops = instr.op_str

if '[ebp + edi' in ops:

pants = ops[ops.find('[')+1:ops.find(']')]

val = eval(pants, {'ebp': 0, 'edi': uc.reg_read(UC_X86_REG_EDI)}) % 2**32

stack_access_simplify_candidates[address].append((instr.mnemonic, ops, pants, val, size))

uc.hook_add(UC_HOOK_CODE, hook_code_simplify)

# ...

for addr, vec in stack_access_simplify_candidates.items():

if len(vec) != 1:

print 'Non constant stack access @ %08x, ignoring...' % (addr)

continue

mnem, ops, pants, val, size = vec[0]

new = 'ebp '

if val < 2**31:

new += '+ ' + str(val)

else:

new += '- ' + str(2**32 - val)

ops = ops.replace(pants, new)

enc, cnt = ks.asm(mnem + ' ' + ops, addr)

assert cnt == 1

enc = ''.join(map(chr, enc))

if len(enc) > size:

print 'Not enough space @ %08x, skipping...' % addr

continue

pe.set_bytes_at_rva(addr - 0x400000, enc.ljust(size, '\x90'))

# ...

除函数结尾处 0045BFC5 的一个手动 ret 没有被识别出来需要手动修复一下以外,这样得到的程序 IDA Pro 终于可以正确分析出栈帧了。Hex-Rays 也可以正常得出结果:



然而我们惊讶的发现,Hex-Rays 的优化器居然没有化简连续的 ror / rol 的功能,导致这个结果十分难看。解决这个问题的正常方法当然是利用 7.1 版本以上新加入的 microcode API 给 Hex-Rays 写一个新的优化 pass,化简掉这些东西。但人总是懒惰的,Hex-Rays 输出的代码又是几乎可编译的 C 代码,所以我们不妨将结果修改到 GCC 可编译,然后丢进 GCC 里试试,看看 O2 能给优化成什么样子,顺便可以把这里参数传进去的常数都写死。(morenicer.cpp 见附件)


$ g++ -ggdb -O2 morenicer.cpp

$


使用 Hex-Rays 分析得到的 a.out,结果令人惊讶的好:



已经能看出明显的 Feistel cipher 的结构了。


解决


Feistel cipher 是一种只要看出了结构,并不需要把每一个操作逆回去就可以解密的东西,将上面的代码再复制出来,稍作整理,然后念一念咒语:



即可解密。


反调试藏哪了?


仔细看看,可以发现该程序修改了初始化 stack cookie 有关的函数,在其中加入了对反调试函数 00490F70 的调用。这个函数里有一些花指令混淆,但全都是同一种固定 pattern,可直接替换掉。


其检测调试器的方法是调用


NtQueryInformationProcess(GetCurrentProcess(), ProcessDebugObjectHandle, ...)。


也就是说,其实那一条 LoadLibrary + GetProcAddress ntdll.dll 里的所有函数的长链大概是为了隐藏 NtQueryInformationProcess 这个字符串,使其不那么扎眼,从而让反调试代码不那么显眼吧……


Trivia


1、为了便于阅读,上文并不是按照我实际看题目的时候看到的顺序写的。实际发生的事情是:标完表面上看起来有的部分 -> 对第二部分无解感到非常困惑 -> 认为可能混淆的函数里藏了东西 -> 还原了代码,发现里面真没什么特殊之处,顺便写出第一部分的解密 -> 挠头若干分钟 -> 看到关键逻辑处有一些奇怪的无用指令 -> 调试器附加了一个已经执行完检查再等 system("pause") 反馈的程序 -> 竟然真的有个有趣的反调试+自修改……


2、这个躲猫猫型反调试,没检测到调试器的时候默默修改代码太可怕了,尤其是对我这种不喜欢使用各路高手们人手一个的高端神秘魔改“过检测”调试器就喜欢直接看代码的人来说。


3、但从它低调的行为和藏的位置来说,还真挺有趣的。


4、混淆的那里面第一段真正干活的代码之前没有反调试+SMC,反倒是最后一段真正干活的代码之后有 (00462FE2),不知道是不是写的有点问题……



吐槽


Unicorn Engine 的文档基本等于没有,用起来真头疼,早知道换一个用了……


原文链接:https://bbs.pediy.com/thread-248285.htm(含附件)



合作伙伴


腾讯安全应急响应中心 

TSRC,腾讯安全的先头兵,肩负腾讯公司安全漏洞、黑客入侵的发现和处理工作。这是个没有硝烟的战场,我们与两万多名安全专家并肩而行,捍卫全球亿万用户的信息、财产安全。一直以来,我们怀揣感恩之心,努力构建开放的TSRC交流平台,回馈安全社区。未来,我们将继续携手安全行业精英,探索互联网安全新方向,建设互联网生态安全,共铸“互联网+”新时代。


转载请注明:转自看雪学院



看雪CTF.TSRC 2018 团队赛 解题思路汇总: 













声明:该文观点仅代表作者本人,转载请注明来自看雪