隐藏站点的源服务器 IP
<p>从不开 CDN,域名 DNS 直接解析到 IP,再到服务器被打到空路由后的总结</p>
<h3>为什么要隐藏源服务器 IP</h3>
<p>首先我们要看看浏览器访问一个站点时,这个请求会如何到达源服务器:</p>
<ol>
<li>浏览器获得用户输入的域名,首先向 DNS 服务器发送查询请求</li>
<li>DNS 服务器收到了请求,解析到了域名对应的 IP,返回给浏览器</li>
<li>浏览器得知了源服务器的 IP 地址,接着继续向源服务器发送请求</li>
</ol>
<p><b><abbr title="Domain Name System"><a href="https://zh.wikipedia.org/zh-cn/域名系统" target="_blank" >DNS</a></abbr></b> 的作用就不多说了,简单来看就是放域名进去,就可以吐出域名对应的 IP</p>
<p>这个过程中,浏览器就已经知道源服务器的 IP 了,怎么知道的呢?当然是 DNS 告诉它的</p>
<p>但 DNS 是怎么知道源服务器的 IP 呢?不是什么魔法,是站点主人自己告诉 DNS 的,不然没了 DNS,想访问站点只能记一串 IP,很不方便</p>
<h4>现在我看某个人不爽,想把他的网站做掉</h4>
<p><code>当然我不提倡任何网络攻击,这里只是做一个例子</code></p>
<p>上面我们知道了请求是如何到达源站的,最简单的方法,就是一秒给他发几千个请求甚至更多,就能起效果。于是立刻手搓了一段代码,一秒发五千个请求,这个叫分布式拒绝服务(<b><abbr title="Distributed Denial-Of-Service">DDoS</abbr></b>)攻击</p>
<p>由于服务器的配置并不是很高,处理不来我们发送的这么多请求,一段时候后,服务器的主人发现了不对劲,赶紧把 DNS 解析删除了,打算等我们不打他之后再重新添加 DNS 解析,让网站恢复访问</p>
<p>此时,虽然 DNS 解析被删除了,但服主在被打之前在 DNS 里设置的 IP 已经被我们请求过了,我们早已知道了源站 IP。于是我们乘胜追击,把发送请求的域名换成 IP,接着打:</p>
<pre><code class="language-text">在我们的不懈努力下,服务器的占用爆满,服主只能关机跑路
就算服主重新开机,迎接他的依然是 100% 占用,跑不掉
</code></pre>
<p>到这里我们已经知道 DNS 与 IP 公开绑定的后果了,就算站点不能通过域名访问了,只要服务器不关机,那还是能通过 IP 访问的,不做些什么,站点就只能停了</p>
<p>所以,我们要在确保通过域名可以正常访问的情况下,保证源服务器的 IP 不被泄露</p>
<h3>有哪些办法可以隐藏源服务器 IP?</h3>
<p>我们先代入到前面的攻击事件中服主的视角,看看有什么方法可恢复站点访问,还能让他打不到我们</p>
<h4>换 IP</h4>
<p>一台机器想要作为站点的服务器,大部分情况下都需要被分配一个可被外部访问的 IP 地址,但它是可以更换的,可能会需要一些成本</p>
<p>更换了 IP 后,我们可以的服务器就可以开机了,因为攻击者并不知道我们服务器新的 IP 是多少,在不设定 DNS 解析的情况下,服务器是暂时安全了。但如果再次把域名解析到新的 IP,那他还是跟前面一样,只需要访问一次就能知道我们的新 IP,服务器就会继续挨打</p>
<p>我们也可以更换 IP 的同时更换一个域名,不过这样可能会损失之前积累的用户。如果你将更换域名的消息告诉了之前的用户,而攻击者潜伏在用户之中,那么你的服务器可能会再次遭殃</p>
<p>若网站之前有被搜索引擎收录,更换后的新域名再次被收录,那攻击者可以通过搜索引擎得知更换后的域名,DNS 再返回对应的 IP,那服务器同样会遭殃</p>
<h4>上高防 / 升级服务器</h4>
<p>只要你的配置够高,他就没法把你的服务器干掉线,只是看你的钱包能不能受得住。DDoS 的攻击和防御成本并不是一个量级的,一般的小站点可没有这个资金去买高防,升级服务器的性价比也很低,这是一个很糟糕的解决办法</p>
<h4>使用 CDN</h4>
<p>本文章的主角,能让 DNS 解析不会给出你的源站 IP,同时还能保证域名可以正常访问,它是怎么运作的呢?</p>
<p><b><abbr title="Content Delivery Network">CDN</abbr></b> 翻译过来就是内容分发网络,在 DNS 层面,它可以将 DNS 解析出来的 IP 从你的源站 IP 换为来自 CDN 的 IP,避免 DNS 直接暴露源站 IP</p>
<p><strong>DNS 解析出来不是源站 IP,那怎么获取我的站点内容呢?</strong></p>
<p>按照流程,在 <a href="#为什么要隐藏源服务器-ip">前面</a> 所说的 DNS 解析出 IP 进行返回的过程中,使用 CDN 后,解析出来的 IP 是来自 CDN 的。接下来的请求部分,用户会从向源站 IP 发送请求变为向 CDN 的 IP 发送请求,这里 CDN 需要负责接受请求,然后再向源服务器发送请求,过程如下:</p>
<pre><code class="language-text"># 没有使用 CDN 的请求
用户发送请求 > 源服务器收到请求
源服务器发送响应 > 用户收到响应
# 使用了 CDN 的请求
用户发送请求 > CDN 收到请求
CDN 转发请求 > 源服务器收到请求
源服务器发送响应 > CDN 收到响应
CDN 转发响应 > 用户收到响应
</code></pre>
<p>可以看到,开启 CDN 比不开启 CDN,用户每个请求都会先到达 CDN,再由 CDN 转发到源服务器,源服务器处理完得到响应之后,再发送给 CDN,CDN 再将回应发送给用户</p>
<p>看上去多了两个步骤,麻烦了不少,但我们需要的隐藏源站 IP 功能不是实现了?</p>
<p>不过要注意,CDN 很多时候只能转发 HTTP/S 请求,例如你本来用你的域名直接解析到 IP,使用 git 克隆仓库时可以使用域名替换服务器 IP,但开启 CDN 后,就不能这样了,你可能需要找一些其他方法</p>
<h4>还没完</h4>
<p>我们选择了最实用的 CDN 方法来隐藏了源站 IP,但还有一些需要注意的事情</p>
<ol>
<li>如果你在没开 CDN 之前就被打了,只开 CDN 是不行的,你还需要同步更换 IP。CDN 只是阻断了通过 DNS 获取源站 IP 的方法,但你的 IP 早就在开 CDN 前泄露了,此时攻击者不会和你走流程,直接对着 IP 继续打,服务器还是得遭殃</li>
<li>我没有开过 CDN,但我也并没有被打过,看了文章我现在感觉有必要开一个 CDN,还来得及吗?<br>
没问题,但有隐患。网上有很多专门扫域名扫 IP 的,可能已经记录了你的 IP,但情况并不坏,只要没人刻意去查,是找不到的,就算找到了,你简单换一下 IP,那些记录也就失效了</li>
</ol>
<h3>避免源服务器暴露站点特征</h3>
<p>截止到这里,防止 DNS 泄露源站 IP 的方法其实已经解决了,不过为了安全,我们还是要配置一下源服务器上的反向代理,避免网上那些扫 IP 的服务暴露了我们在 CDN 后的源服务器 IP</p>
<h4>排查暴露的端口</h4>
<p>首先,我们要检查一下直接访问服务器 IP 能获得什么,请按照下面的列表排查一下自己服务器:</p>
<ul>
<li>在浏览器中通过 <code>IP:80</code> 或 <code>IP:443</code> 能否直接访问到你的 HTTP/S 服务</li>
<li>如果你有其他非标准端口的服务,使用 <code>IP:端口</code> 是否能直接访问</li>
<li>如果你有部署 docker 或其他容器类服务,请检查容器的端口映射是否暴露在了公网</li>
<li>(可选)去 <a href="https://search.censys.io/">Censys</a> 搜搜你的 IP 中有哪些被记录的端口和服务</li>
</ul>
<h5>暴露的 80/443 标准端口</h5>
<p>如果你的 HTTP/S 服务能够通过 80/443 端口访问,那说明你的服务就直接暴露出来了,就算攻击者因为 CDN 中继保护还不知道你的 IP,但 80/443 是标准的 HTTP/S 端口,最简单的 IP 扫描也不会放过这两个端口,只要被扫到,基本就会暴露对应的数据</p>
<p>你可能会好奇,不是开了 CDN 吗?但 CDN 也是通过 80/443 端口才能转发服务器与客户端之间的请求,CDN 能访问到的数据,通过 IP 访问也可以,而直接通过 IP 访问则会绕过 CDN。如果一个 IP 访问后显示的网页数据和某个域名一样,那不用猜,这个 IP 就是域名后面对应的源服务器 IP,这时你的源服务器 IP 也就暴露了</p>
<p>还有上面说到的 <a href="https://search.censys.io/">Censys</a>,有些人可能觉得我去上面搜自己的 IP 不就是主动暴露自己吗,确实有这个风险,但你可以去搜一下你的域名,有时候你的 DNS 解析或是服务器设置的证书中的域名也可能会在上面暴露 IP 与域名的关系</p>
<h5>暴露的非标准端口</h5>
<p>第二则是非标准端口的服务,其实也像上面一样,没什么安全性。大一点的扫描类服务为了数据库够大、相比同行更有竞争性,基本都会把每个 IP 能扫的端口都扫一遍,设定非标准端口并没有太大作用</p>
<h5>暴露的容器端口</h5>
<p>最后是容器中的端口映射,一般容器与主机的端口映射是可以修改的,很多容器自部署服务为了避免端口冲突,并不会将服务端口设为 80/443,而是 3000、5000 这种四位数的端口。为了让 IP 或域名可以不加端口直接访问,需要在服务器上设定反向代理,在收到请求时,根据传入的域名,决定转发请求到哪个容器中处理</p>
<p>设置得当时,通过域名访问站点,不管源服务器上有多少个容器,反向代理都可以帮你正确的转发请求到对应的容器,而用户与 CDN 发送的请求始终都是通过标准的 80/443 HTTP/S 端口,这可以让我们无需为每个相同的 HTTP/S 服务单独设置一个端口,避免了暴露多个端口的问题</p>
<p><strong>不过,你有按照我说的步骤去排查 docker 容器端口映射了吗?如果发现你的 docker 容器也可以使用 <code>IP:端口</code> 直接访问,那你就要注意一下 docker 的端口映射方式了</strong></p>
<p>docker 中容器的端口映射有两种方式:暴露至公网与仅在本机暴露,下面假设容器内的端口为 2340,映射到主机的端口为 1230:</p>
<pre><code class="language-bash">// 暴露至公网
// docker run 启动
docker run -p 1230:2340 <容器镜像>
// docker compose 配置
ports:
- 1230:2340
// 仅在本机暴露
// docker run 启动
docker run -p 127.0.0.1:1230:2340 <容器镜像>
// docker compose 配置
ports:
- 127.0.0.1:1230:2340
</code></pre>
<p>相比之下就是在 <code>1230:2340</code> 前多加了一段 <code>127.0.0.1:</code>,可能有点难看懂,这时它实际也是分为两段,<code>127.0.0.1:1230</code> 为主机的部分,<code>2340</code> 依然为容器端口,这里我们主要是限定了这个端口只允许在 <code>127.0.0.1</code> 访问,而 <code>127.0.0.1</code> 同时也对应了 <code>localhost</code>,就仅为本机使用</p>
<p>修改容器配置后,重启一下容器,再运行 <code>docker ps</code> 看看 PORTS 是不是由 <code>0.0.0.0:1230->2340/tcp</code> 变成了 <code>127.0.0.1:1230->2340/tcp</code>。这个时候,就无法通过 <code>IP:1230</code> 的方式访问到 docker 中的容器了</p>
<p><strong>还有什么要注意的吗?当然,现在通过 IP 的 80/443 端口依然可以访问到服务器上的服务,跟 <a href="#暴露的-80443-标准端口">前面</a> 说的一样,只要通过 IP 访问依然能得到与通过域名访问一样的数据,那还是能猜到这两者的关联,所以我们要完全切断 IP 与域名的联系,不留任何可以被关联的数据</strong></p>
<p><del>当然肯定不是让你直接关机跑路</del></p>
<h4>禁止直接通过 IP 访问</h4>
<p>看上去很玄乎?虽然前面也提到了 CDN 能访问到的数据,通过 IP 访问也可以,但也有方法,让 CDN 可以正常转发,而直接访问 IP 不返回任何数据</p>
<p>此段部分内容来自 <a href="https://zinglix.xyz/2021/10/04/nginx-ssl-reject-handshake/">NGINX 配置避免 IP 访问时证书暴露域名 - ZingLix Blog</a> 文章,在此感谢 <a href="https://zinglix.xyz/">ZingLix</a></p>
<p>假设我们使用的反向代理软件为 <a href="https://www.nginx.com/">Nginx</a>,我们原本的配置如下,其中假设自己的域名为 <code>example.com</code>:</p>
<pre><code class="language-conf"># 此段作用为转发 HTTP 请求至 HTTPS
server {
listen 80 default_server;
return 301 https://$host$request_uri;
}
server {
listen 443 http2 ssl;
server_name example.com;
ssl_certificate <SSL 证书公钥文件>;
ssl_certificate_key <SSL 证书私钥文件>;
# 忽略了一些配置
location / {
proxy_pass http://localhost:3000;
# 忽略了一些配置
}
}
</code></pre>
<p>一份很普通的 nginx 配置,除了转发 HTTP 请求到 HTTPS 外,还有一段配置,作用是收到包含 <code>example.com</code> 域名的请求时,将请求转发到本地的 <code>3000</code> 端口进行处理。解析好 DNS 或同时加上 CDN,你都可以通过 <code>example.com</code> 域名来访问到服务器上的 <code>3000</code> 端口对应的服务</p>
<p>不过,如果你通过浏览器直接访问 IP,不管使用 <code>http://</code> 还是 <code>https://</code> 前缀,最后都是访问到了 443 端口,因为第一端的转发请求起了作用。而下一步就是请求 443 端口了,第二段配置的 <code>listen 443</code> 意思就是监听 443 端口,那么这个请求自然就传到第二段配置设定的 <code>3000</code> 端口对应的服务里去了,这就造成了使用 IP 能直接访问到服务器上的服务,我们可不希望这样</p>
<h5>添加配置拒绝直接通过 IP 访问时的请求</h5>
<p>那怎么办?我们可以在第一段后面添加一段监听 <code>443</code> 端口的配置,返回 403 或者 404?你可以现在试一下:</p>
<pre><code class="language-conf"># 此处作用为转发 HTTP 请求至 HTTPS
server {
listen 80 default_server;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl default_server;
return 403;
}
# 忽略了第三段配置
</code></pre>
<p>然后你会不出所料的得到类似 <code>no "ssl_certificate" is defined for the "listen ... ssl"</code> 的错误,简单来说就是,想要监听 443 端口,你必须要设定一个 SSL 证书,当然这个证书是否有效并不重要,只要是个证书就可以</p>
<p>但在添加证书前,我们先看看证书里有哪些内容(来自 <a href="https://www.cloudflare.com/zh-cn/learning/ssl/what-is-an-ssl-certificate/">什么是 SSL 证书? | Cloudflare</a>):</p>
<pre><code class="language-text">针对其颁发证书的域名
证书颁发给哪一个人、组织或设备
证书由哪一证书颁发机构颁发
证书颁发机构的数字签名
关联的子域
证书的颁发日期
证书的到期日期
公钥(私钥为保密状态)
</code></pre>
<p>看到了吗,第一行中就有关于域名的信息,这可不行,在这个配置里加 SSL 证书也会泄露我们的域名,怎么办呢?</p>
<p>也有办法,只要我们给一个不包含我们域名信息的 SSL 证书就可以了,毕竟 nginx 并不会检验证书的有效性,于是我们使用 openssl 生成一个私钥和证书:</p>
<pre><code class="language-bash"># 请在 Linux 下运行,生成后的证书将生成在运行代码时的目录
# 在运行第二行时,会询问你证书的信息,可以随便填写也可以直接全部回车
openssl genpkey -algorithm RSA -out private.key
openssl req -new -key private.key -out cert.csr
openssl x509 -req -in cert.csr -signkey private.key -out certificate.crt
</code></pre>
<p>运行以上命令后,目录下会多出来三个文件,分别是 <code>private.key</code>、<code>cert.csr</code> 和 <code>certificate.crt</code>,将其添加到前面的配置里:</p>
<pre><code class="language-conf"># 忽略了第一段配置
server {
listen 443 ssl default_server;
ssl_certificate <文件目录>/certificate.crt;
ssl_certificate_key <文件目录>/private.key;
return 403;
}
# 忽略了第三段配置
</code></pre>
<p>此时再去直接访问 IP,你就会得到证书无效的错误,当然你也可以在页面上找一找,一般会有高级选项允许继续访问,就会得到 403 或 404 的错误,看你是如何设置的</p>
<p>不过我们是为了防止证书暴露信息,所以这是无法访问也是在我们的预期中的。点击浏览器地址栏旁边的信息按钮,这个按钮一般会显示成一个小锁或是类似设置的图标,就能看到证书无效的提示,点击查看详细信息就在弹出的窗口里可以看到里面的证书信息,这些信息就是你在前面生成证书时填写的信息</p>
<p>到这里,已经很难将你的源站与域名联系起来了,只要你使用 openssl 生成的证书中没有明晃晃写上你的域名,那想通过特征找到源站对应的域名,就等于大海捞针了</p>
<p><strong>如果你的 Nginx 版本高于 1.19.4,你还可以使用新功能:拒绝握手</strong></p>
<h5>设定直接通过 IP 访问时拒绝握手</h5>
<p><strong>注意:这个功能和上一个方法作用类似,你只需要选择其中的一个来设置。相对而言,拒绝握手能提供更少的信息,效果更佳</strong></p>
<p>这个功能需要 Nginx 版本高于 1.19.4,你可以在终端运行一下 <code>nginx -v</code> 检查一下是否可以使用该新功能。当然你也可以去升级 Nginx,如果无法升级或其他原因不想升级,上面的方法也足够安全了</p>
<p>拒绝握手的方法配置起来很简单,与上面的方法类似,只需在正常配置前添加一个监听 443 端口的服务即可,可以不指定 SSL 证书:</p>
<pre><code class="language-conf"># 此处作用为转发 HTTP 请求至 HTTPS
server {
listen 80 default_server;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl default_server;
# 当配置中开启拒绝握手时,不需要添加 SSL 证书与私钥
ssl_reject_handshake on;
}
# 忽略了第三段配置
</code></pre>
<p>保存配置并重启 nginx 服务后,再去浏览器里访问 IP,就可以看到已经无法通过 IP 访问到站点了,而且页面信息里也没有证书信息,可谓非常的干净</p>
<p>第二段配置中的 <code>ssl_reject_handshake on</code> 就是关键所在,只要一段配置中启用了这个功能,那么走这个通道的请求都会被拒绝握手,不会提供任何信息,所以注意不要往正常的服务配置里启用这个功能</p>
<h4>我们安全了?暂时的</h4>
<p>直到这里,我们开启了 CDN、关闭了暴露的端口、禁止 IP 访问以及配置了证书避免泄露我们的域名,不过还有一个方式能访问到服务,那就是我们的域名</p>
<p>为什么配置了怎么多,IP 都不能访问到网站了,而通过域名还可以?下面有两行命令,你可以试一试:</p>
<pre><code class="language-bash">curl -v -k https://<你的源站 IP>
curl -v -k https://<你的源站所绑定的域名>
</code></pre>
<p>第一行是直接通过 IP 请求内容,第二行相同,但走的是域名来请求内容,可能还会经过 CDN。看着我们是配置成功了,IP 会无法访问,域名访问一切正常,不过,如果你试试下面这行命令呢?</p>
<pre><code class="language-bash">curl -v -k https://<你的源站所绑定的域名> --resolve <你的源站所绑定的域名>:443:<你的源站 IP>
</code></pre>
<p>看似能正常从域名获取网页内容?没错,不过我可要告诉你,这里请求的其实是 IP,而不是你的域名,是不会经过 CDN 的。但我们配置了禁止 IP 访问,为什么还是能获取到站点内容呢?</p>
<h5>依然有办法可以通过 IP 访问</h5>
<p>先看看这行命令有哪些参数,下面的域名将被替换为 example.com,IP 被替换为 333.333.333.333,CDN 的 IP 被替换为 444.444.444.444:</p>
<pre><code class="language-bash">curl \
-v \ # 输出请求时的整个过程
-k \ # 请求时跳过 SSL 检测(不会因为证书原因导致请求失败)
https://example.com \ # 要请求的域名
--resolve \ # 将某个域名强制解析到某个 IP(替代 DNS 的作用)
example.com:443:333.333.333.333 # 要强制指定的数据
</code></pre>
<p>这么看,这行命令的作用就是:在请求时输出全部日志并跳过 SSL 检测,跳过正常的 DNS 环节,直接将域名解析到指定的 IP 地址,再向强制解析后的域名发送请求,运行后的输出类似这样:</p>
<pre><code class="language-bash">$ curl -v -k https://example.com --resolve example.com:443:333.333.333.333
* Added example.com:443:333.333.333.333 to DNS cache
* Hostname example.com was found in DNS cache
* Trying 333.333.333.333:443...
* Connected to example.com (333.333.333.333) port 443 (#0)
# 忽略后续信息
</code></pre>
<p>而正常通过域名访问的输出呢?</p>
<pre><code class="language-bash">$ curl -v -k https://example.com
* Trying 444.444.444.444:443...
* Connected to example.com (444.444.444.444) port 443 (#0)
# 忽略后续信息
</code></pre>
<p>可以看到,如果指定了 <code>--resolve example.com:443:333.333.333.333</code> 参数,在请求时会将 <code>example.com</code> 直接解析到 <code>333.333.333.333</code>,之后请求会直接冲着源服务器去,服务器再返回站点数据,我们的服务器可能就会再次沦陷了</p>
<p>怎么办呢?说实话,如果攻击者到了这种拿着域名一个个试试 IP 能不能通过请求的时候,我们就只能限制哪些 IP 可以直接访问服务器了</p>
<h5>限制哪些 IP 可以直接访问源服务器</h5>
<p>想要限制哪些 IP 可以访问我们的源服务器,我们总不能手动将用户的 IP 地址添加到防火墙白名单,家庭宽带很多时候并不会有固定 IP,每次用户 IP 变动时,你都需要实时将用户的 IP 添加到防火墙白名单,这相当的麻烦,而且你总不能在用户的设备上安装软件实时检测 IP 变化,这个解决方法很不现实</p>
<p>但如果我们使用的是 CDN,那解决方法就很方便了,由于 CDN 会负责转发用户的请求,使用 CDN 后用户与源服务器的请求始终是由 CDN 负责的,服务器日志中只会有来自 CDN IP 的请求,我们只需要使用防火墙拒绝其他 IP 的连接请求,仅允许来自 CDN IP 的请求即可</p>
<p>不过,你需要提前查询一下你使用的 CDN 供应商是否能提供 CDN 全部 IP 的列表,不然下方的两个方法都不适用你的情况</p>
<p>下面部分内容来自 <a href="https://zikin.org/block-censys-scanner/#header-id-4">屏蔽 Censys 扫描器, 及设置仅允许 Cloudflare 回源 - Zikin 的独立博客</a> 文章,同样在此感谢 <a href="https://zikin.org/">Zikin</a></p>
<p>我们有两种方式可以对设置仅允许哪些 IP 与源服务器建立连接:</p>
<p><strong>方法 1: 使用 Nginx 的 <code>allow</code> / <code>deny</code> 字段 (不推荐)</strong></p>
<blockquote>
<p>由于这是一个不被推荐的方法,我并不会写出具体的操作流程,如果你还是想看看如何配置,你可以去 <a href="https://zikin.org/block-censys-scanner/#header-id-4">屏蔽 Censys 扫描器, 及设置仅允许 Cloudflare 回源 - Zikin 的独立博客</a> 文章查看操作步骤</p>
</blockquote>
<p>这个方法是可以不向被限制的 IP 发送原本的网页数据,但在搭配了上面添加的 nginx 拒绝握手特性时,会出现一个自欺欺骗人的情况。我们先保留之前的拒绝握手设置,再往普通配置里添加 <code>deny</code> 字段拒绝所有 IP 的连接请求:</p>
<pre><code class="language-conf"># 此处作用为转发 HTTP 请求至 HTTPS
server {
listen 80 default_server;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl default_server;
ssl_reject_handshake on;
}
server {
listen 443 http2 ssl;
server_name example.com;
ssl_certificate <SSL 证书公钥文件>;
ssl_certificate_key <SSL 证书私钥文件>;
# 忽略了一些配置
# 方便测试,这里我们将拒绝来自所有 IP 的连接请求,使用时,你需要改为仅允许来自你 CDN 供应商的 IP
deny all;
location / {
proxy_pass http://localhost:3000;
# 忽略了一些配置
}
}
</code></pre>
<p>我们再使用 curl 发送请求,域名被替换为 example.com,源站 IP 依然为 333.333.333.333:</p>
<p>我们先试试直接请求 IP</p>
<pre><code class="language-bash">$ curl -v -k https://333.333.333.333
* Trying 333.333.333.333:443...
* Connected to 333.333.333.333 (333.333.333.333) port 443 (#0)
* ALPN: offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS alert, unrecognized name (624):
* OpenSSL: error:0A000458:SSL routines::tlsv1 unrecognized name
* Closing connection 0
curl: (35) OpenSSL: error:0A000458:SSL routines::tlsv1 unrecognized name
</code></pre>
<p>与预期相同,服务器拒绝了我们发送的握手请求,也没有提供证书,接下来我们试试强制将域名解析到源站的 IP 地址:</p>
<pre><code class="language-bash">$ curl -v -k https://example.com --resolve example.com:443:333.333.333.333
* Added example.com:443:333.333.333.333 to DNS cache
* Hostname example.com was found in DNS cache
* Trying 333.333.333.333:443...
* Connected to example.com (333.333.333.333) port 443 (#0)
* ALPN: offers h2,http/1.1
# 忽略了部分 TLS 握手内容
* ALPN: server accepted h2
* Server certificate:
# 忽略了服务器证书内容
# 忽略了请求信息
> GET / HTTP/2
> Host: example.com
> user-agent: curl
> accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
< HTTP/2 403
< server: nginx
< content-type: text/html
< content-length: 153
<
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx</center>
</body>
</html>
* Connection #0 to host example.com left intact
</code></pre>
<p>可以看到,curl 成功与源服务器握手,并输出了来自 nginx 的回应,虽然最后输出的是 403 Forbidden(无权访问) 的回应,也没有输出原本的网页信息。看上去 IP 白名单起效果了,但它并没有达到我们想要的效果,此时我们再试试将一个不存在的域名解析到源站的 IP 地址:</p>
<pre><code class="language-bash">$ curl -v -k https://noexistdomian --resolve noexistdomian:443:333.333.333.333
* Added noexistdomian:443:333.333.333.333 to DNS cache
* Hostname noexistdomian was found in DNS cache
* Trying 333.333.333.333:443...
* Connected to noexistdomian (333.333.333.333) port 443 (#0)
* ALPN: offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS alert, unrecognized name (624):
* OpenSSL: error:0A000458:SSL routines::tlsv1 unrecognized name
* Closing connection 0
curl: (35) OpenSSL: error:0A000458:SSL routines::tlsv1 unrecognized name
</code></pre>
<p>从最后的输出可以看出,这个不存在的域名,触发了我们的拒绝握手配置</p>
<p><strong>如果向源服务器发送请求时传递的域名正确,则可成功握手,否则将返回拒绝握手的 <code>tlsv1 unrecognized name</code> 错误</strong></p>
<p>这代表当攻击者使用域名对我们的服务器进行扫描时,直接请求 IP 或附加的是错误的域名,那么你的服务器会正常的拒绝握手。但如果请求的是正确的域名,服务器接收了请求、完成了握手,然后返回了 403 Forbidden(无权访问)</p>
<p>有什么问题呢?将视角换为攻击者就好理解了:我直接请求 IP 你拒绝我的握手,用其他域名请求你也拒绝握手,但我用 <code>example.com</code> 你通过了握手再给我返回 403?那这个 IP 绝对跟 <code>example.com</code> 这个域名有关系</p>
<p>后面的就不用我说了吧,可以等着换下一个 IP 了</p>
<p><strong>方法 2: 使用系统防火墙</strong></p>
<p>在这里,我们使用基于 <code>iptables</code> 易用接口的 <code>ufw</code> 设定防火墙规则,此处部分内容同样来自 <a href="https://zikin.org/block-censys-scanner/#header-id-4">屏蔽 Censys 扫描器, 及设置仅允许 Cloudflare 回源 - Zikin 的独立博客</a> 文章</p>
<p>我们照样先试试被防火墙封禁后,直接请求 IP 与携带域名请求会怎么样,先设置防火墙:</p>
<pre><code class="language-bash">sudo ufw allow OpenSSH # 设定防火墙允许 ssh,否则可能就连不上服务器了
sudo ufw default deny # 将默认入站连接设定为拒绝
</code></pre>
<p><strong>注意!如果你需要通过 ssh 才能连接到服务器,请务必按照上面的方法允许 ssh 请求,否则你可能会再也无法连接到你的服务器</strong></p>
<p>此时,我们已经设定好了防火墙配置,允许 ssh 连接,并拒绝所有入站请求,接下来我们就可以通过 ufw 开启防火墙了:</p>
<pre><code class="language-bash">sudo ufw enable # 启用防火墙
sudo ufw status verbose # 查看防火墙状态以及规则
# 此时的输出应如下所示
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere
22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)
</code></pre>
<p>这时候再去试试请求服务器,你会发现不管是单独请求 IP 还是携带域名请求都无法获取到内容了,可能会提示无法连接或者迟迟没有完成请求,因为这个时候请求到达防火墙的时候直接被拒绝了,没有返回任何信息</p>
<p>不过这个时候再去浏览器访问域名,你会发现域名也进不去了,因为上面的规则阻止了所有 IP 的入站请求,CDN 的 IP 也同样被阻止了,所以我们还需要设定规则允许来自 CDN IP 的连接</p>
<p>与用户多变的 IP 不同,CDN 的 IP 一般是固定的,我们只需要将 CDN 供应商提供的 CDN IP 列表添加到防火墙白名单即可</p>
<p>我使用的 CDN 来自 Cloudflare,如果你使用的是其他 CDN 提供商,请手动查找对应的 IP 范围列表,按照 <a href="#限制哪些-ip-可以直接访问源服务器">前面</a> 的需求,Cloudflare 是提供全部 IP 的列表的,可见 <a href="https://www.cloudflare.com/ips/">IP 范围 | Cloudflare</a>,我们只需要将列出的 IP 范围添加到防火墙白名单,通过域名访问的请求即可恢复正常:</p>
<pre><code class="language-bash">sudo ufw allow from 173.245.48.0/20
sudo ufw allow from 103.21.244.0/22
# 省略后面一条条加的过程
</code></pre>
<p>测试的时候我觉得很麻烦,于是我写了一个 shell 脚本,发布在 <a href="https://gist.github.com/Interstellar750/1803cdefcaa91940e87a3d27fe78f17b">GitHub Gists</a> 上,可以一键获取 Cloudflare CDN 的所有 IP 范围列表并添加到防火墙白名单,你可以使用它来一键添加:</p>
<pre><code class="language-bash">curl -L https://gist.githubusercontent.com/Interstellar750/1803cdefcaa91940e87a3d27fe78f17b/raw/add_cf_ips.sh | sudo bash
# 如果你想从防火墙白名单中移除 Cloudflare 的 IP,执行下面这行
# 运行后会在运行时的目录留下来一个 add_cf_ips.sh 文件,你可能需要手动删除它
curl -L https://gist.githubusercontent.com/Interstellar750/1803cdefcaa91940e87a3d27fe78f17b/raw/add_cf_ips.sh > add_cf_ips.sh && sudo bash add_cf_ips.sh --remove
</code></pre>
<p>对了,在添加完防火墙规则后,你可能需要运行一下 <code>sudo ufw reload</code> 加载修改后的规则以生效,之后再去浏览器使用域名访问,就可以正常访问了。使用 curl 请求 IP 或携带域名请求 IP,都不会返回站点信息,我们就完成了对源服务器的保护</p>
<h3>最后的一些琐事</h3>
<p>如果设定了防火墙白名单,那么上面 nginx 的拒绝握手配置其实也可有可无了,非 CDN 的 IP 根本无法触发到这个拒绝握手的规则,最坏的情况只能是攻击者用 CDN 的 IP 去扫服务器请求,如果真有这种情况,可以考虑换一个 CDN 提供商了</p>
<p>在想到防火墙添加 Cloudflare CDN 的 IP 白名单时,我还在考虑如果攻击者连上 warp 之后扫服务器怎么办,我就去查了查 warp 的 IP 段,跟 CDN 并不在一个段。这下除非去机房一台机一台机的拔网线,不然基本就不可能找得到我 IP 了</p>
<p>文章末尾感谢:</p>
<p><strong><a href="https://www.cloudflare.com/">Cloudflare</a></strong> <br>
DNS / CDN / PaaS 提供商,提供了很多好用的服务,帮我缓解了很多通过 CDN 的攻击,让我的网站不至于关站跑路</p>
<p><strong><a href="https://vercel.com/">Vercel</a></strong> <br>
PaaS 提供商,服务真的非常好用,尽管我的流量被攻击刷到远超 100GB 的 1.2TB 后,没有向我发送账单也没有封禁我的账号</p>
<p><strong>本文提到文章的两位作者</strong> <br>
再次感谢您的教程,让我可以学习到需要的网络安全知识</p>
<p><strong>某个服务器提供商</strong> <br>
由于安全原因我这里不能说是哪个服务器提供商,但还是在此感谢一下,不然我就没地方放自部署服务了</p>
<p><strong>不知名的攻击者</strong> <br>
从 23 年 7 月开始将我服务器打到空路由,在我建立防护后顶着 Cloudflare 一直给我刷了估计有 30m 请求。同时给我 Vercel 的免费 100 GB 套餐超额刷至 1.2TB,让我被迫学习网络安全知识</p>