从CTF EasyLogin题解剖析JWT安全与Node.js原型链污染实战

📅 2026/8/7 6:06:43
从CTF EasyLogin题解剖析JWT安全与Node.js原型链污染实战
1. 从一道CTF题看Web登录的“简单”与“复杂”最近在复盘一些经典的CTFCapture The Flag题目发现很多安全问题的根源往往就藏在那些看似“简单”的功能里。比如登录一个几乎每个Web应用都有的基础功能却常常是安全攻防的焦点。今天我们就来深度拆解一道名为“EasyLogin”的CTF题目它来自HFCTF2020。题目名字叫“简单登录”但背后涉及的知识点可一点都不简单涵盖了JWTJSON Web Token安全、Node.js原型链污染、逻辑漏洞等多个层面。通过这道题我们不仅能学到如何解题更能深入理解一个现代Web登录机制可能存在的脆弱点以及在实际开发中应该如何规避。无论你是CTF爱好者、Web安全初学者还是想加固自己应用的后端开发这篇文章都会带你走一遍完整的思考、测试与利用流程。2. 初探靶场环境搭建与基础信息收集拿到一个CTF题目尤其是Web类第一步永远是信息收集。我们需要知道目标是什么能做什么暴露了哪些接口。2.1 题目环境与访问通常CTF题目会提供一个IP地址或域名加端口。假设我们访问到的地址是http://target:port/。打开页面我们很可能看到一个非常简洁的登录界面可能只有一个用户名、密码输入框和一个登录按钮。题目名字叫“EasyLogin”暗示登录是突破口。首先用浏览器开发者工具F12查看网络请求和源代码。查看页面源码右键查看网页源代码寻找隐藏的注释、JS文件路径、可能泄露的目录或接口信息。有时出题人会在HTML注释里给提示。分析网络请求尝试随意输入用户名密码点击登录观察浏览器发送了什么样的请求。大概率是一个POST请求到某个如/login或/api/login的端点。查看请求头和响应体。目录/文件扫描使用工具如dirsearch、gobuster或ffuf对网站进行目录爆破寻找可能存在的备份文件如.git、.bak、www.zip、管理员界面/admin、API文档/api、/swagger-ui或其他功能端点/register、/profile。注意在真实CTF或授权测试中目录扫描的强度线程数、字典大小要适当避免对目标造成压力。在本地靶场则无此顾虑。2.2 关键发现JWT与源码泄露在对“EasyLogin”的测试中我们很可能发现以下关键信息登录认证方式登录成功后服务器返回的不是传统的Session Cookie而是一个放在响应头或响应体中的Token。仔细看这个Token通常由三部分组成xxxxx.yyyyy.zzzzz用点分隔这正是JWT的典型特征。源码获取通过目录扫描我们可能发现了一个关键的源码文件例如www.zip或app.js.bak。下载并解压我们获得了整个Node.js后端应用的源代码。这是本题的核心突破口。有了源码我们就可以从“黑盒测试”转向“白盒审计”效率将大大提升。接下来我们深入审计源码。3. 白盒审计三层漏洞链的深度剖析假设我们拿到的源码结构如下主要文件是app.jsconst express require(express); const jwt require(jsonwebtoken); const bodyParser require(body-parser); const app express(); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); const SECRET_KEY a_very_secret_key_that_you_need_to_guess; // 示例实际可能不同 // 模拟一个用户数据库 let users [ { username: admin, password: you_never_guess_it, isAdmin: true }, { username: guest, password: guest, isAdmin: false } ]; // 登录接口 app.post(/login, (req, res) { const { username, password } req.body; const user users.find(u u.username username u.password password); if (user) { // 关键点1JWT生成 const token jwt.sign({ username: user.username, isAdmin: user.isAdmin }, SECRET_KEY, { algorithm: HS256 }); // 使用HS256算法 res.json({ token }); } else { res.status(401).json({ error: Invalid credentials }); } }); // 一个需要认证的API例如获取flag app.get(/flag, (req, res) { const authHeader req.headers[authorization]; if (!authHeader || !authHeader.startsWith(Bearer )) { return res.status(401).json({ error: No token provided }); } const token authHeader.split( )[1]; try { // 关键点2JWT验证 const decoded jwt.verify(token, SECRET_KEY); // 关键点3权限检查 if (decoded.isAdmin true) { res.json({ flag: HFCTF{This_Is_The_Real_Flag} }); } else { res.status(403).json({ error: You are not admin }); } } catch (err) { res.status(401).json({ error: Invalid token }); } }); // 一个可能存在问题的配置或工具函数 const merge (target, source) { for (let key in source) { if (typeof source[key] object source[key] ! null) { if (!target[key]) Object.assign(target, { [key]: {} }); merge(target[key], source[key]); } else { Object.assign(target, { [key]: source[key] }); } } return target; } // 某个接收用户JSON配置的接口假设 app.post(/config, (req, res) { const userConfig req.body; const defaultConfig { theme: light, notifications: true }; // 关键点4不安全的对象合并 const finalConfig merge(defaultConfig, userConfig); // ... 使用finalConfig res.json({ config: finalConfig }); }); app.listen(3000, () console.log(Server running on port 3000));审计这段代码我们可以梳理出至少三条攻击路径它们可能独立存在也可能需要串联利用。3.1 漏洞点一脆弱的JWT密钥在/login路由中JWT使用HS256HMAC with SHA-256算法和一个固定的SECRET_KEY进行签名。HS256是一种对称加密算法意味着签名和验证使用同一个密钥。攻击思路 如果SECRET_KEY强度不够如短、常见、可预测攻击者可以尝试暴力破解。但更常见的是密钥可能被硬编码在源码中并且源码可能通过.git泄露、备份文件泄露等方式被获取。一旦攻击者拿到源码密钥就直接暴露了。本题中我们通过信息收集拿到了源码密钥SECRET_KEY就直接在手了。利用方法 拥有密钥后我们可以伪造任意内容的JWT Token。使用官方库jsonwebtoken或在线工具用获取到的SECRET_KEY签名一个新的Token。在新Token的载荷Payload中将username改为adminisAdmin改为true。将伪造的Token放入请求头Authorization: Bearer your_fake_token访问/flag接口。修复与加固永远不要硬编码密钥使用环境变量或配置中心管理密钥。使用强密钥密钥应有足够的长度和随机性。考虑非对称算法如RS256使用私钥签名公钥验证即使公钥泄露也无法伪造Token。3.2 漏洞点二JWT算法混淆攻击即使我们拿不到SECRET_KEY也可能存在算法混淆攻击。注意jwt.sign和jwt.verify的用法。攻击原理 JWT头部Header中的alg字段声明了签名算法。如果服务器在验证时依赖于Token头部自声明的alg来选择验证逻辑就可能出现问题。一些有缺陷的库或代码实现可能会这样验证// 危险写法根据token.header.alg动态选择密钥和算法 if (decodedHeader.alg HS256) { verify(token, SECRET_KEY); } else if (decodedHeader.alg RS256) { verify(token, PUBLIC_KEY); // 使用公钥验证 }攻击者可以将一个原本是RS256非对称算法签名的Token在头部改为HS256对称然后尝试用公开的PUBLIC_KEY作为HS256的密钥去让服务器验证。如果服务器逻辑有缺陷它会用PUBLIC_KEY作为HS256的密钥去验证签名而攻击者恰好可以用PUBLIC_KEY伪造HS256签名。在本代码中的情况 我们的代码明确指定了jwt.verify(token, SECRET_KEY)没有动态根据alg选择密钥因此直接利用算法混淆攻击可能不成功。但是我们需要检查jwt.verify的第三个参数。在jsonwebtoken库的某些版本或使用方式下如果调用jwt.verify(token, SECRET_KEY)而不指定算法列表库可能会接受多种算法。更安全的做法是明确指定允许的算法// 安全的验证明确指定算法 const decoded jwt.verify(token, SECRET_KEY, { algorithms: [HS256] });如果题目源码中没有指定algorithms且服务器环境存在RS256的公钥/私钥对理论上存在算法混淆攻击的可能。我们需要检查源码或环境寻找公钥。利用步骤找到服务器的公钥可能存在于/static/public.pem、/cert等路径。使用工具如jwt_tool将Token算法改为HS256并用公钥文件作为密钥进行签名伪造。发送伪造的Token。3.3 漏洞点三Node.js原型链污染这是本题更精彩也可能更隐蔽的一环。看源码中的merge函数和/config接口。漏洞原理merge函数是一个常见的深合并deep merge实现但它没有对传入的source对象的属性名进行安全检查。在JavaScript中对象的__proto__、constructor、prototype属性是特殊的它们指向对象的原型。当执行merge(target, source)时如果source是{ __proto__: { isAdmin: true } }函数会递归合并。在合并到__proto__这个key时由于typeof source[“__proto__”]是object它会进入递归。但target[“__proto__”]可能不存在Object.assign(target, { [“__proto__“]: {} })这一行试图给target设置一个名为__proto__的属性。然而在JavaScript中直接设置__proto__的行为是复杂的并且可能修改target对象的原型链。关键在于如果target是一个普通的对象如defaultConfig在某些Node.js环境下通过精心构造的Payload我们可以污染这个对象的原型。如果后续有代码从target或其它共享同一原型的对象中读取属性比如decoded.isAdmin中的decoded对象就可能读取到被污染的原型属性。攻击链串联想象假设登录后用户信息如user对象被存储在某个全局或上下文对象中。/config接口存在原型链污染漏洞我们可以通过它污染某个基础对象的原型。后续JWT验证后得到的decoded对象如果其原型被污染那么访问decoded.isAdmin时JavaScript会沿着原型链查找最终找到我们污染进去的isAdmin: true。这样即使Token载荷里isAdmin是false实际判断时也会变成true从而绕过管理员检查。利用步骤首先需要有一个普通用户账号如guest/guest登录获取一个合法的JWT TokenisAdmin: false。使用这个Token认证向/config接口发送恶意Payload{ __proto__: { isAdmin: true } }注意实际的Payload构造可能需要根据merge函数的具体实现和Node.js版本进行调整有时需要使用constructor.prototype等属性。污染成功后再次使用之前那个普通用户的Token去访问/flag。此时decoded对象的原型链已被污染decoded.isAdmin可能会返回true从而获取flag。修复方案避免不安全的递归合并使用安全的库进行对象合并如lodash.merge但需注意旧版本也可能有问题。过滤敏感属性名在合并前检查source对象的key拒绝__proto__、constructor、prototype等属性。function safeMerge(target, source) { for (let key in source) { if ([__proto__, constructor, prototype].includes(key)) { continue; // 直接跳过危险属性 } // ... 其余合并逻辑 } }使用无原型的对象在创建作为合并目标的对象时使用Object.create(null)创建一个没有原型的纯净对象。4. 实战利用一步步获取Flag结合以上分析最可能的解题路径是串联漏洞。我们假设场景是SECRET_KEY未知无法直接伪造JWT但存在原型链污染漏洞。4.1 第一步信息收集与登录访问靶场地址发现登录页。尝试弱口令、SQL注入等无果后进行目录扫描发现www.zip下载得到源码。审计源码发现/login逻辑、/flag的权限检查、/config的不安全merge函数。从源码或通过其他途径有时题目描述会给出获得一个普通用户凭据例如guest:guest。使用guest登录从响应中获取JWT Token假设为token_guest。4.2 第二步构造原型链污染Payload我们需要研究merge函数在目标Node.js版本下的具体行为。可以使用一个简单的测试脚本或者直接向/config接口发送不同变体的Payload。常见Payload变体{__proto__: {isAdmin: true}}{constructor: {prototype: {isAdmin: true}}}嵌套结构的Payload以适配merge函数的递归逻辑。发送请求curl -X POST http://target:port/config \ -H Content-Type: application/json \ -H Authorization: Bearer token_guest \ -d {__proto__: {isAdmin: true}}观察响应如果污染成功服务器可能不会返回明显错误。4.3 第三步验证污染与获取Flag污染成功后原型污染的影响范围是当前Node进程或特定上下文中后续创建的或符合条件的所有对象。我们需要验证污染是否对JWT解码后的对象生效。再次使用guest的Token访问/flagcurl http://target:port/flag \ -H Authorization: Bearer token_guest如果我们的污染攻击成功这次请求应该会返回{“flag”: “HFCTF{...}”}而不是403 You are not admin。因为jwt.verify返回的decoded对象其原型链被我们注入了isAdmin: true。4.4 第四步其他可能性与验证如果以上步骤不成功我们需要回溯检查算法混淆查看源码中jwt.verify是否指定了算法。如果没有尝试寻找公钥文件进行算法混淆攻击。检查密钥硬编码如果源码中直接显示了SECRET_KEY那就直接用该密钥伪造管理员Token这是最直接的路径。检查污染PayloadNode.js版本不同对__proto__赋值的限制不同。尝试使用constructor.prototype的Payload。也可以尝试污染其他可能被decoded对象访问的属性。寻找其他接口可能还存在其他接收用户输入、进行不安全对象操作的接口同样是污染入口。5. 防御之道从CTF到真实开发的安全编码实践这道“EasyLogin”题目是一个绝佳的教学案例它揭示了从入口点到核心权限校验的完整攻击链。在实际开发中我们应该如何防御5.1 JWT安全实践密钥管理使用强随机密钥并通过环境变量 (process.env.SECRET_KEY) 引入绝对不要硬编码。算法明确化在jwt.verify时始终明确指定允许的算法列表如{ algorithms: [‘HS256’] }防止算法混淆。令牌有效期为JWT设置合理的exp过期时间避免令牌被无限期使用。避免敏感信息不要在JWT载荷中存放密码等敏感信息因为它仅被Base64编码而非加密。5.2 防范原型链污染禁用原型属性使用Object.freeze(Object.prototype)、Object.freeze(Object)或类似方法冻结内置原型防止被修改对系统有影响需谨慎。安全的对象操作使用Object.assign()进行浅拷贝它不会复制原型链上的属性。使用安全的库进行深拷贝如lodash的cloneDeep函数确保版本最新。如果需要自定义合并必须对输入对象的key进行过滤拒绝__proto__、constructor、prototype。代码审计在代码审查中特别关注那些对用户提供的JSON或对象进行递归合并、克隆、赋值的函数。5.3 整体权限与输入安全最小权限原则像/flag这样的高权限接口除了检查isAdmin还应结合其他因素如IP、二次认证进行校验。输入验证与净化对所有用户输入进行严格的类型、范围、格式检查。对于JSON输入可以使用JSON Schema进行验证。依赖库安全定期更新依赖库如jsonwebtoken、lodash使用npm audit或snyk检查已知漏洞。这道“EasyLogin”题目之所以经典是因为它将多个中高级Web漏洞巧妙地编织在一个简单的登录功能背后。解决它需要攻击者具备完整的安全知识链信息收集、源码审计、JWT机制理解、Node.js原型继承原理以及逻辑串联能力。而在防御侧它再次提醒我们安全无小事任何一个看似微不足道的代码片段都可能成为整个系统沦陷的起点。在开发中时刻保持对用户输入的不信任对第三方库的警惕以及对安全最佳实践的遵循才是构建稳固应用的基石。