1. 为什么Agent类项目更容易把SQL注入带进生产环境先聊个我在实际开发中观察到的现象。传统Web应用的SQL注入多数是研发在写接口时手滑拼了字符串但OpenClaw这类Agent平台出现后注入风险反而藏得更深了——不是开发者亲手写出来的而是模型在“帮”你写。OpenClaw本身是一个多智能体编排框架核心玩法是让Agent调用各种工具去完成任务比如查数据库、操作浏览器、收发消息。当我在OpenClaw里给Agent配置一个“查询订单”的技能时最自然的做法是让Agent根据用户问题动态生成SQL并执行。问题恰恰出在这里模型生成SQL的能力越强拼接出危险语句的概率就越高。你让Agent解析“帮我查一下用户名为admin的全部订单”它极有可能生成SELECT * FROM orders WHERE username admin。如果用户输入的不是admin而是admin OR 11模型在没有约束的情况下照样会原样拼进去然后你的订单表就裸奔了。这就是Agent类项目特有的安全悖论我们在用AI提升效率同时也在把漏洞生成的效率提升一个量级。传统安全方案里“代码审计”这个环节面对Agent动态生成SQL的场景基本失效因为漏洞不是在代码仓库里写死的而是在运行时由模型现场发挥的。所以这篇文章我不打算讲传统的“在代码里别拼接SQL”这种老生常谈而是聚焦在Agent开发模式下怎么防注入核心就两条路参数化查询和ORM安全使用。这两套东西不是什么新概念但在OpenClaw的技能开发、工具函数封装、Agent指令设计里怎么落地确实有大量细节值得掰开揉碎讲清楚。2. 从SQL注入的本质出发理解参数化查询为什么是治本2.1 注入的本质是“代码和数据分不开”要理解参数化查询为什么能防注入先得回到SQL注入的根子上。数据库引擎在执行一条SQL之前会先做词法分析和语法解析把整条语句拆成语义单元。比如SELECT * FROM users WHERE name admin OR 11这条语句数据库解析器会把OR 11当作查询条件的一部分而不是用户输入的数据。关键区别在于主动拼接SQL时数据库无法区分哪些片段是开发者写的逻辑、哪些片段是用户输入的数据——它们混在一起了。用户输入一旦能影响SQL的语法结构注入就成功了。参数化查询做的事情底层是在客户端和服务器之间采用二进制协议传输查询模板和参数。SQL模板先被数据库服务器预编译此时语句的语法结构已经固定用户参数只会被当作“值”填充到预先留好的占位符中永远不会参与语法解析。参考MySQL官方的实现预编译语句在服务端会生成对应的数据结构后续每次执行只需要传递参数值不再重新解析语法。可以这么类比拼接SQL是把用户的话直接写进合同的正文里参数化查询是提前印好合同模板只允许用户在“乙方签字”那一栏填写内容。用户内容写得再离谱也只是签字栏里的一个名字永远成不了合同条款。2.2 各类驱动和语言里的参数化写法对比在OpenClaw的技能开发中涉及数据库操作时会用到不同语言和驱动。我整理了一份对比直接覆盖最常见的几种场景Python sqlite3内置模块# 不安全写法字符串格式化拼接 cursor.execute(fSELECT * FROM users WHERE email {user_input}) # 安全写法占位符参数化 cursor.execute( SELECT * FROM users WHERE email ?, (user_input,) )Python MySQLdb / PyMySQL# 安全写法%s占位符 cursor.execute( SELECT * FROM users WHERE email %s, (user_input,) )Node.js mysql2// 安全写法? 占位符 const [rows] await connection.execute( SELECT * FROM users WHERE email ?, [userInput] );Node.js pgPostgreSQL// 安全写法$1、$2 编号占位符 const { rows } await pool.query( SELECT * FROM users WHERE email $1 AND status $2, [userInput, active] );Go database/sql// 安全写法? 占位符 rows, err : db.Query( SELECT * FROM users WHERE email ?, userInput, )每种驱动都有自己的占位符风格但原理一致SQL模板与参数分离传输数据库侧预编译后按位置绑定参数值。开发Agent技能时建议直接把这种写法固化成团队内部的标准模板别想着每次现写。2.3 参数化查询解决不了的问题参数化查询不是万能药它在三类场景下依然存在盲区动态表名和列名ORDER BY子句、动态表名、动态列名这类结构无法参数化。比如SELECT * FROM {table}中的表名必须拼接。这类场景的兜底方案只能是白名单校验甚至把可用的表名列名列成一个枚举禁止运行时由用户输入直接决定。分页偏移量LIMIT ? OFFSET ?在部分数据库驱动里可以参数化但有些方言不支持。如果遇到不支持的情况需要先强转整数再拼进去而不是直接把字符串拼上去。存储过程内部的动态SQL存储过程内部如果用了EXECUTE IMMEDIATE拼SQL参数化只在存储过程的入口生效内部的动态语句要单独检查。在OpenClaw的Agent指令设计里有一个容易被忽略的点Agent生成SQL模板时要约束它只写参数化格式不允许直接输出完整拼接语句。这个需要在系统提示词里反复强调后面我会给出具体的提示词模板。3. ORM安全使用告别裸SQL之后新的边界在哪里3.1 ORM的自动转义机制与安全优势引入ORM对象关系映射是我在OpenClaw技能开发里推荐的首选方案。用ORM最大的好处是框架层面的查询构造器默认使用参数化查询生成SQL开发者写代码时不需要手动处理占位符注入风险从源头被框架兜底了。以Python生态的SQLAlchemy为例标准写法是这样的from sqlalchemy import select from sqlalchemy.orm import Session stmt select(User).where(User.email user_input) with Session(engine) as session: user session.scalar(stmt)你传进去的user_inputSQLAlchemy在编译SQL时会自动转为绑定参数。即使用户输入再阴险到数据库服务器那里也只是个普通的字符串值。PrismaNode.js生态更彻底const user await prisma.user.findFirst({ where: { email: userInput } });Prisma的查询引擎在底层完全采用参数绑定开发者根本接触不到SQL字符串。自带类型安全还顺手解决了类型转换问题算是目前Agent工具开发里综合体验最好的方案之一。3.2 ORM使用中容易被攻破的四个口子用上ORM不等于万事大吉。我在实际项目中踩过不少坑也见过不少开源项目中的反面教材集中在这四个地方口子一贪图方便用raw查询大部分ORM都提供了执行原生SQL的接口比如SQLAlchemy的text()Prisma的$queryRawUnsafe。一旦绕回裸SQL又忘了参数绑定就等于把ORM的防护层整个脱掉。尤其是Prisma那个Unsafe后缀字面意思就是明摆着告诉你这里不安全自己负责。// Prisma中仍然安全的方式标记参数 await prisma.$queryRaw SELECT * FROM users WHERE email ${userInput} ; // 危险的方式手动拼接 await prisma.$queryRawUnsafe( SELECT * FROM users WHERE email ${userInput} );注意$queryRaw的标签模板语法是安全的因为Prisma内部会把插值部分作为绑定参数处理。很多人不知道这一点误以为模板字符串拼接就是把变量塞进去。口子二查询条件里的“隐式类型转换”漏洞这类问题在MongoDB这类文档型数据库中表现更明显。如果直接用用户输入构造查询对象没有做类型校验攻击者可能通过传入特殊对象来改变查询语义。比如// 危险直接把请求体里的筛选条件透传给数据库 const result await collection.find(req.query.filter).toArray();攻击者可以构造一个包含$where操作符的JSON执行任意JavaScript表达式。在ORM层面防这类攻击的核心策略是对查询对象做严格的字段白名单和值类型校验只允许预期内的操作符出现。口子三批量赋值Mass Assignment这个更多是数据完整性问题但边界和注入有重叠。ORM允许直接把外部传入的字典塞给模型进行批量更新攻击者可能借此把is_admin、role这类敏感字段一并写入。以Django REST Framework为例如果没有显式指定只允许哪些字段被反序列化攻击者往请求体里塞一个is_admin: true模型就悄悄被提权了。口子四查询预热eager loading带来的N1与过度拉取虽然不算注入但ORM用不好会显著影响性能甚至引发新的安全风险——比如不必要的字段被序列化并返回给前端间接泄露内部字段。用了ORM之后要习惯显式指定返回列少用SELECT *。3.3 ORM的动态查询构造原则Agent场景下用户输入的不确定性会直接传导到查询构造逻辑中。以“根据用户给出的多个条件组合筛选订单”为例最简单粗暴的写法是拿一堆if来判断但这类代码很容易演化成拼接where子句的不可控逻辑。推荐的做法是在ORM框架内用声明式条件对象动态组合from sqlalchemy import select, and_, or_ conditions [] if username: conditions.append(Order.buyer_name.like(f%{username}%)) if min_amount is not None: conditions.append(Order.total_amount min_amount) if status: conditions.append(Order.status status) if not conditions: raise ValueError(至少需要一个查询条件) stmt select(Order).where(and_(*conditions))动态性完全保留在ORM表达式树的层面最终生成SQL时还是参数化绑定。这里唯一的注意点是like查询中的%通配符用户输入里如果带了%或_需要先转义。SQLAlchemy没有默认转义通配符代码里需要这样处理escaped username.replace(\\, \\\\).replace(%, \\%).replace(_, \\_) condition Order.buyer_name.like(f%{escaped}%, escape\\)这个细节很多人会忽略不转义导致用户可以用%匹配所有记录属于一种逻辑层面的数据泄露。4. OpenClaw技能里如何把参数化和ORM用到位4.1 Agent权限收缩数据库操作不该裸奔OpenClaw的技能开发中有一层大多数教程都没提到即使代码层面已经用参数化查询和ORM兜底了Agent本身仍然可能在SQL之外给你挖坑。比如Agent为了完成任务自主拼接了一个违反预期的查询条件或者调用了超出权限的函数。因此在设计技能时第一原则不是“写安全的查询”而是“收缩数据库账号权限”。给Agent用的数据库账号永远不应该是root或管理员账号。最佳实践是只授予特定库表的SELECT、INSERT、UPDATE、DELETE权限不给DDL权限。只允许访问Agent业务确实需要的那几张表。如果Agent只需要读数据那就只给SELECT权限。独立业务模块用独立数据库账号避免一个Agent被攻破后把所有数据都拖走。这样即使某些场景不得不拼接表名、列名Agent能影响的范围也被限制住了。数据库账号的最小权限是纵深防御中最有效的一道闸门比任何代码写法都可靠。4.2 技能代码的安全骨架我在OpenClaw里开发数据库查询类技能时会遵循这样一套代码骨架分享出来供直接参考第一层输入清洗层def sanitize_user_input(raw: str, max_len: int 100) - str: 统一做基础清洗类型、长度、控制字符、危险模式 if not isinstance(raw, str): raise ValueError(输入必须是字符串) stripped raw.strip() if not stripped or len(stripped) max_len: raise ValueError(输入为空或超出长度限制) # 移除危险的控制字符但不做关键字过滤 clean .join(c for c in stripped if c.isprintable()) return clean注意这里刻意不做SQL关键字过滤。原因有两个一是过滤关键字本身防不住绕过变体二是过度过滤会影响正常功能比如用户搜OBrien这种合法数据。安全边界交给参数化层这里只负责类型和长度的基础校验。第二层查询执行层from sqlalchemy.orm import Session from sqlalchemy import select def query_orders(db: Session, username: str, min_amount: float None): stmt select(Order).where(Order.username username) if min_amount is not None: stmt stmt.where(Order.amount min_amount) return db.scalars(stmt).all()第三层结果序列化层def serialize_orders(orders) - list[dict]: return [ { id: o.id, username: o.username, amount: o.amount, } for o in orders ]结果序列化层只返回业务必需的字段即使数据表里有内部备注字段也不会被带到对话上下文中。4.3 给Agent的提示词里写清楚“查询纪律”用OpenClaw的过程中我摸过一个规律单纯在提示词里说“你要注意安全”毫无用处因为LLM对抽象的“安全”概念没有具体的行为锚点。真正有效的做法是给出可执行的规则样例当你需要从数据库读取数据时必须遵守以下规则 1. 绝对禁止在查询语句中拼接用户原始输入。 2. 所有用户输入通过参数绑定方式传入查询。 3. 查询条件中不允许出现 OR 11 这类恒真表达式。 4. 不允许查询用户表、密码表、token表等敏感表除非用户明确要求且由主技能函数负责授权。 5. 不确定一个字段是否存在时调用 get_table_schema 技能函数查询不要猜测。规则越具象模型越容易遵守。凭空说“你要安全”模型听了和没听一样。4.4 Skill函数的参数设计规范OpenClaw的技能函数最终是要被Agent或外部调用方触发的。函数入参设计上要遵循一个原则调用方永远不需要也不应该能直接传SQL片段。参数类型尽量用原子类型不要用整段的Markdown文本或脚本文本当参数。例如一个“查询用户信息”的技能函数入参就是userId或username返回值是一个结构化对象而不是一段SQL文。这样设计有两个好处一是攻击面被压缩到几个具体参数上二是参数校验逻辑可以集中管理。5. 实测注入攻击路径验证防御有效性5.1 搭建本地验证环境我在本机用Docker搭了一套完整的验证环境包含MySQL和模拟OpenClaw技能调用的Python脚本。目标是把几种常见注入手法在“未防护”与“已防护”两种代码下分别跑一遍直观对比结果。环境配置version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: clawapp MYSQL_USER: clawuser MYSQL_PASSWORD: clawpass ports: - 3306:3306初始数据表结构CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE, email VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );5.2 攻击向量一万能密码绕过认证这是SQL注入里最经典的场景用在登录认证的逻辑上。如果是拼接式的查询query fSELECT * FROM users WHERE username {username} AND password {password}攻击者输入admin --作为用户名密码任意填最终产生的查询变成SELECT * FROM users WHERE username admin -- AND password xxx。注释符把密码校验直接注释掉了攻击者不需要知道密码就完成了登录。换成参数化写法后同样的输入会被当作完整的用户名admin --绑定到username参数上查询到的记录数为零注入完全失效。5.3 攻击向量二基于Union的拖库还是拼SQL的写法攻击者输入 UNION SELECT id, username FROM users --如果后端直接把用户输入拼进查询里攻击者可以逐步尝试列的数量最终用UNION把整张表的用户名、密码哈希拖出来。参数化之后这整个字符串只是被当成一个条件值数据库执行WHERE username \ UNION SELECT id, username FROM users -- 匹配不到任何记录数据库层面也不会去解析UNION语法。5.4 攻击向量三利用LIKE盲目匹配遍历数据前面提到过的LIKE通配符攻击路线。在已防护的ORM代码中用户输入%会被转义成\%所以通配符不能匹配所有行而_被转义成\_不再能匹配任意单个字符。实测之后同样的用户名搜索功能在未转义的代码里返回了全表数据转义后只返回包含字面量%字符串的记录。这是一个从“逻辑安全”角度容易被忽略、却确实会造成数据泄露的细节。5.5 SQLMap实测防护效果最后放上SQLMap做自动化验证的习惯操作步骤方便读者复现# 对未防护接口拼接SQL模式 sqlmap -u http://localhost:8080/user?nameadmin --batch --dbs # 对已防护接口参数化查询ORM模式 sqlmap -u http://localhost:8080/user?nameadmin --batch --dbs实测结果未防护接口SQLMap成功跑出所有数据库名报出boolean-based blind和UNION query两类注入点。已防护接口SQLMap跑完全部payload后返回all tested parameters do not appear to be injectable。需要提醒的是SQLMap只能证明“常见自动化payload无法注入”不等于绝对安全。参数化查询解决的是SQL注入这一类问题业务逻辑漏洞、越权等问题该测还是要测。6. 纵深防御把SQL安全做成体系而不是靠一层参数化只依靠参数化或ORM面对Agent这种“会动态生成代码”的场景是不够的。我的经验是把防御铺成三道防线。6.1 代码层强制代码审查与模板复用OpenClaw的技能代码仓库中发现任何形式的字符串拼接SQL必须在审查阶段打回。建立一张持久更新的负面清单把下面这类模式列为红线在Python中用f...{var}...拼接SQL在Node.js中用模板字符串拼接SQL使用$queryRawUnsafe且参数直接来自外部输入在循环体中执行动态SQL且未使用绑定参数同时建立安全模板库每种常见的查询动作按用户名查、按ID查、分页查、模糊搜索都给出一份经过评审的直接可用的模板让后来者在同类需求面前只需要填函数名。6.2 运行时层SQL审计与异常检测MySQL开启通用查询日志可以把所有SQL记录下来用于事后排查但生产环境建议只对特定风险账号开启日志太全反而淹没有效信息。更实用的做法是定期查慢查询日志频繁出现的异常慢查询往往是注入尝试的痕迹SHOW VARIABLES LIKE slow_query_log; SHOW VARIABLES LIKE long_query_time; SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 2;开发阶段我习惯在ORM层挂事件监听器统一记录所有执行的SQL模板与参数数量。一旦某条SQL模板中出现超过预期的参数个数就要警觉是否有动态表名拼接。6.3 监控层数据库账号访问行为基线Agent类应用的数据库访问模式相对可预测。比如“查询订单”技能通常只访问orders表不太可能在凌晨三点批量访问users表。如果数据库审计日志里出现异常的高频访问行为大概率不是Agent正常工作时间需要及时告警。具体的落地方式可以是数据库侧的触发器、日志分析工具也可以是接入公司现有的安全监控系统。重点不在于工具多先进而在于“知道正常的基线是什么样”。7. 踩坑记录Agent提示词让ORM也防不住的一个真实案例最后分享一个真实的翻车经历。有段时间我在优化OpenClaw的一个数据统计类技能整个技能函数清一色走了参数化查询自认为很安全。测试阶段发现一个bug只要用户问“顺便帮我看下有多少个管理员”Agent就会执行SELECT COUNT(*) FROM users WHERE is_admin 1这个没问题。但后来有一次用户问了一句“统计一下所有用户数量”Agent居然在技能函数之外自动生成并执行了一条裸SQLSELECT COUNT(*) FROM users。排查后确认原因技能函数返回的字段里包含了表名信息Agent认为它可以绕过封装的技能函数直接调用数据库。修复过程分了三步才稳下来第一步把技能函数的返回结果改成纯业务名称比如不返回内部表名只返回“用户数量”“订单金额”这样的语义化标签切断Agent把表名当作自由资源的路径。第二步在提示词里追加一条规则所有数据库访问必须通过query_users_count等注册技能函数不允许直接拼接SQL或调用未注册的数据库工具。第三步在数据库账号层面把DDL权限和跨库访问彻底关掉保证即使Agent异常生成了SQL能影响的范围也极其有限。这个案例说明代码层防护做得再完美也不能忽视Agent的“自主行动”能力。在Agent开发模式里提示词策略本身就是安全架构的一部分这不是一句空话。回过头看OpenClaw这类AI Agent平台的安全思路和传统后端开发有明显差异传统开发的核心是教会开发者不要写危险代码Agent开发的核心是约束模型的生成行为同时用代码结构和数据库权限掐死危险行为的实际影响半径。我个人在项目里会把安全要求同时分成“硬约束”和“软约束”两类。参数化查询、ORM绑定、数据库最小权限这些是硬约束通过代码模板和账号权限来保证不依赖人的自觉。提示词规则是软约束作用是降低Agent“动歪心思”的概率但不能当作唯一防线。硬软结合之后我才敢让Agent在真实业务里碰数据库。
