A Quick Intro
Three CVEs, one weekend, three different phpMyAdmin Composer packages. Accepted by VulDB today — all three at once, 27 days after submission. phpmyadmin/shapefile, phpmyadmin/sql-parser, and phpmyadmin/motranslator. Not the same bug, not the same class, but the same habit: code that trusts the shape of its input. A token that's assumed to be a number, a locale string that's assumed to be a locale, a size field that's assumed to be honest about how many bytes actually exist.
Here's the set:
- CVE-2026-121261 — CWE-835 infinite loop in
ShapeRecord::loadFromFile(VDB-430227) - CVE-2026-121262 — CWE-704 incorrect type conversion in
ExplainStatement::parse(VDB-430228) - CVE-2026-121263 — CWE-22 path traversal in
Loader::getTranslator(VDB-430229)
Vendor contact for all three went unanswered. All three are published with gists. And I want to be upfront, same as last time: the parser one is the strongest of the three, the traversal one is the most interesting, and the ShapeFile one is the simplest. None of them are RCE. I'll say that plainly up front instead of burying it.
Where to find them: - VulDB: VDB-430227 · VDB-430228 · VDB-430229 - MITRE: CVE-2026-121261 · CVE-2026-121262 · CVE-2026-121263 - NVD: 121261 · 121262 · 121263 - Gists: ShapeFile · SQL-Parser · MoTranslator
Tooling I Used
This one is all source, no reversing. That's the whole difference between this post and the last one — these are PHP libraries on GitHub, not stripped PE binaries, so the work is reading code carefully instead of decompiling it.
PHP 8.5.10 was the ground truth for every reproducer. All three bugs were confirmed live on that build, and the versions matter because two of them lean on behaviour that only shows up when you actually run the code (strict_types, fread at EOF) instead of reasoning about what the language "should" do.
A throwaway sweep script for the parser bug. Nothing clever — 12 hand-picked inputs around the FOR CONNECTION grammar, run each one through new Parser(...) in a child process, check the exit. Three crash, nine don't, all three go through the same code path. That's a better result than a random fuzzer would have given me on the same afternoon, because I knew which production rule to aim at.
ripgrep and the package source, unglamorous but effective. For the traversal the interesting question wasn't "can I put .. in a path", it was "where does this string enter, and does anything between entry and file_exists reject it". listLocales() is the answer to that, and it doesn't reject anything.
Component 1: phpmyadmin/shapefile
ShapeFile reads ESRI .shp files. It's the shapefile parser that phpMyAdmin's import tooling leans on, which means in a real deployment it's fed files that came from somewhere the application does not control.
The loop that consumes the rest of a record looks like this:
php while ($this->read < $this->size) { $this->loadData('V', 4); }
$this->size is read from the file. $this->read is supposed to advance on every iteration. The catch is at end-of-file: the underlying fread() returns an empty string rather than false, so strlen("") == 0, so $this->read doesn't move, so the condition stays true. Forever. The process spins in userspace and pins one core at 100% until max_execution_time kills it — or until it doesn't, if the calling context has no execution limit, which is exactly the situation in a long-running worker or a CLI importer.
The record header declares the content length. The file doesn't have to contain that many bytes. That's the whole bug.
Proving the Overflow
Actually — no overflow here, wrong word. Proving the hang. A valid 100-byte SHP header (shape type Point) followed by a record header whose declared content length is larger than the remaining file bytes. Total payload: 112 bytes. That's it.
Confirmed two ways on PHP 8.5.10 (ZTS x64, Windows): first by letting max_execution_time fire its fatal error, second by watching from outside and taskkill-ing php.exe after five seconds because the script was still eating a core. The skip loop was captured in both cases. If you want the reproducer and a diff that bounds the loop by checking loadData()'s return value and rejecting records whose declared size exceeds the remaining file length, it's in the gist.
Component 2: phpmyadmin/sql-parser
This is the strongest one. ExplainStatement::$connectionId is declared int|null. parse() assigns $nextToken->value to it without checking what kind of token it's holding:
php $this->connectionId = $nextToken->value;
For most EXPLAIN forms the token there is a number and everything is fine. For certain EXPLAIN FOR CONNECTION forms — including syntactically valid ones — the token is a Delimiter or an Operator whose value is a string. The file has declare(strict_types=1); at the top. So PHP doesn't coerce. It throws:
Cannot assign string to property PhpMyAdmin\SqlParser\Statements\ExplainStatement::$connectionId of type ?int
A TypeError, uncaught, out of the parser constructor, before $parser->errors ever gets populated. Which means the documented error-handling path in this library — construct the parser, then check count($parser->errors) — is exactly the path that doesn't survive the bug. That's the part that makes it more than a curiosity: it's not that the parser fails, it's that it fails in a way that bypasses the API contract callers are told to rely on.
The 12-input sweep produced three crashes, all on the same FOR CONNECTION production:
EXPLAIN FOR CONNECTION; -> crash EXPLAIN FOR CONNECTION -> crash (EOF) EXPLAIN FOR CONNECTION (42); -> crash
That third one is the one I'd point at if someone asks whether this is "just malformed input". EXPLAIN FOR CONNECTION (42); parses. It's valid. It reaches the assignment with an opening-paren token instead of a number and dies. A single line of valid SQL terminating the calling process is a DoS, and if the application is the thing passing untrusted SQL into new Parser(...), it's remotely reachable with no authentication.
Component 3: phpmyadmin/motranslator
The traversal. Two functions, a few lines apart, and the gap between them is the bug.
Loader::listLocales() collects locale names and returns anything that isn't in the known set verbatim:
php if (! in_array($locale, $localeNames, true)) { $localeNames[] = $locale; }
Loader::getTranslator() then builds a filesystem path out of that string with no containment check:
php $filename = $base . '/' . $locale . '/LC_MESSAGES/' . $domain . '.mo'; if (file_exists($filename)) { break; }
The OS resolves .. during path lookup. So a locale of "../evil" walks out of $base, file_exists finds whatever .mo is sitting there, and the file gets parsed and cached. From that point on Translator::gettext() is returning attacker-supplied strings.
Four traversal shapes confirmed against motranslator-master/src/Loader.php on PHP 8.5.10. The variant suite in the gist is worth a look for one specific reason: each naive fix people reach for (strip leading .., realpath, basename the segment) stops some of the four shapes and not others. It's a nice demonstration that "we sanitise the locale" is not the same statement as "we check the result is inside the directory".
What it isn't: RCE, and not arbitrary file read either. The file has to be a .mo that parses, and the strings have to get rendered by the consuming application. What it is: spoofing, phishing, and conditional XSS wherever translations are echoed without escaping. That's a real impact and it's the reason the bug matters, but I'm not going to inflate it into something it isn't.
What I'm Claiming
CVE-2026-121261 (infinite loop):
- Unbounded skip loop in
ShapeRecord::loadFromFile,$this->readnever advances at EOF becausefreadreturns"". - 112-byte payload reproduces it. Deterministic, no timing, no race.
- Remote unauthenticated DoS in any application that accepts untrusted
.shpfiles.
CVE-2026-121262 (type conversion):
strict_types=1plus an unvalidated token assignment equals an uncaughtTypeErrorout of the parser constructor.- Three crashing inputs out of twelve, all valid or near-valid SQL, all through the same code path.
- The failure mode defeats the library's own documented error-checking contract, which is the part that makes it a security bug rather than a lint warning.
- Remote, unauthenticated DoS.
CVE-2026-121263 (path traversal):
listLocales()passes non-POSIX strings through;getTranslator()concatenates them into a path;..escapes$base.- Four shapes confirmed, and the variant suite shows why the obvious fixes are incomplete.
- Attacker-controlled translation strings. Not file read, not RCE. Spoofing, phishing, conditional XSS.
What VulDB Did With Them
I submitted 7.5 on both DoS bugs and 5.4 on the traversal: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H for the DoS pair, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N for the traversal.
All three were reviewed and accepted in the same batch — submitted 2026-10-07, moderated 2026-11-03, 27 days later, 20 points each. The published entries are still showing their risk scores as locked while the records settle, so I can't tell you what VulDB's internal score ended up being. When it lands I'll be curious whether the parser bug keeps its 7.5 or gets walked down the way my last pair did. A DoS with no demonstrated consequence past process death is a defensible downgrade, and given that's exactly what I demonstrated, I wouldn't argue with it.
One thing worth noting for anyone submitting to VulDB: the raw submissions show as embargoed on the submit page even though the gists are already public. That's their policy, not a contradiction I need to resolve — but if you're reading this and wondering why the detail page is thin while the gist is full, that's why.
The vendor, for the record: contacted early about all three, no response of any kind. No advisories, no patches, no acknowledgement. Same story as the last batch.
Fixing It
ShapeFile — bound the loop:
php while ($this->read < $this->size) { if ($this->loadData('V', 4) === false) { throw new ShapeFileException('Unexpected EOF in record'); } }
Better: reject the record before you enter the loop if the declared size exceeds the bytes actually remaining in the file. The fix in the gist does both.
SQL-Parser — validate the token:
php if ($nextToken->type !== TokenType::Number) { $this->errors[] = new ParseError(..., 'Expected numeric connection id'); break; } $this->connectionId = (int) $nextToken->value;
Recording a parser error instead of letting the TypeError escape is the important half. The cast alone stops the crash but leaves callers with a parser that silently accepted something it shouldn't have.
MoTranslator — canonicalise and contain:
php $filename = realpath($base . '/' . $locale . '/LC_MESSAGES/' . $domain . '.mo'); if ($filename === false || !str_starts_with($filename, realpath($base) . DIRECTORY_SEPARATOR)) { continue; }
And reject non-POSIX locale strings at listLocales() on the way in, because defence in depth on a path-building bug costs nothing.
Across all three: these packages share a maintainer ecosystem, and the same class of mistake shows up in all of them — a value enters from outside, gets used in a context that assumes something about its type or its shape, and nothing between the boundary and the use site enforces that assumption. That's a review checklist item, not a compiler setting. PHP won't save you here; strict_types is what turned one of these into a crash.
Timeline
| Date | What Happened |
|---|---|
| 2026-10-07 | All three found and confirmed on PHP 8.5.10 |
| 2026-10-07 | Vendor contacted (no response) |
| 2026-10-07 | Submitted to VulDB: #1018488, #1018498, #1018559 |
| 2026-11-03 | All three accepted — VDB-430227 / 430228 / 430229 |
| 2026-11-03 | CVE-2026-121261 / 121262 / 121263 assigned, pushed to CVE stream |
References
| Source | Link |
|---|---|
| VulDB (ShapeFile) | VDB-430227 |
| VulDB (SQL-Parser) | VDB-430228 |
| VulDB (MoTranslator) | VDB-430229 |
| MITRE (all three) | 121261 · 121262 · 121263 |
| PoC + evidence (ShapeFile) | GitHub Gist |
| PoC + evidence (SQL-Parser) | GitHub Gist |
| PoC + evidence (MoTranslator) | GitHub Gist |
| CWE-835 | Loop with Unreachable Exit Condition |
| CWE-704 | Incorrect Type Conversion or Cast |
| CWE-22 | Path Traversal |
Wrapping Up
Three packages, three bugs, one weekend of reading. The parser one is the one I'd defend hardest: valid SQL, unauthenticated, kills the process, and it does it by breaking the exact API contract callers are told to trust. The traversal one is the one I'd put in front of a code reviewer, because the variant suite makes the point better than any write-up could — "we sanitise it" is not a fix, and you can watch four plausible fixes fail on four different shapes. The ShapeFile one is a fifteen-line reproducer and a one-line fix, and it's still a remote unauthenticated DoS in import code, so I'm not going to pretend it's the boring one.
Honest summary: none of these get you code execution. Two get you a dead PHP process, one gets you someone else's translation strings. VulDB reviewed all three in the same batch and accepted them, the vendor didn't respond, and the gists are public. That's the deliverable.
The habit underneath all three is the one worth taking away: every one of these is a boundary where a value from outside is used somewhere that assumed something about it. Type. Shape. Length. The fix in each case is to check the assumption at the boundary — not to hope the next layer checks it for you.
CVE-2026-121261 · VDB-430227 · MITRE · NVD
CVE-2026-121262 · VDB-430228 · MITRE · NVD
CVE-2026-121263 · VDB-430229 · MITRE · NVD