August 30, 2026
CVE-2026–65650
How an unbounded image upload in Elgg’s avatar handler turns a single low-privileged request into a memory-exhaustion denial of service…
By ketu
4 min read
How an unbounded image upload in Elgg's avatar handler turns a single low-privileged request into a memory-exhaustion denial of service, and the PoC I built to prove it.
TL;DR
Elgg's avatar upload feature passes user-supplied images straight into saveIconFromUploadedFile() with no check on dimensions, decoded size, or memory footprint. PHP's GD library allocates memory based on an image's pixel dimensions, not its file size on disk, so a small file with extreme dimensions forces a massive allocation the moment GD touches it.
A single authenticated, low-privileged user can upload a roughly 308 KB PNG and force the server to allocate close to 850 MB decoding and resizing it. On memory-constrained deployments, that's enough to trigger a kernel OOM kill of PHP-FPM workers and take the site down, with no self-recovery. I built a PoC that reproduces this deterministically, reported it, and Elgg shipped a fix in 6.3.5 and 7.0.0.
Reporter: Swornim Poudel PoC: github.com/swornim619/CVE-2026–65650
This post covers how the bug works, how I built the PoC, and what the fix changes.
Background: Avatar Uploads in Elgg
Elgg is an open-source social networking engine used to build community platforms. Like most social platforms, it lets users upload a profile avatar, and the upload path runs through actions/avatar/upload.php, which hands the file to saveIconFromUploadedFile() on the owner entity.
That function exists to take whatever a user uploads, generate an icon, and resize it into the various thumbnail sizes Elgg needs across the UI. It's a small, unglamorous piece of infrastructure that almost every account on a given instance touches at least once. That ubiquity is exactly what makes an unguarded allocation here so dangerous: it doesn't take an admin, a rare workflow, or a chained exploit. It takes one registered user and one file picker.
The Bug
actions/avatar/upload.php pulls the uploaded file and immediately hands it off:
$avatar = elgg_get_uploaded_file('avatar', false);
// No dimension check.
// No size check.
// No ratio check.
if (!$owner->saveIconFromUploadedFile('avatar')) {
return elgg_error_response(elgg_echo('avatar:resize:fail'));
}$avatar = elgg_get_uploaded_file('avatar', false);
// No dimension check.
// No size check.
// No ratio check.
if (!$owner->saveIconFromUploadedFile('avatar')) {
return elgg_error_response(elgg_echo('avatar:resize:fail'));
}Nothing between the upload and the resize call inspects the image's dimensions. Nothing bounds how large the decoded bitmap is allowed to be. Nothing checks the ratio between the file's on-disk size and what it claims to decode to.
That gap matters because of how PHP's GD library actually works. GD doesn't care about a file's size on disk. It allocates memory based on the image's pixel dimensions once decoded: width times height times bytes per pixel. A PNG can compress an enormous canvas down to a few hundred kilobytes on disk and still demand hundreds of megabytes the instant GD decodes it into memory to resize it.
So the attack shape is simple: craft an image with extreme dimensions but a small compressed footprint, upload it as an avatar, and let GD do the rest. No exploit chain, no privilege escalation, just a decode operation that costs far more than the file it operates on suggests.
Building the PoC
I wrote CVE-2026-65650.py (github.com/swornim619/CVE-2026-65650) to generate a genuine, decodable image rather than a header trick that GD might reject outright. It uses Pillow to create a 10,000 by 10,000 pixel RGB canvas filled with a single solid color, then saves it as a PNG at maximum compression:
width = 10000
height = 10000
# Create real image with actual pixel data
img = Image.new('RGB', (width, height), color=(255, 0, 0))
img.save('CVE-2026-65650.png', format='PNG', compress_level=9)width = 10000
height = 10000
# Create real image with actual pixel data
img = Image.new('RGB', (width, height), color=(255, 0, 0))
img.save('CVE-2026-65650.png', format='PNG', compress_level=9)A solid-color canvas compresses extremely well, which is exactly the point. PNG's compression collapses a huge, uniform pixel grid down to a small file, but GD still has to reconstruct the full 10,000 by 10,000 grid in memory before it can do anything with it. The script computes the expected cost directly from the dimensions, at 3 bytes per pixel for RGB, and prints it alongside the actual file size so the gap between the two is obvious before the upload even happens:
mem = (width * height * 3) / (1024 * 1024)
print(f" Memory GD needs to decode : ~{mem:.0f} MB")
print(f" Memory GD needs to resize : ~{mem*2:.0f} MB (src + dst buffer)")mem = (width * height * 3) / (1024 * 1024)
print(f" Memory GD needs to decode : ~{mem:.0f} MB")
print(f" Memory GD needs to resize : ~{mem*2:.0f} MB (src + dst buffer)")Then I walked through the actual upload flow by hand to confirm impact end to end:
- Run
python3 CVE-2026-65650.pyto produceCVE-2026-65650.png. - Log in as any registered, low-privileged user.
- Navigate to Profile > Edit Avatar and upload the generated file.
The result was consistent every time. Uploading the roughly 308 KB file forced GD to allocate around 286 MB just to decode it, matching the script's own estimate, then another 572 MB on top of that to resize it, for close to 850 MB total from a single request. On a 14 GB test box with no swap enabled, one upload dropped available memory to about 1.3 GB.
That's from one request, from one account. Nothing about the attack requires elevated privileges or repeated attempts. Concurrent or repeated uploads from multiple accounts compound the effect further and push a memory-constrained deployment toward an OOM kill of PHP-FPM workers, which takes the site down without any self-recovery path.
Root Cause
The root cause is a missing bound, not a logic error. saveIconFromUploadedFile() trusts whatever dimensions the uploaded image claims to have and proceeds straight into decode and resize. Nothing in the path validates that a file's claimed dimensions are reasonable relative to its actual size on disk, and nothing caps the memory an avatar upload is allowed to consume.
This is the same failure pattern that shows up across a lot of image and file-upload handling: file size on disk is not a proxy for decoded memory cost, and any code path that decodes user-supplied media needs its own explicit ceiling, independent of whatever the file format's compression happens to achieve.
Disclosure and Fix
I reported the finding to the Elgg maintainers as Swornim Poudel. The fix landed in Elgg/Elgg#15041 (commit ab91d59) and shipped in Elgg 6.3.5, with the same fix carried into the final 7.0.0 release after the 7.0 release candidates.
The issue is tracked as CVE-2026–65650 and GHSA-7983–35fr-8qwm, filed under CWE-770, allocation of resources without limits or throttling. GitHub's advisory rates it moderate severity with a CVSS v3.1 base score of 4.3 (network vector, low attack complexity, low privileges required, no user interaction, availability impact only). That score reads lower than the practical impact might suggest, mostly because CVSS scores a single request's theoretical worst case rather than what happens when several accounts fire uploads concurrently. On a memory-constrained box, that gap between the standardized score and the operational reality is worth keeping in mind when you triage findings like this one.
CVE-2026–65650 covers the Elgg 6.x line before 6.3.5, along with the 7.0.0 release candidates before the final 7.0.0 release. Full PoC and writeup: github.com/swornim619/CVE-2026–65650.
Takeaways
File size on disk tells you almost nothing about decoded memory cost. Any code that hands user-supplied media to a decoder needs to check the media's actual dimensions or resource footprint before that decoder runs, not after.
A single unguarded allocation is enough. This didn't need a chain of bugs or an elevated account. One low-privileged user, one upload form, one missing check.
Ubiquitous, boring code paths are exactly where this kind of bug survives longest. Avatar upload isn't an edge case feature. It's something nearly every account touches, which is precisely why it deserved more scrutiny than it got.
If your application decodes user-supplied images, video, or any compressed media, it's worth asking: does anything bound the decoded size, or does the code just trust whatever the file claims?