# 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 <unique marker>` 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 (`<project_cache_dir>/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 <unique marker>;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 `<?php` source); 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 (proves `is_image` is enforced, not bypassed by harness misconfiguration).
  - `bundle/logs/repro/service_{vuln,fixed,control}_*.log` → `php spark serve` product 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 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.

## 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` (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.
- 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.
