本文首发于我的个人博客:https://by-musi.github.io/
Hack to learn. Hacking for fun.
我在前两天发布了一篇 SSRF 基础介绍 的文章,今天再分享一个案例,是一篇 hackerone 2019 年的报告,讲述了如何通过 DNS 重新绑定(DNS rebinding)绕过 ip 验证,从而执行 SSRF 攻击获取敏感信息。
报告链接:HackerOne report 541169: GitLab::UrlBlocker validation bypass leading to full Server Side Request Forgery。
Greg 在一个视频中也解释了这个漏洞。
如果你想简单直观地理解 DNS rebinding 绕过 SSRF,可以看 Lostsec 的这个视频:SSRF Bypass by DNS Rebinding | Bug bounty poc
1 漏洞描述
The
GitLab::UrlBlocker
IP address validation methods suffer from a Time of Check to Time of Use (ToCToU) vulnerability。The vulnerability occurs due to multiple DNS resolution requests performed before and after the checks.
简单讲,用户输入一个 url,GitLab::UrlBlocker
会先对该 url 进行 DNS 解析,并验证 ip 有效性,防止其解析到诸如 127.0.0.1
, ::1
, 169.254.0.0/16
等的 ip。之后,通过 HTTP 请求该 url 时,又会进行一次 DNS 解析。第一次解析到有效 ip 后会默认后面解析的 ip 均有效,使用 DNS rebinding 将 127.0.0.1
绑定到该 url 上,再结合使用 TOCTOU(经典的 Race Condition)即可绕过 ip 有效性验证。
2 影响版本
gitlab 11.9.8 ee PS: hackerone report 中的版本
3 复现流程
证明:配置一个域名 gitladextssrf.webhooks.pw
,该域名可能会被解析到 127.0.0.1
(DNS 重新绑定后的恶意域名)与 198.211.125.160
(gitlab 服务原域名)。如果访问域名时,域名被解析到 127.0.0.1
则说明攻击成功。
- Create a new repository
- Add a commit to the repository
- Create a new Web Hook integration with the URL
http://gitlabextssrf.webhooks.pw:9999
. - Log into the gitlab server and start a TCP listener on port 9999/tcp (e.g.
nc -vvn -l -p 9999
) - 发送一系列并行报文请求 webhook。
DNS rebinding 一般要跟 TOCTOU 逻辑漏洞组合使用,毕竟要跟解析到的正常 IP 竞争。TOCTOU 算是比较传统的条件竞争(race condition)漏洞,而条件竞争的关键就是使各个请求间的延迟尽可能短,尽可能实现“同时”。所以这里要并行发送报文请求 webhook。
具体用到的命令如下:
$ ./wfuzz -X POST \
-b "_gitlab_session=<session_id>;" \
-d "_method=post&authenticity_token=<token>" \
-z range,0-1000 \
"https://<domain>/<user>/<repo>/hooks/<hook_id>/test?trigger=push_events&test=FUZZ"
上面的命令貌似是通过 wfuzz 使用多线程或并发的方式向服务端发送 1000 个请求报文,无法保证各请求之间的延迟。目前主流的并行发送请求的方式有两种:last-byte request 和 single-packet attack,我们可以使用 BurpSuite 中的 Turbo Intruder 以这两种方式发送并行请求,后面我写一篇关于 Race Condition 的文章细说。
域名能解析到 127.0.0.1
意味着也可以解析到任意 IP,包括内网 IP、AWS 云服务 IP(一般是 169.254.169.254
),继而允许攻击者读取敏感信息、攻击内网主机等。
4 行为分析
4.1 当前行为
The
GitLab::UrlBlocker
validation code resolves the IP addresses of a URL domain, validates them against a series of block lists, and if valid returns to theGitLab::HTTP
module which re-resolves the URL domain in order to perform the HTTP request
4.2 期望行为
The validated resolved addresses should be returned by
GitLab::UrlBlocker
and used byGitLab::HTTP
to make the TCP connection to the destination host.
5 代码分析
先吐个槽
妈的,这代码怎么找啊,目录结构都是一团浆糊更别说在里面找相关代码了,麻了…
5.1 漏洞相关代码
代码链接:lib/gitlab/url_blocker.rb · v11.9.8-ee · GitLab.org / GitLab · GitLab
ps: gitlab 的代码都是用 Ruby 写的,建议不熟悉 Ruby 的小伙伴先简单过一下Ruby基础语法。
url_blocker.rb
中的存在逻辑漏洞的代码块如下。功能:判断是否可以 DNS 解析,以验证 url 合理性,返回一个 bool 变量。同时,也就意味着当请求该 url 时仍会进行一次 DNS 解析以获取 ip 地址。总共进行了两次 DNS 解析,存在 TOCTOU 逻辑漏洞。
begin
addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM).map do |addr|
addr.ipv6_v4mapped? ? addr.ipv6_to_ipv4 : addr
# 如果是 ipv4 映射到 ipv6 地址空间的,调用 .ipv6_to_ipv4
end
rescue SocketError
return true
end
url_blocker.rb
的全注释代码见文末。
5.2 漏洞修复代码
再来看看该漏洞是如何修复的。
漏洞修复代码链接:Protect Gitlab HTTP against DNS rebinding attack
主要的变动就是有效性验证成功的返回值包括了 uri 解析后的 ip [uri, hostname]
,而不仅仅返回布尔值。
# Returns the given URI with IP address as hostname and the original hostname respectively
# in an Array.
#
# It checks whether the resolved IP address matches with the hostname. If not, it changes
# the hostname to the resolved IP address.
#
# The original hostname is used to validate the SSL, given in that scenario
# we'll be making the request to the IP address, instead of using the hostname.
def enforce_uri_hostname(addrs_info, uri, hostname)
address = addrs_info.first # 取第一个 ip 地址作为 uri 解析后的 ip 地址
ip_address = address&.ip_address
# The & (safe navigation operator) ensures that if address is nil, the code doesn't raise an error, returning nil for ip_address.
if ip_address && ip_address != hostname
uri = uri.dup
# 若 original hostname 是 www.example.com,更新后就变成了对应的 ip address,所以要保存原来的 uri 用于其他用途
uri.hostname = ip_address
return [uri, hostname]
end
[uri, nil]
end
6 一些问题
Wikipedia上为什么说 DNS rebinding 解析到攻击者 DNS server 时需要 TTL 尽可能小甚至为0?
In order to perform this attack a DNS server must be configured to resolve a domain to alternating addresses with a low (or zero) Time To Live.
什么是 TTL?
[!quote] Time to live (TTL) or hop limit is a mechanism which limits the lifespan or lifetime of data in a computer or network。
不同场景也有不同的意义。
- 数据包路由:TTL 表示数据包的生存时间或跳数。每经过一个路由器,TTL 计数均减一,防止数据包无限循环吃掉网络资源。
- DNS 服务器缓存的 DNS 记录:DNS 缓存服务器在连接到权威性 DNS 服务器并获取记录的新副本之前可以为 DNS 记录提供服务的时间。
简而言之,当 TTL 用在缓存(DNS, CDN)上时一般就表示缓存信息有效时间。
什么是 DNS?
可以使用 chrome://net-internals/#dns
查看 chrome 浏览器缓存的 DNS 记录。
- A 记录:“A” 代表地址,即给定域名的 IP 地址。其中
@
表示该记录为根域的记录。
example.com | record type: | value: | TTL |
---|---|---|---|
@ | A | 192.0.2.1 | 14400 |
NS 记录:NS 代表“域名服务器”,域名服务器记录指示哪个 DNS 服务器对该域具有权威性(即,哪个服务器包含实际 DNS 记录)。基本上,NS 记录告诉互联网可从哪里找到域的 IP 地址。简单讲,NS 记录表示该域名的权威域名服务器。
example.com | record type: | value: | TTL |
---|---|---|---|
@ | NS | ns1.exampleserver.com | 21600 |
CNAME 记录:canonical name,规范名,表示一个域名可映射为另一个规范域名(canonical name)。当该域名是另一域名的别名或解析到同一 ip 地址时,cname 可代替 a 记录。
blog.example.com | record type: | value: | TTL |
---|---|---|---|
@ | CNAME | is an alias of example.com | 32600 |
回到那个问题,DNS rebinding 为什么要求 TTL 尽可能小?
TTL 越小,DNS 解析或查询就会越频繁,进而允许攻击者更频繁地修改域名解析到的 IP 地址。另一方面,更小的 TTL 也允许忽略掉缓存中的 DNS 记录
其实 DNS rebinding 的关键在于 how to redirect DNS resolution to an attack server?
- 设置一个 DNS 服务器并把它配置成某个域名的 authoritative DNS server 权威 DNS 服务器。
- short TTL。更正常 DNS 服务器竞争
- DNS 投毒。把 DNS 缓存中的记录直接改到 attack server
参考链接:
必须通过域名来验证 SSL 或 TLS 证书嘛,IP 是否可以?
可以。SSL 证书绑定在一个 “common name”上,可以是域名,可以是带通配符的名称 *.example.com 也可以是 IP,但很少给 IP 分配证书。
一个 IP 上可能寄宿着很多不同的站,分别有不同的域名,SSL 跟域名绑定可以,确保每个网站有其对应的 SSL 证书。
在一个 IP 上跑网站,可以避免 DNS lookup 产生的时间开销,虽然效果微乎其微。DNS 记录都有缓存,每过 TTL 的时间才发一次请求。我们假设这个 TTL 是 10 min,也就是说过 10 min 才会有个毫秒级的 DNS lookup 请求,时间约等于没有。
1.1.1.1 有 SSL 证书,但好像解析到了 cloudflare-dns.com .
root@OuluVM072311847:~# openssl s_client -connect 1.1.1.1:443 -servername 1.1.1.1 | openssl x509 -noout -ext subjectAltName
depth=2 C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root G2
verify return:1
depth=1 C = US, O = DigiCert Inc, CN = DigiCert Global G2 TLS RSA SHA256 2020 CA1
verify return:1
depth=0 C = US, ST = California, L = San Francisco, O = "Cloudflare, Inc.", CN = cloudflare-dns.com
verify return:1
X509v3 Subject Alternative Name:
DNS:cloudflare-dns.com, DNS:*.cloudflare-dns.com, DNS:one.one.one.one, IP Address:1.0.0.1, IP Address:1.1.1.1, IP Address:162.159.36.1, IP Address:162.159.46.1, IP Address:2606:4700:4700:0:0:0:0:1001, IP Address:2606:4700:4700:0:0:0:0:1111, IP Address:2606:4700:4700:0:0:0:0:64, IP Address:2606:4700:4700:0:0:0:0:6400
使用 IP 的好处:
- 网站经常会使用 cookie 进行认证或追踪用户行为,对于每个发送到域名的请求,这些 cookie 也会随之发送,但请求大多静态内容时却不需要 cookie。如果 cookie 很长,时间开销也不容小觑。一种做法是使用其他域名(separate domain)存放这些静态内容,或者直接将其存放到某个 IP。
- 域名要花钱
- 避免了 DNS lookup
使用 IP 的缺点:
- IP vs domain 相当于 硬编码 vs 变量。更换提供商之后,所有的 IP 都得修改,还要重新配置 SSL。
- 取决于 SSL 证书办法机构。有些机构不会给私有 IP 颁发 SSL 证书。The Certificate Authority/Browser Forum doesn’t like seeing private IPs in certs but has nothing against public IPs. Let’s Encrypt 不会给 IP 地址颁发证书
参考链接:
- StackOverflow: Is it possible to have SSL certificate for IP address, not domain name?
- StackOverflow: Are SSL certificates bound to the servers IP address?
openssl 貌似是使用 ip 查找证书?如何处理 CDN?
webhook?
Webhook 是一种基于 HTTP 的回调函数,可在两个 API 之间实现事件驱动的轻量级通信,也有人称之为 “反向 API”。
当客户端 API 从服务器 API 请求数据时,客户端需要以固定间隔向服务端发送 HTTP 请求(轮询),若服务端数据发生了变化,服务器 API 会发送相关的数据(也称为 payload)。
这种方式需要客户端不断发送请求以追踪服务端数据变化。在使用 Webhook 时,客户端向服务器 API 提供唯一的 URL,并指定要了解的事件。设置 Webhook 后,客户端不再需要轮询服务器。发生特定的事件时,服务器会自动将相关的有效负载发送到客户端的 Webhook URL。
参考链接:
localhost, loopback, local network 和 link local 之间的区别?
- localhost: 本地计算机自己,一般映射到 127.0.0.1 (IPv4) 或 ::1 (IPv6)
- loopback: 回环(地址),用来与自己通信,从源发出的信息又回到源。一般用来 debug、测试或与内部服务器通信。
- ipv4 127 打头的都是回环地址,127.0.0.1 - 127.255.255.255 即 127.0.0.0/8
- ipv6 ::1/128
- local network:本地网络,本地子网,包含在同一网段内的所有设备的集合。
- 家庭网络 192.168.1.0/24
- 办公网络 10.0.0.0/8,172.16.0.0/12
- ipv6: fd00::
- link local: “Link-local” refers to a special range of addresses used for network configuration on a single network link (like a segment of a larger network). These addresses allow devices to communicate with each other when there’s no central DHCP server. Link-local addresses are often used for auto-configuration in the absence of DHCP.
- ipv4: 169.254.0.0 - 169.254.255.255
- ipv6: fe80::/10 以 fe80 打头的
参考链接:回环地址的一点儿破事
0.0.0.0 是什么地址?
0.0.0.0
指不可路由的元地址(a non-routable meta-address),一般用来表示一个无效、未知或不可用的目标,可以理解成the “no particular address” placeholder. So,0.0.0.0 的意思就是没有地址 emmm。IPv6 可以写成 ::
或 ::/0
0.0.0.0
在不同的场景下有不同的意义:
- In the context of servers, 0.0.0.0 means “all IPv4 addresses on the local machine”.
listen 0.0.0.0
会监听本机上的所有IPV4地址。 - In the context of routing, 0.0.0.0 usually means the default route. 默认路由。
用法:
- 在通过 DHCP 分配到 IP 地址之前,主机可以使用
0.0.0.0
表示自己 - A way to explicitly specify that the target is unavailable.
- A way to specify “any IPv4 address at all”. It is used in this way when configuring servers (i.e. when binding listening sockets). This is known to TCP programmers as
INADDR_ANY
参考链接:
附
url_blocker.rb
的全注释代码:
# frozen_string_literal: true
require 'resolv'
require 'ipaddress'
module Gitlab
class UrlBlocker
BlockedUrlError = Class.new(StandardError)
class << self
def validate!(url, ports: [], protocols: [], allow_localhost: false, allow_local_network: true, ascii_only: false, enforce_user: false, enforce_sanitization: false)
return true if url.nil?
# Param url can be a string, URI or Addressable::URI
uri = parse_url(url)
validate_html_tags!(uri) if enforce_sanitization
# Allow imports from the GitLab instance itself but only from the configured ports
return true if internal?(uri)
port = get_port(uri)
validate_protocol!(uri.scheme, protocols)
validate_port!(port, ports) if ports.any?
validate_user!(uri.user) if enforce_user
validate_hostname!(uri.hostname)
validate_unicode_restriction!(uri) if ascii_only
# 判断是否可以 DNS 解析,以验证 url 合理性。返回一个 bool 变量也就意味着 http 请求该 url 时仍会进行一次 DNS 解析以获取 ip 地址,从而产生 TOCTOU 逻辑漏洞
begin
addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM).map do |addr|
addr.ipv6_v4mapped? ? addr.ipv6_to_ipv4 : addr # 如果是 ipv4 映射到 ipv6 地址空间的,调用 .ipv6_to_ipv4
end
rescue SocketError
return true
end
validate_localhost!(addrs_info) unless allow_localhost
validate_loopback!(addrs_info) unless allow_localhost
validate_local_network!(addrs_info) unless allow_local_network
validate_link_local!(addrs_info) unless allow_local_network
true
end
# ? 表示该方法会返回一个 boolean 值
def blocked_url?(*args)
validate!(*args)
false
rescue BlockedUrlEr ror
true
end
private
def get_port(uri)
uri.port || uri.default_port
end
# ! a convention indicating that the method performs a potentially dangerous operation or raises an exception in certain conditions
# ! 表示方法有危险操作或会抛出异常
def validate_html_tags!(uri)
uri_str = uri.to_s
# 移除 uri 中所有的 html 标签
sanitized_uri = ActionController::Base.helpers.sanitize(uri_str, tags: [])
# he tags: [] argument specifies that no HTML tags are allowed, ensuring a clean and safe string
if sanitized_uri != uri_str
raise BlockedUrlError, 'HTML/CSS/JS tags are not allowed'
end
end
def parse_url(url)
raise Addressable::URI::InvalidURIError if multiline?(url)
Addressable::URI.parse(url)
rescue Addressable::URI::InvalidURIError, URI::InvalidURIError
raise BlockedUrlError, 'URI is invalid'
end
def multiline?(url)
CGI.unescape(url.to_s) =~ /\n|\r/
# =~ 表示匹配后面的正则,这里的正则分隔符为 / 也可以 %r (\n|\r) %r + 任意符号(会被当成分隔符
# unescape() 解码,转义
end
def validate_port!(port, ports)
return if port.blank?
# Only ports under 1024 are restricted
return if port >= 1024
return if ports.include?(port)
raise BlockedUrlError, "Only allowed ports are #{ports.join(', ')}, and any over 1024"
end
def validate_protocol!(protocol, protocols)
if protocol.blank? || (protocols.any? && !protocols.include?(protocol))
raise BlockedUrlError, "Only allowed protocols are #{protocols.join(', ')}"
end
# protocols.any? checks if the protocols list has any elements
end
def validate_user!(value)
return if value.blank?
return if value =~ /\A\p{Alnum}/
raise BlockedUrlError, "Username needs to start with an alphanumeric character"
end
def validate_hostname!(value)
return if value.blank?
return if IPAddress.valid?(value)
return if value =~ /\A\p{Alnum}/
raise BlockedUrlError, "Hostname or IP address invalid"
end
def validate_unicode_restriction!(uri)
return if uri.to_s.ascii_only?
raise BlockedUrlError, "URI must be ascii only #{uri.to_s.dump}"
end
def validate_localhost!(addrs_info)
local_ips = ["::", "0.0.0.0"] # 分别代表 ipv6, ipv4 下的地址通配符(本机所有地址)
local_ips.concat(Socket.ip_address_list.map(&:ip_address))
# Socket.ip_address_list 获取与系统网络接口相关的所有地址,包括 loopback addr, local network addr 等
# .map(&:ip_address) :extract the IP address from each object in Socket.ip_address_list
return if (local_ips & addrs_info.map(&:ip_address)).empty? # 两数组交集为空
raise BlockedUrlError, "Requests to localhost are not allowed"
end
def validate_loopback!(addrs_info)
# 不是回环地址 127.0.0.0/8 ::1
return unless addrs_info.any? { |addr| addr.ipv4_loopback? || addr.ipv6_loopback? }
# 类比 python 应该可以写成 any(addr.ipv4_loopback() or addr.ipv6_loopback() for addr in addrs_info)
raise BlockedUrlError, "Requests to loopback addresses are not allowed"
end
def validate_local_network!(addrs_info)
# 不是私有地址 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fd00::
# site-local addresses in IPv6 were used to represent addresses valid only within a single site, similar to private IPv4 addresses. 不过已被弃用
# unique local addresses are used for internal network communication across multiple subnets within an organization. 是 site-local addresses 的替代
return unless addrs_info.any? { |addr| addr.ipv4_private? || addr.ipv6_sitelocal? || addr.ipv6_unique_local? }
raise BlockedUrlError, "Requests to the local network are not allowed"
end
def validate_link_local!(addrs_info)
netmask = IPAddr.new('169.254.0.0/16')
return unless addrs_info.any? { |addr| addr.ipv6_linklocal? || netmask.include?(addr.ip_address) }
# fe80::/10
# Link-local addresses are used for operations that are local to a network segment, such as neighbor discovery, router advertisements, and auto-configuration of network interfaces.
# 链路本地地址,用在本地网段的操作中。
raise BlockedUrlError, "Requests to the link local network are not allowed"
end
def internal?(uri)
internal_web?(uri) || internal_shell?(uri)
end
def internal_web?(uri)
uri.scheme == config.gitlab.protocol &&
uri.hostname == config.gitlab.host &&
(uri.port.blank? || uri.port == config.gitlab.port)
end
def internal_shell?(uri)
uri.scheme == 'ssh' &&
uri.hostname == config.gitlab_shell.ssh_host &&
(uri.port.blank? || uri.port == config.gitlab_shell.ssh_port)
end
def config
Gitlab.config
end
end
end
end