☰
用SQLGlot从AST层面精准识别SQL注入:告别正则黑名单
2026/10/19 14:03:28 网站建设 项目流程

做后端开发的这几年,我一大半的安全应急都跟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\s+select/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, dialect="mysql") 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就传"mysql",PostgreSQL传"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, dialect="mysql") 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 = ?", dialect="mysql") ) 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, params=None): # 第一步:解析并检测SQL结构 expression = sqlglot.parse_one(sql, dialect="mysql") # 第二步:结构白名单校验 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(f"Dangerous 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转换,再把结果拼进SQL,SQLGlot看到的是解码后的最终字符串,解析逻辑倒不受影响。问题在于检测引擎要放在解码完成之后的位置——顺序搞反了,解析器拿到的是编码后的原文,照样能解析,但检测结果跟数据库实际执行的SQL就对不上了。

我在项目里有过真实教训:框架自带的中间件先对请求做了解码,我的检测逻辑写在了解码之前,结果一条包含%27的请求被当成正常字符放过去了。后来把检测层挪到参数解析之后、SQL执行之前,问题才彻底解决。排查任何绕过问题,先确认检测引擎在数据处理链路上的位置对不对。

4.2 解析失败本身也是一个告警信号

有一个最简单的检测信号经常被忽略:SQLGlot解析失败的那条SQL,恰恰最可疑。正常业务代码里的SQL模板通常语法正确,你把它丢给解析器,基本都是成功的。一旦解析器报错,往往说明这段SQL根本不是你预期中的样子——它可能是攻击者在拼接过程中生成的畸形语句。

我在检测引擎里专门加了一条规则:

def is_parseable(sql, dialect="mysql"): try: sqlglot.parse(sql, dialect=dialect) 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牵着走。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询