CVE-2026-63223: Verified Reproduction
CVE-2026-63223: CodeIgniter4 is image/mime in upload validation bypass — unrestricted file upload leading to RCE
CVE-2026-63223 is verified against codeigniter4/framework · github. Affected versions: < 4.7.4. Fixed in 4.7.4. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00320.
What Is CVE-2026-63223?
CVE-2026-63223 is a critical-severity RCE vulnerability affecting codeigniter4/framework < 4.7.4. Pruva has independently reproduced it and publishes a verified, runnable proof-of-concept (reproduction REPRO-2026-00320).
CVE-2026-63223 Severity
CVE-2026-63223 is rated critical severity.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected codeigniter4/framework Versions
codeigniter4/framework · github versions < 4.7.4 are affected.
How to Reproduce CVE-2026-63223
pruva-verify REPRO-2026-00320 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00320/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-63223
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
multipart upload of a GIF89a+PHP polyglot with client filename shell.php to POST /upload validated by the is_image rule (no ext_in)
- POST /upload (CodeIgniter4 Upload controller, StrictRules FileRules is_image)
- saved as public/uploads/shell.php
- GET /uploads/shell.php?cmd=... executes attacker commands
reproduction_steps.sh How the agent worked
Root Cause and Exploit Chain for CVE-2026-63223
CodeIgniter4 versions before 4.7.4 validate file uploads with the is_image() and
mime_in() rules (system/Validation/StrictRules/FileRules.php) using only the
content-sniffed MIME type (finfo magic bytes). They never compare the
client-supplied filename extension with the detected content type. An attacker can
therefore upload a polyglot file whose first bytes are GIF89a (sniffed as
image/gif) but whose body contains PHP code, named shell.php. Validation trusts
the content (image ✔); the web server trusts the extension (.php → execute). This
confused-deputy gap yields unrestricted file upload and remote code execution when
the application saves the upload under the client filename into a web-accessible,
PHP-enabled directory.
- Package/component:
codeigniter4/framework—CodeIgniter\Validation\StrictRules\FileRules::is_image()and::mime_in()(non-strictFileRulesis an empty subclass, so both rule sets are affected). - Affected versions: < 4.7.4 (reproduced on v4.7.3).
- Risk level: critical — unauthenticated remote code execution on any application that (1) validates uploads with
is_imageormime_inwithoutext_in, (2) preserves the client filename on save, and (3) stores uploads in a web-accessible, PHP-enabled directory.
Impact Parity
- Disclosed/claimed maximum impact: code execution (unrestricted file upload → RCE).
- Reproduced impact from this run: code execution — a GIF89a+PHP polyglot uploaded as
shell.phppassedis_imagevalidation on v4.7.3, was saved topublic/uploads/shell.php, and an HTTP GET to/uploads/shell.php?cmd=...executed attacker-controlled commands (echo <unique marker>andid, returninguid=1000(vscode) ...) in 2/2 clean attempts. - Parity:
full. - Not demonstrated: nothing — the claimed impact was reproduced end-to-end through the real HTTP boundary.
Root Cause
FileRules::is_image() (v4.7.3) checks uploaded[], then calls
$file->getMimeType() (finfo content sniffing) and accepts the file if the detected
type starts with image. mime_in() likewise compares only the sniffed type against
an allow-list. Neither rule inspects $file->getClientExtension() /
$file->getClientName(). Because a polyglot can simultaneously be valid GIF89a
content and executable PHP source, the validator and the web server reach different
conclusions about the same file:
- Fix (v4.7.4, release commit
67ead895b7491703e5e5bc17436778806192008f): addshasInvalidImageClientExtension()tois_image()(reject non-empty client extensions that do not map to animage/*MIME type) andhasMismatchedClientExtension()tomime_in()(reject client extensions that do not match the extension guessed from the sniffed content), insystem/Validation/StrictRules/FileRules.php. - Note: the ticket names fixed commit
b6e9a4fa. That hash is not present in thecodeigniter4/frameworkdistributable mirror (which receives squashed release commits from thecodeigniter4/CodeIgniter4development repo). The v4.7.4 release tag was verified to contain exactly the named fix helpers and was used as the fixed checkout; patch-anchor verification (helper absent in v4.7.3, present in v4.7.4) is enforced by the script on every run.
Reproduction Steps
bundle/repro/reproduction_steps.sh(self-contained; run twice consecutively, exit 0 both times).- The script:
- Installs PHP CLI + extensions and Composer if absent.
- Clones
codeigniter4/frameworkinto the prepared project cache (<project_cache_dir>/repo) orbundle/artifacts/frameworkas fallback. - Verifies the patch anchor (fix helper absent at v4.7.3 / present at the fixed ref).
- Writes a real upload controller (
app/Controllers/Upload.php) using the ruleuploaded[userfile]|is_image[userfile](noext_in) that saves with the client filename intopublic/uploads/, plus aPOST /uploadroute. - Builds a GIF89a+PHP polyglot (verified to sniff as
image/gif) and a plain-text negative control. - Starts the real product server (
php spark serve, PHP built-in web server bound to 127.0.0.1) and runs, for both v4.7.3 and the fixed ref:- Negative control: plain-text
plain.phpupload → rejected (rule active). - 2 clean vulnerable attempts: POST polyglot as
shell.php, then GET/uploads/shell.php?cmd=echo <unique marker>;id. - 2 clean fixed attempts: same POST, then GET.
- Negative control: plain-text
- Expected evidence: v4.7.3 returns
{"status":"saved"}and the GET response contains the unique per-attempt marker plusuid=output (and not the raw<?phpsource); the fixed build returns HTTP 400{"status":"rejected"}, writes no file, and the GET returns 404.
Evidence
- Main log:
bundle/logs/reproduction_steps.log. - Per-attempt artifacts (listed in
bundle/repro/runtime_manifest.json):bundle/logs/repro/vuln_attempt{1,2}_upload.json→{"status":"saved","path":"uploads/shell.php"}bundle/logs/repro/vuln_attempt{1,2}_shell_get.txt→ e.g.GIF89a; RCE_1_1785590766_8732 uid=1000(vscode) gid=1000(vscode) groups=1000(vscode)bundle/logs/repro/fixed_attempt{1,2}_upload.json→{"status":"rejected","errors":{"userfile":"userfile is not a valid, uploaded image file."}}(HTTP 400), shell GET HTTP 404.bundle/logs/repro/control_upload.json→ plain-text upload rejected on the vulnerable build (provesis_imageis enforced, not bypassed by harness misconfiguration).bundle/logs/repro/service_{vuln,fixed,control}_*.log→php spark serveproduct server logs.
- Environment: Ubuntu 26.04, PHP 8.5.4 (cli, distro package), Composer 2.10.2, CodeIgniter v4.7.3 (
ab9bf33) vulnerable vs v4.7.4 (67ead89) fixed; server = product CLIphp spark serve(PHP built-in web server), which executes.phpfiles underpublic/exactly as a standard Apache/FPM deployment would for a PHP-enabled upload directory.
Recommendations / Next Steps
- Upgrade to CodeIgniter ≥ 4.7.4, which rejects uploads whose client filename extension is inconsistent with the sniffed content type.
- Defense in depth for applications regardless of framework version: always add
ext_in(andmax_size) to upload rules, never preserve client filenames ($file->getRandomName()), store uploads outside the webroot or behind a controller, and disable PHP execution in upload directories at the web-server layer. - Testing: add regression tests that upload a
GIF89a+PHP polyglot namedshell.phpand assert rejection underis_image/mime_in.
Additional Notes
- Idempotency: the script was run twice consecutively; both runs exited 0 with 2/2 vulnerable RCE attempts and 2/2 fixed rejections. It re-checkouts, re-applies the overlay, and removes
public/uploadsbefore every attempt, so it is safe to re-run. - Edge cases/limitations: RCE requires the three deployment preconditions listed above (validation without
ext_in, client filename preserved, web-accessible PHP-enabled upload dir). The proof usesphp spark serve(the framework's documented development server built on PHP's web server); any deployment that executes.phpunder the upload directory (Apache mod_php, PHP-FPM with typical location rules) exhibits the same behavior. If uploads are stored outside the docroot or PHP is disabled there, the validation bypass still occurs (file is accepted) but code execution is not reachable — that is a deployment mitigation, not a fix. - The named fixed commit
b6e9a4facould not be resolved in thecodeigniter4/frameworkmirror; the v4.7.4 tag contains the exact helpers described in the advisory and was used with patch-anchor verification.
CVE-2026-63223 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
ls -la /pruva/project-cache/ 2>/dev/null; ls /pruva/project-cache/repo-mirrors 2>/dev/null; cat /pruva/project-cache/.pruva/cache_manifest.json 2>/dev/nulltotal 4
drwx------ 4 vscode vscode 80 Aug 1 13:17 .
drwxr-xr-x 3 root root 4096 Aug 1 13:18 ..
drwx------ 2 vscode vscode 60 Aug 1 13:17 .pruva
drwx------ 2 vscode vscode 40 Aug 1 13:17 repo-mirrors
{
"schema_version": 1,
"entries": [php -v 2>&1 | head -3; composer --version 2>&1 | head -1; which curl git jq; php -m 2>/dev/null | grep -Ei 'fileinfo|mbstring|intl|curl|xml|json' | headpruva-command: 1: php: not found pruva-command: 1: composer: not found /usr/bin/curl /usr/local/bin/git /usr/bin/jq
id; which sudo apt-get apt 2>&1; cat /etc/os-release | head -3uid=1000(vscode) gid=1000(vscode) groups=1000(vscode) /usr/bin/sudo /usr/bin/apt-get /usr/bin/apt PRETTY_NAME="Ubuntu 26.04 LTS" NAME="Ubuntu" VERSION_ID="26.04"
sudo apt-get update -qq 2>&1 | tail -2 && sudo apt-get install -y -qq php-cli php-xml php-mbstring php-intl php-curl php-sqlite3 unzip 2>&1 | tail -3; php -v | head -2sysctl: setting key "kernel.yama.ptrace_scope", ignoring: Read-only file system sysctl: setting key "vm.mmap_min_addr", ignoring: Read-only file system Processing triggers for php8.5-cli (8.5.4-0ubuntu1.2) ... PHP 8.5.4 (cli) (built: Jul 16 2026 18:56:38) (NTS) Copyright (c) The PHP Group
cd /tmp && curl -sS https://getcomposer.org/installer -o composer-setup.php && php composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer 2>&1 || sudo php composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer; composer --version 2>&1 | head -1The installation directory "/usr/local/bin" is not writable Composer version 2.10.2 2026-07-01 11:24:45
cd /pruva/project-cache && git clone --quiet https://github.com/codeigniter4/framework.git repo 2>&1 | tail -1; cd repo && git cat-file -t b6e9a4fa 2>&1; git tag -l 'v4.7.*' | tail -5; git log --oneline -1 b6e9a4fa 2>&1{"cwd":"/workspace","duration_ms":1037,"exit_code":128,"stderr":"","stdout":"fatal: Not a valid object name b6e9a4fa\nv4.7.0\nv4.7.1\nv4.7.2\nv4.7.3\nv4.7.4\nfatal: ambiguous argument 'b6e9a4fa': unknown revision or path not in the working tree.\nUse '--' to separate paths from revisions, like this:\n'git <command> [<revision>...] -- [<file>...]'\n","success":false,"timeout_secs":600}Unknown error
Artifacts and Evidence for CVE-2026-63223
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-63223
Upgrade codeigniter4/framework · github to 4.7.4 or later.
FAQ: CVE-2026-63223
Is CVE-2026-63223 exploitable?
How severe is CVE-2026-63223?
What type of vulnerability is CVE-2026-63223?
Which versions of codeigniter4/framework are affected by CVE-2026-63223?
Is there a fix for CVE-2026-63223?
How can I reproduce CVE-2026-63223?
Is the CVE-2026-63223 reproduction verified?
References for CVE-2026-63223
Authoritative sources for CVE-2026-63223 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.