防止藏在 CDN 后的源站 IP 被找出的几种方法 | 掘金技术征文-双节特别篇

6,767 阅读15分钟

若是作为刚入门的网站管理员,你或许知道使用 CDN 来避免暴露源站 IP。同时,你或许会采取一些常用措施,诸如:修改源站的 Hostname,对非法请求不返回或返回无关/迷惑类信息。
然而,有些东西容易被忽视,例如“证书信息”。我很确信大多数人在尝试对非法请求返回空内容时,没有注意到证书信息是否也不会同时返回。还有一种容易被忽略的:忽略了默认返回块的配置(在 Nginx 中,如果在客户端请求时没有携带 Hostname,则会返回第一个从配置文件中读取的 server{} 块)。
注:如果你像下面给出的示例那样配置,你可能会觉得「服务端不会返回东西」。然而证书信息还是被返回了。

这是 nginx.conf 中仅存的 SSL 相关的块:

server {
    listen       443 ssl;
    server_name  localhost;

    ssl_certificate      /root/cert.pem;
    ssl_certificate_key  /root/cert.key;

    location / {
        return 444;
    }
}

然而证书信息依旧会被返回:

curl -k https://127.0.0.1 -v
*   Trying 127.0.0.1:443...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server accepted to use http/1.1
* Server certificate:
*  subject: CN=127.0.0.1
*  start date: Feb  12 07:35:46 2020 GMT
*  expire date: Feb  13 07:35:46 2020 GMT
*  issuer: CN=127.0.0.1
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
> GET / HTTP/1.1
> Host: 127.0.0.1
> User-Agent: curl/7.58.0
> Accept: */*
> 
* Empty reply from server
* Connection #0 to host 127.0.0.1 left intact
curl: (52) Empty reply from server

在开始正文前,首先要说明一下:这篇文章我最初是以英文的形式发布在自己的博客中,同时也在 Stack Overflow 中做了存档。你可能会觉得这篇文章有点“翻译腔”,但确实是原创文章,请不要误会。如果你觉得本文对你有所帮助,请帮忙 Upvote 一下,感谢。

另外,如果你需要完全地保护你的服务器 IP 不被泄露,仅仅是做我在本文中提到的部分是远远不够的安全遵循_短板理论_1。任何疏忽都会导致不可预计的的损失。所以,你需要为自己的安全负责。我在此仅仅是介绍防止源站 IP 泄露的一些方法。如果出现诸如软件设计错误导致的 IP 泄露,本文并不能帮你修正那些错误。

回到正题:如果你非常完全地、幸运地解决了其它任何可能会导致 IP 泄露的问题,那总的来说,攻击者只能通过模仿普通用户请求,扫描(请求)所有可能的 IP,并根据返回的结果进行判断。在大多数情况下,你可以借由 IP 白名单来屏蔽它们的扫描,但这是要视情况而定的。你很可能不知道 CDN 用于请求你源站服务器的 IP 范围,或者这 IP 范围是在持续变化的。使用这种策略可能会导致服务中断。

概要

  • IP 白名单
  • 修改 Hostname/监听端口
  • 防止泄露证书信息(针对非指向性性扫描)
    • 你的源站域名信息不会被列入到基于此建立的通用数据库
    • 如果可能,请修改 Web 服务器(如:Nginx 等)监听的端口
    • 请务必设置默认返回规则
  • 通过伪造成其它真实存在的网站/CDN 节点来混淆视听
  • 通过伪装成其它自制网站/返回空内容来屏蔽非法请求
    • 需要结合 CDN 服务商提供的策略来完成
    • 客户端证书验证作为非常见策略也是其中一种方法
  • 结论

如果你在阅读过程中感到困惑,你可以先查看结论部分中的流程图,然后再继续阅读。

策略

在下文阐述中,全部假定使用 Debian/Ubuntu 作为操作系统,使用 Nginx 作为 Web 服务器。

IP 白名单

事实上,最直接、有效率阻止源站 IP 泄露的方法就是设置 IP 白名单。*如果你能这么做,那就这么做。*不过,请注意以下事项:

  1. 如果 CDN 服务商并没有提供他们使用的 IP 列表,请不要使用此方案,不然可能会造成服务中断
  2. 如果你使用 HTTPS 作为回源协议,那么你应当使用 iptables 而不是 Nginx 内置的 access module,否则攻击者依旧能通过探测证书 SNI 信息来找到你的源站服务器;
  3. 在使用 Cloudflare 作为 CDN 的情况下,仅仅通过使用 IP 白名单可能会给攻击者机会绕过 Cloudflare 的保护找到源站服务器 IP。

如果你正在使用 iptables,请记得安装 iptables-persistent,不然你可能会在重启的时候丢失你的过滤规则:

apt-get install iptables-persistent

使用 iptables 对非白名单 IP 丢弃请求的示例:

修改 Hostname/监听端口

通常来说,带指向性的扫描器会对所有 IP 的常规端口进行带 hostname(也是你的网站域名)的扫描(http/80, https/443)。所以你如果修改 Hostname 或修改监听端口,通常就能避免这些扫描。


通过自定义请求回源站时使用的 Hostname/域名,可以防止攻击者通过 Hostname 找到你的源站 IP


一些 CDN 服务商支持设置自定义回源端口

然而,如果你不小心通过某种方式让攻击者知道了你回源用的 Hostname,或者你使用的 IP 范围,你的源站 IP 还是有被发现的风险的。所以请自行注意。

防止 SNI 信息泄露的补丁

阻止 SSL 握手的意图是防止在非指向性扫描的情形下,证书 SNI 信息的泄露(也可以简单认为是域名信息)。同时,在完成扫描后,攻击者(发起非指向性扫描的人)可以建立一个基于「网站/域名—IP」关系的数据库便于日后快捷检索。

证书内包括的域名信息,攻击者可以据此获知有什么网站正在运行(虽然不一定确实有在运行):

如果你的 Nginx 版本高于等于 1.19.4,你可以使用 ssl_reject_handshake 特性来防止 SNI 信息泄露。否则的话,你还是需要安装 strict-sni 补丁

注:这项措施仅当在你使用 HTTPS 作为回源协议时才有效。如果你只是想使用 HTTP 作为回源协议,你可以单纯地在默认 server 块中使用 return 444;,并且这一小段中的其它部分你可以直接跳过,或仅仅时略读即可。

ssl_reject_handshake 的配置 (Nginx ≥ 1.19.4)

对于配置 ssl_reject_handshake,涉及两个部分:默认块、常规块。

server { # 如果使用了错误的 Hostname,SSL 握手会被拒绝
    listen               443 ssl;
    ssl_reject_handshake on;
}

server { # 对于携带正确 Hostname 的请求,服务器会继续做后续处理
    listen              443 ssl;
    server_name         example.com;
    ssl_certificate     example.com.crt;
    ssl_certificate_key example.com.key;
}

这个方法仅适用于 Nginx 大于等于 1.19.4 的情况。否则要想达到阻止 SNI 信息泄露的目的,你需要安装strict-sni 补丁。这个补丁是由来自南韩的 PHP 开发者 Hakase 开发的,该补丁可以使 Nginx 在 1.19.3 之前的实例针对非法请求真正地空返回

安装 strict-sni 补丁的步骤 (Nginx ≤ 1.19.3)

首先,安装必要的安装包:

apt-get install git curl gcc libpcre3-dev software-properties-common \
build-essential libssl-dev zlib1g-dev libxslt1-dev libgd-dev libperl-dev

然后,在 OpenSSL 的发布页中下载你想使用的版本。

下载仓库 openssl-patch:

git clone https://git.hakase.app/Hakase/openssl-patch.git

基于你之前选择的 OpenSSL 版本,先切换至 OpenSSL 源码的目录,然后为 OpenSSL 打上相应版本的补丁:
cd openssl patch -p1 < ../openssl-patch/openssl-equal-1.1.1d_ciphers.patch

来自开发者的备注:OpenSSL 3.x 版本有很多 API 上的改动,对于这些 OpenSSL 版本来说,这个补丁不再有用。(特指:Chacha20 和 Equal Preference 补丁)在条件允许的情况下,推荐使用 OpenSSL 1.1.x。

下载你所需版本的 Nginx 安装包
解压 Nginx 安装包,切换目录至 Nginx,然后为其打上补丁:

cd nginx/
curl https://raw.githubusercontent.com/hakasenyang/openssl-patch/master/nginx_strict-sni_1.15.10.patch | patch -p1

在 Nginx 的配置指令中指定 OpenSSL 的目录:

./configure --with-http_ssl_module --with-openssl=/root/openssl

重要:在实际的实践中,仅使用这些参数并不能真正使网站如预期一样运行,你需要同时添加你想要和你需要的参数。例如:如果你想要你的网站支持 http/2 协议,则需要添加 --with-http_v2_module 参数。它不会主动自己把自己编译进去。

如果你意图针对非针对性扫描相对于返回空信息,想要把自己的服务器伪装成其它真实存在的网站,以达到给扫描工具假信息的目的,你可以再添加下述参数:

./configure --with-stream=dynamic --with-stream_ssl_module --with-stream_ssl_preread_module --with-http_ssl_module --with-openssl=/root/openssl

注:这部分是指代概要部分中「通过伪造成其它真实存在的网站/CDN 节点来混淆视听」的这一节,目的仅仅是给予发起非指向性扫描的人假信息。对于指向性扫描,它很难很好地达到目的。如果你只是想要对非授权客户端返回一个假网站,例如:手工制作的假网站、设置反向代理等等(同时也对非指向性扫描器返回空结果),你应当跳过这个部分,或者仅仅将这些参数视作「以待后用」添加。

在完成配置后,编译并安装 Nginx。
make && make install

安装到这里就结束了。
出于方便,我个人习惯会在安装完毕后执行以下参数:

ln -s /usr/lib/nginx/modules/ /usr/share/nginx
ln -s /usr/share/nginx/sbin/nginx /usr/sbin

cat > /lib/systemd/system/nginx.service <<-EOF
[Unit]
Description=The NGINX HTTP and reverse proxy server
After=syslog.target network.target remote-fs.target nss-lookup.target

[Service]
Type=forking
PIDFile=/run/nginx.pid
ExecStartPre=/usr/sbin/nginx -t
ExecStart=/usr/sbin/nginx
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s QUIT $MAINPID
PrivateTmp=true

[Install]
WantedBy=multi-user.target
EOF

systemctl enable nginx

配置 strict-sni 补丁的步骤 (Nginx ≤ 1.19.3)

和之前提到的 ssl_reject_handshake 的配置类似,有三个元素需要被配置:

  1. 控制开关
  2. 假的(默认)server 块
  3. 常规 server 块
http {
    # 控制开关
    strict_sni on;
    strict_sni_header on;

    # 假的(默认)server 块
    server {
        server_name  localhost;
        listen       80;
        listen       443 ssl default_server; # "default_server" 需要被写在这里
        ssl_certificate /root/cert.crt; # 可以为任意证书
        ssl_certificate_key /root/cert.key; # 可以为任意证书

        location / {
            return 444;
        }
    }

    # 常规 server 块
    server {
        server_name  normal_domain.tld;
        listen       80;
        listen       443 ssl;
        ssl_certificate /root/cert.crt; # 你的真实证书
        ssl_certificate_key /root/cert/cert.key; # 你的真实证书

        location / {
            echo "Hello World!";   
        }
    }
}

现在,非指向性扫描器不再能获知你在这台服务器上运行什么网站了,除非是被指向性扫描,也就是对方知道你的 Hostname 的情况。

注:使用 return 444; 意味着在返回 HTTP(并非 HTTPS)请求时就是字面意义的什么都不返回。如果没有打上 openssl-patch 补丁,当客户端尝试建立 TLS 链接时,证书信息仍会被返回。
重要:在设置 strict_sni on; 后,CDN 节点若在请求源站时不携带 SNI 信息,将会导致请求失败。参见 proxy_ssl_name.

结果

在开启对应选项后,证书信息不再会被返回。
启用前:

curl -v -k https://35.186.1.1
* Rebuilt URL to: https://35.186.1.1/
*   Trying 35.186.1.1...
* TCP_NODELAY set
* Connected to 35.186.1.1 (35.186.1.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Client hello (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server accepted to use http/1.1
* Server certificate:
*  subject: CN=normal_domain.tld
*  start date: Nov 15 05:41:39 2019 GMT
*  expire date: Nov 14 05:41:39 2020 GMT
*  issuer: CN=normal_domain.tld
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
> GET / HTTP/1.1
> Host: 35.186.1.1
> User-Agent: curl/7.58.0
> Accept: */*
> 
* Empty reply from server
* Connection #0 to host 35.186.1.1 left intact
curl: (52) Empty reply from server

启用后:

curl -v -k https://35.186.1.1
* Rebuilt URL to: https://35.186.1.1/
*   Trying 35.186.1.1...
* TCP_NODELAY set
* Connected to 35.186.1.1 (35.186.1.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS alert, Server hello (2):
* error:14094458:SSL routines:ssl3_read_bytes:tlsv1 unrecognized name
* stopped the pause stream!
* Closing connection 0
curl: (35) error:14094458:SSL routines:ssl3_read_bytes:tlsv1 unrecognized name

谨防不知道,你应当了解:无论你后续怎么配置客户端校验规则(如 HTTP 头信息校验等),客户端在请求时携带目标/部署在服务端的 Hostname 时,证书信息仍会被返回。这是因为它的作用仅仅是预防非指向性扫描:这是建立在攻击者保护知道这台服务器上运行着什么网站的前提上的。如需应对指向性扫描,我强烈建议在条件允许的情况下修改在源站服务器上部署的 Hostname。

使用错误的 Hostname 请求时的结果:(证书信息不会在 Hostname 错误的情况下返回)

curl -v -k --resolve wrong_domain.tld:443:35.186.1.1 https://wrong_domain.tld
* Added wrong_domain.tld:443:35.186.1.1 to DNS cache
* Rebuilt URL to: https://wrong_domain.tld/
* Hostname wrong_domain.tld was found in DNS cache
*   Trying 35.186.1.1...
* TCP_NODELAY set
* Connected to wrong_domain.tld (35.186.1.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS alert, Server hello (2):
* error:14094458:SSL routines:ssl3_read_bytes:tlsv1 unrecognized name
* stopped the pause stream!
* Closing connection 0
curl: (35) error:14094458:SSL routines:ssl3_read_bytes:tlsv1 unrecognized name

使用正确的 Hostname 请求时的结果:(仅在 Hostname 正确的情况下,证书信息才会被返回)

curl -v -k --resolve normal_domain.tld:443:35.186.1.1 https://normal_domain.tld
* Added normal_domain.tld:443:35.186.1.1 to DNS cache
* Rebuilt URL to: https://normal_domain.tld/
* Hostname normal_domain.tld was found in DNS cache
*   Trying 35.186.1.1...
* TCP_NODELAY set
* Connected to normal_domain.tld (35.186.1.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: /etc/ssl/certs/ca-certificates.crt
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Client hello (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384
* ALPN, server accepted to use http/1.1
* Server certificate:
*  subject: CN=normal_domain.tld
*  start date: Nov 15 05:41:39 2019 GMT
*  expire date: Nov 14 05:41:39 2020 GMT
*  issuer: CN=normal_domain.tld
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
> GET / HTTP/1.1
> Host: normal_domain.tld
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Server: nginx/1.17.5
< Date: Fri, 15 Nov 2019 05:53:19 GMT
< Content-Type: text/plain
< Transfer-Encoding: chunked
< Connection: keep-alive
< 
abc
* Connection #0 to host normal_domain.tld left intact

注:如果你知道已知的非指向性扫描器的 IP 范围,你可以将它们全部拦截,权当是再上一层保险。这里给出 Censys 扫描器的 IP 范围:

74.120.14.0/24
167.248.133.0/24
162.142.125.0/24
192.35.168.0/23

通过伪造成其它真实存在的网站/CDN 节点来混淆视听

借由此策略,你可以通过传递假信息,以使得扫描者建立起存在错误信息的数据库。你可能想欺骗非指向性的扫描器,使其认为你的服务器是 CDN 节点服务器;你也可能同时想将你的真实站点混入其中,使得存在指向性的扫描器无法区分你的服务器究竟是 CDN 节点还是源站服务器……

个人来说,我不是很想用这种策略,因为它需要我考虑很多因素:真正的 CDN 会使用的 IDC 服务商(并把我的网站部署在相同的服务商)、其使用的 IP 对应的 AS 名称、其开放的端口、附加的头文件信息等等,以确保攻击者对此困惑。这个过程是非常烦人的。

*重要:如若可能,你应当将 HTTPS 作为唯一的回源协议。**如果不能,你需要注意 HTTP 端口的行为特征。*例如:你想要伪装的目标服务器/网站总是会把 http/80 端口的请求转跳至 https/443,但你没有对你的网站做相同的事情。

注:实际上,伪装成 Cloudflare 的 CDN 服务器算是不上不下的选择。因为就算我们能在官网找到 Cloudflare 的 IP 范围,也是你可能会认为 Cloudflare 仅仅会使用这些 IP 的原因。然而,那些现存实际正在运行 Cloudflare 节点应用,但并没有使用 IP 列表中的 IP 地址的服务器是存在的(或者这些服务器只是在运行正向代理服务,如我将在描述的做法一样)。曾有一次,我执行了一次扫描操作并找到了一些没有使用 Cloudflare IP,但却做着我上面所说事情的服务器。所以,伪装成 Cloudflare CDN 服务器这个主意也算是个主意——因为你确实无需真的要拥有/使用 Cloudflare 的 IP 段。
然而,这么做也不是什么好的选项,这是因为你必须使用为你真实的网站部署自建(包括自签和非自签)的证书。然而我们知道,大多数 Cloudflare 用户都使用由 Cloudflare 签发的证书。如果你想把自己的服务器伪装成 Cloudflare 的 CDN 服务器,请想清楚你是出于什么目的要这么做。

配置

关于安装 ngx_stream_module 的步骤已在之前安装 strict-sni 的步骤里提到,如果你忘记了,可以回去看看。
在本配置文件中有 3 个要点:

  1. 在 http 块中,为 http/80 端口配置的伪装/默认块;
  2. 在 stream 块中,为 https/443 端口配置的伪装/默认块;
  3. 为你真实的域名/网站而设置的到后端的路由块。

配置文件示例:

load_module "modules/ngx_stream_module.so";

http{ # 给自己 DIY 下 http 块
    server {
        listen       80 default_server;
        server_name  localhost;
        location / {
            proxy_pass http://104.27.184.146:80; # 伪装成 Cloudflare CDN 节点服务器
            proxy_set_header   Host   $host;
        }
    }
    server {
        listen       80;
        server_name  yourwebsite.com; # 如果你设置 https 作为唯一的回源协议,你不应该在 http{} 块中配置关于你真实域名的块,就像这里(除非你是监听在 localhost 而不是公网 IP)
        location / {
            proxy_pass http://127.0.0.1:8080; # 你的后端地址
            proxy_set_header   Host   $host;
        }
    }
}

stream{
    map $ssl_preread_server_name $name {
        yourwebsite.com website-upstream; # 你的真实网站路由
        default cloudflare; # 默认路由
    }
    upstream cloudflare {
        server 104.27.184.146:443; # Cloudflare 的 IP
    }
    upstream website-upstream {server 127.0.0.1:8080;} # 你的真实网站后端
    server {
        listen      443;
        proxy_pass  $name;
        proxy_ssl_name $ssl_preread_server_name;
        proxy_ssl_protocols TLSv1.2 TLSv1.3;
        ssl_preread on;
    }
}

结果

它将返回携带其它网站真实存在的证书的内容:

curl -I -v --resolve www.cloudflare.com:443:127.0.0.1 https://www.cloudflare.com/

* Expire in 0 ms for 6 (transfer 0x55f3f0ae0f50)
* Added www.cloudflare.com:443:127.0.0.1 to DNS cache
* Hostname www.cloudflare.com was found in DNS cache
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Expire in 200 ms for 4 (transfer 0x55f3f0ae0f50)
* Connected to www.cloudflare.com (127.0.0.1) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: none
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server accepted to use h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
*   CAfile: none
  CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server accepted to use h2
* Server certificate:
*  subject: businessCategory=Private Organization; jurisdictionC=US; jurisdictionST=Delaware; serialNumber=4710875; C=US; ST=California; L=San Francisco; O=Cloudflare, Inc.; CN=cloudflare.com
*  start date: Oct 30 00:00:00 2018 GMT
*  expire date: Nov  3 12:00:00 2020 GMT
*  subjectAltName: host "www.cloudflare.com" matched cert's "www.cloudflare.com"
*  issuer: C=US; O=DigiCert Inc; OU=www.digicert.com; CN=DigiCert ECC Extended Validation Server CA
*  SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x55f3f0ae0f50)
> HEAD / HTTP/2
> Host: www.cloudflare.com
> User-Agent: curl/7.64.0
> Accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* Connection state changed (MAX_CONCURRENT_STREAMS == 256)!
< HTTP/2 200 
HTTP/2 200 
< date: Tue, 06 Oct 2020 06:26:50 GMT
* Connection #0 to host www.cloudflare.com left intact

(成功伪装成其它网站,部分结果已省略)

通过伪装成其它自制网站/返回空内容来屏蔽非法请求

在开始本部分前,你应当了解这个策略仅能在 CDN 节点可以返回区别鱼正常用户不同内容的前提下才能进行。下面是个例子:

在 GCP 中的回源 HTTP 头信息设置项
HTTP 头信息校验是一种常见的验证请求是否来自 CDN 节点的方式。

注:GCP(Google Cloud Platform) 的 HTTP 负载均衡服务提供了一个选项,可设置在源站服务器向 GCP CDN 节点接收请求时,GCP CDN 节点应额外给出的请求头信息。2这使得源站可以根据将请求从普通/恶意客户端中区分出来。

注:在部分产品中,有些工程师喜欢在节点服务器请求源站服务器时添加一些头信息用于 debug。然而由于他们并非是出于添加特性而这么做的,所以这也不会出现在他们的产品文档中(例如:CDN.net),客服也对此不知道。如果你想找寻在你使用的 CDN 产品中是否有什么特殊头信息,好的做法是:写一个简单的脚本来导出收到的全部头信息。这里就不具体展开了。

配置文件很直观,无需解释。

若想不作返回时的配置文件:

server {
    listen       80;
    server_name  yourdomain.com;

    if ($http_auth_tag != "here_is_the_credential") {
        return 444;
    }
    location / {
        echo "Hello World!";
    }
}

若想返回虚假的网站/后端时的配置文件:

server {
    listen       80;
    server_name  yourdomain.com;

    if ($http_auth_tag != "here_is_the_credential") {
        return @fake;
    }
    location / {
        echo "Hello World!";
    }
    location @fake {
        root /var/www/fakewebsite/; # 强烈建议自己 DIY 一个假站点
    }
}

注:如果你倾向于在 https/443 端口上配置这些,我推荐你使用未知域名自签证书。使用真实且域名为公共暴露的域名可能会让攻击者更容易找到你的源站。Nginx 允许你在 SNI 信息不匹配 server_name 的情况下使用证书
重要:一些人可能会认为使用公共暴露域名的子域名的真实证书,而且还很可能使用 Let's Encrypt 来获取免费证书。我希望你能留心一下证书透明度这个东西。它能获知特定域名下你有哪些证书。特别地,Let's Encrypt 会递交所有其签发的证书至证书透明度日志(CT Log)。(参考:源信息, 存档)
如果你想要知道你的证书是否被收入至证书透明度日志,你可以访问crt.sh
如果你无法确定你签发证书所依赖的 CA 是否会递交所有其签发的证书至证书透明度日志,你最好还是自签证书。

自签证书的指令如下:(为 yeet.com 自签证书的过程)

cat > csrconfig.txt <<-EOF
[ req ]
default_md = sha256
prompt = no
req_extensions = req_ext
distinguished_name = req_distinguished_name
[ req_distinguished_name ]
commonName = yeet.com
countryName = SG
[ req_ext ]
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=critical,serverAuth,clientAuth
subjectAltName = @alt_names
[ alt_names ]
DNS.0 = yeet.com
EOF

cat > certconfig.txt <<-EOF
[ req ]
default_md = sha256
prompt = no
req_extensions = req_ext
distinguished_name = req_distinguished_name
[ req_distinguished_name ]
commonName = yeet.com
countryName = SG
[ req_ext ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=critical,serverAuth,clientAuth
subjectAltName = @alt_names
[ alt_names ]
DNS.0 = yeet.com
EOF

openssl genpkey -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out cert.key
openssl req -new -nodes -key cert.key -config csrconfig.txt -out cert.csr
openssl req -x509 -nodes -in cert.csr -days 365 -key cert.key -config certconfig.txt -extensions req_ext -out cert.pem

考虑到有人可能会拿这些指令生成 csr 用于申请真实证书,我保留了国家的字段(部分 CA 在接收 csr 文件时要求这个字段需要存在),如果不需要可以自行删除。

重要:自签证书可能会增加中间人攻击的风险,除非底层设施是可信的,或 CDN 服务商支持请求源站时携带客户端证书,在 Cloudflare 中又称经过身份验证的源服务器拉取(Authenticated Origin Pulls)


在 Cloudflare 中启用“经过身份验证的源服务器拉取”3

客户端证书校验也是一种验证请求是否来自 CDN 节点的方式。只有少数的 CDN 服务商支持在回源请求时携带客户端证书。无论是哪家服务商具有此特性,在你的服务器上的配置几乎都是一样的。下面是示例:

server {
    listen       443;
    ssl_certificate /etc/nginx/certs/cert.crt;
    ssl_certificate_key /etc/nginx/certs/cert.key;

    server_name  yourdomain.com;

    ssl_client_certificate /etc/nginx/certs/cloudflare.crt;
    ssl_verify_client on;

    error_page 495 496 = @444; # 用于在出现客户端证书校验相关错误时,用自行指定的内容替代默认的错误返回信息

    location @444 {return 444;}

    location / {
        echo "Hello World!";
    }
}

此配置将会在遇到客户端证书错误时不作返回

注:伪装成其它网站/后端也是可行的,只需简单模仿“HTTP 头信息校验”部分中的配置文件即可。
重要:无论你使用何种方式,请注意“默认返回”。使“默认返回”和在遭遇非法请求时的返回一致。

使默认和针对非法请求的返回一样都是不作返回:

server {
    listen       80  default_server;
    listen       443 ssl default_server;
    ssl_certificate /etc/nginx/certs/cert.crt;
    ssl_certificate_key /etc/nginx/certs/cert.key;

    server_name  localhost;

    location / {
        return 444;
    }
}

结果

curl http://127.0.0.1:80
curl: (52) Empty reply from server

curl -k https://127.0.0.1:443   
curl: (92) HTTP/2 stream 0 was not closed cleanly: PROTOCOL_ERROR (err 1)

结论

简单来说,要防止你的源站 IP 不被探测到,你可以:

  1. 在可能的情况下,设置 IP 白名单
  2. 尽可能地修改源站 Hostname 和监听端口
  3. 为不匹配的 Hostname 设置默认返回
  4. 为匹配的 Hostname 设置验证方式
  5. 放下身段去设想扫描器的视角,观察服务器的行为

整个过程可粗略地画作下方的流程图:


这篇文章和其中所有原创制图/作品以 CC BY-SA 4.0 的形式授权。转载请保留英文版本的链接,如需转载中文版需同时保留英文版本和中文版本(掘金/知乎)链接。
我在以中文重写的过程中已经尽量规范用词和避免双语歧义,并尽可能使用中文语言的网页作为外链,但如果依旧存在冲突,请以英文描述为准。


本文参与:🏆 掘金技术征文|双节特别篇

Footnotes

  1. 这里的外链没有使用 Google 搜索「短板理论/木桶原理」之类关键词出来的中文结果,是因为在相关结果中,几乎找不到一篇可以直接使用、有严谨依据的文章。甚至找到一篇质疑这个理论在中文范畴内的诠释合理/适度性的文章,故直接使用了英文 Wikipedia 的结果。

  2. 即便 GCP 负载均衡/CDN 服务仅接受 GCP 虚拟机器的实例作为后端,但原理都是相同的。

  3. 最早我是把“Authenticated Origin Pulls”翻译成“源站拉取验证”的,看了官方翻译后果断切回英文了=_=