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

WriteUp 4周前 admin
76 0 0
2024 KCTF 大赛 | 第四题《神秘信号》设计思路及解析

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


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


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

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


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



出题战队:one team


战队成员ID:穿甲葡萄籽


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


设计思路


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


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


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


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


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



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


可发现以下代码:


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


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


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


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


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


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


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


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


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


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


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



赛题解析


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


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



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)


输出:

<module 'base64' from '...\main\_internal\base64.pyc'>


CrackMe的真身竟然是base64?


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

a = main.__code__.replace(1, (), b'dx01}x01dx02}x02dx03}x03dx04}x04|x00Dx00]x1c}x05|x05dx05Ax00}x05|x04|x05xa0x00dx06dx07xa1x02x17x00}x04qx14|x04}x00tx01dx02tx02|x00x83x01dx08x83x03Dx00]x90}x05|x00|x05|x05dx08x17x00x85x02x19x00}x06dx01xa0x03dtdnx84x00|x06Dx00x83x01xa1x01}x07tx01dx02tx02|x07x83x01dx0bx83x03Dx00]V}x08|x07|x08|x08dx0bx17x00x85x02x19x00}ttx02|tx83x01dx0bkx00rxc2|x02dx0btx02|tx83x01x18x007x00}x02|tdx0cdx0btx02|tx83x01x18x00x14x007x00}t|x01|x03tx04|tdrx83x02x19x007x00}x01q~qF|x01dx0e|x02drx1ax00x14x007x00}x01tx01tx02|x01x83x01drx1ax00x83x01Dx00]L}x05|x01|x05drx14x00x19x00}n|x01|x05drx14x00dx06x17x00x19x00}x0b|x01dx00|x05drx14x00x85x02x19x00|x0bx17x00|nx17x00|x01|x05drx14x00drx17x00dx00x85x02x19x00x17x00}x01qxf8|x01Sx00', (None, '', 0, 'ZQ+U7tSBEKVzyf5coCwb94Dd6raT0eLNin12Hp8mOxFuvMgIPlhRY3WjksqJAXG/', b'', 85, 1, 'little', 3, compile('', '', 'exec').replace(1, (), b'|x00]x10}x01tx00|x01dx00x83x02Vx00x01x00qx02dx01Sx00', ('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

得到输出:

...请输入账号:KCTF<built-in 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' && v11[2] == 'Ayra' )// LoadLibraryA              v22 = (__int64 (__fastcall *)(__int64, __int64, __int64, char *))(*(unsigned int *)(v18                                                                                                + 4LL                                                                                                * *(unsigned __int16 *)(v17 + 2LL * i))                                                                              + v7);            if ( *v11 == 'PteG' && v11[1] == 'Acor' )// GetProcA              v23 = (__int64 (__fastcall *)(__int64, __int64, char *, __int64))(*(unsigned int *)(v18                                                                                                + 4LL                                                                                                * *(unsigned __int16 *)(v17 + 2LL * i))                                                                              + v7);            if ( v20 && v21 && v22 && v23 )            {              v30 = v20(a1, a2, v2, 'xFFxFFxFFxF6');              for ( j = 0; j < 100; ++j )                v32[j] = 0;              v8 = 0;              v21(a1, a2, v32, v30, 50LL, &v8);              for ( k = 0; k < 200; ++k )                v33[k] = 0;              v33[0] = 's';              for ( m = 0; m < v8; ++m )              {                if ( v32[m] == 'r' || (char)v32[m] == '0x00' || v32[m] == 'n' )                {                  v8 = m;                  break;                }                v33[m + 5] = (v32[m] ^ 0x77) + 21;              }              *(_DWORD *)&v33[1] = v8;              strcpy(v12, "python38.dll");              v31 = v22(a1, a2, v3, v12);              strcpy(v13, "PyMarshal_ReadObjectFromString");              v25 = (__int64 (__fastcall *)(__int64, __int64, _QWORD, _BYTE *))v23(a1, a2, v13, v31);              return v25(a1, a2, v8 + 5, v33);            }          }        }      }    }  }  return 0LL;}

注意到 v33[m + 5] = (v32[m] ^ 0x77) + 21


做个验证:

def convertinput(s):    return bytes((ord(c) ^ 0x77) + 21 for c in s) assert convertinput("KCTF") == b'QI8F'assert main(convertinput("KCTF")) == "QQMlP7!!"assert main(convertinput("Hello World!KCTF")) == "dZpKdrsiB6cndrGYQQMlP7!!"

与先前的所有观察都能对应上,包括main.py里面的常量。


也能解释为什么胡乱输入偶尔会触发builtin input return NULL的System Error:

Traceback (most recent call last):  File "PyInstallerloaderpyiboot01_bootstrap.py", line 78, in <module>  File "PyInstallerloaderpyimod03_ctypes.py", line 96, in install  File "codecs.py", line 37, in fun  File "...main_internalctypesutil.py", line 2, in <module>    import shutil  File "codecs.py", line 37, in fun  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked  File "PyInstallerloaderpyimod02_importers.py", line 419, in exec_module  File "shutil.py", line 29, in <module>  File "codecs.py", line 37, in fun  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked  File "PyInstallerloaderpyimod02_importers.py", line 419, in exec_module  File "lzma.py", line 27, in <module>  File "codecs.py", line 37, in fun  File "...main_internal_lzma.py", line 105, in <module>    print(input())SystemError: <built-in function input> returned NULL without setting an error

至于为什么忽略convertinput的转换也能找到正确的答案,回顾一下CrackMe.main函数的逻辑,依次是单字节异或、换表base64、相邻两字节交换。


那么,输出的每4个字节实际只受对应输入的3个字节影响。


而main.py里恰恰是对CrackMe.main输出以4字节为单位做重组,对于”KCTF”这样的短输入,甚至只是直接移动到末尾。


所以,最终的serial,一定是输入的name与”Hello World!”这个12字节的常量的组合。


事实上,convertinput可以改成任意的单字节映射,都不影响这个结论。


至此,题目最后的谜团只有对_frozen_importlib._find_and_load和input两处的修改是在何处初始化的。


估计是藏在了某个模块的初始化代码里,具体可能要追pyinstaller的初始化流程。
不过,从上面SystemError的调用栈来看,这两处修改的位置相当早,至少在加载_lzma.py之前已经完成了。

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

今日中午12点,第五题  废弃星球

正式开赛

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


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

球分享

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

球点赞

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

球在看


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

点击阅读原文查看更多

原文始发于微信公众号(看雪学苑):2024 KCTF 大赛 | 第四题《神秘信号》设计思路及解析

版权声明:admin 发表于 2024年8月22日 下午6:00。
转载请注明:2024 KCTF 大赛 | 第四题《神秘信号》设计思路及解析 | CTF导航

相关文章