WEB-2026DASCTF夏季赛-CorpGate

发布时间:2026/6/28 2:43:24
WEB-2026DASCTF夏季赛-CorpGate 这里给了个源码附件我们先进行源码审计【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和prototypeif (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 {}返回的是objecttypeof {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刚好有这个三层嵌套让我们来绕过)里面——为了凑够 depth3经代码审计后面有deepMerge(user.settings, req.body);即target是user.settings即服务器默认用户配置,具体解析见下面的实际流程3实际流程deepMerge(target, source, depth)deepMerge(settings, payload, depth0) ##先遍历我们payload的key keynotifications → settings.notifications 存在且是对象 → 递归 deepMerge(settings.notifications, payload.notifications, depth1) keydigest → settings.notifications.digest 存在且是对象 → 递归 deepMerge(settings.notifications.digest, payload.digest, depth2) keychannels → settings.notifications.digest.channels 存在且是对象 → 递归 deepMerge(settings.channels, payload.channels, depth3) ← depth3 keyconstructor → depth≥3绕过BLOCKED_ROOTS → target[constructor] Object(函数) → typeof function → 递归 deepMerge(Object, {prototype:{pending:sb}}, depth4) keyprototype → depth≥3绕过BLOCKED_ROOTS → Object.prototype 是对象 → 递归 deepMerge(Object.prototype, {pending:sb}, depth5) keypending → 不是对象 → 直接赋值 → 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文件中