{"id":"GHSA-cmwh-g2h8-c222","title":"Poweradmin: OIDC `sub` collation bypass in Poweradmin leading to account takeover","summary":"Poweradmin: OIDC `sub` collation bypass in Poweradmin leading to account takeover","severity":"high","cvss":8.1,"cwe":["CWE-287"],"vendor":"poweradmin","product":"poweradmin/poweradmin","ecosystem":"composer","affected":["poweradmin/poweradmin >= 4.1.0, < 4.2.5","poweradmin/poweradmin >= 4.3.0, < 4.3.4"],"patched":["poweradmin/poweradmin 4.2.5","poweradmin/poweradmin 4.3.4"],"published":"2026-07-24","updated":"2026-07-24","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-cmwh-g2h8-c222","references":[{"url":"https://github.com/poweradmin/poweradmin/security/advisories/GHSA-cmwh-g2h8-c222"},{"url":"https://github.com/poweradmin/poweradmin/commit/cbb78f401a9cc77c976939e93b558f39cb738965"},{"url":"https://github.com/poweradmin/poweradmin/commit/f814f9e241a7af742193cb68f80b19867c24ba69"},{"url":"https://github.com/poweradmin/poweradmin/releases/tag/v4.2.5"},{"url":"https://github.com/poweradmin/poweradmin/releases/tag/v4.3.4"},{"url":"https://github.com/advisories/GHSA-cmwh-g2h8-c222"}],"tags":["ghsa","composer"],"ingestedAt":"2026-07-24T22:40:26.756Z","slug":"GHSA-cmwh-g2h8-c222","body":"## Overview\n\n## Preface\n\nPoweradmin maps OIDC identities into local users through `oidc_user_links.oidc_subject` plus `provider_id`. In the MySQL schema, the OIDC link table explicitly uses `utf8mb4_unicode_ci`, which is case-insensitive and accent-insensitive. OIDC `sub` is a stable external subject identifier and should be matched byte-for-byte within the issuer/provider scope.\n\nThe confirmed local PoC used two different OIDC users:\n\n- Victim subject: `victim-login`\n- Attacker subject: `victím-login` (`í`, U+00ED)\n\nMySQL reported those two subjects as equal under `utf8mb4_unicode_ci`. After the victim linked their OIDC account, the attacker authenticated to the same provider with the attacker's own password and Poweradmin resolved the session to the victim's local account.\n\n## Server Info\n\n- **Application:** Poweradmin\n- **Version:** `targets/poweradmin` git `e1f9c9a`\n- **Database:** MySQL `8.4.10`, `character_set_server=utf8mb4`, `collation_server=utf8mb4_unicode_ci`\n- **Access Permissions:** Any user who can create or control an account in the connected OIDC provider\n- **Auth Method:** OIDC generic provider\n- **Tools:** Docker Compose, local OIDC provider, Python PoC harness\n\n**Affected Entry Point:**\n\n```text\nGET /oidc/login?provider=generic\nGET /oidc/callback?code=...&state=...\n```\n\nRelevant request properties:\n\n- Authentication: valid OIDC authorization code flow\n- Trigger: attacker OIDC account has a `sub` that collides with a victim's linked `sub`\n- Vulnerable field: OIDC `sub` stored and looked up as `oidc_user_links.oidc_subject`\n\n## Root Cause Analysis\n\n### part0 — `oidc_user_links.oidc_subject` uses an accent-insensitive collation\n\nThe MySQL schema defines the OIDC link table with `utf8mb4_unicode_ci`:\n\n```sql\n-- sql/poweradmin-mysql-db-structure.sql:341-356\nCREATE TABLE `oidc_user_links` (\n  `user_id` INT(11) NOT NULL,\n  `provider_id` VARCHAR(50) NOT NULL,\n  `oidc_subject` VARCHAR(255) NOT NULL,\n  ...\n  UNIQUE KEY `unique_subject_provider` (`oidc_subject`, `provider_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;\n```\n\nIn the PoC database:\n\n```text\nField          Type          Collation\nprovider_id    varchar(50)   utf8mb4_unicode_ci\noidc_subject   varchar(255)  utf8mb4_unicode_ci\n\nSELECT 'victim-login' = 'victím-login' COLLATE utf8mb4_unicode_ci;\n-- accent_collision = 1\n```\n\n### part1 — OIDC `sub` flows into a normal SQL equality lookup\n\nPoweradmin reads the subject from OIDC userinfo data. If no custom `subject` mapping is configured, it uses the `sub` claim:\n\n```php\n// lib/Application/Service/OidcService.php:424-459\n$resourceOwner = $provider->getResourceOwner($token);\n$userData = $resourceOwner->toArray();\n...\nsubject: $userData[$mapping['subject'] ?? 'sub'] ?? '',\n```\n\nThe callback passes the resulting `OidcUserInfo` to provisioning:\n\n```php\n// lib/Application/Service/OidcService.php:269-291\n$userInfo = $this->getUserInfo($provider, $token, $providerId);\n$userId = $this->userProvisioningService->provisionUser($userInfo, $providerId);\n```\n\nProvisioning first tries to find an existing user by subject:\n\n```php\n// lib/Application/Service/UserProvisioningService.php:86-90\n$existingUserId = $authMethod === self::AUTH_METHOD_SAML\n    ? $this->findUserBySamlSubject($userInfo->getSubject(), $providerId)\n    : $this->findUserByOidcSubject($userInfo->getSubject(), $providerId);\n```\n\nThe lookup is normal SQL equality evaluated under the column's weak collation:\n\n```php\n// lib/Application/Service/UserProvisioningService.php:145-149\n$stmt = $this->db->prepare(\"\n    SELECT user_id FROM oidc_user_links\n    WHERE oidc_subject = ? AND provider_id = ?\n\");\n$stmt->execute([$subject, $providerId]);\n```\n\nWhen the attacker authenticates with `sub = victím-login`, MySQL matches the existing row for `oidc_subject = victim-login` and returns the victim's `user_id`.\n\n### part2 — The returned user ID becomes the authenticated session\n\nAfter provisioning returns the matched `user_id`, Poweradmin fetches the database username for that user and stores the matched user ID in the session:\n\n```php\n// lib/Application/Service/OidcService.php:307-317\n$databaseUsername = $this->userProvisioningService->getDatabaseUsername($userId);\n$this->setSessionValue('userlogin', $databaseUsername);\n```\n\n```php\n// lib/Application/Service/OidcService.php:360-371\n$this->setSessionValue('userid', $userId);\n...\n$this->setSessionValue('authenticated', true);\n```\n\nThe attacker's OIDC password is validated by the IdP, but the local `user_id` selected by Poweradmin comes from the weak-collation SQL lookup.\n\n## Security Impact\n\nAn attacker who can register or control an OIDC principal with an accent/collation variant of a victim's OIDC subject can authenticate with the attacker's own IdP credentials and obtain a Poweradmin session for the victim's local account.\n\nThe confirmed PoC used distinct OIDC usernames, subjects, emails, and passwords. The attacker did not know or modify the victim's password.\n\n## Reproduction\n\n### 1. Start the local Poweradmin OIDC lab\n\n```bash\nsudo -n docker compose -p poweradminoidcpoc -f poc/work/poweradmin-oidc-collation/docker-compose.yml up -d\n```\n\nThe lab uses MySQL `utf8mb4_unicode_ci` and a local OIDC provider with two real login accounts:\n\n```text\nVictim:\n  username/sub: victim-login\n  email: victim.poweradmin@example.com\n  password: VictimPassword123!\n\nAttacker:\n  username/sub: victím-login\n  email: attacker.poweradmin@example.com\n  password: AttackerPassword123!\n```\n\n### 2. Log in once as the victim through OIDC\n\nAuthenticate through `GET /oidc/login?provider=generic` using:\n\n```text\nusername: victim-login\npassword: VictimPassword123!\n```\n\nPoweradmin creates the victim local user and OIDC link:\n\n```text\nusers:\nid  username      fullname            email\n2   victim-login  Victim Poweradmin   victim.poweradmin@example.com\n\noidc_user_links:\nid  user_id  provider_id  oidc_subject\n1   2        generic      victim-login\n```\n\n### 3. Log in as the attacker OIDC user\n\nAuthenticate from a separate browser session with:\n\n```text\nusername: victím-login\npassword: AttackerPassword123!\n```\n\nObserved result from `poc/work/poweradmin-oidc-collation/run_poc.py`:\n\n```json\n{\n  \"attacker_login\": {\n    \"selected_idp_user\": \"attacker\",\n    \"selected_idp_username\": \"victím-login\",\n    \"has_session_cookie\": true,\n    \"home_contains_victim_username\": true,\n    \"home_contains_attacker_username\": false\n  },\n  \"attacker_resolved_to_victim\": true\n}\n```\n\nDatabase evidence after both logins:\n\n```text\nversion  charset_server  collation_server\n8.4.10   utf8mb4         utf8mb4_unicode_ci\n\naccent_collision\n1\n\nusers:\nid  username      fullname            email\n2   victim-login  Victim Poweradmin   victim.poweradmin@example.com\n\noidc_user_links:\nid  user_id  provider_id  oidc_subject  oidc_subject_hex\n1   2        generic      victim-login   76696374696D2D6C6F67696E\n```\n\nThe application audit log also records all OIDC login events as the victim user:\n\n```text\nuser:victim-login operation:login_success auth_method:oidc\n```\n\nNo local user or OIDC link was created for `victím-login`.\n\n## Recommended Fix\n\nTreat OIDC subject identifiers as byte-exact strings.\n\nFor MySQL, migrate the OIDC mapping identifiers to a binary or byte-preserving collation:\n\n```sql\nALTER TABLE oidc_user_links\n  MODIFY COLUMN provider_id varchar(50)\n    CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,\n  MODIFY COLUMN oidc_subject varchar(255)\n    CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;\n```\n\nAlso make lookup queries byte-preserving so patched application code protects existing deployments before schema migrations are complete:\n\n```php\n$stmt = $this->db->prepare(\"\n    SELECT user_id FROM oidc_user_links\n    WHERE BINARY oidc_subject = BINARY ?\n      AND BINARY provider_id = BINARY ?\n\");\n```\n\nReview related identity and authorization lookups:\n\n- `findUserByEmail()` uses `users.email = ?` and can be impacted when `link_by_email` is enabled.\n- `findPermissionTemplateByName()` uses `perm_templ.name = ?` for SSO permission template mapping.\n- `findGroupByName()` uses `user_groups.name = ?` for SSO group mapping.\n\nThose fields should either be intentionally documented as case/accent-insensitive or migrated/looked up with byte-preserving semantics where they represent security boundaries.\n\n## Patches\n\nFixed in 4.2.5, 4.3.4, and 4.4.0. OIDC and SAML subject identifiers are now matched byte-for-byte. The fix includes a database migration that changes the collation of the identity link columns, so upgrading requires running the SQL update script for your database in the `sql/` directory.\n\n## Acknowledge / Credit\n\nwhale120 (@whale120_tw), working with DEVCORE Internship Program\n\n## Affected packages\n\n- `poweradmin/poweradmin >= 4.1.0, < 4.2.5`\n- `poweradmin/poweradmin >= 4.3.0, < 4.3.4`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `poweradmin/poweradmin 4.2.5`\n- `poweradmin/poweradmin 4.3.4`","depth":"twilight","depthScore":45,"depthScoreParts":{"impact":44.6,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}