BCTF-2015 freak

2015年03月31日

此题属实变态,竟然只有250分,反正我是看着两个超级大牛的writeup才算看懂的。也算学习到了相对完整的SEH的相关知识点。

1、此题充分展示了如果通过SEH链来控制程序的执行流程。 2、此题充分展示了try...catch...final...块的嵌套和执行。 3、实际上程序中所有的try...catch...块使用同一个SEH链,但不同函数中的try...catch...块,使用ScopeTable来指定try块的嵌套层级和对应层级的catch块的执行代码。 4、一般由编译器在main之前使用__SEH_prolog或者__SEH_prolog4对程序所要使用的try...catch...块结构进行初始化。后者相比前者是在ScopeTable中加入了cookie,防止被栈溢出覆盖。

2012年hexray在xxx会上有过一次关于编译器如何支持异常的演讲,没有找到视频,只有ppt,基本涵盖了windows、linux、VC、GCC的异常处理的逆向知识,可以作为参考。

讲了一大堆,回到这个程序上来。就程序如何执行的来进行一下解构: 大部分的执行流程,可以参考Xiao Han大牛的writeup,里面对SEH控制执行流程的跳转讲解的非常清楚。我这里只是补充一下大牛没有讲到的几个地方,作为自己学习的总结:

一、程序入口点究竟在何处:

IDA自动分析出了__tmainCRTStartup,这是windows C++运行时库,命令行程序的入口点。关于这个函数的说明查看相关资料,主要干几件事情: 1、初始化SEH链。 2、在__cinit中调用全局C++类的构造函数。 3、获取命令行参数。 4、调用main函数。 5、main返回后调用全局C++类的析构函数。

下面分析一下__tmainCRTStartup是如何调用C++全局类的构造函数的:

int __cdecl _cinit(int a1)
{
  int result; // eax@4

  if ( _fpmath && _IsNonwritableInCurrentImage(&off_410DE8) )
    _fpmath(a1);
  _initp_misc_cfltcvt_tab();
  result = _initterm_e(&unk_40D14C, &unk_40D164);
  if ( !result )
  {
    sub_401A51(sub_404246);
    sub_4031D8(&unk_40D13C, &unk_40D148);	//一般由c运行时的_initterm函数,负责调用全局C++类的构造函数。
    if ( dword_41406C )
    {
      if ( _IsNonwritableInCurrentImage(&dword_41406C) )
        dword_41406C(0, 2, 0);
    }
    result = 0;
  }
  return result;
}

unsigned int __cdecl sub_4031D8(unsigned int *a1, unsigned int *a2)
{
  unsigned int *v2; // esi@1
  unsigned int v3; // ebx@1
  unsigned int result; // eax@1
  unsigned int v5; // edi@1

  v2 = a1;
  v3 = 0;
  result = (a2 - (unsigned int)a1 + 3) >> 2;
  v5 = a2 >= (unsigned int)a1 ? result : 0;
  if ( a2 >= (unsigned int)a1 ? result : 0 )
  {
    do
    {
      result = *v2;
      if ( *v2 )
        result = ((int (*)(void))result)();	//调用a1 -- a2(不包括a2)函数表上的函数。这个表由编译器根据全局类自动生成
      ++v2;
      ++v3;
    }
    while ( v3 < v5 );
  }
  return result;
}
C++全局类构造函数表,属于用户的总共就上面两个函数

.rdata:0040D13C dword_40D13C    dd 0                    ; DATA XREF: __cinit+50o
.rdata:0040D140                 dd offset sub_401000
.rdata:0040D144                 dd offset sub_4010C0
.rdata:0040D148 dword_40D148    dd 0                    ; DATA XREF: __cinit+49o
.rdata:0040D14C dword_40D14C    dd 0                    ; DATA XREF: __cinit+2Fo
.rdata:0040D150                 dd offset ___onexitinit
.rdata:0040D154                 dd offset ___initstdio
.rdata:0040D158                 dd offset ___initmbctable
.rdata:0040D15C                 dd offset sub_406B9C
.rdata:0040D160                 dd offset sub_4034D0
.rdata:0040D164 unk_40D164      db    0

二、程序流程中的一些细节

从这里开始,找到用户函数的调用起始点,可以对照Xiao Han大牛的writeup了解和掌握整个程序的流程。 这里我的失误在于没有认识到sub_401000建立自己的库函数调用表的重要性,以至于后面理解代码的时候几个关键的库函数一直认为IDA不能识别,从而找不到北。直到都分析完了,回头看,发现原来“陷阱”就是在这里的。 看大牛的writeup,知道一个良好的习惯就是充分地、不厌其烦地使用标注。如果我一开始就对sub_401000的调用表进行标注的话,后面的代码理解会快很多。

从构造函数到后续函数调用的衔接,是在sub_4010C0

.text:004010C0 sub_4010C0      proc near               ; DATA XREF: .rdata:0040D144o
.text:004010C0                 call    ds:GetTickCount
.text:004010C6                 push    offset Handler  ; Handler
.text:004010CB                 push    1               ; First
.text:004010CD                 mov     dword_413F9C, eax
.text:004010D2                 call    ds:AddVectoredExceptionHandler	;arg_1 > 0 表示放到SEH链的第一个位置
.text:004010D8                 push    offset dword_401810 ; Ptr
.text:004010DD                 call    sub_401A51	;此处应该触发异常,因为后面没有函数调用了,main就直接return 0了
.text:004010DD										;实际上这个函数应该调用了atexit(*func)
.text:004010E2                 add     esp, 4
.text:004010E5                 mov     dword_413FA0, 0
.text:004010EF                 retn
.text:004010EF sub_4010C0      endp

跟踪sub_401A51,会发现最后调用了_onexit_onlock(Ptr),google发现 ‘atexit’ calls a function ‘onexit’ which then calls another function ‘__onexit_nolock’ 显然这个sub_401A51实际上就是atexit

经过以上分析,可以知道程序的全局C++类就干了两件事情: 1、将Handler放到了SEH链的第一个。 2、在程序退出时(onexit)调用,sub_401810。

至此__tmainCRTStartup的准备工作都已经完成,然后调用main函数,实际上这个main直接就返回了,从而在退出时触发sub_401810。下面的SEH调用在大牛的writeup里讲解的很清楚了。

这里我想总结的是第二次int 1跳转到sub_4014c0后,函数的try块产生除零异常,这是时候捕获异常的catch块函数定义在 _EH3_EXCEPTION_REGISTRATION结构中,也就是所谓的ScopeTable。根据相应的TryLevel,可以在ScopeTable中找到对应的HandlerFunc。 还有一点关于SEH的CONTEXT结构,由于是32位程序,需要找到对应的winnt.h,查看结构定义。

三、整个程序的等效流程

//打开文件“Critical:secret”
//读取文件内容
//使用publickey解密文件内容。

aBctf2015 = "BCT2015!";
for(i=0; i<strlen(aBctf2015); i++)
{
	if(decryptedData[i+1] ^ decryptedData[i] != aBctf2015[i])
		goto ExitProgram;
}

sbox = rc4_init(decryptedData);

s = rc4(sbox, unk_410E18, 62h);

puts(s);	//这一步由于IDA没能识别出相应库函数,基本功能靠猜。

程序流程分析完毕后似乎和flag没有任何关系,观看整个流程,因为没有“Critical:secret”,flag的信息只有可能是rc4输出的字符串了。 分析上面的校验算法,只有256种可能,因为decryptedData[0]决定了,整个decryptedData也就决定了,所以符合条件的decryptedData的个数和decryptedData[0]的可能取值相同。

所以,暴力算法可以参考Xiao Han大牛的writeup,只是算decryptedData那里可以稍微改进一下。


以上分析都是基于Xiao Han大牛的writeup。实际上最后有个难点就是rc4算法的识别———如何确定它是标准的rc4,而不是里面稍加了变化? 所以参考了ppp大牛的writeup,发现他们竟然连SEH也没有必要完全搞懂,依赖IDA的强大能力,分析出了中间那段decryptedData的校验+后面一串算法+猜测的puts。所以就直接暴力了————修改程序流程,生成了256个freak程序。 这里的难度是OD中改程序比较容易,但不可能改256个(除非使用脚本),需要直接对PE文件进行操作。这里有个段的文件偏移和内存偏移的差别。

I started this challenge by reversing some of the binary in IDA. IDA’s FLIRT is able to rule out most of the binary as library code. Note that even though FLIRT only identifies ~70% of the functions here, it’s usually safe to assume that all user-written functions are adjacent, and all library code is adjacent. This assumption serves well on freak, and you really only need to look at the first 7 functions.

ppp大牛的经验确实叹为观止,只看关键用户函数,猜测程序逻辑。