资讯动态

用SQLGlot从AST层面精准识别SQL注入:告别正则黑名单

发布时间:2026/10/19 14:03:35 来源:尧图企业网站定制
做后端开发的这几年我一大半的安全应急都跟SQL注入有关。每次排查到最后十有八九是同一句话“我就拼了个字符串怎么就出事了”今年接手的安全复审项目里我彻底换了个思路不再靠正则黑名单疲于奔命而是引入SQLGlot把每条SQL语句真正“读”进去从解析树AST层面判断它到底是不是一条有问题的语句。这套方案在模拟项目X上跑通之后效果比预期好很多——恶意语句被拦截得更早误报率也降下来了。这篇就把完整思路、核心代码、踩坑记录都摊开讲清楚给同样被注入问题折磨的开发者一个可直接抄作业的参考。1. SQL注入的防御困局为什么正则黑名单总在补漏1.1 注入的本质与字符串拼连锁链SQL注入的根源从来不复杂就是程序把不可信的外部输入直接拼进了SQL文本里。用户输入一个参数后端拿到之后不做任何校验SELECT * FROM users WHERE id input这一行代码就是整个漏洞的温床。攻击者传入一个精心构造的字符串原本的SQL语义就被篡改了——等号后面的值变成了一个恒真表达式或者直接追加了另一条完整的查询语句数据库解析到的是一段完全不同的逻辑。很多新手不理解为什么注入能造成那么大的破坏不是说好数据库权限要收紧吗问题是注入点之后能做的事情远不止“多查几条数据”。拼接进去的片段可以携带子查询、UNION、函数调用甚至可以触发错误信息回显来逐步探测库结构。这不是某一款数据库的问题MySQL、PostgreSQL、Oracle、SQL Server全都有被注入的可能。差别只是方言语法上的一些细节但攻击原理是通用的谁能控制拼进SQL的字符串谁就控制了SQL的语义。参数字典里加一条过滤规则函数里写一个替换这些办法我早期都试过。能挡住一部分简单的尝试但注入者只要换一个编码、插几个注释符、改大小写规则就失效了。整条防御链条一直处在“攻击者变招、防御者补丁”的循环里——问题的根源不是规则写得不够多而是从设计上就选错了对抗的层面。1.2 黑名单正则为什么治标不治本黑名单的思路是“我认识危险的东西看见就拦”。为了识别危险得维护一份不断增长的规则列表union要拦、select要拦、sleep()要拦、load_file()要拦、注释符--要拦……写的时候觉得挺全面上线就会发现无穷无尽的绕过姿势。比如经典的UNION SELECT正则可能写成/union\sselect/i攻击者用注释把空格顶掉UN/**/ION SEL/**/ECT普通匹配直接失效再换大小写、换Tab、换换行规则库又要更新一版。更麻烦的是有些规则本身就会误伤表名里带select的合法查询怎么办用户提交的搜索关键词里含union怎么办黑名单规则越加越多误报率就跟着涨最后运维同学每天在告警里翻误报真正有问题的那几条反倒被淹没了。关键在于正则只能看到“字符串长什么样”看不到“SQL语句究竟干了什么”。一条SQL是否危险本质上是语义问题——它读取了哪些表、有没有多余的组合查询、是否调用了敏感函数、WHERE条件是否被结构性地篡改。这些信息必须通过真正的语法解析才能得到。这就是SQLGlot的价值所在它不是另一个更聪明的正则而是把SQL当成一门语言去理解让防御者终于能站在语义层面说话了。2. SQLGlot的解析能力安全方向的身份定位2.1 SQLGlot是什么不只是SQL方言转换器SQLGlot是一个用Python写的SQL解析器、语法树遍历器和方言转换工具。它能把一条SQL字符串解析成结构化的表达式树AST每个关键字、表名、字段、运算符、函数调用都变成树上的节点。拿到这棵树之后你可以遍历它、查询它、修改它甚至把它重新输出成另一种数据库方言的SQL语句。这听起来像是给开发同学做数据迁移时用的工具——也确实很多人用它把SQL从MySQL翻成PostgreSQL或者Spark SQL。但真正用过之后你会发现AST级别的操作能力在安全场景里同样好用。把一条SQL拆成树之后任何试图藏进字符串里的“小心思”都得变成具体的节点类型暴露出来。攻击者可以在文本层用大小写和注释伪装但他没办法在AST层把一个UNION伪装成普通的WHERE——树的形状不会说谎。举个例子同样的SELECT A, B FROM T WHERE ID 1解析成AST之后你能清晰地看到读取操作、目标表T、字段A和B、WHERE约束条件。每条SQL的“意图”都变成了结构化的数据这让自动化检查有了扎实的基础——你不再猜这个字符串可不可疑而是直接检查这棵树里有没有不该出现的器官。2.2 安全方向能拿它做什么在安全方向上SQLGlot能做的事情非常具体而且都是传统方案很难做到的首先是结构识别判断一条SQL里是否存在UNION、子查询、批量插入、INTO OUTFILE这类高危操作。因为你知道确切的节点类型比如exp.Union、exp.Subquery直接查树就行比对字符串可靠得多。其次是信息提取从SQL里自动抽出所有表名、列名、函数名。做敏感表保护的时候可以精确判断一条语句是否触碰了不该碰的表。然后是语义校验对SQL模板的AST结构与实际输入SQL的AST结构做模式比对任何偏离模板的结构变化都能被标记出来。这一点用来做白名单校验效果非常扎实。最后是多方言适配内部的SQL代码、外部的输入SQL可能来自不同来源SQLGlot能统一解析、归一化到同一套表达式体系安全逻辑不用针对每种数据库写一遍。我实际试下来的感受是SQLGlot就像给Web应用装了一个“SQL显微镜”——你终于能看见注入尝试在SQL文本里留下的语义级痕迹而不仅仅是碰运气式地匹配几个关键词。2.3 必须说清的边界解析器不是护身符要泼一盆冷水SQLGlot永远替代不了参数化查询。解析器做得再好也只是在“SQL到达数据库之前”增加了一道审查层。如果代码本身的写法就是字符串拼接这道审查层就成了唯一防线一旦攻击手法不在检测规则覆盖范围内数据照样裸奔。我在做方案设计时一直强调的定位是SQLGlot是纵深防御里的关键一环而不是唯一的希望。正确的姿势是先做好基础防护——所有SQL都走参数化查询或ORM的预编译语句用户输入只作为参数值绑定绝不拼进SQL文本。在这个前提之上SQLGlot才有意义用来兜底检查历史遗留代码里的拼接SQL、检测ORM底层是否泄露了恶意片段、拦截在应用层已经被拼好的危险SQL。防御是分层的事把宝全押在某一个工具上本身就是危险的思路。3. 完整实操用SQLGlot构建注入检测引擎3.1 基础解析把SQL变成可遍历的语法树先安装SQLGlot一条命令搞定pip install sqlglot安装完之后最简单的用法是这样import sqlglot from sqlglot import exp sql SELECT id, name FROM users WHERE id 1 expression sqlglot.parse_one(sql, dialectmysql) print(expression)parse_one会把SQL字符串转换成语法树对象。拿到树之后就可以用.find_all()方法去遍历特定类型的节点。比如找出所有表名tables expression.find_all(exp.Table) for table in tables: print(table.name) # 输出: users表名、列名、函数名都会以节点形式出现在树上。这里我建议新手先做一个实验解析几条不同的SQL把AST打印出来看看。repr(expression)的输出会展示树形结构看一次就能直观理解“SQL怎么变成树”这件事。这一步是后续所有安全检测的基础——如果连树都遍历不顺后面的规则肯定写不稳。需要特别注意一个细节解析时要指定dialect参数比如MySQL就传mysqlPostgreSQL传postgres。不同数据库的语法有差异比如MySQL的反引号引标识符、PostgreSQL的::类型转换指定方言可以让解析更准确也能避免一些由于方言差异造成的误判。3.2 风险模式检测UNION、堆叠查询、危险函数基础解析熟悉了之后就可以进入核心环节写检测规则。我常用的几个检测点是硬编码在检测引擎里的第一个是UNION探测。注入最经典的变种就是在原查询后面拼一个UNION SELECT把另一份数据带出来。在SQLGlot里UNION是一个明确的节点类型exp.Union检测起来非常直接def detect_union(expression): unions list(expression.find_all(exp.Union)) return len(unions) 0不管攻击者怎么写大小写、怎么在UNION和SELECT之间插注释解析出来的AST节点都是同一个类型这个方法不会漏。我在测试的时候专门试过各种伪装写法SQLGlot在AST层面全部还原成了Union节点命中率是百分之百。第二个是堆叠查询探测。如果应用后端允许一次提交多条SQL语句攻击者就可以在后面追加DELETE、DROP之类的语句。SQLGlot的parse_one默认只解析第一条语句但sqlglot.parse可以返回所有语句的列表。检测逻辑就是如果一条SQL文本被解析出了多棵语法树立刻标记风险def detect_multiple_statements(sql): statements sqlglot.parse(sql, dialectmysql) return len(statements) 1正常业务里除了存储过程外几乎没有场景需要一次提交多条语句。这里是绝对的零容忍区只要是多语句直接拦截。第三个是危险函数检测。像load_file()、sleep()、benchmark()、xp_cmdshell()这类函数在业务代码里出现的频率极低一旦出现就高度可疑。实现方法就是遍历所有函数节点跟危险函数名单做比对DANGEROUS_FUNCS {sleep, benchmark, load_file, xp_cmdshell, pg_sleep} def detect_dangerous_funcs(expression): funcs expression.find_all(exp.Func) return [func.sql() for func in funcs if func.sql_name().lower() in DANGEROUS_FUNCS]在此基础上还能加评分机制多命中一条规则就累加风险分超过阈值才判为恶意。这样做的好处是能降低单条规则的误报率。比如有个规则是“检测注释符”但SQL里合理的注释很多把它跟“检测UNION”组合起来两个都命中才有更高风险判断就稳很多。3.3 白名单AST结构校验误杀率更低的第二道闸黑名单检测再完善总有没覆盖到的攻击姿势。我在实际项目中跑了一段时间之后发现真正能把误杀率压到极低的是反过来做白名单——不是“识别危险的”而是“只放过合法的”。核心思路是这样的业务系统里每一条SQL几乎都能归到固定模板比如“按ID查用户”“按条件分页查订单”“插入一条新记录”。把合法SQL的AST结构提取出来作为“金标准”。当输入SQL到达检测层时解析成AST然后比较它的结构特征跟金标准是否一致。如果解析出来的AST里多出了不该有的节点——额外的UNION、多了一个注释节点、WHERE条件里出现了复杂的子查询——直接拒绝。我在项目里用一个非常实用的简化方案提取AST的“节点类型指纹”。把整棵树上的节点类型按顺序拉出来形成一个类型序列然后跟合规模板的指纹对比def ast_signature(expression): # 按遍历顺序收集所有节点的类型名忽略具体值和文本 signatures [] for node in expression.walk(): signatures.append(node.__class__.__name__) return signatures ALLOWED_TEMPLATE ast_signature( sqlglot.parse_one(SELECT id, name FROM users WHERE id ?, dialectmysql) ) def validate_structure(expression): sig ast_signature(expression) # 基础一致性检查合法模板的关键结构必须保持 # 实际应用中会做更细的对比包括节点类型序列、嵌套深度等 return sig[0:3] ALLOWED_TEMPLATE[0:3]这只是个演示生产环境的指纹比对要更复杂要忽略参数值节点带来的变化、要允许排序分页的合法扩展、要按业务类别建多套模板。我记得第一次上线时白名单模板定得太死直接误伤了一批正常的分页SQL。后来调整策略模板分成“必须完全匹配的部分”和“允许可选扩展的部分”两层才平衡过来。但我要强调的是白名单机制有一个天然前提业务SQL得相对固定。如果是让用户自由输入SQL文本的在线分析产品白名单就不好做这时候得靠黑名单加评分机制兜底。两种手段根据场景组合着用防御强度是单一思路比不了的。3.4 与参数化查询搭伙的完整场景只在检查引擎里做文章还不够我还把SQLGlot接进了数据访问层做成了一个第三方的“SQL审查器”。接入之后执行SQL之前必经一道关卡def execute_with_guard(conn, sql, paramsNone): # 第一步解析并检测SQL结构 expression sqlglot.parse_one(sql, dialectmysql) # 第二步结构白名单校验 if not validate_structure(expression): raise ValueError(SQL structure rejected by whitelist policy) # 第三步黑名单风险点扫描 if detect_union(expression) or detect_multiple_statements(sql): raise ValueError(SQL injection attempt blocked) dangerous detect_dangerous_funcs(expression) if dangerous: raise ValueError(fDangerous function detected: {dangerous}) # 第四步安全通过后走参数化查询 cursor conn.execute(sql, params) return cursor配合参数化查询的场景是SQL语句本身来自代码里的固定模板params作为参数传入SQLGlot审查的对象其实是“静态模板SQL”而不是拼接了参数之后的最终SQL。这个思路很关键——检测静态模板几乎不会有误报因为模板就是固定的任何可疑节点出现都说明模板本身有问题或者代码路径绕过了模板。我原本担心的性能开销实测下来基本可以忽略。SQLGlot解析一条普通的查询语句耗时在微秒到毫秒级别。即便每一条业务SQL都过一遍检测在普通QPS下完全不是瓶颈。当然如果系统每秒处理上万次查询建议加一层缓存SQL模板相同就缓存AST结构只对参数部分做轻量类型校验能进一步压掉解析开销。4. 对抗绕过手法的检测细节与踩坑记录4.1 大小写、注释、编码混入怎么处理解析器能自动处理大小写和注释符这是我当初转向AST方案的最直接原因。SQLGlot在词法分析阶段就会把注释识别成Comment节点并不会因为注释的存在而改变其他节点的含义。所以UN/**/ION这种用注释隔断关键字的写法在AST层面上跟正常的UNION没有任何区别——都会被解析成Union节点。这意味着检测规则只要盯着节点类型就够了完全不用关心攻击者是怎么拼的文本这是AST方案相对正则降维打击的地方。但编码绕过的场景要小心。如果应用层先对输入做了一次URL解码、HTML实体解码或者Unicode转换再把结果拼进SQLSQLGlot看到的是解码后的最终字符串解析逻辑倒不受影响。问题在于检测引擎要放在解码完成之后的位置——顺序搞反了解析器拿到的是编码后的原文照样能解析但检测结果跟数据库实际执行的SQL就对不上了。我在项目里有过真实教训框架自带的中间件先对请求做了解码我的检测逻辑写在了解码之前结果一条包含%27的请求被当成正常字符放过去了。后来把检测层挪到参数解析之后、SQL执行之前问题才彻底解决。排查任何绕过问题先确认检测引擎在数据处理链路上的位置对不对。4.2 解析失败本身也是一个告警信号有一个最简单的检测信号经常被忽略SQLGlot解析失败的那条SQL恰恰最可疑。正常业务代码里的SQL模板通常语法正确你把它丢给解析器基本都是成功的。一旦解析器报错往往说明这段SQL根本不是你预期中的样子——它可能是攻击者在拼接过程中生成的畸形语句。我在检测引擎里专门加了一条规则def is_parseable(sql, dialectmysql): try: sqlglot.parse(sql, dialectdialect) return True except sqlglot.errors.ParseError: return False但凡解析失败这条SQL我不会放行而是直接进高优先级告警队列。有人可能会问“会不会误伤有没有合法SQL是解析器不支持的”确实有比如某些数据库特有的隐藏语法、最新的扩展特性。处理办法是把这类SQL列入“豁免名单”但每条豁免都要有人工审核记录。这里的原则是宁可让极少数合法SQL走了审核流程也不能默认放行一条解析不了的语句。不过要说清楚一个细节SQLGlot的解析器容错性比较强部分非法字符串可能会以容错方式解析出一棵“部分有效”的树。所以解析成功不代表绝对安全它只是防线第一步。我会在解析成功后继续走结构校验和危险模式扫描两道都过了才算过关。整个流程是一个层层递进的关系不是某一个方法包打天下。4.3 容易被忽略的性能与兼容性坑先说性能。解析器的开销来源有几个SQL语句复杂度、AST节点数量、解析后遍历深度。我试过拿一条几千行的动态生成SQL去解析耗时确实比简单语句高一个数量级。生产环境如果不做防护攻击者提交超大SQL文本就能把检测引擎拖垮这等于制造了另一个拒绝服务入口。解决思路有两个方向。一个是限制SQL文本长度业务上正常的单条SQL一般不会太长超过某个阈值比如64KB直接拒绝。另一个是加AST缓存针对固定模板命中缓存不重复解析。实测下来加完缓存之后检测引擎的P99延迟下降了好几倍效果明显。兼容性坑主要集中在方言差异上。同一句SQL在MySQL能解析换到PostgreSQL方言就可能报错。好在这里给了最大的容错度——SQLGlot本身支持多方言。检测引擎最好根据目标数据库类型统一指定dialect同时把方言参数配置化。当业务从一种数据库迁移到另一种时检测规则不用大改但方言参数必须同步更新否则解析器可能在迁移之后就“看不懂”新数据库的SQL了。5. 生产落地经验从检测到防御的几条建议5.1 放在哪个环节最合适检测引擎的位置选择直接影响防御效果和误伤范围这个环节我在不同架构上都试过。最前置的方案是放在API网关层所有请求先经过SQL检测再进入应用覆盖面最广但问题也很明显——网关层拿到的多是HTTP参数SQL语句还散落在应用代码里你压根没有一条完整的SQL可以解析。网关层做SQL检测只能做一些浅层参数过滤没法发挥AST语义检测的优势。最贴近数据库的方案是放在数据访问层DAO层或者ORM中间件里。这是我认为的最优位置SQL文本在这里已经完整构造出来了可以拿到全量信息做解析检测。直接在数据访问层拦截的好处是接入成本低、不需要改网络架构也不受参数传递方式的干扰。我在项目里就是封装了一个安全执行器替换原来直接调数据库API的地方改动量很小。如果你用的是ORM框架可以在底层连接器做个装饰器回调拦截所有SQL语句。但要注意ORM自身有时会产生一些复杂的、自动生成的SQL这些SQL模板的AST结构特殊白名单模板得相应地覆盖进去否则就会疯狂误报。5.2 误报、漏报如何权衡上线安全检测引擎之后我碰到的第一个现实问题不是“拦不住”而是“拦太多”。好几天都在处理误报工单开发同学来反馈说正常业务被拦截了。冷静下来分析问题不在引擎本身而在策略配得太激进。那段时间我把所有检测规则的风险阈值都调得很低连“SQL文本中含有注释符”都直接拦截结果项目里的历史遗留SQL大量带着合法注释直接误伤一片。后来我把策略分成三层第一层危险操作零容忍。命中多语句、UNION和危险函数组合、明显注入特征拦截并告警。第二层可疑行为观察。只命中单项规则但证据不足的放行但记录日志并归入可疑样本库。第三层合规性提醒。比如字段权限、敏感表操作仅提示不拦截。这种分层设计在生产环境跑了一个月之后误报率降到了可以接受的水平真正可疑的SQL被打上了标签留作后续分析。这给我的体会是检测引擎的默认策略一定不能是“宁可错杀一千”而是“把证据充分的操作拦死、证据不足的操作看住”。安全能力只有在一个不干扰正常业务的阈值下才真正守得住阵地。5.3 日志告警与灰度上线如果安全检测做完了却没有留下任何日志这个方案等于只做完了一半。每一条被拦截的SQL、每一条被标记的可疑SQL、解析失败的SQL都应该落日志并带上完整上下文来源IP如有、操作者身份、SQL原文、命中规则、风险评分、检测耗时。这些数据对于一个安全运营团队来说是金子——它们可以用来回头优化规则、发现新型注入手法、做溯源分析。我落地时的做法是先以观察模式灰度上线一周。拦截逻辑不生效只记录检测结果和判定结论。一周之后复盘日志统计误报率和漏报样本调整阈值之后切换为拦截模式。这个灰度过程强烈建议做——直接上拦截模式很容易在业务高峰期引发大范围线上事故。另外告警不要只发消息就完事。我给检测引擎接了一个简单的面板把风险评分、拦截趋势、命中规则分布展示出来。当你看到某条规则的命中数突然飙升往往意味着有新的攻击波次在尝试你的系统。这一步做到位了SQLGlot防注入就从“单点工具”升级成了整个安全体系里的一个常态化防御组件。这套方案我现在仍然在持续迭代。每处理完一轮新型注入样本我就往规则库里补充一条对应的AST模式。说到底安全对抗就是一场持续的博弈SQLGlot帮我把战场拉到了语义层面剩下的持久战还得靠不断更新的检测规则和完善的运营流程。如果你也在为注入问题头疼不妨从今天开始先把一条你业务里最核心的SQL模板接进SQLGlot解析一遍看看你的应用到底有没有被自己“看不见”的SQL牵着走。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑