GHSA-2JX3-65F3-XR8R

Vulnerability from github – Published: 2026-06-18 21:07 – Updated: 2026-06-18 21:07
VLAI
Summary
spomky-labs/otphp: Mass-assignment in Factory::loadFromProvisioningUri lets a hostile provisioning URI corrupt OTP state or leak an uncaught TypeError
Details

Summary

OTPHP\Factory::loadFromProvisioningUri() parses an attacker-supplied otpauth:// URI and forwards every query key to OTP::setParameter($key, $value). setParameter() resolves the name with property_exists($this, $parameter) and performs a dynamic write $this->{$parameter} = $value (src/OTP.php:196-197). Because the query keys are entirely controlled by whoever produced the URI, a URI can target the internal properties of the OTP object that are not meant to be set from a URI: parameters, issuer, label, issuer_included_as_parameter, and (on TOTP) the readonly clock. This is an instance of object property mass-assignment (CWE-915).

Impact

The Factory is documented as the entry point for third-party provisioning URIs (e.g. QR codes from Microsoft 365 / Google Authenticator). An application that loads such a URI is exposed to:

  • State corruption. A URI such as otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP&parameters[foo]=bar overwrites the whole internal $parameters array that createFromSecret() primed (period, algorithm, digits, epoch). The resulting object is silently unusable: getProvisioningUri(), getDigits(), at(), verify() then throw ParameterNotFoundException.
  • Uncaught TypeError escaping the documented exception type. A URI such as otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP&issuer_included_as_parameter=notabool assigns a string to a typed bool property and raises a TypeError. The try/catch in loadFromProvisioningUri() only wraps Url::fromString(); createOTP() and populateOTP() run outside it, so the TypeError (and Error on the readonly clock) escapes past the documented InvalidProvisioningUriException, breaking callers that catch only the documented type.
  • Label/issuer validation bypass. parameters[label]=hijacked stores a label into the parameters array without running the label validation callback (keyed on label, not parameters). getLabel() and getParameter('label') then disagree — a confused-deputy risk.

Affected component

  • src/OTP.php:187-201setParameter() dynamic property write
  • src/Factory.php:50-55populateParameters() forwarding all query keys

Proof of concept

use OTPHP\Factory;

// State corruption
$otp = Factory::loadFromProvisioningUri(
    'otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP&parameters[foo]=bar',
    $clock
);
$otp->getProvisioningUri(); // ParameterNotFoundException: Parameter "period" does not exist

// Uncaught TypeError
Factory::loadFromProvisioningUri(
    'otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP&issuer_included_as_parameter=notabool',
    $clock
); // TypeError escapes InvalidProvisioningUriException

Remediation

Restrict the keys accepted from a provisioning URI to a known allow-list of public OTP parameters, and never let a URI key resolve to an internal object property via property_exists. Route all URI-sourced values through the validated parameter map only.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "spomky-labs/otphp"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "11.4.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-915"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-18T21:07:41Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\n`OTPHP\\Factory::loadFromProvisioningUri()` parses an attacker-supplied `otpauth://` URI and forwards **every** query key to `OTP::setParameter($key, $value)`. `setParameter()` resolves the name with `property_exists($this, $parameter)` and performs a dynamic write `$this-\u003e{$parameter} = $value` (`src/OTP.php:196-197`). Because the query keys are entirely controlled by whoever produced the URI, a URI can target the internal properties of the OTP object that are not meant to be set from a URI: `parameters`, `issuer`, `label`, `issuer_included_as_parameter`, and (on TOTP) the readonly `clock`. This is an instance of object property mass-assignment (CWE-915).\n\n## Impact\n\nThe `Factory` is documented as the entry point for third-party provisioning URIs (e.g. QR codes from Microsoft 365 / Google Authenticator). An application that loads such a URI is exposed to:\n\n- **State corruption.** A URI such as `otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026parameters[foo]=bar` overwrites the whole internal `$parameters` array that `createFromSecret()` primed (`period`, `algorithm`, `digits`, `epoch`). The resulting object is silently unusable: `getProvisioningUri()`, `getDigits()`, `at()`, `verify()` then throw `ParameterNotFoundException`.\n- **Uncaught TypeError escaping the documented exception type.** A URI such as `otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026issuer_included_as_parameter=notabool` assigns a string to a typed `bool` property and raises a `TypeError`. The `try/catch` in `loadFromProvisioningUri()` only wraps `Url::fromString()`; `createOTP()` and `populateOTP()` run outside it, so the `TypeError` (and `Error` on the readonly `clock`) escapes past the documented `InvalidProvisioningUriException`, breaking callers that catch only the documented type.\n- **Label/issuer validation bypass.** `parameters[label]=hijacked` stores a label into the parameters array without running the `label` validation callback (keyed on `label`, not `parameters`). `getLabel()` and `getParameter(\u0027label\u0027)` then disagree \u2014 a confused-deputy risk.\n\n## Affected component\n\n- `src/OTP.php:187-201` \u2014 `setParameter()` dynamic property write\n- `src/Factory.php:50-55` \u2014 `populateParameters()` forwarding all query keys\n\n## Proof of concept\n\n```php\nuse OTPHP\\Factory;\n\n// State corruption\n$otp = Factory::loadFromProvisioningUri(\n    \u0027otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026parameters[foo]=bar\u0027,\n    $clock\n);\n$otp-\u003egetProvisioningUri(); // ParameterNotFoundException: Parameter \"period\" does not exist\n\n// Uncaught TypeError\nFactory::loadFromProvisioningUri(\n    \u0027otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026issuer_included_as_parameter=notabool\u0027,\n    $clock\n); // TypeError escapes InvalidProvisioningUriException\n```\n\n## Remediation\n\nRestrict the keys accepted from a provisioning URI to a known allow-list of public OTP parameters, and never let a URI key resolve to an internal object property via `property_exists`. Route all URI-sourced values through the validated parameter map only.",
  "id": "GHSA-2jx3-65f3-xr8r",
  "modified": "2026-06-18T21:07:41Z",
  "published": "2026-06-18T21:07:41Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Spomky-Labs/otphp/security/advisories/GHSA-2jx3-65f3-xr8r"
    },
    {
      "type": "WEB",
      "url": "https://github.com/FriendsOfPHP/security-advisories/blob/master/spomky-labs/otphp/GHSA-2jx3-65f3-xr8r.yaml"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Spomky-Labs/otphp"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "spomky-labs/otphp: Mass-assignment in Factory::loadFromProvisioningUri lets a hostile provisioning URI corrupt OTP state or leak an uncaught TypeError"
}



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…