引入 Cloudflare Zero Trust 以防范 OSINT 泄漏

概述

虽然使用了 Cloudflare Proxy,但源站 IP 和端口信息却被暴露在了 Censys、Shodan 等 OSINT(开源情报)搜索引擎中。

在确认原因后,我实施了阻止源站直接访问、隐藏管理端口以及配置基于 Cloudflare Tunnel 的 ZTNA 等措施。

问题情况分析

尽管通过 Cloudflare 运营服务,但在 OSINT 搜索引擎中却查到了源站 IP(Origin IP),而非代理 IP(Proxy IP)。

原因分析

服务器 IP 泄漏的原因如下:

  • TLS 证书泄漏
  • 类似 Censys 的扫描器会持续扫描互联网的公共 IP (Public IP)。
  • 当直接连接到源站 IP 的 443 端口时,Nginx 会返回实际服务的证书。
  • 通过证书的 SAN 等信息,可以将该 IP 与域名关联起来。
  • 入站(Inbound)防火墙策略不足(未对 80 和 443 端口进行防火墙设置,导致可以直接绕过代理进行访问)

Nginx 安全加固

进行基于 IP 的直接访问时,配置为不必要地返回实际证书。

设置拒绝 SSL 握手 (SSL Handshake)

ssl_reject_handshake 在 Nginx 1.19.4 及以上版本中受支持。

首先检查版本。

bash
nginx -v

File: /etc/nginx/sites-available/default

nginx
server {
    listen 443 ssl default_server;
    server_name _;

    ssl_reject_handshake on;
}
bash
sudo nginx -t
sudo systemctl reload nginx

此设置用于拒绝进入 Default Server 的 TLS 握手。

如果攻击者将实际域名指定为 SNI,则可能会选择正常的 Server Block,因此仅靠这一点无法阻止对源站的访问。

更改防火墙策略

为了应对 Cloudflare IP 网段的变化,自动更新入站访问列表 (Inbound Access List)。

File: cloudflare_ip_update.sh

bash
#!/usr/bin/env bash

set -Eeuo pipefail
umask 077

readonly TABLE="cf_origin"
readonly IPV4_SET="cf_ipv4"
readonly IPV6_SET="cf_ipv6"

readonly CLOUDFLARE_IPV4_URL="https://www.cloudflare.com/ips-v4"
readonly CLOUDFLARE_IPV6_URL="https://www.cloudflare.com/ips-v6"

readonly LOCK_FILE="/run/cloudflare_nft_update.lock"

log() {
    printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}

error() {
    printf 'Error: %s\n' "$*" >&2
}

if [[ $EUID -ne 0 ]]; then
    error "Run as root."
    exit 1
fi

for cmd in curl nft python3 flock paste mktemp; do
    if ! command -v "$cmd" >/dev/null 2>&1; then
        error "$cmd is required."
        exit 1
    fi
done

exec 200>"$LOCK_FILE"

if ! flock -n 200; then
    error "Another update process is already running."
    exit 1
fi

WORK_DIR="$(mktemp -d)"
IPV4_RAW="$WORK_DIR/ips-v4.raw"
IPV6_RAW="$WORK_DIR/ips-v6.raw"
IPV4_FILE="$WORK_DIR/ips-v4"
IPV6_FILE="$WORK_DIR/ips-v6"
NFT_FILE="$WORK_DIR/cloudflare.nft"

cleanup() {
    rm -rf "$WORK_DIR"
}

trap cleanup EXIT
trap 'error "Update failed at line $LINENO."' ERR

download() {
    local url="$1"
    local output="$2"

    curl \
        --fail \
        --silent \
        --show-error \
        --location \
        --proto '=https' \
        --tlsv1.2 \
        --connect-timeout 10 \
        --max-time 30 \
        --retry 3 \
        --retry-delay 2 \
        --retry-connrefused \
        --output "$output" \
        "$url"
}

log "Downloading Cloudflare IP ranges..."

download "$CLOUDFLARE_IPV4_URL" "$IPV4_RAW"
download "$CLOUDFLARE_IPV6_URL" "$IPV6_RAW"

if [[ ! -s "$IPV4_RAW" ]]; then
    error "Cloudflare returned an empty IPv4 list."
    exit 1
fi

if [[ ! -s "$IPV6_RAW" ]]; then
    error "Cloudflare returned an empty IPv6 list."
    exit 1
fi

##
## Validate all downloaded values and rewrite them into a canonical form.
##
## This also prevents arbitrary nftables syntax from being injected through
## the downloaded files.
##
python3 - \
    "$IPV4_RAW" \
    "$IPV6_RAW" \
    "$IPV4_FILE" \
    "$IPV6_FILE" <<'PY'
import ipaddress
import sys

v4_input, v6_input, v4_output, v6_output = sys.argv[1:]


def parse_networks(path, version):
    networks = []

    with open(path, "r", encoding="ascii") as f:
        for line_number, line in enumerate(f, 1):
            value = line.strip()

            if not value:
                continue

            try:
                network = ipaddress.ip_network(value, strict=True)
            except ValueError as exc:
                raise SystemExit(
                    f"{path}:{line_number}: invalid CIDR {value!r}: {exc}"
                )

            if network.version != version:
                raise SystemExit(
                    f"{path}:{line_number}: expected IPv{version}, "
                    f"got IPv{network.version}: {value}"
                )

            networks.append(network)

    if not networks:
        raise SystemExit(f"IPv{version} list is empty")

    #
    # Remove duplicates and produce deterministic output.
    #
    networks = sorted(
        set(networks),
        key=lambda net: (
            int(net.network_address),
            net.prefixlen,
        ),
    )

    return networks


ipv4 = parse_networks(v4_input, 4)
ipv6 = parse_networks(v6_input, 6)

with open(v4_output, "w", encoding="ascii") as f:
    for network in ipv4:
        f.write(f"{network}\n")

with open(v6_output, "w", encoding="ascii") as f:
    for network in ipv6:
        f.write(f"{network}\n")

print(f"Validated {len(ipv4)} IPv4 networks")
print(f"Validated {len(ipv6)} IPv6 networks")
PY

IPV4_ELEMENTS="$(paste -sd, "$IPV4_FILE")"
IPV6_ELEMENTS="$(paste -sd, "$IPV6_FILE")"

##
## Recreate our dedicated table on every run.
##
## Because all commands are passed to nft as one batch, the change is
## committed as a single nftables transaction.
##
if nft list table inet "$TABLE" >/dev/null 2>&1; then
    printf 'delete table inet %s\n' "$TABLE" >> "$NFT_FILE"
fi

cat >> "$NFT_FILE" <<EOF
add table inet $TABLE

add set inet $TABLE $IPV4_SET {
    type ipv4_addr;
    flags interval;
}

add set inet $TABLE $IPV6_SET {
    type ipv6_addr;
    flags interval;
}

add element inet $TABLE $IPV4_SET {
    $IPV4_ELEMENTS
}

add element inet $TABLE $IPV6_SET {
    $IPV6_ELEMENTS
}

add chain inet $TABLE input {
    type filter hook input priority -10;
    policy accept;
}

## Allow localhost health checks / local reverse proxy access.
add rule inet $TABLE input iifname "lo" accept

## HTTP/HTTPS may reach the origin only from Cloudflare.
add rule inet $TABLE input \
    meta nfproto ipv4 \
    tcp dport { 80, 443 } \
    ip saddr @$IPV4_SET \
    accept

add rule inet $TABLE input \
    meta nfproto ipv4 \
    tcp dport { 80, 443 } \
    drop

add rule inet $TABLE input \
    meta nfproto ipv6 \
    tcp dport { 80, 443 } \
    ip6 saddr @$IPV6_SET \
    accept

add rule inet $TABLE input \
    meta nfproto ipv6 \
    tcp dport { 80, 443 } \
    drop
EOF

log "Checking nftables configuration..."

nft -c -f "$NFT_FILE"

log "Applying Cloudflare origin firewall..."

nft -f "$NFT_FILE"

log "Cloudflare origin firewall updated successfully."

echo
nft list table inet "$TABLE"

由于可能会出现脚本出错或出于其他不得已的原因导致无法访问服务器的问题,建议直接在实例管理仪表板中管理 22 SSH 端口。

现在不光洗角色,连电脑也一起洗了

@Binci

更换公网 IP (Public IP)

退回现有的公网 IP,并分配一个新的临时 IP (Ephemeral IP)。

重新生成 SSH 主机密钥 (SSH Host Key)

如果没有发生实际密钥泄漏,这并非必须执行的操作,但为了防止指纹识别 (Fingerprinting),我进行了更改。

bash
sudo rm /etc/ssh/ssh_host_*
sudo dpkg-reconfigure openssh-server
sudo systemctl restart ssh

Zero Trust Network Access

现在不直接将其暴露在公共互联网 (Public Internet) 中,而是通过 Cloudflare Tunnel 进行访问。

安装 cloudflared

image

在 Cloudflare One Dashboard 中创建 Tunnel 并获取 Token。

bash
sudo cloudflared service install <YOUR_TOKEN>

安装后进行检查。

bash
sudo systemctl status cloudflared

配置 Split Tunnel 及私有网络 (Private Network)

image

首先检查服务器的 IP。

bash
hostname -I

示例:

text
123.xxx.xxx.xxx 10.0.12.34

私有 IPv4 网段如下:

私有 IP 网段 (Private IP)

  • 10.0.0.0/8
  • 172.16.0.0/12
  • 192.168.0.0/16

Team & Resources/Devices > Devices > Device profiles > Profile > Split Tunnels

image

如果您要访问的私有网络网段包含在 Exclude(排除)中,请调整相应范围。

image

Networks/Routes > Routes > CIDR Routes

添加私有网络。

image

访问

安装 Cloudflare One Agent (WARP Client) 后,登录配置时使用的 Team Domain。

image