# REPRO-2026-00320: CodeIgniter4 is_image/mime_in upload validation bypass — unrestricted file upload leading to RCE ## Summary Status: published Severity: critical CVSS: Unknown CWE: CWE-434 (Unrestricted Upload of File with Dangerous Type) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00320 CVE: CVE-2026-63223 ## Package Name: codeigniter4/framework Ecosystem: github Affected: < 4.7.4 Fixed: 4.7.4 ## Root Cause # RCA Report — CVE-2026-63223: CodeIgniter4 is_image/mime_in Upload Validation Bypass → RCE ## Summary 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. ## Impact - Package/component: `codeigniter4/framework` — `CodeIgniter\Validation\StrictRules\FileRules::is_image()` and `::mime_in()` (non-strict `FileRules` is 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_image` or `mime_in` without `ext_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.php` passed `is_image` validation on v4.7.3, was saved to `public/uploads/shell.php`, and an HTTP GET to `/uploads/shell.php?cmd=...` executed attacker-controlled commands (`echo ` and `id`, returning `uid=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`): adds `hasInvalidImageClientExtension()` to `is_image()` (reject non-empty client extensions that do not map to an `image/*` MIME type) and `hasMismatchedClientExtension()` to `mime_in()` (reject client extensions that do not match the extension guessed from the sniffed content), in `system/Validation/StrictRules/FileRules.php`. - Note: the ticket names fixed commit `b6e9a4fa`. That hash is not present in the `codeigniter4/framework` distributable mirror (which receives squashed release commits from the `codeigniter4/CodeIgniter4` development 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 1. `bundle/repro/reproduction_steps.sh` (self-contained; run twice consecutively, exit 0 both times). 2. The script: - Installs PHP CLI + extensions and Composer if absent. - Clones `codeigniter4/framework` into the prepared project cache (`/repo`) or `bundle/artifacts/framework` as 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 rule `uploaded[userfile]|is_image[userfile]` (no `ext_in`) that saves with the client filename into `public/uploads/`, plus a `POST /upload` route. - 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.php` upload → rejected (rule active). - 2 clean vulnerable attempts: POST polyglot as `shell.php`, then GET `/uploads/shell.php?cmd=echo ;id`. - 2 clean fixed attempts: same POST, then GET. 3. Expected evidence: v4.7.3 returns `{"status":"saved"}` and the GET response contains the unique per-attempt marker plus `uid=` output (and not the raw `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 named `shell.php` and assert rejection under `is_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/uploads` before 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 uses `php spark serve` (the framework's documented development server built on PHP's web server); any deployment that executes `.php` under 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 `b6e9a4fa` could not be resolved in the `codeigniter4/framework` mirror; the v4.7.4 tag contains the exact helpers described in the advisory and was used with patch-anchor verification. ## Reproduction Details Reproduced: 2026-08-23T15:36:57.228Z Duration: 1442 seconds Tool calls: 145 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00320 pruva-verify CVE-2026-63223 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00320&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00320/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-63223 - Source: https://github.com/codeigniter4/CodeIgniter4/commit/b6e9a4fa ## Artifacts - bundle/repro/rca_report.md (analysis, 7804 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 15189 bytes) - bundle/logs/repro/control_upload.json (other, 91 bytes) - bundle/logs/repro/fixed_attempt1_shell_get.txt (other, 2 bytes) - bundle/logs/repro/fixed_attempt1_upload.json (other, 95 bytes) - bundle/logs/repro/fixed_attempt2_shell_get.txt (other, 2 bytes) - bundle/logs/repro/fixed_attempt2_upload.json (other, 95 bytes) - bundle/logs/repro/service_fixed_1.log (log, 572 bytes) - bundle/logs/repro/service_fixed_2.log (log, 572 bytes) - bundle/logs/repro/service_vuln_1.log (log, 785 bytes) - bundle/logs/repro/service_vuln_2.log (log, 785 bytes) - bundle/logs/repro/vuln_attempt1_shell_get.txt (other, 85 bytes) - bundle/logs/repro/vuln_attempt1_upload.json (other, 45 bytes) - bundle/logs/repro/vuln_attempt2_shell_get.txt (other, 85 bytes) - bundle/logs/repro/vuln_attempt2_upload.json (other, 45 bytes) - bundle/logs/reproduction_steps.log (log, 6330 bytes) - bundle/repro/runtime_manifest.json (other, 1424 bytes) - bundle/repro/validation_verdict.json (other, 860 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00320 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00320/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00320 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev