{"repro_id":"REPRO-2026-00133","version":13,"title":"Drupal core: unauthenticated SQL injection via JSON:API filter array keys","repro_type":"security","status":"published","severity":"critical","description":"A vulnerability in Drupal core's Entity Query SQL backend allows an\n**unauthenticated (anonymous) attacker** to inject arbitrary SQL into queries on\nsites backed by a **PostgreSQL** database. Per the advisory this can lead to\ninformation disclosure and, in some configurations, privilege escalation or\nremote code execution. The issue does **not** trigger on MySQL/MariaDB or\nSQLite.","root_cause":"# RCA Report: CVE-2026-9082 — Drupal Core SQL Injection (SA-CORE-2026-004)\n\n## Summary\n\nCVE-2026-9082 is a highly critical SQL injection vulnerability in Drupal core's Entity Query SQL backend that affects only PostgreSQL-backed sites. The vulnerability exists because attacker-controlled array keys from entity query condition values are directly concatenated into named SQL placeholder identifiers in the PostgreSQL-specific `Condition::translateCondition()` method. An unauthenticated attacker can inject arbitrary characters (such as backticks, quotes, or SQL fragments) into the raw SQL text by providing malicious keys through JSON:API collection filter query parameters or the JSON login endpoint, resulting in SQL syntax errors or — with more sophisticated payloads — information disclosure and potential privilege escalation.\n\n## Impact\n\n- **Package/component affected**: Drupal core, specifically the PostgreSQL entity query condition handler (`core/modules/pgsql/src/EntityQuery/Condition.php`) and upstream condition compilation in `core/lib/Drupal/Core/Entity/Query/Sql/Condition.php` and `ConditionAggregate.php`\n- **Affected versions**: Drupal 8.9.0 through <10.4.10, 10.5.0 through <10.5.10, 10.6.0 through <10.6.9, 11.0.0 through <11.1.10, 11.2.0 through <11.2.12, 11.3.0 through <11.3.10\n- **Risk level**: Highly critical (20/25 on Drupal's scale; CVSS 3.1 base 6.5)\n- **Consequences**: Unauthenticated SQL injection on PostgreSQL sites. Can lead to information disclosure, privilege escalation, and in some configurations remote code execution. MySQL/MariaDB and SQLite backends are **not** affected.\n\n## Root Cause\n\nThe root cause is a trust boundary violation in `core/modules/pgsql/src/EntityQuery/Condition.php`. When processing an `IN` condition with case-insensitive comparison on PostgreSQL, the code iterates over `$condition['value']` and appends each array key directly into the SQL placeholder name:\n\n```php\nforeach ($condition['value'] as $key => $value) {\n  $where_id = $where_prefix . $key;\n  $condition['where'] .= 'LOWER(:' . $where_id . '),';\n  ...\n}\n```\n\nWhen JSON:API filter parameters or JSON login bodies decode JSON objects into associative PHP arrays, the attacker controls both the keys and values. A malicious key like `` ` `` (backtick) becomes part of the placeholder name: `LOWER(:node_field_data_title`)`. PDO then cannot bind `:node_field_data_title`` because it was never added to the bound arguments list, producing `SQLSTATE[HY093]: Invalid parameter number`.\n\nMore advanced payloads can inject actual SQL fragments (e.g., using `)` to close the `LOWER()` function call and `||` for PostgreSQL string concatenation), enabling boolean-blind SQL injection or error-based data exfiltration.\n\nThe fix is three `array_values()` calls that strip attacker-controlled keys before the vulnerable loop is reached:\n- `core/lib/Drupal/Core/Entity/Query/Sql/Condition.php`\n- `core/lib/Drupal/Core/Entity/Query/Sql/ConditionAggregate.php`\n- `core/modules/pgsql/src/EntityQuery/Condition.php`\n\n## Reproduction Steps\n\n1. **Reference script**: `repro/reproduction_steps.sh`\n\n2. **What the script does**:\n   - Ensures PostgreSQL is running and creates a fresh `drupal` database with user `drupal`/`drupal`\n   - Prepares the Drupal core repository (clones if needed, installs Composer dependencies)\n   - **Vulnerable phase**: Checks out Drupal 11.3.9, installs a minimal site with PostgreSQL, enables the JSON:API module, creates a `page` content type and node, starts PHP's built-in server, and sends an anonymous GET request to `/jsonapi/node/page?filter[t][condition][path]=title&filter[t][condition][value][%60]=x`\n   - **Fixed phase**: Repeats the same setup on Drupal 11.3.10 and sends the identical exploit request\n\n3. **Expected evidence**:\n   - **Vulnerable 11.3.9**: The JSON:API endpoint returns HTTP 500 with a `SQLSTATE[HY093]: Invalid parameter number` error. The response body contains the malformed SQL with the injected backtick inside the placeholder name (`:node_field_data_title``).\n   - **Fixed 11.3.10**: The same request returns HTTP 200 with a normal JSON:API response body (`data: []`), confirming the injection is blocked.\n\n## Evidence\n\n### Log files\n- `logs/vulnerable_response.txt` — HTTP response from the exploit against 11.3.9\n- `logs/fixed_response.txt` — HTTP response from the same exploit against 11.3.10\n- `logs/repro_output.txt` — Full console output from the first reproduction run\n- `logs/repro_output_2.txt` — Full console output from the second (idempotency) run\n\n### Key excerpts\n\n**Vulnerable 11.3.9** (`logs/vulnerable_response.txt`):\n```\nHTTP_CODE:500\n{\"errors\":[{\"title\":\"Internal Server Error\",\"status\":\"500\",\"detail\":\"SQLSTATE[HY093]: Invalid parameter number: parameter was not defined: SELECT ... WHERE ((LOWER(\\\"node_field_data\\\".\\\"title\\\") = (LOWER(:node_field_data_title`)))) ...\"}]}\n```\n\n**Fixed 11.3.10** (`logs/fixed_response.txt`):\n```\nHTTP_CODE:200\n{\"jsonapi\":{\"version\":\"1.1\",...},\"data\":[],\"links\":{\"self\":{\"href\":\"http://localhost:8080/jsonapi/node/page?...\"}}}\n```\n\n### Environment details\n- **PHP**: 8.4.19 (CLI + built-in server)\n- **PostgreSQL**: 16.13\n- **OS**: Ubuntu 24.04\n- **Drupal test profile**: Minimal install profile with JSON:API module enabled\n\n## Recommendations / Next Steps\n\n1. **Immediate upgrade**: All Drupal sites running on PostgreSQL should upgrade to the patched releases: 10.4.10, 10.5.10, 10.6.9, 11.1.10, 11.2.12, or 11.3.10 (whichever matches their branch).\n2. **Verify backend**: Site operators should confirm their database backend. Sites on MySQL, MariaDB, or SQLite are not affected by this specific vulnerability.\n3. **WAF tuning**: If immediate patching is not possible, consider blocking or sanitizing JSON:API filter query strings that contain special characters (`\n`, `'`, `\"`, `)`, `|`, `--`) in filter value keys, as these are the characters that reach the vulnerable placeholder name construction.\n4. **Regression testing**: After upgrading, verify JSON:API filter functionality still works correctly for normal use cases, since the fix reindexes condition value arrays with `array_values()`.\n\n## Additional Notes\n\n- **Idempotency confirmed**: `repro/reproduction_steps.sh` was executed twice consecutively with identical results (HTTP 500 + HY093 on 11.3.9, HTTP 200 on 11.3.10).\n- **Limitations**: The reproduction requires a real running Drupal site over HTTP with a PostgreSQL database. A minimal PHP harness or mock objects would not satisfy the ticket requirements and were explicitly rejected.\n- **Edge cases**: The vulnerability only triggers on the PostgreSQL-specific case-insensitive `IN` condition path. MySQL/MariaDB sites are unaffected because they use a different condition translation path that does not wrap comparisons in `LOWER()`.\n","cve_id":"CVE-2026-9082","cwe_id":"CWE-89","source_url":"https://git.drupalcode.org/project/drupal","package":{"name":"drupal/core","ecosystem":"composer","affected_versions":"8.9.0 -> <10.4.10, 10.5.0 -> <10.5.10, 10.6.0 -> <10.6.9, 11.0.0 -> <11.1.10, 11.2.0 -> <11.2.12, 11.3.0 -> <11.3.10"},"reproduced_at":"2026-05-22T06:21:08.481210+00:00","duration_secs":1287.3623189926147,"tool_calls":335,"turns":258,"handoffs":3,"total_cost_usd":3.357724309999998,"agent_costs":{"repro":1.5986210700000003,"support":0.03514771999999999,"vuln_variant":1.72395552},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":1.5986210700000003},"support":{"accounts/fireworks/models/kimi-k2p6":0.03514771999999999},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.72395552}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T06:21:10.673162+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":6802,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6371,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":10853,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12826,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3013,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":700,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":5170,"category":"ticket"},{"path":"repro/install_drupal.php","filename":"install_drupal.php","size":1274,"category":"other"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":836,"category":"other"},{"path":"repro/setup_content.php","filename":"setup_content.php","size":1699,"category":"other"},{"path":"logs/repro_output_2.txt","filename":"repro_output_2.txt","size":2612,"category":"other"},{"path":"logs/variant_results_11.3.9.txt","filename":"variant_results_11.3.9.txt","size":15215,"category":"other"},{"path":"logs/fixed_response.txt","filename":"fixed_response.txt","size":315,"category":"other"},{"path":"logs/vulnerable_response.txt","filename":"vulnerable_response.txt","size":1711,"category":"other"},{"path":"logs/variant_results_11.3.10.txt","filename":"variant_results_11.3.10.txt","size":3866,"category":"other"},{"path":"logs/repro_output.txt","filename":"repro_output.txt","size":2612,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":7704,"category":"documentation"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1093,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":700,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1167,"category":"other"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3489,"category":"other"}]}