漏洞概述
Flowise 是一个广泛使用的开源无代码/低代码平台,旨在简化不具备高级技术技能的用户创建 AI 代理的过程。它提供拖放式界面,方便用户配置知识库、工具和模型。
Flowise 拥有超过 35K 的 GitHub Star 和 1M+ 的 Docker 拉取量,用户范围涵盖中小型企业到大型企业。
在探索使用 Flowise 平台开发 AI 代理的过程中,我们发现了一个严重漏洞(CVE-2025-26319)。该漏洞允许未认证攻击者通过"知识上传"功能,向托管 AI 代理的服务器上传任意文件。攻击者可利用此漏洞上传恶意文件、脚本、配置文件甚至 SSH 密钥,从而获取整个服务器的远程控制权。
技术细节
在 Flowise 系统中,某些 API 被有意设置为无需认证即可访问,这些 API 被归类在白名单 WHITELIST_URLS 中。
javascript
export const WHITELIST_URLS = [
'/api/v1/verify/apikey/',
'/api/v1/chatflows/apikey/',
'/api/v1/public-chatflows',
'/api/v1/public-chatbotConfig',
'/api/v1/prediction/',
'/api/v1/vector/upsert/',
'/api/v1/node-icon/',
'/api/v1/components-credentials-icon/',
'/api/v1/chatflows-streaming',
'/api/v1/chatflows-uploads',
'/api/v1/openai-assistants-file/download',
'/api/v1/feedback',
'/api/v1/leads',
'/api/v1/get-upload-file',
'/api/v1/ip',
'/api/v1/ping',
'/api/v1/version',
'/api/v1/attachments',
'/api/v1/metrics'
]
当服务器收到新请求时,系统会检查 URL 是否在白名单中。若在白名单内,请求继续处理;否则,系统要求进行身份验证。
javascript
// @ /packages/server/src/index.ts
this.app.use(async (req, res, next) => {
// Step 1: Check if the req path contains /api/v1 regardless of case
if (URL_CASE_INSENSITIVE_REGEX.test(req.path)) {
// Step 2: Check if the req path is case sensitive
if (URL_CASE_SENSITIVE_REGEX.test(req.path)) {
// Step 3: Check if the req path is in the whitelist
const isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url))
if (isWhitelisted) {
next() // continue
} else if (req.headers['x-request-from'] === 'internal') {
basicAuthMiddleware(req, res, next)
} else {
const isKeyValidated = await validateAPIKey(req)
if (!isKeyValidated) {
return res.status(401).json({ error: 'Unauthorized Access' })
}
next()
}
} else {
return res.status(401).json({ error: 'Unauthorized Access' })
}
} else {
// If the req path does not contain /api/v1, then allow the request to pass through, example: /assets, /canvas
next()
}
})
现在,让我们仔细检查 /api/v1/attachments 路由。
此 API 路由用于处理最终用户上传附件供代理处理。例如,用户可以上传图像并要求代理或聊天机器人描述图片内容。
javascript
// @ /packages/server/src/routes/attachments/index.ts
const router = express.Router()
// CREATE
router.post('/:chatflowId/:chatId', getMulterStorage().array('files'), attachmentsController.createAttachment)
export default router
经过多次调用后,请求最终到达 createFileAttachment 函数。
最初,该函数从请求中获取 chatflowid 和 chatid,但未进行任何额外验证。唯一检查是确保这些参数存在于请求中。
javascript
// @ /packages/server/src/utils/createAttachment.ts
// @ createFileAttachment function
const chatflowid = req.params.chatflowId
if (!chatflowid) {
throw new Error('Params chatflowId is required! Please provide chatflowId and chatId in the URL: /api/v1/attachments/:chatflowId/:chatId')
}
const chatId = req.params.chatId
if (!chatId) {
throw new Error('Params chatId is required! Please provide chatflowId and chatId in the URL: /api/v1/attachments/:chatflowId/:chatId')
}
接下来,该函数检索上传的文件,并尝试通过调用 addArrayFilesToStorage 函数将它们添加到存储中。
javascript
// @ /packages/server/src/utils/createAttachment.ts
// @ createFileAttachment function
const files = (req.files as Express.Multer.File[]) || []
const fileAttachments = []
if (files.length) {
// ...
for (const file of files) {
const fileBuffer = await getFileFromUpload(file.path ?? file.key) // get the uploaded file
const fileNames: string[] = []
file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8')
// add it to the storage
const storagePath = await addArrayFilesToStorage(
file.mimetype,
fileBuffer,
file.originalname,
fileNames,
chatflowid,
chatId
)
// add it to the storage
// ...
await removeSpecificFileFromUpload(file.path ?? file.key) // delete from tmp
// ...
fileAttachments.push({
name: file.originalname,
mimeType: file.mimetype,
size: file.size,
content
})
} catch (error) {
throw new Error(`Failed operation: createFileAttachment - ${getErrorMessage(error)}`)
}
}
return fileAttachments
现在,我们来看一下 addArrayFilesToStorage 函数。
javascript
// @ /packages/components/src/storageUtils.ts
// @ addArrayFilesToStorage function
export const addArrayFilesToStorage = async (
mime: string,
bf: Buffer,
fileName: string,
fileNames: string[],
...paths: string[]
) => {
const storageType = getStorageType()
const sanitizedFilename = _sanitizeFilename(fileName)
if (storageType === 's3') {
// ...
} else {
const dir = path.join(getStoragePath(), ...paths) // PATH TRAVERSAL.
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
const filePath = path.join(dir, sanitizedFilename)
fs.writeFileSync(filePath, bf)
fileNames.push(sanitizedFilename)
return 'FILE-STORAGE::' + JSON.stringify(fileNames)
}
}
如代码注释所示,该函数通过将 getStoragePath 函数的输出与 ...paths(即之前从请求中提取的 chatflowid 和 chatId)拼接来构建目录路径。
如前所述,这些值并未被验证是否为 UUID 或整数。因此,攻击者可以操纵这些变量,将 dir 变量设置为任意值。
结合文件名也由用户提供的这一点,最终导致了未认证任意文件上传漏洞。
漏洞利用演示(POC)
以下仅为所需的 HTTP 请求。
如我们所见,我们并未经过身份认证。通过操纵 chatId 参数,我们可以执行路径遍历。在此示例中,我们覆盖了 api.json 文件,该文件包含系统的 API 密钥。
在此示例中,dir 变量将解析为 /root/.flowise/storage/test/../../../../../root/.flowise/,filename 为 api.json。当在用户界面中检查 API 密钥时,我们可以看到它们已被修改。
漏洞影响
未认证任意文件上传可能导致严重后果,例如:
- 危害整个代理框架
- 获取整个服务器的远程控制权
- 数据渗透等
披露时间线
- 2025年1月20日 --- 首次尝试联系供应商。
- 2025年2月2日 --- 使用 GitHub 私有安全功能提交漏洞详情。
- 2025年2月11日 --- 通过 Flowise Discord 服务器第二次尝试联系供应商。
- 2025年2月17日 --- 通过 Flowise 邮箱地址第三次尝试联系供应商。
- 2025年2月28日 --- 第四次尝试联系供应商,通知其将公开发布。
- 2025年3月6日 --- 发布此博客文章。
重要说明
遗憾的是,Flowise 团队对我们的努力未作出回应。尽管我们在过去45天内尝试与他们合作修复此漏洞,但未收到任何确认。鉴于我们观察到该漏洞正被积极利用,我们艰难地决定公开披露它,以确保其他人能够采取适当的预防措施。此漏洞的解决方案简单明了,我们提供了一个补丁以帮助有兴趣解决此问题的人。
补救措施
有两种方法可以缓解此漏洞:
-
更改存储类型为 S3 :默认情况下,存储类型设置为
Local,这使漏洞更为严重。如果您的存储类型是 S3,则免受此类攻击。 -
应用我们提供的补丁:我们鼓励您事先审查补丁,因为它非常小且直观,您可以放心应用。
您可以在此处获取漏洞补丁。
如果您想在应用补丁后验证您的服务是否保持安全,请在 uncoveragent.com 留下您的详细信息。 CSD0tFqvECLokhw9aBeRqpnsY1rOFcBy+9txYADKsO6F9Pf3nieYy9++rTT+gJJQZ4OJKjXH5GuyaxkaaHya2+NIG5umwLcKj6pmJjyiP2ve0eFKxaJOJKnMGl0HSUa19EO0ZpdbBf967/m0JqZ8MvXpZhj/OSiAB4dGHsUQGDA=