Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ CHANGELOG
1.14.0
-------------------

* Bounded the resources that the pure PHP decoder spends on a single lookup. A
crafted database could nest data-section pointers to shared targets so that
decoding one record cost exponential time and memory, or point many times at
one large value so that the decoder copied far more data than the file holds.
The decoder now follows the Reader Resource Limits section of the MaxMind DB
specification. Each lookup is limited to 65,536 values, 512 levels of
nesting, and 2 MiB of string and bytes payload.
* Exceeding a limit throws an `InvalidDatabaseException`.
* Opening a database whose metadata exceeds a limit throws the same
exception.
* A scalar that declares more than 16 bytes, the width of the widest
fixed-width type, is rejected as invalid data.
* The bundled libmaxminddb used by `--with-maxminddb-bundled` builds of the
extension now applies the same decoder limits. The extension throws an
`InvalidDatabaseException` when a lookup exceeds them.
* The pure PHP reader is about 40% faster on City lookups. It no longer seeks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 40% figure is conservative, if you want to claim more.

I benchmarked both branches on GeoIP2-City-Test, 40,000 lookups after a 2,000-lookup warmup, three alternating runs:

µs per lookup
main 182.4 / 191.4 / 182.9
this branch 89.9 / 90.7 / 89.9

About 51% faster on this machine, against the 39% in the PR description's table. Different hardware, so the number is not directly comparable, but the claim holds comfortably.

🤖 Comment by Claude (Claude Code) on behalf of Will.

before a read that continues where the last one ended, and it checks read
lengths with `strlen()` instead of `ftell()`.
* The Windows build configuration now accepts either `libmaxminddb.lib` or
`maxminddb.lib` when building the extension. The `lib` prefix was removed
in libmaxminddb 1.6.0, but the libmaxminddb that PHP publishes for Windows
Expand Down
2 changes: 1 addition & 1 deletion ext/bundled-include/maxminddb_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* every build, so a stale value here fails CI rather than shipping.
*/
#ifndef PACKAGE_VERSION
#define PACKAGE_VERSION "1.13.3"
#define PACKAGE_VERSION "1.14.0"
#endif

#endif /* MAXMINDDB_CONFIG_H */
217 changes: 198 additions & 19 deletions src/MaxMind/Db/Reader/Decoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,29 @@ class Decoder
*/
private $switchByteOrder;

/**
* Remaining decoded-value allowance for the current decode() call.
*
* @var int
*/
private $budget = 0;

/**
* Remaining string and bytes payload allowance for the current decode()
* call.
*
* @var int
*/
private $byteBudget = 0;

/**
* Stream position after the last read of the current decode() call, or -1
* when unknown.
*
* @var int
*/
private $position = -1;

private const _EXTENDED = 0;
private const _POINTER = 1;
private const _UTF8_STRING = 2;
Expand All @@ -47,6 +70,33 @@ class Decoder
private const _BOOLEAN = 14;
private const _FLOAT = 15;

// Per-lookup decode limits recommended by the MaxMind DB specification. The
// depth limit stops pointer cycles and over-deep data. The value limit
// stops a pointer fan-out, where nested pointers to shared targets would
// otherwise cost 2**depth decode operations. The count follows the
// specification's flat rule: the root is one value, each array and map
// charges its declared children (a map entry costs two, key and value),
// and a pointer costs nothing beyond the value it resolves to, which its
// container already charged. The largest real records decode a few hundred
// values, so the limit leaves a wide margin.
private const MAX_DEPTH = 512;
private const MAX_VALUES = 1 << 16;

// The value limit alone does not stop payload amplification: an array of
// pointers to one large string or bytes value keeps the value count low
// while forcing the reader to copy the target once per pointer. This
// second, independent limit bounds the total string and bytes payload
// copied for one lookup to 2 MiB, matching libmaxminddb and the Go reader.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Citing sibling implementations rather than the spec.

The libmaxminddb half is true today: MAXIMUM_DATA_STRUCTURE_BYTES (1U << 21) at src/maxminddb.c:74, with depth 512 and values 1U << 16 matching too. But there they are #ifndef-overridable at build time while these constants are hard-coded, so "matching" already holds only for the C defaults. The Go claim cannot be checked from this repo at all.

The spec's Reader Resource Limits section is the stable reference, and libmaxminddb's own comments cite it rather than us. The CHANGELOG already names it.

🤖 Comment by Claude (Claude Code) on behalf of Will.

// No real record approaches it, and re-decoding a shared target charges its
// payload again, so the fan-out is bounded.
private const MAX_PAYLOAD_BYTES = 1 << 21;

// A fixed-width scalar (a float, double, or integer) never needs more than
// 16 bytes (the width of a uint128). A larger declared size is either
// corrupt or an attempt to amplify the read of an oversized variable-length
// integer, so it is rejected before the bytes are materialized.
private const MAX_SCALAR_BYTES = 16;

/**
* @param resource $fileStream
*/
Expand All @@ -68,7 +118,31 @@ public function __construct(
*/
public function decode(int $offset): array
{
$ctrlByte = \ord(Util::read($this->fileStream, $offset, 1));
// Bound the work per lookup so a crafted database cannot exhaust CPU or
// memory. $budget counts decoded values and stops the pointer fan-out;
// $byteBudget counts copied string and bytes payload and stops payload
// amplification. Both live on the decoder and are reset here, so every
// call starts with the full allowance. Passing them by reference
// through each recursive call instead costs a few percent per lookup.
// No other lookup can observe them mid-decode: PHP runs one request
// per thread, and the decoder never yields while it decodes. The root
// value is charged here; containers charge their children.
$this->budget = self::MAX_VALUES - 1;
$this->byteBudget = self::MAX_PAYLOAD_BYTES;

// Other readers of the stream, such as the search tree walk, may have
// moved it since the last call.
$this->position = -1;

return $this->decodeWithBudget($offset, 0);
}

/**
* @return array<mixed>
*/
private function decodeWithBudget(int $offset, int $depth): array
{
$ctrlByte = \ord($this->read($offset, 1));
++$offset;

$type = $ctrlByte >> 5;
Expand All @@ -84,13 +158,22 @@ public function decode(int $offset): array
return [$pointer];
}

[$result] = $this->decode($pointer);
if ($depth >= self::MAX_DEPTH) {
throw new InvalidDatabaseException(
"The MaxMind DB file's data section exceeds the maximum depth"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The limit messages carry no numbers, so an operator cannot size the problem.

This one, and the payload and value-count messages, say what was exceeded but not the limit, not the amount asked for, and not the offset. The limits are private const with no override, so there is no lever either. "No real record approaches it" is a reasonable bet, but when it loses, the person on the pager gets a bare sentence.

Include the limit, the amount and the offset in all four messages.

🤖 Comment by Claude (Claude Code) on behalf of Will.

);
}

// The value at the pointer's position was charged by its containing
// array or map, so the target costs nothing more. Only the depth
// grows.
[$result] = $this->decodeWithBudget($pointer, $depth + 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pointer-to-pointer is not rejected, so a 198 KB file still costs 9.8 s per lookup.

The spec says it is illegal (MaxMind-DB-spec.md:310, "It is illegal for a pointer to point to another pointer"), and libmaxminddb enforces it at src/maxminddb.c:1819-1821. Here a pointer follow charges only depth, never the value or byte budget, so chains are free.

That lets an attacker multiply the maximum array width by the maximum chain length: an array of 65,535 pointers, each aimed at one shared 508-long pointer chain. I built it and ran it against this branch:

Input Result
198,135-byte crafted data section decodes successfully in 9.82 s
normal GeoIP2-City lookup 99.7 µs

About 98,000x, and it stays inside all three new limits. This is bounded and linear rather than exponential, so the PR meets its stated goal, but 9.8 s of CPU from a 198 KB file is still usable as a denial of service.

Rejecting pointer-to-pointer removes the chain multiplier and costs nothing for valid data. I instrumented the decoder and counted pointer-to-pointer follows across 57,600 lookups on all 40 valid test fixtures: zero. The depth tests added in this PR use pointer-to-array (DecoderTest.php:539-551), so they keep passing.

Also worth noting that the pure PHP reader and the bundled extension currently disagree on this input.

🤖 Comment by Claude (Claude Code) on behalf of Will.


return [$result, $offset];
}

if ($type === self::_EXTENDED) {
$nextByte = \ord(Util::read($this->fileStream, $offset, 1));
$nextByte = \ord($this->read($offset, 1));

$type = $nextByte + 7;

Expand All @@ -108,35 +191,62 @@ public function decode(int $offset): array

[$size, $offset] = $this->sizeFromCtrlByte($ctrlByte, $offset);

return $this->decodeByType($type, $offset, $size);
return $this->decodeByType($type, $offset, $size, $depth);
}

/**
* @param int<0, max> $size
*
* @return array{0:mixed, 1:int}
*/
private function decodeByType(int $type, int $offset, int $size): array
private function decodeByType(int $type, int $offset, int $size, int $depth): array
{
switch ($type) {
case self::_MAP:
return $this->decodeMap($size, $offset);
return $this->decodeMap($size, $offset, $depth);

case self::_ARRAY:
return $this->decodeArray($size, $offset);
return $this->decodeArray($size, $offset, $depth);

case self::_BOOLEAN:
return [$this->decodeBoolean($size), $offset];

case self::_BYTES:
case self::_UTF8_STRING:
// A string or bytes value is copied into a native string, so N
// pointers to one large value copy N times its length. Charge
// the payload against the byte budget wherever it is decoded,
// including inline inside a pointed-to container, so a shared
// target recharges each time it is followed. Compare before
// subtracting so an oversized declared size cannot drive the
// budget negative. A total exactly at the limit is allowed.
if ($size > $this->byteBudget) {
throw new InvalidDatabaseException(
"The MaxMind DB file's data section exceeds the maximum payload size"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A metadata breach reports "data section".

This message reaches the caller from Reader.php:107, where the metadata decoder runs. Opening MaxMind-DB-test-metadata-payload-limit.mmdb gives:

InvalidDatabaseException: The MaxMind DB file's data section exceeds the maximum payload size

The failure is in the metadata and it names the wrong section. The C extension gets this right and reports Error opening database file, which is why testMetadataPayloadLimitIsRejectedOnOpen has to branch on the implementation.

Decoder already knows nothing about which section it is in. Pass a label into the constructor, 'metadata' for one instance and 'data section' for the other, and interpolate it into the three limit messages.

🤖 Comment by Claude (Claude Code) on behalf of Will.

);
}
$this->byteBudget -= $size;

return [$this->read($offset, $size), $offset + $size];
}

// The remaining valid types are fixed-width scalars, none wider than a
// uint128. A few other control bytes also reach here: the container
// (12) and end-marker (13) types, and any unknown extended type. The
// size guard below rejects one that declares an oversized size, and the
// default case at the end of the switch rejects the rest. Reject an
// oversized declared size before materializing the bytes, so an
// oversized variable-length integer cannot amplify the read.
if ($size > self::MAX_SCALAR_BYTES) {
throw new InvalidDatabaseException(
"The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reuses the generic corrupt-data message for a distinct condition.

The string is byte-identical to the one in verifySize() and in decodeInt32()'s default, and it is what ReaderTest::testBrokenDatabase asserts for a legitimately truncated double. So one message now covers a bit-rotted file, a malformed int32, and a deliberate integer-amplification attempt.

The six-line comment above explains exactly what this branch means. None of that reaches the operator. Say the type, the declared size, and the offset.

testOversizedVariableLengthIntegerIsBounded asserts the generic string, so it needs updating too. Keep the property that the new message is not a substring of the short-read message, otherwise the test stops discriminating the guard from a plain short read.

🤖 Comment by Claude (Claude Code) on behalf of Will.

);
}

$newOffset = $offset + $size;
$bytes = Util::read($this->fileStream, $offset, $size);
$bytes = $this->read($offset, $size);

switch ($type) {
case self::_BYTES:
case self::_UTF8_STRING:
return [$bytes, $newOffset];

case self::_DOUBLE:
$this->verifySize(8, $size);

Expand All @@ -163,6 +273,39 @@ private function decodeByType(int $type, int $offset, int $size): array
}
}

/**
* Reads from the stream, seeking only when the read does not continue where
* the previous one ended. Most values in a record are laid out in order, and
* fseek() discards PHP's read buffer, so seeking before every read turned
* each small read into a system call. Skipping the seek makes a City lookup
* about 40% faster.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A hard percentage in a permanent comment will rot.

There is no benchmark in the repo, no PHP version and no workload definition beyond "a City lookup", so nothing will re-verify this. CHANGELOG.md already carries the number, and changelog entries are dated and versioned in a way code comments are not. Keep the mechanism here and drop the figure.

Same at Decoder.php:126, "costs a few percent per lookup".

While I am here: the mechanism as stated is a little strong. php_stream_seek() has a fast path for a forward SEEK_SET inside the buffered region, so fseek() does not unconditionally discard the buffer. The reason the change helps is narrower and more interesting: that fast path is gated on offset > stream->position, so a seek to the position the stream is already at, the dominant case in sequential decoding, misses it and takes the full invalidate-and-syscall path.

🤖 Comment by Claude (Claude Code) on behalf of Will.

*
* @param int<0, max> $numberOfBytes
*/
private function read(int $offset, int $numberOfBytes): string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The position cache turns re-entrancy into a silently wrong record.

$this->position is a claim about a handle Decoder does not own. Reader::readNode() moves the same handle through Util::read() (Reader.php:261, :273, :290), which knows nothing about the cache. Today the only thing holding this together is that nothing runs between decode()'s reset and its last read. That invariant is unenforced and breakable.

The comment at Decoder.php:127-129 says it cannot happen:

No other lookup can observe them mid-decode: PHP runs one request per thread, and the decoder never yields while it decodes.

fread() on a userland stream wrapper runs PHP code, and Reader::__construct opens an arbitrary path string, so a proto:// path reaches a registered wrapper. I re-entered Reader::get() from stream_read() against the stock MaxMind-DB-test-decoder.mmdb, sweeping 60 different re-entry points:

correct threw silently wrong
main 0 60 0
this branch 47 10 3

A wrong record, no exception, nothing to grep for. Reachability is low, but the failure mode moved from loud to silent, and that is the part worth fixing.

A guard inside decode() alone does not close it: in my proof of concept the desync started in findAddressInTree() -> Util::read(), before the inner decode() ran. Put a busy flag on Reader::getWithPrefixLen(), or give Decoder its own fopen() handle so nothing else can move it.

🤖 Comment by Claude (Claude Code) on behalf of Will.

{
if ($numberOfBytes === 0) {
return '';
}

$stream = $this->fileStream;
if ($offset !== $this->position && fseek($stream, $offset) !== 0) {
$this->position = -1;

throw new InvalidDatabaseException('The MaxMind DB file contains bad data');
}

$value = fread($stream, $numberOfBytes);
if ($value === false || \strlen($value) !== $numberOfBytes) {
$this->position = -1;

throw new InvalidDatabaseException('The MaxMind DB file contains bad data');
}
$this->position = $offset + $numberOfBytes;

return $value;
}

private function verifySize(int $expected, int $actual): void
{
if ($expected !== $actual) {
Expand All @@ -172,15 +315,48 @@ private function verifySize(int $expected, int $actual): void
}
}

/**
* Applies the per-lookup limits when entering a container. The depth limit
* stops cycles and over-deep data (checked here and at pointer follows,
* the only places depth grows). The value budget is charged per declared

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enterContainer() is not where depth grows.

It checks the container's own depth. Growth happens at the three $depth + 1 sites: Decoder.php:170 for a pointer follow, :359 for array elements, and :445-446 for map keys and values.

The parenthetical also omits the consequence that matters: the depth guard never runs on the scalar path, because decodeByType's scalar branch has no check. That is correct behaviour under the spec, where a scalar does not add a level, and it is why testPointerChainAtMaximumDepthDecodes passes. It is just not what this sentence says.

🤖 Comment by Claude (Claude Code) on behalf of Will.

* element up front, so an oversized declared size is rejected before the
* loop reads anything. A pointer element costs nothing more when it is
* followed: its slot is charged here, and a container it resolves to
* charges its own children each time it is decoded, which is what bounds
* a fan-out through shared targets.
*/
private function enterContainer(
int $size,
int $depth,
int $valuesPerEntry = 1
): void {
if ($depth >= self::MAX_DEPTH) {
Comment thread
oschwald marked this conversation as resolved.
throw new InvalidDatabaseException(
"The MaxMind DB file's data section exceeds the maximum depth"
);
}
// Compare with a division rather than multiplying the declared size, so
// an oversized declaration cannot overflow the integer on 32-bit builds
// before the budget check runs.
if ($size > intdiv($this->budget, $valuesPerEntry)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overflow rationale in the comment above is not true.

Two independent reasons:

  1. The overflow is unreachable. $size comes from sizeFromCtrlByte(), whose largest output is 0xFFFFFF + 65821 = 16,843,036. $valuesPerEntry is only ever 1 or 2, so the product tops out at 33,686,072, well under PHP_INT_MAX on a 32-bit build (2,147,483,647).
  2. PHP does not wrap on integer overflow. It promotes to float, so $size * $valuesPerEntry > $this->budget would still compare correctly even if the product did overflow.

The two forms are equivalent for positive integers, so intdiv() is a fine style choice. The problem is the comment presents it as a safety requirement. Someone will either refuse to simplify it, or copy the "division guards overflow" pattern somewhere it does not hold. Drop the justification, or state the real intent.

🤖 Comment by Claude (Claude Code) on behalf of Will.

throw new InvalidDatabaseException(
"The MaxMind DB file's data section exceeds the maximum number of values"
);
}
$this->budget -= $size * $valuesPerEntry;
}

/**
* @return array{0:array<mixed>, 1:int}
*/
private function decodeArray(int $size, int $offset): array
private function decodeArray(int $size, int $offset, int $depth): array
{
$this->enterContainer($size, $depth);

$array = [];

for ($i = 0; $i < $size; ++$i) {
[$value, $offset] = $this->decode($offset);
[$value, $offset] = $this->decodeWithBudget($offset, $depth + 1);
$array[] = $value;
}

Expand Down Expand Up @@ -258,13 +434,16 @@ private function decodeInt32(string $bytes, int $size): int
/**
* @return array{0:array<string, mixed>, 1:int}
*/
private function decodeMap(int $size, int $offset): array
private function decodeMap(int $size, int $offset, int $depth): array
{
// A map entry decodes a key and a value, so it costs two values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third statement of the same fact.

The value-charging rule is already spelled out at Decoder.php:76-81 and again at :322-323. Comments are 46% of the added lines in this file. Most of that is earned, since the security invariants here are genuinely invisible in the code, but this one and a couple of others restate what the line below already says.

The strongest comment in the diff, for contrast, is at :216-220: it states that the payload charge is per-decode rather than per-distinct-target, which is the entire reason the limit works and is nowhere visible in the three lines beneath it.

🤖 Comment by Claude (Claude Code) on behalf of Will.

$this->enterContainer($size, $depth, 2);

$map = [];

for ($i = 0; $i < $size; ++$i) {
[$key, $offset] = $this->decode($offset);
[$value, $offset] = $this->decode($offset);
[$key, $offset] = $this->decodeWithBudget($offset, $depth + 1);
[$value, $offset] = $this->decodeWithBudget($offset, $depth + 1);
$map[$key] = $value;
}

Expand All @@ -278,7 +457,7 @@ private function decodePointer(int $ctrlByte, int $offset): array
{
$pointerSize = (($ctrlByte >> 3) & 0x3) + 1;

$buffer = Util::read($this->fileStream, $offset, $pointerSize);
$buffer = $this->read($offset, $pointerSize);
$offset += $pointerSize;

switch ($pointerSize) {
Expand Down Expand Up @@ -404,7 +583,7 @@ private function sizeFromCtrlByte(int $ctrlByte, int $offset): array
}

$bytesToRead = $size - 28;
$bytes = Util::read($this->fileStream, $offset, $bytesToRead);
$bytes = $this->read($offset, $bytesToRead);

if ($size === 29) {
$size = 29 + \ord($bytes);
Expand Down
6 changes: 2 additions & 4 deletions src/MaxMind/Db/Reader/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@ public static function read($stream, int $offset, int $numberOfBytes): string
if (fseek($stream, $offset) === 0) {
$value = fread($stream, $numberOfBytes);

// We check that the number of bytes read is equal to the number
// asked for. We use ftell as getting the length of $value is
// much slower.
if ($value !== false && ftell($stream) - $offset === $numberOfBytes) {
// Check that the number of bytes read is the number asked for.
if ($value !== false && \strlen($value) === $numberOfBytes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change also removed an accidental re-entrancy detector.

The old ftell($stream) - $offset === $numberOfBytes check failed whenever something else moved the handle between the fseek and the ftell. That is why main throws on every re-entrant lookup instead of returning bad data. strlen() is the correct check for the short-read case and is faster, so this is the right change on its own, but it is the second half of the silent-corruption path described on Decoder::read().

Worth a comment here noting that nothing now detects a moved handle.

🤖 Comment by Claude (Claude Code) on behalf of Will.

return $value;
}
}
Expand Down
Loading
Loading