ネットワーク攻撃は、ほぼ例外なくポートスキャンから始まります。攻撃者がまずやることは「どのホストが生きていて、どのポートが開いていて、そこで何が動いているか」を知ること——NIST SP 800-115 の発見(Discovery)フェーズの中核です。逆に言えば、防御側が自分の攻撃面を nmap で正確に把握していないなら、攻撃者の方が自社のネットワークに詳しいという危険な状態にあります。
この記事は、ネットワークペンテストの全体像に続き、ポートスキャンの仕組みを nmap 公式ドキュメントに忠実に解説し、その上で攻撃面の最小化と検知という防御を型安全なコードで示します。
安全地帯の徹底:本記事の
nmapコマンドは、すべて自宅ラボの**自分が管理するVM(例10.10.10.10)**に対してのみ実行します。無許可の第三者ホスト・公開サーバーへのスキャンは、不正アクセス禁止法の問題に加え、相手のIDSに記録され通報されうる実害ある行為です。スキャンしてよいのは「自分の資産・CTF・書面で許可されたスコープ」だけ——法律の記事を必ず先に。
1. ポートスキャンの原理 — TCP の握手を「途中でやめる」
ポートスキャンは、TCPの3ウェイハンドシェイクの挙動を逆手に取ります。ポートに SYN を送ったとき、相手の反応で状態が分かるのです。
■ ポートが「開いている(open)」場合
scanner ── SYN ──► target:port "開いてる?"
scanner ◄─ SYN/ACK ─ target "うん、開いてるよ" ← これで「open」と判定
scanner ── RST ──► target "やっぱやめた"(接続を張らずに中断)
■ ポートが「閉じている(closed)」場合
scanner ── SYN ──► target:port
scanner ◄─ RST ──── target "そのポートは閉じてる" ← 「closed」と判定
■ ファイアウォールに「遮断されている(filtered)」場合
scanner ── SYN ──► target:port
(無応答 / ICMP unreachable) ← 「filtered」と判定(FWの存在が分かる)
この「SYN を送って SYN/ACK が返れば開、ただし ACK は返さず RST で中断する」のが **TCP SYN スキャン(-sS、ハーフオープンスキャン)**です。完全な接続(ESTABLISHED)を張らないため、かつては「ステルス」と呼ばれました。
1.1 「ステルススキャン」は現代では筒抜け
重要な誤解を正します。SYN スキャンは現代のIDS/ファイアウォールには容易に検知されます。理由は単純で、「短時間に大量のポートへSYNだけを送り、SYN/ACKに対してACKを返さずRSTで中断する」という挙動自体が、正常な通信ではありえない明確なシグネチャだからです。攻撃側に「ステルス」の幻想を持たせないことが、防御側の出発点です(§4でこの検知を実装します)。
1.2 状態の3分類が「防御の地図」になる
| 状態 | 意味 | 防御側の読み方 |
|---|---|---|
| open | サービスが待ち受け中 | 攻撃面。本当に公開が必要かを問う |
| closed | 応答するがサービスなし | ホストの存在は露見。FWで隠す余地 |
| filtered | FW/SGが遮断 | 理想の状態。攻撃者に情報を与えない |
防御の目標は明確です——公開すべきでないポートを open から filtered へ移すこと。
2. 偵察の手順 — ラボで「自分の攻撃面」を見る
自分のVMに対して、攻撃者と同じ視点で攻撃面を可視化します。
# ① ホスト発見:ラボのセグメントで「生きているホスト」を洗い出す(自分のVMのみ)
nmap -sn 10.10.10.0/24
# -sn = ポートスキャンせずホスト発見だけ(ping sweep)
# ② TCP SYN スキャン:開いているポートを特定(自分のVM 10.10.10.10 のみ)
sudo nmap -sS 10.10.10.10
# sudo が要るのは raw socket で SYN を直接組み立てるため
# ③ サービス・バージョン検出:開いたポートで「何が動いているか」を特定
nmap -sV 10.10.10.10
# 例: 22/tcp open ssh OpenSSH 8.9 / 80/tcp open http nginx 1.24.0
# ↑ 版数が分かると、その版の既知脆弱性(CVE)に直結する=最も危険な情報漏れ
# ④ OS 推定 + デフォルトスクリプト(軽い既知チェック)
sudo nmap -O -sC 10.10.10.10
# -O = TCP/IP スタックの癖から OS を推定
# -sC = 安全寄りのデフォルトNSEスクリプト(バナー取得等)
防御側にとっての最大の学びは ③ です。-sV が nginx 1.24.0 のようにバージョンまで露出させると、攻撃者は「その版の既知CVE」を即座に引けます。サービスバナーの版数を隠す/最新に保つことが、いかに重要かが体感できます。
nmapの出力は-oX scan.xmlでXML保存できます。診断レポートや、後述の「自分の攻撃面の継続監視」に使えます。自分の資産の棚卸しとしてのnmapは、防御側の正当な必須スキルです。
3. 防御①:攻撃面の最小化 — 設定で機械的に潰す
スキャンへの最強の防御は、そもそも開いているポートを減らすことです。これは設計と設定で機械的に達成できます。
3.1 原則 — 「必要最小限の公開」
- 管理ポート(SSH:22・RDP:3389・DB:5432/3306)を公開しない。踏み台(bastion)か、AWS なら SSM Session Manager(ポートを一切開けずに接続)を使う。
- データベース・内部APIはプライベートサブネットへ。インターネットから到達不能にする。
- 公開は 443(HTTPS)に集約し、その背後で経路を分岐させる。
3.2 AWS セキュリティグループを「最小許可」で書く
クラウドでは、ファイアウォール=セキュリティグループ(SG)の設計がそのまま攻撃面になります。0.0.0.0/0 でSSHを開けるのは典型的な事故です。
# ❌ 事故の典型:SSH を全世界に開放(スキャンで即発見され総当たりの的に)
resource "aws_security_group_rule" "bad_ssh" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # ← 攻撃面を全世界に晒している
security_group_id = aws_security_group.app.id
}
# ✅ 正:公開は 443 のみ。管理は SSM 経由でポートを開けない
resource "aws_security_group" "app" {
name = "app-minimal-surface"
description = "Public 443 only; admin via SSM (no inbound SSH)"
vpc_id = var.vpc_id
ingress {
description = "HTTPS from anywhere (terminate at ALB/WAF)"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# SSH(22) の ingress は「無い」のが正解。SSM がポートレスで代替する。
egress {
description = "Allow outbound (tighten per workload)"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "app-minimal-surface" }
}
ポイント:攻撃面の最小化は「気をつける」ではなくコード(IaC)で表現し、レビューと差分で守る。これは私が AWS で多層ネットワークを設計・運用してきた中核の考え方です。
4. 防御②:スキャンを検知する — 型安全な検知ロジック
予防(攻撃面の縮小)をすり抜けても、スキャンには明確な行動シグネチャがあります:「短時間に、1つの送信元が、多数の異なるポート/ホストへ接続を試みる」。これを検知します。
4.1 本番では IDS(Suricata)が定石
本番では Suricata や Snort のようなIDS/IPSが、ポートスキャンを既製ルールで検知します。AWS なら VPC フローログ+GuardDuty が Recon:* の finding を上げます。まずはマネージド/既製の検知に乗るのが正解です。
4.2 仕組みを理解するための最小実装(型安全)
検知の原理を理解するため、フローログ(または接続イベント)から「スキャンらしさ」を判定する純粋関数を書きます。本番ロジックではなく原理の可視化です。
/** 1件の接続試行(フローログの1行を正規化したもの)。 */
interface ConnectionAttempt {
readonly srcIp: string;
readonly dstPort: number;
readonly timestamp: number; // epoch ms
}
interface ScanVerdict {
readonly srcIp: string;
readonly distinctPorts: number;
readonly isLikelyScan: boolean;
}
/**
* 「短時間に同一送信元が多数の異なるポートへ接続を試みた」かを判定する純粋関数。
* 副作用なし=単体テスト・ゴールデンベクタ固定が容易(テスト容易性)。
*
* @param windowMs 観測窓(既定60秒)
* @param portThreshold この窓で触れた異なるポート数の閾値(既定15)
*/
export function detectPortScan(
attempts: readonly ConnectionAttempt[],
now: number,
windowMs = 60_000,
portThreshold = 15,
): readonly ScanVerdict[] {
const since = now - windowMs;
const portsBySrc = new Map<string, Set<number>>();
for (const a of attempts) {
if (a.timestamp < since) continue; // 窓の外は無視
const ports = portsBySrc.get(a.srcIp) ?? new Set<number>();
ports.add(a.dstPort);
portsBySrc.set(a.srcIp, ports);
}
return [...portsBySrc.entries()]
.map(([srcIp, ports]) => ({
srcIp,
distinctPorts: ports.size,
isLikelyScan: ports.size >= portThreshold,
}))
.filter((v) => v.isLikelyScan)
.sort((a, b) => b.distinctPorts - a.distinctPorts); // 重い順に並べる(決定的)
}
この純粋関数なら、フローログのバッチや CloudWatch Logs のサブスクリプションから呼び出して、Slack 通知や自動ブロック(許可リストを壊さない範囲で)につなげられます。検知ロジックを副作用から切り離すことで、テストで挙動を固定でき、誤検知(自社の正当なスキャン・ヘルスチェック)を許可リストで安全に除外できます。
予防と検知は別物・両方要る:攻撃面の最小化(§3)は「そもそも見せない」予防、スキャン検知(§4)は「見られたら気づく」発見。どちらか一方では不十分です。これは多層防御の基本姿勢です。
5. まとめ
- ポートスキャンは全攻撃の起点。SYN スキャン(-sS)は握手を途中でやめて open/closed/filtered を判定する。「ステルス」は幻想で、現代のIDSには筒抜け。
- 最も危険な情報漏れはサービスの版数(
-sV)。既知CVEに直結する。版数を隠し、最新に保つ。 - 防御①=攻撃面の最小化:管理ポートを公開せず、SGを最小許可でIaC化し、SSM/踏み台で代替。
openをfilteredに移す。 - 防御②=検知:スキャンの行動シグネチャ(短時間・多ポート・単一送信元)をIDS/フローログで捉える。検知ロジックは純粋関数で分離しテスト可能に。
- 自分の
nmapで自社を棚卸しするのは、防御側の正当な必須スキル。攻撃者より自社に詳しくあれ。
次は、L2 で経路そのものに割り込む**ARPスプーフィング / 中間者攻撃(MITM)**を扱います。
私(友田 陽大)は、AWS のセキュリティグループ・NACL・VPC 設計を最小権限で IaC 化し、GuardDuty・フローログによる偵察検知まで含めた攻撃面管理を実装してきました。「自社の公開ポートを棚卸ししたい」「SG が 0.0.0.0/0 だらけで不安」「スキャン/偵察を検知したい」——こうした攻撃面の可視化と縮小を、攻撃者の視点で診断し、設定(IaC)と検知の両輪で根治します。お気軽にご相談ください。