# detection/ - CVE-2026-85706 detection package (ARL case 004) Rules here are written **only from the live wire/log captures of this case**, not from advisory text. Every claim below carries the capture file + line it is grounded on. Rules contain **no lab-internal IP** - project id `1`/`2` are payload variables (any GitLab id works), paths probed are benign sentinels. ## Package inventory | file | family | arm | validation | |---|---|---|---| | `rules/nuclei/CVE-2026-85706-probe-shape.yaml` | nuclei v3.11 | probe-shape | `-validate` OK + **LIVE fire on stock arm** (`rule-fires/nuclei-live-stock-20260912a.txt`) | | `rules/nuclei/CVE-2026-85706-confirmed-read.yaml` | nuclei v3.11 | confirmed-read | `-validate` OK + **LIVE fire** (same evidence file) | | `rules/sigma/cve-2026-85706-gitlab-nginx-access.yaml` | sigma | edge request shape | YAML parse + offline predicate replay 9/9 (`rule-fires/sigma-offline-replay-20260912a.txt`) | | `rules/sigma/cve-2026-85706-rails-api-json.yaml` | sigma | rails api_json signature | same | | `detection/harness/replay_sigma_offline.py` | harness | positive+negative matrix | prints the table, exit 0 | The nuclei pair ships as **two files, one rule each**: nuclei v3.11 loads only the first YAML document per file (measured: multi-doc file → `Templates loaded for current scan: 1`), so a combined file would silently skip the second arm. ## Rule → exact wire signature map ### CVE-2026-85706-probe-shape (request shape + absence oracle, zero reads) Request bytes are the golden `--wire-dump` request from `evidence/fix-verification/run-20260912-a/phase1-stock/stock-read-oracle-proof.wire` (lines 2-10) with only `file.path` swapped to a nonexistent sentinel: ``` POST /api/v4/projects/1/repository/commits/ HTTP/1.1 ← trailing slash = \z-miss file=&file.path=%2Ftmp%2F.gitlab-cve-2026-85706-absent-probe&file.size=1&Content-Type=application%2Fx-www-form-urlencoded ``` - `file=` empty → Grape `requires :file, type: WorkhorseFile` passes via `value.blank?` short-circuit (exploit/README.md mechanism §2). - body-field `Content-Type=` (not header) selects the `application/x-www-form-urlencoded` branch of `file_params_from_body_upload` (`evidence/sink-19.3.1-commits_body_uploader_helper.rb:23,39`). - no `Cookie` / `Authorization` / `PRIVATE-TOKEN` sent (captures carry none). Matcher `400 + "local file not present"` is verbatim from golden `absent_file` (`exploit/tests/golden/live_classify_goldens.json`): `{"message":"400 Bad request - local file not present"}` - the string a pre-fix host can only produce by running `File.exist?(params['file.path'])` pre-auth (`.../helper.rb:19`). Patched parity: **401** (fix adds `authenticate!` first - `STATUS.md` gate ledger `wire-diff` row: "+authenticate! commits.rb:352"), so the rule does not fire on patched. Live fire tail: ``` [CVE-2026-85706-probe-shape] [http] [critical] http://172.30.99.20/api/v4/projects/1/repository/commits/ [project_id="1"] ``` ### CVE-2026-85706-confirmed-read (two non-reading differentials) - **`eisdir-preauth-open`**: `file.path=%2Fetc` → helper pre-auth executes `File.read('/etc')` → raises EISDIR → HTTP 500. Golden `exists_dir_500`: `{"message":"500 Internal Server Error"}`. Byte-verified against the live arm during authoring (raw netcat replay → `HTTP/1.1 500 Internal Server Error`). A directory read error transfers zero bytes; patched answers 401. Live fire tail: `[CVE-2026-85706-confirmed-read:eisdir-preauth-open] ... 1/repository/commits/`. - **`byte-echo-leak`**: word matcher `"Invalid parameter: invalid %-encoding ("` - verbatim prefix of the leak captures: golden `proof_file_full_leak` and `stock-read-fx.wire:12` → `{"message":"400 Bad request - Invalid parameter: invalid %-encoding (FIXVERIFY%zz leak-line-two\n)"}`. The accompanying benign probe uses `/etc/hostname` (world-readable, non-secret) - **not** `/etc/passwd` (charter: non-reading; a %-clean hostname file yields 401 and is deliberately not matched). ### Why the 401 "EXISTS-read-clean" shape is NOT a matcher (task asked for it) Golden `exists_read_clean_401` / `stock-read-oracle-proof.wire:12` (`{"message":"401 Unauthorized"}` after full pre-auth read of `/etc/passwd`) is a *real* vulnerable-arm signal - but the **identical** 401 is what a *patched* GitLab returns to the same request (fix-verifier negative). A nuclei matcher on it fires on benign stock, i.e. a broken rule by charter §Method-2. The 401 therefore stays the interpretive rung of the oracle ladder (README §tuning) and the two arms above carry the load: `local file not present` ⇒ vulnerable code path executes pre-auth; 500/`Invalid parameter: invalid %-encoding (` ⇒ confirmed pre-auth open/read. ### cve-2026-85706-gitlab-nginx-access.yaml (edge / workhorse layer) | selection | signature source (quoted) | |---|---| | `cs-method: POST` | `wire-proof-live-capture.txt:2` `POST /api/v4/projects/1/repository/commits/ HTTP/1.1` | | `c-uri contains '/repository/commits/'` | same line (trailing slash = Workhorse `\z` miss, exploit/README mechanism §1) | | `'/repository/commits.json'` | `wire_shape_goldens.json` `wire_read_gitlab_yml_dotjson`: `"path": "/api/v4/projects/1/repository/commits.json"` | | `'/repository/commits%2F'` | probe arm `p9_shape_sweep.py:92` (Y6) | | `'/repository/files/'` | files-family sibling, same helper/fix (exploit/README §Mechanism-4: "reachable identically via POST\|PUT /api/v4/projects/:id/repository/files//") | | `c-uri / c-query contains 'file.path'` | query arm captured in api_json distribution (`commits.json` + query rows) and `p9_shape_sweep.py:69` (`?file.path=%2Froot%2Flab-proof.txt`) | | `not auth header` | captures carry no Cookie/Authorization/PRIVATE-TOKEN (`stock-read-fx.wire` header block: Host, UA, Accept, CT, CL, Connection only) | Workhorse JSON-log mapping (`/var/log/gitlab/gitlab-workhorse/current`, observed during authoring on the live arm): exploit rows carry `"method":"POST","route_id":"api","status":400|401|500` - the catch-all `^/api/` proxy row is itself the route-miss tell (the canonical route would log the body-uploader route_id). Field renames in the sigma rule header. ### cve-2026-85706-rails-api-json.yaml (rails layer) Field names/shape quoted verbatim from `evidence/fix-verification/run-20260912-a/phase1-stock/stock-api_json-fx.log`: `"method":"POST"`, `"path":"/api/v4/projects/1/repository/commits"`, `"params":[{"key":"file","value":""},{"key":"file.path","value":"/tmp/fx.txt"}, {"key":"file.size","value":"1"},{"key":"Content-Type","value":"application/x-www-form-urlencoded"}]`, `"remote_ip":"172.30.99.1, 127.0.0.1"`, `"correlation_id":"01M2BVV9TXXSKMRX6CGJYHD1C0"`, `api_error":["{\"message\":\"400 Bad request - Invalid parameter: invalid %-encoding (FIXVERIFY%zz leak-line-two\\n)\"}"]`. Honest capture facts encoded: * Rails **normalizes the path** - the wire trailing slash does NOT appear in `path` (both phase1 captures logged `/commits` for wire `/commits/`). The slash evidence lives at nginx/workhorse; that is why the two sigma rules are companions, correlated via `correlation_id` (= `X-Request-Id` response header, `wire-proof-live-capture.txt:12`). * unauthenticated rows carry **no `user_id`/`username` keys** (measured: `grep '"username"'` over all `file.path` rows in live api_json = 0). * legit authenticated body-uploads carry the Workhorse-signed `file.gitlab-workhorse-upload` JWT param (sanitized `` in `exploit/probes/m1_matrix.py:28` and `p9_shape_sweep.py:26`) - filtered. * `POST .../commits/authorize` rows (52 of them live; Rails-200, file.path params, no read - attempt-log A1: "authorize response BODY never reaches the client (api.blocker)") - filtered via `filter_authorize_route`. ## Validation evidence (this package) - `nuclei -t -validate` → `[INF] All templates validated successfully` for both files (also `yaml.safe_load` OK). - **LIVE against stock arm** (`cve2026-85706-stock` Up/healthy at time of run; per parent directive) - `rule-fires/nuclei-live-stock-20260912a.txt` (verbatim `-silent` tails above; both arms fired, rc=0). - Sigma: `sigma-cli` NOT available (host python is PEP 668 externally-managed; parent directive not to fight it) → validation = `yaml.safe_load` + manual spec review + predicate replay harness `detection/harness/replay_sigma_offline.py`: **9/9: positives P1/P2 fire (real captured rows P1/P2 parsed straight out of the evidence files), N1-N5 silent** (`rule-fires/sigma-offline-replay-20260912a.txt`). sigma-cli smoke-test: **DEFERRED** (see gaps). ## Negative control (benign silence) - nuclei: on a **patched** host both templates stay silent (probe→401, /etc→401: neither `local file not present` nor 500 reachable post-fix - `authenticate!` precedes helper per `STATUS.md` wire-diff gate row). Patched A/B live confirmation runs at W5 (arm swap cadence). - sigma: harness N1-N5 (legit authed upload w/ JWT, authorize-200 row, benign GETs, authed bypass-path POST without file.path) all silent. - Benign traffic baseline: stock lab nginx log lines for normal UI/API traffic contain none of the selection strings (`commits` list is GET; commit creation is authenticated POST to canonical no-slash route). ## Tuning notes 1. **401-oracle noise**: mass-probers enumerate file existence via the 401-vs-400 differential (one POST per path - the PoC `--probe-list` mode). Alert on the *response-status mix per client* (a probe sweep shows `400 local file not present` + `401` + `500` from one remote_ip within minutes); rate-limit benign monitor false alarms by requiring ≥2 distinct signature classes per source before paging. 2. **Scanner vs true leak by response length**: the leak arm's body grows with the leaked slice - the 400 leak capture body is 153 B (`Content-Length: 153`) vs 54 B for the `local file not present` probe and 30 B for 401 (`Content-Length: 30`, oracle wire line 6/12). In nginx logs (`$body_bytes_sent`), a `POST /repository/commits*/ → 400` line with bytes_sent ≫ ~120 is a strong confirmed-exfil tell; escalate those first. 3. api_json `api_error` contains `invalid %-encoding (` ⇒ **incident** (file bytes already in the response, and in the log itself - scrub carefully). ## Coverage gaps (honest list) 1. **sigma-cli / real SIGMA backend not exercised** - PEP 668 host, parent directive; predicate-equivalence harness only. W5 should run `sigma-cli backend` smoke-test in an unrestricted env. 2. **Live nuclei fire against stock happened; patched negative NOT yet run** (single-arm RAM rule; patched arm lifecycle owned by fix-verifier). W5 re-verify step: `-t rules/nuclei/ -target http://` expecting zero results. 3. Body-only exploit variants (file.path inside POST body, the *canonical* exploit channel) are invisible to the nginx rule - omnibus nginx default format logs no request body; covered by the rails rule, gap only if a deployment ships edge logs without rails api_json to the SIEM. 4. nginx rule assumes ingest exposes method/uri/query and an Authorization presence flag. Omnibus default `log_format` lacks the Authorization variable → add `$http_authorization` or accept rails-rule-only auth absence evidence. 5. `file.path` obfuscated encodings (`file%2Epath`, `file[path]`, duplicate-key casing) - Grape's flat-string key is matched literally in captures; variants would need additional contains-regex arms (attempt-log and probes only exercised literal `file.path`; untested variants listed, not claimed). 6. Suricata/YARA families: out of scope for this wave (no pcap in this case; charter counts stay honest - nothing shipped, nothing counted). 7. Rule depends on ≥1 **public project** precondition (advisory/watchTowr); against an instance with zero public projects both arms 404 at Grape and the rules stay silent - a *vulnerable-but-unexploitable* host reads as clean. Pair with `/api/v4/projects?public=true` reachability before trusting silence.