【アップデート】Network Firewall の SNI 詐称対策が強化されたので、再現して確かめてみた

【アップデート】Network Firewall の SNI 詐称対策が強化されたので、再現して確かめてみた

AWS Network Firewall の Route 53 Resolved IP validation で、SNI 詐称を試しました。標準のドメインルールだけでは通った詐称が、ポリシーを DROP にすると止まり、名前解決した example.com への接続は通りました。
2026.10.22

はじめに

2026年10月21日、AWS Network Firewall のアップデートが発表されました。

https://aws.amazon.com/jp/about-aws/whats-new/2026/10/network-firewall-route-53-ip-validation/

このアップデートで加わった Resolved IP validation は、接続先 IP が VPC の Route 53 Resolver の名前解決で返されたものかを検証する機能です。発表では、SNI 詐称への対策を簡単にできると説明されています。SNI 詐称は、SNI で許可ドメインを名乗って別の IP へ接続することです。

発表には、SSH や SFTP など HTTP/HTTPS 以外にもドメインベースのフィルタを広げられることも書かれています。また、Resolved IP validation は Network Firewall と Route 53 Resolver DNS Firewall が使える全商用リージョンで、追加料金なしで利用できると説明されています。

この記事では、東京リージョンで Resolved IP validation の有無を切り替えて、HTTPS の SNI 詐称を実行しました。

Resolved IP validation の仕組み

標準のドメインルールは、クライアントが名乗る SNI か Host ヘッダーをドメインリストと照合します。Resolved IP validation は、これに加えて接続先 IP を照合します。

照合のレベルは 2 つあります。ポリシーレベルは、接続先 IP がいずれかのドメインの名前解決で返された IP かを照合し、単独で使えます。SNI や Host で名乗ったドメインとは別のドメインの名前解決で Resolver が返した IP も、この照合を通ります。

ルールレベルは、接続先 IP が特定のドメインの名前解決で返された IP かを照合し、ポリシーレベルの設定が前提です。ドメインリストのターゲット種別 AWS_ROUTE_53_RESOLVED_DOMAIN か Suricata キーワードで指定します。このターゲット種別は、許可リスト型のドメインリストで TLS_SNI や HTTP_HOST と併用できます。その場合に許可されるのは、SNI か Host が一致し、かつ接続先 IP がそのドメインの名前解決で返された IP である接続だけです。この記事では、ルールレベルの照合を指定したルールを resolved-domain ルールと呼びます。

名前解決の記録と照合は AZ 単位なので、クライアントとファイアウォールエンドポイントは同じ AZ に置く必要があります。同じ DNS Firewall ルールグループを複数の VPC に関連付けると、それらの VPC の名前解決結果は 1 つにまとめて照合されます。そのため、ルールグループはファイアウォールごとに分けます。

https://docs.aws.amazon.com/network-firewall/latest/developerguide/route53-resolved-ip-validation-how-it-works.html

https://docs.aws.amazon.com/network-firewall/latest/developerguide/stateful-rule-groups-domain-names.html

検証の構成

環境と構成

2026年10月22日に、ap-northeast-1 の ap-northeast-1a(AZ ID apne1-az4)で、aws-cli/2.38.4 を使って実施しました。新規 VPC(10.77.0.0/16)にファイアウォールサブネットとクライアントサブネットを 1 つずつ置いた 1 AZ 構成です。クライアントはパブリック IP を持つ Amazon Linux 2023 の t3.micro です。構成は次の図のとおりです。

検証構成図: クライアント EC2 から Network Firewall エンドポイント、インターネットゲートウェイを経由して詐称先 EC2 へ接続する。Route 53 Resolver と DNS Firewall は名前解決の結果をファイアウォールに記録する

詐称先はデフォルト VPC に置いた t3.micro の EC2 で、自己署名証明書で 443 を待ち受け、TARGET-SERVER-REACHED を返します。詐称先のセキュリティグループは、クライアントのパブリック IP /32 からの 443 だけを許可します。詐称先の IP は、検証用 VPC の Resolver がどのドメインの名前解決でも返したことのない IP です。ファイアウォールが許可するドメインは example.com だけです。

ドキュメントは、有効化の直後にポリシーの Action を ALERT にして約 24 時間おき、DNS キャッシュが入れ替わってから DROP か REJECT に切り替えることを推奨しています。

https://docs.aws.amazon.com/network-firewall/latest/developerguide/route53-resolved-ip-validation-recommendations.html

この検証では 24 時間おきませんでした。クライアントの起動後、ALERT で約 7 分通信を流し、Resolved IP validation を外した状態を経て、約 10 分で DROP に切り替えています。クライアントは DNS Firewall ルールグループの関連付けのあとに起動したので、関連付け前の DNS キャッシュを持ちません。

構築スクリプトは 01、02、03 の順に実行します。01 には AZ 名を渡します(./01-setup-network.sh ap-northeast-1a)。/tmp/nfw-riv/ids.env は 01 が作成し、02 と 03 は、自分が作った ID を追記します。後のスクリプトはこのファイルを読み込んで ID を引き継ぎます。03 は同じディレクトリの target-userdata.sh を読み込みます。01 が作る SSM 用の VPC エンドポイントと IAM ロールは、SSM Run Command でクライアント上の curl を実行するためのものです。

構築スクリプト(01、02、03、target-userdata.sh)

01-setup-network.sh

#!/usr/bin/env bash
# 検証用 VPC、DNS Firewall ルールグループ、SSM 用 VPC エンドポイント、IAM ロールを作る。
# 前提: 一時認証情報が環境変数 (AWS_ACCESS_KEY_ID など) にある。AZ は第1引数 (例: ap-northeast-1a)。
set -euo pipefail
export AWS_DEFAULT_REGION=ap-northeast-1 AWS_PAGER=""
AZ="${1:?AZ name required}"
W=/tmp/nfw-riv; mkdir -p "$W"
TAG='{Key=project,Value=nfw-riv-1022}'

VPC=$(aws ec2 create-vpc --cidr-block 10.77.0.0/16 \
  --tag-specifications "ResourceType=vpc,Tags=[{Key=Name,Value=nfw-riv-vpc},$TAG]" \
  --query Vpc.VpcId --output text)
aws ec2 modify-vpc-attribute --vpc-id "$VPC" --enable-dns-support '{"Value":true}'
aws ec2 modify-vpc-attribute --vpc-id "$VPC" --enable-dns-hostnames '{"Value":true}'

IGW=$(aws ec2 create-internet-gateway \
  --tag-specifications "ResourceType=internet-gateway,Tags=[{Key=Name,Value=nfw-riv-igw},$TAG]" \
  --query InternetGateway.InternetGatewayId --output text)
aws ec2 attach-internet-gateway --internet-gateway-id "$IGW" --vpc-id "$VPC"

FW_SUBNET=$(aws ec2 create-subnet --vpc-id "$VPC" --cidr-block 10.77.1.0/24 --availability-zone "$AZ" \
  --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=nfw-riv-fw},$TAG]" \
  --query Subnet.SubnetId --output text)
CL_SUBNET=$(aws ec2 create-subnet --vpc-id "$VPC" --cidr-block 10.77.2.0/24 --availability-zone "$AZ" \
  --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=nfw-riv-client},$TAG]" \
  --query Subnet.SubnetId --output text)
aws ec2 modify-subnet-attribute --subnet-id "$CL_SUBNET" --map-public-ip-on-launch

# DNS Firewall ルールグループ(ルールは空)を作り、VPC に関連付ける
FRG=$(aws route53resolver create-firewall-rule-group --name nfw-riv-frg \
  --tags "Key=project,Value=nfw-riv-1022" \
  --query FirewallRuleGroup.Id --output text)
ASSOC=$(aws route53resolver associate-firewall-rule-group --firewall-rule-group-id "$FRG" \
  --vpc-id "$VPC" --priority 101 --name nfw-riv-assoc \
  --query FirewallRuleGroupAssociation.Id --output text)

# クライアント用 SG とエンドポイント用 SG
CL_SG=$(aws ec2 create-security-group --group-name nfw-riv-client --description "nfw-riv client" --vpc-id "$VPC" \
  --tag-specifications "ResourceType=security-group,Tags=[{Key=Name,Value=nfw-riv-client},$TAG]" \
  --query GroupId --output text)
EP_SG=$(aws ec2 create-security-group --group-name nfw-riv-endpoint --description "nfw-riv ssm endpoints" --vpc-id "$VPC" \
  --tag-specifications "ResourceType=security-group,Tags=[{Key=Name,Value=nfw-riv-endpoint},$TAG]" \
  --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$EP_SG" --protocol tcp --port 443 --source-group "$CL_SG" >/dev/null

# SSM 用インターフェイスエンドポイント(VPC 内通信なのでファイアウォールを通らない)
EPS=""
for svc in ssm ssmmessages ec2messages; do
  id=$(aws ec2 create-vpc-endpoint --vpc-id "$VPC" --vpc-endpoint-type Interface \
    --service-name "com.amazonaws.ap-northeast-1.$svc" --subnet-ids "$CL_SUBNET" \
    --security-group-ids "$EP_SG" --private-dns-enabled \
    --tag-specifications "ResourceType=vpc-endpoint,Tags=[{Key=Name,Value=nfw-riv-$svc},$TAG]" \
    --query VpcEndpoint.VpcEndpointId --output text)
  EPS="$EPS $id"
done

# SSM 接続用 IAM ロールとインスタンスプロファイル
aws iam create-role --role-name nfw-riv-ssm-role --tags "Key=project,Value=nfw-riv-1022" \
  --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' >/dev/null
aws iam attach-role-policy --role-name nfw-riv-ssm-role --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam create-instance-profile --instance-profile-name nfw-riv-ssm-profile >/dev/null
aws iam add-role-to-instance-profile --instance-profile-name nfw-riv-ssm-profile --role-name nfw-riv-ssm-role

cat > "$W/ids.env" <<EOF
AZ=$AZ
VPC=$VPC
IGW=$IGW
FW_SUBNET=$FW_SUBNET
CL_SUBNET=$CL_SUBNET
FRG=$FRG
ASSOC=$ASSOC
CL_SG=$CL_SG
EP_SG=$EP_SG
EPS="$EPS"
EOF
cat "$W/ids.env"

02-create-firewall.sh

#!/usr/bin/env bash
# Network Firewall のルールグループ、ポリシー(Resolved IP validation = ALERT)、ファイアウォールを作る。
# 前提: 01-setup-network.sh を実行済みで /tmp/nfw-riv/ids.env がある。
set -euo pipefail
export AWS_DEFAULT_REGION=ap-northeast-1 AWS_PAGER=""
W=/tmp/nfw-riv; source "$W/ids.env"
TAGS='[{"Key":"project","Value":"nfw-riv-1022"}]'
ALLOWED_DOMAIN=example.com

# ステートフルルール: ドメインリスト(許可リスト)。ターゲット種別は SNI と Host のみ(標準のドメインルール)
RG_ARN=$(aws network-firewall create-rule-group --rule-group-name nfw-riv-allow-domain \
  --type STATEFUL --capacity 100 --tags "$TAGS" \
  --rule-group "{
    \"RulesSource\": {\"RulesSourceList\": {
      \"Targets\": [\"$ALLOWED_DOMAIN\"],
      \"TargetTypes\": [\"TLS_SNI\", \"HTTP_HOST\"],
      \"GeneratedRulesType\": \"ALLOWLIST\"}},
    \"StatefulRuleOptions\": {\"RuleOrder\": \"STRICT_ORDER\"}}" \
  --query 'RuleGroupResponse.RuleGroupArn' --output text)

# ファイアウォールポリシー: Resolved IP validation を ALERT で有効化
POLICY_ARN=$(aws network-firewall create-firewall-policy --firewall-policy-name nfw-riv-policy \
  --tags "$TAGS" \
  --firewall-policy "{
    \"StatelessDefaultActions\": [\"aws:forward_to_sfe\"],
    \"StatelessFragmentDefaultActions\": [\"aws:forward_to_sfe\"],
    \"StatefulEngineOptions\": {\"RuleOrder\": \"STRICT_ORDER\"},
    \"StatefulDefaultActions\": [\"aws:drop_established\", \"aws:alert_established\"],
    \"StatefulRuleGroupReferences\": [{\"ResourceArn\": \"$RG_ARN\", \"Priority\": 100}],
    \"Route53ResolvedIPValidation\": {
      \"Action\": \"ALERT\",
      \"DNSFirewallRuleGroupId\": \"$FRG\",
      \"DNSExpiryExceptionPolicy\": \"CONTINUE\"}}" \
  --query 'FirewallPolicyResponse.FirewallPolicyArn' --output text)

aws network-firewall create-firewall --firewall-name nfw-riv-fw \
  --firewall-policy-arn "$POLICY_ARN" --vpc-id "$VPC" \
  --subnet-mappings "SubnetId=$FW_SUBNET" --tags "$TAGS" \
  --query 'Firewall.FirewallArn' --output text >/dev/null

# ログ出力先(CloudWatch Logs)
for t in flow alert; do
  aws logs create-log-group --log-group-name "/nfw-riv/$t" --tags project=nfw-riv-1022
  aws logs put-retention-policy --log-group-name "/nfw-riv/$t" --retention-in-days 1
done

echo "RG_ARN=$RG_ARN" >> "$W/ids.env"
echo "POLICY_ARN=$POLICY_ARN" >> "$W/ids.env"

# READY と同期完了を待つ(最大 25 分)
for i in $(seq 1 100); do
  s=$(aws network-firewall describe-firewall --firewall-name nfw-riv-fw \
    --query '[FirewallStatus.Status,FirewallStatus.ConfigurationSyncStateSummary]' --output text)
  echo "$(date +%H:%M:%S) $s"
  [ "$s" = "READY	IN_SYNC" ] && break
  sleep 15
done

# ロギング設定は 1 回の呼び出しで 1 つの出力先しか追加できない。FLOW → ALERT の順に 2 回呼ぶ
aws network-firewall update-logging-configuration --firewall-name nfw-riv-fw \
  --logging-configuration '{"LogDestinationConfigs":[
    {"LogType":"FLOW","LogDestinationType":"CloudWatchLogs","LogDestination":{"logGroup":"/nfw-riv/flow"}}]}' >/dev/null
aws network-firewall update-logging-configuration --firewall-name nfw-riv-fw \
  --logging-configuration '{"LogDestinationConfigs":[
    {"LogType":"FLOW","LogDestinationType":"CloudWatchLogs","LogDestination":{"logGroup":"/nfw-riv/flow"}},
    {"LogType":"ALERT","LogDestinationType":"CloudWatchLogs","LogDestination":{"logGroup":"/nfw-riv/alert"}}]}' >/dev/null

VPCE=$(aws network-firewall describe-firewall --firewall-name nfw-riv-fw \
  --query "FirewallStatus.SyncStates.\"$AZ\".Attachment.EndpointId" --output text)
echo "VPCE=$VPCE" >> "$W/ids.env"
echo "VPCE=$VPCE"

03-create-routes-and-instances.sh

#!/usr/bin/env bash
# ルートテーブル(クライアント → ファイアウォール → IGW、戻りは IGW エッジ経由)と、
# クライアント EC2 / 詐称先(target)EC2 を作る。
# 前提: 02-create-firewall.sh を実行済み(/tmp/nfw-riv/ids.env に VPCE がある)。
set -euo pipefail
export AWS_DEFAULT_REGION=ap-northeast-1 AWS_PAGER=""
W=/tmp/nfw-riv; source "$W/ids.env"
TAG='{Key=project,Value=nfw-riv-1022}'

# --- ルーティング ---
FW_RT=$(aws ec2 create-route-table --vpc-id "$VPC" \
  --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=nfw-riv-fw-rt},$TAG]" \
  --query RouteTable.RouteTableId --output text)
aws ec2 create-route --route-table-id "$FW_RT" --destination-cidr-block 0.0.0.0/0 --gateway-id "$IGW" >/dev/null
aws ec2 associate-route-table --route-table-id "$FW_RT" --subnet-id "$FW_SUBNET" >/dev/null

CL_RT=$(aws ec2 create-route-table --vpc-id "$VPC" \
  --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=nfw-riv-client-rt},$TAG]" \
  --query RouteTable.RouteTableId --output text)
aws ec2 create-route --route-table-id "$CL_RT" --destination-cidr-block 0.0.0.0/0 --vpc-endpoint-id "$VPCE" >/dev/null
aws ec2 associate-route-table --route-table-id "$CL_RT" --subnet-id "$CL_SUBNET" >/dev/null

EDGE_RT=$(aws ec2 create-route-table --vpc-id "$VPC" \
  --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=nfw-riv-edge-rt},$TAG]" \
  --query RouteTable.RouteTableId --output text)
aws ec2 create-route --route-table-id "$EDGE_RT" --destination-cidr-block 10.77.2.0/24 --vpc-endpoint-id "$VPCE" >/dev/null
EDGE_ASSOC=$(aws ec2 associate-route-table --route-table-id "$EDGE_RT" --gateway-id "$IGW" \
  --query AssociationId --output text)

# --- クライアント EC2 (ファイアウォール配下) ---
AMI=$(aws ssm get-parameter --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
  --query Parameter.Value --output text)
CLIENT=""
for i in 1 2 3 4 5 6; do
  CLIENT=$(aws ec2 run-instances --image-id "$AMI" --instance-type t3.micro \
    --subnet-id "$CL_SUBNET" --security-group-ids "$CL_SG" \
    --iam-instance-profile Name=nfw-riv-ssm-profile \
    --metadata-options HttpTokens=required \
    --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=nfw-riv-client},$TAG]" \
    --query 'Instances[0].InstanceId' --output text 2>/dev/null) && break
  echo "run-instances retry $i (instance profile propagation)"; sleep 10
done
aws ec2 wait instance-running --instance-ids "$CLIENT"
CLIENT_IP=$(aws ec2 describe-instances --instance-ids "$CLIENT" \
  --query 'Reservations[0].Instances[0].PublicIpAddress' --output text)

# --- 詐称先(target) EC2: 既定 VPC に置く。443 はクライアントの公開 IP からだけ許可 ---
DVPC=$(aws ec2 describe-vpcs --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output text)
DSUBNET=$(aws ec2 describe-subnets --filters Name=vpc-id,Values="$DVPC" Name=default-for-az,Values=true \
  --query 'Subnets[0].SubnetId' --output text)
T_SG=$(aws ec2 create-security-group --group-name nfw-riv-target --description "nfw-riv target" --vpc-id "$DVPC" \
  --tag-specifications "ResourceType=security-group,Tags=[{Key=Name,Value=nfw-riv-target},$TAG]" \
  --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id "$T_SG" --protocol tcp --port 443 --cidr "$CLIENT_IP/32" >/dev/null

TARGET=$(aws ec2 run-instances --image-id "$AMI" --instance-type t3.micro \
  --subnet-id "$DSUBNET" --security-group-ids "$T_SG" --associate-public-ip-address \
  --metadata-options HttpTokens=required \
  --user-data file://"$(dirname "$0")/target-userdata.sh" \
  --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=nfw-riv-target},$TAG]" \
  --query 'Instances[0].InstanceId' --output text)
aws ec2 wait instance-running --instance-ids "$TARGET"
TARGET_IP=$(aws ec2 describe-instances --instance-ids "$TARGET" \
  --query 'Reservations[0].Instances[0].PublicIpAddress' --output text)

cat >> "$W/ids.env" <<EOF
FW_RT=$FW_RT
CL_RT=$CL_RT
EDGE_RT=$EDGE_RT
EDGE_ASSOC=$EDGE_ASSOC
CLIENT=$CLIENT
CLIENT_IP=$CLIENT_IP
DVPC=$DVPC
T_SG=$T_SG
TARGET=$TARGET
TARGET_IP=$TARGET_IP
EOF
echo "CLIENT=$CLIENT CLIENT_IP=$CLIENT_IP"
echo "TARGET=$TARGET TARGET_IP=$TARGET_IP"

target-userdata.sh

#!/bin/bash
# 詐称先サーバー: 自己署名証明書で 443 を待ち受け、固定の文字列を返す
openssl req -x509 -newkey rsa:2048 -nodes -keyout /tmp/k.pem -out /tmp/c.pem -days 1 -subj "/CN=target"
cat > /tmp/s.py <<'EOF'
import http.server, ssl
class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        body = b"TARGET-SERVER-REACHED\n"
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def finish(self):
        # TLS の close_notify を送ってから閉じる(curl が EOF エラーにしないため)
        super().finish()
        try:
            self.connection.unwrap()
        except Exception:
            pass
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain("/tmp/c.pem", "/tmp/k.pem")
s = http.server.HTTPServer(("0.0.0.0", 443), H)
s.socket = ctx.wrap_socket(s.socket, server_side=True)
s.serve_forever()
EOF
nohup python3 /tmp/s.py > /tmp/server.log 2>&1 &

対応 AZ の確認

ドキュメントによると、Resolved IP validation を使えない AZ があります。その AZ の通信は DnsAzUnavailableFlows に数えられます。resolved-domain ルールは、その AZ ではポリシーの Action に関係なく対象の通信を drop します。ドキュメントに対応 AZ の一覧はありません。AZ 名と物理 AZ の対応はアカウントごとに異なるので、ここでは AZ ID を併記します。

ap-northeast-1a(apne1-az4)では、検証期間全体(ALERT で通信を流した期間を含む)で、DnsAzUnavailableFlows の合計は 0 でした。同じ期間の DnsResolvedFlows は 94、DnsUnresolvedFlows は 6 でした。取得に使ったコマンドは次のとおりです。

aws cloudwatch get-metric-statistics --namespace AWS/NetworkFirewall --metric-name DnsAzUnavailableFlows \
  --dimensions Name=FirewallName,Value=nfw-riv-fw Name=AvailabilityZone,Value=ap-northeast-1a Name=Engine,Value=Stateful \
  --start-time 2026-10-22T01:25:00Z --end-time 2026-10-22T01:50:00Z --period 1500 --statistics Sum \
  --query "Datapoints[0].Sum" --output text

DROP や resolved-domain ルールを入れる前に、使う AZ のポリシーを ALERT にして通信を流し、DnsAzUnavailableFlows が 0 のままか確認してください。

https://docs.aws.amazon.com/network-firewall/latest/developerguide/route53-resolved-ip-validation-troubleshooting.html

設定

Resolved IP validation をポリシーに設定する前提として、DNS Firewall ルールグループを作って VPC に関連付けます。ルールグループにルールは入れず、ポリシーの DNSFirewallRuleGroupId から参照します。$VPC には検証用 VPC の ID を入れます。

FRG=$(aws route53resolver create-firewall-rule-group --name nfw-riv-frg \
  --query FirewallRuleGroup.Id --output text)
aws route53resolver associate-firewall-rule-group --firewall-rule-group-id "$FRG" \
  --vpc-id "$VPC" --priority 101 --name nfw-riv-assoc

次は DROP の状態のファイアウォールポリシーです。アカウント ID と DNS Firewall ルールグループの ID は置き換えています。ALERT の状態は Action だけが違い、Resolved IP validation なしの状態は Route53ResolvedIPValidation を外したものです。

{
    "StatelessDefaultActions": [
        "aws:forward_to_sfe"
    ],
    "StatelessFragmentDefaultActions": [
        "aws:forward_to_sfe"
    ],
    "StatefulRuleGroupReferences": [
        {
            "ResourceArn": "arn:aws:network-firewall:ap-northeast-1:111122223333:stateful-rulegroup/nfw-riv-allow-domain",
            "Priority": 100
        }
    ],
    "StatefulDefaultActions": [
        "aws:drop_established",
        "aws:alert_established"
    ],
    "StatefulEngineOptions": {
        "RuleOrder": "STRICT_ORDER"
    },
    "Route53ResolvedIPValidation": {
        "Action": "DROP",
        "DNSFirewallRuleGroupId": "rslvr-frg-0123456789abcdef",
        "DNSExpiryExceptionPolicy": "CONTINUE"
    }
}

この JSON を policy.json に保存し、describe-firewall-policy が返す UpdateToken を指定して更新します。

aws network-firewall update-firewall-policy --firewall-policy-name nfw-riv-policy \
  --update-token <トークン> --firewall-policy file://policy.json

ドメインリストのルールグループは、example.com だけを許可します。ターゲット種別は TLS_SNI と HTTP_HOST で、resolved-domain ルールを足す状態では、次のように AWS_ROUTE_53_RESOLVED_DOMAIN を追加します。

{
    "Targets": [
        "example.com"
    ],
    "TargetTypes": [
        "TLS_SNI",
        "HTTP_HOST",
        "AWS_ROUTE_53_RESOLVED_DOMAIN"
    ],
    "GeneratedRulesType": "ALLOWLIST"
}

ポリシー(none、ALERT、DROP、REJECT)とドメインリスト(sni、resolved)を切り替えるスクリプトは次のとおりです。どちらも /tmp/nfw-riv/ids.env を読み、更新後にファイアウォールが READY / IN_SYNC になるまで待ちます。

設定の切り替えスクリプト(04、05)

04-set-policy.sh

#!/usr/bin/env bash
# ファイアウォールポリシーの Resolved IP validation を切り替える。
#   04-set-policy.sh none|ALERT|DROP|REJECT
# 変更後、ファイアウォールが READY / IN_SYNC になるまで待つ。
set -euo pipefail
export AWS_DEFAULT_REGION=ap-northeast-1 AWS_PAGER=""
W=/tmp/nfw-riv; source "$W/ids.env"
MODE="${1:?none|ALERT|DROP|REJECT}"

OUT=$(aws network-firewall describe-firewall-policy --firewall-policy-name nfw-riv-policy --output json)
TOKEN=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["UpdateToken"])' <<<"$OUT")
NEW=$(python3 -c '
import json, sys
d = json.load(sys.stdin)["FirewallPolicy"]
mode, frg = sys.argv[1], sys.argv[2]
if mode == "none":
    d.pop("Route53ResolvedIPValidation", None)
else:
    d["Route53ResolvedIPValidation"] = {"Action": mode, "DNSFirewallRuleGroupId": frg, "DNSExpiryExceptionPolicy": "CONTINUE"}
print(json.dumps(d))' "$MODE" "$FRG" <<<"$OUT")

aws network-firewall update-firewall-policy --firewall-policy-name nfw-riv-policy \
  --update-token "$TOKEN" --firewall-policy "$NEW" --query 'FirewallPolicyResponse.FirewallPolicyStatus' --output text

# 反映待ち。ポリシー更新直後は IN_SYNC のままに見えることがあるので、少し待ってから判定する
sleep 20
for i in $(seq 1 60); do
  s=$(aws network-firewall describe-firewall --firewall-name nfw-riv-fw \
    --query '[FirewallStatus.Status,FirewallStatus.ConfigurationSyncStateSummary]' --output text)
  echo "$(date +%H:%M:%S) $s"
  [ "$s" = "READY	IN_SYNC" ] && break
  sleep 10
done
aws network-firewall describe-firewall-policy --firewall-policy-name nfw-riv-policy \
  --query 'FirewallPolicy.Route53ResolvedIPValidation' --output json

05-set-rulegroup.sh

#!/usr/bin/env bash
# ドメインリストのルールグループのターゲット種別を切り替える。
#   05-set-rulegroup.sh sni        # TLS_SNI + HTTP_HOST のみ(標準のドメインルール)
#   05-set-rulegroup.sh resolved   # TLS_SNI + HTTP_HOST + AWS_ROUTE_53_RESOLVED_DOMAIN
set -euo pipefail
export AWS_DEFAULT_REGION=ap-northeast-1 AWS_PAGER=""
W=/tmp/nfw-riv; source "$W/ids.env"
MODE="${1:?sni|resolved}"
ALLOWED_DOMAIN=example.com

case "$MODE" in
  sni)      TYPES='"TLS_SNI", "HTTP_HOST"' ;;
  resolved) TYPES='"TLS_SNI", "HTTP_HOST", "AWS_ROUTE_53_RESOLVED_DOMAIN"' ;;
  *) echo "mode must be sni|resolved" >&2; exit 2 ;;
esac

TOKEN=$(aws network-firewall describe-rule-group --rule-group-arn "$RG_ARN" --query UpdateToken --output text)
aws network-firewall update-rule-group --rule-group-arn "$RG_ARN" --update-token "$TOKEN" \
  --rule-group "{
    \"RulesSource\": {\"RulesSourceList\": {
      \"Targets\": [\"$ALLOWED_DOMAIN\"],
      \"TargetTypes\": [$TYPES],
      \"GeneratedRulesType\": \"ALLOWLIST\"}},
    \"StatefulRuleOptions\": {\"RuleOrder\": \"STRICT_ORDER\"}}" \
  --query 'RuleGroupResponse.RuleGroupStatus' --output text

sleep 20
for i in $(seq 1 60); do
  s=$(aws network-firewall describe-firewall --firewall-name nfw-riv-fw \
    --query '[FirewallStatus.Status,FirewallStatus.ConfigurationSyncStateSummary]' --output text)
  echo "$(date +%H:%M:%S) $s"
  [ "$s" = "READY	IN_SYNC" ] && break
  sleep 10
done
aws network-firewall describe-rule-group --rule-group-arn "$RG_ARN" \
  --query 'RuleGroup.RulesSource.RulesSourceList' --output json

SNI 詐称の結果

クライアントから、example.com を Resolver で名前解決して接続する通常接続と、SNI を example.com にして詐称先 IP へ接続する詐称接続を実行しました。

# 通常接続(Resolver で名前解決)
curl -sS --max-time 15 -o /dev/null -w "http=%{http_code} remote_ip=%{remote_ip}\n" https://example.com/; echo curl_exit=$?

# 詐称接続(SNI は example.com、接続先は詐称先の IP)
curl -sS -k --max-time 15 -w 'http=%{http_code} remote_ip=%{remote_ip}\n' --resolve example.com:443:<詐称先の IP> https://example.com/; echo curl_exit=$?

出力の IP アドレスなど一部の値は置き換えています。

Resolved IP validation なし: 詐称が通る

標準のドメインルールだけの状態では、詐称接続は詐称先に届きました。

TARGET-SERVER-REACHED
http=200 remote_ip=203.0.113.10
curl_exit=0

比較のため、SNI を許可ドメイン以外の blocked.example.net にして詐称先 IP へ接続すると、標準のドメインルールで止まりました。

Resolved IP validation あり: 詐称が止まる

ポリシーを DROP にすると、同じ詐称接続は TCP 接続が確立しないまま、--max-time に指定した 15 秒で打ち切られました。ドメインリストに AWS_ROUTE_53_RESOLVED_DOMAIN を足した状態でも、足さない状態でも、出力は同じでした。

http=000 remote_ip=
curl_exit=28
	curl: (28) Connection timed out after 15002 milliseconds

表の各値は 1 回の実行結果です。

設定 通常接続 詐称接続
Resolved IP validation なし 200 通った
ポリシー DROP 200 止まった
ポリシー DROP + resolved-domain ルール 200 止まった

今回の詐称先 IP は Resolver が返したことのない IP なので、ポリシーレベルの照合で止まりました。resolved-domain ルールは、Resolver が返した IP のうち特定のドメインのものだけを通したいときに使います。

ALERT から DROP へ切り替えるときの確認

ALERT では詐称接続も通りました(curl exit 0)。フローログには、event.netflow.route53_resolved_ip として照合結果が記録されていました。次の 2 件は、event 以下の一部のフィールドを抜き出し、クライアントと詐称先の IP アドレスを置き換えたものです。1 件目(詐称接続)は false、2 件目(通常接続)は true でした。

{"src_ip": "10.77.2.10", "dest_ip": "203.0.113.10", "dest_port": 443, "app_proto": "tls", "netflow": {"pkts": 11, "bytes": 1298, "start": "2026-10-22T01:32:55.963360+0000", "end": "2026-10-22T01:32:56.027437+0000", "age": 1, "min_ttl": 126, "max_ttl": 126, "state": "closed", "reason": "timeout", "alerted": false, "route53_resolved_ip": false, "tx_cnt": 1}}
{"src_ip": "10.77.2.10", "dest_ip": "104.20.23.154", "dest_port": 443, "app_proto": "tls", "netflow": {"pkts": 14, "bytes": 1607, "start": "2026-10-22T01:32:59.803821+0000", "end": "2026-10-22T01:32:59.834164+0000", "age": 0, "min_ttl": 126, "max_ttl": 126, "state": "closed", "reason": "timeout", "alerted": false, "route53_resolved_ip": true, "tx_cnt": 1}}

DROP へ切り替える前にこのログで route53_resolved_ip が false の通信を見れば、その中に業務で必要な通信が含まれていないかを確かめられます。

DROP にしたあとも通常接続は通り、フローログには route53_resolved_ip が true で記録されました。止まった詐称接続は、DROP に切り替えて約 5 分後の検索で、フローログとアラートログのどちらにも記録されていませんでした。同じ時間帯の DnsUnresolvedFlows は、10:40 JST に 1、10:42 JST に 2 増えました。詐称先 IP への接続は、10:40 JST に詐称接続 1 回、10:42 JST に詐称接続と blocked.example.net 宛の接続を 1 回ずつ実行しました。

設定の反映は、更新の呼び出しから READY / IN_SYNC になるまで 54〜85 秒でした(ポリシー更新 2 回とルールグループ更新 1 回を、10 秒間隔のポーリングで確認)。

まとめ

標準のドメインルールはクライアントが名乗る SNI や Host を照合するため、SNI で許可ドメインを名乗れば別の IP へ接続できました。Resolved IP validation を有効にすると、接続先の IP が VPC の Route 53 Resolver の名前解決で返されたものかを確かめる段階が加わります。ポリシーの Action を DROP にすると、Resolver が返したことのない IP への接続が止まりました。Resolver で名前解決した example.com への接続は、DROP にしても通りました。

許可した SaaS 宛の HTTPS だけを出口で通している構成なら、ALERT で影響を見たうえで DROP に切り替えられます。DROP にすると、SNI を偽って Resolver が返したことのない IP へ接続する通信を止められます。DROP へ切り替える前に、ALERT の段階で DnsAzUnavailableFlows が 0 のままであることを確かめてください。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事