{"parent_root_cause": "UserApiController::saveAction() (app/system/modules/user/src/Controller/UserApiController.php, lines 178-183) authorizes user-role changes by searching the submitted roles array ONLY for Role::ROLE_ADMINISTRATOR (constant id=3). Any custom role (id>=4) yields add=remove=false, so the 403 guard never fires for a non-administrator caller; the submitted roles are persisted unchanged via $user->save($data). A user holding 'user: manage users' (@Access on the controller) can therefore self-assign arbitrary custom roles, including ones carrying trusted/elevated permissions.", "variant_root_cause": "Identical escalation root cause: the editor self-assigns a custom 'Updater' role (id=4) carrying 'system: software updates' via the same saveAction id=3-only check. The divergence is purely in the DOWNSTREAM sink reached after escalation: the variant uses UpdateController/SelfUpdater (system: software updates) instead of PackageController/PackageManager (system: manage packages).", "equivalence_class": "saveAction-incomplete-role-authorization", "same_root_cause_confidence": "high", "same_sink_confidence": "low", "same_surface_confidence": "medium", "shared_trust_boundary": "Authenticated low-privileged user (holding 'user: manage users') -> Pagekit HTTP API -> server-side privilege escalation -> server-side code execution as www-data. Both parent and variant cross the same network-to-server trust boundary via the same authenticated API caller.", "shared_vulnerable_function": "Pagekit\\User\\Controller\\UserApiController::saveAction", "shared_vulnerable_file": "app/system/modules/user/src/Controller/UserApiController.php", "shared_vulnerable_lines": "178-183", "divergence_point": "Post-escalation sink selection. Parent: PackageController (POST /admin/system/package/upload|install, 'system: manage packages') -> PackageManager extracts a pagekit-extension ZIP into packages/. Variant: UpdateController (POST /admin/system/update/download|update, 'system: software updates') -> SelfUpdater extracts an 'update' ZIP over the entire Pagekit web root.", "why_not_just_relabel": "The variant reaches a DIFFERENT admin endpoint (/admin/system/update vs /admin/system/package), requires a DIFFERENT permission (system: software updates vs system: manage packages), uses a DIFFERENT payload format (update ZIP with app/installer/requirements.php vs extension ZIP with composer.json), and exercises a DIFFERENT sink primitive (SelfUpdater core-file overwrite vs PackageManager package extract). A fix that only blocks 'system: manage packages' self-assignment or only hardens the package installer would NOT block this variant.", "fix_must_cover": ["saveAction must validate EVERY role in the roles array, not just id=3 (block non-administrators from assigning any trusted/elevated custom role)", "bulkSaveAction (delegates to saveAction) inherits the same fix", "Enforce the 'trusted' permission flag server-side (currently UI-advisory only) in both RoleApiController and UserApiController", "Harden UpdateController::downloadAction (restrict url scheme/host; no file://; no SSRF) and verify update archive signatures before SelfUpdater::extract() overwrites core files", "Harden PackageController::uploadAction/installAction (already the parent sink) equivalently"], "rule_outs": [{"candidate": "POST /api/user/bulk (bulkSaveAction)", "verdict": "not_distinct", "reason": "Calls saveAction directly \u2014 same function, same check. Different route but identical code path; per anti-patterns this is the same bug, not a variant."}, {"candidate": "ProfileController::saveAction (own profile)", "verdict": "ruled_out", "reason": "Calls $user->save() with no $data argument; only name/email/password are set on the model. No role field is processed, so role injection is impossible."}, {"candidate": "RegistrationController::registerAction (self-registration)", "verdict": "ruled_out", "reason": "Creates the user with explicit fields only; roles are not derived from request data (the @Saving hook adds only ROLE_AUTHENTICATED id=2)."}, {"candidate": "RoleApiController::saveAction (role management)", "verdict": "ruled_out", "reason": "Gated by @Access('user: manage user permissions') \u2014 a different permission and trust boundary; manages role definitions, not user->role assignment. Not reachable by a 'user: manage users'-only account, so not a variant of this CVE."}, {"candidate": "InstallerController (/installer/install) unauthenticated reinstall", "verdict": "ruled_out", "reason": "The /installer route is registered only when installer.enabled config is true (default false; disabled after installation). Not reachable post-install."}]}