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