From 7a1f13f58502f09353f450351b307316784006ba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 22:34:42 +0000 Subject: [PATCH] feat(opcache): bounds-validate relocation offsets and document the trust model Defense-in-depth for the file-cache reader (issue #123). PayloadRelocator turned every stored offset into base + stored and drove its loops off count fields read straight from the payload, without range checks - so a crafted .bin carrying the current build's system_id (a build fingerprint, not an authenticator; adler32 is forgeable) fed into getReflection() was an FFI arbitrary read/write primitive. Now every stored value is validated before it becomes an address the engine walks, in the relocate() (untrusted-input) path: - requireOffset: interior-pointer offsets against [0, memSize] - requireStringOffset: tagged interned-string offsets against [0, strSize), plain string offsets against [0, memSize] - requireSpan / requireCount: scriptOffset and every count-driven element array - hashtable buckets/packed data, literals, arg_info (incl. the arg_info[-1] return slot), vars, type lists, attribute args, class property tables, properties_info_table, class/trait names, trait alias/precedence NUL-terminated arrays and their exclude lists, property hooks, dynamic_func_defs, ast children/nodes, warnings and early bindings A violation throws OpCacheException::malformedPayload - a loud refusal, never an out-of-bounds walk. The derelocate()/serialize() path and the #117 graph ScriptSerializer operate on an already-relocated in-process image and inherit this validation. The tagged-offset bound tracks the string section serialize() just rebuilt (re-pinned from the header str_size), so the relocate() inside derelocate() validates against the section it produced, not the stale size. docs/opcache-binary.md gains a "Trust model" section: .bin input must come from a trusted source; system_id is a build fingerprint (ABI guard), not authenticity; adler32 catches accidental corruption, not tampering; the bounds checks turn a memory-safety catastrophe into a clean exception but are not a licence to load untrusted code. The optional keyed-MAC for distributing protected binaries is described as a deploy-side responsibility and flagged as a possible follow-up, deliberately not built in (key management belongs to the application). BoundsValidationTest proves the loud refusal on a truncated buffer, an out-of-range scriptOffset, a hostile pointer field, a hostile hash count and an out-of-range interned-string offset - and that a well-formed image still relocates and round-trips (no false positives). Run in the debug84 and debug84-zts containers too, where an unguarded out-of-bounds read segfaults loudest: no crashes, all refusals clean. Acceptance evidence: - full default suite (536 tests, baseline skips), --group opcache --fail-on-skipped OK (64 tests, 472 assertions) on host, debug84 and debug84-zts; phpstan level max clean; php-cs-fixer clean Fixes #123 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BDcCQiYqbMkjRPyhWgLL6M --- docs/opcache-binary.md | 48 ++++++- phpstan.dist.neon | 7 + src/OpCache/OpCacheException.php | 12 ++ src/OpCache/PayloadRelocator.php | 168 ++++++++++++++++++++--- tests/OpCache/BoundsValidationTest.php | 182 +++++++++++++++++++++++++ 5 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 tests/OpCache/BoundsValidationTest.php diff --git a/docs/opcache-binary.md b/docs/opcache-binary.md index e272959d..6c65be83 100644 --- a/docs/opcache-binary.md +++ b/docs/opcache-binary.md @@ -266,9 +266,53 @@ $report->appliedMethods; // what actually happened, per entry patched image to already-loaded functions and classes landed as `CacheImageSync` (see above). +## Trust model — the `.bin` input must be trusted + +Loading a cache binary is loading **code**. The relocator turns stored byte +offsets into real engine addresses that the interpreter then executes, so a +`.bin` file is exactly as trusted as the PHP source it was compiled from. Treat +it that way: read binaries only from a location your own deployment controls. + +Two header fields look like integrity checks but are **not** authentication: + +- **`system_id`** is a *build fingerprint* — a hash of the PHP version, + extension set and build flags. It exists so a binary compiled by one build is + refused by an incompatible one (`systemIdMismatch`), preventing accidental + ABI mismatch. It says nothing about *who* produced the binary; anyone can + compute the current build's `system_id` and stamp it on a crafted file. +- **`checksum`** is an **adler32** of the payload. It catches accidental + corruption (a truncated write, a bad disk block). adler32 is trivially + forgeable — an attacker who alters the payload simply recomputes it — so it + is not tamper protection against a motivated adversary. + +Because neither field authenticates the producer, the relocator does **not** +rely on them for safety. Instead, **every stored offset, count and element span +is bounds-validated against the declared buffer before it is dereferenced** +(issue #123): interior-pointer offsets against `[0, memSize]`, tagged +interned-string offsets against `[0, strSize)`, `scriptOffset` and every +count-driven element array (hashtable buckets, literals, arg_info, vars, type +lists, class/trait names, property hooks, `dynamic_func_defs`, warnings, early +bindings, …) against the region bounds. A violation raises +`OpCacheException::malformedPayload` — a loud refusal, never an out-of-bounds +engine read/write. The validation lives in the `relocate()` (read) path, the +untrusted-input surface; `derelocate()`/`serialize()` and the graph +`ScriptSerializer` operate on an already-relocated, in-process image and +inherit that validation. This is defense in depth, **not** a licence to load +untrusted binaries: it converts a memory-safety catastrophe into a clean +exception, but a validated binary can still contain hostile *compiled code*. + +**Distributing protected binaries.** If you need to ship binaries across a trust +boundary (a build server to production hosts, say), authenticate them yourself +with a keyed MAC or a signature over the file before loading — e.g. an +HMAC-SHA256 with a deployment secret, verified before `BinaryCacheFile::read()`. +A built-in keyed-MAC mode is a possible future option (a follow-up to issue +#123); it is deliberately not part of this version, because the right key +management belongs to the deploying application, not the library. + ## Failure modes Everything the API rejects is a static factory on `OpCacheException` (`invalidMagic`, `truncatedFile`, `systemIdMismatch`, `checksumMismatch`, -`binFileNotFound`, `compilationFailed`, `unsupportedPayload`, …), so call sites -read as intent and the wording lives in one place. +`binFileNotFound`, `compilationFailed`, `unsupportedPayload`, +`malformedPayload`, …), so call sites read as intent and the wording lives in +one place. diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 36942d97..2f682bc0 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -122,6 +122,13 @@ parameters: - identifier: assignOp.invalid path: src/OpCache/ReflectionOpcacheFile.php + # BoundsValidationTest crafts hostile payloads by poking engine-struct + # pointer fields through FFI\CData (the address of a filename/HashTable + # slot to overwrite), the same pointer surgery PayloadRelocator does - + # FFI::addr() on a CData field read resolves to mixed here. + - + identifier: argument.type + path: tests/OpCache/BoundsValidationTest.php # The dimension tests exist to prove that a plain `count($object)` reaches the engine's # count_elements handler on a class that never declared the count itself. Rewriting them # as assertCount() would measure PHPUnit's Count constraint instead of the language diff --git a/src/OpCache/OpCacheException.php b/src/OpCache/OpCacheException.php index f744e6fb..54e7cdfa 100644 --- a/src/OpCache/OpCacheException.php +++ b/src/OpCache/OpCacheException.php @@ -146,6 +146,18 @@ public static function unresolvedGraphReference(string $what): self return new self("Graph serialization failed, {$what}: the referenced structure was not persisted"); } + /** + * A stored offset, count or element span in the payload points outside the + * declared buffer bounds - the binary is truncated or crafted. Relocating it + * would be an out-of-bounds engine read/write, so the load is refused + * (issue #123). The bin must come from a trusted producer; system_id is a + * build fingerprint, not an authenticator, and adler32 is not tamper-proof. + */ + public static function malformedPayload(string $what): self + { + return new self("Malformed opcache payload: {$what}"); + } + /** * A graft donor does not contain the requested function/class/method */ diff --git a/src/OpCache/PayloadRelocator.php b/src/OpCache/PayloadRelocator.php index 172a5b4f..74e2d126 100644 --- a/src/OpCache/PayloadRelocator.php +++ b/src/OpCache/PayloadRelocator.php @@ -24,6 +24,7 @@ use ZEngine\Generated\zend_attribute_arg; use ZEngine\Generated\zend_class_name; use ZEngine\Generated\zend_early_binding; +use ZEngine\Generated\zend_persistent_script; use ZEngine\Generated\zend_string; use ZEngine\Generated\zend_type; use ZEngine\Generated\zend_type_list; @@ -85,6 +86,13 @@ final class PayloadRelocator private readonly int $base; private readonly int $size; + /** + * Upper bound for tagged interned-string offsets. Starts at the header's + * str_size and is re-pinned to the rebuilt string-section length whenever + * serialize() re-emits it, so the relocate() inside derelocate() validates + * against the section it just produced, not the stale original size. + */ + private int $strSize; private readonly int $strSectionBase; /** Interned-string re-emission state (write path) */ @@ -119,6 +127,7 @@ public function __construct(private readonly object $buffer, private readonly Ca } $this->base = Core::addressOf(Core::addr($buffer)); $this->size = $metaInfo->memSize(); + $this->strSize = $metaInfo->strSize(); $this->strSectionBase = $this->base + $this->size; // _ZSTR_HEADER_SIZE = XtOffsetOf(zend_string, val): the flexible val[1] // member starts at the last 8-byte slot of the (padded) struct, so the @@ -136,7 +145,14 @@ public function __construct(private readonly object $buffer, private readonly Ca public function relocate(): object { $this->sharedOpcodes = []; - $script = Core::pointerAtAddress('zend_persistent_script *', $this->base + $this->metaInfo->scriptOffset()); + // The script struct itself must fit inside the mem region before we + // dereference a single field of it (issue #123) + $this->requireSpan( + $this->metaInfo->scriptOffset(), + Core::sizeOfType(zend_persistent_script::class), + 'zend_persistent_script at scriptOffset', + ); + $script = Core::pointerAtAddress('zend_persistent_script *', $this->base + $this->metaInfo->scriptOffset()); $this->unStr($script->script, 'filename'); $this->unserializeHash($script->script->class_table, $this->unserializeClass(...)); @@ -183,6 +199,9 @@ private function serialize(): string $this->serializeEarlyBindings($script); $memRegion = FFI::string($this->buffer, $this->size); + // Re-pin the tagged-offset bound to the section just emitted, so the + // relocate() in derelocate() validates against it (issue #123) + $this->strSize = strlen($this->strSection); return $memRegion . $this->strSection; } @@ -226,6 +245,79 @@ private function isUnserialized(int $pointer): bool return $pointer >= $this->base && $pointer <= $this->base + $this->size; } + // --- bounds validation (issue #123) ------------------------------------- + // Every stored offset in the file is attacker-controllable in an untrusted + // binary (system_id is a build fingerprint, adler32 is forgeable), so each + // one is range-checked before it becomes a real address the engine walks. + // The checks live in the UNSERIALIZE (relocate) primitives and the + // count-driven relocate loops - the derelocate/serialize path and the graph + // serializer both operate on an already-relocated, in-process image and + // inherit that image's validation. + + /** + * Validates a stored mem-region offset lies in [0, size]. The upper bound is + * inclusive because a return-type-only &arg_info[1] legitimately points at + * the region end. Returns the offset for fluent use. + */ + private function requireOffset(int $stored, string $what): int + { + if ($stored < 0 || $stored > $this->size) { + throw OpCacheException::malformedPayload( + sprintf('%s: stored offset %d is outside [0, %d]', $what, $stored, $this->size), + ); + } + + return $stored; + } + + /** + * Validates a stored zend_string reference: a tagged interned reference must + * land in the appended string section [0, strSize), a plain one in the mem + * region [0, size]. + */ + private function requireStringOffset(int $stored, string $what): void + { + if (($stored & 1) !== 0) { + $offset = $stored & ~1; + if ($offset < 0 || $offset >= $this->strSize) { + throw OpCacheException::malformedPayload( + sprintf('%s: interned-string offset %d is outside [0, %d)', $what, $offset, $this->strSize), + ); + } + + return; + } + $this->requireOffset($stored, $what); + } + + /** + * Validates that [resolvedAddress, resolvedAddress + count * elementSize) + * lies fully within the mem region, before a loop dereferences the span. + * A negative or overflowing count is rejected too. + */ + private function requireSpan(int $offsetOrAddress, int $bytes, string $what): void + { + // Accept either a stored offset or a resolved (base+offset) address + $offset = $offsetOrAddress >= $this->base ? $offsetOrAddress - $this->base : $offsetOrAddress; + if ($bytes < 0 || $offset < 0 || $offset > $this->size || $offset + $bytes > $this->size) { + throw OpCacheException::malformedPayload( + sprintf('%s: span [%d, %d) escapes the %d-byte mem region', $what, $offset, $offset + $bytes, $this->size), + ); + } + } + + /** Validates a count field before it drives an element-span walk */ + private function requireCount(int $count, string $what): int + { + if ($count < 0 || $count > $this->size) { + throw OpCacheException::malformedPayload( + sprintf('%s: implausible count %d for a %d-byte region', $what, $count, $this->size), + ); + } + + return $count; + } + /** UNSERIALIZE_PTR on a struct field, returning the resolved address (0 if null) */ /** * @param \FFI\CData $owner @@ -236,6 +328,7 @@ private function unPtr(object $owner, string $field): int if ($stored === 0) { return 0; } + $this->requireOffset($stored, "pointer field {$field}"); $address = $this->base + $stored; $this->writePtrField($owner, $field, $address); @@ -265,6 +358,7 @@ private function unPtrAt(int $slotAddress): int if ($stored === 0) { return 0; } + $this->requireOffset($stored, 'raw pointer slot'); $slot[0] = $this->base + $stored; return $this->base + $stored; @@ -294,6 +388,7 @@ private function unStr(object $owner, string $field): void if ($stored === 0) { return; } + $this->requireStringOffset($stored, "string field {$field}"); if (($stored & 1) !== 0) { // Tagged interned reference into the string section $address = $this->strSectionBase + ($stored & ~1); @@ -330,6 +425,7 @@ private function unStrAt(int $slotAddress): void if ($stored === 0) { return; } + $this->requireStringOffset($stored, 'raw string slot'); if (($stored & 1) !== 0) { $slot[0] = $this->strSectionBase + ($stored & ~1); } else { @@ -386,9 +482,10 @@ private function unserializeHash(object $ht, callable $each): void return; } $dataAddress = $this->unPtr($ht, 'arData'); - $used = $ht->nNumUsed; + $used = $this->requireCount((int) $ht->nNumUsed, 'hashtable nNumUsed'); if (($ht->u->flags & self::HASH_FLAG_PACKED) !== 0) { $zvalSize = Core::sizeOfType(zval::class); + $this->requireSpan($dataAddress, $used * $zvalSize, 'packed hashtable data'); for ($i = 0; $i < $used; $i++) { $zval = Core::pointerAtAddress('zval *', $dataAddress + $i * $zvalSize); if ($zval->u1->v->type !== 0) { @@ -399,6 +496,7 @@ private function unserializeHash(object $ht, callable $each): void return; } $bucketSize = Core::sizeOfType(Bucket::class); + $this->requireSpan($dataAddress, $used * $bucketSize, 'hashtable bucket data'); for ($i = 0; $i < $used; $i++) { $bucket = Core::pointerAtAddress('Bucket *', $dataAddress + $i * $bucketSize); if ($bucket->val->u1->v->type !== 0) { @@ -516,6 +614,8 @@ private function serializeZval(object $zval): void /** @param int $astAddress address of the zend_ast (already resolved) */ private function unserializeAst(int $astAddress): void { + // The node header (kind + attr) must fit before it is read + $this->requireSpan($astAddress, Core::sizeOfType(zend_ast::class), 'zend_ast node'); $ast = Core::pointerAtAddress('zend_ast *', $astAddress); $kind = $ast->kind; if ($kind === self::ZEND_AST_ZVAL || $kind === self::ZEND_AST_CONSTANT) { @@ -526,15 +626,17 @@ private function unserializeAst(int $astAddress): void if (($kind >> self::ZEND_AST_IS_LIST_SHIFT & 1) !== 0) { $list = Core::pointerAtAddress('zend_ast_list *', $astAddress); $childBase = $astAddress + Core::sizeOfType(zend_ast_list::class) - PHP_INT_SIZE; - $count = $list->children; + $count = $this->requireCount((int) $list->children, 'ast list children'); } else { $childBase = $astAddress + Core::sizeOfType(zend_ast::class) - PHP_INT_SIZE; $count = $kind >> self::ZEND_AST_CHILDREN_SHIFT; } + $this->requireSpan($childBase, $count * PHP_INT_SIZE, 'ast children slots'); for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $childBase + $i * PHP_INT_SIZE)); $child = (int) $slot[0]; if ($child !== 0 && !$this->isUnserialized($child)) { + $this->requireOffset($child, 'ast child'); $slot[0] = $this->base + $child; $this->unserializeAst($this->base + $child); } @@ -612,7 +714,9 @@ private function unserializeAttribute(object $zval): void $this->unStr($attr, 'lcname'); $argSize = Core::sizeOfType(zend_attribute_arg::class); $argBase = Core::addressOf($attr->args); - for ($i = 0; $i < $attr->argc; $i++) { + $argc = $this->requireCount((int) $attr->argc, 'attribute argc'); + $this->requireSpan($argBase, $argc * $argSize, 'attribute args'); + for ($i = 0; $i < $argc; $i++) { $arg = Core::pointerAtAddress('zend_attribute_arg *', $argBase + $i * $argSize); $this->unStr($arg, 'name'); $this->unserializeZval($arg->value); @@ -667,11 +771,14 @@ private function unserializeTypeStruct(object $type): void $typeMask = $type->type_mask; if (($typeMask & self::TYPE_LIST_BIT) !== 0) { $listAddress = $this->unPtr($type, 'ptr'); - $list = Core::pointerAtAddress('zend_type_list *', $listAddress); - $typeSize = Core::sizeOfType(zend_type::class); + $this->requireSpan($listAddress, Core::sizeOfType(zend_type_list::class), 'zend_type_list header'); + $list = Core::pointerAtAddress('zend_type_list *', $listAddress); + $typeSize = Core::sizeOfType(zend_type::class); // ZEND_TYPE_LIST_FOREACH: entries start at list->types (the flexible member) $entryBase = $listAddress + Core::sizeOfType(zend_type_list::class) - $typeSize; - for ($i = 0; $i < $list->num_types; $i++) { + $numTypes = $this->requireCount((int) $list->num_types, 'type list num_types'); + $this->requireSpan($entryBase, $numTypes * $typeSize, 'type list entries'); + for ($i = 0; $i < $numTypes; $i++) { $this->unserializeTypeStruct(Core::pointerAtAddress('zend_type *', $entryBase + $i * $typeSize)); } @@ -764,7 +871,9 @@ private function unserializeOpArray(object $opArray): void if ($this->ptrValue($opArray, 'literals') !== 0) { $address = $this->unPtr($opArray, 'literals'); $zvalSize = Core::sizeOfType(zval::class); - for ($i = 0; $i < $opArray->last_literal; $i++) { + $count = $this->requireCount((int) $opArray->last_literal, 'op_array last_literal'); + $this->requireSpan($address, $count * $zvalSize, 'op_array literals'); + for ($i = 0; $i < $count; $i++) { $this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); } } @@ -784,7 +893,9 @@ private function unserializeOpArray(object $opArray): void if ($opArray->num_dynamic_func_defs !== 0) { // zend_op_array* array: relocate it, then recurse into each nested body $defsAddress = $this->unPtr($opArray, 'dynamic_func_defs'); - for ($i = 0; $i < $opArray->num_dynamic_func_defs; $i++) { + $count = $this->requireCount((int) $opArray->num_dynamic_func_defs, 'num_dynamic_func_defs'); + $this->requireSpan($defsAddress, $count * PHP_INT_SIZE, 'dynamic_func_defs table'); + for ($i = 0; $i < $count; $i++) { $defAddress = $this->unPtrAt($defsAddress + $i * PHP_INT_SIZE); $this->unserializeOpArray(Core::pointerAtAddress('zend_op_array *', $defAddress)); } @@ -871,7 +982,7 @@ private function serializeOpArray(object $opArray): void */ private function argInfoBounds(object $opArray): array { - $count = (int) $opArray->num_args; + $count = $this->requireCount((int) $opArray->num_args, 'op_array num_args'); $start = 0; if (($opArray->fn_flags & self::ZEND_ACC_HAS_RETURN_TYPE) !== 0) { $start = -1; @@ -894,6 +1005,8 @@ private function unserializeArgInfo(object $opArray): void $address = $this->unPtr($opArray, 'arg_info'); $argInfoSize = Core::sizeOfType(zend_arg_info::class); [$start, $end] = $this->argInfoBounds($opArray); + // The array starts at arg_info[start] (start is -1 for a return type) + $this->requireSpan($address + $start * $argInfoSize, ($end - $start) * $argInfoSize, 'op_array arg_info'); for ($i = $start; $i < $end; $i++) { $arg = Core::pointerAtAddress('zend_arg_info *', $address + $i * $argInfoSize); if (!$this->isUnserialized($this->ptrValue($arg, 'name'))) { @@ -932,10 +1045,13 @@ private function unserializeVars(object $opArray): void return; } $address = $this->unPtr($opArray, 'vars'); - for ($i = 0; $i < $opArray->last_var; $i++) { + $count = $this->requireCount((int) $opArray->last_var, 'op_array last_var'); + $this->requireSpan($address, $count * PHP_INT_SIZE, 'op_array vars table'); + for ($i = 0; $i < $count; $i++) { $slot = Core::pointerAtAddress('zend_string **', $address + $i * PHP_INT_SIZE); $view = Core::cast('uintptr_t *', $slot); if (!$this->isUnserialized((int) $view[0]) && (int) $view[0] !== 0) { + $this->requireStringOffset((int) $view[0], 'op_array var name'); if (((int) $view[0] & 1) !== 0) { $view[0] = $this->strSectionBase + ((int) $view[0] & ~1); } else { @@ -1062,6 +1178,8 @@ private function unserializePropertyTable(object $ce, string $field, int $count) } $address = $this->unPtr($ce, $field); $zvalSize = Core::sizeOfType(zval::class); + $count = $this->requireCount($count, "class {$field} count"); + $this->requireSpan($address, $count * $zvalSize, "class {$field}"); for ($i = 0; $i < $count; $i++) { $this->unserializeZval(Core::pointerAtAddress('zval *', $address + $i * $zvalSize)); } @@ -1091,9 +1209,12 @@ private function unserializePropInfoTable(object $ce): void return; } $address = $this->unPtr($ce, 'properties_info_table'); - for ($i = 0; $i < $ce->default_properties_count; $i++) { + $count = $this->requireCount((int) $ce->default_properties_count, 'default_properties_count'); + $this->requireSpan($address, $count * PHP_INT_SIZE, 'properties_info_table'); + for ($i = 0; $i < $count; $i++) { $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); if ((int) $slot[0] !== 0) { + $this->requireOffset((int) $slot[0], 'properties_info_table entry'); $slot[0] = $this->base + (int) $slot[0]; } } @@ -1124,6 +1245,8 @@ private function unserializeClassNames(object $ce, string $field, int $count): v { $address = $this->unPtr($ce, $field); $nameSize = Core::sizeOfType(zend_class_name::class); + $count = $this->requireCount($count, "class {$field} count"); + $this->requireSpan($address, $count * $nameSize, "class {$field}"); for ($i = 0; $i < $count; $i++) { $name = Core::pointerAtAddress('zend_class_name *', $address + $i * $nameSize); $this->unStr($name, 'name'); @@ -1157,12 +1280,15 @@ private function unserializeTraitAliases(object $ce): void } // A NULL-terminated zend_trait_alias* array; each entry's strings follow $slotAddress = $this->unPtr($ce, 'trait_aliases'); + // Bound the terminator scan: each slot read must stay inside the region + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_aliases array'); while (($aliasAddress = $this->unPtrAt($slotAddress)) !== 0) { $alias = Core::pointerAtAddress('zend_trait_alias *', $aliasAddress); $this->unStr($alias->trait_method, 'method_name'); $this->unStr($alias->trait_method, 'class_name'); $this->unStr($alias, 'alias'); $slotAddress += PHP_INT_SIZE; + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_aliases array'); } } /** @@ -1194,15 +1320,19 @@ private function unserializeTraitPrecedences(object $ce): void } // A NULL-terminated zend_trait_precedence* array with inline exclude names $slotAddress = $this->unPtr($ce, 'trait_precedences'); + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_precedences array'); while (($precedenceAddress = $this->unPtrAt($slotAddress)) !== 0) { $precedence = Core::pointerAtAddress('zend_trait_precedence *', $precedenceAddress); $this->unStr($precedence->trait_method, 'method_name'); $this->unStr($precedence->trait_method, 'class_name'); $excludeBase = Core::addressOf($precedence->exclude_class_names); - for ($j = 0; $j < $precedence->num_excludes; $j++) { + $excludes = $this->requireCount((int) $precedence->num_excludes, 'trait precedence num_excludes'); + $this->requireSpan($excludeBase, $excludes * PHP_INT_SIZE, 'trait precedence excludes'); + for ($j = 0; $j < $excludes; $j++) { $this->unStrAt($excludeBase + $j * PHP_INT_SIZE); } $slotAddress += PHP_INT_SIZE; + $this->requireSpan($slotAddress, PHP_INT_SIZE, 'trait_precedences array'); } } /** @@ -1250,6 +1380,7 @@ private function unserializePropInfo(object $zval): void // zend_function*[ZEND_PROPERTY_HOOK_COUNT]: relocate the array, then // each non-NULL hook and its op_array (a shared body returns early) $hooksAddress = $this->unPtr($prop, 'hooks'); + $this->requireSpan($hooksAddress, self::PROPERTY_HOOK_COUNT * PHP_INT_SIZE, 'property hooks array'); for ($i = 0; $i < self::PROPERTY_HOOK_COUNT; $i++) { $hookAddress = $this->unPtrAt($hooksAddress + $i * PHP_INT_SIZE); if ($hookAddress !== 0) { @@ -1409,8 +1540,11 @@ private function unserializeWarnings(object $script): void return; } $address = $this->unPtr($script, 'warnings'); - for ($i = 0; $i < $script->num_warnings; $i++) { - $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); + $count = $this->requireCount((int) $script->num_warnings, 'num_warnings'); + $this->requireSpan($address, $count * PHP_INT_SIZE, 'warnings table'); + for ($i = 0; $i < $count; $i++) { + $slot = Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $address + $i * PHP_INT_SIZE)); + $this->requireOffset((int) $slot[0], 'warning entry'); $slot[0] = $this->base + (int) $slot[0]; $warning = Core::pointerAtAddress('zend_error_info *', (int) $slot[0]); $this->unStr($warning, 'filename'); @@ -1447,7 +1581,9 @@ private function unserializeEarlyBindings(object $script): void } $address = $this->unPtr($script, 'early_bindings'); $bindingSize = Core::sizeOfType(zend_early_binding::class); - for ($i = 0; $i < $script->num_early_bindings; $i++) { + $count = $this->requireCount((int) $script->num_early_bindings, 'num_early_bindings'); + $this->requireSpan($address, $count * $bindingSize, 'early_bindings table'); + for ($i = 0; $i < $count; $i++) { $binding = Core::pointerAtAddress('zend_early_binding *', $address + $i * $bindingSize); $this->unStr($binding, 'lcname'); $this->unStr($binding, 'rtd_key'); diff --git a/tests/OpCache/BoundsValidationTest.php b/tests/OpCache/BoundsValidationTest.php new file mode 100644 index 00000000..c9664057 --- /dev/null +++ b/tests/OpCache/BoundsValidationTest.php @@ -0,0 +1,182 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\OpCache; + +use FFI; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use ZEngine\Core; + +/** + * Bounds-validation coverage (issue #123): a crafted or truncated binary whose + * stored offsets, counts or element spans escape the declared buffer must be + * refused loudly - never dereferenced. Every stored offset in a .bin is + * attacker-controllable (system_id is a build fingerprint, adler32 is + * forgeable), so an application that feeds an untrusted binary into + * getReflection() must get an exception, not an out-of-bounds engine walk or a + * crash. These tests corrupt a real payload's structural fields and assert the + * loud refusal; they run in the debug container too, where an unguarded + * out-of-bounds read segfaults the loudest. + */ +#[Group('opcache')] +#[Group('opcache-relocator')] +final class BoundsValidationTest extends TestCase +{ + use FileCacheFixture; + + protected function setUp(): void + { + if (!PayloadRelocator::isSupported()) { + self::markTestSkipped( + 'The file-cache relocator supports 64-bit POSIX payloads only' + . ' (Windows opcache support is an intentional non-goal, issue #119)', + ); + } + } + + protected function tearDown(): void + { + self::removeCacheDir(); + } + + public function testTruncatedBufferIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + // The header still claims the full memSize, but the buffer is short: + // scriptOffset now points past the (shrunk) region + $truncated = substr($payload, 0, intdiv(strlen($payload), 2)); + $buffer = $this->bufferOf($truncated, strlen($payload)); + // Shrink the region the relocator believes it has to the real length + $shortMeta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: strlen($truncated), + strSize: 0, + scriptOffset: $meta->scriptOffset(), + timestamp: 0, + checksum: 0, + ); + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/Malformed opcache payload/'); + (new PayloadRelocator($buffer, $shortMeta))->relocate(); + } + + public function testScriptOffsetPastRegionIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $hostileMeta = CacheMetaInfo::forPayload( + systemId: SystemId::current(), + memSize: $meta->memSize(), + strSize: $meta->strSize(), + scriptOffset: $meta->memSize() + 4096, // well past the region + timestamp: 0, + checksum: 0, + ); + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/scriptOffset|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $hostileMeta))->relocate(); + } + + public function testHostileScriptPointerFieldIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $base = Core::addressOf(Core::addr($buffer)); + + // Corrupt the script's filename offset to a wild value past the region. + // zend_script is the first member of zend_persistent_script, so the + // script offset is a zend_script* - single-hop to keep the field typed. + $script = Core::pointerAtAddress('zend_script *', $base + $meta->scriptOffset()); + $filenameAt = Core::addressOf(FFI::addr($script->filename)); + Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $filenameAt))[0] = $meta->memSize() + 0x4000; + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/string field filename|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $meta))->relocate(); + } + + public function testHostileHashCountIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $base = Core::addressOf(Core::addr($buffer)); + + // Blow up the function table's nNumUsed so the bucket walk would spill + $script = Core::pointerAtAddress('zend_script *', $base + $meta->scriptOffset()); + $functionTableAt = Core::addressOf(FFI::addr($script->function_table)); + $functionTable = Core::pointerAtAddress('HashTable *', $functionTableAt); + $functionTable->nNumUsed = 0x7fffffff; + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/count|span|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $meta))->relocate(); + } + + public function testHostileInternedStringOffsetIsRefused(): void + { + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $base = Core::addressOf(Core::addr($buffer)); + + // Tag the script filename as an interned reference far past the (empty) + // string section - a plausible-looking but out-of-range interned offset + $script = Core::pointerAtAddress('zend_script *', $base + $meta->scriptOffset()); + $filenameAt = Core::addressOf(FFI::addr($script->filename)); + Core::cast('uintptr_t *', Core::pointerAtAddress('void *', $filenameAt))[0] = 0x100001; // odd => tagged, offset 0x100000 + + $this->expectException(OpCacheException::class); + $this->expectExceptionMessageMatches('/interned-string offset|Malformed opcache payload/'); + (new PayloadRelocator($buffer, $meta))->relocate(); + } + + public function testValidPayloadStillRelocates(): void + { + // The guard must not reject a well-formed image (no false positives) + [$payload, $meta] = $this->compiledPayload(); + $buffer = $this->bufferOf($payload); + $relocator = new PayloadRelocator($buffer, $meta); + $relocator->relocate(); + + self::assertSame($payload, $relocator->derelocate()); + } + + /** + * @return array{string, CacheMetaInfo} + */ + private function compiledPayload(): array + { + $fixture = self::fixturePath(); + $binPath = self::compileFixture($fixture); + $payload = substr((string) file_get_contents($binPath), CacheMetaInfo::byteSize()); + $meta = CacheMetaInfo::parse((string) file_get_contents($binPath), $binPath); + + return [$payload, $meta]; + } + + /** + * @return \FFI\CData a writable char[capacity] holding $payload + */ + private function bufferOf(string $payload, ?int $capacity = null): object + { + $capacity = $capacity ?? strlen($payload); + $buffer = Core::new("char[{$capacity}]", false); + if ($payload !== '') { + Core::memcpy($buffer, $payload, strlen($payload)); + } + + return $buffer; + } +}