Project

General

Profile

Security #8633 » SU-UPDATE-01.md

Jason Ish, 06/26/2026 04:42 PM

 

SU-UPDATE-01 — suricata-update: rule-archive path traversal → arbitrary file write → RCE

Severity: HIGH [CONDITIONAL] — code execution as the user running suricata-update. Conditions: the attacker controls a fetched archive source, and the updater's privilege.
Reach: ruleset feed (0.3). Requires archive (tar/zip) delivery — the standard format for rule feeds (a single plain .rules source is inert).
Status: ✅ lab-confirmed end-to-end against suricata-update 1.3.7 (current release); the flaw is in the Python updater, independent of the Suricata engine version it is paired with.
Novelty: NOVEL — no CVE/GHSA/redmine; present at OISF/suricata-update master HEAD 210e0b6 (tag 1.3.7). Mechanism shipped for filemd5/filesha* in 1.2.0rc1 (2020), widened to lua in 1.3.6 (2025-07).

Description

When suricata-update installs a downloaded rule archive, a rule keyword (lua, filemd5, filesha1, filesha256) whose value contains ../, plus a matching archive member, writes that member's
contents outside the rules output directory — an arbitrary file write → RCE (e.g. via /etc/cron.d/).

Mechanism

Three behaviours combine (all in current source):

  1. Extractor keeps ..extract.py extract_tar/extract_zip only member.name.lstrip("/"); ../ survives into the dep_files dict (main.py:1259-1260, every non-.rules archive member).
  2. Keyword value stored verbatimfile_kw = ["filemd5","filesha1","filesha256","dataset","lua"] (main.py:100).
  3. handle_embedded_file() writes unconfined (main.py:504-531):
    dest_filename = source_filename[len(prefix)+len(os.path.sep):]                         # keeps "../"
    filepath      = os.path.join(config.get_output_dir(), os.path.dirname(dest_filename))    # "../" escapes
    os.makedirs(filepath)
    open(os.path.join(filepath, os.path.basename(embedded_filename)), "w").write(...)        # attacker bytes
    
    basename() guards only the leaf; the directory part keeps ../, and there is no realpath/commonpath confinement (contrast the dataset-save path, which rejects ../absolute — CVE-2023-35852).
    Reached by both --merge (:575) and --no-merge (:636).

Reachability & preconditions

Exploitation requires:

  • An archive (tar/zip) source. A single plain .rules URL is inert (dep_files is filled only from non-.rules archive members). Archives are the standard format for the feeds suricata-update
    manages: default ET/Open, ET/Pro, Positive Technologies, abuse.ch, malsilo and pawpatrules are all .tar.gz.
  • Attacker control of that archive's bytes — via a hostile or breached rule source (a compromised feed/mirror/CDN, or a third-party source the operator added).
  • A UTF-8 text payload — fine for cron, profile.d, authorized_keys, configs.

These conditions are realistic: archives are the default delivery format, and a compromised or hostile rule feed is the standard supply-chain threat for an auto-updater.

Impact bypasses Suricata's rule containment

Suricata is designed to contain rule content — rule Lua is sandboxed by default, rule-load is hardened, and dataset save is confined — so a hostile feed's intended worst case is degraded detection /
file read / DoS, not code execution.

SU-UPDATE-01 bypasses all of it, and doesn't rely on that containment being airtight (it isn't — cf. the Lua sandbox bypass CVE-2026-45770, fixed 8.0.5): the write/exec happens at update time on the
filesystem, before the engine ever runs the rule. The decisive case is a trusted feed (e.g. ET/Open) compromised at the vendor or a mirror — the operator never consented to running attacker code;
path-confinement restores the guarantee.

Lab

docker compose (builder → Caddy → a real Suricata + the suricata-update 1.3.7 it ships).
Two demonstrations:

  • cron: the poisoned archive in the configured source list + a root cron running suricata-update every minute: the update-cron plants a distinct /etc/cron.d/suri_autocron, which crond then executes — autonomous root RCE, no manual trigger (uid=0, ~100 s).
  • manual: --url — tar+lua/etc/cron.d, zip+filemd5/etc/profile.d write outside the rules dir (both keywords/formats)

Remediation

Confine the write:

  • require realpath(output_filename) to lie within realpath(get_output_dir())
  • reject ../absolute in embedded_filename and in extracted member names (use Python's tarfile data filter; explicit zip checks). Mirror the dataset-save guard.

Systemic: sign rule archives so a hostile source cannot ship arbitrary content.

(2-2/2)