high

GHSA-cmwh-g2h8-c222

Packagist · poweradmin/poweradmin

Summary

Poweradmin: OIDC `sub` collation bypass in Poweradmin leading to account takeover

Severity
high
CVSS
8.1
CWE
CWE-287
Published
2026-07-24
Updated
2026-07-24

Advisory 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:

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

Affected Entry Point:

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

Relevant request properties:

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:

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.