先说个很多初学者都会遇到的情况:在网上翻SQL注入教程,跟着文章敲了几个payload,靶场也能打穿,可一旦换个场景、换个参数名,就完全不知道怎么下手了。为什么会这样?因为大部分教程只教了你“背答案”,没告诉你答案是怎么推出来的。
SQL注入这东西,说到底是“数据与代码不分家”惹的祸。你传进去的字符串本该是数据,结果被程序拼进SQL语句里当了代码执行,于是你就能借着SQL语法,让数据库替你干点额外的事。理解了这一层,后面所有的手工注入、盲注、绕WAF,都是同一个思路在不同场景下的变体。
这篇文章我会从最底层的SQL拼接逻辑讲起,把手工注入的完整流程拆开揉碎,再给出真正能落地的防御方案。无论你是CTF选手、渗透测试新手,还是负责业务系统的开发工程师,都能在里面找到能直接用的东西。
1. SQL注入的本质:参数被当成代码执行了
1.1 一段SQL是怎么被“注”进去的
先拿一个最常见的登录场景举例。假设后端代码是这样写的:
$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "' AND password = '" . md5($_POST['password']) . "'";正常用户输入用户名admin、密码123456,拼出来的SQL是:
SELECT * FROM users WHERE username = 'admin' AND password = 'e10adc3949ba59abbe56e057f20f883e'这条语句没毛病,就是按用户名和密码查一下用户表。问题在于,如果有人在用户名一栏输入的不是普通字符串,而是精心构造的SQL片段呢?比如用户名填这个:
admin' --那么拼接后的SQL就变成了:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'e10adc3949ba59abbe56e057f20f883e'在MySQL里,--(注意后面有个空格)是注释符,从它开始到行尾的内容全部会被数据库忽略。也就是说,AND password = '...'这段密码校验逻辑直接被注释掉了,SQL变成了:
SELECT * FROM users WHERE username = 'admin'只要存在用户名为admin的账号,这条查询就能查出记录。如果程序判断“查询结果不为空就判定登录成功”,那攻击者只用知道一个用户名,不需要密码就进去了。
这就是SQL注入最基础的形态——你输入的单引号把开发者原本写好的字符串边界给闭合了,后续输入的SQL片段被数据库当成真正的代码执行。
把这句话记牢:注入的本质,是输入数据跨越了“数据”和“代码”之间的边界。开发者的本意是让你输入一个值,但你输入的内容被当成了指令。
1.2 万能密码为什么总能绕过登录
刚才的admin' --已经算是一种登录绕过,但还有一个更经典的玩法叫“万能密码”,对应热搜词里的“sql注入万能密码绕过”。先看一个最典型的payload:
' OR '1'='1假设用户名输入' OR '1'='1,密码随便填个x,拼出来的SQL是:
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = 'x'这里就要提到SQL运算符的优先级了。AND的优先级高于OR,所以数据库会先算'1'='1' AND password = 'x',也就是TRUE AND FALSE,结果是FALSE。然后再算username = '' OR FALSE,因为username = ''是FALSE,整条语句结果是FALSE。诶,这么一看好像绕不过去?
别急,如果payload稍微调整一下,用户名输入' OR 1=1 --,密码随便填:
SELECT * FROM users WHERE username = '' OR 1=1 -- ' AND password = 'x'OR 1=1是恒真条件,后面全被注释掉了,整条SQL等价于:
SELECT * FROM users WHERE username = '' OR 1=1只要表里有一行数据,这个查询就会返回非空结果集。如果表里有多行数据且程序只取第一行,那登录成功的身份就是表里的第一个用户——往往就是管理员。
这里有两个细节值得注意。第一,很多教程写的' OR '1'='1,其实是配合密码位置还有括号才能生效的写法,严格说不一定通用;真正稳定生效的是带注释符的写法,因为注释能干净利落地切断后面的逻辑。第二,利用点往往不止用户名一个,如果用户名有校验、密码没校验,密码框一样能打。判断到底哪个参数能注入,就看你输入的引号是否破坏了SQL原本的结构。
2. 手工注入标准流程:从探测到拖库的五步方法论
网上很多文章提到“sql注入手工注入”都是一笔带过,好像大家天生就会似的。实际上手工注入是有标准流程的,一步步走,稳得很。这一节我用一个典型场景,带你完整走一遍。
假设目标是一个带文章详情页的网站,URL长这样:
http://example.com/article.php?id=1点击不同文章,数字1会变。这就是最经典的数字型注入点。开始测试。
2.1 第一步:探测注入点,单引号是最便宜的试金石
在id=1后面直接加一个单引号,看看页面反应:
http://example.com/article.php?id=1'如果程序直接把参数拼进SQL且没有做任何处理,数据库会因为SQL语法错误而报错。报错的情况分两种:一种是你能看到具体的SQL错误信息,比如You have an error in your SQL syntax;另一种是页面直接白屏或者500。不管哪种,都说明这个位置可能跟数据库有交互。
但单引号报错只能说明“有交互”,还不够证明一定存在注入。你还需要进一步确认。最常用的验证方法是逻辑判断,在参数后面分别加上and 1=1和and 1=2:
http://example.com/article.php?id=1 and 1=1 http://example.com/article.php?id=1 and 1=2如果第一条页面正常显示文章,第二条页面不正常(空白、无内容、报错),说明你输入的and 1=1确实参与了SQL逻辑运算。能参与逻辑运算,就说明这个位置可以被你控制,注入点确认存在。
注意:在URL里直接敲
and和空格,有的服务器或中间件会拦截。实战中可以把空格写成注释符/**/,也就是id=1/**/and/**/1=1。这个技巧在绕过WAF时特别常用,原理就是MySQL支持用/**/代替空格。
2.2 第二步:判断注入类型,数字型和字符型打法不一样
不同类型的注入点,payload写法差异很大。区分方法其实就一句话:看开发者拼SQL时,参数是否被引号包裹。
数字型拼接代码长这样:
$sql = "SELECT * FROM articles WHERE id = " . $_GET['id'];字符型拼接代码长这样:
$sql = "SELECT * FROM articles WHERE title = '" . $_GET['title'] . "'";判断方法:如果id=1 and 1=1生效,说明你输入的内容被直接当成数值参与运算,这是数字型;如果数字型不生效,试试id=1' and '1'='1,页面正常,再试试id=1' and '1'='2,页面不正常,说明参数是被单引号包起来的字符型。
一句话总结区分办法:
| 测试输入 | 数字型结果 | 字符型结果 |
|---|---|---|
1 and 1=1 | 正常 | 大概率报错或异常(因为SQL里还有引号没闭合) |
1' and '1'='1 | 报错或异常 | 正常 |
1' and '1'='2 | 报错或异常 | 异常 |
字符型注入并没有比数字型高级,只是多了一步“闭合引号”的操作。核心思路就是:先用引号把前面的字符串边界关上,再用注释符把后面多余的内容处理掉。所以字符型注入的通用payload格式是:
参数值' + 注入语句 + -- --- -也是MySQL注释符,相比--(后面跟空格)来说,-- -在URL编码场景下更不容易被截断,实战中用得更普遍。
2.3 第三步:确定字段数量,order by 帮你数清列数
知道了注入点、知道了类型,下一步就是确定查询结果有几列。这一步是联合查询的前置条件,因为联合查询要求前后两个SELECT的列数一致。
用order by来试:
http://example.com/article.php?id=1 order by 1 http://example.com/article.php?id=1 order by 2 http://example.com/article.php?id=1 order by 3order by的作用是按第N列排序。如果N超过了实际列数,MySQL会报Unknown column。比如刚才那条查询实际只有3列,那么order by 3正常,order by 4就报错。这样你就可以确定:这个查询一共3列。
为什么先确定列数这么重要?因为联合查询的语法要求是:
SELECT 1,2,3 UNION SELECT 4,5,6两个SELECT的结果集列数必须相同,否则直接语法报错。所以你得先知道目标查询有几列,后面构造UNION SELECT的时候才能对齐。
2.4 第四步:联合查询,找到回显位置
确定列数之后,把原来的id改成一个不存在的值,比如-1,这样前面的查询结果为空,UNION后面的结果就能“顶上来”显示在页面上:
http://example.com/article.php?id=-1 union select 1,2,3为什么要把id改成-1?因为如果id=1有正常数据,UNION查询会把原来的结果和新查询的结果合并在一起,页面到底显示哪一部分很不可控。把id改成不存在的值,前面查不出数据,页面就只能回显你UNION SELECT的内容。
页面正常回显的话,你会在文章标题、正文、作者等位置看到数字1、2、3中的某几个。比如标题位置显示2,正文字置显示3,那说明这2个位置是可以回显数据的“注入位”。接下来,把对应位置的数字替换成你要查的SQL函数或子查询就行。
比如要查当前数据库版本和当前用户,可以构造:
http://example.com/article.php?id=-1 union select 1,version(),user()然后页面的标题位置就会显示MySQL的版本号,正文位置会显示当前数据库用户。到这一步,基本的联合查询注入就算打通了。
2.5 第五步:从库名到表名再到字段名,逐层拿数据
拿到回显位以后,经典操作顺序是三个步骤:爆库名、爆表名、爆字段名,最后才是拖数据。每一步都用MySQL自带的information_schema元数据库来查。
第一步,爆当前数据库名:
http://example.com/article.php?id=-1 union select 1,database(),3假设得到库名demo_db。
第二步,爆这个库下有哪些表:
http://example.com/article.php?id=-1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schema='demo_db'这里用group_concat把多行表名拼成一行输出,就是为了适配页面上一个回显位只能显示一段内容的情况。假设拿到了users,articles,logs。
第三步,爆users表有哪些字段:
http://example.com/article.php?id=-1 union select 1,group_concat(column_name),3 from information_schema.columns where table_schema='demo_db' and table_name='users'假设拿到了id,username,password。
第四步,拖数据:
http://example.com/article.php?id=-1 union select 1,group_concat(username,0x3a,password),3 from demo_db.users0x3a是冒号的十六进制写法,作用是给用户名和密码之间加个分隔符,方便阅读。这一步执行完,页面就会把所有用户名和密码列出来。
到这里,手工联合查询注入的完整链路就走完了。用一张表把这五步整理一下:
| 阶段 | 目标 | 关键payload |
|---|---|---|
| 探测注入点 | 确认参数可控制 | id=1'、id=1 and 1=1 |
| 判断类型 | 确认数字型/字符型 | id=1' and '1'='1 |
| 确定列数 | 为联合查询对齐列数 | order by N |
| 定位回显位 | 找可显示数据的列 | union select 1,2,3 |
| 提取数据 | 拿库名/表名/字段/数据 | information_schema系列查询 |
3. 没有回显怎么办:报错注入与盲注
联合查询很好用,但前提是页面会把查询结果直接显示出来。现实中很多程序只告诉你“查询成功”或者“查询失败”,什么都不显示。这时候就得换打法。
3.1 报错注入:让数据库把报错信息当喇叭
报错注入的核心思路是:故意构造会让MySQL报错的SQL语句,让数据库在报错信息里把你要查的数据带出来。MySQL里最常用的报错函数是extractvalue和updatexml,原理都是XPath函数接收到非法格式的参数时报错,并把参数内容输出到错误信息里。
以updatexml为例,标准报错注入payload长这样:
http://example.com/article.php?id=1 and updatexml(1,concat(0x7e,database(),0x7e),1)0x7e是波浪号~的十六进制表示。concat(0x7e,database(),0x7e)拼出来的字符串是~demo_db~。updatexml收到这个不以合法XPath开头的字符串,就会报错,报错信息里包含这个字符串内容。于是数据库名就随着报错信息显示在页面上了。
报错注入的效率比联合查询低一点,但胜在不需要回显位。爆表名、爆字段名的思路跟联合查询完全一致,只是把查询语句换到报错函数里:
http://example.com/article.php?id=1 and updatexml(1,concat(0x7e,(select table_name from information_schema.tables where table_schema=database() limit 0,1),0x7e),1)注意,报错信息有长度限制,extractvalue和updatexml通常只能显示32个字符左右,所以group_concat在这里基本用不了,要配合limit一行一行地爆。
3.2 盲注:页面不说话,就让它用“是与否”回答问题
如果连报错信息都被程序吞掉了呢?那就用盲注。盲注分两种:布尔盲注和时间盲注。
布尔盲注针对的场景是:页面不会显示数据,但会根据SQL条件成立与否,在页面内容上产生差异。比如id=1 and 1=1页面正常,id=1 and 1=2页面空白。这就是一个“是/否”的判断管道。
接下来就用这个管道逐个字符地猜数据。比如猜当前数据库名的第一个字符是不是a:
http://example.com/article.php?id=1 and substring(database(),1,1)='a'如果页面正常,说明第一个字符确实是a;如果页面空白,就换下一个字符继续猜。这是纯手工最枯燥的部分,所以实战中盲注基本都交给工具脚本跑,但原理你得懂,后面遇到奇怪的过滤规则时,才能自己写出对应的脚本。
时间盲注针对的场景更极端:页面不管条件真还是假,显示内容完全一样。这时候判断依据的载体从“页面内容”变成了“响应时间”。MySQL里最常用的函数是sleep,配合if判断:
http://example.com/article.php?id=1 and if(substring(database(),1,1)='a',sleep(3),1)如果猜对了,数据库执行sleep(3),页面响应时间会明显变慢;如果猜错了,语句瞬间执行完,响应很快。通过响应时间差,同样能一点一点把数据猜出来。
盲注的本质是“二分法”思维,系统性地做判断,每轮能把范围缩小一半。虽然效率低,但它是所有注入类型里存活率最高的——只要有时间响应,数据就跑不掉。
4. 防御方案:参数化查询才是底牌
前面花了大量篇幅讲怎么攻,接下来讲怎么防。这部分内容,开发工程师请重点看。网上很多“SQL注入防御方案”文章翻来覆去就讲三件事:过滤关键字、转义特殊字符、用WAF。这些都不是根治方案,只能增加攻击成本。真正从根上解决问题的,只有一个——参数化查询,也叫预处理语句。
4.1 预处理语句为什么能免疫SQL注入
先看看参数化查询的代码长什么样。以PHP的PDO为例:
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND password = :password"); $stmt->execute([ ':username' => $username, ':password' => md5($password) ]);以Java的PreparedStatement为例:
String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setString(1, username); pstmt.setString(2, md5(password)); ResultSet rs = pstmt.executeQuery();你没看错,就是“提前把SQL骨架发给数据库,告诉它:这里有俩占位符,待会儿我会传值过来”。数据库收到这条SQL后,会先完成编译,把:username和:password的位置固定为“数据参数”,之后传进来的任何值都只是数据,不会再被解析成SQL代码。
这就是参数化查询免疫SQL注入的根本原因:它从语法层面切断了“数据”和“代码”的混合。你输入admin' --,在参数化查询里它就是一个完整的字符串,会被当成用户名去匹配,永远不可能变成注释符去截断SQL。
网上还有一种说法:“用mysqli_real_escape_string转义一下就行了。”这个说法不严谨。转义确实能处理单引号、双引号、反斜杠等字符,但它的有效性严重依赖数据库字符集配置,一旦遇到宽字节注入,转义就形同虚设。真正靠谱的方式永远是参数化查询。
其他语言的对应方案也列出来,开发时直接照着用:
| 语言/框架 | 参数化方案 |
|---|---|
| PHP | PDO::prepare + execute,或mysqli的预处理语句 |
| Java | PreparedStatement |
| Python | sqlite3.execute参数绑定、psycopg2的%s占位、SQLAlchemy ORM |
| Go | database/sql的?占位符 |
| Node.js | mysql库的?占位符、Sequelize/Knex ORM |
4.2 纵深防御:输入校验、权限控制、错误隐藏三板斧
参数化查询是底线,但现实中代码是人在写,人总有疏忽。所以只靠参数化还不够,要按纵深防御的思路,把每一层都加固一下。
第一板斧:输入校验。参数该是数字的,就严格校验数字格式。可以用类型转换、正则白名单,比如id参数用intval()转成整数,比任何过滤都干净。对于字符串参数,可以校验长度、校验格式范围,不要把校验寄托在“黑名单”上。记住一个原则:白名单永远比黑名单可靠。黑名单你要枚举所有攻击载荷,永远有遗漏;白名单你只允许合法格式,攻击载荷自然进不来。
第二板斧:最小权限原则。给应用配置数据库账号时,只授予必要的权限。比如一个只需要查询文章的应用,就只给SELECT权限,千万不要用root或者具有FILE、INTO OUTFILE、super权限的账号连数据库。这个原则能防止攻击者在注入成功后进一步拖库、写WebShell、提权。很多做了参数化但还是被攻击的案例,问题都出在数据库账号权限过大上。
第三板斧:隐藏错误信息。数据库报错信息是攻击者的导航仪。线上环境务必关闭详细的SQL错误展示,统一返回友好错误页。这一点配合报错注入的场景最好理解——你连报错详情都看不到,updatexml报错注入基本就废了。
4.3 过滤方案为什么靠不住
这一节专门聊聊很多团队还在用的“黑名单过滤”方案,方便你回头审视自己的代码。常见的过滤做法是拦截这些关键字:select、union、update、and、or、sleep、单引号等等。
从攻击者视角看,这些过滤可以绕过。大小写绕过:Union Select。URL编码绕过:%27代替单引号。注释绕过:sel/**/ect。内联注释绕过:/*!50000SELECT*/。双写绕过:selselectect。十六进制编码绕过:把字符串写成0x...。当过滤规则变复杂,比如加了更严格的关键字匹配,攻击者还能用报错注入、堆叠注入、DNSLog外带等不需要这些关键字的办法绕过。
所以我的建议一直很明确:不要把安全寄托在过滤上。过滤可以做,但它只是增加攻击门槛的辅助层,不是防御的主干。主干必须是参数化查询加最小权限加错误隐藏这个组合。
写到这里,我把防御侧的优先级整理成一个清单,方便你对照检查:
- 第一优先级:所有SQL操作全部改造成预处理语句/参数化查询,没有任何例外
- 第二优先级:最小化数据库账号权限,严格按业务所需分配增删改查权限
- 第三优先级:关闭生产环境详细报错信息,统一错误页面
- 第四优先级:输入参数做白名单校验,数字就强转数字,字符串限长限格式
- 第五优先级:预留一份SQL日志审计,方便事后追踪异常查询
5. 踩坑记录:实际操作里最容易被绊倒的地方
最后这部分,分享一些我自己踩过的坑,以及平时帮读者看代码时频繁发现的问题。有些属于实战技巧,有些属于开发习惯,放在一起方便你一次性扫雷。
5.1 手工注入时最常见的坑
第一,在URL里直接敲#注释符会丢掉后面的参数。#在URL中是锚点标识,浏览器根本不会把它发到服务器。所以URL注入时注释符要么URL编码成%23,要么换成--或-- -。这一点新手几乎必踩。
第二,order by的列数上限不等于回显列数。order by只能告诉你“第几列不存在”,如果字段数很多,逐次尝试太慢。可以用二分法快速定位边界,正常情况下几十次请求就能测出来。
第三,字符型注入点一定要记得闭合引号。很多人判断出注入点,直接套数字型的payload,结果怎么打都不通。回到 2.2 节那个表格,先确认类型再选payload,能省一大堆无用功。
第四,联合查询时前面查询的结果集不是空的话,页面显示很混乱。所以要把原始条件改成不可能命中的值,id=-1就是最常用的手法。不这样做,你联合查询的结果大概率被原始数据淹没。
5.2 登录绕过场景里的坑
万能密码能绕过的前提是,后端代码用的是字符串拼接,且登录判定逻辑看的是“查询结果是否为非空”。如果代码改成先取用户,再去单独校验密码哈希,万能密码就不灵了。
绕过登录框还有一个容易忽略的点:很多登录框实际上有多个参数参与拼接,比如用户名、密码、验证码。如果一个参数有后端校验,另一个没有,就转打没有校验的那个。审计代码时要逐个参数去看。
5.3 防御落地时容易被忽略的细节
很多团队说“我们用了预编译”,但漏了几个边角。第一,预编译只对SQL语句中的数据部分有效,表名、列名、ORDER BY后面的排序字段是拼不进去的,这部分必须单独走白名单校验。比如排序字段,就只能允许asc或desc,字段名必须匹配预设列表。第二,动态构造SQL的场景,比如后台管理系统的复杂筛选功能,很容易因为“不好用预处理”而退回拼接,这是最危险的重灾区。第三,使用ORM框架不等于绝对安全,尤其是那些允许写原生SQL查询的接口,写的人一不注意就踩回拼接老路。
最后再说一个我平时检查代码时的习惯:全局搜代码里所有的$_GET、$_POST、request参数接收点,找到之后一个一个往下游追,看这些值最终去了哪里。如果发现它们被拼进了SQL字符串,不管有没有过滤,都先标记出来,再逐个评估修复方案。这个方法虽然土,但效率极高,能在代码审计时快速定位注入面。
SQL注入这个老话题,之所以到现在还有这么多漏洞爆出来,根子不在技术难度,而在“知道但没做到”。手工注入的流程你练两天就能上手,参数化查询的改造你花一个下午就能把代码全部改完,真正难的,是让每个开发者都从心底里承认:用户输入永远是不可信的,每个数据接口都可能是攻击面。把这句意识刻进骨子里,再配合这一整套标准流程,无论是去靶场刷题,还是回头加固自己的业务系统,你都不会再觉得无从下手。