首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
2
0
[原创]KCTF 2026 第二题:巳时·绿光幽语 writeup
发表于: 2026-8-12 11:44
750
[原创]KCTF 2026 第二题:巳时·绿光幽语 writeup
number_Z
2
2026-8-12 11:44
750
## 题目信息 题目给了一个 Windows 程序,运行后要求输入 6 个字符来恢复下面的函数: ```python def my_function(): print("恭喜成功!") ```  ## 1. 先拆 PyInstaller 用 Detect It Easy 看了一眼,样本是 64 位 PE,文件末尾带有很大的 overlay。基本可以确定是 PyInstaller one-file打包。 ```text 题目.exe SHA-256: f6b192f9ee87c4ad2bc8681291ca428cf8e98fec26fa5d7b82c6e791a7a90ead ```  先用 <a href="elink@d0aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6W2P5s2c8J5k6h3#2W2j5$3!0V1k6i4u0K6i4K6u0V1M7X3g2Q4x3V1k6H3P5h3W2F1M7%4c8^5N6s2u0S2j5%4c8G2M7R3`.`.">pyinstxtractor</a> 解包: ```powershell python pyinstxtractor.py "题目.exe" ``` 解包后比较值得注意的只有几项: ```text main.pyc PYZ.pyz python313.dll base_library.zip ```  ## 2. `main` 是个假入口 题目使用 Python 3.13,使用 pycdc 查看main.pyc  `main` 内容等价于: ```python import sys sys.exit(0) ``` 这个结果和实际运行现象明显矛盾:程序明明会打印提示并读取 key,入口脚本却直接退出。说明真正的逻辑不在 `main`,而是在启动过程中加载的其他组件里。 这里检查对比一下官方dll和题目dll的签名信息,可以明显看出dll是未进行官方签名的 官方3.13.15版本python313.dll  题目python313.dll  ## 3. 在 DLL 中找到冻结模块 把 `python313.dll` 单独载入 IDA,在 Strings 窗口搜索: ```text my_function ```  字符串位于 `.rdata`,附近还能看到一些信息:  ```text process_cpu_count print my_function utf-8 co_code co_consts ``` `process_cpu_count` 是 Python 3.13 `os` 模块中的函数。结合周围的 marshal 类型字节,可以判断题目把自定义逻辑塞进了冻结的 `os` 模块。 这里没有正常的代码交叉引用并不奇怪。`my_function` 不是 C 函数名,而是序列化 CodeObject 中的字符串,解释器通过冻结模块表和 marshal 解析器间接读取。 ## 4. 拆出 `my_function` CodeObject Python 3.13 marshal 中常见的类型标记如下: 参考如下资料 <a href="elink@733K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6H3P5i4c8Z5L8$3&6Q4x3V1k6U0M7s2W2@1K9r3!0F1i4K6u0r3j5X3I4G2j5W2)9J5c8U0y4Q4x3X3f1I4x3#2)9J5c8W2m8&6N6r3S2G2L8W2)9J5c8X3#2S2M7Y4y4Z5j5h3I4Q4x3X3g2U0i4K6t1K6e0o6x3J5y4K6l9`.">/3.13/Python/marshal.c</a> | 标记 | 含义 | |---|---| | `63` | CodeObject | | `73` / `F3` | bytes | | `69` / `E9` | 32 位整数 | | `72` | 对前面对象的引用 | | `29` | 小元组 | | `4E` | `None` | | `DA` | 短 ASCII 字符串 | 高位 `0x80` 是引用标记,所以 `F3 & 0x7F == 73`。 沿着 `process_cpu_count` 向后看,可以确定它的 CodeObject 尾部结束于 `0x1804DC68E` 左右。随后出现一个新的 `63`,也就是 `my_function` CodeObject: ```text 0x1804DC693 CodeObject 开始 0x1804DC710 CodeObject 结束,不包含该地址 ``` 我用 IDAPython 把这一段直接导出: ```python import ida_bytes start = 0x1804DC693 end = 0x1804DC710 data = ida_bytes.get_bytes(start, end - start) with open(r"F:\Temp\frozen_1.bin", "wb") as f: f.write(data) ``` 这 125 字节虽然是完整的嵌套 CodeObject,但不能直接 `marshal.load()`。里面存在 `72 xx xx xx xx` 回引用,引用表是在更早的父 marshal 流中建立的。 按照 Python 3.13 CodeObject 的字段顺序手工读取,可以得到: ```text argcount = 0 stacksize = 3 flags = 3 co_code = 26 bytes co_consts = (None, 666) co_names = ('print',) co_name = 'my_function' ``` 把这些字段装进一个最小 CodeType,再用 Python 3.13 自带的 `dis` 反汇编: ```text RESUME LOAD_GLOBAL print LOAD_CONST 666 CALL POP_TOP RETURN_CONST None ``` 所以 DLL 中原本放着的是一个诱饵函数: ```python def my_function(): print(666) ``` ## 5. CodeObject 后面的异常常量 继续向后整理 marshal 数据,可以看到一组很不自然的常量: ```text 8 个长度为 16 的 bytes 对象 整数 0x55 字符串 "utf-8" 整数 6 长度为 26 的 bytes 对象 字符串 "co_code" 长度为 15 的 bytes 对象 字符串 "co_consts" ``` 这些数据连在一起,已经能提出几个比较靠谱的判断: - 8 个等长块可能是被拆开的提示密文; - `0x55` 很像单字节 XOR 掩码; - 解密结果会按 UTF-8 解码; - key 长度为 6; - 26 字节数据可能用于替换 `my_function.__code__.co_code`; - 15 字节数据可能用于替换 `co_consts` 中的字符串。 这时还只是猜测,需要用数据本身验证。 ## 6. 先还原提示 把 8 个 16 字节块拼起来,逐字节与 `0x55` 异或后得到: ```text 提示,需要还原的代码 def my_function(): print("恭喜成功!") 请输入key还原代码(请输入6个字符) ``` 这也解释了旁边为什么同时出现 `0x55`、`utf-8` 和 `6`。 注意,`0x55` 对应的是字符 `U`,但只是解开提示所用的掩码,不是最终 key。  ## 7. 从同一片段恢复 key  当前诱饵函数的 `co_code` 长度正好是 26 字节: ```text 95005b0100000000000000005301350100000000000020006700 ``` 后面紧挨着 `co_code` 字符串的可疑数据同样是 26 字节: ```text ec626d7563727962367463722a63037563727962367443721e62 ``` 既然长度一致,而且旁边明确写着 `co_code`,最直接的验证就是把两段逐字节异或: 两者逐字节异或,前六字节为: | 位置 | 密文 | 明文 | XOR | ASCII | |---:|---:|---:|---:|---| | 0 | `EC` | `95` | `79` | `y` | | 1 | `62` | `00` | `62` | `b` | | 2 | `6D` | `5B` | `36` | `6` | | 3 | `75` | `01` | `74` | `t` | | 4 | `63` | `00` | `63` | `c` | | 5 | `72` | `00` | `72` | `r` | 后续 20 字节继续以相同周期重复,所以 key 为: ```text yb6tcr ``` 再用这个周期 key 解密 15 字节数据: ```text 密文:9fe39b91f5ee9feaa691e9ed96deb7 明文:e681ade5969ce68890e58a9fefbc81 ``` 明文是合法 UTF-8: ```text 恭喜成功! ``` 至此,`co_code` 和 `co_consts` 两条线索互相验证,key 可以确定。  ``` """IDA Python: verify the repeated-XOR relationship in the frozen marshal data. Run in the IDA database opened for extracted/python313.dll: File -> Script file... -> ida_xor_verify.py """ import ida_auto import ida_bytes import ida_kernwin import ida_nalt # RVAs in python313.dll. Using RVAs keeps the script valid if IDA rebases the image. RVA_PLAIN_HEADER = 0x4DC6A8 RVA_PLAIN_DATA = 0x4DC6AD RVA_CIPHER_HEADER = 0x4DC7D0 RVA_CIPHER_DATA = 0x4DC7D5 RVA_SUCCESS_HEADER = 0x4DC7FA RVA_SUCCESS_DATA = 0x4DC7FF CODE_SIZE = 0x1A SUCCESS_SIZE = 0x0F KEY_SIZE = 6 def read_exact(ea, size, label): data = ida_bytes.get_bytes(ea, size) if data is None or len(data) != size: raise RuntimeError( f"Cannot read {label}: address={ea:#x}, expected={size}, " f"actual={0 if data is None else len(data)}" ) return data def hex_line(data): return " ".join(f"{b:02X}" for b in data) def ascii_line(data): return "".join(chr(b) if 0x20 <= b <= 0x7E else "." for b in data) def marshal_bytes_length(header): """Parse the five-byte marshal bytes header: type tag + uint32 length.""" if len(header) != 5 or (header[0] & 0x7F) != ord("s"): return None return int.from_bytes(header[1:5], "little") def main(): ida_auto.auto_wait() image_base = ida_nalt.get_imagebase() plain_header_ea = image_base + RVA_PLAIN_HEADER plain_ea = image_base + RVA_PLAIN_DATA cipher_header_ea = image_base + RVA_CIPHER_HEADER cipher_ea = image_base + RVA_CIPHER_DATA success_header_ea = image_base + RVA_SUCCESS_HEADER success_ea = image_base + RVA_SUCCESS_DATA plain_header = read_exact(plain_header_ea, 5, "plain marshal header") cipher_header = read_exact(cipher_header_ea, 5, "cipher marshal header") success_header = read_exact(success_header_ea, 5, "success marshal header") if marshal_bytes_length(plain_header) != CODE_SIZE: raise RuntimeError("Unexpected plain co_code marshal length") if marshal_bytes_length(cipher_header) != CODE_SIZE: raise RuntimeError("Unexpected encrypted co_code marshal length") if marshal_bytes_length(success_header) != SUCCESS_SIZE: raise RuntimeError("Unexpected encrypted success-string marshal length") plain = read_exact(plain_ea, CODE_SIZE, "plain co_code") cipher = read_exact(cipher_ea, CODE_SIZE, "encrypted co_code") success_cipher = read_exact(success_ea, SUCCESS_SIZE, "encrypted success string") key_stream = bytes(a ^ b for a, b in zip(plain, cipher)) key = key_stream[:KEY_SIZE] periodic = all(key_stream[i] == key[i % KEY_SIZE] for i in range(CODE_SIZE)) success_plain = bytes( value ^ key[i % KEY_SIZE] for i, value in enumerate(success_cipher) ) try: success_text = success_plain.decode("utf-8") except UnicodeDecodeError as exc: success_text = f"<UTF-8 decode failed: {exc}>" key_chunks = " | ".join( key_stream[i:i + KEY_SIZE].decode("ascii", errors="replace") for i in range(0, len(key_stream), KEY_SIZE) ) lines = [ "=" * 78, "Frozen marshal repeated-XOR verification", "=" * 78, f"Image base : {image_base:#x}", "", f"[1] Original co_code : {plain_ea:#x} ({len(plain)} bytes)", f" HEX : {hex_line(plain)}", "", f"[2] Encrypted co_code : {cipher_ea:#x} ({len(cipher)} bytes)", f" HEX : {hex_line(cipher)}", f" ASCII : {ascii_line(cipher)}", "", "[3] XOR stream = original XOR encrypted", f" HEX : {hex_line(key_stream)}", f" ASCII : {key_stream.decode('ascii', errors='replace')}", f" 6-byte chunks: {key_chunks}", f" Candidate key : {key.decode('ascii', errors='replace')}", f" Period-6 check : {periodic}", "", f"[4] Encrypted success string : {success_ea:#x} ({len(success_cipher)} bytes)", f" HEX : {hex_line(success_cipher)}", f" Decrypted : {hex_line(success_plain)}", f" UTF-8 : {success_text}", "=" * 78, ] ida_kernwin.msg("\n".join(lines) + "\n") if __name__ == "__main__": try: main() except Exception as exc: ida_kernwin.msg(f"[xor_verify] ERROR: {exc}\n") raise ``` ## 8. 验证 重新运行题目,输入【yb6tcr】: 
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
2
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
number_Z
2
6
发帖
16
回帖
125
RANK
关注
私信
他的文章
[原创]KCTF 2026 第七题:戌时·暗能潜流 writeup
32
[原创]KCTF 2026 第五题:申时·忆海倒带 writeup
1127
[原创]KCTF 2026 第二题:巳时·绿光幽语 writeup
750
[原创]看雪2020 KCTF秋季赛 第九题 命悬一线writeup
8054
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部