導入 Cloudflare Zero Trust 以防範 OSINT 洩漏

概述

雖然使用了 Cloudflare Proxy,但仍存在 Origin IP 與連接埠資訊暴露在 Censys、Shodan 等 OSINT(開源情資,Open Source Intelligence)搜尋引擎上的問題。

在確認原因後,我們實作了封鎖直接存取 Origin、隱蔽管理連接埠,以及基於 Cloudflare Tunnel 的 ZTNA。

問題狀況分析

雖然透過 Cloudflare 營運服務,但在 OSINT 搜尋引擎中卻能查到 Proxy IP 以外的 Origin IP。

原因分析

伺服器 IP 洩漏的原因如下:

  • TLS 憑證外洩
  • 像 Censys 這樣的掃描器會持續掃描網際網路的 Public IP。
  • 當直接連線至 Origin IP 的 443 連接埠時,Nginx 會回傳實際的服務憑證。
  • 透過憑證的 SAN 等欄位,可以將該 IP 與網域產生關聯。
  • Inbound 防火牆政策不足(未設定 80 與 443 連接埠的防火牆規則,導致能夠繞過 Proxy 進行連線)

Nginx 安全強化

設定以避免在基於 IP 的直接連線時,不必要地回傳實際憑證。

拒絕 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 Handshake。

由於當攻擊者將實際網域指定為 SNI 時,仍可能會選擇到正常的 Server Block,因此僅靠這個設定並無法完全阻止對 Origin 的存取。

變更防火牆政策

為了因應 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

更換 Public IP

歸還現有的 Public IP 並配置新的 Ephemeral IP。

重新產生 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 儀表板中建立 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

Private IPv4 區段如下:

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

如果想要存取的 Private Network 區段包含在 Exclude 中,請調整該範圍。

image

Networks/Routes > Routes > CIDR Routes

新增 Private Network。

image

連線

安裝 Cloudflare One Agent (WARP Client) 後,登入設定時使用的 Team Domain。

image