OSINT流出防御のためのCloudflare Zero Trustの導入

概要

Cloudflare Proxyを使用していたが、CensysやShodanなどのOSINT(Open Source Intelligence)検索エンジンにOrigin IPとポート情報が露出する問題があった。

原因を確認し、Originへの直接アクセスの遮断、管理ポートの非公開化、Cloudflare Tunnel基盤のZTNAまでを構築した。

問題状況の分析

Cloudflareを通じてサービスを運営中であったが、OSINT検索エンジンでProxy IPではなくOrigin IPが確認された。

原因分析

サーバーのIPが露出した原因は以下の通りである。

  • TLS証明書の露出
  • CensysなどのスキャナーはインターネットのPublic IPを継続的にスキャンする。
  • Origin IPの443ポートに直接接続した際、Nginxが実際のサービス証明書を返却していた。
  • 証明書のSANなどを通じて、該当IPとドメインを関連付けることができた。
  • Inboundファイアウォールポリシーの不備(80、443ポートのファイアウォール設定を行っていなかったため、プロキシを経由しない接続が可能だった)

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へのアクセスを防ぐことはできない。

ファイアウォールポリシーの変更

Inbound Access ListをCloudflare IP帯域の変更に備えて自動的に更新する。

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 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

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