本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
TL;DR
- 前回 10M PV/月のボットスクレイピング攻撃に AWS WAF で対処した記録 で Singapore Geo Block によりアクセスを遮断した
- 1 週間後、攻撃者は HK (Tencent Cloud) → VN (residential 分散) → ID (Telkom Indonesia) → DE (Tencent Cloud Frankfurt) と pivot
- WAF ログ分析で 90% が
/sparql/snorql/(Linked Data API) に集中していた - 国を 1 つずつ Block する個別対応の追加を諦め、先進国 45 カ国の Geo allowlist + default deny に切り替え
- さらに、観測した Tencent/Alibaba CIDR を IPSet に追加 → 抜けていた CIDR から DE pivot が成立してしまい、結局 ASN(Autonomous System Number、自律システム番号)ベースの全 prefix 取得へ
- 学び: 「観測 IP を CIDR で IPSet に手で並べる」は構造的に漏れる。BGP(Border Gateway Protocol)feed から ASN announce を pull するのが正解
前回からの 1 週間
前回の記事 の最後に、こう書きました:
攻撃と防御はイタチごっこですが、"一段上の備え" は CloudWatch アラームで再発検知を自動化すること
SNS 通知 (waf-blocked-attack-sources-spike、waf-sg-allowed-spike) を設定して、再発検知の運用基盤は整えていました。
しかし攻撃者は予想と違う経路で戻ってきました。
GA4 リアルタイムでの兆候
ある日、GA4 でリアルタイム users の国別を眺めていて、メインプロパティの内訳が:
Vietnam: 41
Hong Kong: 25
Japan: 16
になっていることに気付いた。日本語コンテンツのアーカイブで Japan が 3 番目はおかしい。
直前の数日でも目立つアラートは出ていなかったので、念のため GA4 Data API で Geo Block 適用後の 6 日間 (4/25 以降) を多軸 (国 / ブラウザ / OS / 時間帯 / ランディングページ) で集計:
PV/session = 1.04 (人間なら 2〜5、1.0 付近 = ボット)
sessions/user = 1.01
平均セッション = 13.1 秒
bounce rate = 95.1%
Chrome 94% / Windows 84% / Desktop 98.6%
direct (referer なし) 92.8%
前回 SG 攻撃と完全に同じフィンガープリント。同じスクレイパーが発信元を pivot させたと判断。
国別:
China: 9,009 (44%) ← 既に Geo Block 済 (前回以降に CN 追加していた)
Vietnam: 2,675 (13%) ← New
Japan: 1,893 (9%) ← 正規
Hong Kong: 1,086 (5%) ← New
Singapore: 997 ← Geo Block 後の残党
そして日別に:
4/25: 613 sessions
4/26: 8,949 ← バースト
4/27: 321
4/28: 692
4/29: 8,709 ← バースト
4/30: 1,184 (進行中)
2〜3 日おきにバッチでスクレイパーを走らせているパターン。
WAF ログで詳細を確認 — GA4 の 100 倍の規模
GA4 のウェブストリーム計測は gtag.js の実行を前提としているため(Measurement Protocol で別途送信しない限り)、API リクエストや Linked Data の取得は GA4 では原理的に見えません。攻撃の全容把握には WAF ログ (aws-waf-logs-<acl-name>) と GA4 の両方を見る必要がある。
CloudWatch Logs Insights で 過去 3 時間 の国別を集計:
| 国 | ALLOW | BLOCK |
|---|---|---|
| US | 245,979 | - |
| VN | 149,191 | - |
| CN | - | 54,830 (geo-block-cn ヒット) |
| JP | 30,750 | - |
| HK | 27,766 | - |
| ID | 19,535 | - |
| DE | 18,810 | - |
| IN | 18,169 | - |
| SG | - | 17,034 (geo-block-sg ヒット) |
| BR | 16,350 | - |
GA4 で VN は 1 日 281 sessions だったが、WAF レイヤーでは 3 時間で 15 万 req。3 桁の差は、JS を実行しない API スクレイパーが大半を占めることを示している。
US の 24 万 ALLOW は Top IP を確認すると 66.249.66.x (Googlebot) と 40.77.x / 52.167.x / 207.46.13.x / 157.55.x (Bing/Microsoft) で大半。これは正規クロールなので保持。
標的の特定: 攻撃の正体は LOD のメタデータ全件吸い取り
クライアント IP のレンジと UA だけで判別が難しいなら、何を取りに来ているかを見るのが早い。WAF ログから host header と URI で集計:
| host | uri | 国 | reqs (3h) |
|---|---|---|---|
| ld.example.jp | /sparql | VN | 89,437 |
| ld.example.jp | /snorql/ | US | 32,850 |
| ld.example.jp | /sparql | US | 23,350 |
| ld.example.jp | /snorql/ | VN | 23,518 |
| ld.example.jp | /sparql | HK | 16,302 |
| ld.example.jp | /snorql/ | HK | 4,116 |
| ld.example.jp | /sparql | ID | 9,354 |
| ld.example.jp | /snorql/ | ID | 2,765 |
VN/HK/ID の 90% が Linked Data API の SPARQL endpoint に集中。攻撃者の真の目的は 書誌・人物・関連リンクの全 RDF 取得 (10M ノード級)。HTML フロントエンドの /book/<id> はおまけで叩いている (前回の SG 攻撃時はこちらが主)。
発信元の 3 パターン
実際の Top IP を引いてみると、同じ攻撃者が 3 種類のリソースを使い分けていた。
(1) HK: Tencent Cloud HK のクラウド IP 集中型
119.28.24.33 106 req/3h
129.226.12.157 101
43.132.249.185 100
43.154.198.51 99
43.129.239.15 96
101.32.9.206 96
150.109.48.35 95
119.28.x 129.226.x 43.129-159.x 101.32.x 150.109.x — すべて Tencent Cloud (AS132203) の HK レンジ。各 IP が 25-35 req/5min で WAF rate-limit (5,000/5min) を完全回避。前回 AWS Singapore 攻撃と同型。
(2) VN: 完全分散 residential 型
14.x, 113.x, 116.x, 117.x, 171.x, 183.x — VNPT, Viettel
Top 20 IPs を合算しても 178 req (全体の 0.1%)
1,000+ IP に薄く分散。各 IP は数 req のみ。residential proxy network (BrightData / Soax / Oxylabs 等) の典型。IP-based の Block は不可能、Geo Block しか手がない。
(3) ID: Telkom Indonesia の単一 /16
163.7.13.x, 163.7.14.x, 163.7.15.x, 163.7.16.x - 各 IP 38-62 req/3h
163.7.0.0/16 の単一レンジに集中。HK と VN の中間。
場当たり Geo Block の限界
ここで方針転換しました。
このまま個別国 Geo Block を続けても、攻撃者は 次の国に pivot するだけ。SG → CN → HK → VN → ID と来て、次は IN, PH, BD, NP, BR, MX のどれかになる。
個別国 Block の追加は構造的に追いつかない。選択肢を整理:
| 戦略 | 効果 | 副作用 |
|---|---|---|
| 個別 Geo Block を国数増やす | △ 永続的に追いかけ続ける | 運用コスト |
| AWS WAF Bot Control (managed, 有料) | ◎ 自動 | $100+/月、誤検知のチューニング |
| Linked Data endpoint だけ閉じる | △ メイン site に pivot される | 限定的 |
| default-deny + 先進国 allowlist | ◎ 未来の pivot に強い | 許可外国の正規ユーザーが弾かれる |
サイトのミッション (日本文化アーカイブ) を考えれば、許可外国からの正規アクセスはマイノリティで、その人たちには将来 RDF dump を提供することで代替可能。割り切る方を選んだ。
default-deny + 45 カ国 allowlist への切り替え
最終的に採用した allowlist:
東アジア (3): JP KR TW
北米 (2): US CA
欧州 (35): GB IE FR DE IT ES NL BE LU AT CH
SE NO DK FI IS PT GR PL CZ SK HU
SI HR EE LV LT RO BG MT CY
AD MC SM LI VA
大洋州 (2): AU NZ
中東 (2): IL AE
含めなかったもの:
- アジア: CN HK SG VN ID IN PH TH MY BD NP PK
- 中南米: BR MX AR
- 欧露: RU UA TR
- アフリカ: 全域
HK と SG は形式的には先進国だが、Tencent Cloud / AWS のクラウド IP が攻撃元と化しているため除外。「人間としてのアクセス需要」より「ボットインフラとしての悪用度」の方が大きい、という判断。
WAF rule の構造
AWS WAF は NotStatement(GeoMatchStatement) で「リスト外を Block」を表現できる:
{
"Name": "geo-allowlist",
"Priority": 0,
"Action": {"Block": {}},
"Statement": {
"NotStatement": {
"Statement": {
"GeoMatchStatement": {
"CountryCodes": ["JP","KR","TW","US","CA",...]
}
}
}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "geo-allowlist"
}
}
これを Priority 0 (最優先) で投入。既存の個別 geo-block-sg/cn/hk/vn は allowlist に subsume されたので削除。
適用ハマりどころ
update-web-acl --cli-input-json に空文字の Description や 空 dict の AssociationConfig を含めると:
Parameter validation failed:
Invalid length for parameter Description, value: 0, valid min length: 1
で失敗する。空フィールドは jq で del(.Description) | del(.AssociationConfig) してから投げる必要あり。
また IPSet の Description には括弧 () が使えない (regex ^[\w+=:#@/\-,\.][\w+=:#@/\-,\.\s]+[\w+=:#@/\-,\.]$ 制約)。日本語不可、ASCII のみ。
残った課題: allowlist 内の "クラウド IP" 経由
allowlist 適用後、allowlist 内の国を 1 つずつ「中身は何を叩いているか」確認したところ、以下が判明:
- US 24 万 ALLOW のうち Tencent Cloud US リージョン + Alibaba Cloud US が 5-8 万 req
- DE 1.8 万 ALLOW のうち Tencent Cloud Frankfurt が大部分
- TW 7 千 ALLOW のうち 92% が
/sparql/snorql/(Datacamp proxy 含む) - JP 3 万 ALLOW のうち 57% が
/sparql(一部は大学研究、一部は JP residential 経由 scraper)
つまり GeoIP 的には US/DE/JP/TW でも、実態は中国系オペレータがクラウドリージョンを使い分けて scraping している。allowlist では止まらない。
Tencent / Alibaba CIDR を IPSet に追加 — でも漏れた
「観測した IP 範囲を IPSet に列挙すれば良い」と判断し、まず 18 件追加:
Tencent Cloud (14件):
43.110.0.0/15, 43.130.0.0/15, 43.132.0.0/14,
43.153.0.0/16, 43.154.0.0/15, 43.159.0.0/16,
43.166.0.0/15, 43.173.0.0/16, 49.51.0.0/16,
101.32.0.0/16, 119.28.0.0/16, 129.226.0.0/16,
150.109.0.0/16, 170.106.0.0/16
Alibaba Cloud (4件):
47.74.0.0/15, 47.76.0.0/14, 47.250.0.0/15, 47.252.0.0/14
WAF Traffic overview で確認 → block-aggressive-scrapers ルールが 5万件 / 30 分で発火、効いている。
しかし 30 分後、再点検したところ DE で 4,890 件の /sparql ALLOW が出続けていた。Top IP を見ると:
43.157.1.36 ALLOW ← !!!
162.62.213.74 ALLOW ← !!!
43.157.x ALLOW 多数
47.245.x ALLOW ← Alibaba
8.209.x ALLOW ← Alibaba
8.211.x ALLOW ← Alibaba
43.157 は Tencent Cloud Frankfurt、162.62 は Tencent の比較的新しい割当、47.245 8.208-215 は Alibaba。全部、私の IPSet から漏れていた。
追加で 4 件投入:
43.156.0.0/14 ← 43.156-159、Tencent Frankfurt 含む
162.62.0.0/15 ← Tencent 新規割当
47.245.0.0/16 ← Alibaba
8.208.0.0/13 ← Alibaba 8.208-215
学び: 観測ベース IPSet は構造的に漏れる
ここで重要な反省。
「観測した IP 範囲を IPSet に手で並べる」アプローチは、自分の記憶や経験に依存している時点で漏れが出る。
私の手順:
[観測] WAF ログから Top IP を確認
↓
[判定] 「これは Tencent っぽい」「Alibaba っぽい」と目視
↓
[列挙] 「Tencent の主要 CIDR」を記憶から並べる ← ここが漏れの温床
↓
[投入] IPSet に追加
「記憶から並べる」が authoritative ではない。Tencent も Alibaba も自社の AS に対して 公式に prefix を BGP announce しているので、その list を引けば authoritative にカバーできる。
ASN-based pull が正解
各社の ASN:
| ASN | 組織 |
|---|---|
| AS132203 | Tencent Cloud (international 向け、RIPE 登録は中国法人) |
| AS133478 | Tencent Cloud Computing (Beijing)(本土寄り、IPv6 は Singapore 含む) |
| AS45090 | Tencent China (本土) |
| AS45102 | Alibaba (international) |
| AS37963 | Alibaba China(一部 Singapore prefix 含む) |
これらの announced prefix の全 list を取得する方法 (DNS が許可されない CI 環境でも RADB whois は使える):
# 1. RADB (Routing Assets Database) の whois
whois -h whois.radb.net '!gAS132203' > /tmp/asn-132203.txt
# 結果は 1 行に空白区切りの prefix がずらっと並ぶ:
# A236332
# 43.162.176.0/22 43.174.144.0/20 43.171.160.0/19 ...
# C
# 2. Hurricane Electric BGP (Web ベース)
# https://bgp.he.net/AS132203#_prefixes
# 3. RIPE Stat (JSON API)
curl -s "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS132203"
# 4. bgpview.io API (DNS 許可があれば。2026 年 5 月時点では稼働が不安定な日があるようです)
curl -s https://api.bgpview.io/asn/132203/prefixes
私が今回引いた数 (RIPE Stat 経由):
| ASN | RIPE 上の announced prefix |
|---|---|
| AS132203 (Tencent) | 1,547 件 |
| AS133478 (Tencent) | 8 件 |
| AS45102 (Alibaba) | 514 件 |
合計 2,069 件。私が手で並べた 18 件は氷山の一角でしかありませんでした。RIPE Stat は RIS feed のスナップショットに基づくため、件数は取得タイミングで変動します。
AWS WAF IPSet の上限は 10,000 entry なので余裕。ipaddress.collapse_addresses() で集約すると:
import ipaddress, json, sys
nets = []
for f in sys.argv[1:]:
with open(f) as h:
d = json.load(h)
for p in d.get("data", {}).get("prefixes", []):
try:
n = ipaddress.ip_network(p["prefix"], strict=False)
if n.version == 4:
nets.append(n)
except (ValueError, KeyError): pass
collapsed = list(ipaddress.collapse_addresses(nets))
for c in sorted(collapsed, key=lambda n: (n.network_address, n.prefixlen)):
print(c)
2,069 → 345 件 に圧縮 (隣接する /24 が /22 や /20 に統合される)。
Oracle Cloud + Vultr の予防追加 (96% 防御へ)
ここまでで Tencent/Alibaba 全 BGP prefix を網羅したが、攻撃者は AWS → Tencent/Alibaba と既に乗り換え経験あり。次の pivot 先は何か?
| 確度 | クラウド | ASN | 理由 |
|---|---|---|---|
| 高 | Hetzner (DE) | AS24940 | 欧州最安、DE は allowlist 内 |
| 高 | OVH (FR) | AS16276 | 同上 |
| 中 | DigitalOcean (US) | AS14061 | US allowlist 内、$5/月 droplet |
| 中 | Vultr (US/DE/多リージョン) | AS20473 | 即時デプロイ、安価、scraping 常連 |
| 中 | Oracle Cloud | AS31898 | 無料枠悪用、scraping 専用に近い |
| 低 | Akamai Connected Cloud (旧 Linode) | AS63949 | AP 中心、グローバル分散 |
このうち Oracle Cloud と Vultr は予防的に追加することにした。理由:
- 両者は無料/激安で「scraping 専用クラウド」の側面が強い
- 正規の文化アーカイブ閲覧者がここから来る確率はほぼゼロ
- 副作用 (誤爆) リスクが極めて低い
一方で Hetzner / OVH / DigitalOcean は保留。これらには:
- 大学・研究機関がホストしている SPARQL クライアント
- OSS の federated query ツール
- 個人開発者の研究用サーバー
など legit な利用例があり得るため、観測ベースで追加する方針。
適用後の IPSet
| ASN | 集約後 prefix 数 |
|---|---|
| Tencent (AS132203 + AS133478) | ~340 |
| Alibaba (AS45102) | ~5 |
| Oracle Cloud (AS31898) | ~200 |
| Vultr (AS20473) | ~1,500 (多リージョンで /24 細分化が多い) |
| 単独 IP (元からある scraper) | 3 |
| 合計 | 2,058 |
WAF IPSet 上限 10,000 まで余裕がある。Hetzner / OVH / DigitalOcean を後で追加する余地もある。
「これで完全?」 — いや、構造的に無理
ASN-based pull は強力ですが、完全防御 には至らない。
残る穴
1. 他クラウド事業者
AS24940 Hetzner (DE) ← 攻撃者が pivot 可能、保留中
AS16276 OVH (FR) ← 同上、保留中
AS14061 DigitalOcean (US) ← 保留中
AS63949 Akamai/Linode (US) ← 保留中
これらは正規利用 (大学・研究・OSS ツール) のリスクが高めなので、観測ベースで追加する方針。今日 Oracle / Vultr を予防追加したので、次に新たな pivot が来る時点で順次対処。
注意:
- AS16509 (AWS): origin がここに居る、安易に Block 不可
- AS15169 (Google): Googlebot がここ、絶対 Block 厳禁
- AS8075 (Microsoft): Bingbot がここ、Block 厳禁
ASN-based も「Block しても安全な ASN」を判別する人間判断が必須。
2. ASN の新規取得
Tencent / Alibaba が来年新リージョンを別 ASN で開くと、その時点で漏れる。月次で ASN list を再取得する運用が必要。
3. Residential proxy 業者
- BrightData, Soax, Oxylabs, IPRoyal: 数百万の residential IP を借りて scrape
- VNPT/Viettel/NTT/KDDI 等の正規 ISP IP として広報されているので ASN block では切れない
- 対処の現実解: AWS WAF Bot Control (有償)
4. 未来の新興クラウド
5 年後の主流脅威は今と違う可能性。
完全性のスペクトル
0% Tencent/Alibaba 観測なし、何もしない
↓
50% 観測 IP を /32 で追加
↓
80% 主要 CIDR を観測ベースで追加
↓
85% 抜けに気付いて追加
↓
95% Tencent/Alibaba ASN 全 prefix pull
↓
96% ★ Oracle / Vultr ASN も予防追加 ← ここまでが今日の到達点
↓
98% Hetzner / OVH / DigitalOcean も ASN pull (保留、要観測)
↓
99% AWS WAF Bot Control 導入 (residential proxy 対応)
↓
100% 達成不可能 (新興クラウド・residential proxy 完全対応は構造的に無理)
今日の到達点は 96%。「完全ではない」が「現状最も合理的」。残り 4% は副作用とコストのトレードオフが大きく、現状の脅威モデルでは過剰投資。
適用後の Web ACL 構成
最終形:
| Priority | Rule | Action |
|---|---|---|
| 0 | geo-allowlist (NOT in 45 countries) | Block |
| 5 | block-aggressive-scrapers (IPSet, Tencent + Alibaba CIDRs) | Block |
| 6 | block-attacker-ja3 (JA3 fingerprint) | Block |
| 7 | block-bot-uas (UA regex) | Block |
| 10 | AWSManagedRulesCommonRuleSet | Count override 一部 |
| 20 | AWSManagedRulesKnownBadInputsRuleSet | Block |
| 30 | AWSManagedRulesAmazonIpReputationList | Block |
| 100 | rate-limit-per-ip (5,000/5min) | Block |
旧 geo-block-sg/cn/hk/vn は allowlist に subsume されたため削除。
余談: Cloudflare ならデフォルトで弾けたのか
ここまで AWS WAF + IPSet で 2 段防御を手で組んだわけですが、「Cloudflare に乗り換えたら、もっと楽だったのでは?」という疑問は残ります。実際どうなのか整理しておきます。
Cloudflare の bot 対策階層
最新の料金は Cloudflare 公式 plans ページ を参照してください。ここでは bot 対策機能の階層構造のみ整理します。
| プラン | 対 bot 機能 |
|---|---|
| Free | Bot Fight Mode (basic) — クラウドホスト由来の単純な bot や headless browser に挑戦を発行 |
| Pro | Super Bot Fight Mode — ML スコアで自動 bot を弾く、verified bot (Googlebot/Bingbot) は維持 |
| Business | Super Bot Fight Mode(sophisticated bot 検知を含む高度版) |
| Enterprise | Full Bot Management、JA4 fingerprint、ASN scoring、custom rules |
Pro プランの Super Bot Fight Mode は、今回の攻撃の おそらく 7〜8 割をデフォルトで弾けるようです。具体的には:
- クラウドホスト IP 自動判定: Tencent / Alibaba / Oracle / Vultr / Hetzner 等を「likely automated」とスコアリング → Block 可能
- verified bot 自動許可: Googlebot / Bingbot / IndexNow 等は ML 不要で素通り
- JA3 / TLS 指紋: ヘッドレス Chrome を判別
- Cookie / JS challenge: 必要に応じて発動 (今回の攻撃 traffic はほぼ 100% 失敗するはず)
- IP threat score: residential proxy 業者 (BrightData, Datacamp 等) も検知、ML が反映
つまり Cloudflare Pro は Tencent/Alibaba CIDR 一覧を自分で並べる作業を全部 managed で肩代わりしてくれる。
それでも AWS から Cloudflare に乗り換えるべきか?
短期的には乗り換える価値がない、というのが正直なところ:
| 観点 | AWS WAF (現行) | Cloudflare Pro |
|---|---|---|
| 月額目安 (今回規模) | 基本的な WAF 料金のみ | Pro プラン料金(公式参照) |
| Tencent/Alibaba 対処 | 自分で IPSet 構築 (今日やった) | デフォルトで効く |
| 細かい挙動制御 | 完全に手動 (CIDR / Country / UA / JA3 / rate) | Pro は ML 任せ、Business 以上で custom rule |
| WAF + CDN 統合 | CloudFront との結合度高い | Cloudflare 自身が CDN |
| 移行コスト | - | DNS 切替、SSL 再構成、origin protection 設計、既存 IPSet/WebACL の捨て、CloudFront 関連の rewrite |
| 管理 UI の透明性 | 全 rule が見える | "Bot Fight Mode" のスコアリングは block-box |
特に大きいのは 移行コスト。今回の AWS 構成は CloudFront origin verification (X-Origin-Verify header)、SNS アラート、Logs Insights クエリ、IPSet 自動更新スクリプトなど、4 月から積み上げてきた具体物がある。これらを Cloudflare に置き換えるとフルスクラッチになる。
結論: 「Cloudflare ならデフォルト」は半分正解、半分嘘
- 新規サイトを今から立ち上げるなら Cloudflare Pro は 強くおすすめ(今日やった作業の 8 割が managed で肩代わりされるようです)
- AWS スタックで既に運用中のサイトを切り替えるかは、移行コスト を入れた損得勘定になります。今回の規模 (10M PV) なら数日〜1 週間の作業 = 数十万円の人件費に対し、月額差は数十ドル程度のため、回収までに長期を要する見込みで合理的とは言いにくいようでした
- AWS で続けるなら 本記事の手順 (allowlist + ASN-based IPSet) で Cloudflare Pro 相当の防御の大部分を AWS WAF の基本料金内で実現できそうです
「楽」という観点では Cloudflare、「既存スタックの上で最適化」という観点では AWS WAF + 本記事手順、というすみ分けです。
ちなみに有償 AWS WAF Bot Control はどうか
AWS WAF にも AWSManagedRulesBotControlRuleSet という有償マネージドルールがあります。Web ACL あたり月額 $10 のサブスクリプションに加え、per-request 課金が発生します(最新は AWS WAF 公式料金 参照)。
Common Bot Control は最初の 10M req/月が無料枠なので、10M PV 規模では追加課金は基本的に発生しないようです。一方、Cloudflare Super Bot Fight Mode に相当する Targeted Bot Control(ASN/JA3/挙動の総合判定)を有効にすると無料枠は 1M req/月までで、10M req を超える部分に超過課金が乗ります。AWS スタックを離れたくないが managed bot 検知が欲しい、という用途では選択肢ですが、本記事の手動構築でも基本料金の範囲に収めることができ、文化アーカイブの規模では現実的でした。
反省: スクレイパーは「行儀の悪さ」を選んでいる
LOD 界隈では SPARQL endpoint を全件巡回するのは技術的に劣等とされる。なぜなら:
/sparqlへの CONSTRUCT クエリは 1 リクエストずつ重い- 全 RDF を欲しいなら bulk dump (TTL/N-Triples の gzip) をダウンロードすれば 1 ファイルで終わる
- bulk dump は CDN cache が効くので運営側の負荷もほぼゼロ
つまり、まともなスクレイパーなら最初に void:dataDump の有無を確認するし、無くても robots.txt を読み、お問い合わせフォームから dump の存在を尋ねる。
今回の攻撃者は逆をやっていました:
- robots.txt を無視
- User-Agent を Chrome/Windows に偽装
- 1,000+ IP に分散 (rate-limit 検知を回避)
- 24 時間フラットに走行
- Cookie/セッションを保持しない
- direct GET の繰り返し (キャッシュヘッダ無視)
これは「マナーを知らない」のではなく、意図的に検知回避している。商用 residential proxy network や複数クラウドリージョンを使い分ける運用は、明確な意志があります。
「お願いベース」では止まらない相手なので、結局は技術的な遮断しかない、というのが現実。
我々ができる「行儀の良い対応」
攻撃側に行儀の良さを期待できないなら、運営側で「行儀の良い経路」を準備しておくのが王道:
/dump.ttl.gzを公開: 全 RDF を 1 ファイルで提供。CDN cache, Range Request 対応void:Datasetの自己記述をhttps://ld.example.jp/.well-known/voidに置く- License を明記: CC0 or CC-BY なら堂々とコピーされる前提
- Update feed (RSS/Atom) で「いつ dump が更新されたか」を通知
これがあれば、技術的に上等なスクレイパー (LLM training pipeline 含む) は dump を取りに来る方が圧倒的に低コストなので、自然と SPARQL 全件巡回をやめる。「行儀悪く scrape する人は最初から技術的に下等な人」という構造を作れる。
学び (前回の追補)
1. 「Block で止まった」は短期記憶。pivot を必ず警戒する
前回 4/14 ピーク → 4/24 SG Geo Block 適用。約 1 週間の静寂を経て 4/30 に VN/HK/ID/DE から再開していた。攻撃者は「止まったので諦めた」のではなく「次のリソースを準備していた」。アラーム以外に GA4 の国別比率が崩れていないか の人間レビューを週次で入れることが必要。
2. WAF メトリクスと GA4 は両方必要
GA4 のウェブストリーム計測は JS の実行を前提とするため、Measurement Protocol を使わない限り API スクレイピングは GA4 では透明。今回も GA4 上は VN 1 日 281 sessions だったが、WAF ログを引けば 3 時間で 15 万 req が API 層に来ていた。**GA4 を「不自然さの最初の検知器」、WAF ログを「実態の確認装置」**と二段で運用するのが必須で、片方だけでは攻撃の規模感を取り違える。
3. 個別 Block の限界と default-deny への切り替え判断
個別 Block は最初の 1〜2 回までは効く。3 回目以降は「永遠に追いかけるか / allowlist にひっくり返すか」の判断点。今回は 5 国目の Block を入れる前に切り替えた。SaaS / 業務系サイトでは難しいが、コンテンツ配信が主目的のサイトなら allowlist は意外と現実的。
4. 「観測 IP を IPSet に手で列挙する」は構造的に漏れる
これが今回の最大の学び。目視でクラウド IP を識別→記憶から並べる、はダメ。authoritative なソース (BGP feed) から ASN announce を引かないと、必ず新規割当 CIDR が漏れる。
正解は:
ASN → BGP feed で全 announce CIDR を取得 → aggregate → IPSet に投入
(月次で再取得して差分更新)
5. allowlist にも漏れはある — クラウド IP 経由 + 商用 proxy
国 allowlist で対処しきれないのが「許可した国を出口とするクラウドリージョン or 商用 proxy」。Tencent Cloud Frankfurt は GeoIP では DE になるし、Datacamp / BrightData / Oxylabs はあらゆる国に出口を持っている。allowlist (Country) と IPSet (ASN/CIDR) の二段防御が必須。
6. LOD endpoint は守りより「正しい代替路の提供」
スクレイピング攻撃は 「dump を提供していないこと」が遠因。RDF を SPARQL 経由でしか取れないようにすると、悪意あるスクレイパーが選ぶのは sparql 全件巡回の一択。dump を出して「行儀よくしたい人がそれを使える状態」にするのが、運営側の本来の責任。
まとめ
4/14 SG 攻撃ピーク 92 万 sessions/日
4/24 SG Geo Block 適用、遮断
4/25-29 静寂期間
4/30 VN/HK/ID から pivot 攻撃開始 — WAF ログで裏取り
4/30 default-deny + 45 カ国 allowlist に切り替え
4/30 Tencent/Alibaba CIDR を IPSet 追加 (18件) → 漏れ発覚
4/30 ASN-based 全 prefix pull に方針転換 (95% カバー)
今後 - dump.ttl.gz の提供
- 月次 ASN list 自動更新
- 他クラウド ASN の追加 (観測ベース)
- AWS WAF Bot Control 評価 (residential proxy 対応)
国別 Geo Block と allowlist のどちらが正解かはサイト次第ですが、
- コンテンツが日本語/特定言語/特定地域向け
- API/RDF を全世界に開く必要が strict には無い
- 攻撃を 2 回以上 pivot された経験がある
の 3 つが揃ったら allowlist 化を本気で検討する価値があると思います。見通しが圧倒的に良くなります。
そして、IPSet を併用するなら 最初から ASN-based で組むこと。観測ベースで CIDR を手で並べる戦略は、本記事のように高確率で漏れます。
付録 A: WAF ログの調べ方 (Logs Insights クエリ集)
CloudWatch Logs Insights で aws-waf-logs-<acl name> を対象に。
国別 Allowed/Blocked 一覧
fields @timestamp
| stats count() as reqs by httpRequest.country, action
| sort reqs desc
| limit 50
特定国の中身
filter httpRequest.country in ["VN","HK","ID","DE"]
| stats count() as reqs by httpRequest.headers.0.value, httpRequest.uri
| sort reqs desc
| limit 30
httpRequest.headers.0.value は典型的に Host header。
/sparql の発信元
filter httpRequest.uri="/sparql" or httpRequest.uri="/snorql/"
| stats count() as reqs by httpRequest.country, httpRequest.clientIp
| sort reqs desc
| limit 30
Block 発火ルールの内訳
filter action="BLOCK"
| stats count() as reqs by terminatingRuleId
| sort reqs desc
| limit 20
付録 B: ASN announced prefix の取得
⚠️ ソース選択が重要 — RADB ではなく RIPE Stat を使う
最初は RADB whois (whois -h whois.radb.net '!gAS132203') で取得したところ:
| ASN | RADB | RIPE Stat |
|---|---|---|
| AS132203 (Tencent) | 15,047 件 | 1,547 件 |
| AS133478 (Tencent) | 543 件 | 8 件 |
| AS45102 (Alibaba) | 50,955 件 | 514 件 |
RADB は 過去の登録された route object をすべて返すため、すでに無効になった経路や、第三者が登録した経路まで混入する。実際 RADB データには 43.0.0.0/9 (NTT Communications などが含まれる /9 ブロック) や 8.128.0.0/10 のような Tencent/Alibaba が実際には持っていない範囲が紛れていて、これを IPSet に投入すると正規 ISP まで巻き添えで block する。
正しいのは BGP feed そのまま (現在 announce されている経路のみ) を返す RIPE Stat:
curl -s "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS132203" \
| jq -r '.data.prefixes[].prefix' > /tmp/tencent-prefixes.txt
bgp.tools (https://bgp.tools/as/132203) も BGP-active のみ返すので同様に使える。
集約
# /tmp/extract_ripe.py
import ipaddress, json, sys
nets = []
for f in sys.argv[1:]:
with open(f) as h:
d = json.load(h)
for p in d.get("data", {}).get("prefixes", []):
try:
n = ipaddress.ip_network(p["prefix"], strict=False)
if n.version == 4:
nets.append(n)
except (ValueError, KeyError): pass
collapsed = list(ipaddress.collapse_addresses(nets))
for c in sorted(collapsed, key=lambda n: (n.network_address, n.prefixlen)):
print(c)
# 3 ASN の JSON を取得
for asn in 132203 133478 45102; do
curl -s "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS$asn" \
-o /tmp/ripe-$asn.json
done
# 集約
python3 /tmp/extract_ripe.py /tmp/ripe-132203.json /tmp/ripe-133478.json /tmp/ripe-45102.json \
> /tmp/aggregated.txt
wc -l /tmp/aggregated.txt
# → 345 行 (Tencent + Alibaba 全 prefix を 345 CIDR に集約)
Python で aggregate
import ipaddress, sys
nets = []
for line in sys.stdin:
line = line.strip()
if line:
try: nets.append(ipaddress.ip_network(line))
except ValueError: pass
collapsed = list(ipaddress.collapse_addresses(nets))
for c in collapsed: print(c)
cat /tmp/tencent-prefixes.txt | python3 aggregate.py > /tmp/tencent-aggregated.txt
wc -l /tmp/tencent-aggregated.txt
# → 数百行に圧縮される
付録 C: WAF IPSet の更新コマンド
# 取得 (LockToken)
aws wafv2 get-ip-set --name block-aggressive-scrapers \
--scope CLOUDFRONT --id <id> --region us-east-1 \
> /tmp/ipset-current.json
LOCK=$(jq -r '.LockToken' /tmp/ipset-current.json)
# update payload 構築
jq --arg lock "$LOCK" \
--argjson new "$(jq -R . /tmp/tencent-aggregated.txt | jq -s .)" \
'{
Name: "block-aggressive-scrapers",
Scope: "CLOUDFRONT",
Id: "<id>",
Description: "Tencent + Alibaba ASN announced ranges",
Addresses: (.IPSet.Addresses + $new),
LockToken: $lock
}' /tmp/ipset-current.json > /tmp/ipset-update.json
# 適用
aws wafv2 update-ip-set --cli-input-json file:///tmp/ipset-update.json \
--region us-east-1
注意:
- Description は ASCII のみ、括弧
()不可 - IPSet 上限 10,000 entry なので aggregate 必須
- LockToken は楽観的ロックなので、競合があれば再取得して再投入
攻撃と防御は継続的に追いかけ合うものですが、"二段上の備え" は authoritative なソース (BGP feed) から自動生成すること。手で記憶を頼りに並べると、3 ヶ月後に Tencent が新リージョン開いた時点で確実に漏れます。
次回予告: /dump.ttl.gz を 10M ノード級の RDF で提供する実装と、void:Dataset 記述の話を書く予定。
動画版(生成AIによる自動生成): この記事の内容を掛け合いで解説しています。自動生成のため、内容に誤りがある可能性があります。正確な情報は記事本文をご参照ください。
コメント
…