1. SQL注入到底是什么从一次看似正常的登录说起做安全测试和代码审计这些年SQL注入一直是我最常碰到、也最头疼的问题。很多开发同学觉得这都2025年了各种框架和ORM早就把SQL注入堵死了但实际上我在代码评审和渗透测试里依然能稳定地在各种意想不到的地方翻出注入点。所以我打算用这篇文章把SQL注入从原理到实战到防御完整地讲透一遍。这篇文章面向三类人一是刚入门安全、想搞懂注入原理的新手二是写后端代码、想知道怎么从根本上防住注入的开发三是做安全测试、需要一套完整排查思路的同行。文章会用大量可操作的示例代码和步骤你能直接照着复现和排查。先看一个经典的登录场景。假设某系统后端用Java处理登录请求代码大概是这样的String sql SELECT id FROM users WHERE username username AND password password ; Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(sql);如果用户在用户名输入框里填的是admin --那拼接出来的SQL就变成了SELECT id FROM users WHERE usernameadmin -- AND password任意内容在SQL里--是单行注释。上面这条语句的查询条件实际只剩usernameadmin后面的密码验证直接被注释掉了。攻击者连密码都不用输入直接以admin身份登录系统。1.1 SQL注入的本质把用户输入拼进SQL语句SQL注入的本质用一句话概括程序把用户可控的输入当作SQL代码的一部分拼进了查询语句导致输入中的特殊字符改变了SQL的语义。这里有个很关键的概念需要区分数据和代码。正常程序逻辑里用户输入是数据SQL语句执行逻辑是代码两者是完全不同的层次。而注入问题的根源是程序把数据当成了代码来执行打破了这条边界。可以拿日常生活打个比方。比如你写了一张购物清单交给助手清单上写着买两斤苹果如果看到香蕉也买两斤。正常情况下助手按清单执行。但如果助手把别人夹带进清单的纸条也当成购物指令那这张纸条上的内容就变成了代码——也许上面写着把保险柜打开助手还真照做了。SQL注入就是类似的问题攻击者往购物清单里SQL语句夹带了自己的指令数据库这个助手分不清哪些是正常数据、哪些是攻击指令全都执行了。1.2 注入是怎么发生的一段代码的问题现场我们再看一个更接近真实业务的场景。某系统的商品搜索功能支持按分类ID筛选商品后端代码是PHP$categoryId $_GET[category_id]; $sql SELECT * FROM products WHERE category_id . $categoryId . ORDER BY price DESC; $result mysqli_query($conn, $sql);正常情况下用户访问/products?category_id5执行的SQL是SELECT * FROM products WHERE category_id 5 ORDER BY price DESC这条语句没问题。但攻击者把URL改成/products?category_id5 UNION SELECT username, password, 1, 1 FROM users拼出来的SQL就变成了SELECT * FROM products WHERE category_id 5 UNION SELECT username, password, 1, 1 FROM users ORDER BY price DESC这条语句的含义彻底变了先查出分类ID为5的商品然后联合查询users表里的用户名和密码最终页面会直接把用户表的数据渲染出来。如果业务代码直接遍历结果集输出攻击者就能在页面上看到所有账号密码。这里需要补充一个基础概念UNION联合查询要求前后两个SELECT的列数一致、列类型兼容。攻击者通常需要先试探出目标表的列数用的方法是在URL后面加ORDER BY n从ORDER BY 1开始试到ORDER BY 10当某个数字报错Unknown column时说明列数比这个数字少。注意注入点分两种刚才演示的是数字型注入参数没加引号直接拼接进了SQL。还有一种是字符型注入参数被单引号包裹比如WHERE username $username攻击者需要先闭合前面的单引号再用注释符把后面的引号吃掉这就是文章开头登录案例的做法。判断是数字型还是字符型最简单的方法是输入一个单引号看页面报错或者输入1 AND 11和1 AND 12看页面返回是否不同——如果两次结果不一样大概率存在注入点。2. 注入类型拆解从报错、盲注到堆叠注入很多人对SQL注入的理解停留在能联合查询出数据这个层面实际上注入的利用方式远不止一种。不同场景下攻击者会选用完全不同的注入手段。我在实际测试中遇到过不少POST请求、JSON参数、排序字段里的注入点各有各的玩法。下面逐一拆解。2.1 报错注入让数据库把答案念出来报错注入的思路很有意思既然页面会把数据库的报错信息显示出来那就构造一条SQL让数据库在执行时把敏感数据拼进报错信息里。最经典的报错注入函数是MySQL里的updatexml()。这个函数本身的作用是更新XML文档第二个参数是一个XPath表达式如果表达式语法错误MySQL会返回错误信息并且错误信息里会包含这个参数的内容。于是攻击者构造如下语句AND updatexml(1, concat(0x7e, (SELECT password FROM users LIMIT 1)), 1)当数据库执行到这条语句时concat(0x7e, (SELECT password FROM users LIMIT 1))会先执行子查询取出第一条用户的密码拼上一个波浪号前缀然后交给updatexml作为XPath表达式解析。这个表达式必然语法错误于是MySQL报错报错信息中会原样带出拼接后的字符串——密码就这样念出来了。页面上的效果类似XPATH syntax error: ~e10adc3949ba59abbe56e057f20f883e这段看起来像随机字符的东西就是MD5加密后的密码。虽然加密了但攻击者可以拿去跑字典、查彩虹表或者直接撞库反正加密强度不够的话基本等于明文。我在实际测试中还经常用的是extractvalue()用法类似原理一致。这类注入的利用条件有两个数据库开了报错可见开发环境常见生产环境也有配置不当的情况、页面直接把数据库错误信息渲染出来。2.2 布尔盲注和时间盲注看不见数据也能猜相当多的时候页面不会回显数据库错误也不会显示查询结果——比如登录接口只返回成功或失败或者一个判断接口只返回存在跟不存在。这种情况叫盲注分两种布尔盲注的核心思路是把是否能触发某条条件为真作为信息通道。比如某接口判断用户名是否存在SELECT * FROM users WHERE username $username如果用户名叫admin页面返回存在否则返回不存在。攻击者可以构造admin AND SUBSTRING((SELECT password FROM users WHERE usernameadmin), 1, 1) a --如果密码第一个字符确实是a那么整条WHERE条件为真页面返回存在如果猜错了条件为假返回不存在。就这样一个字符一个字符地猜配合二分法或者ASCII()函数几十次请求就能把整段密码猜出来。手工测试很累但用工具比如sqlmap可以自动化完成。时间盲注更进一步适用于页面既不回显错误、也不区分存在/不存在的场景——所有请求返回内容完全一样。这时候攻击者利用的是数据库执行时间这个侧信道admin AND IF(ASCII(SUBSTRING((SELECT password FROM users LIMIT 1), 1, 1)) 100, SLEEP(5), 0) --如果第一个字符的ASCII值大于100数据库会执行SLEEP(5)整个查询卡顿5秒再返回否则立即返回。攻击者通过观察请求耗时就能推断出字符范围。我在一次代码审计任务里见过一个典型的例子某系统的导出报表接口接收一个日期参数后端直接拼接进SQL查询页面不管什么结果都返回报表生成中请稍后查看。这个接口从界面看完全感知不到异常但用时间盲注在几小时内就能把数据库里的敏感数据逐行拖出来。2.3 堆叠注入与二次注入两条容易被忽视的延展路径堆叠注入stacked injection利用的是某些数据库驱动支持多条SQL语句用分号分隔连续执行这一特性。比如攻击者输入1; DROP TABLE products; --如果后端用的数据库连接支持堆叠查询那么这条输入会变成两句SQL第一句正常查询分类为1的商品第二句直接删掉整个products表。MariaDB、SQL Server默认支持MySQL原本不支持但通过某些连接参数可以开启PostgreSQL要看驱动配置。堆叠注入的破坏力远大于普通的注入因为它能执行任意SQL而不仅仅局限于查询语句。但它有个前提数据库驱动和连接配置必须允许多语句执行。很多框架默认不开启这个能力所以实际利用门槛会比普通注入高。二次注入则是另外一个套路它的特点在于攻击者恶意数据被安全地存进了数据库但在后续某个环节被不安全地取出来拼接SQL。最典型的场景是注册功能用户注册时输入用户名admin --注册逻辑本身用了参数化查询恶意字符被当作普通字符串存进数据库没有触发任何问题。但过了几天用户修改密码时后端有一段代码用了字符串拼接String sql UPDATE users SET password newPassword WHERE username username ;如果这个username是从数据库里取出来的那原本存进去的admin --就被拼进了新的SQL语句——注入点此时才会被触发。二次注入隐蔽性极高常规的输入校验和参数化查询在入口处已经放行了漏洞却爆发在完全不同的出口处。我做代码审计时对这类数据入库后再被取出拼接的路径会格外关注因为WAF和输入校验都对这种跨请求的注入无能为力。3. 实操视角一次完整的注入过程还原理论上讲了一堆不如跑一遍完整的流程。下面我模拟一个实际渗透测试中遇到的场景某后台管理系统的公告管理功能支持按标题搜索公告请求形如GET /admin/announcement/search?kw标题关键字后端代码逻辑简化后为$kw $_GET[kw]; $sql SELECT id, title, content FROM announcements WHERE title LIKE % . $kw . %;这个接口有登录鉴权但某次测试中我发现其中的子账号权限配置不当低权限账号也能访问公告搜索接口。于是开始测试注入。3.1 第一步探测注入点先在正常参数后加一个单引号GET /admin/announcement/search?kwtest页面返回500错误部分错误信息泄露在响应体中提示You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version。有报错、有MySQL版本信息这说明参数没有经过任何过滤直接进入了SQL语句。为了确认是字符型注入再测试GET /admin/announcement/search?kwtest AND 11页面正常返回公告列表。GET /admin/announcement/search?kwtest AND 12页面返回空列表没有结果。两条请求返回内容完全不同布尔条件成立与否能直接反映在页面上——这是一个典型的字符型布尔盲注也可以用报错注入直接出数据。两者结合测试下来最终确定走报错注入路径效率更高。3.2 第二步爆库名、爆表名、爆字段先用updatexml报错注入拿库名kwtest AND updatexml(1, concat(0x7e, (SELECT database())), 1) AND 11页面报错信息显示XPATH syntax error: ~admin_db当前数据库叫admin_db。随后爆表名用information_schema.tableskwtest AND updatexml(1, concat(0x7e, (SELECT table_name FROM information_schema.tables WHERE table_schemaadmin_db LIMIT 1)), 1) AND 11数据表一个个暴露出来其中admin_user表吸引了我的注意。接下来爆字段kwtest AND updatexml(1, concat(0x7e, (SELECT column_name FROM information_schema.columns WHERE table_nameadmin_user LIMIT 1)), 1) AND 11字段名逐个爆出包括username、password、role等。最后直接查数据kwtest AND updatexml(1, concat(0x7e, (SELECT concat(username, :, password) FROM admin_user LIMIT 1)), 1) AND 11拿到了管理员账号和密码哈希。整个过程大约耗时20分钟全部通过浏览器手工完成不需要任何扫描工具。3.3 注入的破坏力到底有多大很多人以为SQL注入最多就是泄露点数据这种想法过于乐观。配合数据库的高权限和系统配置注入的破坏力可以层层放大读取任意文件。MySQL的LOAD_FILE()函数可以读取服务器本地文件只要数据库进程有权限、文件在允许路径内就能直接读取web目录下的源码文件比如.env配置文件里面可能有数据库密码、API密钥。写入webshell。如果数据库目录对web目录可写SELECT ... INTO OUTFILE可以把内容写到web根目录相当于直接在目标服务器上植入了一个网页后门后续可以完全控制服务器。这个利用条件较苛刻但一旦成立就是灾难级别。提权到操作系统。利用数据库的扩展能力比如UDF自定义函数在特定配置下可以执行操作系统命令。我见过某次攻击中数据库连接配置用的竟然是root账号攻击者直接通过数据库执行了whoami拿到的还是系统管理员权限。企业资源耗尽。盲注场景下攻击者为了让响应产生时间差构造SLEEP(10)这样的语句频繁发起请求数据库连接池会被慢查询打满正常业务直接不可用造成事实上的拒绝服务。所以我在给客户做安全评估时对SQL注入的评级从来不低于高危一旦确认某个参数可注入但暂时无法进一步利用我也会明确告知这个漏洞只是暂时没被放大利用修复优先级必须拉满。4. 防御方案为什么参数化查询能扛住95%以上的注入讲完攻接下来讲防。防御SQL注入的手段很多但优先级非常明确第一道防线永远是参数化查询预编译语句其他所有手段都是辅助。我见过不少团队折腾WAF、折腾过滤函数却连最基础的参数化都没做好这属于本末倒置。4.1 参数化查询的原理把数据和代码彻底分开参数化查询的原理是SQL语句的骨架先编译好用户输入只作为参数传给已经编译好的语句不再参与SQL语句的语法解析。用Java的PreparedStatement举例String sql SELECT id, title, content FROM announcements WHERE title LIKE ?; PreparedStatement pstmt conn.prepareStatement(sql); pstmt.setString(1, % kw %); ResultSet rs pstmt.executeQuery();注意这里的?是占位符它只表示这里会有一个值在预编译阶段就已经确定了这个位置是数据不是代码。即使攻击者输入test AND updatexml(...)这段内容也只会被当作字符串常量传给SQL语句数据库不会对它做语法解析。也就是说、--、AND这些字符完全丧失了改变SQL语义的能力。一开始代码里的拼接方式之所以危险是因为用户输入在编译前就进入了SQL语句的文本里数据库不得不把它当作语句的一部分来解析。参数化把编译和赋值两个阶段分离从根上解决了这个问题。一个常见的误区以为用了ORM框架就自动免疫SQL注入。实际上ORM只是帮你封装了参数化查询如果你在ORM里写了原生查询、或者用了String.format去拼SQL片段注入该有还是有。我见过某系统用MyBatis结果XML里全是${}拼接效果跟直接拼字符串一模一样。MyBatis里#{}是参数化占位符${}是直接拼接字符串这两者的区别是安全边界代码评审时必须一字一字看。另一个误区是我做了输入过滤单引号、关键字都拦掉了。这种思路大方向没错实操上很容易出问题。比如过滤了和--但攻击者可以用宽字节绕过、URL编码绕过、Unicode兼容性绕过过滤了SELECT还有sElEcT这种大小写混写部分数据库不区分大小写就算拦了常见关键字还有无引号十六进制编码的方式绕。黑名单过滤的逻辑天然有漏洞因为你永远无法穷举所有绕过姿势。这也是为什么安全界共识是白名单参数化优先过滤只做辅助。4.2 白名单校验与最小权限哪怕注入成功也拿不到东西参数化查询解决了大部分注入但还有一类场景很难用参数化覆盖表名、列名、排序字段、SQL关键字这些数据库结构层面的内容不属于数据不能用占位符替代。比如用户可以选择列表按哪个字段排序、升序还是降序代码可能写出String sortCol request.getParameter(sort); String sql SELECT * FROM products ORDER BY sortCol direction;这种场景下推荐的做法是白名单校验预先定义好允许的字段名集合用户传过来的值必须精确匹配集合中的某一项否则就用默认值。ListString allowedSortCols Arrays.asList(price, sales, rating, create_time); if (!allowedSortCols.contains(sortCol)) { sortCol create_time; } String direction DESC.equalsIgnoreCase(dir) ? DESC : ASC;这样用户能够影响的只有从集合里挑哪个拼接进去的内容完全受控。最小权限原则也是必须强调的防御纵深。很多系统为了图省事数据库连接直接给root或DBA权限。刚才演示的LOAD_FILE、INTO OUTFILE、SLEEP这些高危操作几乎都依赖高权限。如果应用账号只有SELECT、INSERT、UPDATE、DELETE这四类操作权限按业务需求裁剪而且能访问的库表被限制在业务必需的范围内那么即使注入成功攻击者也会发现读不了文件、写不了文件、删不了其他库的表、甚至查不了information_schema之外的表利用成本就大大增加。我在一次客户系统加固中发现某旧系统用的是DBA权限连接池后来改成按业务拆分成3个不同权限的账号查询账号、业务写入账号、管理维护账号。上线后模拟注入测试原来能直接读文件的操作现在报错ACCESS DENIED。权限最小化不会让注入消失但它把漏洞的破坏半径牢牢困在了一个小圈子里。4.3 纵深防御的其他手段WAF、编码处理、错误治理参数化是主防线但实战中我还会叠加以下几层WAFWeb应用防火墙放在最外层做第一波拦截常见的注入特征UNION SELECT、updatexml(、SLEEP(等会被挡掉一批脚本小子的试探。但WAF不是万能钥匙——特征库总有滞后攻击者可以fuzz绕过规则、可以利用二次注入绕过检测、可以用分块传输等手法绕过流量检测。所以WAF的角色定位是降低攻击者效率、增加利用成本而不是一夫当关万夫莫开。数据库连接配置上尽量关闭不必要的能力比如MySQL的local_infile如果不必要就关掉防止LOAD DATA LOCAL INFILE读取服务器文件SQL Server如果不需要xp_cmdshell就禁用它连接字符串里不开启多语句支持防范堆叠注入。这些配置项是很多人忽略的防御点实际效果很好。应用层编码对于传进SQL之前涉及URL解码、JSON解析、XML解析的内容要特别注意多层解码导致的二次编码绕过。我建议在接入层统一做一次输入解码规整避免同一条数据在多个环节被不同规则解码导致黑名单过滤被绕过。错误处理与信息泄露数据库异常必须统一捕获返回给前端的只能是一句系统繁忙请稍后再试。我见过太多生产系统把完整SQL语句、堆栈信息、甚至数据库版本号直接打到页面上这给攻击者提供了极佳的探测辅助。线上环境务必关闭debug模式、关闭数据库错误回显。数据库审计日志开启对敏感表的操作审计虽然不能阻止攻击但能在事发后提供溯源线索。实战中很多安全事件追溯失败就是因为日志不全或者根本没开审计。退一步说即使注入成功有完整的日志也能定位到攻击者用的时间和IP对应急响应至关重要。这几层叠加起来就是常说的纵深防御每一层都可能被绕过但多层叠加之后攻破所有层的成本和难度会成倍上升。5. 常见问题与排查技巧实录安全测试中的那些坑这部分是我自己在做代码审计和应急响应中总结的经验很多细节在网上教程里翻不到写出来给同行参考。5.1 常见问题速查表问题现象可能原因排查思路推荐做法翻遍代码没找到拼接SQL但测试还是能注入使用了MyBatis的${}或原生Statement全局搜索${、Statement、createQuery等关键字统一改为#{}或PreparedStatement输入没报错、AND 11也没反应框架自带参数化或参数并非直接进入SQL检查是否有ORM层观察完整请求参数名确认无误即可放行重点排查自定义拼接处WAF拦截了测试但手动测试能过WAF规则存在绕过尝试编码、大小写、注释符拆分等绕过技巧别过度依赖WAF主防线仍然靠参数化盲注请求导致数据库CPU飙高攻击者用时间盲注制造慢查询查看慢查询日志定位请求来源IP及参数限流封禁同时排查并修复注入点生产环境突然报Unknown column可能有人正在探测列数查数据库错误日志看是否有ORDER BY报错确认注入点并紧急修复代码里过滤了但注入依然成功过滤思路没覆盖宽字节或二次编码场景测试%bf%27、URL二次编码等绕过姿势放弃黑名单思路改用参数化查询这里重点说一个高频问题框架到底是否自动防注入。很多用Spring Boot的同学以为自己用了JPA就万事大吉但如果代码里写的是Query原生SQL并且用了拼接参数那JPA也挡不住。框架层会自动参数化的只有通过ORM接口传入的条件参数任何绕过ORM接口、直接拼SQL的行为都需要人工审查。我的评审经验是凡是代码里出现String sql SELECT ...这种以字符串形式拼接的语句一律优先怀疑并在测试环境实测注入。5.2 一份可以直接抄的代码评审SQL检查清单每次做代码评审我都会按下而这份清单逐项过一遍。这些内容适用于Java、PHP、Python、Go等所有后端语言核心逻辑是通用的全局搜索executeQuery、executeUpdate、query、rawQuery等方法调用逐一确认SQL语句是参数化还是字符串拼接。全局搜索${符号MyBatis场景下直接用变量拼接确认是否出现在SQL相关位置。检查排序字段、动态表名、动态列名相关的拼接逻辑看是否走了白名单。检查所有LIKE %xxx%查询确保参数化写法是like ?而不是拼接进%。确认数据库账号权限是否严格按业务最小化禁止生产环境使用DBA账号跑应用。检查错误处理逻辑确认数据库异常不会回显详细信息到前端。检查数据库连接串配置关闭不必要的高危特性多语句支持、本地文件读取等。关注批量导入、数据迁移脚本中的SQL拼接——这类脚本往往不上线评审但实现了全部注入风险。检查第三方组件是否使用了老旧的数据库驱动或ORM版本这类组件往往存在已知CVE。对一个疑似安全的功能跑至少两轮注入验证一轮报错注入测试一轮布尔盲注测试。这份清单看起来简单但实际执行中最大的障碍是检查者的经验盲区——很多开发者自己写的拼接代码因为业务逻辑简单根本无法想象输入能变成多语句或者报错注入。所以我建议把它作为标准审查步骤固化到CI流程里配合SonarQube等静态扫描工具至少能拦掉60%的常规注入。5.3 侥幸心理的代价一个埋在小接口里的漏洞最后讲一个我印象深刻的真实案例。某次应急响应中客户报告数据库被异常删除排查后发现是一个内部工具类接口出了问题——数据导出功能开发同学图方便直接在接口里拼了SQL测试的时候用正常参数验证过功能没做过任何恶意输入测试。结果上线后某内部账号异常操作直接通过注入把一张核心业务表删了。事件调查下来发现这个接口在系统里存在了两年多开发可能觉得内部接口外网访问不到但这个系统实际上有内网穿透配置攻击者已经通过其他入口渗透进了内网顺手就把这个接口当成跳板了。这个案例给我最深的教训是没有不值得防护的接口。只要接口接收了用户可控输入、并且输入进入了SQL就一定要做防护。那些看起来人畜无害的内部接口管理后台导出功能往往是实战中最容易出问题的地方。攻击者的耐心和手段远超想象侥幸心理是安全工作中最大的敌人。文章写到这里SQL注入的攻防体系基本完整了。做安全久了会发现真正稳妥的思路永远是回到数据和代码分离这个原则上——所有把用户输入当代码执行的行为都会出问题。希望这篇文章能帮你把SQL注入看透不管是写代码还是做测试都有自己的判断力。