GHSA-CMWH-G2H8-C222

Vulnerability from github – Published: 2026-07-24 21:56 – Updated: 2026-07-24 21:56
VLAI
Summary
Poweradmin: OIDC `sub` collation bypass in Poweradmin leading to account takeover
Details

Preface

Poweradmin 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.

The confirmed local PoC used two different OIDC users:

  • Victim subject: victim-login
  • Attacker subject: victím-login (í, U+00ED)

MySQL 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.

Server Info

  • Application: Poweradmin
  • Version: targets/poweradmin git e1f9c9a
  • Database: MySQL 8.4.10, character_set_server=utf8mb4, collation_server=utf8mb4_unicode_ci
  • Access Permissions: Any user who can create or control an account in the connected OIDC provider
  • Auth Method: OIDC generic provider
  • Tools: Docker Compose, local OIDC provider, Python PoC harness

Affected Entry Point:

GET /oidc/login?provider=generic
GET /oidc/callback?code=...&state=...

Relevant request properties:

  • Authentication: valid OIDC authorization code flow
  • Trigger: attacker OIDC account has a sub that collides with a victim's linked sub
  • Vulnerable field: OIDC sub stored and looked up as oidc_user_links.oidc_subject

Root Cause Analysis

part0 — oidc_user_links.oidc_subject uses an accent-insensitive collation

The MySQL schema defines the OIDC link table with utf8mb4_unicode_ci:

-- sql/poweradmin-mysql-db-structure.sql:341-356
CREATE TABLE `oidc_user_links` (
  `user_id` INT(11) NOT NULL,
  `provider_id` VARCHAR(50) NOT NULL,
  `oidc_subject` VARCHAR(255) NOT NULL,
  ...
  UNIQUE KEY `unique_subject_provider` (`oidc_subject`, `provider_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

In the PoC database:

Field          Type          Collation
provider_id    varchar(50)   utf8mb4_unicode_ci
oidc_subject   varchar(255)  utf8mb4_unicode_ci

SELECT 'victim-login' = 'victím-login' COLLATE utf8mb4_unicode_ci;
-- accent_collision = 1

part1 — OIDC sub flows into a normal SQL equality lookup

Poweradmin reads the subject from OIDC userinfo data. If no custom subject mapping is configured, it uses the sub claim:

// lib/Application/Service/OidcService.php:424-459
$resourceOwner = $provider->getResourceOwner($token);
$userData = $resourceOwner->toArray();
...
subject: $userData[$mapping['subject'] ?? 'sub'] ?? '',

The callback passes the resulting OidcUserInfo to provisioning:

// lib/Application/Service/OidcService.php:269-291
$userInfo = $this->getUserInfo($provider, $token, $providerId);
$userId = $this->userProvisioningService->provisionUser($userInfo, $providerId);

Provisioning first tries to find an existing user by subject:

// lib/Application/Service/UserProvisioningService.php:86-90
$existingUserId = $authMethod === self::AUTH_METHOD_SAML
    ? $this->findUserBySamlSubject($userInfo->getSubject(), $providerId)
    : $this->findUserByOidcSubject($userInfo->getSubject(), $providerId);

The lookup is normal SQL equality evaluated under the column's weak collation:

// lib/Application/Service/UserProvisioningService.php:145-149
$stmt = $this->db->prepare("
    SELECT user_id FROM oidc_user_links
    WHERE oidc_subject = ? AND provider_id = ?
");
$stmt->execute([$subject, $providerId]);

When 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.

part2 — The returned user ID becomes the authenticated session

After provisioning returns the matched user_id, Poweradmin fetches the database username for that user and stores the matched user ID in the session:

// lib/Application/Service/OidcService.php:307-317
$databaseUsername = $this->userProvisioningService->getDatabaseUsername($userId);
$this->setSessionValue('userlogin', $databaseUsername);
// lib/Application/Service/OidcService.php:360-371
$this->setSessionValue('userid', $userId);
...
$this->setSessionValue('authenticated', true);

The attacker's OIDC password is validated by the IdP, but the local user_id selected by Poweradmin comes from the weak-collation SQL lookup.

Security Impact

An 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.

The confirmed PoC used distinct OIDC usernames, subjects, emails, and passwords. The attacker did not know or modify the victim's password.

Reproduction

1. Start the local Poweradmin OIDC lab

sudo -n docker compose -p poweradminoidcpoc -f poc/work/poweradmin-oidc-collation/docker-compose.yml up -d

The lab uses MySQL utf8mb4_unicode_ci and a local OIDC provider with two real login accounts:

Victim:
  username/sub: victim-login
  email: victim.poweradmin@example.com
  password: VictimPassword123!

Attacker:
  username/sub: victím-login
  email: attacker.poweradmin@example.com
  password: AttackerPassword123!

2. Log in once as the victim through OIDC

Authenticate through GET /oidc/login?provider=generic using:

username: victim-login
password: VictimPassword123!

Poweradmin creates the victim local user and OIDC link:

users:
id  username      fullname            email
2   victim-login  Victim Poweradmin   victim.poweradmin@example.com

oidc_user_links:
id  user_id  provider_id  oidc_subject
1   2        generic      victim-login

3. Log in as the attacker OIDC user

Authenticate from a separate browser session with:

username: victím-login
password: AttackerPassword123!

Observed result from poc/work/poweradmin-oidc-collation/run_poc.py:

{
  "attacker_login": {
    "selected_idp_user": "attacker",
    "selected_idp_username": "victím-login",
    "has_session_cookie": true,
    "home_contains_victim_username": true,
    "home_contains_attacker_username": false
  },
  "attacker_resolved_to_victim": true
}

Database evidence after both logins:

version  charset_server  collation_server
8.4.10   utf8mb4         utf8mb4_unicode_ci

accent_collision
1

users:
id  username      fullname            email
2   victim-login  Victim Poweradmin   victim.poweradmin@example.com

oidc_user_links:
id  user_id  provider_id  oidc_subject  oidc_subject_hex
1   2        generic      victim-login   76696374696D2D6C6F67696E

The application audit log also records all OIDC login events as the victim user:

user:victim-login operation:login_success auth_method:oidc

No local user or OIDC link was created for victím-login.

Recommended Fix

Treat OIDC subject identifiers as byte-exact strings.

For MySQL, migrate the OIDC mapping identifiers to a binary or byte-preserving collation:

ALTER TABLE oidc_user_links
  MODIFY COLUMN provider_id varchar(50)
    CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
  MODIFY COLUMN oidc_subject varchar(255)
    CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;

Also make lookup queries byte-preserving so patched application code protects existing deployments before schema migrations are complete:

$stmt = $this->db->prepare("
    SELECT user_id FROM oidc_user_links
    WHERE BINARY oidc_subject = BINARY ?
      AND BINARY provider_id = BINARY ?
");

Review related identity and authorization lookups:

  • findUserByEmail() uses users.email = ? and can be impacted when link_by_email is enabled.
  • findPermissionTemplateByName() uses perm_templ.name = ? for SSO permission template mapping.
  • findGroupByName() uses user_groups.name = ? for SSO group mapping.

Those fields should either be intentionally documented as case/accent-insensitive or migrated/looked up with byte-preserving semantics where they represent security boundaries.

Patches

Fixed 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.

Acknowledge / Credit

whale120 (@whale120_tw), working with DEVCORE Internship Program

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "poweradmin/poweradmin"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.1.0"
            },
            {
              "fixed": "4.2.5"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "poweradmin/poweradmin"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.3.0"
            },
            {
              "fixed": "4.3.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-287"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-24T21:56:02Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## 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\u00edm-login` (`\u00ed`, 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\u0027s own password and Poweradmin resolved the session to the victim\u0027s 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=...\u0026state=...\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\u0027s 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 \u2014 `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 \u0027victim-login\u0027 = \u0027vict\u00edm-login\u0027 COLLATE utf8mb4_unicode_ci;\n-- accent_collision = 1\n```\n\n### part1 \u2014 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-\u003egetResourceOwner($token);\n$userData = $resourceOwner-\u003etoArray();\n...\nsubject: $userData[$mapping[\u0027subject\u0027] ?? \u0027sub\u0027] ?? \u0027\u0027,\n```\n\nThe callback passes the resulting `OidcUserInfo` to provisioning:\n\n```php\n// lib/Application/Service/OidcService.php:269-291\n$userInfo = $this-\u003egetUserInfo($provider, $token, $providerId);\n$userId = $this-\u003euserProvisioningService-\u003eprovisionUser($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-\u003efindUserBySamlSubject($userInfo-\u003egetSubject(), $providerId)\n    : $this-\u003efindUserByOidcSubject($userInfo-\u003egetSubject(), $providerId);\n```\n\nThe lookup is normal SQL equality evaluated under the column\u0027s weak collation:\n\n```php\n// lib/Application/Service/UserProvisioningService.php:145-149\n$stmt = $this-\u003edb-\u003eprepare(\"\n    SELECT user_id FROM oidc_user_links\n    WHERE oidc_subject = ? AND provider_id = ?\n\");\n$stmt-\u003eexecute([$subject, $providerId]);\n```\n\nWhen the attacker authenticates with `sub = vict\u00edm-login`, MySQL matches the existing row for `oidc_subject = victim-login` and returns the victim\u0027s `user_id`.\n\n### part2 \u2014 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-\u003euserProvisioningService-\u003egetDatabaseUsername($userId);\n$this-\u003esetSessionValue(\u0027userlogin\u0027, $databaseUsername);\n```\n\n```php\n// lib/Application/Service/OidcService.php:360-371\n$this-\u003esetSessionValue(\u0027userid\u0027, $userId);\n...\n$this-\u003esetSessionValue(\u0027authenticated\u0027, true);\n```\n\nThe attacker\u0027s 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\u0027s OIDC subject can authenticate with the attacker\u0027s own IdP credentials and obtain a Poweradmin session for the victim\u0027s local account.\n\nThe confirmed PoC used distinct OIDC usernames, subjects, emails, and passwords. The attacker did not know or modify the victim\u0027s 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\u00edm-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\u00edm-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\u00edm-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\u00edm-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-\u003edb-\u003eprepare(\"\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",
  "id": "GHSA-cmwh-g2h8-c222",
  "modified": "2026-07-24T21:56:02Z",
  "published": "2026-07-24T21:56:02Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/poweradmin/poweradmin/security/advisories/GHSA-cmwh-g2h8-c222"
    },
    {
      "type": "WEB",
      "url": "https://github.com/poweradmin/poweradmin/commit/cbb78f401a9cc77c976939e93b558f39cb738965"
    },
    {
      "type": "WEB",
      "url": "https://github.com/poweradmin/poweradmin/commit/f814f9e241a7af742193cb68f80b19867c24ba69"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/poweradmin/poweradmin"
    },
    {
      "type": "WEB",
      "url": "https://github.com/poweradmin/poweradmin/releases/tag/v4.2.5"
    },
    {
      "type": "WEB",
      "url": "https://github.com/poweradmin/poweradmin/releases/tag/v4.3.4"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Poweradmin: OIDC `sub` collation bypass in Poweradmin leading to account takeover"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…