Skip to content

Latest commit

 

History

History
184 lines (136 loc) · 13.2 KB

File metadata and controls

184 lines (136 loc) · 13.2 KB

CVE-2026-44796 — Nautobot bulk-rename ReDoS: a 30-character regex stalls the worker indefinitely

Any Nautobot user with rename access on a single object type (e.g. dcim.change_interface) can submit a (a+)+$-style "evil regex" to the bulk-rename endpoint and pin a Nautobot worker thread on re.sub() until the connection is killed — application-wide DoS gated only by low-privilege object-change permission.

CVE CVE-2026-44796 (NVD)
GHSA GHSA-qrpw-gjvh-x5gm
Severity Moderate — CVSS 6.5
Vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-1333: Inefficient Regular Expression Complexity (also CWE-400: Uncontrolled Resource Consumption)
Affected nautobot < 2.4.33, < 3.1.2
Fixed 2.4.33, 3.1.2
Authentication Authenticated user with change_<model> permission for any model that exposes the bulk-rename action (Interface, Device, Cable, …)
Reporter @whatisproblem
Fix commits c2b7669 (2.4.33), 5a30d09 (3.1.2)

Summary

Nautobot's UI ships a "bulk rename" action on most object list views — /dcim/interfaces/rename/, /dcim/devices/rename/, /dcim/cables/rename/, etc. The form lets the operator type a find pattern, a replace string, and tick a use_regex checkbox; if use_regex is on, Nautobot compiles the find value with Python's re module and runs re.sub(find, replace, name) against each selected object's name to produce the renamed value.

Pre-2.4.33 / 3.1.2, that re.sub() had no timeout, no length cap, and no complexity bound. Python's re engine is a backtracking NFA — there is a well-known family of ReDoS patterns whose evaluation time is exponential in the input length:

(a+)+$
(a|a)*b
^(([a-z])+.)+[A-Z]([a-z])+$

Submitting one of those with use_regex=True, plus an input string that nearly matches but eventually fails (a long aaaaaaaa...x), pins the worker thread on the re.sub() call. With Nautobot's default Gunicorn worker count, one such request per worker is enough to exhaust the entire pool. New requests queue, hit worker timeout, and the application is unavailable until the workers are restarted.

The minimum permission required is the change_<model> permission for any one of the models that has a bulk-rename action — a permission routinely granted to network engineers, not just admins. A user with dcim.change_interface (a baseline-operator permission) is enough.

Root cause

Pre-fix nautobot/core/views/generic.pyBulkRenameView.post() (paraphrased to the security-relevant lines):

import re

class BulkRenameView(...):
    def post(self, request, *args, **kwargs):
        ...
        find = form.cleaned_data["find"]
        replace = form.cleaned_data["replace"]
        use_regex = form.cleaned_data["use_regex"]

        for obj in selected_objects:
            if use_regex:
                obj.new_name = re.sub(find, replace, obj.name)   # ⚠ no timeout, no bound
            else:
                obj.new_name = obj.name.replace(find, replace)
        ...

Three properties combine to make this exploitable:

  1. User-controlled regex pattern. find is whatever the user typed. There is no static analysis of the pattern's complexity, no allow-list of "safe regex constructs", no length cap.
  2. Server-side execution with no timeout. Python's re module does not support a timeout (the standard library's re.compile/re.sub cannot be cancelled). The re.sub call runs to completion or until the worker is killed.
  3. Loop multiplier. re.sub runs once per selected object. The attacker can select 100 objects in the bulk-rename form to multiply wall-clock time by 100×, but in practice a single ReDoS pattern + a single ~30-character input string is already enough to exceed Gunicorn's --timeout.

The exploit input is typed straight into the bulk-rename form — it doesn't even need API access. The find field is (a+)+$, the replace field is anything, use_regex is checked, and any one selected object whose name happens to be an aaaaaaaaaaaaa...! shape (or where the attacker has rename-permission to first set a name to such a shape) will trigger the catastrophic backtracking on submit.

Reproduction

Setup

git clone https://github.com/nautobot/nautobot
cd nautobot && git checkout v2.4.32
docker compose -f development/docker-compose.yml up -d

Provision a user with dcim.change_interface (or any change_<model> permission for a model that has a bulk-rename action — Device, Cable, Rack, etc.).

Create one Interface named aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa! (any string of ~30 as followed by a non-a character).

Step 1 — open the bulk-rename form

Browse to /dcim/interfaces/, tick the checkbox next to the aaaa... interface, click Rename Selected.

Step 2 — submit the evil regex

In the rename form:

Field Value
Find (a+)+$
Replace x
Use regex

Click Apply.

Step 3 — observe the DoS

The HTTP request hangs. Tail the worker process (docker compose logs nautobot -f) and observe one Gunicorn worker pinned at 100% CPU. After Gunicorn's worker timeout (default 30s) elapses:

[CRITICAL] WORKER TIMEOUT (pid:42)

Gunicorn kills and respawns the worker. Sending the same request to N workers simultaneously (where N = Gunicorn worker count) starves the entire pool — all subsequent requests, including ones from other users, queue indefinitely until the workers cycle.

Demonstrative scope only. The PoC uses a single ~30-character interface name and the textbook (a+)+$ pattern. A real attacker would (a) tune the pattern + input length to exceed worker timeout reliably while staying under request body limits, (b) send the request to N workers in parallel from a single shell loop, and (c) re-send on a tight loop to keep the worker pool starved as workers respawn. None of those amplifications are reproduced here.

Fix

Patched in v2.4.33 (commit c2b7669) and v3.1.2 (commit 5a30d09) by switching from the standard-library re module to the third-party regex module — which supports a timeout= parameter on every match call — and bounding the bulk-rename regex evaluation to a hard wall-clock budget.

- import re
+ import regex

  class BulkRenameView(...):
+     bulk_rename_regex_timeout = 1.0   # seconds

      def post(self, request, *args, **kwargs):
          ...
          if use_regex:
-             try:
-                 pattern = re.compile(find)
-             except re.error as e:
-                 form.add_error("find", f"Invalid regex: {e}")
-             else:
-                 for obj in selected_objects:
-                     obj.new_name = pattern.sub(replace, obj.name)
+             try:
+                 pattern = regex.compile(find)
+             except regex.error as e:
+                 form.add_error("find", f"Invalid regex: {e}")
+             else:
+                 try:
+                     for obj in selected_objects:
+                         obj.new_name = pattern.sub(
+                             replace, obj.name,
+                             timeout=self.bulk_rename_regex_timeout,
+                         )
+                 except TimeoutError:
+                     form.add_error(
+                         "find",
+                         "Regex matching exceeded the configured timeout and was aborted; "
+                         "the pattern may have catastrophic backtracking."
+                     )

Three things this gets right:

  1. Hard wall-clock cap. regex raises TimeoutError after bulk_rename_regex_timeout seconds per call, regardless of pattern complexity. There is no way to construct a pattern that bypasses the timer.
  2. Two-phase form processing. The fix splits validation from application: phase 1 runs the regex with the timeout to compute new_name for every selected object; phase 2 only persists if phase 1 had no errors. A timeout in phase 1 surfaces as a form error, not a half-applied rename.
  3. Configurable budget. bulk_rename_regex_timeout = 1.0 is a class attribute, so per-view subclasses can adjust it (in either direction) without forking the whole bulk-rename flow.

The regex library is added as a runtime dependency (pyproject.toml + poetry.lock updates in the same commit). It's a long-lived, well-maintained drop-in: same API surface as re, plus the timeout= kwarg that the standard library has resisted adding for over a decade.

Defense-in-depth recommendations beyond the minimal patch

  • Apply the same regex.compile(...).sub(..., timeout=...) treatment to every place Nautobot evaluates a user-supplied regex (filter forms with regex operators, custom-field validation patterns, etc.). The fix here is scoped to the bulk-rename surface; a second pass through the codebase for re.compile/re.sub/re.search/re.match with user-controlled patterns would catch the rest.
  • Consider rate-limiting the bulk-rename endpoint per user — a 1-second timeout still lets an attacker burn 60 worker-seconds per minute per worker if they fire requests in a tight loop. A short 1 req/10s rate limit on rename actions would keep that bounded.

Timeline

  • (internal research, prior to disclosure) — Discovered.
  • (prior to 2026-05-08) — Reported privately via GHSA Draft to the Nautobot maintainers.
  • 2026-05-08 — Patches released in v2.4.33 and v3.1.2; GHSA-qrpw-gjvh-x5gm published; CVE-2026-44796 assigned.
  • 2026-05-10 — Public write-up.

Lessons

  • Python's re is a ReDoS hazard wherever the pattern is user-controlled. The standard library has no timeout, no static-complexity check, and no way to cancel an in-progress match. Any user-controlled find field that ends up in re.sub/re.match/re.search is a one-shot DoS unless wrapped. The regex library's timeout= kwarg is the cheapest known fix; alternatives are RE2-based engines (google-re2, pyre2) that reject backtracking-prone patterns at compile time, or running the regex inside a subprocess with a wall-clock kill.
  • "Authenticated" is not a meaningful gate when the permission is broadly delegated. dcim.change_interface is a baseline operator permission. Any DoS gated by "needs to be logged in as a network engineer" is functionally a low-skill attack against operations teams. The fix correctly treats this as a real DoS path, not a "trusted user" issue.
  • Synchronous-request CPU work needs a wall-clock budget. Anything that runs inside a request → response cycle and can take user-controlled time (regex, JSONPath, XPath, GraphQL query depth, template rendering) needs an explicit timeout. The Gunicorn worker timeout is a failsafe, not a control: by the time it fires, the worker is already killed and respawned and the user has visibly seen a 504.
  • Two-phase form handling makes safe-cancellation possible. The pre-fix code interleaved "compute the new name" and "persist the new name". The fix separates the compute pass (which can fail-stop on timeout) from the persist pass (which only runs if compute succeeded for every selected object). Forms that mutate state in a loop should always validate-everything-then-apply-everything, not validate-and-apply per item.
  • Variant analysis: grep for the shape, not the symptom. The same re.compile(<user-input>) pattern likely exists elsewhere in the codebase. After landing the bulk-rename fix, the natural follow-up is grep -r 'compile\(.*find\)' nautobot/ and grep -r 're\.\(sub\|match\|search\)' nautobot/ to enumerate every other surface that takes a user pattern.

References