Four Bytes Good: Shifting Left with PHP

Header image for the blog: Four Bytes Good: Shifting Left with PHP
“An ounce of prevention is worth a pound of cure”
— Benjamin Franklin

If you've read any of my previous posts, you'll know I have a bit of a thing for image parsers. TIFF gave me a ‘Write-What-Where’, MIFF gave me ‘A sorrowful tale of loss’, so naturally I went looking for another three or four letter acronym to complete the trio. This time it's TGA (and it's PHP… somewhat upstaged by the recent critical CVEs!). 

Image parsers are brilliant targets. They take a chunk of bytes from a complete stranger, trust the header that came with it, and then let C do the maths. What could possibly go wrong? 

Unfortunately,  this tale has a less satisfying ending that my previous write-ups. If the recent critical PHP vulnerabilities announced by the PHP Foundation are the headline acts, this finding isn’t even a support act, it’s an understudy that will never make it to the stage. There's no CVE at the end of it, and nobody needs to rush out and patch anything. On balance, that’s a good thing, and I'll get to why. 

The Short Version 

PHP's GD extension recently picked up a new TGA image reader. That reader has a stack buffer overflow in it. A crafted TGA file can write up to 28 bytes of attacker-chosen data past the end of a 4-byte buffer on the stack. On a normal hardened build, that's a reliable crash, and depending on the build and its mitigations, it could be a lot more serious. 

The bug only exists on PHP's master branch (8.6.0-dev). It has never been present in a released version of PHP. After I reported it to the PHP developers, I was informed it wasn’t considered a security issue because it only exists in the development branch. I considered this a reasonable response, as it would be fixed before 8.6 ships. Furthermore, after I had reported it, I found that libgd, the project that the code came from, had already fixed it upstream. 

So, we've got a high to critical risk bug that was caught and fixed before it ever reached a release. That's ‘shift left’ doing exactly what it says on the tin. 

A Bit of Background 

GD is the image library that ships with PHP. If you've ever called imagecreatefromjpeg() or knocked up a thumbnail in PHP, it was GD doing the work behind the scenes. PHP doesn't link against the system libgd by default; it carries its own bundled copy in ext/gd/libgd, and every so often that copy gets synced with upstream.  

In July 2026 one of those syncs landed as a commit, which brought the bundled library up to libgd 2.4. A decent chunk of it was a reworked TGA reader. 

TGA support in PHP isn't new in itself. imagecreatefromtga() cropped up in PHP 7.4 back in 2019. The old reader worked quite differently though. It pulled the image data into a heap buffer and didn't have the code path I'm about to show you. The new version is a rewrite, and new code is often the best place to look for vulnerabilities, as it hasn't had years of fuzzers and bored researchers interrogating it yet. 

TGA in Thirty Seconds 

TGA (or Targa, if you're feeling retro) is an image format from 1984. It has an 18-byte header and no magic number. 

The following headers are the ones that are most interesting: 

  • colormaptype (byte 1) - is there a colour map (a palette) in the file? 0 for no, 1 for yes. 

  • imagetype (byte 2) - indexed, RGB, greyscale, or the RLE-compressed versions of those. 

  • colormaplength (bytes 5-6) - how many entries the colour map has. 

  • colormapbits (byte 7) - how many bits each colour map entry takes up. 

  • bits (byte 16) - bits per pixel for the image itself. 

The legal values for colormapbits are 15, 16, 24 and 32, which work out at 2, 2, 3 or 4 bytes per entry. Keep the number 4 in your head. 

Finding the Issue 

Here's the function that reads the colour map, from ext/gd/libgd/gd_tga.c (trimmed a bit). Have a look and see if you can spot it before I spoil it: 

static int tga_pixel_size(uint8_t bits) { return (bits + 7) / 8; } 
    static int tga_read_color_map(gdIOCtx ctx, oTga tga) 
    { 
        ... 
        entry_size = tga_pixel_size(tga->colormapbits); 
        tga->colormap = (int )gdMalloc(tga->colormaplength  sizeof(int)); 
        ... 
        for (i = 0; i < tga->colormaplength; i++) { 
            unsigned char buf[4] = {0, 0, 0, 0}; 
            if (gdGetBuf(buf, entry_size, ctx) != entry_size) { 
                ... 
                return -1; 
            } 
            tga->colormap[i] = tga_decode_color(buf, tga->colormapbits, ...); 
        } 
        ... 
    } 

 Each colour map entry gets read into buf, which is 4 bytes on the stack. The number of bytes read is entry_size, which comes from colormapbits, which comes straight out of the file. gdGetBuf() has no idea how big buf is. It just passes the length down to fread() or memcpy(), depending on how the file was opened, and they copy however much they're told to. 

So everything hinges on colormapbits never being anything other than 15, 16, 24 or 32. Is it checked? Well, sort of. 

 The Half-Check  

The header validation lives in read_header_tga(), and it's a switch on the image type: 

  switch (tga->imagetype) { 
    case TGA_TYPE_INDEXED: 
    case TGA_TYPE_INDEXED_RLE: 
        if (tga->colormaptype != 1 || tga->bits != TGA_BPP_8 || 
            !(tga->colormapbits == 15 || tga->colormapbits == 16 || 
              tga->colormapbits == 24 || tga->colormapbits == 32)) { 
            return -1; 
        } 
        break; 
 
    case TGA_TYPE_RGB: 
    case TGA_TYPE_RGB_RLE: 
        if (!(tga->bits == TGA_BPP_16 || tga->bits == TGA_BPP_24 || 
              (tga->bits == TGA_BPP_32 && tga->alphabits == 8))) { 
            return -1; 
        } 
        break; 
 
    case TGA_TYPE_GREYSCALE: 
    case TGA_TYPE_GREYSCALE_RLE: 
        if (tga->bits != TGA_BPP_8) { 
            return -1; 
        } 
        break; 
    ... 

 There's the whitelist in the indexed case. On the face of it that makes sense, because indexed images are the ones that actually need a palette. RGB and greyscale images get their bits value checked, and colormapbits never comes into it. 

The catch is that a colour map is optional for RGB and greyscale images, and the header check only throws out colormaptype values above 1. An RGB image that says "oh, and I've brought a colour map" is perfectly acceptable, and read_image_tga() goes on to call tga_read_color_map() regardless of the image type. 

So you can declare an RGB image, say it comes with a palette, and set the palette entry size to whatever you fancy. colormapbits is a single byte, so the most you can ask for is 255: 

entry_size = (255 + 7) / 8 = 32

Thirty-two bytes into a four-byte buffer. That's 28 bytes of the stack frame written with whatever you put in the file. 

The slightly annoying bit is that the function directly below it, tga_read_pixel(), does pretty much the same thing (a buf[4] and a size from tga_pixel_size()) and is completely fine, because its size comes from tga->bits, and tga->bits gets checked for every image type. The only real difference between the two is which header field the size comes from. 

Building the File 

The whole proof of concept is 50 bytes. First, an 18-byte header: 

    00 01 02 00 00 01 00 FF 00 00 00 00 01 00 01 00 18 00 

Reading left to right, that's: no ident field, colour map present, image type 2 (uncompressed RGB), colour map starts at 0, one colour map entry, 255 bits per entry, origin at 0,0, one pixel wide, one pixel high, 24 bits per pixel, no alpha. 

Then 32 bytes of payload for that single, gloriously oversized colour map entry. I went with 32 x 0x41, because it’s traditional unlike cauliflower at Christmas (I hope some people get the reference). 

The image is one pixel by one pixel, which doesn't matter in the slightest, because we never get as far as reading a pixel. The overflow happens while the colour map is being read, before any image data is touched. 

Proving It 

I built PHP from master with AddressSanitizer and pointed imagecreatefromtga() at the file: 

WRITE of size 32. There's our entry_size, right where the maths indicated. On a build with -fstack-protector and no ASAN, you get the typical response from a stack overflow. Stack Smashing Detected. Or in the case where there’s no stack protection, Segfault! 

 How Bad Is It? 

There are some mitigations, of course. For example, there's the stack protector. With -fstack-protector-strong, which most distro builds use, character arrays get placed right up against the stack canary. A forward overflow of buf overwrites the canary before it reaches the return address, and the process aborts as soon as the function returns. On a typical hardened build, the realistic outcome is a reliable, remotely triggerable crash of whichever PHP worker decoded the image. 

On a build without a stack protector, or with a less forgiving frame layout, 28 fully controlled bytes heading towards saved registers and a return address is a textbook stack overflow.  

To reach it, an application needs to call imagecreatefromtga() on a file an attacker supplied, so think upload handlers, thumbnail generators and image converters. No special configuration is needed, since GD is on and TGA reading is always compiled in. Remember TGA not having a magic number? That means imagecreatefromstring() can't recognise TGA data and won't route it to this code, so the application has to be asking for TGA specifically. That narrows things down a bit, but anything taking TGA uploads is in the firing line. 

If you want to put a number on it, considering only the crash, it comes out around 7.5 under CVSS 3.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H). If you assume the overflow can be turned into code execution, it's 9.8. High to critical, depending on how pessimistic you're feeling that day. 

 Responsible disclosure 

I wrote it up the finding and reported it to the PHP developers. 

The response was that it isn't a security issue, because it exists only in the -dev branch and has never been in a released version. Honestly, that's fair enough. Security advisories are there to tell people running PHP that they need to patch, and nobody running a released PHP needs to do anything here. It'll be fixed before 8.6 is released. 

Then came the plot twist. After finding it, I went to have a look at upstream libgd, where the code was synced from, to see whether the same bug was sitting there too. It wasn't, because it had already been fixed. On 7 July 2026, libgd moved the colormapbits whitelist out of the indexed branch so that it applies to every image type. The commit message reads "fix possible OOB in not yet released gd_tga TGA support improvements", and it came with a regression test called colormap_bits_stack_oob, which is more or less what this is. 

So upstream had caught the same bug, also before release. The timing was tight. The libgd fix went in on 7th July and PHP's sync was merged on 16 July, carrying the version of the code from before the fix. That's how I came to find it sitting on PHP's master branch. 

Was I a touch frustrated that my find wouldn’t result in a sparkling new CVE that I could add to my CV? Maybe a little. Finding out your shiny new bug has already been squashed upstream takes a bit of the wind out of your sail. But PHP's copy still had the bug in it, and if nobody had noticed, it would have shipped that way. 

At the very least, it’s character building – and we do this to make the cyber security world better, don’t we? CVEs are not the be all and end all of the industry. 

Shifting Left 

"Shift left" is one of those phrases that gets rolled out in a lot of meetings. If you draw software delivery as a timeline, with design and code on the left and release and production on the right, shifting left just means finding problems earlier on that line. The further right a bug gets, the more it costs, because more people are running it and every one of them has to patch. 

This bug was found about as far left as it possibly could, while still being real. It was introduced in July, was found on the development branch, and will be fixed in PHP before release (thanks to my report!). Unless someone out there is running a nightly build of PHP in production (please don't), the number of affected production systems is zero. 

The trade-off is that there's no CVE. Bugs that never ship don't get CVEs, which is entirely reasonable even if it leaves the trophy cabinet looking a little bare. But we're not in this to collect CVEs, we're in it to make the stuff people rely on more secure. A high to critical memory corruption bug that never made it into a single release is a better outcome than one that did, CVE or not. 

 Closing Thoughts 

The lesson I keep relearning with parsers is that a check only protects the code paths it actually sits in front of. Here the whitelist guarded the indexed branch, and the colour map got read for everything. One function further down, tga_read_pixel() got it right, which somehow makes it more frustrating rather than less. 

The other key take-away is that big vendored syncs are worth a look. A lot of new code lands in one go, and fixes made upstream around the same time don't always come along for the ride. Dev branches in general are a good hunting ground. You won't get a CVE out of them, but the bugs you find there are the cheapest ones anyone is ever going to fix. 

AnchorSec Shift Left 

A lot of AnchorSec’s research investigations focus on open-source projects for three main reasons. First, the source code is readily accessible. Second, there is always a genuine urge to take things apart and see what breaks; even with a full schedule of interesting and varied client engagements, there is always an itch to find something that can be shared that will benefit the community. Vulnerabilities found in OSS code, or libraries used by commercial products expose many clients to risk downstream. When libraries or software stacks are used by organisations – as is the case for PHP, LibTiff, ImageMagick, Libheif, etc. – vulnerabilities in that software feed into technologies used in the world right now. 

As you will have seen in previous blogs, not every investigation bears fruit and, even when it does, the outcome is rarely the news-worthy acclaim you might hope for. But that’s indicative of positive progress by the industry, a trend which AnchorSec is proud to play a very small part in. This particular piece of research, demonstrates exactly how AnchorSec seeks to help its clients, by finding bugs early in the product and software development process so they can be fixed before the code reaches production. That reduces the overall effort and cost of remediation while keeping potential exposure to a minimum. 

When security professionals become part of the development process and integrate in a way that gives us visibility across the codebase and early builds, this is how we find bugs: before they are released. 

Whether it be web applications, IoT systems or binary code in all colours of the rainbow, we continue to find some genuinely interesting things. More importantly, vulnerabilities found before production rarely need to appear on a risk register because they never reach the point of exposing the organisation. In our experience, this also helps reduce vulnerability saturation and keeps security teams focused on the risks that genuinely exist in the live environment. 

 

PoC || GTFO 

 The file as hex. Run it through xxd -r -p and feed it to imagecreatefromtga() on an affected master build (in a VM, please): 

    00 01 02 00 00 01 00 FF 00 00 00 00 01 00 01 00 18 00 

    41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 

    41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 

Or if you'd rather do it in PHP: 

    <?php 
    $hdr = "\x00\x01\x02\x00\x00\x01\x00\xFF\x00\x00\x00\x00\x01\x00\x01\x00\x18\x00"; 
    file_put_contents('poc.tga', $hdr . str_repeat("\x41", 32)); 
    var_dump(imagecreatefromtga('poc.tga')); 

Then one can simply run it in PHP: 

$path = DIR . '/poc.tga'; 
var_dump(imagecreatefromtga($path)); 

On a patched build you'll get a warning and bool(false). On an unpatched one, you're in for a much more interesting afternoon. 

Next
Next

AI on the Offensive: How AI is reshaping offensive security