{"repro_id":"REPRO-2026-00320","version":6,"title":"CodeIgniter4 is_image/mime_in upload validation bypass — unrestricted file upload leading to RCE","repro_type":"security","status":"published","severity":"critical","description":"CodeIgniter4 < 4.7.4: is_image() and mime_in() validation rules in system/Validation/StrictRules/FileRules.php validate uploads only by content-sniffed MIME type (finfo magic bytes) and never check the client-supplied filename extension. A polyglot file starting with GIF89a magic bytes but containing PHP code passes is_image (sniffs as image/gif) while keeping an attacker-controlled shell.php name. Validation trusts content; the web server trusts extension — confused deputy → RCE. Fixed in 4.7.4 (commit b6e9a4fa) via hasInvalidImageClientExtension() (is_image) and hasMismatchedClientExtension() (mime_in) helpers added to StrictRules/FileRules.php; non-strict FileRules is an empty subclass so one patch covers both. Reproduction plan: stand up CodeIgniter 4.7.3 app with an upload controller using the is_image rule (no ext_in), saving with the client filename into public/uploads/; upload GIF+PHP polyglot named shell.php; GET /uploads/shell.php?cmd=id to confirm RCE; then verify 4.7.4 rejects the same file. RCE requires: (1) is_image or mime_in without ext_in, (2) filename preserved on save, (3) web-accessible PHP-enabled upload dir.","root_cause":"# RCA Report — CVE-2026-63223: CodeIgniter4 is_image/mime_in Upload Validation Bypass → RCE\n\n## Summary\n\nCodeIgniter4 versions before 4.7.4 validate file uploads with the `is_image()` and\n`mime_in()` rules (`system/Validation/StrictRules/FileRules.php`) using only the\n**content-sniffed** MIME type (finfo magic bytes). They never compare the\nclient-supplied filename extension with the detected content type. An attacker can\ntherefore upload a polyglot file whose first bytes are `GIF89a` (sniffed as\n`image/gif`) but whose body contains PHP code, named `shell.php`. Validation trusts\nthe content (image ✔); the web server trusts the extension (`.php` → execute). This\nconfused-deputy gap yields unrestricted file upload and remote code execution when\nthe application saves the upload under the client filename into a web-accessible,\nPHP-enabled directory.\n\n## Impact\n\n- 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).\n- Affected versions: < 4.7.4 (reproduced on v4.7.3).\n- 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.\n\n## Impact Parity\n\n- Disclosed/claimed maximum impact: code execution (unrestricted file upload → RCE).\n- 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 <unique marker>` and `id`, returning `uid=1000(vscode) ...`) in 2/2 clean attempts.\n- Parity: `full`.\n- Not demonstrated: nothing — the claimed impact was reproduced end-to-end through the real HTTP boundary.\n\n## Root Cause\n\n`FileRules::is_image()` (v4.7.3) checks `uploaded[]`, then calls\n`$file->getMimeType()` (finfo content sniffing) and accepts the file if the detected\ntype starts with `image`. `mime_in()` likewise compares only the sniffed type against\nan allow-list. Neither rule inspects `$file->getClientExtension()` /\n`$file->getClientName()`. Because a polyglot can simultaneously be valid GIF89a\ncontent and executable PHP source, the validator and the web server reach different\nconclusions about the same file:\n\n- Fix (v4.7.4, release commit `67ead895b7491703e5e5bc17436778806192008f`): adds\n  `hasInvalidImageClientExtension()` to `is_image()` (reject non-empty client\n  extensions that do not map to an `image/*` MIME type) and\n  `hasMismatchedClientExtension()` to `mime_in()` (reject client extensions that do\n  not match the extension guessed from the sniffed content), in\n  `system/Validation/StrictRules/FileRules.php`.\n- Note: the ticket names fixed commit `b6e9a4fa`. That hash is not present in the\n  `codeigniter4/framework` distributable mirror (which receives squashed release\n  commits from the `codeigniter4/CodeIgniter4` development repo). The v4.7.4 release\n  tag was verified to contain exactly the named fix helpers and was used as the fixed\n  checkout; patch-anchor verification (helper absent in v4.7.3, present in v4.7.4)\n  is enforced by the script on every run.\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; run twice consecutively, exit 0 both times).\n2. The script:\n   - Installs PHP CLI + extensions and Composer if absent.\n   - Clones `codeigniter4/framework` into the prepared project cache (`<project_cache_dir>/repo`) or `bundle/artifacts/framework` as fallback.\n   - Verifies the patch anchor (fix helper absent at v4.7.3 / present at the fixed ref).\n   - 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.\n   - Builds a GIF89a+PHP polyglot (verified to sniff as `image/gif`) and a plain-text negative control.\n   - 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:\n     - Negative control: plain-text `plain.php` upload → rejected (rule active).\n     - 2 clean vulnerable attempts: POST polyglot as `shell.php`, then GET `/uploads/shell.php?cmd=echo <unique marker>;id`.\n     - 2 clean fixed attempts: same POST, then GET.\n3. 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 `<?php` source); the fixed build returns HTTP 400 `{\"status\":\"rejected\"}`, writes no file, and the GET returns 404.\n\n## Evidence\n\n- Main log: `bundle/logs/reproduction_steps.log`.\n- Per-attempt artifacts (listed in `bundle/repro/runtime_manifest.json`):\n  - `bundle/logs/repro/vuln_attempt{1,2}_upload.json` → `{\"status\":\"saved\",\"path\":\"uploads/shell.php\"}`\n  - `bundle/logs/repro/vuln_attempt{1,2}_shell_get.txt` → e.g.\n    ```\n    GIF89a;\n    RCE_1_1785590766_8732\n    uid=1000(vscode) gid=1000(vscode) groups=1000(vscode)\n    ```\n  - `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.\n  - `bundle/logs/repro/control_upload.json` → plain-text upload rejected on the vulnerable build (proves `is_image` is enforced, not bypassed by harness misconfiguration).\n  - `bundle/logs/repro/service_{vuln,fixed,control}_*.log` → `php spark serve` product server logs.\n- 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 CLI `php spark serve` (PHP built-in web server), which executes `.php` files under `public/` exactly as a standard Apache/FPM deployment would for a PHP-enabled upload directory.\n\n## Recommendations / Next Steps\n\n- Upgrade to CodeIgniter ≥ 4.7.4, which rejects uploads whose client filename extension is inconsistent with the sniffed content type.\n- Defense in depth for applications regardless of framework version: always add `ext_in` (and `max_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.\n- Testing: add regression tests that upload a `GIF89a`+PHP polyglot named `shell.php` and assert rejection under `is_image`/`mime_in`.\n\n## Additional Notes\n\n- 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.\n- 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.\n- 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.\n","cve_id":"CVE-2026-63223","cwe_id":"CWE-434","source_url":"https://github.com/codeigniter4/CodeIgniter4/commit/b6e9a4fa","package":{"name":"codeigniter4/framework","ecosystem":"github","affected_versions":"< 4.7.4","fixed_version":"4.7.4"},"reproduced_at":"2026-08-23T15:36:57.228297+00:00","duration_secs":1442.0,"tool_calls":145,"handoffs":2,"total_cost_usd":3.024458,"agent_costs":{"claim_matcher":0.013305,"judge":0.68478,"learning_policy":0.009056,"repro":0.934213,"support":0.072814,"vuln_variant":1.31029},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.013305},"judge":{"gpt-5.6-sol":0.68478},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.009056},"repro":{"accounts/fireworks/models/kimi-k3":0.934213},"support":{"accounts/fireworks/models/kimi-k3":0.072814},"vuln_variant":{"accounts/fireworks/models/kimi-k3":1.31029}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-08-23T15:36:58.129198+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7804,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":15189,"category":"reproduction_script"},{"path":"bundle/logs/repro/control_upload.json","filename":"control_upload.json","size":91,"category":"other"},{"path":"bundle/logs/repro/fixed_attempt1_shell_get.txt","filename":"fixed_attempt1_shell_get.txt","size":2,"category":"other"},{"path":"bundle/logs/repro/fixed_attempt1_upload.json","filename":"fixed_attempt1_upload.json","size":95,"category":"other"},{"path":"bundle/logs/repro/fixed_attempt2_shell_get.txt","filename":"fixed_attempt2_shell_get.txt","size":2,"category":"other"},{"path":"bundle/logs/repro/fixed_attempt2_upload.json","filename":"fixed_attempt2_upload.json","size":95,"category":"other"},{"path":"bundle/logs/repro/service_fixed_1.log","filename":"service_fixed_1.log","size":572,"category":"log"},{"path":"bundle/logs/repro/service_fixed_2.log","filename":"service_fixed_2.log","size":572,"category":"log"},{"path":"bundle/logs/repro/service_vuln_1.log","filename":"service_vuln_1.log","size":785,"category":"log"},{"path":"bundle/logs/repro/service_vuln_2.log","filename":"service_vuln_2.log","size":785,"category":"log"},{"path":"bundle/logs/repro/vuln_attempt1_shell_get.txt","filename":"vuln_attempt1_shell_get.txt","size":85,"category":"other"},{"path":"bundle/logs/repro/vuln_attempt1_upload.json","filename":"vuln_attempt1_upload.json","size":45,"category":"other"},{"path":"bundle/logs/repro/vuln_attempt2_shell_get.txt","filename":"vuln_attempt2_shell_get.txt","size":85,"category":"other"},{"path":"bundle/logs/repro/vuln_attempt2_upload.json","filename":"vuln_attempt2_upload.json","size":45,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":6330,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1424,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":860,"category":"other"}]}