{"repro_id":"REPRO-2026-00233","version":7,"title":"Grav CMS unsafe deserialization and command injection RCE","repro_type":"security","status":"published","severity":"critical","description":"Grav CMS before 2.0.0-beta.2 contains multiple code-execution vulnerabilities. Unsafe unserialize() calls in Scheduler\\JobQueue, Framework\\Cache\\Adapter\\FileCache, and Session deserialize untrusted data without restricting allowed classes, enabling PHP object injection. Additionally, a command injection path exists. Reproduce: deploy Grav CMS from a vulnerable version, craft a malicious serialized payload or cache/session file, and trigger processing through the affected component to obtain arbitrary command execution.","root_cause":"## Summary\nGrav CMS before 2.0.0-beta.2 contains an unsafe deserialization vulnerability in the modern scheduler job queue. A queue item may contain a `serialized_job` field, and vulnerable versions reconstruct that field with `unserialize(base64_decode(...))` without integrity protection. A forged queue item containing a serialized `Grav\\Common\\Scheduler\\Job` can therefore be processed by the real scheduler and execute an attacker-selected command when the scheduler is triggered through the real HTTP scheduler-webhook endpoint.\n\n## Impact\n- Package/component affected: `getgrav/grav`, specifically `Grav\\Common\\Scheduler\\JobQueue` and the scheduler execution path.\n- Affected versions: Grav CMS versions before 2.0.0-beta.2. The reproduction uses 2.0.0-beta.1 as the vulnerable version and 2.0.0-beta.2 as the fixed negative control.\n- Risk level and consequences: critical. If an attacker can place or tamper with a scheduler queue item and trigger scheduler processing, the queue deserialization path can instantiate a forged scheduler `Job` and execute arbitrary commands in the PHP/Grav process context.\n\n## Impact Parity\n- Disclosed/claimed maximum impact: remote/API-triggered arbitrary code execution through unsafe deserialization in Grav components, including `Scheduler\\JobQueue`.\n- Reproduced impact from this run: code execution. The proof crafts real serialized `Grav\\Common\\Scheduler\\Job` payloads whose command writes marker files, starts a real Grav web server with the real scheduler-webhook plugin, and sends `POST /scheduler/webhook` to trigger `Scheduler::processWebhookTrigger()` -> `Scheduler::run()` -> `JobQueue::popWithId()` / `reconstructJob()`.\n- Parity: `full`.\n- Not demonstrated: the reproduction focuses on the `Scheduler\\JobQueue` vector. It does not separately exploit the `FileCache` or `Session::getFlashObject` unsafe unserialize sinks.\n\n## Root Cause\nThe vulnerable `JobQueue::reconstructJob()` path trusted the on-disk queue item field `serialized_job` and directly performed:\n\n```php\n$job = unserialize(base64_decode((string) $item['serialized_job']));\n```\n\nNo HMAC, signature, or class restriction prevented an attacker-planted/tampered queue item from supplying a serialized `Grav\\Common\\Scheduler\\Job`. The `Job` object has an execution path that invokes its configured command/arguments, so a forged serialized job with command `system` and attacker-controlled arguments becomes a direct PHP object injection to command execution primitive.\n\nThe fixed version signs the serialized job blob with an HMAC derived from Grav's nonce key and only unserializes when the sibling `serialized_job_hmac` verifies. Missing or mismatched HMAC values fall through to the structured queue fields instead of trusting the serialized object. The advisory identifies the fix as Grav core commit `c66dfeb5f` for JobQueue/FileCache/Session/InstallCommand hardening, released in 2.0.0-beta.2.\n\n## Reproduction Steps\n1. Run `bundle/repro/reproduction_steps.sh`.\n2. The script reuses/clones the real Grav repository, checks out 2.0.0-beta.1 and 2.0.0-beta.2, installs Composer dependencies, installs/enables the real `scheduler-webhook` plugin, starts the real PHP/Grav web server, poisons the real scheduler queue with a forged `serialized_job`, and sends `POST /scheduler/webhook` to trigger scheduler processing over HTTP.\n3. Expected evidence: vulnerable 2.0.0-beta.1 creates `bundle/repro/proof_vulnerable_1.txt` and `bundle/repro/proof_vulnerable_2.txt`; fixed 2.0.0-beta.2 does not create fixed proof files and logs execution of the benign `/bin/true` fallback job instead.\n\n## Evidence\n- Runtime manifest: `bundle/repro/runtime_manifest.json` records `entrypoint_kind=\"api_remote\"`, `service_started=true`, `healthcheck_passed=true`, and `target_path_reached=true`.\n- Structured verdict: `bundle/repro/validation_verdict.json` records `claim_outcome=\"confirmed\"`, `validated_surface=\"api_remote\"`, and `observed_impact_class=\"code_execution\"`.\n- Vulnerable proof files:\n  - `bundle/repro/proof_vulnerable_1.txt` contains `GRAV_JOBQUEUE_WEBHOOK_RCE_vulnerable_1`.\n  - `bundle/repro/proof_vulnerable_2.txt` contains `GRAV_JOBQUEUE_WEBHOOK_RCE_vulnerable_2`.\n- Vulnerable HTTP evidence:\n  - `bundle/logs/vulnerable_webhook_response_1_body.json` shows the webhook returned success and `jobs_run: 1`.\n  - `bundle/logs/vulnerable_scheduler_1.log` and `bundle/logs/vulnerable_scheduler_2.log` show the forged job completed successfully with command `system`.\n- Fixed negative control:\n  - `bundle/logs/fixed_scheduler_1.log` and `bundle/logs/fixed_scheduler_2.log` show the fixed version completed the benign fallback job (`/bin/true`) rather than the attacker-controlled serialized `Job`.\n  - No `proof_fixed_*.txt` files are produced.\n- Patch-gap evidence:\n  - `bundle/logs/vulnerable_jobqueue_gap.log` contains the vulnerable unsigned `unserialize(base64_decode(...))` sink.\n  - `bundle/logs/fixed_jobqueue_gap.log` contains the HMAC validation logic added in the fixed version.\n- Environment details:\n  - Grav vulnerable tag: 2.0.0-beta.1 (`26a2d519c59c620e2b0a54d0baf33889d7d5db0a`).\n  - Grav fixed tag: 2.0.0-beta.2 (`f95b0ff51a655edcbcc060a3d74b43e3f20b9585`).\n  - Scheduler-webhook plugin commit used for the HTTP endpoint: recorded in `bundle/logs/webhook_plugin_commit.log`.\n\n## Recommendations / Next Steps\n- Upgrade Grav CMS to 2.0.0-beta.2 or later.\n- Preserve HMAC/integrity validation for every serialized queue/cache/session payload and reject or safely rebuild unsigned legacy data rather than unserializing it.\n- Prefer avoiding PHP object serialization for attacker-influenced persistent data. If serialization is unavoidable, use strict integrity checks, narrow `allowed_classes`, and schema-based reconstruction.\n- Add regression tests that insert an unsigned/tampered queue item with a serialized `Job` and assert that the scheduler does not execute it.\n- Review other flat-file write paths to ensure untrusted users cannot create scheduler queue, cache, or session payloads.\n\n## Additional Notes\n- Idempotency confirmation: `bundle/repro/reproduction_steps.sh` was executed twice consecutively and produced the same vulnerable/fixed divergence both times.\n- The proof uses the real Grav product, real Composer dependencies, real scheduler-webhook plugin, real local HTTP requests, and real `JobQueue::reconstructJob()` deserialization. It does not mock Grav classes or reimplement the sink.\n- The queue-file placement models the advisory precondition for the JobQueue vector: attacker-planted or tampered queue data. The remote/API boundary is the real webhook trigger that causes the product to process that queue item.\n","cve_id":"CVE-2026-56700","reproduced_at":"2026-07-06T08:17:46.038025+00:00","duration_secs":3200.0,"tool_calls":359,"handoffs":3,"total_cost_usd":17.173287209999994,"agent_costs":{"hypothesis_generator":0.02411224,"judge":0.9717600000000002,"repro":11.074719999999996,"support":0.02722497,"vuln_variant":5.07547},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/kimi-k2p7-code":0.02411224},"judge":{"gpt-5.5":0.9717600000000002},"repro":{"accounts/fireworks/models/kimi-k2p7-code":1.711789,"gpt-5.5":9.362931},"support":{"accounts/fireworks/models/kimi-k2p7-code":0.02722497},"vuln_variant":{"gpt-5.5":5.07547}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-06T08:18:47.294018+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":21004,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":6672,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":9341,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":9570,"category":"analysis"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":719,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":885,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":4261,"category":"other"},{"path":"bundle/repro/proof_vulnerable_1.txt","filename":"proof_vulnerable_1.txt","size":38,"category":"other"},{"path":"bundle/repro/proof_vulnerable_2.txt","filename":"proof_vulnerable_2.txt","size":38,"category":"other"},{"path":"bundle/logs/fixed_composer_1.log","filename":"fixed_composer_1.log","size":7910,"category":"log"},{"path":"bundle/logs/fixed_composer_2.log","filename":"fixed_composer_2.log","size":7910,"category":"log"},{"path":"bundle/logs/fixed_filecache_gap.log","filename":"fixed_filecache_gap.log","size":366,"category":"log"},{"path":"bundle/logs/fixed_health_1_body.txt","filename":"fixed_health_1_body.txt","size":46,"category":"other"},{"path":"bundle/logs/fixed_health_1_headers.txt","filename":"fixed_health_1_headers.txt","size":523,"category":"other"},{"path":"bundle/logs/fixed_health_2_body.txt","filename":"fixed_health_2_body.txt","size":46,"category":"other"},{"path":"bundle/logs/fixed_health_2_headers.txt","filename":"fixed_health_2_headers.txt","size":523,"category":"other"},{"path":"bundle/logs/fixed_jobqueue_gap.log","filename":"fixed_jobqueue_gap.log","size":367,"category":"log"},{"path":"bundle/logs/fixed_queue_files_after_1.txt","filename":"fixed_queue_files_after_1.txt","size":124,"category":"other"},{"path":"bundle/logs/fixed_queue_files_after_2.txt","filename":"fixed_queue_files_after_2.txt","size":124,"category":"other"},{"path":"bundle/logs/fixed_scheduler_1.log","filename":"fixed_scheduler_1.log","size":373,"category":"log"},{"path":"bundle/logs/fixed_scheduler_2.log","filename":"fixed_scheduler_2.log","size":373,"category":"log"},{"path":"bundle/logs/fixed_scheduler_health_1_body.json","filename":"fixed_scheduler_health_1_body.json","size":315,"category":"other"},{"path":"bundle/logs/fixed_scheduler_health_1_headers.txt","filename":"fixed_scheduler_health_1_headers.txt","size":432,"category":"other"},{"path":"bundle/logs/fixed_scheduler_health_2_body.json","filename":"fixed_scheduler_health_2_body.json","size":315,"category":"other"},{"path":"bundle/logs/fixed_scheduler_health_2_headers.txt","filename":"fixed_scheduler_health_2_headers.txt","size":432,"category":"other"},{"path":"bundle/logs/fixed_server_1.log","filename":"fixed_server_1.log","size":587,"category":"log"},{"path":"bundle/logs/fixed_server_2.log","filename":"fixed_server_2.log","size":587,"category":"log"},{"path":"bundle/logs/fixed_session_gap.log","filename":"fixed_session_gap.log","size":460,"category":"log"},{"path":"bundle/logs/fixed_webhook_curl_1.log","filename":"fixed_webhook_curl_1.log","size":0,"category":"log"},{"path":"bundle/logs/fixed_webhook_curl_2.log","filename":"fixed_webhook_curl_2.log","size":0,"category":"log"},{"path":"bundle/logs/fixed_webhook_request_1.txt","filename":"fixed_webhook_request_1.txt","size":292,"category":"other"},{"path":"bundle/logs/fixed_webhook_request_2.txt","filename":"fixed_webhook_request_2.txt","size":292,"category":"other"},{"path":"bundle/logs/fixed_webhook_response_1_body.json","filename":"fixed_webhook_response_1_body.json","size":141,"category":"other"},{"path":"bundle/logs/fixed_webhook_response_1_headers.txt","filename":"fixed_webhook_response_1_headers.txt","size":432,"category":"other"},{"path":"bundle/logs/fixed_webhook_response_2_body.json","filename":"fixed_webhook_response_2_body.json","size":141,"category":"other"},{"path":"bundle/logs/fixed_webhook_response_2_headers.txt","filename":"fixed_webhook_response_2_headers.txt","size":432,"category":"other"},{"path":"bundle/logs/payload_fixed_1.generate.log","filename":"payload_fixed_1.generate.log","size":23,"category":"log"},{"path":"bundle/logs/payload_fixed_2.generate.log","filename":"payload_fixed_2.generate.log","size":23,"category":"log"},{"path":"bundle/logs/payload_vulnerable_1.generate.log","filename":"payload_vulnerable_1.generate.log","size":23,"category":"log"},{"path":"bundle/logs/payload_vulnerable_2.generate.log","filename":"payload_vulnerable_2.generate.log","size":23,"category":"log"},{"path":"bundle/logs/vulnerable_composer_1.log","filename":"vulnerable_composer_1.log","size":7909,"category":"log"},{"path":"bundle/logs/vulnerable_composer_2.log","filename":"vulnerable_composer_2.log","size":7909,"category":"log"},{"path":"bundle/logs/vulnerable_filecache_gap.log","filename":"vulnerable_filecache_gap.log","size":76,"category":"log"},{"path":"bundle/logs/vulnerable_health_1_body.txt","filename":"vulnerable_health_1_body.txt","size":46,"category":"other"},{"path":"bundle/logs/vulnerable_health_1_headers.txt","filename":"vulnerable_health_1_headers.txt","size":523,"category":"other"},{"path":"bundle/logs/vulnerable_health_2_body.txt","filename":"vulnerable_health_2_body.txt","size":46,"category":"other"},{"path":"bundle/logs/vulnerable_health_2_headers.txt","filename":"vulnerable_health_2_headers.txt","size":523,"category":"other"},{"path":"bundle/logs/vulnerable_jobqueue_gap.log","filename":"vulnerable_jobqueue_gap.log","size":89,"category":"log"},{"path":"bundle/logs/vulnerable_queue_files_after_1.txt","filename":"vulnerable_queue_files_after_1.txt","size":129,"category":"other"},{"path":"bundle/logs/vulnerable_queue_files_after_2.txt","filename":"vulnerable_queue_files_after_2.txt","size":129,"category":"other"},{"path":"bundle/logs/vulnerable_scheduler_1.log","filename":"vulnerable_scheduler_1.log","size":347,"category":"log"},{"path":"bundle/logs/vulnerable_scheduler_2.log","filename":"vulnerable_scheduler_2.log","size":347,"category":"log"},{"path":"bundle/logs/vulnerable_scheduler_health_1_body.json","filename":"vulnerable_scheduler_health_1_body.json","size":315,"category":"other"},{"path":"bundle/logs/vulnerable_scheduler_health_1_headers.txt","filename":"vulnerable_scheduler_health_1_headers.txt","size":432,"category":"other"},{"path":"bundle/logs/vulnerable_scheduler_health_2_body.json","filename":"vulnerable_scheduler_health_2_body.json","size":315,"category":"other"},{"path":"bundle/logs/vulnerable_scheduler_health_2_headers.txt","filename":"vulnerable_scheduler_health_2_headers.txt","size":432,"category":"other"},{"path":"bundle/logs/vulnerable_server_1.log","filename":"vulnerable_server_1.log","size":587,"category":"log"},{"path":"bundle/logs/vulnerable_server_2.log","filename":"vulnerable_server_2.log","size":587,"category":"log"},{"path":"bundle/logs/vulnerable_session_gap.log","filename":"vulnerable_session_gap.log","size":116,"category":"log"},{"path":"bundle/logs/vulnerable_webhook_curl_1.log","filename":"vulnerable_webhook_curl_1.log","size":0,"category":"log"},{"path":"bundle/logs/vulnerable_webhook_curl_2.log","filename":"vulnerable_webhook_curl_2.log","size":0,"category":"log"},{"path":"bundle/logs/vulnerable_webhook_request_1.txt","filename":"vulnerable_webhook_request_1.txt","size":302,"category":"other"},{"path":"bundle/logs/vulnerable_webhook_request_2.txt","filename":"vulnerable_webhook_request_2.txt","size":302,"category":"other"},{"path":"bundle/logs/vulnerable_webhook_response_1_body.json","filename":"vulnerable_webhook_response_1_body.json","size":141,"category":"other"},{"path":"bundle/logs/vulnerable_webhook_response_1_headers.txt","filename":"vulnerable_webhook_response_1_headers.txt","size":432,"category":"other"},{"path":"bundle/logs/vulnerable_webhook_response_2_body.json","filename":"vulnerable_webhook_response_2_body.json","size":141,"category":"other"},{"path":"bundle/logs/vulnerable_webhook_response_2_headers.txt","filename":"vulnerable_webhook_response_2_headers.txt","size":432,"category":"other"},{"path":"bundle/logs/webhook_plugin_commit.log","filename":"webhook_plugin_commit.log","size":41,"category":"log"},{"path":"bundle/logs/webhook_plugin_fetch.log","filename":"webhook_plugin_fetch.log","size":108,"category":"log"},{"path":"bundle/repro/payload_fixed_1.b64","filename":"payload_fixed_1.b64","size":2376,"category":"other"},{"path":"bundle/repro/payload_fixed_1.generate.log","filename":"payload_fixed_1.generate.log","size":23,"category":"log"},{"path":"bundle/repro/payload_fixed_2.b64","filename":"payload_fixed_2.b64","size":2376,"category":"other"},{"path":"bundle/repro/payload_fixed_2.generate.log","filename":"payload_fixed_2.generate.log","size":23,"category":"log"},{"path":"bundle/repro/payload_vulnerable_1.b64","filename":"payload_vulnerable_1.b64","size":2392,"category":"other"},{"path":"bundle/repro/payload_vulnerable_1.generate.log","filename":"payload_vulnerable_1.generate.log","size":23,"category":"log"},{"path":"bundle/repro/payload_vulnerable_2.b64","filename":"payload_vulnerable_2.b64","size":2392,"category":"other"},{"path":"bundle/repro/payload_vulnerable_2.generate.log","filename":"payload_vulnerable_2.generate.log","size":23,"category":"log"},{"path":"bundle/repro/queue_fixed_1.json","filename":"queue_fixed_1.json","size":2688,"category":"other"},{"path":"bundle/repro/queue_fixed_2.json","filename":"queue_fixed_2.json","size":2688,"category":"other"},{"path":"bundle/repro/queue_vulnerable_1.json","filename":"queue_vulnerable_1.json","size":2714,"category":"other"},{"path":"bundle/repro/queue_vulnerable_2.json","filename":"queue_vulnerable_2.json","size":2714,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6339,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3476,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":3056,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1398,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_result.json","filename":"reproduction_result.json","size":543,"category":"other"},{"path":"bundle/vuln_variant/proof_vulnerable.txt","filename":"proof_vulnerable.txt","size":28,"category":"other"},{"path":"bundle/vuln_variant/proof_fixed.txt","filename":"proof_fixed.txt","size":23,"category":"other"}]}