拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SQL注入原理与防御实战:从手工注入到参数化查询

只要写过几年代码几乎没人没踩过SQL注入的坑。我自己第一次真正意识到SQL注入的杀伤力是在一个已经上线两年的后台管理系统上登录框里随手输了一个单引号整个页面直接抛出一段完整的数据库语法错误那一瞬间后背发凉——你的数据库到底暴露在多大的风险之下后来我又在不少企业内网系统、外包项目甚至一些知名数字化系统的公开漏洞信息里反复看到同一个词SQL注入。比如热词里那个“喰星云·数字化餐饮服务系统 not_out_depot 接口 SQL 注入漏洞”本质上就是我当年在后台登录框里遇到的那类问题的工业化版本。这篇文章我打算一次性把SQL注入讲透它到底是什么、为什么能穿破层层防御、手工注入的完整套路是什么、盲注场景怎么应对以及最关键的——到底怎么样才能真正防住它。适合刚入门安全测试的开发者、正在学 Web 安全的学生以及所有写 SQL 或调接口的后端程序员阅读。我会把原理掰开揉碎配靶场实操最后给出一套可落地的防御方案。1. 漏洞原理从一条“很普通”的登录SQL说起1.1 一条登录语句是怎么被攻破的先看最基本的情况。几乎所有新手项目里登录功能都长这样SELECT * FROM users WHERE username admin AND password 123456这段 SQL 逻辑很清晰如果查到了记录说明用户名和密码匹配允许登录。问题出在 username 和 password 的值是从前端表单接收的后端代码通常把接收到的字符串直接拼接进 SQL。用伪代码表示就是username request.POST[username] password request.POST[password] sql SELECT * FROM users WHERE username username AND password password result db.query(sql)这时候如果用户输入的 username 不是admin而是一段精心构造的字符串比如admin or 11拼进 SQL 之后原本的语句就变成了SELECT * FROM users WHERE username admin or 11 AND password 123456注意or的优先级低于and所以这条语句的判定结果取决于三者组合后的布尔值。因为11恒为真整个 where 条件恒为真数据库就会把users表里的第一行记录返回。如果恰好第一行是管理员账号那攻击者就什么都不用知道直接以管理员的身份登录后台了。这就是热词里反复出现的“SQL注入万能密码绕过”的底层原理。1.2 为什么“拼字符串”是万恶之源很多人会把 SQL 注入归咎于“没有过滤特殊字符”这是一种常见的误解。真正的根源不是某个单引号而是代码把用户的输入当成了 SQL 代码的一部分来执行。正常情况下用户在登录框输入的admin应该被当作“数据”来处理但当它被直接拼接进 SQL 文本之后admin or 11里的引号、or、等号都被 SQL 解析器当成了“代码”来理解。你可以把 SQL 引擎想象成一个严格执行命令的机器人。你给它一段指令它不理解“这段是用户数据”和“这段是业务逻辑”的边界只要语法合法它就会照单全收。开发者如果没有人为制造这层边界用户输入就能混进指令区。这也是为什么“过滤单引号”不是根治方案。你过滤了单引号攻击者可以用宽字节、编码绕过、注释符/**/来代替空格甚至用数据库内置函数来替代被过滤的关键字。道高一尺魔高一丈。真正要让用户输入始终以“数据”身份参与 SQL 执行需要的是预编译和参数化查询这个我在后面防御部分会展开讲。1.3 SQL注入的危害到底有多大SQL 注入的后果远不止“后台被绕过”这么简单。以 MySQL 为例一旦注入点存在攻击者可以做的事情包括越权读取数据通过联合查询读取任意表的数据包括用户表、订单表、支付记录。篡改数据通过注入执行update、insert、delete比如把自己的余额改大或者把别人的订单改成已支付。拖库配合information_schema元数据库把整个数据库的表结构、数据全部导出。写文件/读文件在部分配置下可以通过into outfile往服务器写入 WebShell获得服务器权限。也可以读取服务器上的敏感文件。拒绝服务通过堆叠注入或复杂查询拖垮数据库导致业务不可用。用一个生活化的类比你家的保险柜门上留了一条缝攻击者不仅能透过缝看到里面的东西还能伸进一根足够长的铁丝把你放在保险柜旁边的钥匙也勾出来。这条缝就是未参数化的 SQL 拼接点。2. SQL注入的常见分类与识别特征2.1 字符型、数字型、搜索型怎么区分不同注入点在拼接方式上有所差异所以检测和利用方式也不同。最基本的分类数字型注入。后端直接把参数拼进数字比较中例如SELECT * FROM products WHERE id 1如果输入1 and 11还能正常返回输入1 and 12返回为空说明这里的参数是直接参与数值运算的不需要闭合引号判断相对简单。字符型注入。后端把参数用引号包裹例如SELECT * FROM products WHERE name phone攻击者需要先闭合前面的引号再构造后续逻辑。比如输入phone or 11拼进去后变成SELECT * FROM products WHERE name phone or 11搜索型注入。后端在参数前后都加了模糊匹配的通配符例如SELECT * FROM products WHERE name LIKE %phone%这种注入点的闭合方式通常是phone% or 11 or %拼接后变成SELECT * FROM products WHERE name LIKE %phone% or 11 or %%判断一个注入点是哪种类型核心方法是看输入额外符号后页面的反应。输入单引号后页面报错、白屏、或返回结果异常那八成存在字符型注入输入1 and 11与1 and 12返回结果不一致则是数字型或可闭合的数字型注入。2.2 联合查询、报错、布尔、时间、堆叠的区别确认注入点之后接下来的问题是如何把数据“取出来”。不同场景下数据回显的方式不同联合查询注入Union Based。目的是通过union select把查询结果和开发者原本的查询结果合并到一起然后让数据直接展示在页面上。前提是页面存在回显位置且前后查询的列数一致。报错注入Error Based。适用于页面没有直接数据回显但会把数据库错误信息显示出来的场景。通过updatexml()、extractvalue()等函数人为构造一个让数据库报错的表达式让错误信息里携带子查询的数据。核心思路是让数据库自己把答案念出来。布尔盲注Boolean Based。页面不回显数据也不回显错误但可以通过and 11与and 12时页面内容的差异来判断条件真假。通过逐字符比较把数据库信息一位一位地猜出来。时间盲注Time Based。比布尔盲注更极端无论真假页面内容都几乎完全一样。这时候利用sleep()等函数让数据库在条件成立时延迟返回通过响应时间的差异来确认条件是否成立。堆叠注入Stacked Queries。在部分数据库环境中分号可以分隔多条 SQL 语句。攻击者可以在原查询后追加; drop table xxx;这类语句实现多条语句的执行。危害很大但利用条件也高很多数据库驱动默认不允许在一条执行语句里包含多条 SQL。给一张表理清这几个类型的关系注入类型判断条件利用方式适用场景联合查询页面有回显位置union select 语句最优先尝试报错注入页面显示数据库错误updatexml、extractvalue无回显但报错可见布尔盲注真假条件页面有差异逐个字符判断无回显无错误时间盲注真条件延迟返回sleep() 与条件组合页面几乎无差异堆叠注入支持多语句执行分号追加语句危害最大但限制也多2.3 一条通用判断注入点的思路链路我在实战和带新人时总结出一条判断注入点的固定套路适合用在靶场和授权测试场景第一步给参数增加一个单引号看是否报错或页面异常。 第二步输入1 and 11与1 and 12看返回结果是否有差异。 第三步根据差异判断是数字型还是字符型或者是否存在其他闭合方式。 第四步尝试用order by 数字判断查询列数从 1 往上加直到报错为止。 第五步用union select 1,2,3,...找到回显位。 第六步基于回显位查询库名、表名、字段名、数据。这套流程对应到实际靶场里就是热词里 pikachu 靶场通关、ctfshow 入门题 221 这类题目的核心思路。3. 手工注入实战全流程从判断注入点到拿到数据3.1 手工注入的完整思路链路以 pikachu 靶场的字符型注入为例演示一遍完整流程。注意以下操作仅在本地靶场或授权测试环境中进行。打开 pikachu 靶场的“SQL-Inject 字符型注入”关卡存在一个正常的查询入口输入kobe页面会返回对应球员的信息。第一步先输入一个单引号kobe页面直接报错说明输入的引号被拼接进了 SQL 语句破坏了原有语法。第二步判断闭合与真假条件kobe and 11 kobe and 12前者正常返回数据后者返回空说明这里存在字符型注入点我们的输入可以通过引号闭合后修改 SQL 逻辑。第三步用 order by 猜列数。这里表达的是在原有查询基础上增加排序字段kobe order by 2 -- kobe order by 3 -- 注意MySQL 中的--后面必须带一个空格或者用#注释符。当 order by 后面的数字超过实际列数时页面会报错。通过二分法快速定位出当前查询的列数。假设第 3 列报错说明只有 2 列。第四步构造 union 查询确认回显位kobe union select 1,2 -- 如果页面中出现数字 1 和 2说明这两个位置是回显点。接下来就可以用这些回显点查询数据库信息。3.2 一步步爆库、爆表、爆字段、爆数据拿到回显位后接下来的路径非常固定。以 MySQL 为例查询当前数据库名kobe union select 1,database() -- 查询当前数据库下所有表名kobe union select 1,group_concat(table_name) from information_schema.tables where table_schemadatabase() -- 查询指定表中的字段名kobe union select 1,group_concat(column_name) from information_schema.columns where table_nameusers -- 查询数据kobe union select 1,group_concat(username,password) from users -- group_concat()是 MySQL 的一个聚合函数能把多行结果合并成一行用逗号分隔。这在手工注入里非常实用因为大多数注入点在页面上只回显一行内容不用它的话就得用limit一条一条地看。这里补充一个细节为什么查询表名、字段名要依赖information_schema因为它是 MySQL 自带的元数据库里面记录了所有数据库、表、字段的元信息。对攻击者来说这就是数据库的“地图”对防御者来说这也是为什么不能给数据库账号过多权限的原因之一。3.3 手工注入常用的几个核心语句整理一下手工注入过程中必须熟练的几个语句写顺手了基本可以应对大多数靶场题目-- 判断注入点与闭合方式 1 and 11 1 and 12 1 or 11 -- 判断列数 1 order by 1 -- 1 order by 2 -- 1 order by 3 -- -- 判断回显位 1 union select 1,2,3 -- -- 查询库名 1 union select 1,database(),3 -- -- 查询表名 1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase() -- -- 查询字段名 1 union select 1,group_concat(column_name),3 from information_schema.columns where table_namexxx -- -- 查询数据 1 union select 1,group_concat(username,0x3a,password),3 from xxx -- 0x3a是冒号的十六进制表示在 SQL 里充当分隔符这样用户名和密码在回显时就不会糊成一团。这个小技巧看着不起眼实际调试时会帮你省很多事。4. 盲注场景下的手工注入思路4.1 什么是盲注和显错注入怎么区分前面讲的联合查询注入依赖页面回显但在实际渗透测试里真正常见的场景是“页面什么数据都不显示”。你输入合法数据页面返回正常你输入单引号页面也不报错只是返回不正常的内容。这种“看不见答案”的注入就是盲注。题目和真实系统里盲注往往比联合注入更常见因为代码能撑到上线大多做了最简单的错误隐藏但底层 SQL 拼接问题还在。区分是不是盲注一个常用的方法是在参数后面分别拼接and 11和and 12观察页面内容是否一致。不一致大概率是布尔盲注完全一致但响应时间有明显差异可能是时间盲注既无差异又无延迟可能这个点本身不存在注入或者有更复杂的过滤。4.2 布尔盲注通过页面真假来“猜”数据布尔盲注的思路是把查询条件变成一个一个的“是非题”让页面替我们回答“对”或“错”。例如当前用户对应的数据库名第一个字符的 ASCII 码是否大于 1001 and ascii(substr(database(),1,1))100 -- 页面返回正常说明大于再试是不是大于 1101 and ascii(substr(database(),1,1))110 -- 页面返回异常说明小于等于 110。继续二分最后确定第一个字符的 ASCII 码转换成字符就得到了数据库名的第一个字母。接着改substr的第二个参数依次猜第二个、第三个字符直到把整个库名猜完。手工猜字符当然慢但这个过程让我对“盲注的本质就是二分查找”有了很深的理解。在自动化工具里sqlmap跑布尔盲注用的就是这个逻辑只不过把人工二分换成了脚本循环。学会手工你才不会在工具误报时束手无策。4.3 时间盲注用延迟让数据库“说话”如果布尔盲注也行不通比如and 11和and 12页面表现完全一样那只能通过时间差异来判断。MySQL 中可以直接用sleep()1 and if(ascii(substr(database(),1,1))100,sleep(3),0) -- 如果数据库名的第一个字符 ASCII 码大于 100这条 SQL 会执行sleep(3)页面响应时间会明显拉长到 3 秒左右否则立即返回。通过反复比较响应时间就能像布尔盲注一样逐字符猜数据。时间盲注在实际网络中会受网络延迟影响所以判断阈值一定要设置得比正常响应时间大足够多。我在靶场练习时习惯用 3 秒真实环境下建议到 5 秒避免误判。另外测试时间盲注时尽量用curl配合-w %{time_total}看耗时人工盯着浏览器转圈远不如命令行数值直观。4.4 报错注入与常用函数原理报错注入是盲注里相对省力的一种——如果页面会把数据库错误显示出来的话。以 MySQL 为例最常见的是updatexml和extractvalue。它们的原始功能是解析 XML 字符串但传入了非法的 XPath 表达式时数据库会报错并把错误信息返回在页面中。于是人们故意构造1 and updatexml(1,concat(0x7e,database(),0x7e),1) -- concat(0x7e,database(),0x7e)的作用是把数据库名和波浪号拼接起来。因为~不是合法 XPath 路径的起始字符函数执行就会报错错误信息里包含了完整的拼接结果——数据库名就这样被“报”出来了。报错注入能查到数据但要执行多条查询得一条条构造比较繁琐。不少初学者总想找“万能的报错语句”其实没有。不同版本的数据库函数不同过滤规则也不同理解原理比背 payload 有用得多。5. 从真实漏洞看不安全编码的常见成因5.1 一个典型的接口 SQL 注入漏洞长什么样热词里提到的“喰星云·数字化餐饮服务系统 not_out_depot SQL 注入漏洞”是最近比较典型的一类真实企业漏洞。这类系统的代码通常不是新手写的能交付到生产环境说明已经过了功能和验收测试但依然爆出 SQL 注入问题出在哪核心原因几乎总是同一个某个接口接收了前端传入的参数经过很浅的校验后直接拼接进了 SQL。比如String depotId request.getParameter(depotId); String sql SELECT * FROM out_depot WHERE id depotId;这个not_out_depot接口大概率就是类似写法——接收入库单、出库单编号一类的参数开发者认为这个编号是内部系统生成的数字不会有人恶意构造于是既没做类型校验也没用参数化查询。但对外暴露的 HTTP 接口意味着任何人都能提交任意字符串。攻击者提交1 and 11和1 and 12就能测出差异后续就是查库、脱敏数据甚至进一步提权。如果你看过大量真实漏洞报告会发现接近三分之一的注入漏洞来自这种“想当然”的参数。程序员脑子里的假设是“这个参数是我自己系统传的不会有人乱填。”但攻击者根本不看你的页面他直接抓包改请求。5.2 开发阶段“就近改一下”埋下的隐患还有一种很常见的成因是系统演进过程中的“快捷方式”。原本系统用 MyBatis 的#{}参数化方式写得好好的后来某个需求要求排序字段动态传入开发者想省事直接把列名用${}拼了进去。${}和#{}在 MyBatis 里的差异恰好就是 SQL 注入的分水岭。#{}会生成预编译参数占位符用户输入永远只是参数值${}则是直接把字符串拼进 SQL 文本和当年用字符串拼接登录 SQL 没有本质区别。不少团队做代码扫描时发现的问题清单一拉大量都是${}误用还有相当一部分是“之前没想起来要改的预留功能”。所以排查 SQL 注入时除了看当前代码还要重点看历史迭代中哪些地方人为拼过 SQL尤其是排序、批量插入、动态表名这一类追求灵活动态的操作。5.3 修补这类漏洞的通用思路和注意点遇到跑在真实系统上的 SQL 注入漏洞修复顺序和操作要点如下第一确认影响范围。先查这个接口被多少人调用过日志里有没有异常参数数据库里有哪些表可能被拖取。不要一上来就急着改代码先判断有没有数据泄露有没有被植入恶意命令。第二定位所有 SQL 拼接点。不仅修报出来的那个接口还要把同样写法、同类参数的所有接口一并排查。用 IDE 搜索字符串拼接加 SQL 关键字的代码模式或者直接用静态扫描工具扫一遍。第三改成参数化查询。最高优先级是把 SQL 语句改为预编译方式。Java 中用PreparedStatementPython 中%s占位符配合 execute 传参Go 中?占位符。这是换掉病根而不是贴创可贴。第四补充输入校验与最小权限。接口层做白名单校验比如depotId必须是正整数数据库账号尽量只授予业务所需的最小权限堵住利用注入去读information_schema或者写文件的路径。第五灰度验证并观察。修完后先跑一遍正常业务再用注入 payload 做回归验证。别一改完就发版可能漏掉一些边界场景。6. 防御方案别只在输入框上做文章6.1 参数化查询根治 SQL 注入的第一选择SQL 注入的根本原因是“代码与数据不分家”而参数化查询做的事情恰恰是把代码和数据在语法层面彻底分开。以 Python 为例# 错误写法字符串拼接 sql SELECT * FROM users WHERE username username # 正确写法参数化查询 sql SELECT * FROM users WHERE username %s cursor.execute(sql, (username,))第二种写法里%s是占位符数据库驱动会把username作为纯参数值传给数据库而不是先拼接成 SQL 文本再解析。无论用户输入里有什么引号、or、注释符数据库都把它当作一个字符串值来比较根本没有机会变成 SQL 语法的一部分。Java 的PreparedStatement、PHP 的PDO prepare、Go 的database/sql也都有对应的参数化方式。如果只用一句话总结 SQL 注入防御我优先选择凡是用户可控的数据一律不允许直接拼接进 SQL 语句必须走参数化查询。6.2 输入校验白名单永远比黑名单可靠很多人一谈防御第一反应是“过滤关键字”比如把select、union、单引号都过滤掉。这种黑名单思路的最大问题是你根本猜不全攻击者的全部变形手法。大小写绕过、注释符绕过、十六进制编码、双写关键字……随便翻翻安全工具的词库就够过滤逻辑手忙脚乱了。更稳妥的做法是白名单校验先明确这个参数本身应该是什么格式。比如depotId就应该是数字直接在接口层用正则限制成^\d$用户名虽然格式复杂一些也可以限定字符集合和长度。判断规则越明确攻击者越没有发挥空间。当然白名单校验是第二道防线不能替代参数化查询。举个例子如果一个排序字段order_column被白名单校验成只能是create_time或update_time再拼接进 SQL 也不会引入注入但如果第一个参数能过白名单但第二个参数没校验照样出问题。防御纵深从来不是某一层做到位就够的。6.3 权限控制与数据库账号最小化SQL 注入能造成多大危害很大程度上取决于数据库账号的权限。很多项目为了方便直接用最高权限账号连接业务库等于把整个数据库的钥匙挂在门口。一旦注入点被攻破攻击者可以直接into outfile写 WebShell、删除所有表、读取服务器敏感文件。正确的做法是权限分层业务读写账号只具备业务库表的增删改查权限禁止FILE、PROCESS等敏感权限。只读账号供报表、查询类功能使用只允许SELECT。管理账号仅供 DBA 日常运维使用绝不允许被业务代码调用。给权限设限本质上是在假设“所有代码都可能存在漏洞”的前提下把每个漏洞能引爆的最大炸药量控制在最小范围。就算攻击者找到了注入点也会发现连information_schema都查不了连文件都写不了利用链直接断掉。6.4 纵深防御从代码到数据库中间的几道关卡除了参数化查询和权限控制我建议在系统架构层面补上这几道关卡ORM 框架的默认防护使用 MyBatis、Hibernate、Django ORM 等框架时默认生成的查询就是参数化的但这不代表可以随意用原生 SQL。框架本身是盾牌不要主动把它卸了。WAF 或网关过滤在应用层前面加一层 Web 应用防火墙对明显的 SQL 注入特征做拦截。它挡不住所有绕过但能挡住绝大多数自动化扫描和攻击脚本。数据库审计与日志监控开启慢查询日志、审计日志对异常 SQL 特征做监控告警比如大量union select、sleep、information_schema查询。事后发现总比永远不发现好。敏感数据加密存储即使库被拖了密码字段有哈希加盐银行卡号有加密或脱敏也能把损失降到可接受范围。这里说句大实话WAF 不是银弹。攻击者完全可以想出一种 WAF 规则库里没有的编码方式绕过过滤。把防御希望寄托在一个外部产品上不如先把代码层面能确定的变量先做好。7. 常见问题与排查技巧实录7.1 手工注入时最容易卡住的几个点我经常看到初学者在靶场里卡在同一个地方这里整理一份高频卡点清单卡点一order by 报错但 union 也报错。多数情况是前后查询的列数不一致。解决方法是先用order by精确确认列数再用同样数量的union select 1,2,3...构造。注意union前后 SELECT 列数必须完全相同。卡点二union 查询没回显。先确认前面的参数是否限制了结果集为空。在靶场里经常会用到让原始查询查不到数据、走 union 查询结果的技巧比如把参数值写成一个不存在的 id或者用and 12让前面查询返回空集。卡点三注释符没用。MySQL 的--注释符后面必须跟一个空格不然会被认为是普通文本。实际上很多实战环境并不需要注释符闭合引号后继续构造条件即可比如kobe and 11就不需要注释。卡点四页面显示了数据库错误但不显示数据。优先尝试报错注入用updatexml、extractvalue这类函数而不是转过头去死磕联合查询。回显形式决定利用方式报错也是回显的一种。卡点五盲注猜了好久没猜对。可以先猜库名长度再逐字符猜内容这样能少走很多弯路。另外养成脚本思维手工确认前两个字符的规律后直接用脚本循环代替手点效率完全不一样。7.2 授权测试中的行为红线在靶场里怎么操作都行但在真实系统上做安全测试底线必须守牢必须有书面授权。未授权测试是违法行为不管你的初衷是“帮忙看看漏洞”还是“练练技术”。不拖真实数据。确认存在注入点、能读取到少量数据验证危害即可绝不应该把整个用户表拖下来。不做破坏性操作。禁止通过堆叠注入删表、改数据、写文件即使你在测试时也不行。控制测试频率。时间盲注会压测数据库响应单次测试请求过多可能影响正常业务要控制频率。这些红线不仅保护系统也是在保护你自己。做安全测试技术只是基础职业操守和判断力才是立身之本。7.3 开发者自查清单从代码中发现 SQL 注入如果你是开发者想提前排查项目里的 SQL 注入隐患可以从这几个维度自查全局搜索字符串拼接加 SQL 关键字的代码重点看、${}、%s、concat等模式。检查所有order by、group by、动态表名、动态字段名是否用白名单校验。检查数据库账号权限是否只有业务需要的增删改查。查看日志和监控有没有异常的 SQL 查询特征大量union selectsleep()、benchmark()函数information_schema元数据查询单引号密集出现的参数现在很多 CI/CD 流程里已经集成了 SAST 静态扫描能自动识别大部分危险拼接模式。但工具不是万能我最推荐的还是人工 code review 时重点关注两条线一处是“用户输入到 SQL 的传递链路”另一处是“非参数化查询的所有使用位置”。写在最后做了这么多年安全工作踩过不少坑也看过不少被拖库后的复盘报告。SQL 注入这个漏洞从原理上说一点都不难难的是开发者始终在“相信用户输入”的思维惯性里打转。每次看到定位是not_out_depot这种业务接口出现注入漏洞我都不意外——只要还有人图省事直接拼 SQL这个老对手就不会消失。我个人的实操体会是防御 SQL 注入不要寄希望于某个单一手段。参数化查询解决病根白名单校验兜住异常权限控制压缩影响面审计监控负责事后发现。四个环节都做到就算某个环节失效其他环节也能把损失控制住。如果你现在正在写 SQL 拼接代码听我一句劝停手改用参数化。这可能是你今天做的成本最低、收益最高的一次安全投资。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门