题目: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,这不是重点
接下来是重点:
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合并污染了,但下面做了过滤,我们接下来就要绕过过滤
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 };
关键代码有两处:
const BLOCKED_ROOTS = ['__proto__', '__defineGetter__', '__defineSetter__', 'constructor', 'prototype'];
const BLOCKED_KEYS = ['__proto__', '__defineGetter__', '__defineSetter__'];##BLOCKED_ROOTS过滤了5个,BLOCKED_KEYS过滤了3个,他们的差异就是constructor和prototype
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,这里直接拿出来讲解一下:
{"notifications": {"digest": {"channels": {"constructor": {"prototype": {"pending": "sb"}}}}}
}
(1)对于if (BLOCKED_KEYS.indexOf(key) !== -1) continue;
这是绕不了的,如果 key 是 __proto__ 、 __defineGetter__ 、 __defineSetter__ 之一,直接跳过,也就是不会执行后面的deepmarge函数,导致我们的污染失败
if (typeof target[key] === 'object' && target[key] !== null) {deepMerge(target[key], source[rawKey], depth + 1);
(2)对于
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)
deepMerge(settings, payload, depth=0)
##先遍历我们payload的keykey="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=3key="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 的结构是:
{
"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:
{"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文件下:
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文件里面:
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 TTLif (Date.now() - entry.created > entry.ttl) {delete config.diagnosticStore[ref];return res.status(410).json({ error: 'Reference expired' });}// Check one-time useif (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

最后,谢谢大家的阅读,如果上文有错误的地方欢迎指出,本人也是刚学,可能也有不足之处

