On July 7, 2026, the CodeIgniter team shipped version 4.7.4 and published advisory GHSA-mmj4-63m4-r6h5 alongside it, closing a flaw in one of the framework's most routinely used features: file upload validation. The verdict is blunt. In a vulnerable configuration, a remote and unauthenticated attacker can upload a PHP webshell that the framework's own is_image and mime_in rules happily wave through, and then execute it, gaining arbitrary code execution on the server. The issue is tracked as CVE-2026-63223 and carries a CVSS score of 9.8, the near-maximum reserved for network-reachable, no-interaction, no-privilege remote code execution. The striking detail is not the score but the mechanism: the bypass needs nothing more exotic than a few magic bytes glued to the front of a .php file, and it defeats a validation rule that thousands of applications trust precisely because it sounds like the safe one to use.
This is a two-layered breakdown. The first part is for readers who need to decide quickly whether they have anything to fix and what exactly to do about it, in plain language and without the internals. The second part reconstructs the flaw the way the maintainers and the reporter saw it: the exact rules involved, the content-versus-filename gap they left open, the polyglot file that walks through it, and the two small helper functions that finally closed it. The article closes by looking at how the Pragma Core platform addresses exactly the kind of problem that made this possible, the kind that lives in the relationship between a validation function and the code that saves the file, not in any single line.
Part I. Executive breakdown
What happened
CodeIgniter is one of the oldest and most widely deployed PHP web frameworks, first released in 2006 and still actively maintained, with millions of downloads and a large install base of small-to-midsize applications, admin panels, and legacy systems that never migrated away. When one of those applications accepts a file upload (a profile avatar, a document, a product image), developers reach for the framework's built-in validation rules to check that the file is what it claims to be. Two of the most common choices are is_image, which is supposed to confirm the upload is an image, and mime_in, which confirms the file's type is on an allowed list.
Here is the flaw, stated as a cause and effect. Both rules decided whether a file was acceptable by looking only at the file's content, specifically the MIME type that PHP derives by sniffing the bytes inside the file. Neither rule looked at the file's name. That sounds reasonable, even more secure, because checking content instead of a filename is the usual advice. But an attacker controls both. They can build a single file whose contents begin with the handful of bytes that identify a GIF image, and whose remaining contents are working PHP code, and then name that file shell.php. The content sniffer sees a GIF and says "this is an image." The rule passes. The filename, still ending in .php, was never questioned.
The concrete result: if the application then saves that upload under the name the user supplied, into a directory the web server can reach and where PHP files are allowed to run, the attacker has just placed a live script on the server. One request to that URL runs their code. That is remote code execution, from an unauthenticated upload form, using the validation rule that was meant to prevent it.
Who is affected
| Component | Status |
|---|---|
CodeIgniter4 (codeigniter4/framework), versions < 4.7.4 |
Vulnerable. Upgrade to 4.7.4 or later. |
| CodeIgniter4 4.7.4 and later | Patched. is_image and mime_in now also check the client extension. |
Apps using ext_in for the extension check (patched builds) |
Not affected by this specific gap, the independent extension check was already the right pattern. |
| CodeIgniter 3.x | Separate codebase and end of life. Not covered by this advisory; assess independently. |
The rules are only dangerous in combination with how the application stores the file. An application is exposed when all three of the following hold at once: it validates uploads with is_image or mime_in and nothing else that checks the extension, it saves the file using the original client-supplied name, and it puts uploads in a web-reachable directory where PHP can execute. Plenty of tutorials and older CodeIgniter apps do exactly all three, because saving under the original filename feels natural and "just validate that it is an image" feels sufficient. That combination is what turns a validation gap into a full compromise. If your uploads land under a non-executable path or are renamed to a random string, you were spared the worst outcome, but you should still patch, because relying on the storage layer to save you from a validation bug is not a posture you want to keep.
Why this matters beyond CodeIgniter
The bug class here is CWE-434, unrestricted upload of a file with a dangerous type, and it is one of the most durable footguns in web development. The specific trap is the difference between "what is this file made of" and "what will the server treat this file as." Content sniffing answers the first question. File extension, and the web server's handler mapping, answer the second. A validator that answers only the first has not actually secured anything, because execution is decided by the second.
This same mismatch appears far beyond PHP frameworks: in Java apps that trust a declared content type but write attacker-named files under a served path, in Node services that validate a MIME type and then keep the original extension, in any pipeline where one component classifies by content and a later component acts by name. The lesson is not "CodeIgniter had a bug." It is that a security decision has to be made on the same property the danger depends on, and here the danger depended on the extension while the check looked at the bytes.
Recommended actions
- Upgrade to CodeIgniter 4.7.4 or later. This is the fix and it is the first thing to do.
- As interim mitigation until you can upgrade, stop preserving client filenames. Save uploads with
$file->store()or$file->move($path, $file->getRandomName())so the file lands under a random, extension-controlled name. - Add an independent extension check. Use the
ext_inrule, or explicitly reject when$file->getClientExtension()is not an allowed image extension, in addition to the content check. - Move upload directories outside the public web root, preferably under
writable/uploads, and disable script execution in any public upload directory at the web server level. - Inventory every upload endpoint in your codebase and confirm which validation rule each one uses and where the resulting file is written. The vulnerable pattern is a triple, and you need to see all three parts per endpoint.
- Audit existing upload directories for files with executable extensions that predate the fix, and review web server access logs for requests to uploaded paths ending in
.phpor other handler-mapped extensions.
Part II. Technical breakdown
Where the rules live and what they were supposed to guarantee
CodeIgniter's validation library ships two sets of file rules. The stricter set lives in system/Validation/StrictRules/FileRules.php and is the one most security-conscious applications use. Inside it, is_image and mime_in are the rules an application declares when it wants to accept images or a fixed set of MIME types.
Both rules resolve the uploaded file and then classify it by content. is_image reads the file's detected MIME type and requires it to begin with image. mime_in reads the detected MIME type and requires it to be a member of the allowed list passed as a parameter. The important word in both cases is "detected." CodeIgniter's UploadedFile::getMimeType() does not trust the browser-supplied Content-Type header and it does not look at the filename. It sniffs the actual bytes, using PHP's fileinfo machinery, and reports what the content looks like. This is deliberately the safer approach for answering the question "is the payload really an image," and on its own it is correct.
The design invariant the rules were meant to uphold was narrower than developers assumed. is_image guaranteed "the bytes are those of an image." mime_in guaranteed "the content sniffs to one of these types." What developers read them as guaranteeing was "it is safe to save this file under its original name in a public folder." Those two statements are not the same, and the gap between them is the whole vulnerability.
The vulnerability: a content check with no filename check
Consider the pre-4.7.4 shape of is_image. After confirming the resolved value is an uploaded file, it did essentially this:
// system/Validation/StrictRules/FileRules.php (pre-4.7.4, simplified)
public function is_image(?string $blank, string $params): bool
{
// ... resolve $file from the request ...
$type = $file->getMimeType(); // sniffed from the file content
if (mb_strpos($type, 'image') !== 0) { // must start with "image"
return false;
}
// <-- nothing here ever looked at the client filename extension
return true;
}
mime_in had the same silhouette, checking the sniffed MIME type against an allowed list and returning as soon as the type matched:
// system/Validation/StrictRules/FileRules.php (pre-4.7.4, simplified)
public function mime_in(?string $blank, string $params): bool
{
// ... resolve $file and the allowed $params list ...
if (! in_array($file->getMimeType(), $params, true)) {
return false;
}
// <-- again, the client filename extension is never considered
return true;
}
In both functions the decision is made entirely on getMimeType(), which is a property of the content. The client-supplied filename, which is the property that determines what the server will execute once the file is on disk, is never read. An attacker who controls the content and the name can satisfy the content check while keeping any extension they like on the name. The rule returns true, the application believes the file is a validated image, and the dangerous extension rides along untouched.
Root cause: the danger and the check look at different properties
The executability of an uploaded file, on a normal PHP deployment, is decided by its extension and the web server's handler mapping. A file named x.php under a path that the server maps to the PHP handler will be interpreted and run, regardless of what its first bytes look like. A file named x.gif will be served as bytes.
The pre-patch rules made their security decision on the content-derived MIME type. The runtime makes its execution decision on the extension. Those are two different properties of the same upload, and the attacker controls both independently. That is the root cause in one sentence: the validation authorized the file based on a property (content type) that has nothing to do with the property that makes it dangerous (the on-disk extension the web server will honor).
Constructing the trigger is trivial. Image formats are identified by short, fixed magic-byte signatures at the start of the file. GIF is the friendliest for this because its signature, the ASCII bytes GIF87a or GIF89a, is valid to sit at the head of a file that also contains arbitrary trailing bytes, and PHP's parser is content to skip everything up to the opening <?php tag. So a minimal polyglot is:
GIF89a
<?php system($_GET['c']); ?>
Saved to disk and sniffed, this reads as image/gif. Loaded by the PHP interpreter, the GIF89a line is just text outside PHP tags and is emitted as-is, then the <?php ... ?> block executes. One file, two identities, and the pre-patch is_image rule only ever saw the first one.
The project's own regression tests, added with the fix, encode this exactly. One test uploads a real GIF payload under the name shell.php and asserts that is_image must now reject it. Another uploads genuine PHP content under the name fake.gif and asserts rejection, confirming that an image extension alone is never sufficient and the content still has to check out. A third confirms that a real GIF named my-avatar.jpg is rejected under mime_in when the declared list does not match the content, and a fourth confirms that extensionless names such as a JavaScript Blob upload are still accepted, because for those there is no dangerous extension to disagree with.
Exploitation: from a passing validator to a live webshell
The validation gap becomes remote code execution when the application's storage step preserves the attacker's filename in an executable location. The chain is short:
- The attacker crafts the polyglot above and names it
shell.php. In the multipart upload, theContent-Typecan even be set toimage/gif; it does not matter, because the framework sniffs content anyway, and the content genuinely is a GIF. - The application runs its validation, for example a rule set of
is_image[avatar]ormime_in[avatar,image/gif]. The content sniffs toimage/gif, so on a pre-4.7.4 build the rule returns true. - The application, believing it holds a validated image, saves it. If it does something like
$file->move(FCPATH . 'uploads')without a random name, or writes$file->getName()/ the client name into a public directory, the file lands atpublic/uploads/shell.php. - The attacker requests
https://target/uploads/shell.php?c=id. The web server maps.phpto the interpreter, the trailing PHP block runs, and the attacker has command execution as the web server user.
No authentication, no user interaction, and no unusual server configuration beyond the very common "uploads are served from a public, script-enabled directory under the original name." That is why the CVSS vector is AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H and the score is 9.8.
It is worth noting that the same 4.7.4 release also hardened the storage side. The sibling issue CVE-2026-63222 covered UploadedFile::move() using the client filename without sanitization, enabling path traversal, and 4.7.4 makes move() sanitize the client name when called without an explicit target name. The two fixes are complementary: one stops the validator from approving a dangerous name, the other stops the saver from writing a dangerous path. An application that leaned on either behavior alone was exposed, and the release addresses both.
How the fix works
Version 4.7.4 keeps the content check and adds an independent filename-extension check to each rule, with two small private helpers in the same file. For is_image, the new guard rejects any upload whose non-empty client extension is not an image extension:
// system/Validation/StrictRules/FileRules.php (4.7.4)
private function hasInvalidImageClientExtension(UploadedFile $file): bool
{
$clientExtension = trim(strtolower($file->getClientExtension()), '. ');
if ($clientExtension === '') {
return false; // no extension, nothing to disagree with
}
$type = Mimes::guessTypeFromExtension($clientExtension) ?? '';
return mb_strpos($type, 'image') !== 0;
}
For mime_in, the guard rejects any upload whose non-empty client extension does not match the extension implied by the detected content:
// system/Validation/StrictRules/FileRules.php (4.7.4)
private function hasMismatchedClientExtension(UploadedFile $file): bool
{
$clientExtension = trim(strtolower($file->getClientExtension()), '. ');
if ($clientExtension === '') {
return false;
}
return $file->guessExtension() !== $clientExtension;
}
Each rule now calls its guard after the content check and returns false if the guard reports a problem. Applied to the shell.php polyglot, hasInvalidImageClientExtension maps the php extension to a non-image MIME type and returns true, so is_image rejects the file even though the bytes are a valid GIF. The extensionless-upload carve-out is deliberate, so that legitimate blob uploads with no filename extension keep working, since for those the content is still the sole authority and there is no dangerous extension in play. The changelog frames the change as bringing is_image and mime_in into line with the same extension-and-content agreement that ext_in already enforced after its own earlier fix.
Affected versions
- Vulnerable:
codeigniter4/frameworkat all versions prior to 4.7.4. - Fixed: 4.7.4, released July 7, 2026.
- Related: the
ext_inextension-bypass advisory GHSA-2gr4-ppc7-7mhx, which established the extension-and-content agreement that 4.7.4 now extends tois_imageandmime_in. - Sibling in the same release: CVE-2026-63222, path traversal via
UploadedFile::move()with an unsanitized client filename, CVSS 7.5, also fixed in 4.7.4.
Timeline
| Date | Event |
|---|---|
| 2026 (private report) | Reported privately to the CodeIgniter team by the researcher credited as wnsgurd90-keke. |
| 2026-07-07 | CodeIgniter 4.7.4 released with the fix to is_image and mime_in. |
| 2026-07-07 | Advisory GHSA-mmj4-63m4-r6h5 published by the maintainers. |
| 2026-07-31 | CVE-2026-63223 published in the CVE and NVD feeds. |
| 2026-07-31 | Record indexed by third-party vulnerability trackers. |
A note on the discovery methodology
This is a source-review find, and a good example of the value of reading a security rule against the code that consumes its verdict rather than in isolation. Looked at alone, is_image is not obviously wrong: it sniffs content, which is the recommended way to answer "is this really an image." The bug only becomes visible when you ask the next question, "and what does the application do with the file after the rule says yes," and notice that the property the rule validated (content type) is not the property that decides execution (extension). The reporter's contribution was to model the full upload-to-serve path and see the mismatch across the boundary between validation and storage. The meta-lesson for AppSec is that upload validators should be reviewed together with the save and serve steps, because the vulnerability is a property of that chain, not of any single function in it.
What we should learn from CVE-2026-63223
- Validate the property that governs the danger. Executability is decided by the on-disk extension and the server handler mapping, so an upload check that never inspects the extension has not addressed the risk that matters. Match the check to the mechanism of harm, not to a plausible-sounding proxy for it.
- Content sniffing and extension checking are complementary, not substitutes. "Check content, not the filename" is good advice against one attack and useless against another. The robust posture verifies both and requires them to agree, which is exactly what
ext_indid and what 4.7.4 now brings to the image rules. - The bug lives across a boundary. The vulnerability is not in
is_imagealone or in the file save alone; it is in the assumption that a passing content check makes it safe to persist an attacker-controlled name. Review upload validation and storage as one unit. - A rule named for safety invites over-trust. Developers pick
is_imageprecisely because it sounds like the secure choice, which means a gap in it propagates widely and quietly. The more reassuring a security helper's name, the more carefully its guarantee needs to be pinned down and documented. - Ship the storage hardening with the validation fix. 4.7.4 fixed both the validator (this CVE) and the
move()filename handling (the sibling CVE) together. Closing one side of an upload chain while leaving the other open leaves the class of bug alive; treat the chain as the unit of remediation.
How Pragma Core addresses this class of problem
Pragma Core is built for exactly this shape of issue: a vulnerability that is invisible when you read one function and obvious only when you follow how a value crosses from a validation routine into a storage routine and out to a served URL. Classical scanners tend to flag taint that ends in a SQL or shell sink; they rarely reason about a content-versus-name mismatch that only becomes remote code execution three function calls later. Each capability below is tied back to the specific gap in is_image and mime_in.
Autonomous AI agents for attack chain investigation
The autonomous agents reason over the whole upload chain rather than stopping at the validator. For a CodeIgniter codebase, the agent asks the question that surfaces this bug directly: after is_image or mime_in returns true, is the file saved under a client-controlled name, and does that name reach a web-accessible, script-enabled directory. That is precisely the triple the advisory names, and it is a question about flow across functions, not about a single flagged line, which is where off-the-shelf tools go quiet.
Interactive call graphs with vulnerability overlay
Pragma Core auto-generates call graphs for any connected repository and overlays findings on them. The value here is that it makes the relationship between the validation call and the move() or store() call visible, since the flaw lived in that relationship and not in either function on its own. Seeing the upload controller call is_image and then, a few nodes later, write getName() into public/uploads is what makes the risk legible at a glance.
SAST tuned for the relevant pattern, not just injection sinks
Off-the-shelf static analysis is tuned for taint flows that terminate in classic sinks and misses content-versus-extension mismatches entirely. Pragma Core's static analysis can be tuned to this exact pattern: a file-upload validation that authorizes on content-derived MIME type without an accompanying extension check, followed by a save that preserves the client filename. Described that way, the pre-4.7.4 code is a high-confidence finding rather than a blind spot.
Continuous tracking of third-party packages
Pragma Core tracks every third-party package across all connected repositories and surfaces vulnerable versions with their CVSS scores and fixed upgrade paths. An application pinned to codeigniter4/framework below 4.7.4 would light up the moment CVE-2026-63223 entered the catalog, with the exact fixed version to move to, so the team learns it is exposed from the dependency graph rather than from an incident.
Full SBOM per repository
Pragma Core generates a complete component inventory per repository, exportable as CycloneDX JSON. When a framework-level upload CVE lands, the first question is "which of our services run an affected CodeIgniter version, and which version exactly," and most teams cannot answer it quickly. A current SBOM turns that from a scramble into a query.
Human-guided AppSec investigations
The expert-led research module lets an AppSec operator drive a deeper look at the parts of a system the team already suspects are fragile, backed by the autonomous agents and the workspace context. Auditing every upload endpoint for the content-check-plus-preserved-name-plus-public-directory triple is exactly the kind of systematic sweep this supports, applied across a whole portfolio rather than one file at a time.
Closing thoughts
CVE-2026-63223 is not, fundamentally, a bug about GIF headers or about one PHP framework's validation library. It is a bug about making a security decision on the wrong property: authorizing a file by what its bytes contain while the harm is decided by what its name is, and letting an attacker who controls both slip between the two. That pattern is not specific to CodeIgniter or even to PHP. It recurs anywhere one component classifies input by content and a later component acts on it by name, path, or type, across upload pipelines, deserialization boundaries, and content-negotiation layers in every language.
The difference between treating this write-up as a curiosity and using it as an audit trigger comes down to AppSec maturity and code visibility: whether you can see, across your repositories, which upload endpoints check content but not extension and then save under a client name. Organizations that want to move from "we scan and report" to "we systematically investigate what is fragile" can reach Pragma Core at pragma-core.com for a demo.
Sources
- CodeIgniter security advisory GHSA-mmj4-63m4-r6h5, Uploaded file extension validation bypass in is_image and mime_in rules, July 7, 2026.
- CodeIgniter4 commit b6e9a4f, the fix to
system/Validation/StrictRules/FileRules.phpwith regression tests, July 7, 2026. - CodeIgniter user guide, Version 4.7.4 changelog, July 7, 2026.
- CodeIgniter security advisory GHSA-2gr4-ppc7-7mhx, Uploaded file extension validation bypass in ext_in (related).
- VulnDex, CVE-2026-63223, July 31, 2026.
- NVD, CVE-2026-63223 Detail.