ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

SQL注入深度剖析:从万能密码原理到参数化查询防御

SQL注入深度剖析:从万能密码原理到参数化查询防御 很多人对SQL注入的理解始终停留在那句“ or 11”能绕过登录的江湖传说上。这句话没有错但它只是整头牛身上最显眼的一块腱子肉。真正把SQL注入吃透的人会像庖丁解牛一样目光扫过输入框看到的是后端SQL语句的拼接结构手指敲下几个字符心里已经预判了数据库会如何解析这些符号。这篇文章想做的事就是把SQL注入这头牛从皮到骨拆一遍——从漏洞产生的根因到万能密码的底层逻辑再到显错注入的完整利用链路、高级过滤的绕过思路最后落到真实漏洞案例和防御方案上。无论你是CTF选手、安全测试工程师还是被甲方要求修漏洞的后端开发都能从中拿到点能直接用的东西。1. 注入最先发生的地方从SQL语句的“拼装”说起1.1 数据库查询的本质与那条“命门”要说清楚SQL注入得先承认一个事实几乎所有SQL注入漏洞都源自同一个错误——把用户的输入当成可执行代码直接拼进了SQL语句里。正常的SQL查询是什么它是数据库能理解的一套结构化指令比如SELECT * FROM users WHERE username admin AND password 123456;数据库执行这条语句时admin和123456是被当作字符串字面量处理的。它的语义就是去users表里找username字段值是admin、password字段值是123456的那条记录。问题出在很多早期开发的代码喜欢用字符串拼接的方式去构造SQL。后端拿到用户提交的username和password直接把它们塞进SQL模板里。如果用户提交的用户名不是admin而是admin --那拼出来的SQL就变成了SELECT * FROM users WHERE username admin -- AND password 123456;这个--是MySQL的注释符它后面所有的内容都会被数据库忽略。于是查询条件从“用户名等于admin且密码等于123456”悄悄变成了“用户名等于admin”。整个WHERE子句的语义被彻底改写。这就是SQL注入的根源你往一条本应是“数据”的位置塞进了“代码”而数据库忠实地执行了这段代码。可以这样类比你去餐厅点菜菜单上写着“宫保鸡丁”结果你告诉服务员“菜里不要放花生另外把隔壁桌的菜单拿来我看看”——服务员如果真按你说的去拿隔壁桌菜单后厨的逻辑就被你干预了。但SQL注入比这更直接你点宫保鸡丁时把“吃完不付钱”也写在菜名里而后厨按这个菜名做了个奇怪的菜。1.2 一个最简单的注入点是怎么被拼出来的来看一段非常典型、在很多老项目中依然存在的PHP登录代码$username $_POST[username]; $password $_POST[password]; $sql SELECT * FROM users WHERE username $username AND password $password; $result mysql_query($sql);假设用户输入username: admin -- password: 任意值拼出来的SQL是SELECT * FROM users WHERE username admin -- AND password 任意值--后面的 AND password 任意值直接被注释掉了查询条件只剩username admin。如果users表里有admin这条记录查询就会返回结果。而后端代码的常见写法是“查到了记录就认为登录成功”于是攻击者在不知道密码的情况下以admin身份登录成功。这就是“万能密码”的雏形。它根本不需要你输入什么牛掰的密码只需要注入一个注释符把密码校验条件从SQL语句里“撕掉”。1.3 为什么单引号是那条撬开一切的缝隙细心的朋友会发现上面所有注入成功的前提都是后端代码把用户输入用单引号包了起来。单引号在SQL里是字符串的边界符号它意味着“从这里到另一个单引号之间都是数据”。攻击者提交一个单引号本质上是在试探这个单引号会不会逃逸出字符串边界变成一个语法角色比如提交username 1如果后端拼出来的SQL变成SELECT * FROM users WHERE username 1注意有两个连续单引号。数据库解析时会发现字符串没有正常闭合于是报语法错误。如果页面把数据库报错信息直接回显出来攻击者就拿到了一条关键情报这里的输入会进入SQL语句而且报错信息可以被看到。前者意味着可注入后者意味着可显错利用。很多人的SQL注入学习卡在了“只会用万能密码”的阶段就是因为他们没有理解单引号这个“语法角色切换器”的底层含义。掌握了这一层你再看1 AND 1 1、1 OR 1 1、1) OR (11这些payload就不会觉得它们是死记硬背的咒语而是一个个能精确控制SQL语义的零件。2. 万能密码的真相绕过登录的“身份证逻辑”2.1 后台登录校验代码的常见形态在深入各种万能密码变体之前先明确后端校验逻辑通常长什么样。两类最常见第一种是“查记录”形态就是上面提到的$sql SELECT * FROM users WHERE username $username AND password $password; $result mysql_query($sql); if (mysql_num_rows($result) 0) { // 登录成功 }只要查询返回的记录数大于0就放行。这种形态下攻击者的目标非常明确让这条查询在不知道密码的情况下也能返回记录。第二种是“先查用户再比对密码”形态$sql SELECT * FROM users WHERE username $username; $row mysql_fetch_assoc($result); if ($row password_verify($password, $row[password])) { // 登录成功 }这种形态下注入admin --虽然能让第一条查询返回admin的记录但后面还有password_verify的校验简单注释符就失效了。这时攻击者需要换思路比如用union select构造一个自己完全可控的查询结果让返回的“password”字段值等于自己输入的密码的哈希值。理解这两种形态的区别很重要因为它决定了你手头该用哪一套注入姿势。很多人拿“万能密码”去测一个网站失败不是注入不存在而是登录校验逻辑不是第一种形态。2.2 or 11为什么能绕过这是最经典的万能密码变体很多初学者其实并没完全吃透它。假设输入username: or 11 password: 随便拼出来的SQL是SELECT * FROM users WHERE username or 11 AND password 随便问题来了AND的优先级高于OR所以这个WHERE子句会被解析为WHERE (username OR 11) AND (password 随便)逻辑上username 可能是假但11恒为真所以括号里的OR条件是成立的。整句话又被AND了一个password 随便如果数据库里没有password为“随便”的记录查询结果还是空。诶那为什么很多文章说 or 11能绕过登录因为实际应用中password字段也可能是直接拼在同一个WHERE里的如果构造为username: or 11 -- password: 随便SQL变成SELECT * FROM users WHERE username or 11 -- AND password 随便注释符把后半段密码校验条件完全切掉WHERE子句等价于WHERE username OR 11恒真条件成立于是数据库把整张users表都返回了第一条记录大概率是管理员账号登录成功。严格来说 or 11不注释密码条件时并不总能成功真正稳的是它的注释版本。很多文章为了简化表达省略了--导致初学者在靶场上复现时经常对了又不对原因就在这里。2.3 万能密码家族注释、恒真、空密码的变体在实战和CTF中常见的万能密码变体可以归成几类每一类的原理都不同类型典型Payload核心原理注释符截断admin --、admin #用注释符吞掉后面的密码校验条件恒真条件 or 11 --、 or 11 --让WHERE条件永真返回全表记录空密码与OR组合admin OR 11结合AND优先级有时需要闭合前引号UNION查询 union select 1,admin,hash --完全控制查询结果适用于先查用户后比对密码的逻辑反引号/括号闭合admin) or (11匹配后端SQL中的括号包裹方式每一种变体都有它的使用前提。比如遇到后端SQL是WHERE username $username AND password $password用admin --最简单遇到WHERE (username $username AND password $password)就得先闭合右括号用admin) or (11 --。我在带新人时经常说一句话不要背万能密码的字符串要背SQL语句的结构判断法。你看不懂后端到底是什么SQL形态测一万个万能密码都是瞎蒙。2.4 从绕过登录到越权同一个漏洞的两种危害绕过登录只是SQL注入最表层的危害。同一个拼接漏洞换一种攻击思路危害等级会迅速上升。比如用户登录后URL参数里有个?id1001用来查看自己的订单详情后端SQL是SELECT * FROM orders WHERE id 1001 AND user_id 5;如果id直接拼接注入攻击者可以把id改成1001 or 11WHERE子句中user_id 5的限制被绕过所有用户的订单全部暴露。这就是越权漏洞。再比如登录接口拼了SQL攻击者还能通过union select读出数据库里其他表的数据比如管理员密码、用户手机号、支付流水。SQL注入从一个“认证绕过”直接升级成“数据泄露”。这个从“绕过登录”到“越权/拖库”的认知跨越是SQL注入学习中最重要的一步。它意味着你不再把SQL注入看作一个登录破解小技巧而是理解为一个能操纵数据库查询语义的通用攻击手段。同一个漏洞点攻击者的想象力有多大危害边界就有多大。3. 显错注入的完整利用链路以靶场环境为例3.1 环境准备本地靶场的搭建纸上谈兵没有手感强烈建议先在本地靶场里把整个利用链路走一遍。推荐sqli-labs它把SQL注入的常见场景拆成了几十关从最简单到最复杂一关一关练下来基本功会扎实很多。搭建方式很简单# 需要PHP、MySQL环境也可以用Docker docker run -d --name sqli-labs -p 8080:80 acgpiano/sqli-labs:latest打开http://localhost:8080就能进入靶场页面。第一关就是典型的显错注入非常适合用来演示完整的利用链路。还有一个很有用的选择是本地单独跑一个测试数据库配合自己写的一段查询脚本这样每一步报错长什么样、返回结果长什么样都看得清清楚楚。我练习时习惯同时开两个窗口一个浏览器看请求结果一个MySQL客户端直接执行改造后的SQL对照观察。3.2 第一步单引号探针判断是否存在注入以sqli-labs第一关为例URL结构通常长这样http://localhost:8080/Less-1/?id1页面会显示id1的用户名和密码。先做最基础的探针http://localhost:8080/Less-1/?id1页面直接报SQL语法错误而且关键的是报错信息里带着SQL语句的片段You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 1 LIMIT 0,1 at line 1这段报错暴露了两条重要信息后端确实把id参数拼进了SQL语句注入点两侧用了单引号包裹并且错误回显直接暴露在前端页面上看到这个报错基本可以确定这是一个MySQL数据库的显错型注入点接下来所有操作都可以在页面上直接看到结果不需要猜。3.3 第二步用order by 探测字段数知道存在注入点后第一步是探测这条SELECT查询返回几列。方法是用order byhttp://localhost:8080/Less-1/?id1 order by 3 -- -页面正常返回说明查询结果至少有3列。继续试探http://localhost:8080/Less-1/?id1 order by 4 -- -如果页面报错“Unknown column 4 in order clause”说明查询结果只有3列第四列不存在。为什么探测字段数这么重要因为接下来使用union select时联合查询的列数必须与原始查询的列数一致否则数据库会报错。列数探测相当于给后续的注入操作打地基。3.4 第三步union select 显错定位回显位列数是3接下来用union select构造联合查询http://localhost:8080/Less-1/?id1 union select 1,2,3 -- -如果页面上显示了数字“2”和“3”说明原始查询的第二个和第三个字段位置可以在页面中回显。这两个位置就是我们的“广播位”——后续想把数据库里的数据放到页面上就把数据放在2号或3号位。这里有个小技巧构造payload时经常让最前面的查询返回空比如id-1 union select 1,2,3 -- -这样原始查询没有记录返回页面显示的内容完全以union select构造的查询为准回显位置更干净、更清晰。3.5 第四步爆库、爆表、爆字段、爆数据拿到回显位后就是标准的“四步走”数据提取。先把当前使用的数据库名拿到http://localhost:8080/Less-1/?id-1 union select 1,database(),3 -- -页面2号位显示security这是当前数据库名。接着爆表名用MySQL自带的information_schema元数据库id-1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schemasecurity -- -页面会列出security库下所有表名比如emails,referers,uagents,users。然后爆users表的字段id-1 union select 1,group_concat(column_name),3 from information_schema.columns where table_schemasecurity and table_nameusers -- -拿到字段名id,username,password。最后爆数据id-1 union select 1,group_concat(username,0x3a,password),3 from users -- -页面2号位会显示所有用户名和密码的拼接结果。0x3a是冒号的十六进制表示用来在拼接字段之间加上分隔符便于阅读。整个链路走下来从发现注入点到拖出整张用户表核心就四步探针确认存在性 → order by探测列数 → union定位回显位 → 爆库表字段数据。每一步的操作都有它的逻辑依据不是为了炫技而是每一个动作都在回答一个明确的问题这个查询到底长什么样、我能控制哪些位置、数据往哪儿放才能被看见。3.6 显错注入与盲注的分水岭上面这套流程成立的前提是页面能把SQL报错和查询结果显示出来。如果在靶场里把报错关掉、页面只显示“查询失败”或干脆空白显错注入就玩不转了这时只能走盲注。盲注的思路是页面不直接告诉结果但会根据SQL查询条件的真假返回不同的状态比如正常页/空白页或者登录成功/失败。攻击者就靠这个状态差异一个字符一个字符地猜数据。经典的盲注payload长这样id1 and ascii(substr(database(),1,1))100 -- -如果页面正常说明数据库名的第一个字符的ASCII码大于100页面空白则说明小于等于100。二分法反复试探几个请求就能确定一个字符。盲注的效率虽然低但在真实渗透测试中反而更常见——因为稍微有点安全意识的后端都至少会把报错关掉。所以学SQL注入不能只会显错链路盲注的基本功也得练。4. 高级过滤下的“庖丁刀法”绕WAF的思路4.1 高级过滤都在过滤什么如果靶场告诉你“这一关做了过滤”意思通常是后端代码对用户输入做了一些关键词或字符的拦截。常见的过滤目标过滤空格把空格替换为空导致select 1 from users变成select1fromusers过滤关键字把select、union、from、information_schema等直接删掉或替换过滤注释符把--、#、/* */拦截掉过滤特殊符号比如单引号、等号、逗号、括号这些过滤单看都有道理但组合起来往往就有绕过空间。因为过滤本质上也是“代码在处理用户输入”只要有黑名单思维就一定有考虑不周的地方。4.2 大小写、注释符、内联注释最简单粗暴的绕过是大小写变形UnIoN SeLeCt 1,2,3这招只对“严格匹配小写关键词”的过滤规则有效现在的正则过滤基本都加了i标志大小写这招就不太管用了。稍微进阶一点的是用注释符切割关键字UN/**/ION SEL/**/ECT 1,2,3如果后端只做了简单的str_replace(union,)而没有递归清理这种内联注释就能把拼接后的SQL还原成UNION SELECT。MySQL还支持一种特殊的内联注释写法注释符内部带感叹号时内容会被当作SQL正常执行/*!50000UNION*/ /*!50000SELECT*/ 1,2,3这种写法在绕过某些按“注释符”正则拦截的规则时特别好使因为正则匹配到/*以为后面全是注释直接放行了但MySQL实际执行时却把!后面的内容当真代码处理。4.3 编码绕过URL编码、十六进制、宽字节另一种常见思路是编码错位。URL编码绕过后端如果只对明文关键词做过滤但数据库在收到请求时会自动做一次URL解码那注入%27代替单引号就可能在过滤时漏掉。宽字节注入是这类思路里比较经典的一种。它出现在使用GBK编码且开启了magic_quotes_gpc或类似转义机制的老式PHP系统中。这类系统会把单引号转义成\让注入者没法闭合字符串。但如果在单引号前加一个%bf拼进去后%bf\在GBK编码下会被解析成一个合法的中文字符反斜杠被“吃掉”单引号逃逸出来id1%bf%27转义后的SQL变成WHERE id 1縗%bf\被当成一个两字节中文后面的单引号成功闭合了字符串。这就是宽字节注入的底层逻辑编码转换时的字节歧义导致转义符失效。十六进制编码主要用于绕过对引号的过滤。如果后端过滤了单引号但查询条件是数字型id可以直接把字符串用十六进制表示避免使用单引号SELECT * FROM users WHERE username 0x61646d696e0x61646d696e就是admin的十六进制编码。这个技巧在利用information_schema时尤其有用可以避开所有对字符串常量的引号过滤。4.4 等价替换关键字变形再往深一层就是等价替换的思路。过滤了空格可以用/**/代替也可以用小括号隔断SELECT(id)FROM(users)WHERE(id1)过滤了等号可以用like、in、regexp代替WHERE username LIKE admin WHERE id IN (1,2,3) WHERE username REGEXP ^admin$过滤了information_schema可以尝试MySQL的其他元数据来源sys.schema_auto_increment_columnsmysql.innodb_table_stats直接爆库名.表名而不查information_schema这些替代路径不见得每次都能通但它们体现了一个重要的思维方式SQL语法本身是一个巨大的语法树过滤规则只砍掉了其中几根枝条不代表整棵树就死了。4.5 一个典型的“简单WAF”绕过示例用一个组合场景来演示绕WAF的完整思路。假设后端做了这些过滤过滤空格过滤union、select等关键字过滤单引号目标是一个数字型注入点页面显错。我们需要绕过去探测数据库版本。先绕空格id1/**/union/**/select/**/1,2,3但union和select被过滤了所以用内联注释切割id1/**/un/**/ion/**/sel/**/ect/**/1,2,3如果后端对union做了递归替换把union删掉后再检查一遍这种切割会被二次清理掉。这时换等价思路不用union select改用报错注入利用updatexml函数制造报错id1/**/and/**/updatexml(1,concat(0x7e,version()),1)页面报错信息里会带出版本号。整个过程没有用到union、select、单引号绕过了全部三道过滤。所以绕WAF的核心不是死记多少payload而是理解WAF在文本层拦截数据库在语义层执行。只要最终到达数据库的SQL语义是正确的中间用什么写法表达完全可以随机应变。5. 真实世界中的“庖丁”视角谁家的牛难切5.1 一个真实漏洞点的复盘把目光从靶场拉回现实。安全圈最近被提到的“亿赛通电子文档安全管理系统CDGAuthoriseTempletService1接口存在SQL注入漏洞”就是一个非常典型的真实案例。这类系统在企业里通常用于机密文档的安全管控属于权限敏感型系统在暴露面比较少的业务域里运行。但问题是这类系统往往只做了访问控制的验证接口内部却缺乏参数校验。CDGAuthoriseTempletService1这个接口被爆出SQL注入漏洞本质上就是接口接收的参数直接进入了SQL拼接没有走参数化查询。真实漏洞和靶场的核心区别在于靶场为了教学把SQL语句形态、报错信息设计得清晰可见真实系统里你遇到的往往是一个完全黑盒、甚至没有任何报错回显的接口。你可以用同样的order by探测、union select尝试但每一步都得靠盲注的耐心去判断。5.2 为什么系统接口比登录框更危险很多高校在教SQL注入时总拿登录框举例。但真实世界里登录框往往是防护做得最严的地方——有验证码、有锁定策略、有WAF规则。真正容易出问题的是那些不起眼的后台接口。原因很简单登录框是“门面”开发时会精心设计而后台接口是“管道”开发时只关心功能是否跑通。像CDGAuthoriseTempletService1这种接口名字没有暴露语义可能只是第三方SDK默认生成的服务接口开发人员根本不会在代码审计时重点看它。但这类接口一旦直接连通数据库拼装SQL就是一头肥牛——攻击者不需要过登录关卡直接打接口就能拖数据。这个观察给防御者的启示是代码审计不能只盯着用户入口更要覆盖所有服务接口。做渗透测试也一样登录框打不进去的时候把抓包工具打开把系统里发的每一个请求都看一遍那些不起眼的接口往往比主入口更容易突破。5.3 绕不过去的修复方向参数化查询不管漏洞形态怎么变修复方向基本是固定的。厂商在修复这类接口时最标准的做法是把字符串拼接改成参数化查询PreparedStatement。拿Java举例// 漏洞代码 String sql SELECT * FROM users WHERE id request.getParameter(id); Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(sql); // 修复代码 String sql SELECT * FROM users WHERE id ?; PreparedStatement pstmt conn.prepareStatement(sql); pstmt.setString(1, request.getParameter(id)); ResultSet rs pstmt.executeQuery();参数化查询的核心在于SQL语句的结构在发送到数据库之前就已经确定用户输入只作为参数值传递永远不会被解析成SQL语法的一部分。单引号、注释符、union select在参数化查询里统统退化为普通的字符串内容不再具备改变查询语义的能力。这也是为什么所有防御文章都强调参数化查询是SQL注入最有效的通用防线。它不依赖过滤规则不依赖关键字黑名单而是从根源上切断了“数据”变“代码”的通道。6. 防御才是终点参数化查询与白名单6.1 参数化查询为什么是正解前面用大量篇幅讲了攻击侧的“庖丁解牛”但真正的解牛高手最后得懂得如何让牛不受伤。防御SQL注入首先要明白参数化查询到底改变了什么。拿PHP PDO来对比// 不安全的拼接写法 $stmt $pdo-query(SELECT * FROM users WHERE username $username); // 参数化写法 $stmt $pdo-prepare(SELECT * FROM users WHERE username ?); $stmt-execute([$username]);第一种写法里$username的内容会变成SQL逻辑的一部分第二种写法里?是一个占位符数据库拿到这条语句时结构已经确定从users表查询username等于某个值。用户输入在经过MySQL通信协议传输时被作为独立参数传递SQL解析器永远不会把参数内容当成语法指令来解析。这就是参数化查询的底层逻辑它不是把危险字符过滤掉而是让危险字符根本没有机会成为“SQL字符”。任何过滤规则都可能有漏网之日但参数化查询在架构上彻底关闭了注入通道。6.2 输入验证与输出编码的边界参数化查询也不是万能药它主要防“查询语义被改写”。但在某些场景中查询条件无法参数化比如动态排序字段、动态表名这类需求无法用占位符实现必须白名单校验。白名单校验的思路是不判断“哪些字符不能出现”而是判断“输入值必须在预期范围内”。String sortColumn request.getParameter(sort); String[] allowList {id, username, created_at}; if (!Arrays.asList(allowList).contains(sortColumn)) { throw new IllegalArgumentException(Invalid sort column); }这种写法比任何过滤规则都可靠因为它只允许预设值其他一律拒绝。还有一类场景是“输出编码”。SQL注入本质上是数据和代码的混淆在HTML中也有类似的混淆问题那就是XSS。如果SQL查询出来的数据需要展示在网页上还必须对输出做HTML编码避免从数据库里读出的内容携带可执行的脚本。防御是一个链路的工程只堵住注入点不堵住输出点攻击者还是可以绕一圈完成攻击。6.3 三层防线数据库权限、应用防火墙、代码审计很多团队问我代码里到处是SQL拼接历史包袱太重一时半会改不完怎么办我的建议是分三层防御即使代码层暂时有漏其他层也能兜底。第一层是数据库权限管控。应用连接数据库时绝不使用root或dba级别的高权限账号。把账号权限缩小到只允许访问业务所需的库表甚至只允许SELECT权限。这样即使注入成功攻击者能操作的边界也被限制住了。现实中的“拖库”往往是因为应用账号权限过大注入了into outfile写个webshell直接拿下服务器。第二层是Web应用防火墙WAF。在代码层修复之前先在网络层加规则对包含知名攻击特征的请求做拦截。WAF不是万能的但对自动化批量扫描攻击有很好的抑制效果。部署WAF后还要定期看拦截日志分析那些被拦截的payload反推业务真实存在的漏洞及时修补。第三层是代码审计和SDL流程。把SQL注入的检查点前置到开发阶段代码评审时重点关注字符串拼接SQL的写法自研ORM框架时强制走预编译API上线前用自动化扫描工具过一遍。安全不只是安全团队的事得变成研发流程的自然环节。6.4 实践中的加固清单如果现在让你去检查一个老项目可以从这张清单开始逐项排查检查项判断标准风险等级SQL语句是否全部使用预编译API搜索createStatement、拼接SQL的写法和地方高动态排序/表名是否走了白名单排序字段是否硬编码为有限集合中数据库账号权限是否最小化业务账号是否有file、grant等权限高报错信息是否封闭生产环境是否关闭SQL错误回显高关键接口是否接入WAF/日志审计敏感接口的SQL注入尝试能否被记录中前端输入是否在所有入口统一校验是否每个接收参数的接口都做类型/长度校验低我把这张清单用在实际代码审计里经常能发现“改了一半”的项目主登录接口用了预编译但某个老接口还是字符串拼接生产环境关掉了报错但测试环境开着调试模式并连了生产数据库权限缩小了但还留着select ... into outfile权限。漏掉这些边角等于门户锁好了却在围墙上留了一扇没锁的窗。修复SQL注入的过程与其说是“补漏洞”不如说是“恢复边界”区分哪些是数据、哪些是代码并在架构层面守住这条线。攻击者的payload再怎么千变万化绕来绕去踩的永远是这条边界上出现的缺口。把边界补牢固大部分攻击连门槛都摸不到。
返回列表