ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL注入底层原理与防御指南:从参数化查询到纵深防御

SQL注入底层原理与防御指南:从参数化查询到纵深防御 SQL注入这个话题做了十来年后端和安全我对它的感情很复杂。它稳坐OWASP Top榜单多年直到今天依然是重灾区可太多人聊SQL注入聊的是怎么打而不是为什么——为什么一段拼接的SQL会翻车为什么加个引号就能改变整条语句的结构为什么参数化查询能根治而WAF不能。这篇总结我按照一个安全从业者的思路来梳理从注入的根源讲起拆解联合查询、报错注入、布尔盲注、时间盲注、堆叠注入、二次注入这几种核心手法的底层逻辑再复盘一条完整的利用链路最后落到防御层面讲清楚每种防护方案的边界在哪里。适合刚接触安全的开发新人、转岗的测试同学以及想系统理解漏洞成因的运维人员。你不需要把每条payload背下来只需要理解它们被构造出来的思路往后遇到类似的业务场景自己就能推导。1. SQL注入是怎么发生的1.1 一条登录SQL语句的翻车现场先看最常见的一段登录逻辑sql SELECT * FROM users WHERE username username AND password password 这段代码的意图很明确从users表里找出一条用户名和密码都匹配的记录。问题出在拼接——username和password是用户可控的字符串当它们被直接塞进SQL语句时就不再只是查询条件了而是SQL语句的一部分。假设username输入admin --拼接后的SQL变成SELECT * FROM users WHERE username admin -- AND password xxx--是MySQL和标准SQL里的注释符它后面的内容统统被当成注释忽略了。这条语句实际执行的是查找用户名为admin的记录密码校验被注释掉。如果恰好存在admin用户登录直接绕过。这就是SQL注入最基础的形态攻击者通过输入改变SQL语句原本的语义。整个过程用一个生活化的类比理解你让一个助手帮你把客人的话转告给管家结果客人说的是告诉他今天晚上不开饭你原封不动把话传过去语气也没变管家真就取消了晚饭。SQL注入的本质就是**数据客人的话被当成了指令你的命令**来执行。数据库引擎分不出哪些是数据、哪些是代码因为它看到的只是拼接好的完整语句。1.2 两类注入点数字型与字符型搞清楚注入的触发条件要先看SQL语句里的参数是如何被拼接的。按拼接方式注入点可以分两大类数字型sql SELECT * FROM products WHERE id id这里id没有加引号参数直接作为数字拼进去。测试时输入1 AND 11语句变成SELECT * FROM products WHERE id 1 AND 11这个表达式合法、能正常返回结果说明id处的输入没有被强约束有机会被改写成任意条件。数字型注入的常见测试方式是输入1 AND 12看返回是否为空如果1 AND 11有数据而1 AND 12无数据基本可以确定拼接点存在。字符型sql SELECT * FROM products WHERE name name 字符型需要先闭合单引号再拼接自己的逻辑。测试时输入a AND 11拼接后SELECT * FROM products WHERE name a AND 11注意末尾我用了一个11来保持引号闭合这样整条语句在语法上依然成立。字符型注入的要点是手动维护引号的配对每引入一个引号都要想清楚下一步去哪里找配对的引号否则语句直接报错什么都执行不了。还有一个容易被忽略的变体是搜索型注入sql SELECT * FROM articles WHERE title LIKE % keyword %这种场景除了闭合引号还要考虑%和通配符的处理。测试时输入% OR 11 --可以直接把LIKE的模糊匹配变成全表查询。搜索框是开发中每天都在写的逻辑但因为它自带引号和百分号很多开发者想当然觉得里面带了很多特殊符号数据库不会执行恰恰相反。1.3 注释符与无害尾巴的巧妙利用拼接SQL的时候原语句末尾通常会带着尾巴比如AND password xxx攻击者并不需要真的知道原本的密码条件是什么只需要把尾巴无效化。实现手段就是注释。不同数据库的注释符号差别不小数据库注释方式说明MySQL--注意--后需空格、#、/* */--在URL编码里常见因为代表空格Oracle--后面跟空格或行结束符/* */同样可用SQL Server--、/* */老版本还支持分号堆叠实际测试时--出现在URL里很常见有人误以为是某种特殊语法其实它就是URL编码中的空格。所以id1--的意思是注释符--后跟了一个空格满足MySQL对注释符后必须有空格的语法要求。注释符能做的不只是吞掉尾巴。MySQL有一种内联注释/*! ... */被包裹的内容在MySQL里会被当成正常代码执行但在其他数据库里只是普通注释。某些防护系统对这个特性没做过滤就成了绕过的突破口。2. 核心技术手法逐一拆解2.1 联合查询注入最直白的取数姿势联合查询UNION SELECT的原理是两个SELECT语句的查询结果可以纵向拼接前提是两边列的数量一致且对应列的类型兼容。攻击者利用这个特性把自己构造的查询结果附加在原查询后面从而把数据库里的数据直接显示到页面上。使用条件很苛刻但很爽必须有可见的回显位也就是原SQL语句的查询结果会直接渲染在前端页面。比如商品列表页你把id改成1 UNION SELECT username,password FROM users--如果原表恰好是两列页面就会把users表的用户名和密码一并渲染出来。判断列数是第一步标准做法是order by?id1 ORDER BY 1 ?id1 ORDER BY 2 ?id1 ORDER BY 3逐步增加数字当排序的列数超过实际列数时数据库会报错从而确定总列数。列数确定之后用UNION SELECT 1,2,3试探回显位置——页面上哪个位置显示的数字是2哪个位置就是第二列数据的地盘之后把2替换成你想要的字段即可。联合查询的局限也很明显它要求回显可见且语句结构允许UNION。很多情况下原查询和攻击者查询的类型不兼容比如第一列是数字、自己选了字符串要用NULL占位跳过类型冲突。我实际测试时习惯用这样的模板逐步调整UNION SELECT NULL,NULL,NULL UNION SELECT 1,2,3 UNION SELECT username,password,NULL FROM users2.2 报错注入把报错信息变成数据通道不是所有场景都有回显。比如一个修改用户资料的接口不管你输入什么前端只返回成功或失败看不到具体数据。这时候联合查询就失效了但还有一条路让数据库在执行查询时报错并在错误信息里把目标数据带出来。MySQL常见的报错手段有三类extractvalue()对XPath表达式做格式化检查时第二个参数如果非法会把参数内容显示在错误里配合concat(0x7e, 目标数据)使用updatexml()原理同extractvalueXPath检查报错时回显参数内容主键冲突报错select count(*),concat(目标数据,floor(rand(0)*2)) from information_schema.tables group by key——rand加group by的碰撞会导致Duplicate entry错误错误信息里带上目标数据它们的共同点是都依赖数据库的错误信息外泄。所以防御报错注入最直接的办法是关闭详细的错误回显只给前端一个笼统的通知系统繁忙。你以为是小事实际操作中大量系统为了让开发方便把display_errorsOn开到生产环境等于把数据库的数据提取通道亲手打开。这里要说一个误解很多人以为报错注入只能用来报错实际它的本质是利用数据库内部函数对参数的处理逻辑。明白了这一点换成任何能触发报错回显的函数原理相同遇到新的数据库版本时便可以自己推演而不是背几个payload。2.3 盲注在没有回显时逐字符抠数据盲注是SQL注入里最磨人的一类但也是理解数据库本质是个布尔机器最好的入口。它的核心思路是我不需要看见数据只需要知道某个条件成立与否然后把数据一个字符一个字符地猜出来。布尔盲注利用页面返回内容的差异正常结果、空结果、响应时间长短来判断条件真假。经典判断流程AND (SELECT SUBSTRING(username,1,1) FROM users WHERE id1) a AND (SELECT ASCII(SUBSTRING(username,1,1)) FROM users WHERE id1) 100SUBSTRING截取第1个字符ASCII转成数值便于比较再用二分法逐位猜。每次请求只能判断一个条件所以数据量一大就很耗时。例如库名是testdb要猜6个字符每个字符平均7次请求加上起始判断总共要发几十次请求才有结果。时间盲注连页面差异都看不到时把判断结果转换成睡多久。MySQL里是IF(条件, SLEEP(5), 0)条件成立就睡5秒不成立立即返回。这样攻击者通过观察响应耗时就能确定条件真假AND IF(ASCII(SUBSTRING(database(),1,1))100, SLEEP(5), 0)时间盲注开销最大一分钟可能只猜出几个字符所以我一直建议这类场景优先考虑带内数据外传比如通过DNS请求把数据拼到子域名里而不是傻等。2.4 堆叠注入与二次注入容易被低估的两条路径堆叠注入依赖分号;。正常SQL语句以分号结束分号后还可以接另一条语句id1; DELETE FROM users如果数据库连接支持多语句执行MySQL的multi_query、SQL Server某些场景默认支持就能在一条请求里执行多条SQL。堆叠注入最麻烦的地方在于它突破了查询的边界——不仅能读还能写、能删、能改、能调用存储过程。但很多开发框架默认只允许单语句执行所以堆叠注入的利用范围比联合查询和盲注窄。判断方式很简单在参数后加分号再拼一条SELECT 1如果返回了额外的结果集或没有任何报错说明多语句被放行。二次注入的思路更刁钻。它不追求第一次插入时立刻生效而是把恶意内容先存入数据库等应用下次把这条数据取出来拼接SQL时再触发。典型场景是注册用户名用户注册时输入admin--数据库原样存下这个字符串插入阶段不会出问题。等管理人员在后台用SQL拼接查找这个用户名时引号和注释符就激活了。二次注入很难被安全扫描器发现因为触发点在第一次请求之后很久。防御思路也清晰任何从数据库取出的值在拼接进SQL之前必须同样走参数化或转义流程不能因为数据在库里就默认它是安全的。3. 实操复盘从探测到取数的完整链路这一节我用一次授权测试的完整链路来讲解。前提是你拥有目标系统的合法测试授权或者干脆在本地搭的靶场环境里练手任何未授权的测试都是违法行为。3.1 探测输入点先分清参数类型拿到一个带参数的URL比如某商品详情页product.php?id2测试的第一步不是丢一堆payload而是先做最小验证。输入2观察反应。三种典型结果页面报SQL语法错误说明引号被直接拼进语句存在注入点页面返回正常内容说明服务端可能做了过滤、转义或者参数被强类型转换了页面返回500、白屏等笼统错误说明错误信息被统一处理但仍不能排除注入可能再试2--和2--如果这两种输入后页面恢复正常加载几乎可以断定这里是字符型注入点——引号导致语句不完整注释把后面的内容吞掉以后语句重新合法了。这里我要强调一个经验不要只依赖报错来判断。我曾见过一个系统把所有数据库错误都收集到日志里、前端只返回200导致手动测试时完全无感但用延时语句测试时响应时间有明显波动——所以核心是结合报错、布尔差异、时间差异三种信号交叉验证。3.2 判断列数与寻找回显位确认注入点后进入取数准备阶段。先用order by确定列数?id2 ORDER BY 1 ?id2 ORDER BY 2 ?id2 ORDER BY 3第n次不报错、第n1次报错说明原查询是n列。接着用union找回显位置?id2 UNION SELECT NULL,NULL,NULL如果页面上的位置出现NULL字样说明列数符合且类型兼容。再把NULL换成数字1、2、3逐一观察哪个位置的数字显示在页面上——这一步确定回显点之后就能把对应位置替换成目标字段。如果是时间盲注场景没有回显点可找直接用前面2.3的盲注流程从schema_name开始逐库逐表抠。个人体会盲注场景一定要先写一个半自动脚本因为手速再快也扛不住几百次请求。脚本核心逻辑不复杂一个requests循环、二分法比较、字符集预设能让效率提升一个数量级。3.3 数据提取用二分法对抗网络开销取数不是直接把整张表SELECT出来那么简单。联合查询每次只能看一条记录盲注一次只能确定一个字符。为了减少请求次数我习惯把比较操作从字符级优化成使用二分法逐位逼近。以盲注猜库名第一个字符为例数据库名是shopdb第一字符ASCII是115。用条件ASCII(SUBSTRING(database(),1,1))100先问一次返回真再问120返回假。两次请求就把范围缩到100-120之后继续二分通常一个字符七八次请求即可确定比逐字符遍历26个字母加数字的方式快太多了。还有一个细节盲注里比较条件写不如写稳定。会受字符集、大小写、空格等因素干扰配合二分法容错高、稳定这是实操下来的经验。3.4 踩坑实录几类看起来有漏洞却打不动的情况实战中最憋屈的不是没漏洞而是明明有注入却拿不出数据。我整理几类典型情况第一类是参数被转义但拼接方没做参数化。很多PHP老项目用addslashes()把单引号转义成\导致字符串型注入直接堵死。但数字型注入不受影响因为数字参数根本不经过引号。所以遇到转义场景第一反应是去测试不带引号的参数位置——排序字段、数量参数、页数参数这些地方往往还是裸拼接。第二类是LIMIT和ORDER BY后的注入。这两个位置不能使用参数化绑定只能做白名单校验。开发者常常在这类位置忘记过滤例如ORDER BY后面直接拼接字段名。测试时输入ORDER BY 1 INTO OUTFILE虽不一定成功但ORDER BY IF(11,1,2)这类表达式往往能触发布尔差异。第三类是字符集导致的报错信息乱码。连接字符集设置不对数据库返回的错误信息经过浏览器渲染变成一堆问号报错注入拿不到有效数据。这时可以尝试转成十六进制再输出或者改用布尔盲注绕开报错依赖。4. 防御视角这些方案到底哪里靠不住4.1 黑名单过滤的天然短板很多系统对输入做关键词过滤把、、SELECT、UNION、--等黑名单词汇替换或删除。这套路看起来省事实际不堪一击原因在于数据库语言的等价形式太多了。黑名单过滤的典型失败点大小写绕过select写SeLeCt注释符代替空格SELECT/**/username/**/FROM/**/users等价函数替换substring换成mid、substr十六进制编码把字符串写成0x7573657273内联注释/*!50000SELECT*/空白变体用Tab、换行符、%0a代替空格最麻烦的是黑名单需要覆盖所有数据库方言同一段过滤规则对MySQL有效换个Oracle环境可能整条语句都能绕过。我的结论是黑名单可以当作纵深防御的一层但绝不能是唯一防线。4.2 参数化查询为什么是正解参数化查询PreparedStatement的原理是先编译SQL骨架再把参数当作纯数据传入。在Java里这样写String sql SELECT * FROM users WHERE username ? AND password ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password);在Python的MySQL驱动里这样写cursor.execute(SELECT * FROM users WHERE username %s AND password %s, (username, password))参数化之后username的值再诡异也只是数据数据库引擎不会去解析其中的引号、注释符、关键字。被注释掉的密码条件不存在因为整条语句的结构在编译阶段就定死了。这就像你给餐厅的菜单模板提前定好第1道菜是沙拉第2道菜是主菜。客人说把主菜替换成一句骂人的话厨房只会端上来一盘沙拉不会把话当命令执行。但要注意参数化的边界。它管不了SQL语句结构本身由用户决定的场景典型是两个动态表名、动态列名SELECT * FROM tableName 无法参数化例如排序字段、导出功能里常见的选择导出列名ORDER BY / LIMIT子句排序方向ASC/DESC、分页参数很多ORM默认不允许传入这类场景的正确打开方式是白名单映射前端传一个枚举值如sort1代表按时间倒序后端在代码里把枚举值映射到固定的列名和排序方向而不是把用户参数直接拼进SQL。这类防御细节正是很多系统已做参数化但仍被注入的元凶。4.3 纵深防御权限、错误处理、监控参数化是治本但现实中存量系统太多靠推倒重来不现实。我的建议是一层一层加固让攻击者即便找到一个注入点也拿不到太多甜头。最小权限原则是底线。业务数据库连接账户只给业务库的增删改查权限绝不使用DBA账户。没有FILE权限攻击者就无法INTO OUTFILE写文件没有跨库权限攻击者就查不了其他库。这一步的成本最低、效果立竿见影但也是运维最容易忽略的——一堆老系统直接用root连库跑业务这个习惯必须改。错误处理策略生产环境关闭详细报错统一返回系统繁忙。把详细错误打到日志系统里由监控告警捕获。这不只防报错注入对防止敏感信息泄露同样重要。数据库层防护在数据库账号层面用视图或者存储过程隔离敏感字段定期扫描慢查询日志注意那种规律性的明显延迟延时盲注的痕迹以及大量重复查询布尔盲注的特征。我把常规的排查加固项整理成了一个速查清单检查项标准做法常见失误Web层所有SQL拼接统一走参数化查询只在看起来危险的位置做忽略了搜索、排序、导出数据库账号权限最小权限专库专号用root或通用高权限账号跑业务错误信息控制生产环境关详细报错日志另存开着详细报错方便排错一直没关动态表名列名白名单映射图省事直接拼接用户输入非SELECT场景存储过程包一层并限制权限存储过程内部还用动态SQL拼用户输入监控与告警慢查询、重复查询告警数据库日志没人看出了事才发现5. 常见问题与排查技巧实录5.1 报错信息被吞掉以后怎么判断生产环境默认关闭详细报错你输入1、1 AND 11、1 AND 12返回的都是同一张空白页。这时判断注入是否存在的可靠信号是页面响应长度的差异。用1 AND 11请求响应体字节数记为n1再用1 AND 12请求响应体字节数记为n2。如果两者有稳定差异说明条件被真实执行布尔盲注可用。如果完全一致再试延时注入。我常备一个简单的脚本对比两个响应的长度差和耗时比自己肉眼刷页面靠谱。5.2 过滤了空格和注释符号怎么办空格被过滤时可以用/**/充当MySQL里的空格因为注释符和空格在解析层等效。也可以把整条语句改写成不需要空格的替代形式SELECT/**/username/**/FROM/**/usersid1%0aUNION%0aSELECT%0a1,2,3--用换行符代替空格用括号包裹子查询(SELECT(1))注释符本身被过滤时就不能靠--收尾了。替代方案是闭合引号把条件构造完整。例如原语句是WHERE id1输入1 OR 11让引号重新配对不用注释符也能保证语句合法。这是我在某些过滤严格的场景里最常用的一招。5.3 编码绕过的本质是什么很多绕过技巧听起来玄乎本质就一句话数据库在解析前会做多层解码而过滤层只处理了其中一层。典型的例子是URL编码。应用先做URL解码得到参数值再把参数值拼进SQL。过滤规则如果只对解码后的值做关键词匹配那么攻击者把payload做一次URL编码就能绕过——因为过滤层根本没看到真实内容。同理还有Unicode编码、十六进制编码它们共同的问题是每一层解码都发生在数据库语义解析之前而过滤规则只堵在某一层。理解编码绕过后防御结论也变得很清晰不要依赖先过滤再拼SQL的时序因为过滤这一层永远会漏。唯一可靠的方式是让输入永远停留在数据层不进入代码层——这正是参数化查询在做的事情。我个人这些年做安全测试下来最深的一个体会是SQL注入这事儿技术原理其实没多复杂复杂的是人的懒惰和侥幸。开发者总觉得我的输入框用户不会乱填运维总觉得日志开着没人看也没关系结果漏洞往往就出现在这些最不起眼的地方。真心建议大家不要绕开参数化去搞花式防护先把所有SQL固定拼接点排查一遍再立刻把数据库账号权限收到底这两件事做完一大半问题就没了。如果你手头有老系统还在裸拼SQL别拖今天就去翻翻代码找到那些看起来人畜无害的查询语句补上参数化或者至少加一层白名单。等出事再改代价是翻倍的。最后再分享一个小技巧排查存量系统时优先看搜索框、排序参数、导出功能和分页序号这几个位置。它们是最容易被忽视的SQL拼接点也是漏洞最高发的地方。约40%的SQL注入事故源头都在这些不起眼的功能里把这些先堵上再去看登录注册投入产出比高得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进