ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SQL注入从攻到防:原理、手工测试与防御实战指南

SQL注入从攻到防:原理、手工测试与防御实战指南 SQL注入这玩意儿圈内人提到它都有点复杂情绪——说直白点它太“老”了老到有些新入行的同学觉得它已经是历史可一旦你真正钻到某次攻防演练、某个后台系统的代码审计里又会发现它无处不在。OWASP Top 10换了好几版SQL注入依然在榜上这不是偶然。我见过太多团队嘴上说着“我们用了预编译”代码一查还是老老实实地把用户输入拼进字符串里。这篇“从攻到防”就是想把这些年积累的漏洞原理、手工注入方式、常见绕过招数以及防御落地经验一次讲透让刚接触安全测试的新手能按部就班地在本地靶场把完整链路跑通也让写业务的后端开发知道预编译到底怎么救自己、上线前到底该检查哪些地方。我先声明一个原则所有演示都以本地靶场和授权测试为前提千万别拿这套东西去打没有授权的目标。安全测试的前提是合规技术本身没有善恶使用它的边界才决定性质。接下来我们从最基础的问题开始拆。1. 先从根上理解SQL注入不是漏洞是“信任”出了问题1.1 SQL注入到底是怎么发生的用一条登录框讲清楚很多人以为SQL注入是高深的技术其实它的触发机制一句话就能说清程序把用户输入当成SQL代码的一部分拼接后交给数据库执行。数据本该是数据代码本该是代码现在两者混在一起数据库因此“误解”了你的意图。我拿最经典的登录场景举例。假设后端用PHP写了这样一段逻辑$sql SELECT * FROM users WHERE username . $_POST[username] . AND password . $_POST[password] . ; $result mysqli_query($conn, $sql);正常用户输入zhangsan和123456SQL是SELECT * FROM users WHERE username zhangsan AND password 123456看起来很合理。可攻击者在用户名框里输入admin -- -密码随便填个1拼出来的SQL变成了SELECT * FROM users WHERE username admin -- - AND password 1--在MySQL里是注释符后面的 AND password 1全被注释掉这条查询就变成了“查admin用户的记录无需验证密码”。如果这个admin存在攻击者就直接登录了。更严重的是有些数据库配置允许堆叠查询攻击者可以在一句话后面追加; DROP TABLE users;结果就是毁灭性的。这个例子里问题出在开发者“信任”了用户输入以为引号能包裹住一切。生活里打个比方你开了一家餐馆有一套“点菜系统”正常情况下客人说“来一盘宫保鸡丁”这些词只是菜名。但如果你家的系统允许客人把整个句子当菜名直接传给后厨那客人说“来一盘宫保鸡丁不要花生再煮一锅饭”后厨也可能照做。SQL注入就是这种混乱——用户输入本来只是“菜的内容”结果被当成了“菜单指令”。理解了这一点你就会明白所有SQL注入变种的核心都相同用户数据进入SQL语句结构改变了语句的原有逻辑。修复的关键不是屏蔽这个字符那个字符而是让“数据”永远只是“数据”。1.2 注入点判断单引号、报错、逻辑真伪三步走知道了原理遇到一个可疑参数怎么判断它是不是注入点我总结一个“三板斧”流程在靶场和真实授权测试中都管用。第一步加单引号。在原有参数后面直接加一个英文单引号比如?id1变成?id1然后观察页面反应。如果出现数据库报错、页面500、白屏、或者返回的SQL片段说明输入可能参与了SQL拼接。注意有些系统自己会在参数前后加括号、双引号等这个我们稍后说闭合差异。第二步逻辑真伪测试。构造?id1 and 11和?id1 and 12。如果第一个页面正常返回第二个页面内容为空、报错或跳转基本可以断定这里存在数字型注入。为什么因为and 12引入了一个恒假条件SQL查不到数据页面自然就空了。如果两个结果一样再试试?id1 and 11与?id1 and 12看看是否因为参数被当成字符串处理。第三步使用注释符。在参数值后面加--、#、%23等注释符尝试去掉SQL语句中开发者编写的不想让你改动的后缀。比如?id1 --如果页面比不加注释时多了数据、或者不再报错说明注释符生效拼接点已经找着了。这里有个特别容易踩坑的地方SQL语句中查询条件的“闭合方式”不止一种。有些开发者这么写$sql SELECT * FROM users WHERE id ($id);那你的payload需要带上右括号闭合比如?id1) and 11 --。还有的写WHERE name $name那你得用双引号闭合。判断闭合方式的方法很简单不断加单引号、双引号、右括号的一个组合去试错观察哪一次报错信息更接近真实语法。靶场SQLi-LABS前几关就是专门练这个的从Less 1到Less 10把各种闭合方式都给你列出来了新手花半天时间跑一遍比看十篇文章都管用。注入点不仅存在于URL查询参数POST表单里的登录名、搜索框HTTP头里的User-Agent、X-Forwarded-For甚至文件名上传参数都可能是入口。判断思路完全相同就是“喂特殊字符看反应”。养成这种条件反射后面才谈得上深入利用。2. 常见SQL注入类型与手工测试实操从联合查询到盲注2.1 联合查询注入最直观的“拼车”玩法在所有注入类型里联合查询注入是我最喜欢教的入门课因为它最“可视化”。UNION SELECT可以让一个SQL查询的结果集和另一个SELECT查询的结果集拼在一起只要前后两个查询的列数一致。如果页面会把数据库结果直接渲染出来攻击者就能把自定义内容“塞”进去。但前提是要知道目标查询有几列。手工测试时用ORDER BY?id1 ORDER BY 3 --如果正常返回说明列数至少是3再试ORDER BY 4如果报错说明列数就是3。原因很简单ORDER BY的数字一旦超过SELECT列表的列数数据库就会报“Unknown column”。这样你就拿到了列数。接下来用不存在的ID来让前一个查询结果为空让UNION后面的数据有机会显示?id-1 UNION SELECT 1,2,3 --页面上某个位置出现了1、2、3这就是“回显位”。把数字替换成数据库函数就能拿到敏感信息?id-1 UNION SELECT 1,database(),version() --这里就显示了当前数据库名和数据库版本。继续扩展可以查所有表名、字段名再dump数据。SQLi-LABS Less 1的完整过程就是这套网上教程很多但我建议你亲手敲一遍不要复制。为什么前面要写成-1而不是1因为id-1通常查不到任何用户整个UNION结果集里就只有“第二段SELECT”的数据它才能显示在页面上。很多新手在这卡住总觉得页面显示的还是原数据其实就是前一段查询有结果导致自己的数据被顶掉。另外不同数据库的UNION语法有差异。MySQL直接用UNION SELECTSQL Server也一样Oracle要求后面必须带FROM dual比如UNION SELECT 1,2,3 FROM dual。遇到SQL Server 2008这类老库系统视图名也不一样sysobjects和syscolumns是常用的查询字典。所以做注入测试时先通过报错或函数识别数据库类型再去查SQL语句能省很多时间。2.2 报错注入、布尔盲注和时间盲注页面不说话怎么把数据掏出来很多时候页面并不会把你的数据直接回显出来甚至错误信息都被统一拦截。这时需要更隐晦的手法。报错注入的前提是数据库的异常信息能显示到页面。MySQL里常见的extractvalue和updatexml专门利用XPath函数解析报错时会把参数内容带出来的机制。一个典型payloadand extractvalue(1,concat(0x7e,(select database()),0x7e))0x7e是波浪号~的十六进制把它夹在数据两边是为了让报错信息更清晰也避免函数在遇到数据为空时报错。执行后页面的数据库错误里就会带上库名。这种注入非常“快”不需要盲猜。如果页面既没有回显也不报错但正确和错误的SQL执行结果会让页面状态有可观察的差异比如正常显示、空内容、跳转就可以用布尔盲注。经典判断方式and ascii(substr(database(),1,1))100如果页面正常说明数据库名的第一个字符ASCII码大于100再调整数值逼近就能逐字符猜出整个库名。这种方式手工做非常慢一般配合脚本或工具。我写过一个极简的Python脚本用requests库循环探测import requests url http://127.0.0.1/sqli-labs/Less-8/?id1 result for i in range(1, 20): for c in range(32, 127): payload f and ascii(substr(database(),{i},1)){c} -- resp requests.get(url payload) if You are in in resp.text: result chr(c) print([], result) break注意这个脚本只能用于本地靶场绝对不要拿它扫别人的系统。时间盲注更狠页面完全无差异只能用响应时间推断。MySQL用sleep()或benchmark()and if(ascii(substr(database(),1,1))100,sleep(3),0)如果页面停顿了3秒说明条件成立。时间盲注对网络稳定性要求高而且攻击效率极低通常作为最后选择。实际渗透测试里我有一个优先级联合查询 报错注入 布尔盲注 时间盲注。能用快的就不用慢的这个顺序能让测试过程高效很多。2.3 万能密码绕过一个 or 11 -- -的由来网上很多所谓“万能密码”演示看起来像魔法其实就是注入的轻微变形。拿登录框举例攻击者在用户名输入 or 11 -- -拼出来的SQL是SELECT * FROM users WHERE username or 11 -- - AND password xxxor 11让整个WHERE条件恒真后面的密码验证被注释掉于是直接返回第一条用户记录多半是admin。这个利用方式并不需要拿到数据回显只需要一个登录判断逻辑危害同样大。到这里你可能会发现所谓的“万能密码”核心还是开发者把输入直接拼进了SQL。写代码时如果用的是占位符绑定用户再怎么输入or 11它都只是密码字段里的一个普通字符串没有任何特殊意义。所以我一直强调防御的正道不是记住哪些payload危险而是从根上断掉输入影响SQL结构的路。3. 从攻转防参数化查询、过滤与纵深防御3.1 预编译/参数化查询唯一根治手段如果说SQL注入有一个“解药”那就是预编译也叫参数化查询。它的原理是先把SQL语句的整体结构发给数据库编译把所有用户输入的位置标记成占位符“?”等数据库编译完成再把具体参数值传进去。参数值永远不会参与SQL语法解析就像一个已经印好的快递单地址栏和内容栏是物理隔离的无论用户写什么都只会被当成包裹内容而不会被当成重新打印单据的指令。拿三种主流语言演示安全写法。Java JDBCString sql SELECT * FROM users WHERE username ? AND password ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password); ResultSet rs ps.executeQuery();PHP PDO$stmt $pdo-prepare(SELECT * FROM users WHERE username ? AND password ?); $stmt-execute([$username, $password]);Python DB-APIcursor.execute(SELECT * FROM users WHERE username %s AND password %s, (username, password))这三段代码的共同点是SQL语法的骨架在预处理时已经定死参数只作为纯数据传递。不管用户输入admin -- -还是or 11数据库都不会把它当作代码。这里我要特别敲一下黑板用框架不等于安全。Java后端很多项目用MyBatis#{}确实会生成占位符但${}是直接字符串替换写起来方便但就是裸奔。对比一下-- 安全使用 #{}参数按占位符处理 SELECT * FROM users WHERE name #{name} -- 危险使用 ${}参数直接拼进SQL SELECT * FROM users WHERE name ${name}很多开发同学只记得“少用${}”却不知道什么时候会忍不住用它。最常见的场景是动态排序字段比如ORDER BY ${sortColumn}因为MySQL的ORDER BY后面不能直接绑定占位符。这种情况下必须做白名单映射让用户传入的排序字段名先去代码里的一个数组匹配匹配不到就返回默认值绝不能把用户原文拼进去。另一个常见误区是“我已经做了转义应该安全了”。转义只是把单引号等字符变成转义序列在某些场景下有作用但遇到宽字节编码、二次解码、或者某些数据库函数依然可能被绕过。所以我的态度很明确转义、过滤、WAF都是辅助唯一根治方案就是参数化查询。3.2 输入校验和WAF辅助不等于救命既然唯一根治是参数化为什么还要做输入校验和WAF因为防御是分层的你不能保证所有代码都能被预编译覆盖更不能保证所有老系统都能立刻改造。输入校验和WAF的意义是“兜底”。输入校验的核心是白名单思维。一个字段如果是整数就严格校验is_numeric或正则^\d$如果是枚举就在代码里列出允许的值如果是邮箱、手机号用对应的格式正则校验。白名单的优点是攻击者根本没有输入特殊字符的机会。而黑名单过滤、、select、union看起来简单逃逸思路却多到你防不完大小写混写UnIoN、注释符拆分un/**/ion、内联注释/*!50000select*/、URL编码%27、等价函数替换这些都是绕过黑名单的常见手法。WAF则是网络层或应用层的流量过滤设备。它可以拦截大量已知payload但配置不当照样被绕过。我见过有些WAF只检查GET参数不检查POST的JSON体攻击者把注入语句放在data字段里就过来了还有的WAF不检查HTTP头的自定义字段注入点藏在X-Forwarded-For里也漏了过去。更别提一些云WAF对multipart/form-data上传格式处理不完整攻击者可以用分块传输绕过去。所以WAF要定期更新规则库开启所有协议的解析并配合日志监控使用。但请记住所有辅助手段都只是在“预编译还没到位”的过渡期发挥作用。我的建议是哪怕上了WAF和RASP该修的SQL还是得修安全不是一层设备能扛住的。3.3 数据库最小权限与纵深防御就算被打进去也不能拉开保险柜一个很容易被忽略的事实是很多SQL注入之所以危害巨大是因为应用连接数据库用的账号是root或DBA权限。攻击者注入成功后不仅能查数据还能into outfile写文件、调用存储过程、跨库查询甚至直接提权到操作系统。纵深防御策略里数据库权限最小化是最后一道防线。具体做法包括为每个应用单独创建数据库账号只授予业务需要的权限。只读模块用SELECT写操作模块再单独授权INSERT/UPDATE不要给DROP、CREATE、FILE等权限。关闭或严格限制存储过程的执行权限尤其是xp_cmdshell这类危险扩展。数据库服务器只允许来自应用服务器IP的访问不要映射到公网不要用弱密码。开启数据库审计日志记录失败的SQL操作、权限变更、异常登录等方便事后溯源。打个比方就算门被撬开了如果房间里只有一张桌子和几本书小偷拿不走什么但如果钥匙串上还挂着银行金库的钥匙那就全完了。数据库账号权限就是那把钥匙串宁可小不可大。很多企业出事之后才发现攻击者通过一条注入漏洞拿到了整个数据库集群的权限就是因为应用账号权限太大。4. 实战防御落地清单含代码示例4.1 各语言写安全代码的避坑指南这一节不只是列代码我把实际开发中最容易翻车的几个细节拆开说。Java方向强烈建议使用PreparedStatement而不是Statement因为Statement只能靠拼接。如果使用Spring的JdbcTemplate同样可以利用?占位符例如jdbcTemplate.queryForList(SELECT * FROM users WHERE id ?, id);千万别写成SELECT * FROM users WHERE id id。在MyBatis中坚持使用#{}杜绝${}除非场景特殊。对于LIKE模糊查询很多同学想写LIKE %${keyword}%这是非常经典的漏洞应该改成select idsearch resultType... SELECT * FROM products WHERE name LIKE CONCAT(%, #{keyword}, %) /select另外MyBatis的${}还会出现在动态表名、列名、排序字段上这类场景全部要做白名单映射。PHP方向强烈建议全程使用PDO预处理。老代码里的mysqli_query拼接不可取如果一时改不动至少要用mysqli_real_escape_string做转义作为过渡但心里要清楚它不是终点。Python方向%格式化字符串和f-string构造SQL都是大忌正确做法是cursor.execute(sql, (params,))。特别要注意的是params必须作为第二个参数传入很多人会忍不住用executemany但在拼接SQL时同样要传参数列表而不是先格式化成完整SQL再执行。Node.js/TypeScript方向mysql2库的execute方法支持占位符conn.execute(SELECT * FROM users WHERE id ?, [userId]);不要用字符串模板把userId直接插进去。前后端分离的项目里前端传参校验只是体验问题后端代码安全才是根本不要指望前端过滤能兜底。4.2 部署WAF、RASP时需要注意的配置细节WAF不是装完就万事大吉。我见过很多次安全设备“形同虚设”的情况最后排查发现都是配置没到位。部署WAF时至少确认以下几点流量是否真的经过了WAF。有些场景下运营商链路或内部网络没有强制引流WAF旁路部署只能看日志不能拦截。是否同时检测GET、POST、Header、Cookie、Upload体。很多默认规则只查“常见参数”攻击者把payload放在JSON嵌套字段或文件内容里就漏了。是否开启拦截而非仅记录模式。我在应急响应里发现不少WAF初始模式是“观察”规则没生效攻击流量全部放行。黑白名单、IP限速、地区封禁等策略是否按业务需求配置避免误杀正常用户的同时放过高频恶意请求。RASP运行时应用自保护是近年越来越被接受的一种方式。它直接嵌入应用层在代码执行时分析SQL语句是否被参数污染跟WAF的流量视角完全不同。比如你写了预编译RASP可以验证参数所占的“数据位”是否越界如果你用了拼接RASP在运行时检测到恶意SQL特征还能直接阻断。但RASP也有性能开销和误报问题需要合理评估。我的建议是在关键业务系统上WAF和RASP可以一起上一个挡外面一个守里面但这两者都不该成为修代码的替代品。4.3 上线前自测SQL注入的检查清单把下面的清单打印出来每个系统上线前对着过一遍能挡掉大量低级漏洞。全局搜索代码中的SQL字符串拼接用IDE或grep搜索SQL关键字、sql 、.concat、StringBuilder、${}、%格式化等。重点检查DAO层、Mapper XML、存储过程的调用方式。搜索MyBatis和JPA的Query注解看有没有直接在注解里拼:#{}以外的值。检查所有用户输入入口URL路径参数、查询字符串、POST表单、JSON字段、HTTP Header、Cookie、文件上传文件名。给每个输入定义白名单校验规则。确认数据库连接账号的权限应用账号是否为最小权限是否允许跨库查询是否有FILE权限检查生产环境的错误配置是否关闭了详细异常堆栈是否对数据库错误做了统一返回很多报错注入就是靠页面回显异常信息才得手。确认WAF/IDS规则已开启并且处于拦截模式同时配置了告警通知。在测试环境跑一遍自动化扫描比如sqlmap的--batch --level3 --risk2但切记指定只读检测模式不要开启--os-shell和--drop等危险操作并且只在有授权的环境中执行。最后做一次人工冒烟测试在登录框、搜索框、详情页ID参数中分别输入和1 and 12看页面是否异常。这套流程简单却有效。5. 常见问题与排查技巧实录5.1 为什么用了参数化还是被注入了可能是这几个原因有时候开发同学很委屈“我已经用了PreparedStatement怎么扫描器还是报了SQL注入”不要急着怀疑扫描器误报先查下面几种情况。第一种表名和列名或排序字段被拼接了。参数化只能绑定“值”不能绑定“标识符”比如ORDER BY、GROUP BY后面的字段名数据库不允许你把字段名当参数值传。开发为了省事直接把用户传的排序字段拼进SQL这就是漏洞。解决办法是白名单映射。第二种在参数化查询前先把用户输入做了一次替换或拼接。比如有些代码为了支持模糊搜索在传参前写keyword % keyword %然后再预编译。如果替换是在逻辑层做的问题不大但如果用字符串替换处理引号或特殊字符再拼进SQL骨架替换本身可能被绕过。第三种使用了存储过程且存储过程内部是拼接SQL。虽然应用层调存储过程时用了CallableStatement但存储过程里如果有EXECUTE IMMEDIATE或EXEC拼接注入依然存在。这种问题特别难排查因为漏洞藏在数据库代码里代码审计却只查应用代码。第四种字符集和编码问题。在老版本MySQL和GBK编码环境下攻击者可以使用宽字节注入让反斜杠转义失效。比如%bf%27被当成一个多字节字符后面的单引号成功逃逸。修复方式是统一使用utf8mb4字符集并使用参数化查询让转义根本不参与结论。排查时可以先在代码里打印最终被数据库接收的SQL日志观察参数值是否被当作“结构”解析。如果没有SQL日志就在测试环境用代理抓包检查请求经过程序后发送给数据库的语句形态。一般来说只要最终发到数据库的语句是“模板参数”结构不会被用户输入改变就基本安全。5.2 报错注入和盲注的区别与选择别再一刀切很多新手拿到一个疑似存在注入但页面不显示数据的点就直接上sqlmap然后跑出来一堆结果但也说不清到底是怎么判断的。这里我说一下手工思路的优先级。报错注入依赖“用户输入的错误信息能被数据库返回并渲染到页面”。如果生产环境关了详细报错、或者框架统一拦截了异常报错注入就失效。所以在测试时如果先输入单引号看到数据库异常提示优先试报错注入如果不报错但页面有明显内容差异就用布尔盲注。布尔盲注不依赖报错但依赖“条件真假导致查询结果有无”。如果查询结果无论真假都被页面统一渲染成相同内容布尔盲注也失效只能考虑时间盲注。时间盲注是最后的办法因为它的效率最低、受网络环境影响最大。有一种优化盲注的技巧是用二分法。手工判断字符时不要从头到尾遍历先用ascii(substr(database(),1,1)) 128判断大小再逐步二分到具体值。一个字符最多试7次左右40个字符的库名Dump下来也就几分钟比逐个遍历256次快很多。如果你写脚本完全可以实现这种二分逻辑。5.3 现在还存在SQL注入漏洞吗亲测告诉你比想象中多总有人问“SQL注入是不是已经过时了”我的回答是不仅没过时在存量系统里依然高发。你去看看那些十年前的老系统、二次开发的外包项目、报表模块、接口聚合层到处是拼接SQL。不少团队觉得“我们上了ORM框架就安全了”实际上ORM只能覆盖常规CRUD复杂查询时很多开发者又回去写原生SQL拼接。即使大厂也会在低版本或特殊模块里翻车。最近网上偶尔还能看到Nacos等中间件的未授权访问通报这些都说明安全建设永远在路上别把SQL注入当成“老古董”看。自查方法也不难。先把历史代码翻一遍用上节清单里的搜索项扫一遍大概率能找到几处可疑拼接。再用自动化工具交叉验证但记住一定要在测试环境、授权前提下进行。sqlmap跑出来的结果需要人工确认避免误报和破坏数据。最后分享一个小技巧很多“看起来不存在注入”的接口其实入口不在常规参数而在Referer、X-Forwarded-For这类不太起眼的HTTP头甚至文件上传的文件名。你下次审代码时把所有进入后端函数的“外部输入变量”全部列一遍逐个追问它们的使用路径就会发现自己以前漏掉的攻击面往往比想象中多。防御也是同理把输入入口都盘清预编译策略全覆盖才能真正睡个安稳觉。
返回列表