# 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 verbatim** — `file_kw = ["filemd5","filesha1","filesha256","dataset","lua"]` (`main.py:100`).
3. **`handle_embedded_file()` writes unconfined** (`main.py:504-531`):
   ```python
   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.
