首页
论坛
专栏
课程

[原创]看雪CTF 2019Q3 第十题 传家之宝

2019-9-24 14:50 2912

[原创]看雪CTF 2019Q3 第十题 传家之宝

2019-9-24 14:50
2912

这道题已经完全脱壳了,只剩下算法的逆运算了。因为第二天还要搬砖,就没有熬夜搞了 T_T

 

目录

脱壳

壳分析

拿到题目后发现在不调试的情况下让它自己正常运算都要好几秒,再一看代码基本就能想到是解密几行汇编再执行,于是找到解密代码的规律:

  1. 这里接收并保存完输入的用户名和密码后便开始解密代码

    图片描述
    图片描述

  2. 解密完后,通过call eax的方式跳转到解密后的代码

    图片描述

  3. x32dbg调试看看解密后的代码,03754814处的指令很像函数头,准备再看看下一次解密的代码
    图片描述

  4. 此时在后面的代码里没有发现call eax,而是一堆有问题的指令
    图片描述

  5. 经过调试后发现call eax的那部分代码也是动态解密出来的(由于截图分了几次调试截,地址不一样)
    图片描述

  6. 在调试时还发现,在解密新代码的同时会把上一步解密出的所有代码,包括解密代码在内,全部抹掉(机智)

  7. 再观察第二次解密出的代码,更加明显了,popfd pushfd和其他寄存器出栈压栈的操作就是在恢复和保存环境(后面解密出的代码也是这样的操作,就不截图了),另外还有个发现,执行真正的逻辑代码是在解密出call eax那段代码后
    图片描述

总结壳规律

  1. 通过call eax的方式到下一次解密操作
  2. 每次解密call eax那段代码都是执行完jne xxx的那段代码
  3. 解密完call eax那段代码后,跳到恢复寄存器环境执行真正的逻辑代码那里,而这段逻辑代码在每次进入call eax就可以看到,而且除了第一次都很有规律,伴随着popfd开始pushfd结束
  4. 通过写x32dbg脚本跑了一遍,发现这样的解密操作一共执行了2880次(可怕),而2880次之后还有验证的逻辑代码要执行,验证逻辑代码执行完后就是判断序列号运算是否成功、输出结果的操作了(没有加密完所有的验证逻辑代码,应该是因为再多加密几次,运算时间就太长了,会违规)

脱壳脚本

用的是x32dbg

 

需要注意的地方:

  1. 程序有反调试,会导致运算结果不正确,在脚本第一次暂停时去x32dbg菜单调试->高级->隐藏调试器
  2. 这个脚本只是获取到了前2880次解密的指令,因为后面的指令不需要解密,直接抠出来就完了
  3. 脚本把指令的16进制机器码和汇编打印在了日志窗口,复制出来,通过notepad++的替换功能用正则匹配将非#>开头的行删掉,操作一下就分别得到汇编代码和16进制机器码数据了
  4. 他每次解密后的代码格式不固定,有时候有1个jne有时候有多个,所以有时候需要多次寻找才能找到
  5. 反汇编的方式查找指令速度超级超级慢,所以用查找内存特征的方式定位指令,所以定位到之后要判断是否定位正确
  6. 只能下硬件断点,因为解密代码依赖于前面的代码数据,如果有软件断点,内存中读到的数据就是CC,解密后的数据就不对了
  7. 脚本要跑半个多小时,没错,2880次呢!

(附件x32dbg.txt

// 加载完程序后再加载脚本
// 按空格键开始
$num = 0x0

// MAX: 0xb40
$stop_num = 0xb40

bph 004014DF
// 这里暂停后,去设置隐藏调试器
// F9运行起来,输入用户名和序列号
// 输入完了,按空格开始获取指令
// 获取的信息在日志窗口
pause
bp printf
bphc 004014DF

// 寻找 call eax
find_calleax:
    find cip,FFD0,0x200
    cmp $result,0
    je find_jne
    $calleax = $result
    // 这一次是意外情况,跳过下一步操作
    cmp $num, 0x9dA
    jb gogo_0
    // 判断找到的 call eax 对不对
    $dis_len = 0
while_next_0:
    $dis_next_len = dis.len(cip + $dis_len)
    cmp $dis_next_len, 0
    je find_jne_go
    $dis_len = $dis_len + $dis_next_len
    cmp cip+$dis_len, $calleax
    jb while_next_0
    cmp cip+$dis_len, $calleax
    je gogo_0
    jmp find_jne
gogo_0:
    // 下断点
    bph $calleax
    run
    bphc $calleax
    $num = $num + 1
    log "call eax: {0}", $num
    cmp $num, $stop_num
    je opt_xxx
opt_back:
    // 进入 call eax
    StepInto
    StepInto
    StepOver
    // pause
    // 定位真正的代码位置
    find cip,9D5F5E5A595B58,0x100
    cmp $result,0
    je find_jne
    $start = $result + 0x7
    find $start,5053515256579C,0x100
    cmp $result,0
    je find_jne
    $stop = $result
    $size = $stop - $start
    // 打印真正代码的16进制机器码
    log "# {mem;$size@$start}"
    // 打印真正代码的汇编指令
    log "> "
out_asm_log:
    cmp $start, $stop
    jge find_jne
    log "> {$start}    {disasm@$start}"
    $start = $start + dis.len($start)
    jmp out_asm_log
    // pause

    // 寻找 jne
find_jne:
    // pause
    cmp $num, 0xa40
    je find_jne_go
    cmp $num, 0x380
    jg find_jne_0f85
find_jne_go:
    find cip+2,75??,0x200
    cmp $result,0
    je find_calleax
    $jnenext = $result
for_next_1:
    $dis_len = 0
while_next_1:
    $dis_next_len = dis.len(cip + $dis_len)
    $dis_len = $dis_len + $dis_next_len
    cmp cip+$dis_len, $jnenext
    jb while_next_1
    cmp cip+$dis_len, $jnenext
    je gogo_1
    find $jnenext+2,75??,0x200
    cmp $result,0
    je gogo_1
    $jnenext = $result
    jmp for_next_1
gogo_1:
    bph $jnenext + 2
    // pause
    run
    bphc $jnenext + 2
    jmp find_calleax

    // 另一种形式的 jne
find_jne_0f85:
    find cip,0F85????????,0x200
    cmp $result,0
    je find_jne_go
    $jnenext = $result

    $dis_len = 0
while_next_2:
    $dis_next_len = dis.len(cip + $dis_len)
    cmp $dis_next_len, 0
    je find_jne_go
    $dis_len = $dis_len + $dis_next_len
    cmp cip+$dis_len, $jnenext
    jb while_next_2
    cmp cip+$dis_len, $jnenext
    je gogo_2
    jmp find_jne_go
gogo_2:
    $jnenext = $jnenext + 6
    bph $jnenext
    run
    bphc $jnenext
    jmp find_calleax

    // 执行结束
opt_xxx:
    msg "Get It!"
    pause
    jmp opt_back

指令修复

获取到的指令看起来很不舒服,因为有太多无用的地址定位指令和其他数据

 

图片描述

 

再写个idapython脚本,获取所有逻辑验证指令(附件code_dmp.py

# coding=utf-8
import os, sys
import idc
import idautils
import idaapi

def GetCode(start):
    ea = start
    asm_code = ''
    hex_data = ''.decode('hex')
    stop = idc.MaxEA()
    # stop =  0x04ED0B11
    while ea <= stop:
        intr = idc.GetDisasm(ea)
        # print(intr)
        if 'call' in intr or \
           'pop     esi' in intr or \
           'sub     esi' in intr or \
           'add     esi' in intr :
            ea = idc.NextHead(ea)
        elif 'jmp' in intr:
            jmp_len = idc.GetOpnd(ea, 0)
            ea = idc.LocByName(jmp_len)
        else:
            asm_code += intr + '\n'
            buf = idc.GetManyBytes(ea, ItemSize(ea))
            hex_data += buf
            # print(hex_data.encode('hex'))
            print(hex(ea))
            ea = idc.NextHead(ea)
    file_handle =open('code_dmp.txt',mode='w')
    file_handle.write(asm_code)
    file_handle.close()
    file_handle =open('code_dmp.bin',mode='wb')
    file_handle.write(hex_data)
    file_handle.close()

ea = idc.ScreenEA()
GetCode(ea)

合成PE文件

通过以上的操作就获取到了这2880次解密出的所有逻辑验证代码了,但是还不够完美,要做到脱壳后能独立运行

 

于是在这段指令前加上解密第一步的函数头

push    ebp
mov     ebp, esp
sub     esp, 2Ch

把2880次解密完后,剩下的所有指令再手动抠出来,补到后面

 

现在验证代码已经全拿出来了(在附件code_dump.bin中),而且这些代码都是地址无关的运算,唯一需要的地址是在esi中保存,那就需要看看esi中保存的是什么了

esi指向的数据表

通过调试发现,esi 05EF5C91指向了一张表,esi+0x1B0的位置是输入的序列号,esi-0x11的位置存的是用户名

 

图片描述

 

当前,我们还原的时候需要的是把用户名和序列号复制进去前的数据表,重新调试再通过一通硬件断点的操作找到了这张表,就最后一行保存序列号的地方不一样

 

图片描述

函数结尾

调试发现结尾的地方是在比较esi+0x1B0处和edi处的用户名字符串,如果相等就提示成功(没有截调试的图,因为要调到2880次之后需要半小时)

 

图片描述

 

其实edi指向的就是esi-0x11的位置,重写一下这段指令

 

图片描述

两种合成方式

  1. 直接把解出来的16进制机器码粘贴到原PE的对应位置,但是发现esi指向的数据是在第一次解密后才赋值的,这个地址是动态的,所以还需单独把esi指向的数据表提前写进PE。另外后面验证完成后打印提示字符串的部分指令也要抠过来,所以我选择了第二种方式

  2. 自己写c++代码实现输入和输出功能,中间将16进制机器码当作ShellCode来调用,只需要将这段ShellCode的比较结果传回来就行,于是把比较结果ecx保存在eax当作函数返回值,补上函数结尾

    图片描述

生成新的PE

逻辑和原来的一样,只是提示的字符串不同

 

部分代码,全部代码见附件FixCrackMe.cpp

int main(void)
{
    // 修改 shellcode 数据段为可读可写可执行
    HANDLE hProc = GetCurrentProcess();
    DWORD dwIdOld;
    VirtualProtectEx(hProc,&shellcode, 35834, PAGE_EXECUTE_READWRITE, &dwIdOld);

    // 获取输入
    char username[32] = {0};
    printf("Input user name: ");
    scanf("%s", username);
    strncpy((char*)esiData, username, 16);
    char passwd[64] = {0};
    printf("Input password: ");
    scanf("%s", passwd);
    int len = strlen(passwd);
    HexStrToByte(passwd, esiData+0x11+0x1b0, len);

    int (*pfun1)() = (int (*) (void))&shellcode;
    // 给esi寄存器赋值,因为验证函数是从这个寄存器中获取数据地址
    // esi是加密需要的数据表地址,esi+0x1b0是用户输入的序列号的16进制数据
    // esi-0x11是用户输入的用户名字符串
    __asm
    {
        mov esi, offset esiData+0x11
        call pfun1
        cmp eax, 0
        jne OUTERR
        call success
        jmp END
OUTERR:
        call error
    };
END:
    system("pause");

    return 0;
}

效果完全一样(还原后的PE见附件FixCrackMe.cpp
图片描述

算法分析

  1. 验证的开始将输入与esi指向的数据表通过一定规则异或

  2. 验证的结尾再将esi指向的数据表中的snail3896q3405%\0字符串分别与特定下标的数进行异或,结果保存在esi+0x1B0,如果算出的结果等于输入的字符串则验证成功

  3. 中间那一大堆操作就是在算下标。而这一大堆操作基本是:计算几个16元一次方程的结果,将结果重新存起来,作为下标在表里找到新的数据,再次带入几个16元一次方程中计算结果,这样的操作一共经过9次运算,得到下标。最后再到表里获得值,这个值作为下标执行2中的操作

随便截个图感受下吧:
图片描述
图片描述

解题思路

因为没做出来这里只说思路,也许不对

  1. 按照算法分析部分2的操作逆推出密钥异或表里几个数的下标,作为几个16元一次方程的值,用z3解方程算出未知数
  2. 然后再用得到的未知数作为方程的值,算新的未知数,重复逆运算9次,最终算出第一步的下标。难受的是,每一次逆算完方程还要去表里找到对应的下标
  3. 最后再用1的操作异或一下表中的数据,就解出来了

分析完算法距离比赛结束还有12个小时,此时我的内心戏
A:没有什么事是熬夜解决不了的,如果有就战到天亮
B:省省吧,明天还要搬一天的砖呢,毁灭吧,赶紧的,累了
A:那做这么多天的题不能白做了,之前的夜不能白熬呀
B:那,就明天中午休息的时候发一篇总结贴,混个精华或优秀也值呀
图片描述



[公告]安全测试和项目外包请将项目需求发到看雪企服平台:https://qifu.kanxue.com

最后于 2019-10-29 18:02 被KevinsBobo编辑 ,原因: 原附件中少了cpp文件
上传的附件:
最新回复 (10)
看场雪 3 2019-9-25 12:32
2
0
KevinsBobo 7 2019-9-25 12:47
3
0
看场雪 [em_63]
我最早以为算法是你出的呢,就没往标准算法上面去想吃了没文化的亏
看场雪 3 2019-9-25 13:00
4
0
KevinsBobo 我最早以为算法是你出的呢,就没往标准算法上面去想[em_85]吃了没文化的亏
目光敏锐呀
此题原设计,还真不是aes,而是另外一个自定义算法。但是怕涉嫌违规,所以改成了aes
KevinsBobo 7 2019-9-25 13:10
5
0
看场雪 目光敏锐呀[em_63] 此题原设计,还真不是aes,而是另外一个自定义算法。但是怕涉嫌违规,所以改成了aes
我自闭了,我要去学密码学了
ccfer 13 2019-9-26 16:55
6
0
楼主说的也没错,即使看不出aes,aes的MixColumns部分求逆确实是可以用方程组解出来的,我就这样做过(比如二向箔那道题)
KevinsBobo 7 2019-9-26 17:35
7
0
ccfer 楼主说的也没错,即使看不出aes,aes的MixColumns部分求逆确实是可以用方程组解出来的,我就这样做过(比如二向箔那道题)
ccfer tql,我是看着要解这么多次方程头都大了,搞不动了
backer 1 2019-9-26 18:49
8
0
唉,都是科锐的,何必呢。相爱相杀,相煎何太急。
KevinsBobo 7 2019-9-26 19:00
9
0
backer 唉,都是科锐的,何必呢。相爱相杀,相煎何太急。
钱老师,这次是我被杀掉了,要替我做主啊
hasikill 2019-9-27 02:49
10
0
学长好, 我们这一题这一次设计上出现了很多的漏洞,不过都是上交之后才考虑到的。希望下次能再次交手! 额外说明一下,你跑脚本一次半小时,我们一次生成的时间是7小时
KevinsBobo 7 2019-9-27 08:01
11
0
hasikill 学长好, 我们这一题这一次设计上出现了很多的漏洞,不过都是上交之后才考虑到的。希望下次能再次交手! 额外说明一下,你跑脚本一次半小时,我们一次生成的时间是7小时
真的是老钱说的相爱相杀呀!优化一下壳代码,做到快速编译还是可以的。另外我发现程序运行后,所有解密过代码的空间占了200多兆,这一点也是可以优化的。
游客
登录 | 注册 方可回帖
返回