{"repro_id":"REPRO-2026-00384","version":6,"title":"CodeIgniter4 View Parser conditional tags allow arbitrary PHP code execution in user-editable templates","repro_type":"security","status":"published","severity":"high","description":"CodeIgniter4 < 4.7.5 - code injection (CWE-94) leading to arbitrary PHP code execution through View Parser conditional tags when rendering user-editable template source.","root_cause":"# RCA Report — GHSA-4q58-jw8x-8cm7\n\n## Summary\n\nCodeIgniter4's View Parser evaluates `{if ...}` / `{elseif ...}` conditional\ntags by splicing the *unmodified* tag expression into a PHP `if (...)`:\nstatement and running the resulting template through `eval()`. When an\napplication renders user-editable template source (e.g. an online template\neditor) with `Parser::render()`/`renderString()`, any user who can edit the\ntemplate can inject arbitrary PHP expressions into the conditional tag and\nachieve server-side arbitrary PHP code execution (CWE-94). Version 4.7.5 adds\nan opt-in restriction (`Config\\View::$restrictParserConditionals` or the\nper-render `restrictConditionals` option) that tokenizes the condition and\nrejects anything beyond variables, literals, and comparison/logical operators.\n\n## Impact\n\n- Package/component: `codeigniter4/framework`, `system/View/Parser.php`\n  (`Parser::parseConditionals()`)\n- Affected versions: `< 4.7.5` (reproduced on v4.7.4, commit\n  `2bd0f01d2813f9ec06db42643ce39d9f5428bf6d`); **4.7.5 remains exploitable\n  unless the application explicitly enables `restrictParserConditionals` /\n  the `restrictConditionals` render option** (confirmed at runtime).\n- Risk: High (CVSS 8.8). Remote, low-privilege attacker with template-edit\n  capability gains full PHP code execution in the web server process:\n  arbitrary command execution, file read/write, data theft, pivoting.\n\n## Impact Parity\n\n- Disclosed/claimed maximum impact: arbitrary PHP code execution (code\n  injection, CWE-94) via View Parser conditional tags — `code_execution`.\n- Reproduced impact from this run: arbitrary PHP code execution through the\n  real HTTP endpoint of a running CodeIgniter 4.7.4 application. The payload\n  `{if (file_put_contents(getenv('PRUVA_MARKER_DIR').'/vuln1.txt','...')!==false) && print(shell_exec('id'))}COND_BRANCH_OK{endif}`\n  wrote attacker-named marker files to disk *and* returned the output of\n  `shell_exec('id')` (`uid=1000(vscode) gid=1000(vscode) ...`) in the HTTP\n  response body.\n- Parity: **full**. No gap between claimed and demonstrated impact.\n\n## Root Cause\n\n`Parser::parseConditionals()` (v4.7.4, `system/View/Parser.php:455`)\nextracts each `{if CONDITION}` tag with a regex and replaces it with\n`<?php if (CONDITION): ?>`, embedding the attacker-controlled `CONDITION`\nverbatim into PHP source. The whole template is then executed with\n`eval('?>' . $template . '<?php ')` after `extract($this->tempData)`. The\nonly sanitization applied beforehand is a `str_replace` of literal `<?` /\n`?>`, which does nothing to stop code injected *through* the conditional\nexpression itself. Because the condition is arbitrary PHP, expressions such\nas `(file_put_contents(...)!==false) && print(shell_exec('id'))` execute\nwith the privileges of the PHP process.\n\nFix (v4.7.5): `Parser::parseTemplate()` computes\n`$this->restrictConditionals = ... || (bool) ($options['restrictConditionals'] ?? $this->config->restrictParserConditionals)`\nand `parseConditionals()` throws `ViewException::forRestrictedConditional()`\nwhen `isRestrictedCondition()` (a `PhpToken::tokenize` allow-list of\nvariables, literals, arithmetic/comparison/logical operators, and grouping\nparentheses) rejects the expression. The restriction is **opt-in**\n(`public bool $restrictParserConditionals = false;` default), so upgrading\nalone does not close the hole — verified at runtime in this run.\n\nAdvisory: https://github.com/codeigniter4/CodeIgniter4/security/advisories/GHSA-4q58-jw8x-8cm7\nFix diff: `git diff v4.7.4 v4.7.5 -- system/View/Parser.php app/Config/View.php`\n\n## Reproduction Steps\n\n1. `bundle/repro/reproduction_steps.sh` (self-contained; run twice\n   consecutively, both runs exit 0).\n2. The script:\n   - installs PHP CLI + composer if missing;\n   - checks out CodeIgniter4 `v4.7.4` (vulnerable) into\n     `<project_cache_dir>/repo` and `v4.7.5` (fixed) into\n     `bundle/artifacts/ci475`, verifying the patch hunk is absent/present;\n   - `composer install --no-dev` in both apps;\n   - injects a `TemplateRender` controller exposing `POST /render`, which\n     passes the request's `template` body straight into\n     `service('parser')->setData([...])->renderString($template, $options)`\n     (with `restrictConditionals=true` when `restrict=1`);\n   - serves both apps over HTTP with the PHP built-in web server (the exact\n     command `php spark serve` execs), health-checks `GET /`;\n   - sends the conditional-tag payload twice per scenario and asserts:\n     - v4.7.4: marker file created with the unique token **and** `uid=` from\n       `shell_exec('id')` present in the HTTP response (RCE confirmed);\n     - v4.7.5 + `restrict=1`: no marker, no `uid=`, response is a 500\n       `ViewException: The Parser conditional is not allowed in restricted mode`;\n     - v4.7.5 + `restrict=0` (default): marker created, `uid=` present —\n       advisory note \"upgrading alone is insufficient\" confirmed.\n3. Expected evidence: `[+] vuln attempt N: arbitrary PHP executed`, `[+]\n   fixed-restricted attempt N: payload neutralized`, `[+] fixed-unrestricted\n   attempt N: still exploitable`, final `RESULT: CONFIRMED`, exit code 0.\n\n## Evidence\n\n- Full run log: `bundle/logs/reproduction_steps.log`\n- Server logs: `bundle/logs/vuln_server.log`, `bundle/logs/fixed_server.log`\n  (PHP built-in server request logs for both apps)\n- HTTP request/response captures: `bundle/artifacts/http/` (e.g.\n  `vuln1_request.txt` = payload, `vuln1_response.txt` contains\n  `uid=1000(vscode) gid=1000(vscode) groups=1000(vscode)` + `COND_BRANCH_OK`;\n  `fixedr1_response.txt` contains the `ViewException` restricted-mode message)\n- Marker files written by the eval'd payload: `bundle/repro/markers/vuln1.txt`,\n  `vuln2.txt`, `fixedu1.txt`, `fixedu2.txt` (each contains the per-attempt\n  unique token `GHSA-4q58-jw8x-8cm7 arbitrary PHP executed token=...`)\n- Runtime manifest with target identity and SHA-256 of every proof artifact:\n  `bundle/repro/runtime_manifest.json`\n- Environment: Ubuntu 26.04, PHP 8.5.4 (cli, NTS), Composer 2.9.5,\n  CodeIgniter v4.7.4 (`2bd0f01d...`) and v4.7.5 (`36256090...`), served via\n  `php -S localhost:8090/8091` with `CI_ENVIRONMENT=production`.\n\n## Recommendations / Next Steps\n\n- Upgrade to `codeigniter4/framework >= 4.7.5` **and** set\n  `Config\\View::$restrictParserConditionals = true` (or pass\n  `['restrictConditionals' => true]` to every `render()`/`renderString()` call\n  that processes less-trusted template source). Upgrading without enabling the\n  restriction leaves the application exploitable.\n- Longer-term, the framework should consider making the restricted mode the\n  default for `renderString()` of non-file templates.\n- Test recommendation: regression test asserting that a conditional tag\n  containing a function call raises `ViewException::forRestrictedConditional`\n  when restriction is enabled (this exact behavior was observed at runtime).\n\n## Additional Notes\n\n- Idempotency: the script is fully re-runnable. It reuses the prepared\n  project cache checkout (`/pruva/project-cache/repo`) and composer `vendor/`\n  when present, kills stale `php -S` listeners on ports 8090/8091 before\n  starting fresh servers, cleans the marker directory, and regenerates all\n  HTTP captures, markers, and `runtime_manifest.json` with fresh per-attempt\n  tokens on every run. Two consecutive clean runs passed (exit 0).\n- The reproduction uses the framework repository's own application skeleton\n  (`app/`, `public/`, `system/rewrite.php`) served in `production` mode — no\n  mocks, no sanitizer, no reimplementation of the vulnerable code.\n- Edge case noted: `spark serve` silently increments the port when the\n  requested one is busy; the script therefore runs the underlying `php -S`\n  command directly and pre-kills stale listeners so the healthcheck and\n  exploit always target the intended instance.\n","ghsa_id":"GHSA-4Q58-JW8X-8CM7","cwe_id":"CWE-94","source_url":"https://github.com/codeigniter4/CodeIgniter4/security/advisories/GHSA-4q58-jw8x-8cm7","package":{"name":"codeigniter4/framework (Composer ecosystem)","ecosystem":"composer","affected_versions":"< 4.7.5","fixed_version":"4.7.5"},"reproduced_at":"2026-10-09T18:02:47.398383+00:00","duration_secs":4661.0,"tool_calls":158,"handoffs":2,"total_cost_usd":5.615669,"agent_costs":{"claim_matcher":0.012938,"judge":0.594135,"learning_policy":0.01081,"repro":2.211779,"support":0.105547,"vuln_variant":2.68046},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.012938},"judge":{"gpt-5.6-sol":0.594135},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.01081},"repro":{"accounts/fireworks/models/kimi-k3":2.211779},"support":{"accounts/fireworks/models/kimi-k3":0.105547},"vuln_variant":{"accounts/fireworks/models/kimi-k3":2.68046}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","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-10-09T18:02:48.258161+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7849,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12195,"category":"reproduction_script"},{"path":"bundle/artifacts/http/fixedr1_response.txt","filename":"fixedr1_response.txt","size":302,"category":"other"},{"path":"bundle/artifacts/http/fixedr2_response.txt","filename":"fixedr2_response.txt","size":302,"category":"other"},{"path":"bundle/artifacts/http/fixedu1_response.txt","filename":"fixedu1_response.txt","size":68,"category":"other"},{"path":"bundle/artifacts/http/fixedu2_response.txt","filename":"fixedu2_response.txt","size":68,"category":"other"},{"path":"bundle/artifacts/http/vuln1_request.txt","filename":"vuln1_request.txt","size":198,"category":"other"},{"path":"bundle/artifacts/http/vuln1_response.txt","filename":"vuln1_response.txt","size":68,"category":"other"},{"path":"bundle/artifacts/http/vuln2_response.txt","filename":"vuln2_response.txt","size":68,"category":"other"},{"path":"bundle/logs/fixed_server.log","filename":"fixed_server.log","size":563,"category":"log"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":1851,"category":"log"},{"path":"bundle/logs/vuln_server.log","filename":"vuln_server.log","size":373,"category":"log"},{"path":"bundle/repro/markers/fixedu1.txt","filename":"fixedu1.txt","size":76,"category":"other"},{"path":"bundle/repro/markers/fixedu2.txt","filename":"fixedu2.txt","size":76,"category":"other"},{"path":"bundle/repro/markers/vuln1.txt","filename":"vuln1.txt","size":74,"category":"other"},{"path":"bundle/repro/markers/vuln2.txt","filename":"vuln2.txt","size":74,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":2860,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1301,"category":"other"}]}