专栏名称: 看雪学苑
致力于移动与安全研究的开发者社区,看雪学院(kanxue.com)官方微信公众帐号。
目录
相关文章推荐
Hacking黑白红  ·  DeepSeek,紧急声明 ·  12 小时前  
Hacking黑白红  ·  DeepSeek,紧急声明 ·  12 小时前  
默安科技  ·  开工大吉,心情好,财源滚滚,烦恼少! ·  2 天前  
人工智能产业链union  ·  “人工智能产业链联盟投融圈”仅限靠谱投资人& ... ·  3 天前  
晋江市市场监督管理局  ·  新春佳节 ... ·  4 天前  
晋江市市场监督管理局  ·  新春佳节 ... ·  4 天前  
51好读  ›  专栏  ›  看雪学苑

2024 KCTF 大赛 | 第四题《神秘信号》设计思路及解析

看雪学苑  · 公众号  · 互联网安全  · 2024-08-22 17:59

正文

2024 KCTF 大赛于8月15日正式开赛!比赛设置了多维度的评分体系,包括难度值、火力值和精致度积分,旨在引导竞赛的难度和趣味度,使其更具挑战性和吸引力。同时,也为参赛选手提供了更加公平、有趣的竞赛平台。


今天中午12点,第四题《神秘信号》已截止答题, Li0kle 】战队用时4小时19分1秒 抢先拿下此题,第二名来自【保护不保护保护】战队、第三名来自【Nepnep】战队。


*注意:签到题《逐光启航》持续开放,整个比赛期间均可提交答案获得积分


本题共有23支战队成功破解,围观人数达1500+。据参赛选手反馈,本题也是蛮有意思的,一起来看看设计思路和题解吧。



出题战队:one team


战队成员ID:穿甲葡萄籽



设计思路


1.程序是pyinstaller打包,还原主文件。



2.可以发现打包程序并无CrackMe的库。如此可推测,在import时动了手脚。对base_library.zip中的文件进行搜索,寻找_find_and_load_unlocked相关调用。可以发现,在codes.pyc中出现此调用。该文件是不应该出现这个调用的。


还原此pyc代码,可发现以下代码:




3.当import的库名称是CrackMe时,就加载base64库。并且把不需要的属性均去除,只留下main属性。找到base64对应的pyc,进行还原。


可发现以下代码:



4.这里是手动制作一个code类型,可通过dis对此code进行反编译。或输出为pyc文件再进行反编译。


得到一个自定义的base64编码函数:



5.随后根据这个函数,写出一个解码函数来。



6.随后对main.py中dZpK字符串进行解码。发现解码后的数据为乱码。并且input输入的数据和得到的结果不一致。



7.根据论坛地址: https://bbs.kanxue.com/thread-276493.htm,所提及的方式,可找到input所hook的函数地址。是一段shellcode,反汇编其算法,是一个简单亦或算法。



8.综合一下,即可算出验证码。



赛题解析


本赛题解析由 看雪论坛学者【mb_mgodlfyn】 提供 ,来自hzqmwne战队。




pyinstaller打包的二进制,用 pyinstxtractor pyinstxtractor-ng 解包(一定要用最新版本,可以省去自己补pyc文件头的步骤)


解释器版本是Python3.8,看到 main.pyc 熟练的掏出 uncompyle6 decompyle3 反编译一下:

import CrackMewhile True:    while True:        print("(账号密码由字母大小写、数字、!、空格组成)")        print("请输入账号:")        h = input()        z = CrackMe.main(h)        if len(z) < 20:            key = "dZpKdrsiB6cndrGY" + z        else:            key = z[0:4] + "dZpK" + z[4:8] + "drsi" + z[8:12] + "B6cn" + z[12:16] + "drGY" + z[16:]        print("请输入验证码:")        h = input()        m = CrackMe.main(h)        if key == m:            print("Success")            break
print("Fail") continue


看着很友好,但是,CrackMe模块在哪里呢?


各种失败的尝试:

  • 对着解包后的文件各种find+grep没有找到任何包含"CrackMe"字样的东西(除了main.pyc)

  • 试着反编译解包出来的pyiboot*和pyimod*文件,无果

  • (就差对着源码开始调试pyinstaller了)

  • 官方群里看到有人说搜内存,想了想一是太麻烦,二是剧透嫌疑,决定先不搞

  • 发现一个有意思的工具 pyrasite ,能够调试器挂进程找到PyRun_SimpleString之类的函数直接注入代码开启一个交互式的shell(可惜,在本题试验没成功)

  • Process Monitor 看程序读写的文件,似乎都很正常……等等,有一个 _internal/_lzma.pyd ?


既然 _internel 目录下的 pyd 文件会被加载,那么不妨试试在这里做注入。


将 _internal/_lzma.pyd 文件删除,放一个 _internal/_lzma.py 文件,里面写上自己想执行的语句:

import sysprint(sys.modules)


启动 main.exe,sys.modules真的被打印出来了,但是里面还是没有 CrackMe ?

那就再直接一点,看看它的真面目:
import CrackMeprint(CrackMe)


输出:


CrackMe的真身竟然是base64?


找到解包出来的 base64.pyc ,反编译一下,果然在末尾有偷梁换柱:

a = main.__code__.replace(1, (), b'd\x01}\x01d\x02}\x02d\x03}\x03d\x04}\x04|\x00D\x00]\x1c}\x05|\x05d\x05A\x00}\x05|\x04|\x05\xa0\x00d\x06d\x07\xa1\x02\x17\x00}\x04q\x14|\x04}\x00t\x01d\x02t\x02|\x00\x83\x01d\x08\x83\x03D\x00]\x90}\x05|\x00|\x05|\x05d\x08\x17\x00\x85\x02\x19\x00}\x06d\x01\xa0\x03d\td\n\x84\x00|\x06D\x00\x83\x01\xa1\x01}\x07t\x01d\x02t\x02|\x07\x83\x01d\x0b\x83\x03D\x00]V}\x08|\x07|\x08|\x08d\x0b\x17\x00\x85\x02\x19\x00}\tt\x02|\t\x83\x01d\x0bk\x00r\xc2|\x02d\x0bt\x02|\t\x83\x01\x18\x007\x00}\x02|\td\x0cd\x0bt\x02|\t\x83\x01\x18\x00\x14\x007\x00}\t|\x01|\x03t\x04|\td\r\x83\x02\x19\x007\x00}\x01q~qF|\x01d\x0e|\x02d\r\x1a\x00\x14\x007\x00}\x01t\x01t\x02|\x01\x83\x01d\r\x1a\x00\x83\x01D\x00]L}\x05|\x01|\x05d\r\x14\x00\x19\x00}\n|\x01|\x05d\r\x14\x00d\x06\x17\x00\x19\x00}\x0b|\x01d\x00|\x05d\r\x14\x00\x85\x02\x19\x00|\x0b\x17\x00|\n\x17\x00|\x01|\x05d\r\x14\x00d\r\x17\x00d\x00\x85\x02\x19\x00\x17\x00}\x01q\xf8|\x01S\x00', (None, '', 0, 'ZQ+U7tSBEKVzyf5coCwb94Dd6raT0eLNin12Hp8mOxFuvMgIPlhRY3WjksqJAXG/', b'', 85, 1, 'little', 3, compile('', '', 'exec').replace(1, (), b'|\x00]\x10}\x01t\x00|\x01d\x00\x83\x02V\x00\x01\x00q\x02d\x01S\x00', ('08b', None), '', 19, 115, (), 0, b'', '', ('format',), 2, 0, 4, ('.0', 'byte'), **('co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_kwonlyargcount', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_posonlyargcount', 'co_stacksize', 'co_varnames')), '', 6, '0', 2, '!'), '', 4, 67, (), 0, b'', '', ('to_bytes', 'range', 'len', 'join', 'int'), 12, 0, 7, ('data', 'encoded_str', 'padding', 'base64_chars', 'ww', 'i', 'chunk', 'binary_str', 'j', 'six_bits', 'a', 'b'), **('co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_kwonlyargcount', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_posonlyargcount', 'co_stacksize', 'co_varnames'))




    
main.__code__ = a


所以 CrackMe.main 就是 base64.main,而它的字节码也被替换掉了


稳妥起见,不去处理上面反编译的东西,而是直接在_lzma.py的hook中dump原始字节码:

import CrackMe import marshalimport importlib code = CrackMe.main.__code__ marshal_data = marshal.dumps(code)pyc_data = importlib._bootstrap_external._code_to_timestamp_pyc(code) with open("crackme_main.marshal", "wb") as f:    f.write(marshal_data) with open("crackme_main.pyc", "wb") as f:    f.write(pyc_data)


(pyc_data参考自 https://stackoverflow.com/questions/73439775/how-to-convert-marshall-code-object-to-pyc-file


得到的 crackme_main.pyc,uncompyle6和decompyle3都会报错,不过标准库的dis模块能正常反汇编。


人工翻译了一会……突然想起了
pycdc ,试了下效果非常完美(需要小修一下)(对反编译的代码重新生成pyc再dis对比,与dump出来的dis完全相同):

def main(data):    # def是自己补上的    encoded_str = ''    padding = 0    base64_chars = 'ZQ+U7tSBEKVzyf5coCwb94Dd6raT0eLNin12Hp8mOxFuvMgIPlhRY3WjksqJAXG/'    ww = b''    for i in data:        i = i ^ 85        ww = ww + i.to_bytes(1, 'little')    data = ww    for i in range(0, len(data), 3):        chunk = data[i:i + 3]        # binary_str = ''.join((lambda .0: for byte in .0: format(byte, '08b'))(chunk))  反编译出来这里的语法不太对        binary_str = ''.join(format(byte, '08b') for byte in chunk)        for j in range(0, len(binary_str), 6):            six_bits = binary_str[j:j + 6]            if len(six_bits) < 6:                padding += 6 - len(six_bits)                six_bits += '0' * (6 - len(six_bits))            encoded_str += base64_chars[int(six_bits, 2)]    encoded_str += '!' * (padding // 2)    for i in range(len(encoded_str) // 2):        a = encoded_str[i * 2]        b = encoded_str[i * 2 + 1]        encoded_str = encoded_str[:i * 2] + b + a + encoded_str[i * 2 + 2:]    return encoded_str


写出逆向算法:

def rev(encoded_str):    tmp = encoded_str    tmp = "".join(tmp[2*i+1]+tmp[2*i] for i in range(len(tmp)//2))    tmp = tmp.rstrip("!")    trans = str.maketrans("ZQ+U7tSBEKVzyf5coCwb94Dd6raT0eLNin12Hp8mOxFuvMgIPlhRY3WjksqJAXG/", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");    tmp2 = tmp.translate(trans)    tmp2 += "=" * ((4-len(tmp2))%4)    tmp3 = base64.b64decode(tmp2)    data = bytes(c ^ 85 for c in tmp3)    return data.decode()


事情看起来解决了?不,问题才刚刚开始。 逆向出来的main函数,其参数data的类型显然需要是bytes,但是main.py调用的时候传递的是str, 只好先按bytes传递,先用给的序列号做验证:


def ztokey(z):    if len(z) < 20:        key = 'dZpKdrsiB6cndrGY' + z    else:        key = z[0:4] + 'dZpK' + z[4:8] + 'drsi' + z[8:12] + 'B6cn' + z[12:16] + 'drGY' + z[16:]    return key z = main(b"D7C4197AF0806891")key = ztokey(z)m = main(b"D7CHel419lo 7AFWor080ld!6891")print(z)print(key)print(m)print(rev(m))
D7DED6vCn6boDrp3W6v3Zr!!




    
D7DEdZpKD6vCdrsin6boB6cnDrp3drGYW6v3Zr!!D7DEbBsZD6vCb53xn6bo2ZmODrp3b5YtW6v3Zr!!D7CHel419lo 7AFWor080ld!6891


rev的逻辑没有错误,问题出现在main.py里面,疑似ztokey时实际拼接的几个字符串常量与看到的不同。


先不去深究,根据上面输出的key和m的对应关系,猜测真正的ztokey2应该是这样:

def ztokey2(z):    if len(z) < 20:        key = 'bBsZb53x2ZmOb5Yt' + z    else:        key = z[0:4] + 'bBsZ' + z[4:8] + 'b53x' + z[8:12] + '2ZmO' + z[12:16] + 'b5Yt' + z[16:]    return key z = main(b"KCTF")key = ztokey(z)key2 = ztokey2(z)print(z)print(key)print(key2)print(rev(key))print(rev(key2))

得到输出:

nBQ6P7!!dZpKdrsiB6cndrGYnBQ6P7!!bBsZb53x2ZmOb5YtnBQ6P7!!T'00-l5-0(kKCTFHello World!KCTF


最终提交答案为 Hello World!KCTF


拿到题第一时间看了下 _internel 目录下 python38.dll 发现带有官方的数字签名,如蒙大赦,感谢出题人没搞什么自己魔改解释器的恶心套路。

题目的未解之谜还有很多,继续通过_lzma.py的hook探索:

import CrackMe import sys origin_crackme_main = CrackMe.main def hook_decompile_crackme_main(data):    print(repr(data))    r = origin_crackme_main(data)    print(repr(r))    return r  CrackMe.main = hook_decompile_crackme_main sys.modules["CrackMe"] = CrackMe

最后给sys.modules赋值是必须的,否则会出现异常:

Traceback (most recent call last):  File "main.py", line 6, in <module>AttributeError: 'NoneType' object has no attribute 'main'


打印出 CrackMe.main 函数的实际输入输出,发现输入 "KCTF" 时,CrackMe.main 函数的输入是 b'QI8F',输出是 'QQMlP7!!'


输入值在此之前被修改过。输入和输出与逆向出来的算法能对应,至少说明逆向过程没有问题。


回忆一下main.py的片段:

...print("请输入账号:")h = input()z = CrackMe.main(h)...

那么只有两种可能:main.py是虚假的,或者,input被修改过。


hook一下input看看:

old_input = input def hook_input(*args, **kwargs):    r = old_input(*args, **kwargs)    print(repr(old_input), hex(id(old_input)), repr(r))    return r __builtins__["input"] = hook_input

得到输出:

...请输入账号:KCTFin function input> 0x18e4b220a90 b'QI8F'...

问题得到确认,builtin的input被修改过,它的返回值是经过变换的。但是,repr仍然标记为built-in,所以这里是如何实现的?


知己知彼还是非常重要的。每道题目放出前会习惯性的看一下出题人曾经在论坛发过的文章。出题人今年发了一系列python源码分析的文章,所以有预感第四题可能是python,而事实确实如此。


在此回顾下出题人的几篇文章:

  • [原创]向pyc注入shellcode或python代码

  • [原创]Python源码解析-PYC文件

  • [原创]Python源码解析-import过程

  • [原创]Python源码解析-builtin_function_or_method


第一篇讲了pyinstaller打包时篡改标准库注入代码
第二篇讲了替换函数的__code__属性改变其逻辑
第三篇讲了修改_frozen_importlib._find_and_load.__code__改变模块加载过程
第四篇讲了内存patch修改builtin函数(builtin函数没有__code__属性,通过内置id函数可以获得PyMethodDef结构的地址,PyMethodDef偏移16字节处是指向PyCFunction结构的指针,PyCFunction偏移8字节的地方是真正的C函数起始地址)


一和二已经观察到了,现在确认一下三和四。 老方法通过_lzma.py的hook把_frozen_importlib._find_and_load.__code__的内容dump出来:

import _frozen_importlib tmp = importlib._bootstrap_external._code_to_timestamp_pyc(_frozen_importlib._find_and_load.__code__)with open("_frozen_importlib._find_and_load.__code__.pyc", "wb") as f:    f.write(tmp)

然后用pycdc反编译:(有 WARNING: Decompyle incomplete ,可以用 pydas 看反汇编,缺少的地方不重要,先不去管)

a = 0if len(name) == 7 and name[0] == 'C' and name[1] == 'r' and name[2] == 'a' and name[3] == 'c' and name[4] == 'k' and name[5] == 'M' and name[6] == 'e':    name = 'base64'    a = 1module = sys.modules.get(name, _NEEDS_LOADING)

前面hook内置input函数时顺便打印了id(input)的值,挂上调试器按照文章的说法找到最终函数所在,dump内存,ida分析:

__int64 __fastcall sub_136EDA194A0(__int64 a1, __int64 a2){  __int64 v2; // rdx  __int64 v3; // rdx  unsigned int m; // [rsp+30h] [rbp-238h]  unsigned int i; // [rsp+34h] [rbp-234h]  __int64 v7; // [rsp+38h] [rbp-230h]  unsigned int v8; // [rsp+40h] [rbp-228h] BYREF  int j; // [rsp+44h] [rbp-224h]  int k; // [rsp+48h] [rbp-220h]  _DWORD *v11; // [rsp+50h] [rbp-218h]  char v12[16]; // [rsp+58h] [rbp-210h] BYREF  char v13[32]; // [rsp+68h] [rbp-200h] BYREF  unsigned int *v14; // [rsp+88h] [rbp-1E0h]  unsigned int v15; // [rsp+90h] [rbp-1D8h]  _QWORD *v16; // [rsp+98h] [rbp-1D0h]  __int64 v17; // [rsp+A0h] [rbp-1C8h]  __int64 v18; // [rsp+A8h] [rbp-1C0h]  _DWORD *v19; // [rsp+B0h] [rbp-1B8h]  __int64 (__fastcall *v20)(__int64, __int64, __int64, signed __int64); // [rsp+B8h] [rbp-1B0h]  void (__fastcall *v21)(__int64, __int64, _BYTE *, __int64, __int64, unsigned int *); // [rsp+C0h] [rbp-1A8h]  __int64 (__fastcall *v22)(__int64, __int64, __int64, char *); // [rsp+C8h] [rbp-1A0h]  __int64 (__fastcall *v23)(__int64, __int64, char *, __int64); // [rsp+D0h] [rbp-198h]  _QWORD *v24; // [rsp+D8h] [rbp-190h]  __int64 (__fastcall *v25)(__int64, __int64, _QWORD, _BYTE *); // [rsp+E0h] [rbp-188h]  __int64 v26; // [rsp+E8h] [rbp-180h]  _QWORD *v27; // [rsp+F0h] [rbp-178h]  __int64 v28; // [rsp+F8h] [rbp-170h]  __int64 v29; // [rsp+100h] [rbp-168h]  __int64 v30; // [rsp+108h] [rbp-160h]  __int64 v31; // [rsp+110h] [rbp-158h]  _BYTE v32[112]; // [rsp+120h] [rbp-148h] BYREF  _BYTE v33[216]; // [rsp+190h] [rbp-D8h] BYREF   v26 = *(_QWORD *)(*(_QWORD *)(sub_136EDA19BA0() + 96) + 24LL);  v24 = (_QWORD *)(v26 + 16);  v16 = *(_QWORD **)(v26 + 16);  v20 = 0LL;  v21 = 0LL;  v22 = 0LL;  v23 = 0LL;  v25 = 0LL;  while ( v16 != v24 )  {    v27 = v16;    v16 = (_QWORD *)*v16;    v7 = v27[6];    v28 = *(int *)(v7 + 60) + v7;    v15 = *(_DWORD *)(v28 + 136);    if ( v15 )    {      v14 = (unsigned int *)(v15 + v7);      if ( v14[6] )      {        v19 = (_DWORD *)(v14[3] + v7);        if ( (*v19 | '    ') == 'nrek' && (v19[1] | '    ') == '23le' && (v19[2] | '    ') == 'lld.' )// kernel32.dll        {          v18 = v14[7] + v7;          v29 = v14[8] + v7;          v17 = v14[9] + v7;          for ( i = 0; i < v14[6]; ++i )          {            v11 = (_DWORD *)(*(unsigned int *)(v29 + 4LL * i) + v7);            if ( *v11 == 'SteG' && v11[1] == 'aHdt' )// GetStdHa              v20 = (__int64 (__fastcall *)(__int64, __int64, __int64, signed __int64))(*(unsigned int *)(v18 + 4LL * *(unsigned __int16 *)(v17 + 2LL * i))                                                                                      + v7);            if ( *v11 == 'daeR' && v11[1] == 'snoC' )              v21 = (void (__fastcall *)(__int64, __int64, _BYTE *, __int64, __int64, unsigned int *))(*(unsigned int *)(v18 + 4LL * *(unsigned __int16 *)(v17 + 2LL * i)) + v7);            if ( *v11 == 'daoL' && v11[1] == 'rbiL'






请到「今天看啥」查看全文