2024 KCTF 大赛于8月15日正式开赛!比赛设置了多维度的评分体系,包括难度值、火力值和精致度积分,旨在引导竞赛的难度和趣味度,使其更具挑战性和吸引力。同时,也为参赛选手提供了更加公平、有趣的竞赛平台。
今天中午12点,第四题《神秘信号》已截止答题,
【
Li0kle
】战队用时4小时19分1秒 抢先拿下此题,第二名来自【保护不保护保护】战队、第三名来自【Nepnep】战队。
*注意:签到题《逐光启航》持续开放,整个比赛期间均可提交答案获得积分
本题共有23支战队成功破解,围观人数达1500+。据参赛选手反馈,本题也是蛮有意思的,一起来看看设计思路和题解吧。
战队成员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 CrackMe
while 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 sys
print(sys.modules)
启动 main.exe,sys.modules真的被打印出来了,但是里面还是没有 CrackMe ?
import CrackMe
print(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 marshal
import 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
for i in range(len(encoded_str)
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)
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(kKCTF
Hello 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
得到输出:
...
请输入账号:
KCTF
in function input> 0x18e4b220a90 b'QI8F'
...
问题得到确认,builtin的input被修改过,它的返回值是经过变换的。但是,repr仍然标记为built-in,所以这里是如何实现的?
知己知彼还是非常重要的。每道题目放出前会习惯性的看一下出题人曾经在论坛发过的文章。出题人今年发了一系列python源码分析的文章,所以有预感第四题可能是python,而事实确实如此。
在此回顾下出题人的几篇文章:
第一篇讲了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 = 0
if 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 = 1
module = sys.modules.get(name, _NEEDS_LOADING)
前面hook内置input函数时顺便打印了id(input)的值,挂上调试器按照文章的说法找到最终函数所在,dump内存,ida分析:
__int64 __fastcall sub_136EDA194A0(__int64 a1, __int64 a2)
{
__int64 v2;
__int64 v3;
unsigned int m;
unsigned int i;
__int64 v7;
unsigned int v8;
int j;
int k;
_DWORD *v11;
char v12[16];
char v13[32];
unsigned int *v14;
unsigned int v15;
_QWORD *v16;
__int64 v17;
__int64 v18;
_DWORD *v19;
__int64 (__fastcall *v20)(__int64, __int64, __int64, signed __int64);
void (__fastcall *v21)(__int64, __int64, _BYTE *, __int64, __int64, unsigned int *);
__int64 (__fastcall *v22)(__int64, __int64, __int64, char *);
__int64 (__fastcall *v23)(__int64, __int64, char *, __int64);
_QWORD *v24;
__int64 (__fastcall *v25)(__int64, __int64, _QWORD, _BYTE *);
__int64 v26;
_QWORD *v27;
__int64 v28;
__int64 v29;
__int64 v30;
__int64 v31;
_BYTE v32[112];
_BYTE v33[216];
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.' )
{
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' )
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'