# Pruva - Complete Reproduction Database # Generated: 2026-09-06T21:28:44.162Z # Total reproductions: 200 This file contains all published vulnerability reproductions from Pruva. For API documentation, see: https://www.pruva.dev/llms.txt ================================================================================ ## REPRO-2026-00343: Jenkins XStream deserialization of nested PersistenceRoot objects leads to RCE via Stapler (SECURITY-3972) -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00343 - CVE: CVE-2026-84645 (https://nvd.nist.gov/vuln/detail/CVE-2026-84645) ### Package Information - Name: jenkinsci/jenkins - Ecosystem: github - Affected: Jenkins weekly 2.579 and earlier; Jenkins LTS 2.568.2 and earlier. - Fixed: Unknown - Severity: high - CVSS: Unknown - CWE: CWE-94 (Improper Control of Generation of Code ('Code Injection')) ### Root Cause ## Summary CVE-2026-84645 (Jenkins SECURITY-3972) is an authenticated remote code execution vulnerability caused by Jenkins XStream deserialization accepting implementations of `hudson.model.PersistenceRoot` in nested positions of attacker-submitted configuration object graphs. The accepted nested objects remain reflectively traversable by Stapler. In this run, a user limited to Overall/Read, Item/Read, and Item/Configure submitted a job `config.xml` containing `SCMTrigger.BuildAction -> FreeStyleBuild -> FreeStyleProject -> hudson.model.Hudson`, where the forged `Hudson` carried the core `AuthorizationStrategy$Unsecured`; the attacker then reached that object's `doScriptText` method through the forged Stapler route and executed Groovy plus the controller-local `id` command. ## Impact - **Affected package/component:** Jenkins core XStream handling in `hudson.util.RobustReflectionConverter`, combined with Stapler routing over Jenkins model objects. - **Affected versions:** Jenkins weekly 2.579 and earlier, and Jenkins LTS 2.568.2 and earlier, per the official advisory. - **Fixed versions:** Jenkins weekly 2.580 and LTS 2.568.3. - **Risk level:** High (official CVSS 3.1 vector `AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H`, score 8.8). - **Consequences:** An authenticated user with job configuration rights can execute arbitrary Groovy and operating-system commands in the Jenkins controller JVM/container security context, enabling full compromise of controller data and behavior. The runtime used the real Jenkins HTTP/API boundary. Matrix Authorization Strategy Plugin 3.3 was installed only to express the claim's low-privilege account precisely. Before exploitation, the same `attacker` account received HTTP 403 from the legitimate root `/scriptText` endpoint in every vulnerable and fixed attempt. ## Impact Parity - **Disclosed/claimed maximum impact:** Authenticated remote code execution on the Jenkins controller. - **Reproduced impact from this run:** Authenticated remote code execution on two independent Jenkins 2.579 controller processes. Each attacker-supplied Groovy script created a unique controller-local marker and executed `id`, returning `uid=1000(jenkins) gid=1000(jenkins) groups=1000(jenkins)`. - **Parity:** `full`. - **Not demonstrated:** No claimed impact remains unproven. The proof intentionally stops after a harmless unique file write and `id`; it does not perform persistence, secret extraction, or destructive actions. ## Root Cause `PersistenceRoot` identifies Jenkins model objects whose state belongs in an independent top-level persistence document, such as a Jenkins/Hudson singleton, item/job, node, or build. Before the fix, `RobustReflectionConverter` applied the JEP-200 class allowlist but did not enforce the structural invariant that newly deserialized `PersistenceRoot` instances must not occur as ordinary nested field values. Consequently, a class could be allowed by identity yet unsafe in its graph position. The exploit uses only Jenkins core types: 1. `hudson.triggers.SCMTrigger$BuildAction` is inserted into the carrier job's persistent `actions` list. It exposes the Stapler URL name `pollingLog` and a public `getRun()` accessor. 2. Its private `run` field is deserialized as a nested `hudson.model.FreeStyleBuild` (a `PersistenceRoot`). 3. The build's `project` field is deserialized as a nested `hudson.model.FreeStyleProject` (also a `PersistenceRoot`). 4. The nested project's `parent` field is deserialized as a second `hudson.model.Hudson`/`jenkins.model.Jenkins` singleton object (also a `PersistenceRoot`). 5. That forged root object carries `hudson.security.AuthorizationStrategy$Unsecured`, so `Hudson#doScriptText` calls `Jenkins._doScript(..., getACL())` with an ACL that allows `ADMINISTER`. 6. Stapler reflectively traverses `/job/carrier/pollingLog/run/project/parent/scriptText` and invokes the forged object's Script Console endpoint, despite the authenticated principal lacking real Jenkins `ADMINISTER` permission. The primary fixing commit is [`0d731367e08656f8cd1e8275f0e820f97af07fc6`](https://github.com/jenkinsci/jenkins/commit/0d731367e08656f8cd1e8275f0e820f97af07fc6) (`[SECURITY-3972]`), which is present in tag `jenkins-2.580` and absent from `jenkins-2.579`. It adds a `PersistenceRoot` check in `RobustReflectionConverter` and throws `CriticalXStreamException` for unsafe nested instances, with narrow exceptions for references, replacement placeholders, and registered single-value converters. It also adds a second-Jenkins-instance guard and safe replacer logic in `Jenkins`. Related hardening annotations in the 2.580 release prevent unsafe transient-field reconstruction. In the fixed runtime, the same `config.xml` POST completes but serializing the job back shows only ``; the forbidden nested Run/Job/Hudson graph is absent. The forged route returns HTTP 404 and no marker is created. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` from any directory. `PRUVA_ROOT` may optionally identify the bundle root. 2. The script reads `bundle/project_cache_context.json`, reuses the prepared cache when available, and otherwise uses bundle-owned fallback paths. 3. It downloads three pinned plugin dependencies only when absent and verifies their SHA-256 values: Matrix Authorization Strategy 3.3, Ionicons API `94.vcc3065403257`, and commons-lang3 API `3.18.0-98.v3a_674c06072d`. 4. It pulls Jenkins by immutable image digest: vulnerable 2.579 (`sha256:a7342867…d7412be`) and fixed 2.580 (`sha256:0e50a5b1…0839b1`). 5. For each of two vulnerable and two fixed clean controller processes, it provisions `admin` and a low-privilege `attacker`, creates the `carrier` freestyle job, verifies that direct Script Console access returns 403, submits the crafted XML over authenticated HTTP, and posts unique Groovy to the forged route. 6. Vulnerable success requires HTTP 200, the unique marker in both the HTTP response and controller-local marker file, and `uid=1000(jenkins)` output. Fixed success requires a non-success forged-route status, marker absence, and absence of the nested graph from the resulting job XML. 7. The script finalizes controller logs, writes `bundle/repro/runtime_manifest.json` with SHA-256 bindings for all immutable proof artifacts, prints `CONFIRMED`, and exits 0 only if every assertion passes. Expected terminal output: ```text CONFIRMED: SECURITY-3972 achieved authenticated remote command execution on two Jenkins 2.579 controllers; two Jenkins 2.580 controls failed closed. ``` ## Evidence Primary current-run evidence is under `bundle/repro/proof/`, and every file is bound in `bundle/repro/runtime_manifest.json`: - `vulnerable_1.route.response.body` and `vulnerable_2.route.response.body` contain unique markers and controller command output. Latest run excerpts: ```text Result: {marker=CVE_2026_84645_vulnerable_1_12521_12184, id=uid=1000(jenkins) gid=1000(jenkins) groups=1000(jenkins)} Result: {marker=CVE_2026_84645_vulnerable_2_12521_18651, id=uid=1000(jenkins) gid=1000(jenkins) groups=1000(jenkins)} ``` - `vulnerable_1.marker.txt` and `vulnerable_2.marker.txt` are controller-local command markers whose bytes match the per-process values in `vulnerable_1.capability_observation.json` and `vulnerable_2.capability_observation.json`. - `vulnerable_{1,2}.direct_console.headers` begin with `HTTP/1.1 403 Forbidden`, proving the attacker did not already have Script Console access. - `vulnerable_{1,2}.config.request.txt` record the redacted authenticated API request and exact nested graph. - `vulnerable_{1,2}.route.request.txt` record the forged Stapler route and bounded Groovy effect. - `fixed_{1,2}.route.response.headers` begin with `HTTP/1.1 404 Not Found`. - `fixed_{1,2}.marker_absent.txt` and `fixed_{1,2}.negative_control.json` record that the corresponding unique marker was not created after the same procedure reached the fixed target. - `fixed_{1,2}.config.after.xml` contain the empty `SCMTrigger_-BuildAction` and no nested `FreeStyleBuild`. - `target_identity.txt` records the immutable image digests/image IDs, source tag commits, platform, and architecture. - `vulnerable_{1,2}.service.log` and `fixed_{1,2}.service.log` show real Jenkins 2.579/2.580 startup and production service initialization. - `bundle/logs/reproduction_steps.log` and `bundle/logs/reproduction_steps_second.log` show two consecutive successful executions of the final script. Environment identity: - Vulnerable source tag commit: `9095ea3a5c5e7dcd392695a5dd880af1c9910ddf` (`jenkins-2.579`). - Fixed source tag commit: `497de4961ad80d97e26bfdeb0d2e40442a84ecb0` (`jenkins-2.580`). - Vulnerable image digest: `sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be`. - Fixed image digest: `sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1`. - Runtime platform: Linux x86-64/amd64, Docker, Jenkins bundled JVM 21. - Sanitizers: None. ## Recommendations / Next Steps - Upgrade Jenkins weekly to 2.580 or later, or Jenkins LTS to 2.568.3 or later. - Preserve the fixed `PersistenceRoot` structural check and its safe-reference exceptions; do not rely on class allowlisting alone for objects with graph-position invariants. - Retain the second-singleton guard in `Jenkins#readResolve` as defense in depth. - Review plugin-defined `PersistenceRoot` implementations and plugin actions that expose routable accessors to root objects. - Add regression tests for all configuration-accepting endpoints, including jobs, nodes, builds, users, views, and plugin-defined XML documents. Tests should verify both deserialization rejection/neutralization and that Stapler cannot traverse any partially retained graph. - Independently test safe back-references, `writeReplace`/`readResolve` placeholders, and single-value converters to avoid compatibility regressions without weakening the structural policy. ## Additional Notes - **Idempotency:** Confirmed. The final `bundle/repro/reproduction_steps.sh` passed twice consecutively. Each execution created two new vulnerable and two new fixed Jenkins controller processes with unique container IDs and markers. - **Privileges:** The `attacker` account has only Overall/Read, Item/Read, and Item/Configure. A direct `/scriptText` request is a mandatory 403 negative precondition in all four attempts. - **Safety:** The only command effects are a uniquely named file under the ephemeral controller's `/tmp` and `id`; all test containers are removed on completion or interruption. - **Fixed behavior nuance:** The 2.580 endpoint returned HTTP 200 for the configuration update while omitting the prohibited nested values. Security parity is established by the non-routable graph, HTTP 404 forged route, and marker absence rather than by requiring the initial XML POST itself to return an error. - **Network dependency:** A cold run needs access to the pinned Jenkins images and plugin URLs. A warm prepared cache reuses exact plugin bytes, and Docker reuses digest-addressed images. ### Reproduction - Reproduced: 2026-09-03T17:02:18.865Z - Duration: 2645s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00343 # or: pruva-verify CVE-2026-84645 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00343 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00343/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00343 ================================================================================ ## REPRO-2026-00342: Langflow contains an unauthenticated remote code execution vulnerability in the validate endpoint that can lead to arbitrary Python code execution as root. -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00342 - CVE: CVE-2026-0768 (https://nvd.nist.gov/vuln/detail/CVE-2026-0768) ### Package Information - Name: langflow-ai/langflow - Ecosystem: PyPI - Affected: Endpoint fully unauthenticated in langflow <= 1.2.x; exec(code_obj) of user code persists through >= 1.8.0-rc. NO COMPLETE FIX EXISTS. - Fixed: unknown - Severity: critical - CVSS: 9.8 / 10 - CWE: CWE-94 (Improper Control of Generation of Code ('Code Injection')) ### Root Cause # RCA Report — CVE-2026-0768 (Langflow unauthenticated RCE via /api/v1/validate/code) ## Summary Langflow (<= 1.2.x, and partially thereafter) exposes `POST /api/v1/validate/code` without authentication. The endpoint calls `validate_code()` in `src/backend/base/langflow/utils/validate.py`, which `ast.parse()`s the attacker-supplied `code` field and then **`exec()`s each top-level `FunctionDef` node** after compiling it. Python evaluates default-argument expressions and decorators **at function-definition time**, so a function body that never runs still executes arbitrary expressions embedded in its default arguments or decorators. The endpoint's exception handler returns the resulting error text in the HTTP response (`detail.function.errors[0]`), giving a built-in exfiltration channel for command output — a fully non-blind, unauthenticated remote code execution. ## Impact - **Package/component:** `langflow` (`langflow.utils.validate.validate_code`, reached from the `/api/v1/validate/code` FastAPI route). - **Affected versions:** Unauthenticated on langflow <= 1.2.x (verified on the official image `v1.1.1`). Version 1.3.0 (commit `faac4db`, PR #6911) added `get_current_active_user` auth to the route only — `exec(code_obj)` of user code survives, and `LANGFLOW_AUTO_LOGIN=true` (the default) auto-authenticates requests, so default 1.3.0+ deployments remain effectively unauthenticated. Any authenticated user retains RCE on 1.3.0+. No complete fix exists as of the disclosed range (survives through >= 1.8.0-rc per advisory). - **Risk level:** Critical (CVSS 9.8, CWE-94, ZDI-26-034 / ZDI-CAN-27322). Sibling CVE-2025-3248 (same endpoint, same root cause) is in CISA KEV. ## Impact Parity - **Disclosed/claimed maximum impact:** Unauthenticated arbitrary Python code execution in the Langflow server process (advisory says "as root"; in the official container image the process runs as `uid=1000(user) gid=0(root)`, i.e. full container compromise). - **Reproduced impact from this run:** **Full parity — unauthenticated remote command execution.** Two independent fresh Langflow 1.1.1 containers executed attacker-selected shell commands (`id`) via the default-argument `exec()` vector; output `uid=1000(user) gid=0(root) groups=0(root)` was exfiltrated in the HTTP response JSON at `detail.function.errors[0]`; each instance also wrote a unique marker file inside the container filesystem via the executed command (`marker_output` evidence). A decorator-based vector (`@exec(...)`) triggered the identical sink, proving payload-shape agnosticism. - **Parity:** `full`. - **Not demonstrated:** Nothing of the claimed impact is missing. (Note: the advisory's "as root" phrasing maps to `gid=0(root)` container execution observed here; the server process itself runs as uid 1000 in the official image.) ## Root Cause 1. The route `POST /api/v1/validate/code` (langflow <= 1.2.x) has no authentication dependency. 2. `validate_code()` runs `ast.parse(code)`, then for each top-level `FunctionDef` node: `code_obj = compile(ast.Module(body=[node], type_ignores=[]), '', 'exec'); exec(code_obj)` — deliberately executing user code to "validate" it. 3. Python evaluates **default-argument expressions and decorator expressions when the `def` statement executes**, not when the function is called. An attacker embeds `exec('raise Exception(__import__("subprocess").check_output("id", shell=True))')` in a default argument; it runs during `exec(code_obj)` inside the server process. 4. The raised `Exception` text (containing the command output) is captured by the endpoint's error handling and returned to the attacker in `detail.function.errors[0]` — a response-side exfiltration channel making the RCE non-blind. 5. **Partial fix:** langflow 1.3.0 (commit `faac4db`, PR #6911) added `get_current_active_user` to the route. Because `LANGFLOW_AUTO_LOGIN=true` is the default, requests are auto-authenticated and the same payload still executes on default 1.3.0 deployments (verified in this run). With `LANGFLOW_AUTO_LOGIN=false`, the same request is rejected with HTTP 403 `An API key must be passed as query or header` and no code executes. ## Reproduction Steps 1. Script: `bundle/repro/reproduction_steps.sh` (self-contained; run with `bash bundle/repro/reproduction_steps.sh`). Two consecutive successful runs confirmed idempotency (exit 0 both times). 2. What it does: - Pulls digest-pinned official images: vulnerable `langflowai/langflow@sha256:b56d4cfe...` (v1.1.1) and fixed `langflowai/langflow@sha256:8c124064...` (1.3.0). - Starts four fresh containers: two vulnerable (`LANGFLOW_AUTO_LOGIN=true`), one fixed with `LANGFLOW_AUTO_LOGIN=false` (auth enforced), one fixed with defaults (auto-login). - Waits for `GET /health` == `{"status":"ok"}` on each. - **Vulnerable attempts (x2):** sends the exact contract payload `{"code": "def exploit(cd=exec('raise Exception(__import__(\"subprocess\").check_output(\"id\", shell=True))')): pass"}`; asserts HTTP 200 and `uid=` in `detail.function.errors[0]`. - **Marker-backed execution (x2 fresh instances):** payload writes a unique marker file to `/tmp/pruva_marker.txt` inside each container via the executed command; asserts marker bytes match and `uid=` is exfiltrated. - **Decorator variant:** `@exec(...)` payload; asserts command output in `function.errors[0]`. - **Fixed negative control (x2):** same payloads against auth-enforced 1.3.0; asserts 401/403, no `uid=`, and no marker file created in the container. - **Partial-fix documentation (control B):** same payload against default 1.3.0 (auto-login); observes it still executes (expected partial-fix behavior, not a reproduction failure). - Writes `bundle/repro/runtime_manifest.json` with target identity (image digests) and SHA-256 of all proof artifacts; cleans up containers. 3. Expected evidence: HTTP 200 responses containing `b'uid=1000(user) gid=0(root) groups=0(root)\n'` at `detail.function.errors[0]`, marker files with exact attacker-chosen bytes, and 403 rejection on the auth-enforced fixed build. ## Evidence All artifacts under `bundle/` (paths relative to bundle root), SHA-256 map in `bundle/repro/runtime_manifest.json`: - `logs/reproduction_steps.log` — full run transcript (diagnostic). - `logs/repro/image_identity.txt` — immutable image digests/arch used. - `logs/repro/containers.txt` — live container listing. - `logs/repro/attempts/vuln_attempt_{1,2}_{request,response}.txt` — primary payload and responses: `{"imports":{"errors":[]},"function":{"errors":["b'uid=1000(user) gid=0(root) groups=0(root)\n'"]}}` - `logs/repro/attempts/vuln_marker_{a,b}_{request,response}.txt`, `vuln_{a,b}_marker.txt` — marker-backed command execution on two fresh instances (markers `PRUVA-CMDEXEC-1788272103-6040-A` / `-B`, exact bytes verified). - `logs/repro/attempts/vuln_variant_decorator_{request,response}.txt` — decorator vector output `b'1000\n'` (`id -u`). - `logs/repro/attempts/fixed_attempt_{1,2}_{request,response}.txt`, `negative_control_observation.json` — 403 `{"detail":"An API key must be passed as query or header"}`, marker absent. - `logs/repro/attempts/fixed_auto_attempt_1_{request,response}.txt` — 1.3.0 default deployment still executes (partial fix). **Environment:** Docker (overlay2) on linux/x86_64; official images `langflowai/langflow` v1.1.1 (digest `sha256:b56d4cfe18284e9fb2f1ec2d1bc9a29107a8c893397543e4937a55cda0136cd3`) and 1.3.0 (digest `sha256:8c124064a4410ceff7a7ffbee3aec393e3b9fb2e3e43a163b537074143a38ca5`); no sanitizers or instrumentation; plain HTTP via curl. **Exploit knowledge records (current run):** primitive `9199a628-4fa2-4c50-9517-54e12e959e07` (control_flow_influence — full attacker control of executed Python via the validate_code exec sink) and derived capability `c216aee7-41df-4f5f-b157-9fc7518764cf` (command_execution, generic_runtime attestation, two marker-backed process samples + negative control). ⚠️ Record `2a7dbd1c-4e51-4243-87b6-0e8bfb97fe28` in `bundle/repro/exploit_knowledge.json` was an accidental placeholder submission ("address_disclosure", title "Placeholder") and is **invalid — no address disclosure was observed in this run**; it must be ignored by downstream stages and is not part of any composition. ## Recommendations / Next Steps - **Fix:** stop `exec()`ing user-supplied code during validation. Validate with `ast.parse` + a strict allowlist walk over the AST (rejecting `exec`/`eval`/`__import__`/dunder attribute access anywhere in the tree — defaults, decorators, lambdas, comprehensions — not just function bodies), or run validation in a sandboxed subprocess with no network/fs and resource limits. Auth alone is not a fix. - **Upgrade guidance:** no complete fix exists in the disclosed range; 1.3.0+ only adds route auth, defeated by default `LANGFLOW_AUTO_LOGIN=true`. Set `LANGFLOW_AUTO_LOGIN=false` + API-key auth to at least force authentication, and restrict `/api/v1/validate/code` at the reverse proxy/WAF. - **Detection:** requests to `/api/v1/validate/code` whose body contains default-arg/decorator `exec(`/`eval(`/`__import__(`/`subprocess` patterns; EDR: Langflow python worker spawning shell children. ## Additional Notes - **Idempotency:** the script removes prior containers, reclaims ports 27860–27863, rewrites attempt files, and passed two consecutive clean runs (exit 0, all checks green each time). - **Limitations:** host ports are fixed (27860–27863) and must be free; the script checks and fails fast if occupied. First execution on a cold Docker cache pulls ~2 images (several minutes); subsequent runs reuse them. The 1.3.0 "fixed" image is a partial-fix control (route auth added), matching the advisory's affected/fix state — the exec sink itself remains exploitable post-auth, which is exactly what control B documents and what the vuln_variant stage can explore further. - Marker evidence files were extracted with `docker exec cat` (not `docker cp`) to preserve worker file ownership in the bundle. ### Reproduction - Reproduced: 2026-09-02T03:58:13.831Z - Duration: 3788s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00342 # or: pruva-verify CVE-2026-0768 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00342 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00342/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00342 ================================================================================ ## REPRO-2026-00341: JFrog Artifactory critical unauthenticated authentication bypass leading to administrative takeover -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00341 - CVE: CVE-2026-82329 (https://nvd.nist.gov/vuln/detail/CVE-2026-82329) ### Package Information - Name: JFrog Artifactory - Ecosystem: Unknown - Affected: Unknown - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-287 Improper Authentication (Improper Authentication) ### Root Cause # Root Cause Analysis — CVE-2026-82329: JFrog Artifactory Unauthenticated Authentication Bypass (Blank Join Key → Cluster-Join Service Admin Token) ## Summary JFrog Artifactory's Access service, in default configuration, registered a **blank (empty-string) join key** in its "additional join keys" verification cache. Join keys are the HMAC secrets used to authenticate cluster-join requests at the **unauthenticated** endpoint `POST /access/api/v1/registry/join`. Because `JoinKeyUtils.getSigningKey("")` pkcs7-pads the empty key to the constant `32 × 0x20`, every vulnerable instance accepts a join JWT signed with an attacker-known key. A successful join returns a **never-expiring service admin token** (`scope: "admin"`) for an attacker-chosen service id, which is then usable to dump users, reset the built-in administrator's password, and mint platform-wide admin user tokens — full administrative takeover starting from **zero valid credentials**. Fixed in 7.146.38 (and corresponding branches) by rejecting blank join keys. ## Impact - **Component**: JFrog Access service bundled with self-hosted JFrog Artifactory (verified on `artifactory-jcr` 7.146.25; internal Access 7.176.x). - **Affected versions** (vendor advisory): 7.111.4–7.111.20, 7.117.0–7.117.27, 7.125.0–7.125.19, 7.133.0–7.133.28, 7.146.0–7.146.36, 7.161.0–7.161.19. Fixed: 7.111.21 / 7.117.28 / 7.125.20 / 7.133.29 / 7.146.38 / 7.161.20. - **Risk**: CVSS 9.8 Critical (AV:N/AC:L/PR:N/UI:N). Any unauthenticated network attacker can obtain administrative control of the platform (user management, admin credential reset, admin token issuance), leading to full compromise of hosted artifacts and CI/CD supply chain. ## Impact Parity - **Disclosed/claimed maximum impact**: unauthenticated authentication bypass → administrative takeover (`authz_bypass`, CVSS C:H/I:H/A:H). - **Reproduced impact in this run**: identical — zero-credential admin takeover demonstrated end-to-end against the real product: 1. `POST /access/api/v1/registry/join` with a JWT signed with HMAC-SHA256 key `20*32` → **HTTP 201**, service admin token (`sub=jfrt@cve202682329poc…, scp=admin`). 2. `GET /access/api/v1/users` with that token → **HTTP 200**, full user list (including admin record). 3. `PUT /access/api/v1/users/admin` → **HTTP 200**, built-in admin password reset to an attacker-chosen value (account takeover). 4. `POST /access/api/v1/tokens {"username":"admin","scope":"applied-permissions/admin"}` → **HTTP 200**, admin user token (`sub=jfac@…/users/admin, scp=applied-permissions/admin, aud=*@*`). 5. `GET /artifactory/api/system/info` (admin-only) with that token → **HTTP 200** with full system internals. - **Parity: full.** No step used any pre-existing credential, account, or token; the chain starts from a raw unauthenticated HTTP request. ## Root Cause The fix was isolated by binary-diffing `artifactory-jcr:7.146.36` (last vulnerable) against `artifactory-jcr:7.146.38` (fixed). The **entire** payload difference is the Access service (7.176.27 → 7.176.28), and within it exactly two security-relevant classes changed (the rest are manifests/UI bundles): 1. `org/jfrog/access/server/startup/JoinKeyAccess.class` — `tryResolveJoinKeys()`: ```diff - Arrays.stream(joinKey.get().split(",")).map(String::trim).forEach(jKey -> { + Arrays.stream(joinKey.get().split(",")).map(String::trim).filter(Strings::isNotBlank).forEach(jKey -> { ``` 2. `org/jfrog/access/token/JoinKeyHashPair.class` — constructor: ```diff + if (joinKey == null || joinKey.isBlank()) { + throw new IllegalArgumentException("Join key must not be null or blank"); + } ``` Why the bug fires in **default configuration**: - `JoinKeyAccess.tryResolveJoinKeys()` resolves `shared.security.additionalJoinKeys`. When unset (default), `resolveJoinKeys()` returns `""` wrapped in a vavr `Try`. The guard `if (!joinKey.isEmpty())` calls **`Try.isEmpty()`**, which tests for failure/null — **not** string emptiness — so the empty default proceeds to `"".split(",")` → `[""]`, and a `JoinKeyHashPair("")` (blank join key) is registered in the additional-join-keys map under `kid = sha256("") = e3b0c442…b855`. - `JoinKeyUtils.getSigningKey("")` → `hexDecodeAndPad("", 32)` → pkcs7-pads to the constant 32-byte key `0x20 0x20 … 0x20` — **publicly derivable, identical on every default installation**. - The unauthenticated `RegistryNoAuthResource.join` (`POST /access/api/v1/registry/join`) → `JoinServiceImpl.getValidatedJwtToken()` → `getJoinKey(jwt)` → `joinKeyAccess.getTokenSignatureVerifiers(kid)`: with no `kid` claim it tries the main join key **plus every additional join key** (including the blank one); with `kid=e3b0c442…` it selects the blank key directly. An HS256 JWT signed with `32 × 0x20` therefore verifies. - On verification, `ServiceTokenProviderImpl.getToken(serviceId)` issues `TokenSpec … .scope("admin") .expiresIn(0)` via `createInternalTokenWithoutAuthAndNotify` — a platform-trusted, never-expiring **service admin token** for the attacker-chosen `service_id` claim. So one unauthenticated POST yields admin-level identity; trivial follow-ups (`PUT /access/api/v1/users/admin`, `POST /access/api/v1/tokens`) convert it into full administrative takeover of Artifactory. - Fix: JFrog advisory (CVE-2026-82329, published 2026-08-28). Patch is the two-class change above (blank-key rejection), present in Access 7.176.28 / Artifactory 7.146.38. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker, curl, python3). 2. The script: - Pulls `releases-docker.jfrog.io/jfrog/artifactory-jcr:7.146.25` (vulnerable), `:7.146.38` (fixed), and `postgres:16-alpine` (7.146.x refuses to start on the legacy embedded Derby DB). - Boots each Artifactory with a default-config `system.yaml` (external PostgreSQL only; **no** join key / additional join keys configured) plus a generated `master.key`, using `docker create` + `docker cp` + `docker start` (single-file bind mounts break JFrog's atomic `system.yaml` rewrite). - Runs `bundle/repro/exploit_join_bypass.py` against each instance: blank-key JWT join, Access admin operations, admin password reset, admin token mint, admin-only Artifactory API call, plus built-in controls (anonymous token mint must 401; wrong-signature join must 400). - Writes per-run evidence JSON, image IDs, version files, and `runtime_manifest.json`. 3. Expected evidence: vulnerable instance → join HTTP 201 with `scp=admin` token, admin takeover steps all 200, exploit JSON `"exploited": true`, script exit 0; fixed instance → join HTTP 400 (`JWT's signature does not match the server's join key`), `"exploited": false`. ## Evidence - `bundle/artifacts/http/vuln_exploit.json` — full request/response transcript of the successful exploit against 7.146.25 (join 201 + `scp=admin` token claims; users dump 200; admin password reset 200; admin user token claims `sub=jfac@…/users/admin, scp=applied-permissions/admin`; `/artifactory/api/system/info` 200; anonymous controls 401; wrong-signature join 400). - `bundle/artifacts/http/fixed_exploit.json` — identical attack against 7.146.38 rejected at the join step (HTTP 400, `"exploited": false`). - `bundle/artifacts/diff/JoinKeyAccess.diff`, `bundle/artifacts/diff/JoinKeyHashPair.diff` (+ full decompiled classes) — the two-class security patch between 7.146.36 and 7.146.38. - `bundle/logs/reproduction_steps.log` — orchestration log; `bundle/logs/art-{vuln,fixed}-docker.log`, `art-{vuln,fixed}-access-join.log` — service-side logs. - `bundle/artifacts/vuln_image_id.txt` / `fixed_image_id.txt`, `vuln_version.txt` / `fixed_version.txt` — tested target identity. - Environment: Docker (rootless), postgres:16-alpine sidecar, `artifactory-jcr:7.146.25` (Access 7.176.15) vs `artifactory-jcr:7.146.38` (Access 7.176.28), linux x86_64. ## Recommendations / Next Steps - **Upgrade** self-hosted Artifactory to 7.111.21 / 7.117.28 / 7.125.20 / 7.133.29 / 7.146.38 / 7.161.20 or later (per branch). - Interim mitigation: restrict network access to the Access/router endpoints (`/access/api/v1/registry/*`) to trusted networks; audit `access_nodes`/`access_audit` for unexpected service registrations and tokens (`scp=admin` with unknown `jfrt@…` subjects), and rotate the join key, master key, and the admin password after upgrading. - Fix approach (already shipped): reject null/blank join keys in `JoinKeyHashPair` and filter blank entries when parsing `additionalJoinKeys`. Additionally consider requiring a `kid` and binding join tokens to `node_id`/topology registration, and rate-limiting/auditing the no-auth join endpoint. - Testing: regression test that a default install has **no** additional join keys (`/access/api/v1/system/security/join_key` children) and that `registry/join` rejects empty-key HMAC JWTs. ## Additional Notes - Idempotency: the script tears down and recreates all containers/network each run and was executed twice consecutively with identical results (vulnerable exploited, fixed blocked). Each run generates fresh master keys, databases, node ids, and attacker service ids. - Limitations: verification used the JCR (Container Registry) image; repository-management REST (`/api/repositories`) is Pro-gated in JCR, so admin takeover was demonstrated via Access admin APIs + admin token mint + the admin-only `/artifactory/api/system/info` endpoint instead of repository creation. The vulnerable code lives in the shared Access service, so Pro/ProX distributions are equally affected. - The 30-second `iat` freshness check on join tokens (`MAX_REQUEST_AGE_IN_SECONDS`) is honored by minting the JWT at exploit time. ### Reproduction - Reproduced: 2026-09-01T13:05:52.523Z - Duration: 8715s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00341 # or: pruva-verify CVE-2026-82329 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00341 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00341/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00341 ================================================================================ ## REPRO-2026-00340: PaperCut NG/MF CVE-2026-81578 + CVE-2026-82078 unauthenticated RCE chain -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00340 - CVE: CVE-2026-81578 (https://nvd.nist.gov/vuln/detail/CVE-2026-81578) ### Package Information - Name: PaperCut NG/MF - Ecosystem: vendor - Affected: 25.0.11 build 75758 (tested) - Fixed: 25.0.12 build 76510 (Emergency Patch Release 2) - Severity: critical - CVSS: 9.4 / 10 - CWE: CWE-284 ### Root Cause # Root Cause Analysis — PaperCut NG/MF 25.0.11 unauthenticated remote code execution (27 Aug 2026 advisory) ## Summary Stock PaperCut NG/MF 25.0.11 (build 75758) allows a fully unauthenticated remote attacker to execute operating-system commands as the PaperCut application-service account (`papercut`). The chain combines two defects reachable through the pre-authentication application surface of the running Application Server: 1. **Tapestry 3 "complex" direct-service authorization bypass.** Tapestry's `DirectService` accepts `service=direct/1///`. It calls `validate()` (which fires PaperCut's `BasePaperCutPage.pageValidate` access-rights check) **only on the render page**, then triggers the listener on the *component page* without validating it. Using the public login page as render page (`Home`) and a privileged page as component page (`ConfigEditor`) yields unauthenticated arbitrary configuration writes (`quickFindForm` + `$Form`/`$Form$0` listeners invoke `ConfigManager.setString`). 2. **Unrestricted attacker-controlled SQL in the external Card/ID lookup.** `ExternalUserLookupDb.lookupUserByExternalCardNumber` opens a JDBC connection using the `user-lookup.db-driver` / `user-lookup.db-url` config keys and runs the `user-lookup.id-to-username-sql` template with the submitted card number bound as a parameter. With the bundled Apache Derby embedded driver the template may call `SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE`, giving an attacker-controlled arbitrary file write as the `papercut` user. The lookup is reached **before any login** through the web card/ID login flow (`auth.web-login.card-id.enable`) — `Home.login` → `AuthenticationManagerImpl.authenticateUserWithCard` → `UserManagerImpl.getUserByCardNumber` → `ExternalUserLookupManagerImpl` → `ExternalUserLookupDb`. File write is escalated to in-JVM code execution by planting a new Tapestry page (`WEB-INF/Pwn3.page` declaring `org.apache.tapestry.html.BasePage`, plus `Pwn3.html` containing an OGNL expression binding) into the live Jetty webapp extraction directory (`server/tmp/webapp-/`), then requesting `GET /app?service=page/Pwn3`. Tapestry loads the new page specification from the servlet context on first access and evaluates the OGNL expression (`@java.lang.Runtime@getRuntime().exec(...)`), executing the command in the Application Server JVM. The command's output is written into the webroot and fetched back over plain HTTP — a complete remote command receipt. The emergency 25.0.12 (build 76497) patch blocks the chain at the SQL layer: `ExternalUserLookupDb.createLookupSQLStatement` now rejects templates matching `(?i)\b(CALL|EXEC|EXECUTE)\b|\bSYSCS_[A-Z0-9_]*\b` with `ApplicationException("Unsafe external user lookup SQL blocked.")`. ## Impact - Package/component: PaperCut NG (and MF) Application Server, all 25.0.x <= 25.0.11 (and per the vendor advisory, effectively all supported branches until the emergency builds). - Risk: critical. Unauthenticated remote OS command execution as the PaperCut service account from the web port (9191/9192), including from the internet if exposed. Post-exploitation runs as `pc-app` child processes, matching the vendor's IoCs. ## Impact Parity - Disclosed/claimed maximum impact: unauthenticated remote code execution as the PaperCut application-service account via a pre-auth card/ID entrypoint. - Reproduced impact from this run: exactly that — `uid=1001(papercut) gid=1001(papercut)` command output (`id; uname -a; cat /proc/1/comm`) executed inside the Application Server container and retrieved remotely over HTTP. - Parity: **full**. - Nothing claimed was left undemonstrated. ## Root Cause Two chained defects: 1. `biz/papercut/pcng/web/pages/BasePaperCutPage.pageValidate` (25.0.11) only computes access rights for `this` — the page being validated. Tapestry 3's `DirectService.service()` validates only the *render* page (`cycle.activate(pageName)`) but then calls `componentPage.getNestedComponent (componentPath)` and `direct.trigger(cycle)` on a second page named later in the service path. `POST /app?service=direct/1/Home/ConfigEditor/$Form$0` (and `/quickFindForm`, `/$Form`) therefore fires ConfigEditor's form listeners (`doAddNew`, `doConfigEdit` → `ConfigManager.setString`) with no session at all. The 25.0.12 patch adds a loop over `service.split("/")` accumulating the rights of every embedded page — but note this check still only runs in `BasePaperCutPage.pageValidate`, and `Home` *overrides* `pageValidate` without calling `super`, so the config-write bypass via `direct/1/Home/...` in fact still lands on 25.0.12 (observed at runtime; the RCE chain is nonetheless blocked by the SQL filter below). 2. `biz/papercut/pcng/service/impl/ExternalUserLookupDb` built a `PreparedStatement` directly from the `user-lookup.id-to-username-sql` config value with no statement-type restriction, and opened the connection from config-controlled driver/URL (`DatabaseUtils.openConnection` → `Class.forName` + `DriverManager.getConnection`). Because the whole statement is attacker-controlled and the bundled Derby embedded driver supports `CALL SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE(?, ...)`, the card number (bound to the `?`) becomes an arbitrary SQL query whose CLOB result is written verbatim to an arbitrary filesystem path. The advisory IoCs fall out of this naturally: failed attempts log `Database error looking up cardID: VALUES CAST...` and driver probing logs `No suitable driver found for jdbc:...`. Fix commit: no public commit (commercial product). Vendor advisory: `https://www.papercut.com/kb/Main/security-bulletin-27-aug-2026-urgent-security-advisory` (emergency builds 25.0.12 build 76497 / 26.x; the FAQ documents the new EXEC/EXECUTE/CALL restriction). ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; requires docker + curl + python3; downloads the two official installers with vendor-published SHA-256 verification). 2. The script builds `pcng-vuln` (25.0.11.75758) and `pcng-fixed` (25.0.12.76497) images, starts both Application Servers, completes the stock setup wizard on each, performs one operator-side admin login + ConfigEditor render per server (models a normally operated server; warms Tapestry's pooled page/table state that the form-rewind path needs), and then runs the entire attack with **unauthenticated remote HTTP requests only**: - Phase A1: config write via `service=direct/1/Home/ConfigEditor/...` (arms `user-lookup.*` and `auth.web-login.card-id.*`). - Phase A2: two pre-auth card-login requests whose card number is `VALUES(CAST('' AS CLOB))`; the Derby export plants `WEB-INF/Pwn3.page` and `Pwn3.html` (OGNL payload) in the live webapp dir. - Phase A3: `GET /app?service=page/Pwn3` evaluates the OGNL payload. - Phase A4: `GET /pwn-proof.txt` retrieves the command output (remote receipt). - Phase B: identical request sequence against 25.0.12; verifies the armed lookup reaches the new filter (`Unsafe external user lookup SQL blocked`), no files are planted, no command receipt exists, and a benign non-CALL SQL template still executes (proving the feature works and only dangerous SQL is blocked). 3. Expected evidence: vuln proof file containing `PC0DAY-PROOF-BEGIN ... uid=1001(papercut) ... PC0DAY-PROOF-END` fetched over HTTP; fixed build logs the SQL block and returns 404 for the proof file. Exit code 0 = chain proven on 25.0.11 and blocked on 25.0.12. ## Evidence - Full run log: `bundle/logs/reproduction_steps.log` (two consecutive clean runs, both exit 0). - Remote command receipt (fetched over HTTP): `bundle/artifacts/vuln-proof.txt`. - Planted-page render response: `bundle/artifacts/vuln-render.html`. - Vulnerable server.log IoC excerpts (attacker payload visible in `Database error looking up cardID: VALUES(CAST(...)`): `bundle/artifacts/vuln-serverlog-ioc.txt`. - Fixed-build server.log excerpts showing `Unsafe external user lookup SQL blocked` plus the benign-SQL Derby error: `bundle/artifacts/fixed-serverlog-ioc.txt`. - Per-request transcripts: `bundle/artifacts/*-wiz*.html(.headers)`, `bundle/artifacts/fixed-configwrite.html(.headers)`, `bundle/artifacts/vuln-proof.headers`, etc. - Build identities: `bundle/artifacts/vuln-version.txt`, `bundle/artifacts/fixed-version.txt`; installer SHA-256s verified against the vendor advisory page (vuln 64495771…817a, fixed 0782c1d6…c392). - Runtime manifest: `bundle/repro/runtime_manifest.json`. - Patch localization (25.0.11 vs 25.0.12 class diff): `ExternalUserLookupDb` (SQL filter — the effective fix), `BasePaperCutPage` (service-path rights), `StandardRhinoContextFactory` (new Rhino ClassShutter blocking `java.lang.Class/ClassLoader/reflect/invoke`), `WebConfig` (path matching / response headers), `RestrictApiAccessFilter` (URI canonicalization). ## Recommendations / Next Steps - Upgrade to the emergency build (25.0.12 / 26.0.3+) per the vendor advisory. - The 25.0.12 fix blocks the RCE at the SQL layer, but the unauthenticated ConfigEditor write via Tapestry complex direct service was still observable on 25.0.12 in this lab (Home overrides `pageValidate` without invoking the new service-path check). Recommend PaperCut route the rights check through a path that Home cannot skip (e.g., validate the component page in `DirectService` itself or move checks into a servlet filter), and audit every privileged page listener for the same pattern. - Defense-in-depth: disallow `CALL`/`EXEC`/`SYSCS_` (already patched), consider allowing only plain `SELECT` templates; do not let `user-lookup.db-driver` accept arbitrary driver class names; disallow absolute paths in Derby export procedures via a Java SecurityManager-equivalent policy is no longer possible on JDK 21, so statement allow-listing is the right layer. - Network-level: keep the Application Server web interface off untrusted networks (vendor's immediate guidance). ## Additional Notes - Idempotency: the script was run end-to-end twice consecutively (fresh containers each time), both runs exited 0. Derby export procedures refuse to overwrite existing files, so each run uses fresh output paths (`/tmp/pc0day-a.csv`, `pc0day-b.csv`, unique per run) and fresh containers. - Precondition: the Tapestry form-rewind path used for the config write relies on the pooled ConfigEditor page/table state, which exists after the Config Editor page has been rendered once since server start (any normal admin UI usage). The script performs this as an operator-side action (setup wizard admin account); it is not part of the attacker's request sequence. On a production server that has ever opened Options → Config Editor this precondition holds naturally. - The card number is bound as a JDBC parameter (`?`), so no SQL-escaping of the payload is required; the entire export query rides in the card value. Content written to files must avoid single quotes or use Derby literal doubling; the OGNL payload sidesteps this by constructing strings from byte arrays. - The print/device-script (Rhino) route was not needed; 25.0.12 additionally shutters Rhino reflection (`StandardRhinoContextFactory`), closing the alternate script-based escalation. ### Reproduction - Reproduced: 2026-08-31T05:51:19.235Z - Duration: 9828s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00340 # or: pruva-verify CVE-2026-81578 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00340 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00340/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00340 ================================================================================ ## REPRO-2026-00339: bubblewrap: sandbox escape via /oldroot symlink traversal during setup — files created on host -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00339 - GHSA: GHSA-PXHW-H44J-8PFX (https://github.com/containers/bubblewrap/security/advisories/GHSA-pxhw-h44j-8pfx) ### Package Information - Name: bubblewrap - Ecosystem: Unknown - Affected: Unknown - Fixed: Unknown - Severity: high - CVSS: Unknown - CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory (Path Traversal) (Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) ### Root Cause # RCA Report — GHSA-pxhw-h44j-8pfx: bubblewrap sandbox escape via /oldroot symlink traversal during setup ## Summary During sandbox setup, bubblewrap (bwrap) mounts the host filesystem at `/oldroot` and builds the sandbox root at `/newroot`. When a setup operation that creates a file or directory (e.g. `--dir`, `--file`, `--bind-data`, `--ro-bind-data`) has a destination whose parent components contain a symbolic link, vulnerable versions resolve that symlink with plain `mkdir()`/`open()` path semantics. If the symlink lives in attacker-controlled filesystem content (such as a malicious Flatpak app image bound at `/`) and points at an absolute path under `/oldroot/...`, the creation is redirected out of the sandbox and onto the **host filesystem**. This happens during setup, before any sandboxed code runs. We reproduced the escape end-to-end with the real `bwrap` CLI: a directory and an attacker-content marker file were created on the host in 2/2 vulnerable attempts, while the fixed version failed closed in 2/2 attempts. ## Impact - **Package/component:** `bubblewrap` (`bwrap`), used by Flatpak and similar app frameworks. - **Affected versions:** `< 0.12.0` (confirmed on v0.11.0, commit `9ca3b05ec787acfb4b17bed37db5719fa777834f`). No backport exists; the fix is only in v0.12.0, which also drops setuid build support. - **Risk level and consequences:** High (advisory scores 8.8). An attacker who controls filesystem content that a launcher binds into the sandbox can cause bwrap to create directories and attacker-controlled files at arbitrary host paths writable by the launching uid/gid (generally unprivileged). This can overwrite/seed config files, startup scripts, SSH keys, etc., enabling further compromise. If bwrap is invoked by a privileged (setuid) launcher, the write is privileged. ## Impact Parity - **Disclosed/claimed maximum impact:** sandbox escape — files created on the host outside the sandbox during setup (CWE-22 path traversal). - **Reproduced impact from this run:** sandbox escape — `--dir` created `/subdir/newdir` on the host and `--file` wrote a fully attacker-controlled marker file (`ESCAPE_MARKER.txt`) into it, outside the sandbox, through the real `bwrap` CLI entrypoint, in both vulnerable attempts. - **Parity:** `full` - **Not demonstrated:** privileged (root) file creation — the run used an unprivileged launcher, matching the advisory's typical (non-setuid) scenario; this is a deployment precondition, not a gap in the vulnerability proof. ## Root Cause In v0.11.0, `setup_newroot()` (bubblewrap.c:1189) processes each setup op and, for ops with a destination, computes `dest = get_newroot_path(op->dest)` which returns the absolute path `/newroot/` (utils.c:868). It then calls `mkdir_with_parents(dest, parent_mode, false)` (bubblewrap.c:1233) and, for file-creating ops, `ensure_dir()`/`ensure_file()`/`open()` on the resulting path. `mkdir_with_parents()` (utils.c:705) walks the path component by component calling `mkdir()`, which **follows symlinks** in parent components with full host-kernel resolution. At that point in setup, bwrap has already `pivot_root()`ed into a scratch tmpfs (bubblewrap.c:3345) so that the namespace root contains `oldroot/` (a bind of the host filesystem) and `newroot/` (the sandbox root under construction). An absolute symlink inside attacker-controlled content that was bound at `/` (= `/newroot`) therefore resolves against the setup namespace root: a symlink `/subdir -> /oldroot/tmp/.../host_target` makes `mkdir /newroot/subdir/newdir` create `/oldroot/tmp/.../host_target/newdir`, i.e. a directory on the **host**. The same applies to `--file`/`--bind-data`/ `--ro-bind-data`, which additionally write attacker-controlled content. `resolve_symlinks_in_ops()` (bubblewrap.c:1615) only realpaths the **source** paths of bind mounts, not the **destination** paths of file/dir-creating ops, so it does not mitigate this. **Fix:** v0.12.0 (tag `2a76602a8c71f36c1527cf9fc3417d9149822e0c`) resolves all destination paths with `openat2(RESOLVE_IN_ROOT)` via `safe_openat()` imported from crun (commit `67d4be103b18706b5b4e3f495daa35e89e47b163`, with a `chroot_realpath.c` fallback for pre-5.6 kernels) together with commit `ea185f6fb135782cabab342e33432e8482a2f5c9` ("Inline the privileged ops"), so symlink resolution is confined to `/newroot` and escaping symlinks fail with ENOENT instead of being followed onto the host. Advisory: https://github.com/containers/bubblewrap/security/advisories/GHSA-pxhw-h44j-8pfx ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; run from any directory, exit 0 = confirmed). 2. What the script does: - Reads `bundle/project_cache_context.json` and reuses the prepared project cache (`/repo`), falling back to `bundle/artifacts/`. - Clones `containers/bubblewrap`, verifies the vulnerable commit (v0.11.0 = `9ca3b05e...`) lacks fix commits `67d4be10...`/`ea185f6f...` and the fixed commit (v0.12.0 = `2a76602a...`, the ticket's fixed tag) contains them; also checks the source trees (`safe_openat.c` absent/present). - Builds both versions with the system `gcc` (no sudo in this environment: libcap headers are obtained by downloading the `libcap-dev` .deb and extracting it; the only required `config.h` macro, `PACKAGE_STRING`, is generated; no sanitizer is used — this is a plain product build). - Smoke-tests that unprivileged user namespaces work. - Runs the advisory recipe hermetically, twice per build: creates `untrusted/subdir -> /oldroot` and invokes `bwrap --bind / --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 --dir /subdir/newdir --file 3 /subdir/newdir/ESCAPE_MARKER.txt /usr/bin/true` with a known marker content on fd 3. - Verifies per attempt: vulnerable builds must exit 0 **and** leave `/newdir/ESCAPE_MARKER.txt` on the host with the exact attacker content; fixed builds must exit non-zero **and** leave nothing. - Writes `bundle/repro/runtime_manifest.json` with SHA-256 of every proof artifact. 3. Expected evidence: vulnerable attempts show the marker file on the host; fixed attempts log `bwrap: Can't mkdir parents for /subdir/newdir: No such file or directory` and create nothing on the host. ## Evidence - `bundle/logs/attempt-vuln-1.log`, `bundle/logs/attempt-vuln-2.log` — bwrap v0.11.0 exits 0, sandboxed `/usr/bin/true` runs, and the post-run listing shows `host_target/newdir/ESCAPE_MARKER.txt` with the attacker-controlled content `BWRAP_OLDROOT_ESCAPE_`. - `bundle/logs/attempt-fixed-1.log`, `bundle/logs/attempt-fixed-2.log` — bwrap v0.12.0 exits 1 with `Can't mkdir parents for /subdir/newdir: No such file or directory`; host target remains empty. - `bundle/repro/work/vuln-{1,2}/host_target/newdir/ESCAPE_MARKER.txt` — the actual marker files created on the host by the vulnerable binary. - `bundle/repro/proof_summary.txt` — per-attempt verdicts and binary SHA-256s. - `bundle/repro/runtime_manifest.json` — entrypoint `cli_command`, `target_path_reached=true`, proof artifacts with SHA-256 hashes. - Environment: Linux 6.8.0-138-generic x86_64, uid 1000 (unprivileged), `kernel.unprivileged_userns_clone=1`, user namespaces functional (see `bundle/logs/smoke_test.log`). Openat2-capable kernel (>= 5.6), so the fixed build exercises the real `RESOLVE_IN_ROOT` path. Key excerpt (vulnerable attempt): ``` command: timeout 30 .../bwrap-v0.11.0 --bind .../untrusted / --ro-bind /usr /usr ... --dir /subdir/newdir --file 3 /subdir/newdir/ESCAPE_MARKER.txt /usr/bin/true --- exit_code: 0 host_target (post-run): .../host_target/newdir: -rw-rw-rw- 1 pruva pruva ... ESCAPE_MARKER.txt host marker content: BWRAP_OLDROOT_ESCAPE_20260826T200804Z ``` Key excerpt (fixed attempt): ``` --- exit_code: 1 bwrap: Can't mkdir parents for /subdir/newdir: No such file or directory host marker content: ``` ## Recommendations / Next Steps - **Fix approach (upstream, already shipped):** confine all destination path resolution to the new root using `openat2(RESOLVE_IN_ROOT)` (`safe_openat()`), with the `chroot_realpath` fallback on pre-5.6 kernels — commits `67d4be103b18706b5b4e3f495daa35e89e47b163` and `ea185f6fb135782cabab342e33432e8482a2f5c9` in v0.12.0. - **Upgrade guidance:** upgrade to bubblewrap >= 0.12.0. There is no backport for versions that support setuid builds; users relying on setuid bwrap should migrate to unprivileged user namespaces. Until upgraded, avoid binding attacker-controlled filesystem content into sandboxes (audit Flatpak-style launchers for `--bind /` combined with file/dir-creating options). - **Testing recommendations:** regression-test every file/dir-creating option (`--dir`, `--file`, `--bind-data`, `--ro-bind-data`, `--chmod`, bind dest auto-creation) with parent symlinks targeting both `/oldroot/...` (absolute) and relative `..` chains; the variant-analysis stage covers these. ## Additional Notes - **Idempotency:** the script was run four consecutive times (two before the manifest-ordering fix, two after), all exiting 0; per-attempt work directories are wiped and recreated (`rm -rf`) so runs are hermetic and repeatable. Cache reuse (repo/build/tools) makes reruns take ~2 s. - **No sudo/pip in this environment:** the build avoids meson (not installable without root) by compiling the four (vuln) / six (fixed) upstream C files directly with gcc; this is byte-identical upstream source at the anchored commits, and binary SHA-256s are recorded in `proof_summary.txt`. - **Non-sanitized product proof:** the primary oracle is real product behavior (host filesystem state + CLI exit codes), not ASAN/UBSAN. - **"Host" scope:** bwrap was executed directly on this machine (not nested in Docker), so `/oldroot` is the real machine root and the marker files land in the real filesystem outside any bwrap namespace. - **Edge cases not covered here (delegated to variant analysis):** `--file`, `--bind-data`, `--ro-bind-data` as the *primary* creating op without `--dir`; relative (`../../oldroot`) symlink targets; `--symlink`-created parents; pre-5.6 kernel fallback path of the fix. ### Reproduction - Reproduced: 2026-08-26T21:14:47.510Z - Duration: 1108s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00339 # or: pruva-verify GHSA-PXHW-H44J-8PFX ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00339 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00339/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00339 ================================================================================ ## REPRO-2026-00338: Apache Log4j2 serialized LogEvent filter bypass to conditional RCE -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00338 - GHSA: GHSA-LOG4J2-4255-MARSHALLEDOBJECT (https://github.com/advisories/GHSA-LOG4J2-4255-MARSHALLEDOBJECT) ### Package Information - Name: org.apache.logging.log4j:log4j-core - Ecosystem: maven - Affected: log4j-api 2.11.0-2.26.1; log4j-core 2.8.0-2.26.1 (verified against official 2.26.1 jars from Maven Central) - Fixed: none yet - upstream issue open, waiting-for-maintainer label; no CVE/GHSA assigned - Severity: critical - CVSS: Unknown - CWE: CWE-502 (Deserialization of Untrusted Data) ### Root Cause # Root Cause Analysis ## Summary Apache Log4j2 2.26.1 and earlier permits an allowlist bypass in `FilteredObjectInputStream` (FOIS) because `java.rmi.MarshalledObject` is itself allowlisted. A serialized `Log4jLogEvent.LogEventProxy` can carry attacker-controlled inner serialized bytes that are automatically unpacked by `MarshalledObject.get()` through another `ObjectInputStream`. In this run, one exact payload was replayed through the real Apache `TcpSocketServer` / `ObjectInputStreamLogEventBridge` TCP product path and executed an attacker-selected OS command in two fresh receiver JVMs. The same bytes were rejected by two receivers using the minimal patched API. ## Impact - **Affected components:** `log4j-api` 2.11.0 through 2.26.1 and `log4j-core` 2.8.0 through 2.26.1 when used by a FOIS-based serialized-event receiver. - **Validated product path:** `org.apache.logging.log4j.server.TcpSocketServer` with `ObjectInputStreamLogEventBridge` from Apache `logging-log4j-samples` commit `672a1555c7f5670e7affcc7b9984a90b492eb322`, running official Log4j API/Core 2.26.1 artifacts. - **Risk:** Critical where this unauthenticated serialized-event listener is reachable and a usable gadget library is on the receiver classpath. An attacker can bypass the intended class allowlist and execute commands with the receiver process account. ## Impact Parity - **Disclosed/claimed maximum impact:** Remote unauthenticated arbitrary deserialization leading to code execution. - **Reproduced impact from this run:** Remote receiver-side command execution through the required TCP entrypoint. A single generated CC6 payload file was replayed unchanged to two vulnerable JVM instances and produced process-unique `PWNED-TIER2` markers containing receiver-side shell and parent process IDs plus account identity. - **Parity:** `full`. - **Not demonstrated:** Privilege escalation beyond the account running the receiver was not claimed or tested. ## Root Cause Vulnerable `log4j-api` includes `java.rmi.MarshalledObject` in `SerializationUtil.REQUIRED_JAVA_CLASSES`. FOIS checks outer class descriptors in `resolveClass()`, but the object graph stored by `MarshalledObject` is opaque bytes at that stage. During deserialization of `Log4jLogEvent.LogEventProxy`, `readResolve()` calls `message()`, which calls `marshalledMessage.get()`. That operation deserializes the inner graph in another `ObjectInputStream`, outside FOIS's class-name check. A non-allowlisted object can therefore execute its `readObject()` callback, and a receiver-loadable gadget graph can reach `Runtime.exec`. The tested minimal correction removes `java.rmi.MarshalledObject` from `REQUIRED_JAVA_CLASSES`, causing FOIS to reject the carrier before its inner bytes are unpacked. `bundle/logs/fix.patch` records the exact change. The upstream report is https://github.com/apache/logging-log4j2/issues/4255; it was still open at reproduction time and did not identify a merged fix commit, so the negative control applies the report's minimal suggested fix to official 2.26.1 source. ## Reproduction Steps 1. Run `bash bundle/repro/reproduction_steps.sh` from any directory. The script uses `PRUVA_ROOT` or resolves the bundle path itself. 2. It reads `bundle/project_cache_context.json`, uses `/repo` when prepared, verifies the Apache samples origin and exact commit, and extracts the real `log4j-server` sources. 3. It verifies official Log4j 2.26.1 and Commons Collections 3.2.1 jar hashes, compiles the real `TcpSocketServer` and `ObjectInputStreamLogEventBridge` classes, and records their runtime `CodeSource` paths. 4. It generates each serialized payload once and sends the exact file bytes over localhost TCP. It performs direct unwrapped-object rejection, wrapped inner deserialization twice, wrapped CC6 command execution twice, the same tier-2 bytes against two patched receivers, and the same bytes against `-Djdk.serialFilter=!java.rmi.MarshalledObject`. 5. It validates all assertions and writes `bundle/repro/runtime_manifest.json`. Exit 0 means the claim is confirmed. ## Evidence - **Exact identity:** `bundle/logs/target_identity.txt` binds samples commit `672a1555c7f5670e7affcc7b9984a90b492eb322`, Log4j commit `dd0f9d255e24e6bcc13bd2641407a409c0524803`, official API/Core jar hashes, target digest `7310f18dd2601851bcc37b1963906cbd86f417a18b196251c8fe64a6b3b18673`, and runtime digest `98b16715ef49ca21a5b32b7184a718cb9110ddc5269b06be0c3e3167467df639`. - **Exact attacker input:** `bundle/logs/payload_identity.txt` records the SHA-256 of each pre-generated input. The final tier-2 input has SHA-256 `4e3a504e6d390c37a57253a323d3d91382f8ef92caa95a9e2c2193f38a707217`; sender logs prove identical byte count/file reuse across vulnerable, patched, and mitigated attempts. - **Real TCP/product boundary:** `bundle/logs/receiver_tier2_a1.log` and `receiver_tier2_a2.log` identify loaded `TcpSocketServer`, `ObjectInputStreamLogEventBridge`, vulnerable API/Core jars, and Commons Collections; they then show binding, connection acceptance, socket details, and the deserialized event. - **Command execution:** `bundle/logs/tier2_rce_marker_a1.txt` contains token `RCE-1787737383615632389-15376`, `shell_pid=39677`, and `parent_pid=39626`; attempt a2 contains the same payload token but distinct `shell_pid=39759` and `parent_pid=39708`. Both include `uid=1000(vscode)`, proving execution in two fresh receiver contexts. - **Direct control:** `bundle/logs/receiver_control.log` shows `ObjectInputStreamLogEventBridge.logEvents()` rejecting unwrapped `poc.GadgetOnly` through `FilteredObjectInputStream.resolveClass()`. - **Fixed controls:** `bundle/logs/receiver_fixed_a1.log` and `receiver_fixed_a2.log` show TCP acceptance followed by `InvalidObjectException: Class is not allowed for deserialization: java.rmi.MarshalledObject`; the shared command target is absent. - **Mitigation:** `bundle/logs/receiver_mitigation.log` shows the same TCP/readObject path failing with `InvalidClassException: filter status: REJECTED` and no marker. - **Digest closure:** `bundle/repro/runtime_manifest.json` binds 29 finalized proof artifacts to SHA-256 digests and records `entrypoint_kind=tcp_peer`, service/health/path success, and full target/runtime identity. ## Recommendations / Next Steps Remove `java.rmi.MarshalledObject` from the default Log4j deserialization allowlist and avoid unfiltered `MarshalledObject.get()` for event messages; use a filtered wrapped-object format instead. Upgrade to the first vendor release containing the final upstream correction once published. Do not expose Java serialized-event listeners to untrusted networks. As defense in depth, configure a JEP 290 class filter rejecting `java.rmi.MarshalledObject`, remove unnecessary gadget libraries, and add regression tests that replay a malicious event through the real TCP receiver and require fail-closed rejection. ## Additional Notes The final script was executed successfully twice consecutively after all amendments. Every execution uses isolated receiver JVMs and randomized ports, removes prior proof artifacts, generates a fresh payload token, and verifies exact payload-byte reuse across vulnerable and controls. Service startup, TCP health, and target-path reachability are tracked independently, and a failed run preserves digest-bound evidence from completed phases. The launcher does not implement a socket, parser, or `readObject()` path; it only selects serialized mode through `TcpSocketServer.createSerializedSocketServer()`. ### Reproduction - Reproduced: 2026-08-26T19:58:13.909Z - Duration: 4411s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00338 # or: pruva-verify GHSA-LOG4J2-4255-MARSHALLEDOBJECT ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00338 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00338/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00338 ================================================================================ ## REPRO-2026-00337: Keycloak reset-credentials flow: unauthenticated account takeover (CWE-640) -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00337 - CVE: CVE-2026-18963 (https://nvd.nist.gov/vuln/detail/CVE-2026-18963) ### Package Information - Name: org.keycloak:keycloak-services (Maven) - Ecosystem: Unknown - Affected: Unknown - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-640 Weak Password Recovery Mechanism for Forgotten Password ### Root Cause # CVE-2026-18963 — Keycloak reset-credentials unauthenticated account takeover (CWE-640) ## Summary Keycloak's "reset credentials" (forgot-password) flow can be pivoted by an unauthenticated attacker onto any victim account. Two defects combine: (1) the authenticator-selection ("try another way") state is stored as a plain boolean auth note (`AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED="true"`), so a simple GET refresh of the reset-credentials execution URL re-renders the selector screen for the *current* flow execution; and (2) `ResetCredentialEmail.action()` blindly calls `context.success()` without verifying that the request actually arrived via the emailed action token (`ACTION_TOKEN_USER_ID` auth note). An attacker who initiates a password reset for a victim's username can therefore re-enter the selector screen after the reset email was sent to the victim, POST to the now-reachable `reset-credential-email` execution without any action token, force the flow forward to the `UPDATE_PASSWORD` required action, and set an attacker-chosen password on the victim account — a full unauthenticated account takeover. The attacker never sees the victim's reset email. ## Impact - Package/component: `org.keycloak:keycloak-services` (`services` module: `org.keycloak.authentication.DefaultAuthenticationFlow`, `org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail`) - Affected: Keycloak upstream < 26.4.15, 26.5.x, 26.6.0–26.6.5, 26.7.0–26.7.1 (verified at source level); Red Hat build of Keycloak 26.4/26.6 before container 26.6-12; RH-SSO 7; JBoss EAP Expansion Pack (per advisory RHSA-2026:56519/56523/56524) - Verified affected: `quay.io/keycloak/keycloak:26.7.1` (source tag 26.7.1, commit 73f08b397f193712b26d317210dce99898129709) - Verified fixed: `quay.io/keycloak/keycloak:26.7.2` (source tag 26.7.2, contains backport of fix commit cf6e4c8be318f1e38c4001730fe6db6930dad050) - Risk: critical (CVSS 9.1). Any unauthenticated remote attacker who knows a victim's username can take over the account (new password + authenticated session), provided the realm has "forgot password" enabled — a default, common configuration. ## Impact Parity - Disclosed/claimed maximum impact: unauthenticated account takeover (authz_bypass). - Reproduced impact from this run: unauthenticated account takeover — the victim's password was replaced without possessing the reset email link, an authenticated session (authorization code) as the victim was issued, and the new password was verified against the token endpoint (HTTP 200) while the old password stopped working (HTTP 400). - Parity: `full`. - Not demonstrated: nothing material — the claimed impact was fully reproduced. ## Root Cause In the vulnerable code (`services/src/main/java/org/keycloak/authentication/DefaultAuthenticationFlow.java`, tag 26.7.1): 1. When the user clicks "try another way", `processAction()` stores `AUTHENTICATION_SELECTOR_SCREEN_DISPLAYED = "true"` (a boolean), not bound to any execution. 2. `processFlow()` checks only `Boolean.parseBoolean(note)` plus a non-null `CURRENT_AUTHENTICATION_EXECUTION` note. After the attacker posts the victim's username, `ResetCredentialEmail.authenticate()` sends the reset email and calls `context.forkWithSuccessMessage(EMAIL_SENT)`; the FORK handling sets `CURRENT_AUTHENTICATION_EXECUTION` to the *email execution id*. A subsequent GET refresh of the original reset-credentials URL therefore re-renders `createSelectAuthenticatorsScreen(emailExecution)`, whose HTML form action leaks the email execution UUID (`execution=`). 3. `ResetCredentialEmail.action()` (vulnerable) is: ```java public void action(AuthenticationFlowContext context) { context.getUser().setEmailVerified(true); context.success(); } ``` Posting to the leaked email-execution URL (no action token required) invokes this blind success, the required-elements chain continues to `reset-password` / `UPDATE_PASSWORD`, and the attacker sets a new password on the victim identity attached to the authentication session by `ResetCredentialChooseUser`. Fix (upstream commit `cf6e4c8be318f1e38c4001730fe6db6930dad050`, PR #51844, backported to 26.4.15 / 26.6.6 / 26.7.2): - The selector note now stores the execution model id, and `processFlow()` only re-renders the selector when the note matches `CURRENT_AUTHENTICATION_EXECUTION` (otherwise the note is removed) — the GET refresh after the email fork no longer re-renders the selector screen. - `ResetCredentialEmail.action()` now requires `user.getId().equals(authSession.getAuthNote(DefaultActionTokenKey.ACTION_TOKEN_USER_ID))`, i.e. success is only possible through the emailed action token; otherwise the flow fails with `INVALID_USER`. The upstream regression test `ResetPasswordTest.resetPasswordTryAnotherWay()` served as the PoC blueprint for the flow-selector re-entry. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; helper `bundle/repro/smtp_sink.py` is a promoted required artifact). 2. The script: - Verifies at source level that tag 26.7.1 lacks and tag 26.7.2 contains the fix hunk. - Starts a dependency-free Python SMTP sink container (captures the victim's reset email, proving the action-token link is generated and sent only to the victim). - Starts Keycloak 26.7.1 (vulnerable), provisions realm `cvetest` (`resetPasswordAllowed=true`, SMTP → sink), user `victim` (email `victim@cvetest.local`, password `OldPass123!`), and a public direct-grant client `atk-cli` used only for verification. - Runs the full browser-equivalent attack with curl (cookie jar, per-page rotating `session_code` honored): login page → forgot-password → POST `tryAnotherWay=on` → POST `username=victim` → GET-refresh the original reset URL → POST the leaked email execution URL → POST the new password. - Asserts the pivot: step-5 refresh re-renders `kc-select-credential-form`, step-6 returns `302 → login-actions/required-action?execution=UPDATE_PASSWORD`, step-7 returns `302 → /account/?...&code=...` (attacker session as victim), and the token endpoint returns 200 for the new password / 400 for the old one. - Repeats the attack twice (password restored between attempts via admin API). - Repeats the same procedure twice against Keycloak 26.7.2 (fixed): the refresh must re-render the "You should receive an email shortly" page (no selector) and the victim password must remain unchanged. - Writes `bundle/repro/runtime_manifest.json` on every exit (trap) and streams all output to `bundle/logs/reproduction_steps.log`. 3. Expected evidence: `2/2` vulnerable attempts EXPLOITED, `2/2` fixed attempts BLOCKED, exit code 0. ## Evidence - `bundle/logs/reproduction_steps.log` — full transcript of both phases. - `bundle/logs/vuln-attempt-{1,2}.log` — per-attempt exploit transcripts. Key excerpts (attempt 1): - `step5 GET refresh -> selector markers=1 email-sent markers=0` and the re-rendered selector form action now contains `execution=0089b76d-...` (the email execution, different from the choose-user execution `141b34d5-...` used in steps 2–4). - `step6 POST email execution (no action token) -> HTTP/1.1 302 Found Location: .../login-actions/required-action?execution=UPDATE_PASSWORD...` — the blind `context.success()` fired without any action token. - `step7 new password -> HTTP/1.1 302 Found Location: http://localhost:8080/realms/cvetest/account/?session_state=...&code=...` — the attacker is issued an authorization code as `victim`. - `step8 takeover verify: NEW password token HTTP 200, OLD password token HTTP 400`. - `bundle/logs/fixed-attempt-{1,2}.log` — `step5 GET refresh -> selector markers=0 email-sent markers=1`; old password still 200, new password 400 → BLOCKED. - `bundle/logs/smtp-capture-final.log` — the captured "Reset password" email addressed `To: victim@cvetest.local` containing the real `/realms/cvetest/login-actions/action-token?key=eyJ...` link (which the attacker never receives). - `bundle/logs/http/-/step*.html|*.hdr` — raw HTTP responses/headers for every step of every attempt. - `bundle/logs/keycloak-vuln.log`, `bundle/logs/keycloak-fixed.log` — server logs; `server-version` reported by `/admin/serverinfo` was 26.7.1 / 26.7.2 respectively. - `bundle/repro/runtime_manifest.json` — runtime identity (image digests `quay.io/keycloak/keycloak@sha256:f1f1f01e…` vulnerable, `…@sha256:83133051…` fixed), attempt results, proof artifact list. - Environment: rootless Docker 27.5.1 on Linux x86_64; Keycloak `start-dev` (H2), hostname `http://localhost:8080`; no sanitizers involved (product runtime proof). ## Recommendations / Next Steps - Upgrade to a fixed build: upstream Keycloak ≥ 26.4.15 / ≥ 26.6.6 / ≥ 26.7.2; Red Hat build of Keycloak 26.6-12 container or later (RHSA-2026:56519, 56523, 56524). - The upstream fix is the correct approach: bind the selector-screen auth note to the execution model id and require the `ACTION_TOKEN_USER_ID` note in `ResetCredentialEmail.action()`. - Regression testing: keep `ResetPasswordTest.resetPasswordTryAnotherWay()` and add an end-to-end test asserting that a GET refresh after the email fork cannot surface the email execution's action URL. - Defense-in-depth: consider invalidating or single-using the email execution state once the action token is issued, and rate-limiting reset-credential initiations per username/IP. ## Additional Notes - Idempotency: the script is fully idempotent — it re-creates the docker network and containers, truncates the SMTP capture, and restores the victim's password between attempts. Verified by two consecutive successful runs (exit 0 both times). - The exploit uses only unauthenticated endpoints; no SMTP access, no victim interaction, and no knowledge beyond the victim's username are required. The victim's email delivery is not even required to succeed for the pivot (Keycloak forks the flow with a success message even when sending fails), but this reproduction proves real email generation via the SMTP capture. - The fork behavior (`forkWithSuccessMessage` → cloned tab on the browser flow) is why the legitimate "email sent" page is displayed on a cloned authentication-session tab while the original tab retains the selector note and the email execution as current — the exact state the GET refresh abuses. - The verification client `atk-cli` (public, direct grants) exists only to prove the password change; it plays no role in the exploit path itself. ### Reproduction - Reproduced: 2026-08-24T11:14:38.647Z - Duration: 3287s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00337 # or: pruva-verify CVE-2026-18963 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00337 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00337/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00337 ================================================================================ ## REPRO-2026-00336: NLTK <3.10.3 RCE in AllowlistUnpickler — validates pickle module string but not global name; dotted-name traversal escapes allowlist to reach arbitrary callables -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00336 - CVE: CVE-2026-71513 (https://nvd.nist.gov/vuln/detail/CVE-2026-71513) ### Package Information - Name: nltk/nltk - Ecosystem: github - Affected: nltk < 3.10.3 - Fixed: 3.10.3 - Severity: high - CVSS: Unknown - CWE: CWE-502 (Deserialization of Untrusted Data) ### Root Cause # RCA Report: CVE-2026-71513 — NLTK AllowlistUnpickler Dotted-Name Traversal RCE ## Summary NLTK before 3.10.3 ships `nltk.picklesec.AllowlistUnpickler`, a `pickle.Unpickler` subclass meant to safely load untrusted model/data pickles by allowing only audited globals. Its `find_class(module, name)` hook validated **only the module string** against a prefix allowlist (`allowed_modules`) / exact-pair allowlist (`allowed_globals`) and never inspected `name`. For pickle protocol >= 4, `pickle.Unpickler.find_class` resolves the global by `getattr`-chaining the (possibly dotted) `name` starting from the imported module. An attacker can therefore keep the module string inside an allowlisted namespace (e.g. `nltk.tokenize`) while putting the escape into the *name*: `stanford_segmenter.os.system`. The allowlist passes, the dotted traversal reaches `os.system`, and a following `REDUCE` executes an arbitrary shell command while NLTK loads the "model". Fixed in NLTK 3.10.3. ## Impact - Package/component: `nltk` — `nltk.picklesec.AllowlistUnpickler`, reached via the public data-loading entrypoints `nltk.tokenize.punkt.punkt_pickle_load` (legacy Punkt pickle models, allowlist `("nltk.tokenize.punkt", "nltk.tokenize")`) and `nltk.parse.transitionparser.TransitionParser` model loading (allowlist `("numpy", "scipy", "sklearn")`). - Affected versions: nltk < 3.10.3 (confirmed on 3.10.2). - Risk: high — arbitrary code execution with the privileges of the Python process that loads an attacker-controlled pickle (e.g. a downloaded "compatible" Punkt model or parser model file). ## Impact Parity - Disclosed/claimed maximum impact: code execution (RCE). - Reproduced impact from this run: code execution — the attacker command `echo PRUVA_RCE_ > ` ran via `os.system` on 2/2 vulnerable attempts through the real public entrypoint; marker contents verified. - Parity: `full`. - Not demonstrated: nothing material — the claimed impact was demonstrated end-to-end against the real library API. ## Root Cause `nltk/picklesec.py` (3.10.2), `AllowlistUnpickler.find_class`: ```python def find_class(self, module: str, name: str) -> Any: if (module, name) in self._allowed_globals or self._module_allowed(module): return super().find_class(module, name) raise pickle.UnpicklingError(...) ``` Only `module` is checked against the prefix allowlist. The base-class implementation for protocol >= 4 does: ```python __import__(module) return _getattribute(sys.modules[module], name) # getattr-chains "a.b.c" ``` so `name="stanford_segmenter.os.system"` with `module="nltk.tokenize"` resolves `nltk.tokenize.stanford_segmenter` (a submodule that `import os`) → `os` → `system`, a callable the module allowlist never intended to expose. NLTK 3.10.3 fixes this in `nltk/picklesec.py` by rejecting dotted and dunder names before resolution (Guard 1/2), adding a denied-module prefix backstop (`os`, `subprocess`, `builtins`, `nltk.internals`, ...) that applies even under a broad allowlist (Guards 3–5), and re-checking the resolved object's true `__module__`/`__qualname__` after resolution (`_resolve`). Fix reference: GHSA-4489 / GHSA-x99w hardening in `nltk.picklesec` (nltk 3.10.3 release). ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; reuses the prepared project cache for wheels/site dirs, falling back to `pip` + a local artifacts dir). 2. The script installs `nltk==3.10.2` (vulnerable) and `nltk==3.10.3` (fixed) into isolated `--target` site dirs, verifies the dotted-name guard is absent in 3.10.2 and present in 3.10.3, then runs `bundle/repro/harness.py` twice per side. The harness crafts a protocol-4 pickle `REDUCE(GLOBAL("nltk.tokenize", "stanford_segmenter.os.system"), (cmd,))` and feeds it to the real public entrypoint `nltk.tokenize.punkt.punkt_pickle_load`. 3. Expected evidence: each vulnerable attempt creates `repro/marker_vuln_.txt` containing `PRUVA_RCE_vuln` (harness exit 10); each fixed attempt raises `UnpicklingError: ... has a dotted name, which is forbidden` and creates no marker (harness exit 11). Script exits 0 only if 2/2 + 2/2 hold. ## Evidence - `bundle/logs/reproduction_steps.log` / `reproduction_steps_run2.log` — full script output for two consecutive runs (both exit 0). - `bundle/logs/harness_vuln_{1,2}.log` — `nltk=3.10.2`, `punkt_pickle_load returned: 0`, `MARKER CONTENT: PRUVA_RCE_vuln`, `RESULT: VULNERABLE - attacker command executed`. - `bundle/logs/harness_fixed_{1,2}.log` — `nltk=3.10.3`, `BLOCKED with UnpicklingError: global 'nltk.tokenize.stanford_segmenter.os.system' has a dotted name, which is forbidden (attribute-traversal pickle RCE, GHSA-4489)`. - `bundle/repro/marker_vuln_{1,2}.txt` — files created by the attacker command. - `bundle/repro/payload_{vuln,fixed}{1,2}.pickle` — exact 87-byte malicious pickles used. - Environment: Python 3.14.4, pip 25.1.1, linux x86_64. Vulnerable wheel `nltk-3.10.2-py3-none-any.whl` sha256 `2c7ccacb765c5e26b0cb60fb1b57080af522c6924d12a714a243305ba3637412`; fixed wheel `nltk-3.10.3-py3-none-any.whl` sha256 `ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c`. - `bundle/repro/runtime_manifest.json` — structured runtime evidence (`entrypoint_kind=function_call`, `target_path_reached=true`). ## Recommendations / Next Steps - Upgrade to nltk >= 3.10.3. - Fix approach (already upstream): reject dotted/dunder global names before resolution; apply a denied-module backstop even under prefix allowlists; re-verify the resolved object's true `__module__`/`__qualname__`; refuse module-object results. - Defense in depth for downstream users: never load pickle data from untrusted or unauthenticated sources even behind an allowlisting unpickler; prefer non-pickle model formats. - Testing: regression-test that `find_class` rejects `("nltk.tokenize", "stanford_segmenter.os.system")`, `("sklearn", "os.system")`, in-namespace gadgets (`numpy.f2py.crackfortran.myeval`, `ReppTokenizer._execute`), and that legitimate single-qualname model pickles still load. ## Additional Notes - Idempotency: `reproduction_steps.sh` ran twice consecutively, both exit 0; the second run reused the cached site dirs/wheels. - The escape gadget `stanford_segmenter.os.system` works because `nltk.tokenize/__init__.py` imports the `stanford_segmenter` submodule, which itself does `import os`; any allowlisted package with an `os`-importing submodule in its attribute tree is equally exposed (e.g. `sklearn.os.system` per the upstream regression test). - No sanitizer or mock was used; the proof executes the real library code path and observes a real command side effect. ### Reproduction - Reproduced: 2026-08-23T15:44:28.210Z - Duration: 1419s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00336 # or: pruva-verify CVE-2026-71513 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00336 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00336/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00336 ================================================================================ ## REPRO-2026-00335: MLflow unauthenticated full-read SSRF in webhook delivery via redirect-follow bypass of _validate_webhook_url guard -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00335 - CVE: CVE-2026-64849 (https://nvd.nist.gov/vuln/detail/CVE-2026-64849) ### Package Information - Name: mlflow/mlflow - Ecosystem: PyPI - Affected: Official advisory range is mlflow < 3.15.0. The URL guard exists in 3.10.0 through 3.14.x and is bypassable by redirects or DNS rebinding; versions before 3.10.0 lack this guard and permit easier SSRF. - Fixed: mlflow==3.15.0 - Severity: critical - CVSS: Unknown - CWE: CWE-918 Server-Side Request Forgery (Server-Side Request Forgery (SSRF)) ### Root Cause ## Summary CVE-2026-64849 is confirmed in the real default MLflow Tracking Server API. MLflow 3.13.0 validates only the configured webhook URL, then its Requests session follows an HTTPS `302` redirect to an unvalidated private address. An unauthenticated caller can create that webhook and invoke `POST /api/2.0/mlflow/webhooks/{id}/test`; MLflow fetches the private resource and reflects its body. The same current-run procedure against MLflow 3.15.0 reaches the public HTTPS redirector but blocks the redirected connection before sending HTTP to the private service. ## Impact - **Affected component:** MLflow Tracking Server model-registry webhook API and `mlflow/webhooks/delivery.py`. - **Affected versions:** Upstream advisory scope is MLflow versions before 3.15.0. This run directly proves 3.13.0 vulnerable and 3.15.0 fixed. - **Risk level and consequences:** Critical, unauthenticated full-read SSRF. A remote caller can read HTTP resources reachable from the MLflow host, including private network and cloud metadata-style services, and receive those bytes in the API response. ## Impact Parity - **Disclosed/claimed maximum impact:** Unauthenticated full-read SSRF through the default remote API, with internal response data reflected to the caller. - **Reproduced impact from this run:** The real MLflow 3.13.0 API fetched `http://172.30.49.10:9000/latest-meta-data` after a validation-safe HTTPS redirect and returned `PRUVA_INTERNAL_SSRF_CANARY_CVE_2026_64849` with `success: true` and `response_status: 200`. - **Parity:** `full` - **Not demonstrated:** The 307/308 blind-write variant and DNS-rebinding variant were not needed for impact parity and were not tested here. No code execution or privilege escalation is claimed. ## Root Cause Three behaviors compose into the vulnerability: 1. The default `mlflow server` does not load the optional authentication plugin, so webhook creation and test routes are reachable without credentials. 2. `_validate_webhook_url` validates the original webhook hostname and rejects its initially resolved non-public addresses, but the validated address is not bound to subsequent connections. 3. In 3.13.0, `_send_webhook_request` uses a normal Requests session, and `session.post(webhook.url, ...)` follows redirects by default. The redirect target is not passed back through `_validate_webhook_url`, so a public HTTPS first hop can redirect to RFC1918, loopback, or link-local HTTP. The fix is upstream commit [`ba949522477cbd5915aa55d29b0cfad7d5ddf939`](https://github.com/mlflow/mlflow/commit/ba949522477cbd5915aa55d29b0cfad7d5ddf939), “Fix DNS-rebinding SSRF bypass in webhook delivery (#24258).” It introduces `SSRFProtectedHTTPAdapter`, validates the peer address of every actual connection (including a redirect connection), and disables environment proxy handling for the webhook session. MLflow 3.15.0 contains this code. In the fixed control, the initial HTTPS endpoint was successfully contacted, but connecting to the redirected private peer produced `SSRFProtectionError('Webhook connection blocked: 172.30.49.10 is not a public IP address...')`. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` from any working directory. Docker, Git, curl, OpenSSL, and Python 3 are required. 2. The script reads `bundle/project_cache_context.json`, reuses `/repo` when prepared, verifies exact upstream tag SHAs and the fix hunk, and pulls official MLflow images: - vulnerable: `ghcr.io/mlflow/mlflow@sha256:b2136d49f882fdc9c48bdb95514a8a23804e8756524135c3f09f7a553a3ac58b` (`mlflow.__version__ == 3.13.0`) - fixed: `ghcr.io/mlflow/mlflow@sha256:2fef60dd85b18b4a555325b861b50b43ede45b1cc67176aa711cc86ad446a6a3` (`mlflow.__version__ == 3.15.0`) 3. It creates two isolated Docker bridges: a public-class network containing a trusted HTTPS redirector at `93.184.216.34`, and an RFC1918 network containing a canary at `172.30.49.10` and MLflow at `172.30.49.20`. 4. It launches the real default Tracking Server with SQLite and no auth plugin, waits for `/health`, creates a webhook through `POST /api/2.0/mlflow/webhooks`, and invokes `POST /api/2.0/mlflow/webhooks/{id}/test` without credentials. 5. It requires the 3.13.0 response to contain the private canary and requires the private service request count to increase. It then performs the identical procedure on 3.15.0, requires connection-time private-peer rejection, and requires the private service request count not to increase. 6. It writes `bundle/repro/runtime_manifest.json`, including SHA-256 closure over all immutable proof artifacts. Exit code 0 means the vulnerable/fixed behavioral delta was confirmed. ## Evidence - **Runtime manifest:** `bundle/repro/runtime_manifest.json` - **Pinned identities:** `bundle/logs/repro/image-identities.txt` - **Vulnerable API request/response:** - `bundle/logs/repro/vuln/test-wire.txt` - `bundle/logs/repro/vuln/test-response.json` - **Fixed API request/response:** - `bundle/logs/repro/fixed/test-wire.txt` - `bundle/logs/repro/fixed/test-response.json` - **Production service logs:** - `bundle/logs/repro/vuln/mlflow-service.log` - `bundle/logs/repro/fixed/mlflow-service.log` - **Redirect and private-service evidence:** - `bundle/logs/repro/vuln/redirector.log` - `bundle/logs/repro/fixed/redirector.log` - `bundle/logs/repro/final-canary.log` - `bundle/logs/repro/{vuln,fixed}/canary-private-requests-{before,after}.txt` Key vulnerable response: ```json { "result": { "success": true, "response_status": 200, "response_body": "PRUVA_INTERNAL_SSRF_CANARY_CVE_2026_64849\n" } } ``` The private request counter changed from `0` to `1` during the vulnerable test. The fixed response instead contains: ```text SSRFProtectionError('Webhook connection blocked: 172.30.49.10 is not a public IP address...') ``` The fixed counter remained `1` before and after its test, while the redirector log gained the second `POST /redirect`; this proves the negative control trusted and reached the first hop but sent no HTTP request to the private service. Both final consecutive executions of the script passed. No sanitizer or instrumentation was used. ## Recommendations / Next Steps - Upgrade to MLflow 3.15.0 or later. - Validate the actual peer of every connection, including redirects and retries, rather than only resolving and checking the original URL. - Ensure proxy configuration cannot bypass destination-peer validation, and preserve TLS verification against the original hostname. - Require authentication and authorization for webhook creation, modification, and testing even when MLflow is deployed with otherwise default settings. - Keep regression coverage for 301/302/303/307/308 redirects to loopback, RFC1918, link-local, IPv6-local, and mapped-address targets, plus DNS rebinding and proxy paths. ## Additional Notes The script is self-contained aside from standard tools and immutable remote images/repository objects that it fetches itself. It generates its own short-lived test CA and certificate, scopes trust to the MLflow test containers, creates fresh SQLite state, cleans child containers/networks on exit, and was verified twice consecutively in its final form. The public-class IP exists only inside an isolated Docker bridge; the private canary is a deterministic stand-in for an internal metadata endpoint, while all vulnerable logic and API behavior come from the unmodified official MLflow product image. ### Reproduction - Reproduced: 2026-08-23T15:44:22.550Z - Duration: 2463s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00335 # or: pruva-verify CVE-2026-64849 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00335 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00335/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00335 ================================================================================ ## REPRO-2026-00334: Authenticated command injection in pm2panel's /restart handler allows remote shell command execution on the host. -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00334 - CVE: CVE-2026-72573 (https://nvd.nist.gov/vuln/detail/CVE-2026-72573) ### Package Information - Name: 4xmen/pm2panel - Ecosystem: GitHub / Node.js web application - Affected: All versions (version 0 affected per CVE record, defaultStatus unknown) - Fixed: None identified - no fix available - Severity: high - CVSS: Unknown - CWE: CWE-78 (OS Command Injection) (Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')) ### Root Cause # CVE-2026-72573: Command Injection in pm2panel /restart Endpoint ## Summary The `4xmen/pm2panel` web application contains an OS command injection vulnerability in the authenticated `/restart` HTTP endpoint. The handler at line 188 of `pm2panel.js` concatenates the attacker-controlled `req.query.id` query parameter directly into a `child_process.exec()` shell command (`exec("pm2 restart " + req.query.id)`). Because `exec` invokes `/bin/sh`, shell metacharacters such as `;`, `&&`, `|`, backticks, or `$()` in the `id` parameter are interpreted by the shell, allowing an authenticated attacker to execute arbitrary commands on the host with the privileges of the pm2panel process. ## Impact - **Package/component affected:** `4xmen/pm2panel` (Node.js web application, `pm2panel.js`) - **Affected versions:** All versions (commit `dd2a7d2e8cb4dacefb618ab54b0a8f7dc6742fa0`, latest on `master`) - **Risk level:** High — authenticated remote code execution - **Consequences:** An attacker with valid panel credentials (default: `admin`/`admin`) can execute arbitrary shell commands on the host server, leading to full system compromise, data exfiltration, lateral movement, or service disruption. ## Impact Parity - **Disclosed/claimed maximum impact:** Code execution (CWE-78, OS command injection) - **Reproduced impact from this run:** Full arbitrary command execution — a marker file was created on the host filesystem via an injected `touch` command through the authenticated `/restart` endpoint - **Parity:** `full` - **Not demonstrated:** N/A — the full claimed impact was demonstrated ## Root Cause The `/restart` route handler in `pm2panel.js` (line 188) constructs a shell command by directly string-concatenating the user-supplied `req.query.id` query parameter: ```js app.get('/restart', function (req, res) { if (!req.session.islogin) { // redirect to login... } else { if (req.query.id) { exec("pm2 restart " + req.query.id, (error, stdout, stderr) => { // ... }); } } }); ``` `child_process.exec()` spawns a shell (`/bin/sh -c`) to run the command. The `req.query.id` value is never validated, sanitized, or shell-escaped. When an attacker sends a request like `GET /restart?id=0; touch /tmp/pm2panel_pwned`, the shell interprets the `;` as a command separator and executes `touch /tmp/pm2panel_pwned` in addition to `pm2 restart 0`. The same vulnerable pattern exists in four other handlers: - `/start` (line 218): `exec("pm2 start " + req.query.id)` - `/stop` (line 248): `exec("pm2 stop " + req.query.id)` - `/delete` (line 278): `exec("pm2 delete " + req.query.id)` - `/addProccess` (line 149): `exec('pm2 start "' + req.body.path + '"')` No fix commit has been identified; the vulnerability is present in the latest commit on `master`. ## Reproduction Steps 1. **Reference script:** `bundle/repro/reproduction_steps.sh` 2. **What the script does:** - Clones/reuses the `4xmen/pm2panel` repository from the project cache - Installs system dependencies (`libpam0g-dev`) and npm dependencies (including native `node-linux-pam` module) - Installs and starts PM2 with a demo process (id 0) - Starts the pm2panel Express web application on port 3001 - Authenticates via `POST /loginCheck` with default credentials (`admin`/`admin`) - Sends an authenticated `GET /restart?id=0; touch /tmp/pm2panel_pwned_` request - Verifies the marker file was created (proving arbitrary command execution) - Runs negative controls: unauthenticated request is rejected (302 redirect), safe request without injection does not create a marker 3. **Expected evidence of reproduction:** - Marker file exists at `/tmp/pm2panel_pwned_*` after the exploit request - HTTP 302 response from the exploit endpoint (normal redirect behavior) - Unauthenticated requests return 302 redirect to `/login` - Safe restart requests (no injection) do not create marker files ## Evidence - **Log files:** - `bundle/logs/reproduction_steps.log` — full script execution log - `bundle/logs/pm2panel_service.log` — pm2panel application server log - `bundle/logs/artifacts/http/response_login.txt` — login response with session cookie - `bundle/logs/artifacts/http/request_exploit.txt` — exploit request details - `bundle/logs/artifacts/http/response_exploit.txt` — exploit HTTP response (302) - `bundle/logs/artifacts/http/marker_evidence.txt` — marker file existence and stat output - `bundle/logs/artifacts/http/response_unauth.txt` — unauthenticated request response (302 redirect) - `bundle/logs/artifacts/http/safe_restart_status.txt` — safe restart response code - `bundle/repro/runtime_manifest.json` — structured runtime evidence manifest - **Key excerpts:** - Exploit request: `GET /restart?id=0;%20touch%20/tmp/pm2panel_pwned_3850 HTTP/1.1` - Exploit response: `HTTP/1.1 302 Found` with `Location: /` - Marker evidence: `MARKER_FILE_EXISTS=true` with `stat` output showing file creation timestamp - Negative control (unauthenticated): `302` redirect to `/login` - Negative control (safe): no marker file created - **Environment:** - Node.js v24.18.0, npm 11.16.0 - PM2 v7.0.3 - pm2panel commit `dd2a7d2e8cb4dacefb618ab54b0a8f7dc6742fa0` - Linux x86_64, Express 4.x, express-session ## Recommendations / Next Steps 1. **Fix:** Replace `child_process.exec` with `child_process.execFile` (which does not invoke a shell) and pass `req.query.id` as a separate argument array, or validate `req.query.id` against a strict numeric regex before use. 2. **Defense in depth:** Implement input validation on all endpoints that accept process IDs (`/start`, `/stop`, `/delete`, `/addProccess`). 3. **Authentication:** Change default credentials from `admin`/`admin` and enforce strong password policies. 4. **Upgrade guidance:** No patched version exists. Users should apply the fix manually or discontinue use of the panel. 5. **Testing:** Add integration tests that send shell metacharacters in query parameters and assert they are not interpreted by the shell. ## Additional Notes - **Idempotency:** The script uses process-specific marker file names (with `$$` PID suffix) and cleans up PM2 processes and the pm2panel server on each run. It was verified to pass on two consecutive executions. - **Authentication requirement:** The vulnerability requires authentication. The script performs a proper login flow with `POST /loginCheck` and session cookie extraction before sending the exploit request. - **Multiple vulnerable endpoints:** The same command injection pattern affects `/start`, `/stop`, `/delete`, and `/addProccess` in addition to `/restart`. The reproduction focuses on `/restart` as specified in the claim. ### Reproduction - Reproduced: 2026-08-23T15:44:16.384Z - Duration: 854s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00334 # or: pruva-verify CVE-2026-72573 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00334 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00334/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00334 ================================================================================ ## REPRO-2026-00333: Crypt::OpenSSL::PKCS12 before 1.98 can crash with a NULL pointer dereference when `info_as_hash()` parses a crafted PKCS#12 containing a zero-length BMPSTRING attribute. -------------------------------------------------------------------------------- Status: published Severity: medium Type: security ### Identifiers - REPRO ID: REPRO-2026-00333 - CVE: CVE-2026-17510 (https://nvd.nist.gov/vuln/detail/CVE-2026-17510) ### Package Information - Name: dsully/perl-crypt-openssl-pkcs12 - Ecosystem: CPAN - Affected: Versions before 1.98 - Fixed: 1.98 - Severity: medium - CVSS: Unknown - CWE: CWE-476 NULL Pointer Dereference (NULL Pointer Dereference) ### Root Cause # Root Cause Analysis — CVE-2026-17510 ## Summary Crypt::OpenSSL::PKCS12 before 1.98 contains a NULL pointer dereference in the `V_ASN1_BMPSTRING` branch of `print_attribute()` (PKCS12.xs). When `info_as_hash()` parses a crafted PKCS#12 file containing a zero-length BMPSTRING bag attribute, `Renew(*attribute, 0, char)` (Perl's `safesysrealloc`) frees the destination buffer and returns `NULL`; the `NULL` is stored back into `*attribute`, and the downstream caller runs `newSVpvn(attribute_value, strlen(attribute_value))`, dereferencing `NULL` inside `strlen()` and causing a deterministic SIGSEGV (exit status 139). ## Impact - Package: `Crypt-OpenSSL-PKCS12` (CPAN), XS binding to OpenSSL's PKCS12 API. - Affected versions: all versions before 1.98 (vulnerable code confirmed at commit `5934ce7fe7c4683c8d9a08edcbd0c6871a52945f`, the parent of the fix). - Risk: medium — remote/unauthenticated denial of service of any Perl process that calls `info_as_hash()` on attacker-supplied PKCS#12 data. The `info()` path is unaffected (it uses the `BIO_printf` branch and never calls `Renew`). ## Impact Parity - Disclosed/claimed maximum impact: denial of service (process crash via NULL dereference). The advisory explicitly scopes impact to DoS; no memory disclosure or code execution is claimed. - Reproduced impact from this run: deterministic SIGSEGV (exit 139, core dumped) in the real library function `info_as_hash()` on the vulnerable build, twice in a row; fixed build returns the attribute as the empty string and completes normally, twice in a row. - Parity: **full** — the claimed DoS impact was demonstrated exactly. - Not demonstrated: nothing beyond the claim; no code execution was claimed or attempted. ## Root Cause In `print_attribute()` (pre-fix PKCS12.xs, line ~666): ```c value = OPENSSL_uni2asc(av->value.bmpstring->data, length); if (*attribute != NULL) { Renew(*attribute, length, char); /* length == ASN.1 byte length */ strncpy(*attribute, value, length); } ``` For a normal BMPSTRING this is benign because `OPENSSL_uni2asc()` returns a NUL-terminated ASCII string and `strncpy` zero-pads the oversized buffer. For an **empty** BMPSTRING (`length == 0`) it degenerates: Perl's `safesysrealloc` treats a zero size as free-and-return-NULL, so `Renew(*attribute, 0, char)` frees the buffer, `*attribute` becomes `NULL`, `strncpy(NULL, value, 0)` writes nothing, and the downstream `dump_certs_pkeys_bag` / `print_attribs` code calls `newSVpvn(attribute_value, strlen(attribute_value))` on the `NULL` pointer — a deterministic NULL dereference in `strlen()`. Only `info_as_hash()` reaches this branch because it passes a non-NULL hash, making `*attribute` non-NULL. Fix commit: https://github.com/dsully/perl-crypt-openssl-pkcs12/commit/6cb282d8d8e8ded4859551cd2d3cfa7c6028ce48 The fix sizes the buffer with `strlen(value) + 1` (never zero), copies with `memcpy`, writes an explicit terminator, and adds a NULL check on the `OPENSSL_uni2asc()` return value. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; run from anywhere, honors `PRUVA_ROOT`). 2. The script: - Clones `dsully/perl-crypt-openssl-pkcs12` (uses the prepared project cache mirror when available, GitHub otherwise). - Resolves `VULN_COMMIT = 6cb282d...^` = `5934ce7fe7c4...` and `FIXED_COMMIT = 6cb282d8d8e8ded4859551cd2d3cfa7c6028ce48`, and verifies the vulnerable tree contains the pre-fix `Renew(*attribute, length, char)` hunk while the fixed tree contains the `strlen(value) + 1` fix. - Installs the pure-Perl configure dependency `Crypt::OpenSSL::Guess` into a bundle-local `INSTALL_BASE` if missing. - Builds the XS module from both commits with `perl Makefile.PL && make`. - Extracts the crafted fixture `certs/bmpstring-empty.p12` from the fixed commit (it ships there as the regression-test fixture: a certBag whose bag attribute at OID `1.2.3.4.6` is a zero-length ASN.1 BMPSTRING, password `Password1`, SHA-256 MAC) and validates it with `openssl pkcs12 -info`. - Runs `Crypt::OpenSSL::PKCS12->new_from_file(...)->info_as_hash('Password1')` twice against the vulnerable build and twice against the fixed build. 3. Expected evidence: both vulnerable attempts die with SIGSEGV (exit 139, core dumped, `INFO_AS_HASH_RETURNED` never printed); both fixed attempts print `attribute 1.2.3.4.6 value=<>` and `INFO_AS_HASH_RETURNED` with exit 0. ## Evidence - `bundle/logs/reproduction_steps.log` — full script transcript, including: - `[*] vuln-attempt-1 exit=139` / `Segmentation fault (core dumped)` and `timeout: the monitored command dumped core` - `[*] vuln-attempt-2 exit=139` - `[*] fixed-attempt-1 exit=0` → `attribute 1.2.3.4.6 value=<>`, `INFO_AS_HASH_RETURNED` - `[*] fixed-attempt-2 exit=0` → same - `bundle/logs/vuln-attempt-{1,2}.log`, `bundle/logs/fixed-attempt-{1,2}.log` — per-attempt output. - `bundle/logs/build-vuln.log`, `bundle/logs/build-fixed.log` — build logs. - `bundle/repro/runtime_manifest.json` — machine-readable runtime evidence (`entrypoint_kind=function_call`, `target_path_reached=true`, commit and digest identity). - Environment: Perl 5.38.2 (x86_64-linux-gnu-thread-multi), OpenSSL 3.0.13 (module linked against system libssl/libcrypto), gcc, Ubuntu noble. - A `Data::Dumper` dump of `info_as_hash()` on the fixed build confirms `'bag_attributes' => { '1.2.3.4.6' => '' }` — the empty-string return the advisory predicts for the patched version. ## Recommendations / Next Steps - Upgrade to Crypt-OpenSSL-PKCS12 1.98 or later. - The upstream fix (size on `strlen(value) + 1`, explicit terminator, NULL check on `OPENSSL_uni2asc()`) is correct and verified by this run. - Services accepting untrusted PKCS#12 uploads should not call `info_as_hash()` on unpatched versions; sandboxing the parse in a disposable process limits DoS blast radius. ## Additional Notes - Idempotency: the script was executed twice consecutively; both runs exited 0 with identical verdicts. Re-runs reuse the local mirror and rebuild both worktrees from scratch (`rm -rf` + fresh `git worktree add`). - No sanitizer was used; the crash is a product-visible native SIGSEGV from the real XS library (`sanitizer_used=false`). - The crafted fixture is not synthesized by this run: it is the exact regression fixture `certs/bmpstring-empty.p12` shipped in the upstream fix commit, so the attacker input is byte-identical to what upstream used to prove the bug. - The claim surface is `library_api` / `function_call`; the proof invokes the real published library entry points (`new_from_file`, `info_as_hash`) through the module's own compiled XS code, matching the claim contract. ### Reproduction - Reproduced: 2026-08-23T15:39:12.158Z - Duration: 1067s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00333 # or: pruva-verify CVE-2026-17510 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00333 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00333/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00333 ================================================================================ ## REPRO-2026-00332: Fledge backup upload shell command injection -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00332 - CVE: CVE-2026-71284 (https://nvd.nist.gov/vuln/detail/CVE-2026-71284) ### Package Information - Name: fledge-iot/fledge - Ecosystem: github - Affected: CVE.org lists versions 0 through 3.1.0 as affected; the ticket requires proof on 3.1.0. - Fixed: Unknown - Severity: high - CVSS: Unknown - CWE: Unknown ### Root Cause ## Summary Fledge 3.1.0 is vulnerable to authenticated OS command injection in the production backup-upload REST handler. An administrator can upload a syntactically valid `.tar.gz` archive whose first member starts with `fledge_backup_`, ends with `.db`, and contains shell metacharacters between those accepted strings. `upload_backup()` extracts that member, concatenates its path into `cp {source} {backup_path}`, and executes the result with `os.system()`. A real Fledge service consequently runs the embedded shell command as its `fledge` service user. ## Impact - **Product/component:** `fledge-iot/fledge`, specifically `python/fledge/services/core/api/backup_restore.py::upload_backup()` and `POST /fledge/backup/upload`. - **Affected release tested:** Fledge **3.1.0**, tag and commit `f90ffc2047ee49a380ada98a59fcc2985bd6a943` (database schema 75). The ticket reports versions through 3.1.0 as affected. - **Required attacker position:** An authenticated Fledge administrator with access to the backup-upload endpoint. - **Risk:** High. The administrator can execute arbitrary local shell commands with the identity and filesystem access of the Fledge service account. In the reproduced deployment that identity was UID 10001, user/group `fledge`. - **Consequence demonstrated:** Deterministic command execution through the network API, including attacker-selected file creation and content. ## Impact Parity - **Disclosed/claimed maximum impact:** Authenticated remote OS command execution as the Fledge service user. - **Reproduced impact:** Two fresh vulnerable Fledge 3.1.0 service instances accepted authenticated crafted uploads. Each spawned `/bin/sh -c` with the attacker-controlled member filename and created a distinct attacker-selected marker as `fledge`. - **Parity:** `full`. - **Not demonstrated:** No privilege escalation beyond the Fledge service account, lateral movement, persistence, or outbound network behavior was attempted or required. These are not part of the claimed maximum impact. ## Root Cause The handler applies validation to two different filename layers: 1. The outer multipart filename must start with `fledge_backup_` and end in `.tar.gz`. 2. At least one archive member must start with `fledge_backup_` and end in `.db` or `.dump`. For archives compatible with older Fledge versions, the handler calls `tar_file.extractall(temp_path)`, assigns `backup_file_name = tar_file_names[0]`, and builds `source = temp_path + "/" + backup_file_name`. The prefix/suffix checks do not reject shell syntax inside the accepted member name. The vulnerable code then performs: ```python cmd = "cp {} {}".format(source, backup_path) ret_code = os.system(cmd) ``` `os.system()` invokes a command shell. Consequently, characters such as `;`, redirections, and `#` in `source` become shell syntax instead of literal filename bytes. The proof member retains the accepted prefix and `.db` suffix while placing `;printf ...;sleep 2;#` between them. The generated shell command runs `printf`, and the comment suppresses the remainder of the `cp` command. No upstream fixed release or fix commit was identified in the ticket or the tested repository state. The reproduction therefore labels its negative control as a **same-version patched control**, not as an upstream fixed version. That control changes only the unsafe copy block to `shutil.copy2(source, backup_path)`. It accepts the same request and copies the literal filename without interpreting its metacharacters, proving that the shell sink is causal. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` from any directory. The script resolves `PRUVA_ROOT`, uses `/pruva/project-cache/repo` when the prepared cache contract permits it, and otherwise falls back to `bundle/artifacts/fledge`. 2. The script checks out exact commit `f90ffc2047ee49a380ada98a59fcc2985bd6a943`, verifies the vulnerable source statements, and builds the real Fledge product in a pinned Ubuntu 22.04 base image. Compilation is bounded to two jobs. The runtime includes the normal rsyslog socket and starts Fledge through its shipped `bin/fledge start` flow. 3. It generates deterministic minimal archives at runtime and exercises: - one normal valid backup upload (marker must remain absent); - two fresh vulnerable service instances with distinct crafted archives and markers; - two fresh same-version patched-control instances with the exact corresponding archive bytes (requests succeed, literal files are copied, and markers remain absent). 4. Each service must become healthy at `/fledge/ping`, report version 3.1.0 with mandatory authentication, and accept a normal `admin` login before the upload. 5. The vulnerable requests must return HTTP 200, produce service debug logs showing the generated `cp` command, produce `strace` evidence of `/bin/sh -c`, show the shell as a child of the Fledge core process, and create markers owned by `fledge`. 6. Expected final output includes: ```text [+] CONFIRMED: authenticated remote OS command execution through Fledge 3.1.0 backup upload API [+] Vulnerable markers: PRUVA_CVE_2026_71284_ATTEMPT_1, PRUVA_CVE_2026_71284_ATTEMPT_2 [+] Benign and same-version patched controls reached the endpoint without marker execution ``` The final script was executed twice consecutively and returned exit status 0 on both runs. ## Evidence Current-run evidence is under `bundle/logs/repro/cve-2026-71284/`; `bundle/repro/runtime_manifest.json` binds every finalized artifact to its SHA-256. Key artifacts include: - `proof_summary.json` — concise result, exact commit and image identities, four fresh process/container identities, two successful vulnerable markers, and negative-control outcomes. - `source_identity.json` — repository, v3.1.0 commit, vulnerable file digest, and canonical source target digest. - `runtime_image_identity.txt` — pinned base, vulnerable image ID, patched-control image ID, and Docker inspection output. - `vulnerable_1_health_response.json` and `vulnerable_2_health_response.json` — real service health, version 3.1.0, and mandatory authentication. - `vulnerable_{1,2}_login_request.json` / `login_response.json` — normal administrator authentication with current tokens redacted from retained proof. - `vulnerable_{1,2}_upload_request.json` / `upload_response.json` — authenticated production endpoint transactions. - `vulnerable_{1,2}_service.log` — handler debug output containing attacker-controlled `source` and the constructed shell command. - `vulnerable_{1,2}_execve_trace.log` and `shell_lineage.log` — syscall/process evidence. A representative excerpt is: ```text execve("/bin/sh", ["sh", "-c", "cp /var/lib/fledge/upload/fledge_backup_...db;printf PRUVA_CVE_2026_71284_ATTEMPT_1 >/tmp/pruva_cve_2026_71284_attempt_1;sleep 2;#.db /var/lib/fledge/backup"], ...) ``` - `vulnerable_1_process_tree_during_attack.txt` — live lineage showing PID 155 `python3 -m fledge.services.core` (user `fledge`) parenting PID 579 `sh -c ...`, which parents `sleep 2`. - `vulnerable_{1,2}_marker.txt` — exact attacker-selected marker bytes. - `vulnerable_{1,2}_marker_stat.txt` — marker ownership and mode. Both record `uid=10001 user=fledge ... group=fledge`. - `benign_control_*` — a conventional valid backup succeeds without a marker and appears in the backup directory. - `patched_{1,2}_negative_control.json`, `patched_{1,2}_execve_trace.log`, `patched_{1,2}_service.log`, and `patched_{1,2}_backup_listing.txt` — the same crafted archive reaches the same endpoint in the same release, but no attacker command is passed to `execve`; the source is copied as a literal filename and no marker appears. - `patched_control_source.txt` — exact behavior-preserving control statement using `shutil.copy2`. - `bundle/logs/reproduction_steps.log` and `bundle/logs/fledge-image-build.log` — diagnostics for the final run; these are not hashed as immutable proof while active. The final runtime manifest records `entrypoint_kind="endpoint"`, `service_started=true`, `healthcheck_passed=true`, `target_path_reached=true`, exact source commit, source target digest, vulnerable runtime image digest, Linux/x86-64 platform, and 101 finalized proof artifacts. ## Recommendations / Next Steps 1. Remove the shell from the copy operation. Prefer `shutil.copy2(source, backup_path)` or another API that treats source and destination as literal path arguments. If an external utility is unavoidable, invoke it with an argument vector and `shell=False`; do not build a shell command string. 2. Validate and normalize every archive member before extraction. Reject absolute paths, `..` traversal, links, device entries, control characters, and filenames outside a narrowly defined grammar. Extract only explicitly accepted members rather than calling unrestricted `extractall()`. 3. Do not rely solely on `startswith()`/`endswith()` for a security boundary. A suitable allowlist should constrain the entire backup basename, for example a known timestamp pattern and the exact `.db`/`.dump` extension. 4. Add production-path tests that upload names containing `;`, `$()`, backticks, quotes, whitespace, redirection operators, glob characters, newlines, and traversal sequences. Assert no child shell is created and that literal-safe backups still work. 5. Add a regression test using the same crafted archive against the real authenticated API and monitor process ancestry (`Fledge core -> shell`) to ensure the unsafe behavior is gone. 6. Until an upstream release containing a verified fix exists, restrict the endpoint to trusted administrators, isolate the service account, minimize writable paths and privileges, and alert on shell processes spawned by the Fledge core service. ## Additional Notes - **Idempotency:** Confirmed. The script removes prior evidence/work directories and uniquely names containers; it completed successfully twice in succession. Each individual final run itself used two clean vulnerable and two clean patched-control service instances. - **Real boundary:** The proof uses the compiled Fledge 3.1.0 core and storage service, normal product initialization, `/fledge/login`, and `POST /fledge/backup/upload` over localhost TCP. It does not invoke `upload_backup()` directly and uses no mock handler. - **Sanitizers:** None used. The success oracle is real command execution, not a sanitizer or crash. - **Authentication:** Fledge's normal first-install administrator credential was used only inside the isolated containers. Retained request evidence redacts the credential and retained responses redact JWTs. - **Container capabilities:** `SYS_PTRACE` and an unconfined seccomp profile are used only so `strace` can capture child process execution. They do not enable the injection, change product parsing, or create the marker; vulnerable execution was independently observed before adding tracing. - **Upstream status:** No released fixed version is asserted. The negative control is explicitly source-modified Fledge 3.1.0 for causal verification. - **Limitations:** The proof requires a working Docker daemon and network access on an uncached first build to obtain the pinned base and package dependencies. It performs no outbound access from the exploit payload. ### Reproduction - Reproduced: 2026-08-23T15:39:05.410Z - Duration: 5463s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00332 # or: pruva-verify CVE-2026-71284 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00332 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00332/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00332 ================================================================================ ## REPRO-2026-00331: OpenCTI CVE-2026-39980 safeEjs destructuring fix bypass RCE -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00331 - CVE: CVE-2026-39980 (https://nvd.nist.gov/vuln/detail/CVE-2026-39980) ### Package Information - Name: opencti-platform/opencti - Ecosystem: github - Affected: Unknown - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-94 (Improper Control of Generation of Code ('Code Injection')) ### Root Cause # RCA Report: CVE-2026-39980-BYPASS-DESTRUCTURE — OpenCTI 6.9.5 safeEjs quoted-key destructuring sandbox bypass (pre-auth root RCE) ## Summary OpenCTI 6.9.5 shipped a fix for CVE-2026-39980 (commit `d91c19e1e7`, "[backend] Safe ejs with verifier") that rewrites `safeRender` in `src/utils/safeEjs.ts` with an `@lezer/javascript` AST verifier plus a runtime property guard (`____safe____property`). The fix contains a static-analysis gap: quoted object keys are checked against the `forbiddenProperties` denylist only when their AST parent is an object-literal `Property` node. Quoted keys in **destructuring patterns** are `PatternProperty` children and are never inspected, and because destructuring performs a real `[[Get]]` without bracket tokens, the runtime guard (which is injected only around `[` `]`) is never applied either. The formula `(()=>{const{"constructor":F}=Array;return F()()})()` therefore retrieves `Function` from the allowed `Array` global and executes arbitrary JavaScript inside the OpenCTI Node.js process. Delivered through the `jsonMapperTest` GraphQL mutation (JsonMapper variable formula) and chained with the still-unpatched CVE-2026-27960 Bearer-UUID auth bypass, this yields **unauthenticated remote code execution as root** on a fully patched-for-CVE-2026-39980 OpenCTI 6.9.5. ## Impact - **Package/component affected**: `opencti/platform` (backend `opencti-graphql`), file `src/utils/safeEjs.ts` (`processString` / `transformTemplate`). - **Affected versions**: 6.9.5 (the CVE-2026-39980 "fixed" release). The auth bypass used for pre-auth reachability (CVE-2026-27960) is live on 6.9.5 and was only fixed in 6.9.13; even without it, any authenticated user with the `CSVMAPPERS` capability (or access to notifier template testing, which shares the `transformTemplate` core) can trigger the same sandbox escape. - **Risk level and consequences**: Critical. Unauthenticated remote code execution as `uid=0(root)` inside the platform container — full platform compromise (all threat-intel data, credentials, connected systems). ## Impact Parity - **Disclosed/claimed maximum impact**: code execution (root RCE, pre-auth when chained with CVE-2026-27960). - **Reproduced impact from this run**: full remote code execution as `uid=0(root)` inside two fresh `opencti/platform:6.9.5` containers per pass, via the real `POST /graphql` `jsonMapperTest` endpoint, preceded by remote proof of the CVE-2026-27960 auth bypass (`me` query returns the admin identity when only `Authorization: Bearer 88ec0c6a-13ce-5e39-b486-354fe4a7084f` is supplied). - **Parity**: `full`. - **Not demonstrated**: nothing claimed was left undemonstrated. (Persistence/exfiltration beyond the marker command was not attempted and was not claimed.) ## Root Cause `src/utils/safeEjs.ts` (tag `6.9.5` = commit `be4ab13c30d154adc3cfc49ba128b2039b93e348`, fix commit `d91c19e1e7` contained): ```ts const processString = () => { const parentType = cursor.node.parent?.type.name; if (parentType === 'Property') { // object literals ONLY processPropertyDefinitionOrName(); // forbiddenProperties denylist check } }; ``` 1. In `@lezer/javascript`, a quoted key in an object literal (`x={"constructor":1}`) is a `String` node whose parent is `Property` → denylist-checked. The same quoted key in a destructuring pattern (`const {"constructor":F}=Array`) is a `String` node whose parent is `PatternProperty` → **never checked** (verified locally with `@lezer/javascript`: parent `PatternProperty` vs `Property`). 2. The runtime guard `____safe____property(...)` is injected only by `processBracketLeft`/`processBracketRight` around `[`/`]` tokens (`isPropertyNameInBracket` covers `MemberExpression`, `Property`, `PatternProperty` — but destructuring has **no bracket tokens**), so no runtime coercion/denylist happens either. 3. `Array` is an explicitly allowed global (`authorizeGlobals`), so `Array` → destructure `"constructor"` → `Function` → `F("return process.getBuiltinModule('child_process').execSync(...)")()` executes OS commands as the platform process user (root in the official image). Sink chain: `POST /graphql` → `jsonMapperTest(configuration, file)` (`@auth(for: [CSVMAPPERS])`) → `jsonMapper-domain.ts::jsonMapperTest` → `parser/json-mapper.ts::jsonMappingExecution` → `extractComplexPathFromJson` → `safeRender("", ...)`. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; only needs Docker). 2. The script deploys the real stack — `elasticsearch:8.19.9`, `redis:8.4.0`, `rabbitmq:4.2.2-management`, `minio/minio:RELEASE.2025-06-13T11-33-47Z`, and `opencti/platform:6.9.5` (digest pinned and verified: `sha256:1f91ad32f1aadf283b5f369ff7b358da071679d4e2bec64030127306db8e73b0`) — waits for the real `/health` endpoint, then: - proves CVE-2026-27960 auth bypass remotely (`me` query as admin via Bearer admin internal_id), - sends the **negative control**: the original CVE-2026-39980 computed-key payload, which the 6.9.5 fix rejects with `VerifierIllegalAccessError: Forbidden property access {"propertyName":"constructor"}` (proving the fix was active), - sends the **destructuring exploit formula** as a JsonMapper variable formula through `jsonMapperTest` and verifies a unique per-run marker file inside the platform container containing the token, `uid=0(root)`, and the container hostname, - recreates the platform container (fresh process, distinct hostname) and repeats the exploit successfully. 3. Expected evidence: marker files with `uid=0(root)` + per-run epoch token + container hostname; GraphQL 200 responses; negative-control rejection; exit code 0. 4. `bundle/repro/negative_control.sh` runs a dedicated negative control on a **separate fresh process** (distinct marker name) and asserts rejection + marker absence. ## Evidence - `bundle/logs/reproduction_steps.log` — full pass transcript (two consecutive passes, both exit 0). - `bundle/artifacts/http/me_response.json` — `{"data":{"me":{"user_email":"admin@pruva.local","name":"admin"}}}` via Bearer UUID only (CVE-2026-27960). - `bundle/artifacts/http/negative_control_response.json` — rejection of the original payload; the error message even shows the injected guard: `"constructor"})]...) ... Forbidden property access {"propertyName":"constructor"}`. - `bundle/artifacts/http/exploit_attempt{1,2}_response.json` — HTTP 200 `{"data":{"jsonMapperTest":{...}}}` for the destructuring payload (request bodies preserved in `*.operations.json`). - `bundle/artifacts/markers/marker_attempt1.txt` (pass 2, container `f7cd127ef744`), `marker_attempt2.txt` (pass 2, fresh container `e8d8ea37e8dd`): per-run token + `uid=0(root) gid=0(root)...` + hostname. Pass 1 used containers `fc0ed8fda7f3` / `ee1736911ce1` — four distinct fresh processes total. - `bundle/repro/runtime_manifest.json` — endpoint/runtime evidence manifest with pinned target identity. - Environment: rootless Docker 27.5.1, x86_64 Linux, Node runtime bundled in the image. ## Recommendations / Next Steps - Denylist quoted destructuring keys: in `processString`, also handle `parentType === 'PatternProperty'` (and consider `PropertyDefinition`/assignment patterns), or better, switch from a denylist to an **allowlist** of permitted property names. - Treat `constructor`-family access uniformly regardless of syntax surface (dot, bracket, destructuring, default values, rest patterns). - Isolate formula/template evaluation from the main Node.js process (worker with restricted `process`/module access); note `safeEjs.client.ts` already has a worker path — the jsonMapper path uses the in-process `safeEjs.ts`. - Upgrade guidance: 6.9.5 is **not** sufficient remediation for CVE-2026-39980. CVE-2026-27960 (auth bypass) is fixed in 6.9.13; upgrading to ≥6.9.13 removes the pre-auth vector but the destructuring sandbox escape should be verified/fix-forwarded independently. - Testing: add AST-level regression tests feeding `{"constructor":...}` destructuring patterns into the verifier, plus end-to-end `jsonMapperTest` exploit tests. ## Additional Notes - Idempotency: the script tears down and recreates the full stack on every run (`docker rm -f` + fresh network), generates a fresh epoch token and fresh admin API token per run, and cleans up via `trap ... EXIT`. Verified idempotent: two consecutive runs both exited 0. - Limitations: the exploit requires the formula to avoid bracket property access on forbidden names and `this`/`import`; the demonstrated formula is minimal and stable. The Docker-based stack requires ~2 GB RAM for the ES heap; timings on this host: full pass ≈ 4–5 minutes. - The Unicode-escape vector hypothesized in public write-ups was ruled out by the discovering run (escaped identifiers throw `VerifierParsingError`); the residual bypass class is destructuring, confirmed here at runtime. ### Reproduction - Reproduced: 2026-08-23T15:38:59.612Z - Duration: 6384s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00331 # or: pruva-verify CVE-2026-39980 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00331 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00331/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00331 ================================================================================ ## REPRO-2026-00330: MariaDB 13.0.1-rc RCE chain: F-09 GRANT PROXY priv-esc (MDEV-40470) + /proc/self/maps ASLR leak + F-05 SYS_REFCURSOR heap UAF → JOP to system() as uid 999(mysql), pure SQL from a low-priv account -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00330 ### Package Information - Name: mariadb/server - Ecosystem: github - Affected: F-09 affects every released MariaDB version (confirmed 13.0.1 through 10.6.27). F-05 unfixed at HEAD as of 2026-08-03. - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-266 ### Root Cause # RCA Report — MDEV-40470-F05-RCE-CHAIN: MariaDB 13.0.1-rc pure-SQL RCE chain ## Summary A USAGE-only MariaDB account with TCP reachability to port 3306 achieves remote code execution as `uid=999(mysql)` on the **stock, unmodified** MariaDB 13.0.1-rc Docker image (`mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9`) using nothing but SQL statements. The chain combines: (F-09) a `GRANT PROXY ... IDENTIFIED VIA ''` privilege escalation that hijacks the root account with an empty password (MDEV-40470); a server-side `LOAD DATA INFILE '/proc/self/maps'` read that discloses the live PIE and libc base addresses (ASLR defeat); a 128 MiB user-variable buffer whose address is discovered by diffing `/proc/self/maps` from SQL; and (F-05) a `SYS_REFCURSOR` use-after-free in `sp_cursor_array::get_cursor_by_ref()` whose freed 1792-byte array chunk is reclaimed by an exact-fit heap spray, redirecting a virtual dispatch into a two-gadget JOP chain (D2 → D1) that calls libc `system()` with an attacker-chosen command string. ## Impact - **Package/component:** `mariadb/server` — server core (`sql/sql_acl.cc` GRANT PROXY handling; `sql/sp_cursor.{cc,h}` cursor array). - **Affected versions:** reproduced on 13.0.1-MariaDB-ubu2604 (pinned image). Per the advisory, F-09's fix (commit `dbd60d0ad8d`) exists only on dev branches and is absent from every released version 13.0.1 → 10.6.27; F-05 is unfixed upstream (no commits to `sql/sp_cursor.{cc,h}` since the 13.0.1 tag). - **Risk level:** Critical. Any authenticated low-privilege database user gains full OS command execution as the `mysql` service account (uid 999), i.e. complete database-server compromise, remotely, over the normal SQL protocol. ## Impact Parity - **Disclosed/claimed maximum impact:** code execution as uid 999 (mysql) from a USAGE-only account via pure SQL over TCP/3306. - **Reproduced impact from this run:** identical — attacker-chosen shell commands executed as `uid=999(mysql)` on the stock pinned image in 6/6 fresh processes (5 scripted + 1 manual calibration run), each with fresh ASLR. - **Parity:** `full`. ## Root Cause 1. **F-09 — GRANT PROXY privilege escalation (MDEV-40470).** `GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA '';` passes an *empty* authentication clause. `LEX_USER::has_auth()` returns false, so the privilege check in `check_alter_user()` is skipped, while `replace_user_table()` still applies the (empty) password — replacing root's credentials with an empty password. One statement, any authenticated user. Fix: commit `dbd60d0ad8d` (dev branches only; no released version has it). Runtime proof: after the statement, login as `root` with `--skip-password` succeeds (`CURRENT_USER()=root@%`), and the pre-escalation negative control shows `ERROR 1045` on `LOAD DATA INFILE` and `ERROR 1227` on `SET GLOBAL`. 2. **ASLR defeat.** With the FILE privilege obtained in step 1 and `secure_file_priv` unset (stock), `LOAD DATA INFILE '/proc/self/maps'` loads mariadbd's own memory map into a table; plain SELECTs return the PIE base (first `r--p 00000000` mapping of `/usr/sbin/mariadbd`) and the libc base. Fresh values every process (observed 0x63abb3250000, 0x57b3cea2c000, 0x55b611d36000, 0x76955fffe000-area, 0x731cf3fff000-area). 3. **F-05 — SYS_REFCURSOR use-after-free (unfixed 0day).** `sp_cursor_array::get_cursor_by_ref()` returns an interior pointer into a `Dynamic_array`. When a cursor's `open()` executes SQL that opens more cursors (`grow5()` opens 16 cursors, then `OPEN p FOR SELECT spray128()`), the array grows, `my_realloc` frees the old 1792-byte storage (16×112 B), and the caller's cached pointer dangles. A heap spray of 128 session user variables of exactly 1784 bytes (`SET @e3sNNN=@e3pad`, glibc exact-fit for the 1792-byte chunk) reclaims the freed storage with attacker bytes, placing a controlled pointer `V` at offset 0x20 (the `result` member of `sp_cursor`). `Materialized_cursor::open()` then dispatches virtually: `mov rax,[result]; call [rax+0x20]`. 4. **JOP to `system()`.** The fake vtable `V` lives in a 128 MiB user-variable buffer whose address is learned via the maps diff (glibc gives such large allocations a dedicated mmap; the slot is reused across free+realloc, so the self-referential pointer baked by SQL stays valid; buffer data begins at region+0x30 — verified this run via gdb on the live process). Layout: `V+0x20=D2`, `V+0xa0=system`, `V+0xa8=V+0x140` (cmd ptr), `V+0x100=D1`, `V+0x140="sh -c ''"`. Dispatch: `call [rax+0x20]` → D2 (`call *0x100(%rax)`, PIE+0x80da77) → D1 (`mov rdi,[rax+0xa8]; call [rax+0xa0]`, PIE+0xe3075b) → `system()` (libc+0x5c560). All three gadget offsets were re-verified with `objdump` against the binaries extracted from the pinned image in this run (`logs/repro/gadget_check.log`). ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (run twice consecutively — both runs exited 0). 2. The script: installs `mariadb-client`/`binutils` if missing; pulls the pinned image; verifies the D2/D1/system gadget offsets in the extracted binaries; then performs **two** independent attempts, each against a freshly created container (fresh datadir, root password `labpass`, USAGE + `appdb.*`-only `lowpriv` account, fresh ASLR). Each attempt runs `bundle/repro/exploit_mdev40470.py`, a pure-SQL driver (mariadb client over TCP/3306) that executes the whole chain and finally reads the marker file `id > /tmp/mdev40470_pwned_attemptN` the payload wrote inside the container. 3. Expected evidence: per attempt — `F-09 done - root login with EMPTY password`, leaked PIE/libc bases, stable 128 MiB slot, session death on `CALL uaf5()`, and marker content `uid=999(mysql) gid=999(mysql) groups=999(mysql)`; script exits 0 only when both attempts confirm. Note on the exploit driver: it is the published PoC (github.com/dinosn/mariadb-13-rce-lab @ 6ac868e1) with *one* robustness fix in the `/proc/self/maps` region discovery — on this kernel the fresh 128 MiB mmap is sometimes VMA-merged with an adjacent pre-existing anonymous region (observed merged sizes 0x8022000 and 0x10002000), which the stock exact-size filter missed. The patched logic tracks all anonymous rw-p regions and handles both new-region and grew-region cases. Gadget offsets, sizes, DATA_OFF (0x30) and the whole chain are unchanged. ## Evidence - `bundle/logs/reproduction_steps.log` — full scripted run (exit 0, 2/2). - `bundle/logs/repro/version_check.log` — `13.0.1-MariaDB-ubu2604`. - `bundle/logs/repro/gadget_check.log` — objdump verification of D2/D1/system. - `bundle/logs/repro/vuln_attempt_1.log`, `vuln_attempt_2.log` — per-attempt chain transcripts (distinct ASLR bases). - `bundle/logs/repro/marker_attempt_1.log`, `marker_attempt_2.log` — `uid=999(mysql) gid=999(mysql) groups=999(mysql)`. - `bundle/logs/repro/vuln_attempt_token.log` + `marker_attempt_token.log` — additional run writing the unique token `MDEV40470_CHAIN_EXEC_1785762660`. - `bundle/logs/repro/negative_control_lowpriv.log` + `bundle/repro/negative_control_1.json` — identical procedure without F-09: `ERROR 1045` (FILE), `ERROR 1227` (SUPER), no marker, no crash; the escalation is the linchpin. - `bundle/repro/runtime_manifest.json` — tcp_peer entrypoint, service/health/ target-path flags, pinned-image target identity. - Environment: Docker 27.5.1 daemon, host kernel 6.8, container glibc 2.43; ASLR enabled (`/proc/sys/kernel/randomize_va_space` default); PIE binary. ## Recommendations / Next Steps - **F-09 (MDEV-40470):** backport commit `dbd60d0ad8d` to all maintained release branches; reject empty `IDENTIFIED VIA ''` authentication clauses in GRANT PROXY / treat them as authenticated changes requiring `check_alter_user()` privileges. - **F-05:** fix `sp_cursor_array::get_cursor_by_ref()` callers to re-validate the cursor reference after `open()` (or pin the array storage / use index-based lookup) so growth during a nested open cannot dangle the cached pointer. - **Defense in depth:** set `secure_file_priv` to a dedicated directory in the stock image; consider `local_infile=0` and restricting FILE privilege. - Until patched releases exist, any authenticated account must be treated as equivalent to full OS code execution as the mysql user. ## Additional Notes - **Idempotency:** `reproduction_steps.sh` was run twice consecutively; both runs passed (4/4 scripted attempts + 1 manual + 1 token run = 6/6 total). Each attempt recreates the container from the pinned image, so no state carries over. - **No fixed negative control exists:** the F-09 fix is not in any released version and F-05 is unfixed upstream, so no released image can serve as a fixed build. The in-band negative control (same procedure without the F-09 escalation) demonstrates the privilege gate; the gadget-offset verification binds the result to the exact pinned binaries. - **Kernel-dependent VMA merging** was the only environmental deviation from the published PoC (its exact-size maps filter). The patched discovery was verified against gdb ground truth (marker bytes `ABCD` found at region+0x30, confirming `DATA_OFF=0x30`; chunk header `0x8000ff2` IS_MMAPPED at region+8). - The server process crashes after `system()` returns (mariadbd is PID 1, so the container exits); marker files persist in the container layer and are read after `docker start`. This post-exploit housekeeping is the only non-SQL step and is not part of the exploitation. ### Reproduction - Reproduced: 2026-08-23T15:38:53.860Z - Duration: 3693s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00330 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00330 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00330/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00330 ================================================================================ ## REPRO-2026-00329: JetBrains TeamCity On-Premises unauthenticated RCE via agent polling protocol -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00329 - CVE: CVE-2026-63077 (https://nvd.nist.gov/vuln/detail/CVE-2026-63077) ### Package Information - Name: JetBrains TeamCity - Ecosystem: Unknown - Affected: Unknown - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-502 (Deserialization of Untrusted Data) (Deserialization of Untrusted Data) ### Root Cause # CVE-2026-63077 — Root Cause Analysis ## Summary JetBrains TeamCity On-Premises is vulnerable to unauthenticated remote code execution (CWE-502, deserialization of untrusted data) in its **agent polling protocol**. The server-side handler `jetbrains.buildServer.agentServer.polling.Error.fromXml()` (and the sibling `XStreamHolder`s in `PollingRemoteAgentConnection`, `RunBuildCommandResult`, and `NodesAwareLogMessagePersister`) deserializes attacker-controlled HTTP request bodies with an XStream instance configured with `AnyTypePermission.ANY` and only a small denylist. An unauthenticated attacker first registers a synthetic build agent via `POST /app/agents/v1/register` (which issues a valid `TeamCity-AgentSessionId` without any credentials), then posts a crafted XStream XML document to `POST /app/agents/v1/commands/error`. The embedded gadget chain starts an HSQLDB connection whose `connectionInitSqls` drop a self-deleting `.jspws` webshell into the TeamCity webroot; a single GET to that file executes an arbitrary OS command with the privileges of the TeamCity server process. ## Impact - Package/component: JetBrains TeamCity On-Premises server (`webapps/ROOT` webapp, classes in `server-core.jar`, `common-impl.jar`, `messages.jar`, `web-core.jar`). - Affected versions: all TeamCity On-Premises versions before 2025.11.7 / 2026.1.3 (verified vulnerable: 2025.11.6, build 208214; verified fixed: 2025.11.7). - Risk: CVSS 3.1 9.8 Critical (AV:N/AC:L/PR:N/UI:N). Listed in CISA KEV (added 2026-08-05) with confirmed in-the-wild exploitation. Full server compromise: arbitrary OS command execution as the TeamCity server user, access to build secrets, source code, CI/CD pipeline integrity. ## Impact Parity - Disclosed/claimed maximum impact: unauthenticated remote code execution. - Reproduced impact from this run: unauthenticated remote OS command execution (`touch ` executed as `tcuser`, the TeamCity server process user, inside the official `jetbrains/teamcity-server:2025.11.6-linux` container), proven by the command-created marker file and by the one-shot JSPWS response token. - Parity: **full**. - Not demonstrated: nothing material — the claim is unauthenticated RCE and exactly that was demonstrated, twice, through the real HTTP surface. ## Root Cause The agent polling protocol is served by `jetbrains.buildServer.controllers.agentServer.AgentPollingProtocolController` (`web-core.jar`), reachable under `/app/agents/v1/...` with **no servlet-level authentication**: agent identity is established only by the `TeamCity-AgentSessionId` header (`:`), and a fresh valid session is handed out by the unauthenticated `register` action to any caller (`createRegisteredAgentWithPollingConnection` → `registerAgent` → session id in the `TeamCity-AgentSessionId` response header). For the `commands/error` sub-path, `AbstractAgentCommandsRequestsProcessor. handleCommandIsFailedRequest` executes: ```java Error error = Error.fromXml(StreamUtil.readTextFrom(request.getReader())); // <- sink int n = Integer.parseInt(request.getHeader("TeamCity-AgentCommandId")); ``` `Error.fromXml` → `XStreamWrapper.deserializeObject(xml, ourXStreamHolder)`. `jetbrains.buildServer.messages.XStreamHolder` (messages.jar) configures its XStream as: ```java xstream.addPermission(AnyTypePermission.ANY); xstream.denyTypes(new String[]{ "java.beans.EventHandler", "java.lang.ProcessBuilder", "javax.imageio.ImageIO$ContainsFilter", "jdk.nashorn.internal.objects.NativeString", "com.sun.corba.se.impl.activation.ServerTableEntry", "com.sun.tools.javac.processing.JavacProcessingEnvironment$NameProcessIterator", "sun.awt.datatransfer.DataTransferer$IndexOrderComparator", "sun.swing.SwingLazyValue"}); xstream.denyTypesByRegExp(/* LazyIterator, LazyEnumeration, GetterSetterReflection, PrivilegedGetter, java.rmi, javax.crypto, ServiceNameIterator, JavaFX, BCEL */); ``` i.e. an "allow everything except a 2016-era blacklist" configuration. Bundled libraries (commons-collections 3.2.2, freemarker 2.3.31, commons-dbcp2/pool2, hsqldb, plus TeamCity's own classes) provide all the gadget classes needed for code execution. The exploit gadget chain (identical to the in-the-wild chain captured by honeypots and documented by Rapid7): 1. `linked-hash-map` entry value typed as TeamCity's own `jetbrains.buildServer.serverSide.metadata.impl.metadata.HSQLMetadataStorage$SchemaMismatchException` (a `Throwable`, so it passes XStream 1.4.20's default hierarchy permission). Its declared fields instantiate `HSQLStorage` with a DBCP2 `BasicDataSource` whose `driverClassName=org.hsqldb.jdbc.JDBCDriver`, `url=jdbc:hsqldb:mem:`, and three attacker-controlled `connectionInitSqls`. 2. A `freemarker.ext.beans.HashAdapter` whose `falseModel.object` is an XStream `reference=` to that `BasicDataSource`, giving a `Map` view whose `get("connection")` invokes `BasicDataSource.getConnection()` via FreeMarker bean introspection. 3. A `set` containing `org.apache.commons.collections.keyvalue.TiedMapEntry` (not covered by commons-collections 3.2.2's `readObject` serialization guard) bound to that map with key `"connection"`. During `HashSet` population, `TiedMapEntry.hashCode()` → `getValue()` → `map.get("connection")` → `BasicDataSource.getConnection()` → DBCP runs the three init SQL statements against the in-memory HSQLDB: `CREATE TABLE`, `INSERT ''`, and `SCRIPT '../webapps/ROOT/.jspws'`, which writes a polyglot SQL/JSP webshell into the TeamCity webroot. 4. `GET /.jspws` compiles and runs the scriptlet, which deletes itself and calls `java.lang.Runtime.getRuntime().exec()`, printing a per-run token. Fix (confirmed by decompiling the official `fix_CVE_2026_63077.zip` security patch plugin, build limit `max-build="222648"`): the patch reflectively replaces every `XStreamHolder` used by the polling protocol (`PollingRemoteAgentConnection.myXStreamHolder`, `Error.xStreamHolder`, `RunBuildCommandResult.ourXStreamHolder`, `NodesAwareLogMessagePersister.xStreamHolder`) with a wrapper whose `getXStream()` adds `NoTypePermission.NONE` plus an explicit allowlist of ~100 `jetbrains.buildServer.*` data classes. It also installs an `AddToQueuePreprocessor` that strips queued builds carrying the `teamcity.agent.internal.passwords.values` parameter. Fixed releases 2025.11.7 / 2026.1.3 ship the same allowlist natively. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; requires docker, python3, curl). 2. The script: - pulls the pinned official images `jetbrains/teamcity-server@sha256:a435d8…4176` (2025.11.6, vulnerable) and `…@sha256:d3875b…56d8` (2025.11.7, fixed); - starts both servers and drives the real first-run setup wizard over HTTP (`/mnt/do/goNewInstallation` → `/mnt/do/goNewDatabase` (internal HSQLDB) → `/mnt/do/acceptLicenseAgreement`) until the server leaves maintenance mode; - health-checks the attack surface by registering an agent **without credentials** and verifying a `TeamCity-AgentSessionId` header is issued; - runs the exploit (`bundle/repro/exploit_cve_2026_63077.py`, vendored Rapid7 PoC) twice against the vulnerable server and twice against the fixed server, with per-run random markers; - requires, on the vulnerable server: exploit exit 0 **and** the marker file present inside the container (created by the TeamCity server process); - requires, on the fixed server: exploit failure, no marker file, and `com.thoughtworks.xstream.security.ForbiddenClassException` in the server log (the exact IoC JetBrains names for a blocked exploit attempt). 3. Expected evidence: `[+] Command executed: touch /tmp/CVE_2026_63077_PWNED_` for 2025.11.6, `HTTP 404` for the webshell on 2025.11.7, and `RESULT: … CONFIRMED`. ## Evidence - `bundle/logs/reproduction_steps.log` — full orchestration log. - `bundle/logs/exploit_vulnerable.log` — two successful exploit runs: register → `TeamCity-AgentSessionId: :` → `/app/agents/v1/commands/error` HTTP 500 (deserialization side effects already committed) → `GET /.jspws` HTTP 200 with the per-run response token. - `bundle/repro/marker_vulnerable.txt` — `ls -la` of the marker file (owner `tcuser`) and `id` of the server process user inside the container. - `bundle/logs/teamcity_vuln_server.log` — vulnerable server log containing the `com.thoughtworks.xstream.converters.ConversionException` IoC named in JetBrains' guidance. - `bundle/logs/exploit_fixed.log`, `bundle/logs/teamcity_fixed_server.log` — fixed server: same requests, `ForbiddenClassException` ×2, webshell GET → HTTP 404, no marker. - `bundle/repro/payload_vulnerable.xml` — the exact attack XML generated for the run. - `bundle/repro/analysis/` — patch-diff evidence: decompiled JetBrains security patch plugin classes, decoded allowlist, decompiled `Error`/`AgentPollingProtocolController`/ `AbstractAgentCommandsRequestsProcessor`/`XStreamHolder` from 2025.11.6, and the in-the-wild honeypot pcap (`CVE-2026-63077-itw.pcap`, BoredHackerBlog) showing the identical request sequence. - Environment: official Docker images on linux/amd64; TeamCity 2025.11.6 (build 208214) with bundled Tomcat 9.0.109 / JetBrains Runtime 21; no sanitizer, no instrumentation. ## Recommendations / Next Steps - Upgrade to TeamCity 2025.11.7 or 2026.1.3, or install JetBrains' `fix_CVE_2026_63077` security patch plugin (2017.1+; restart required on 2017.1–2018.1). - Restrict network access to the server (the agent polling protocol is same-port HTTP(S)) to trusted build-agent networks. - Detection: server logs containing `ConversionException` (possible attempt/success) or `ForbiddenClassException` (blocked attempt on patched servers); unexpected unauthorized agents (in-the-wild agents used names starting with `scan`); unexpected `.jspws`/`.jsp` files under `webapps/ROOT`. - The correct fix pattern is exactly what JetBrains shipped: never deserialize the polling protocol with `AnyTypePermission.ANY`; use `NoTypePermission.NONE` + a strict allowlist. ## Additional Notes - Idempotency: the script recreates both containers from pinned image digests on every run and uses fresh random markers/tokens, so consecutive runs are independent. - The exploit does not depend on the `TeamCity-AgentCommandId` value (deserialization happens before the header is parsed); any integer suffices. - On the vulnerable server the `/commands/error` request returns HTTP 500 *after* the gadget side effects have executed — the 500 is expected and matches the in-the-wild capture. - Exploit helper provenance: `bundle/repro/exploit_cve_2026_63077.py` is the public Rapid7 PoC (github.com/sfewer-r7/CVE-2026-63077), used unmodified; the same chain was independently captured in the wild (pcap in `bundle/repro/analysis/`). - The default `--webroot-relative ../webapps/ROOT` is correct for the official Linux Docker image (JVM working directory `/opt/teamcity/bin`). ### Reproduction - Reproduced: 2026-08-23T15:38:48.280Z - Duration: 6055s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00329 # or: pruva-verify CVE-2026-63077 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00329 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00329/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00329 ================================================================================ ## REPRO-2026-00328: PasswordPusher allows unauthenticated deletion of anonymous pushes due to a nil==nil ownership check that bypasses viewer-deletion restrictions. -------------------------------------------------------------------------------- Status: published Severity: medium Type: security ### Identifiers - REPRO ID: REPRO-2026-00328 - CVE: CVE-2026-62382 (https://nvd.nist.gov/vuln/detail/CVE-2026-62382) ### Package Information - Name: pglombardo/PasswordPusher - Ecosystem: Ruby on Rails self-hosted application, also shipped as Docker image pglombardo/pwpush - Affected: v1.45.11 through v2.9.5 - Fixed: v2.9.6 - Severity: medium - CVSS: 6.9 / 10 - CWE: CWE-863 Incorrect Authorization (Incorrect Authorization) ### Root Cause # RCA Report — CVE-2026-62382: PasswordPusher Unauthenticated Deletion of Anonymous Pushes ## Summary PasswordPusher versions v1.45.11 through v2.9.5 contain an improper authorization flaw (CWE-863) in the push-deletion paths. Both the JSON API (`Api::V1::PushesController#destroy`) and the HTML UI (`PushesController#expire`) authorize deletion with `(@push.user == current_user) || @push.deletable_by_viewer`. For an anonymous push `@push.user` is `nil`, and for an unauthenticated request `current_user` is also `nil`, so the ownership comparison evaluates `nil == nil` → `true`. The `deletable_by_viewer` restriction is therefore bypassed, and anyone who knows only the secret URL can permanently delete (`expire!`) an anonymous push — clearing payload, passphrase, and attached files — even when viewer deletion was explicitly disabled and a passphrase protects reads. ## Impact - **Package/component:** PasswordPusher (self-hosted Ruby on Rails application), `pglombardo/pwpush` Docker images. - **Affected versions:** v1.45.11 – v2.9.5 (fixed in v2.9.6). Only deployments allowing anonymous pushes (the default) are affected. - **Risk:** Medium (CVSS 4.0: 6.9). Unauthenticated denial-of-service against secrets in transit: an attacker who learns or guesses a secret URL token can irreversibly destroy the push before the intended recipient retrieves it. ## Impact Parity - **Disclosed/claimed maximum impact:** authorization bypass — unauthenticated deletion of anonymous pushes (`authz_bypass`). - **Reproduced impact from this run:** identical. An unauthenticated `DELETE /p/.json` against pwpush 2.9.5 returned HTTP 200, set `expired=true`/`deleted=true`, cleared the passphrase, and destroyed the payload (subsequent authorized read with the correct passphrase returned `"payload": null`). The HTML route `DELETE /p//expire` also destroyed the push (HTTP 302 + push expired). - **Parity:** `full`. - Not demonstrated: nothing claimed beyond the authorization bypass / data destruction (no code execution was claimed or attempted). ## Root Cause In v2.9.5 the deletion guards were: - `app/controllers/api/v1/pushes_controller.rb` (`destroy`): `if (@push.user == current_user) || @push.deletable_by_viewer` - `app/controllers/pushes_controller.rb` (`expire`): `unless @push.deletable_by_viewer || (@push.user == current_user)` `Push#user` is a nullable `belongs_to`. Anonymous pushes have `user_id = NULL`, so `@push.user` is `nil`. Devise's `current_user` is `nil` when the request is unauthenticated. Ruby evaluates `nil == nil` as `true`, so the "owner" branch succeeds and the `deletable_by_viewer` check is never reached. `expire!` then clears `payload`, `passphrase`, and files and marks the push expired/deleted — irreversible. **Fix (v2.9.6, diff v2.9.5...v2.9.6):** both controllers now call a new model method `Push#deletable_by?(user)`: ```ruby def deletable_by?(user) (user.present? && user_id == user.id) || deletable_by_viewer == true end ``` which requires an authenticated (`present?`) user whose id matches the owner, removing the nil==nil equivalence. Advisory: https://github.com/pglombardo/PasswordPusher/security/advisories/GHSA-jf2m-hpj9-4qx2 ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; requires Docker). 2. The script: - Pulls and starts the real product images `pglombardo/pwpush:2.9.5` (vulnerable, port 15100) and `pglombardo/pwpush:2.9.6` (fixed, port 15101). - Waits for HTTP readiness, then completes the real first-run setup flow (extracts the one-time boot code from container logs and creates the admin account), mirroring a fresh deployment. - As an **unauthenticated** client, creates an anonymous push with `payload=SUPER-SECRET-CVE-2026-62382`, `passphrase=s3cr3t`, `deletable_by_viewer=false`. - Verifies the payload is unreadable without the passphrase (HTTP 401). - Sends `DELETE /p/.json` with no session and no passphrase. - Re-reads the push with the correct passphrase and evaluates state. - Repeats the identical flow against the fixed image as a negative control, and additionally exercises the HTML `DELETE /p//expire` route on the vulnerable instance as secondary evidence. 3. Expected evidence: vulnerable → DELETE HTTP 200, push `expired=true`, `deleted=true`, `payload=null`; fixed → DELETE HTTP 401 (`"That push is not deletable by viewers."`), payload intact. ## Evidence - `bundle/logs/reproduction_steps.log` — full run transcript. Key excerpts: ``` [repro] [vuln] read without passphrase -> HTTP 401 [repro] [vuln] unauthenticated DELETE /p/cxlwytxdhjwx.json -> HTTP 200 [repro] [fixed] unauthenticated DELETE /p/5hpxxmf2x6_unhxtmq.json -> HTTP 401 [repro] vuln: DELETE=200 expired=true deleted=true payload=null [repro] fixed: DELETE=401 expired=false payload=SUPER-SECRET-CVE-2026-62382 [repro] RESULT: CVE-2026-62382 CONFIRMED (vuln exploited, fixed rejected). ``` - `bundle/artifacts/http/vuln_delete_response.json` — vulnerable DELETE response body: `expired:true`, `deleted:true`, `passphrase:null`. - `bundle/artifacts/http/vuln_read_after_delete.json` — authorized read after the attack returns `"payload": null` (secret destroyed). - `bundle/artifacts/http/vuln_read_without_passphrase.json` — pre-attack 401 proves the passphrase gate was active. - `bundle/artifacts/http/vuln_html_expire_response.txt` — HTML route also expires the push unauthenticated. - `bundle/artifacts/http/fixed_delete_response.json` — `401` + `{"error":"That push is not deletable by viewers."}`. - `bundle/artifacts/http/fixed_read_after_delete.json` — payload intact on fixed version. - `bundle/logs/pwpush_vuln_service.log` / `bundle/logs/pwpush_fixed_service.log` — container logs (Puma boot, first-run, request handling). - `bundle/repro/runtime_manifest.json` — runtime manifest with image digests and SHA-256 of every proof artifact. - Environment: Docker 29.1.3 on Linux x86_64; images `pglombardo/pwpush:2.9.5` (sha256:ba5cf45b…) and `pglombardo/pwpush:2.9.6` (sha256:c9662425…), Ruby 4.0.6 / Rails 8.1.3.1, production environment with default settings (`allow_anonymous` enabled). ## Recommendations / Next Steps - Upgrade to PasswordPusher ≥ v2.9.6 (or apply the `Push#deletable_by?` patch). - Never use `record.user == current_user` as an ownership test when either side can be `nil`; require `current_user.present? && record.user_id == current_user.id`. - Add regression tests: unauthenticated DELETE/expire of an anonymous push with `deletable_by_viewer=false` must be rejected (the fix release adds `test/integration/password/password_json_deletion_test.rb` etc.). - Defense-in-depth: deployments that do not need anonymous pushes should set `allow_anonymous: false`; secret URL tokens should be treated as bearer secrets and rotated. ## Additional Notes - **Idempotency:** the script removes/recreates its containers (`pwpush-vuln-repro`, `pwpush-fixed-repro`) on each run, performs first-run setup from scratch, and exits 0 only when the vulnerable instance is exploited AND the fixed instance rejects the attack. Verified passing twice consecutively in this run. - First-run admin setup is required by current PasswordPusher images before any push can be created; the script automates it via the boot code printed to container logs. This reflects real deployment behavior and does not affect the unauthenticated attack surface. - Limitations: the boot-code extraction depends on the Docker log driver; a non-Docker deployment would need the equivalent first-run step. The claimed entrypoint (`DELETE /p/.json`) is exercised directly; the HTML `/expire` route is included only as secondary evidence. ### Reproduction - Reproduced: 2026-08-23T15:38:42.908Z - Duration: 1293s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00328 # or: pruva-verify CVE-2026-62382 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00328 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00328/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00328 ================================================================================ ## REPRO-2026-00327: Zimbra Collaboration unauthenticated RCE via Swatchdog/SNMP log-injection command injection (swatchrc dosnmp Perl backtick) -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00327 - CVE: CVE-2026-73570 (https://nvd.nist.gov/vuln/detail/CVE-2026-73570) ### Package Information - Name: Zimbra Collaboration (ZCS) - Ecosystem: Unknown - Affected: Unknown - Fixed: Unknown - Severity: high - CVSS: Unknown - CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')) ### Root Cause # Root Cause Analysis: CVE-2026-73570 ## Summary CVE-2026-73570 is an unauthenticated command-injection vulnerability in Zimbra Collaboration's SNMP monitoring path. An external SMTP peer can make the ZCS-packaged Postfix service log attacker-controlled text resembling a Zimbra `Service status change` record. The installed, packaged `zmswatch` process matches that log line and passes its attacker-controlled `SERVICE` capture to `dosnmp()`. Before ZCS 10.1.20, `dosnmp()` interpolates `SERVICE` into a Perl backtick command, invoking `/bin/sh` and permitting command execution as the `zimbra` operating-system user. ## Impact - **Affected component:** Zimbra Collaboration Suite MTA/SNMP monitoring (`zimbra-mta`, optional `zimbra-snmp`, `/opt/zimbra/conf/swatchrc`, packaged Postfix, and packaged `zmswatch`). - **Affected versions:** ZCS releases before 10.1.20 with the vulnerable Swatchdog configuration. - **Required configuration:** The SNMP package is installed, SNMP notifications are enabled, `zmswatch` is running, and the SMTP listener is reachable. - **Risk:** High. A remote unauthenticated attacker can execute shell commands as the `zimbra` account, which can disclose Zimbra configuration/credentials or alter mail-service data. ## Impact Parity - **Disclosed/claimed maximum impact:** Unauthenticated remote code/command execution through the public SMTP protocol path. - **Reproduced impact from this run:** Two clean installed-ZCS product instances accepted attacker-controlled SMTP bytes over an external TCP connection to packaged Postfix, logged them to `/var/log/zimbra.log`, processed them with packaged `zmswatch` running as `zimbra`, and created unique shell-expanded markers: `v1:u999:nzimbra` and `v2:u999:nzimbra`. - **Parity:** `full` - **Not demonstrated:** No interactive shell, persistence, credential theft, or privilege escalation beyond the `zimbra` account was attempted or claimed. ## Root Cause The vulnerable Zimbra Swatchdog rules match Postfix log records of the form `: Service status change: (\S+) (.*) changed from ...`. A compact SMTP command-pipelining violation can place attacker input in that log format. The second capture becomes `SERVICE` and flows to `dosnmp()`. The vulnerable ZCS 10.1.0 package input contains: ```perl `$snmptrap $snmpsvctrap $snmpsvcname s $args{SERVICE} $snmpsvcstatus i $statuses{$args{STATUS}}`; ``` Perl backticks invoke a shell. Consequently, shell metacharacters and command substitutions in `SERVICE` are interpreted instead of remaining one SNMP argument. The installed vulnerable `/opt/zimbra/conf/swatchrc.in` has SHA-256 `b0b36f69787aad1ad02b3bccac8043187de7113bea1aa49669e31ed98160c02e`. The authentic ZCS 10.1.20 `zimbra-mta-patch` package replaces this with LIST-form `system()`: ```perl system("/opt/zimbra/common/bin/snmptrap", "-v", "2c", "-c", "zimbra", $traphost, "", $snmpsvctrap, $snmpsvcname, "s", $args{SERVICE}, $snmpsvcstatus, "i", $statuses{$args{STATUS}}); ``` LIST-form execution passes `SERVICE` as one literal argument without invoking a shell. The authentic fixed `swatchrc.in` has SHA-256 `06e9be7dfad44519dd3f0f9c70673d7ccce6dbfb18bf54dc5ff2c5078a4c4a5c`. The exact official fixed package is `zimbra-mta-patch 10.1.20.1783342495-1.u22`, package SHA-256 `f242ee41af609b940c091d4bf9dab7ff1dd641134865d045850cfe00cbdee593`. The release-level fix is documented at ; this reproduction does not rely on a generated approximation or an inferred source commit. ## Reproduction Steps 1. Run `bash bundle/repro/reproduction_steps.sh` from any directory. The script uses `PRUVA_ROOT` when set. 2. The script reads `bundle/project_cache_context.json` and first uses the prepared project cache. If necessary, it downloads the pinned official ZCS 10.1.0 Ubuntu 22 installer archive and authentic ZCS 10.1.20 MTA patch, verifies their SHA-256 digests, and constructs installed product images. 3. It creates two clean vulnerable and two clean fixed instances. Every instance starts installed ZCS LDAP state, packaged ZCS Postfix, rsyslog's Zimbra mail-log route, and packaged `zmswatch` as `zimbra`. 4. For each instance, an external Python TCP peer connects through a host-loopback published port, receives the real Postfix banner, and pipelines `VRFY` with a 99-byte forged service-status record. 5. Vulnerable success requires both unique victim-shell markers. Fixed success requires the authentic 10.1.20 package identity, the same log/parser path, no marker, and a trace showing the entire metacharacter-bearing payload preserved as literal `snmptrap` `argv[9]`. 6. The final script was executed successfully twice consecutively; each execution internally performed all four isolated attempts. ## Evidence Primary current-run evidence is under `bundle/repro/evidence/`: - `source-identity.txt` binds the official archive/package digests and immutable vulnerable/fixed runtime image IDs. - `vulnerable-1/attack-request.txt` contains the actual SMTP transaction bytes. - `vulnerable-1/smtp.transcript` records the real ZCS Postfix banner and protocol exchange. - `vulnerable-1/zimbra.log` proves packaged Postfix logged the attacker-selected forged record. - `vulnerable-1/processes.txt` shows Postfix `smtpd` as `postfix` and packaged Swatchdog plus its generated parser as `zimbra`. - `vulnerable-1/package-identity.txt` records ZCS 10.1.0, `zimbra-postfix 3.6.14`, `zimbra-perl-swatchdog 3.2.4`, and `zimbra-snmp 10.1.0`. - `vulnerable-1/marker.txt` contains `v1:u999:nzimbra` (SHA-256 `85fe5f908055df71908e93700df6808558cda5b1e30d390f0247ec365f4d2803`). - `vulnerable-2/marker.txt` contains `v2:u999:nzimbra` (SHA-256 `483742d1e7d96aab6b5fdab8ac45189bed7036ccc9ba3d1b0cf150dbb54bf7ba`). - `fixed-1/result.json` and `fixed-2/result.json` record `target_path_reached=true` and `marker_present=false`. - `fixed-1/snmptrap.trace` and `fixed-2/snmptrap.trace` show the attack string preserved literally as one argument under LIST-form `system()`. - `bundle/repro/runtime_manifest.json` records `entrypoint_kind=tcp_peer`, all three runtime gates as true, exact target identities, and digest-bound proof artifacts. - `bundle/repro/validation_verdict.json` records `confirmed`, `network_protocol`, `production_path`, and observed `code_execution`. - `bundle/logs/reproduction_steps.log` records the latest complete orchestration result. Key current-run excerpts: ```text v1:u999:nzimbra v2:u999:nzimbra ``` ```text postfix smtpd ... improper command pipelining after VRFY ... : Service status change: h x;echo v1:u$(id -u):n$(id -un)>/tmp/p0;# ... ``` ```text argv[9]=/tmp/p2;#> ``` ## Recommendations / Next Steps - Upgrade affected installations to ZCS 10.1.20 or later with the complete vendor-supported package closure. - Verify that `/opt/zimbra/conf/swatchrc.in` uses LIST-form `system()` and regenerate `/opt/zimbra/conf/swatchrc` with the normal Zimbra tooling. - Until upgrading, disable SNMP notifications or stop `zmswatch` if operationally acceptable. - Inspect Zimbra logs and filesystem/web-root modifications for exploitation indicators; rotate credentials in Zimbra configuration if compromise is suspected. - Add an end-to-end regression that sends shell metacharacters through SMTP and asserts they arrive at `snmptrap` as one literal argument without command side effects. ## Additional Notes - The reproduction is idempotent. It deletes prior attempt evidence, starts fresh isolated containers and ports, and removes every attempt container on exit. - The primary oracle is non-sanitized product-visible shell execution through the installed production path; no sanitizer was used. - The real `snmptrap` executable is used for the vulnerable success path. A recording wrapper is introduced only after the fixed negative-control path has already proven no marker, solely to demonstrate literal fixed-version argv boundaries. - LDAP's test database is recreated per instance because installation-time LMDB files are sparse and unsuitable for a portable image; this does not replace or modify Postfix, `zmswatch`, `swatchrc`, or the vulnerable/fixed sink. - The trigger must remain within Postfix's observed 100-byte client-text log limit; the final forged record is 99 bytes. ### Reproduction - Reproduced: 2026-08-23T15:38:37.177Z - Duration: 5641s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00327 # or: pruva-verify CVE-2026-73570 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00327 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00327/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00327 ================================================================================ ## REPRO-2026-00326: Hermes Agent Electron preview webview sandbox escape via CVE-2026-70608 -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00326 - CVE: CVE-2026-70608 (https://nvd.nist.gov/vuln/detail/CVE-2026-70608) ### Package Information - Name: hermes-agent - Ecosystem: github - Affected: electron <39.8.10, >=40.0.0-alpha.1 <41.10.3, >=42.0.0-alpha.1 <42.0.1 - Fixed: 41.10.3 - Severity: high - CVSS: Unknown - CWE: CWE-693 ### Root Cause # RCA Report — CVE-2026-70608 R3B: preview-pane webview guest window.open escalation ## Summary hermes-agent's right-rail URL preview (`apps/desktop/src/app/chat/right-rail/preview-pane.tsx:557-561`) creates a `` whose guest webContents carries **no `setWindowOpenHandler`** (verified: no `web-contents-created` / `did-attach` / `new-window` wiring exists anywhere in `apps/desktop/electron/main.ts`). In Electron, no handler means **default ALLOW**. On Electron 40.10.2 (pinned in `apps/desktop/package.json`), the CVE-2026-70608 OpenURL bypass — a synthetic ctrl/meta-click dispatched from a sandboxed iframe **without `allow-popups`** — fires inside that guest and, because no handler exists to deny it, spawns a **real application BrowserWindow** loading attacker content. On Electron 41.10.3 the identical iframe trigger is blocked (fix confirmed). This escalates the R2 finding (where the main window's `setWindowOpenHandler` capped the effect at an allowlisted external-URL open) to outcome (a) of the ticket: a real window spawns. ## Impact - Product: hermes-agent desktop (`apps/desktop`), repo commit `e3fab0437ee50ebe511cec57b9ac36f0c2803268` - Affected runtime: Electron **40.10.2** (pinned in the repo); fixed in Electron **41.10.3** - Any page rendered in the right-rail URL preview (attacker-selected URL, e.g. a link the agent was asked to preview) can, without any user gesture, spawn real Electron `BrowserWindow`s loading attacker-controlled content — from the top-level guest document AND, on the vulnerable Electron, from a sandboxed iframe (`sandbox="allow-scripts"`, no `allow-popups`) inside it. - Consequences: attacker-controlled application windows inside the Hermes desktop (phishing chrome, permission prompts, further drive-by surface) — beyond R2's browser-external-open cap. ## Impact Parity - Disclosed/claimed maximum impact: sandbox escape (guest content escapes the preview confinement into a real application window); the ticket asked to determine outcome (a) real window spawn / (b) external path / (c) blocked. - Reproduced impact: **outcome (a)** — real `BrowserWindow` creation from the CVE bypass inside the preview webview guest (main-process `browser-window-created` + guest `did-create-window` + `did-finish-load` of the attacker marker URL), twice on 40.10.2; blocked twice on 41.10.3. - Parity: **full** for the claimed escalation question. (No renderer code execution, Node integration, or permission escalation inside the spawned window was attempted or claimed here; the spawned windows use Electron default webPreferences.) ## Root Cause 1. `preview-pane.tsx` creates the preview webview for URL targets with a persistent partition and sandboxed renderer prefs, but nothing in the main process ever attaches a `setWindowOpenHandler` (or `web-contents-created` policy) to that guest. Electron's default in this case is to honor window-open requests by creating new windows. 2. Electron ≤ 40.10.2 (CVE-2026-70608): the OpenURLFromTab path omits the initiating-frame popup-sandbox check, so a synthetic modifier-click on an anchor inside an iframe sandboxed **without `allow-popups`** is still routed as a `foreground-tab` open (the R2 primitive, revalidated here inside the webview guest). 3. Combined: the iframe sandbox bypass reaches the guest's window-open path and the guest has no handler to deny it → a real window is created. On 41.10.3 the popup-sandbox check is restored, so the iframe trigger is blocked before any window is created. Fix upstream: Electron 41.10.3 (CVE-2026-70608 fix). Product-side defense in depth: attach a `setWindowOpenHandler` (deny or route through `openExternalUrl`) to every webview guest via `app.on('web-contents-created')` for `type === 'webview'`. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; idempotent). 2. What it does: - Seeds the work repo from the prepared project cache (or clones) and checks out `e3fab0437ee50ebe511cec57b9ac36f0c2803268`. - Installs node deps with pnpm (workspace file + `pnpm import` of the root package-lock; npm itself OOMs in this container's 1.5GB cgroup) and the Python backend venv the desktop spawns (`venv/bin/python -m hermes_cli.main serve`). - Applies **observation-only** instrumentation to `electron/main.ts` (`bundle/repro/patch_instrumentation.py`, gated on `HERMES_REPRO_INSTRUMENT_LOG`): records `browser-window-created`, `web-contents-created`, guest `did-create-window`, guest console, `openExternalUrl`, `shell.openExternal` (record-only stub — never executes a real OS open), and the main window's `setWindowOpenHandler`. Guest behavior is NOT modified. - Bundles the electron main process with the product's own `scripts/bundle-electron-main.mjs --dev`, and bundles the SAME renderer sources with esbuild (`bundle/repro/bundle_renderer.mjs`; vite build/dev OOMs in the cgroup), served statically at `http://127.0.0.1:5174` and loaded via the product's own `HERMES_DESKTOP_DEV_SERVER` dev-mode entry. - Runs the Playwright driver (`bundle/repro/repro.spec.ts`) under Xvfb, twice on Electron 40.10.2 and twice on 41.10.3. Each attempt: launches the real app with a real backend and a mock OpenAI provider; sends a chat message asking to preview `http://127.0.0.1:/x`; the mock returns a real `open_preview` tool call which the backend executes; the driver clicks the product's "Open Preview" affordance and, if needed, falls back to the product's own localStorage restore path (`hermes.desktop.previewTabs.v2` + reload) to mount the pane. The preview-pane then creates the webview pointed at the attacker page, which fires `window.open` plus synthetic ctrl+meta-clicks from the top level and from a sandboxed iframe (`sandbox="allow-scripts"`, no `allow-popups`). 3. Expected evidence: per-attempt `guest.did-create-window` / `browser-window-created` / `did-finish-load` events for `…/marker?src=frame-click` (CVE bypass) on 40.10.2, and their absence (with the iframe beacon still dispatched) on 41.10.3. ## Evidence Per-attempt artifacts (two runs of the script, both `confirmed=true`): - `bundle/logs/vulnerable_attempt_{1,2}.{json,main.jsonl,log,diag.log,requests.json}` — Electron 40.10.2 - `bundle/logs/fixed_attempt_{1,2}.{json,main.jsonl,log,diag.log,requests.json}` — Electron 41.10.3 - `bundle/repro/runtime_manifest.json` — entrypoint, target identity, artifact hashes - `bundle/logs/reproduction_steps.log` — full script log Key excerpts (run 2, vulnerable attempt 1, Electron 40.10.2): ``` web-contents-created {type:'webview'} # preview guest mounted guest-console: PRUVA src=page-loaded # attacker page ran in the guest guest-console: PRUVA src=windowopen-result&value=null # gesture-less window.open blocked guest.did-create-window url=…/marker?src=top-click disposition=foreground-tab web-contents-created {type:'window'} + win.did-finish-load …/marker?src=top-click guest-console: PRUVA-FRAME src=frame-windowopen-result&value=null # iframe window.open blocked guest.did-create-window url=…/marker?src=frame-click disposition=foreground-tab # CVE bypass fires web-contents-created {type:'window'} + win.did-finish-load …/marker?src=frame-click # REAL window ``` Fixed attempt 1 (Electron 41.10.3): the iframe still dispatches (`PRUVA-FRAME src=frame-click-dispatched` beacon) but **no** `did-create-window` / window for `src=frame-click` ever occurs; only the generic top-level clicks spawn windows (unchanged pre-existing default-allow behavior for a handler-less guest, present on both versions). Outcome matrix (both full script runs): | attempt | Electron | outcome | |---|---|---| | vulnerable #1 | 40.10.2 | A_REAL_WINDOW_SPAWNED_CVE_BYPASS (cveWindows=1, genericWindows=2) | | vulnerable #2 | 40.10.2 | A_REAL_WINDOW_SPAWNED_CVE_BYPASS (cveWindows=1, genericWindows=2) | | fixed #1 | 41.10.3 | A_REAL_WINDOW_SPAWNED_GENERIC_ONLY (cveWindows=0, genericWindows=2) | | fixed #2 | 41.10.3 | A_REAL_WINDOW_SPAWNED_GENERIC_ONLY (cveWindows=0, genericWindows=2) | Environment: Ubuntu 26.04 container, x86_64, Xvfb, UID 1000, all traffic to 127.0.0.1, `shell.openExternal` record-only. Chromium OS sandbox disabled (`--no-sandbox`) because the container blocks all namespace creation (EPERM even for root); this matches the product's own e2e fixtures and does not affect the Blink iframe popup-sandbox under test. ## Recommendations / Next Steps - Upgrade the desktop app to Electron ≥ 41.10.3 (upstream CVE-2026-70608 fix). - Defense in depth (works regardless of Electron version): in the main process, attach `setWindowOpenHandler` to webview guests (`app.on('web-contents-created')`, `type==='webview'`) that denies or routes through the allowlisted `openExternalUrl`, mirroring the main window. - Consider a restrictive `session.setPermissionRequestHandler` / CSP for `persist:hermes-preview`. - Test: an e2e spec asserting that a sandboxed iframe in a preview webview cannot create windows. ## Additional Notes - Idempotency: the script was run twice consecutively end-to-end; both runs printed `confirmed=true` with the same 2×2 outcome matrix. All setup steps skip when outputs exist. - The chat → tool-call → gateway path executed for real (mock provider issued a genuine `open_preview` tool call the backend ran); the pane mount additionally used the product's own persisted-store restore path when the in-test click did not land (a test-harness timing issue, not a product defect). - The renderer is bundled by esbuild instead of vite purely because vite/rolldown exceeds the container's 1.5GB memory cgroup; identical sources and product code paths are exercised. - Separately observed (both Electron versions, pre-existing, not the CVE): the top-level guest page's synthetic ctrl+click also spawns real windows (default allow, no handler). Worth its own product hardening note. ### Reproduction - Reproduced: 2026-08-23T15:38:31.072Z - Duration: 16268s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00326 # or: pruva-verify CVE-2026-70608 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00326 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00326/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00326 ================================================================================ ## REPRO-2026-00325: Wazuh cluster DAPI deserialization of untrusted data — RCE via sort_casting builtin resolution (getattr(builtins, 'exec')) in result merging -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00325 - CVE: CVE-2026-44901 (https://nvd.nist.gov/vuln/detail/CVE-2026-44901) ### Package Information - Name: wazuh/wazuh - Ecosystem: github - Affected: wazuh-manager >= 4.0.0, < 4.14.6 (all cluster-mode deployments) - Fixed: Unknown - Severity: high - CVSS: Unknown - CWE: CWE-502 Deserialization of Untrusted Data (Deserialization of Untrusted Data) ### Root Cause ## Summary CVE-2026-44901 is a Wazuh cluster Distributed API (DAPI) deserialization vulnerability in which a malicious or compromised worker node can return a crafted serialized `AffectedItemsWazuhResult` to a master node. In vulnerable code, `AffectedItemsWazuhResult.decode_json()` accepts attacker-controlled `sort_casting` values and `merge()` resolves those values with `getattr(builtins, type_)`. A worker response containing `sort_casting=["exec"]` therefore makes the master call Python `exec()` on attacker-controlled item data while merging results from multiple nodes. ## Impact - **Affected package/component:** Wazuh manager cluster framework, specifically `framework/wazuh/core/results.py` as reached through `framework/wazuh/core/cluster/dapi/dapi.py` and the cluster TCP channel handled by `wazuh.core.cluster.master.MasterHandler` / `wazuh.core.cluster.common.Handler`. - **Affected versions:** Wazuh manager cluster deployments before the fix commit `b29849f8abb08d78f257e6106b6111a8a1b0e621` (reported as fixed in 4.14.6 and later). The reproduced vulnerable revision is the fixed commit parent: `24609e140155d7fd2bddd4ebb045dbde5bea320f`. - **Risk level and consequences:** High. A malicious/compromised worker node, or an attacker with the shared cluster key able to act as a worker on the cluster channel, can cause code/command execution in the master-side Wazuh process when a distributed API response is merged. ## Impact Parity - **Disclosed/claimed maximum impact:** Code execution on the Wazuh cluster master via the TCP/1516 Fernet-encrypted cluster channel and DAPI result merging. - **Reproduced impact from this run:** Code execution/command execution on the master-side Wazuh process. The payload executed via `exec()` and wrote unique marker files during both vulnerable attempts. - **Parity:** `full` - **Not demonstrated:** No additional privilege escalation beyond the privileges of the reproduced master-side process was claimed or required for this proof. The proof uses a minimally configured product cluster runtime rather than full Dockerized Wazuh service containers because Docker is unavailable in this environment, but it exercises the original Wazuh cluster TCP/Fernet framing, `MasterHandler`, `DistributedAPI.forward_request`, `json.loads(..., object_hook=as_wazuh_object)`, and `results.py` merge sink. ## Root Cause The root cause is unsafe deserialization and later use of trusted-as-code type names from a worker-provided JSON result object. In the vulnerable commit `24609e140155d7fd2bddd4ebb045dbde5bea320f`: - `AffectedItemsWazuhResult.decode_json()` in `framework/wazuh/core/results.py` stores `obj['sort_casting']` directly into the result object. - During DAPI result merging, `AffectedItemsWazuhResult.__or__()` calls `merge(..., types=self.sort_casting)`. - `merge()` constructs casters using `getattr(builtins, type_)` without an allowlist. - `_goes_before_than()` applies each caster to sort values. If the worker supplied `sort_casting=["exec"]`, the caster becomes Python built-in `exec`, and the corresponding item value is executed as Python source. The product path that reaches this is: 1. Master forwards a distributed request to the worker through `MasterHandler.execute(command=b'dapi_fwd', ...)`. 2. The worker response is delivered through the original Wazuh cluster string protocol (`new_str`, `str_upd`, `dapi_res`) over the Fernet-encrypted cluster TCP channel. 3. `dapi.py` parses the worker response with `json.loads(..., object_hook=c_common.as_wazuh_object)`. 4. `as_wazuh_object()` calls `AffectedItemsWazuhResult.decode_json()`. 5. With more than one node response, `dapi.py` merges results using `reduce(or_, response)`, triggering `results.py` sorting/casting. The fix commit is: - `b29849f8abb08d78f257e6106b6111a8a1b0e621` That commit adds validation in `decode_json()` and an explicit `ALLOWED_CASTERS` map in `merge()`, allowing only `int`, `float`, `str`, and `bool`. The same malicious `sort_casting=["exec"]` response is rejected with `WazuhInternalError: Invalid sort_casting type 'exec'. Allowed types: bool, float, int, str`. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh`. 2. The script: - Reuses the prepared Wazuh repository at `/pruva/project-cache/repo` when available. - Resolves the fixed commit and vulnerable parent. - Verifies the vulnerable revision still contains `getattr(builtins, type_)` and the fixed revision contains the new `sort_casting` allowlist. - Creates worktrees for `24609e140155d7fd2bddd4ebb045dbde5bea320f` and `b29849f8abb08d78f257e6106b6111a8a1b0e621`. - Starts a minimally configured real Wazuh master cluster TCP listener using `wazuh.core.cluster.master.MasterHandler` and `wazuh.core.cluster.common.Handler` with Fernet enabled. It binds to `127.0.0.1:1516` when available. - Connects a malicious worker peer over the original Wazuh cluster frame format, completes the encrypted `hello` handshake, receives the master `b'dapi'` forwarded request, and returns the crafted JSON through the original `new_str` / `str_upd` / `dapi_res` sequence. - Runs two vulnerable attempts and two fixed attempts. 3. Expected evidence: - Vulnerable attempts produce `repro/markers/product_marker_vuln_1.txt` and `repro/markers/product_marker_vuln_2.txt`, each containing the unique marker value selected for that process. - Fixed attempts reach the same DAPI/object-hook path but reject `sort_casting='exec'` and do not create marker files. - `bundle/repro/validation_verdict.json` reports `claim_outcome=confirmed`, `validated_surface=network_protocol`, `evidence_scope=production_path`, and `observed_impact_class=code_execution`. ## Evidence Primary runtime artifacts are listed and digest-bound in `bundle/repro/runtime_manifest.json`. Key files from the final successful run: - `bundle/logs/product_patch_check.log` — commit identity and patch absence/presence check. - `bundle/logs/product_vuln_1.log` and `bundle/logs/product_vuln_2.log` — vulnerable product-path attempts. - `bundle/logs/product_fixed_1.log` and `bundle/logs/product_fixed_2.log` — fixed negative-control attempts. - `bundle/repro/observations/product_vuln_1.json` and `bundle/repro/observations/product_vuln_2.json` — structured observations showing `fernet_enabled=true`, `master_listened=true`, `dapi_forward_request_received_by_worker=true`, `worker_response_delivered_via_send_string=true`, `dapi_json_object_hook_path_reached=true`, and `marker_present=true`. - `bundle/repro/observations/product_fixed_1.json` and `bundle/repro/observations/product_fixed_2.json` — structured observations showing the same network/DAPI path was reached but `sort_casting_rejected=true` and `marker_present=false`. - `bundle/repro/markers/product_marker_vuln_1.txt` and `bundle/repro/markers/product_marker_vuln_2.txt` — command-execution markers written by the vulnerable master-side process. - `bundle/repro/evil_worker_response.json` — the attacker-controlled worker JSON response containing `sort_casting=["exec"]` and the payload in `affected_items[*].x`. Representative vulnerable evidence from the product logs: - `MASTER_LISTENING original_wazuh_cluster_tcp=127.0.0.1:1516 ... fernet_key_len=32` - `WORKER_HELLO_ACCEPTED response=b'Client worker01 added'` - `WORKER_GOT_DAPI_REQUEST request_id=... json_len=550 ...` - `WORKER_SEND_STRING_UPDATED malicious JSON stored in master MasterHandler.in_str` - `WORKER_DAPI_RES_ACKNOWLEDGED master accepted dapi_res and released pending DistributedAPI request` - `MASTER_DAPI_RESULT type=AffectedItemsWazuhResult ... '_sort_casting': ['exec'] ...` - `MARKER_CHECK ... present=True content='CVE-2026-44901-vuln-...'` Representative fixed evidence: - `MASTER_DAPI_RESULT type=WazuhInternalError ... "Invalid sort_casting type 'exec'. Allowed types: bool, float, int, str"` - `MARKER_CHECK ... present=False content=None` Environment details captured: - Repository URL: `https://github.com/wazuh/wazuh.git` - Vulnerable commit: `24609e140155d7fd2bddd4ebb045dbde5bea320f` - Fixed commit: `b29849f8abb08d78f257e6106b6111a8a1b0e621` - Entrypoint: Wazuh cluster TCP peer (`entrypoint_kind="tcp_peer"`) - Runtime stack: Wazuh `MasterHandler`, Wazuh Fernet frame `Handler`, Wazuh `DistributedAPI.forward_request`, Wazuh `AffectedItemsWazuhResult.merge`, Python 3 ## Recommendations / Next Steps - Use the fixed implementation from `b29849f8abb08d78f257e6106b6111a8a1b0e621` or upgrade to Wazuh 4.14.6 or later. - Keep `sort_casting` validation both at deserialization time and at use time. Only safe, explicit caster names should be accepted. - Avoid resolving attacker-provided strings into arbitrary built-ins or callables. - Add regression tests that replay serialized worker `AffectedItemsWazuhResult` responses with invalid caster names such as `exec`, `eval`, `open`, and non-string/list values. - Consider hardening the DAPI worker-response trust boundary: even authenticated cluster workers should not be able to deserialize data structures that influence executable behavior on the master. ## Additional Notes - The final `bundle/repro/reproduction_steps.sh` was executed twice consecutively and succeeded both times. - Each script run performs two vulnerable attempts and two fixed attempts with fresh per-process marker values. - Docker was not available in this environment, so the proof uses a minimally configured local Wazuh cluster runtime from the real source tree rather than Wazuh container images. It does not reimplement the vulnerable sink or the cluster frame protocol; it imports and executes the real Wazuh modules for the master TCP listener, Fernet framing, DAPI forwarding, JSON object hook, and result merging. - The reproduction is self-contained: it creates runtime helper code and payload files at execution time, installs Python dependencies into `bundle/repro/venv` if needed, and writes all proof diagnostics under `bundle/logs/` and `bundle/repro/`. ### Reproduction - Reproduced: 2026-08-23T15:38:24.746Z - Duration: 5005s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00325 # or: pruva-verify CVE-2026-44901 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00325 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00325/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00325 ================================================================================ ## REPRO-2026-00324: Unauthenticated path traversal in xmysql `/download` allows arbitrary file read via unsanitized `req.query.name`. -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00324 - CVE: CVE-2026-72572 (https://nvd.nist.gov/vuln/detail/CVE-2026-72572) ### Package Information - Name: xmysql - Ecosystem: npm / JavaScript (Node.js, Express) - Affected: all versions (project is deprecated/superseded by nocodb; no patched version exists) - Fixed: Unknown - Severity: high - CVSS: 7.5 / 10 - CWE: CWE-22 (Path Traversal) (Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) ### Root Cause # Root Cause Analysis — CVE-2026-72572: xmysql `/download` Path Traversal ## Summary xmysql (o1lab/xmysql, a zero-config REST API generator for MySQL/MariaDB) exposes an unauthenticated `GET /download` endpoint whose handler `downloadFile(req, res)` in `lib/xapi.js` builds a filesystem path with `path.join(process.cwd(), req.query.name)` and passes it directly to Express `res.download(file)`. The `name` query parameter is never validated, normalized against, or confined to a base directory, so an unauthenticated remote attacker can supply `../` traversal sequences and read any file readable by the xmysql process. ## Impact - **Package/component:** `o1lab/xmysql` (npm `xmysql`), route handler `lib/xapi.js:downloadFile` (lines 424–427 at commit `8c6b00e`). - **Affected versions:** all versions. The project was renamed to NocoDB and the repository archived; no patched upstream version of xmysql exists. - **Risk level and consequences:** High. Unauthenticated arbitrary file read from the server filesystem (database credentials, `/etc/passwd`, TLS keys, application source, cloud metadata files on disk, etc.). The route is registered by default whenever the MySQL host is localhost (`program.dynamic = 1` in `lib/util/cmd.helper.js`) and `readOnly` is false (the default), so default deployments are exposed. ## Impact Parity - **Disclosed/claimed maximum impact:** unauthenticated remote arbitrary file disclosure (`info_leak` via `api_remote`). - **Reproduced impact from this run:** unauthenticated remote arbitrary file read — `/etc/passwd` returned byte-identical over HTTP 200, and a per-run planted secret file (`/tmp/pruva_xmysql_secret.txt` with a unique token) was recovered verbatim through the same endpoint. - **Parity:** `full`. - **Not demonstrated:** nothing claimed beyond file disclosure; no further impact was claimed or required. ## Root Cause In `lib/xapi.js`: ```js downloadFile(req, res) { let file = path.join(process.cwd(), req.query.name); res.download(file); } ``` `req.query.name` is fully attacker-controlled. `path.join` resolves `..` segments lexically, so a value such as `../../../../../../etc/passwd` escapes the process working directory entirely (excess `..` above `/` collapse to `/`). The result is handed to `res.download()`, which happily streams any file the process can read. There is: 1. no authentication middleware on the route (registered as `this.app.get("/download", this.downloadFile.bind(this))` inside the `dynamic === 1 && !readOnly` block at `lib/xapi.js:322–340`), 2. no allowlist/confined upload-download directory, and 3. no rejection of `..` or absolute-path components. - **Fix commit:** none known; xmysql is unmaintained/archived (renamed to NocoDB). ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (idempotent; run twice consecutively, both exit 0). 2. The script: - reuses the prepared project cache (`/pruva/project-cache/repo`) or clones `https://github.com/o1lab/xmysql`, then pins commit `8c6b00ee22860230975e43ab705d015d2235e308` (v0.6.0, latest master) and asserts the vulnerable line is present in `lib/xapi.js`; - installs and starts MariaDB, creates schema `reprodb` with a table, and gives the TCP account a native password (dual-mode SQL runner keeps it idempotent); - installs node dependencies with `npm install --ignore-scripts` (the declared but unused `sleep@6.1.0` native module fails to build on modern Node and is irrelevant); - starts the real product: `node bin/index.js -h 127.0.0.1 -u root -p rootpass -d reprodb -n 3000` and waits for `/_health`; - plants a unique-token secret file outside the app working directory; - sends the unauthenticated attacker request `GET /download?name=../../../../../../../etc/passwd` and `GET /download?name=../../../../../../../tmp/pruva_xmysql_secret.txt`; - runs a benign control request (`name=definitely_not_here.txt`, observed HTTP 400); - verifies byte-identity with `/etc/passwd` and token recovery, then writes `bundle/repro/runtime_manifest.json` and exits 0 on success. 3. **Expected evidence:** HTTP 200 responses with `Content-Disposition: attachment` for both traversal requests; downloaded `/etc/passwd` byte-identical to the real file; planted secret token recovered. ## Evidence - `bundle/logs/reproduction_steps.log` — full session log of both runs. - `bundle/logs/xmysql_service.log` — real xmysql startup banner ("REST APIs Generated: 135"). - `bundle/logs/health_response.json` — `/_health` response proving service liveness. - `bundle/logs/download_passwd_headers.txt` + `bundle/logs/downloaded_passwd.txt` — HTTP 200 and byte-identical `/etc/passwd` (`diff` clean, `^root:` present). - `bundle/logs/download_secret_headers.txt` + `bundle/logs/downloaded_secret.txt` — unique per-run token (e.g. `PRUVA_XMYSQL_SECRET_1786374155853866884`) recovered via traversal. - `bundle/logs/download_benign_status.txt` — benign control returned HTTP 400. - Environment: Linux x86_64, Node.js v24.18.0, MariaDB 11.8.6, xmysql v0.6.0 @ `8c6b00ee22860230975e43ab705d015d2235e308` (target digest `d7544f1501df408c3c5353b430cef5842b463250b64824974436575afb948112`). ## Recommendations / Next Steps - **Fix approach:** drop the endpoint or confine downloads to a dedicated storage directory: resolve `path.resolve(STORAGE_DIR, name)` and reject any result that does not start with `STORAGE_DIR + path.sep`; additionally reject `..`/absolute inputs and require authentication/authorization on the route. - **Upgrade guidance:** xmysql is unmaintained; migrate to NocoDB or another maintained API layer. Until then, run with `--readOnly` (or a non-localhost DB host) so the `dynamic` block (upload/download routes) is never registered, or front the service with a proxy that blocks `/download`. - **Testing recommendations:** regression test that `GET /download?name=../...` returns 4xx and that downloads are confined to the storage directory. ## Additional Notes - **Idempotency:** the script was executed twice consecutively in the same workspace and both runs exited 0; DB setup is dual-mode (unix-socket root first run, TCP password on re-runs) and service startup kills any previous instance. - **Negative control:** no patched upstream version exists (all versions affected, repository archived), so a fixed-version differential is not applicable; a benign in-cwd control request is included instead (HTTP 400 for a nonexistent file). - **Edge cases:** the route only exists when `dynamic === 1 && !readOnly`; dynamic defaults to 1 whenever the MySQL host is localhost/127.0.0.1/::1, which is the common deployment. The server binds to `localhost` (may resolve to `::1`); the script probes both `127.0.0.1` and `localhost`. ### Reproduction - Reproduced: 2026-08-23T15:38:18.998Z - Duration: 731s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00324 # or: pruva-verify CVE-2026-72572 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00324 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00324/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00324 ================================================================================ ## REPRO-2026-00323: Google::Auth for Perl command injection: external_account credentials JSON executable run via ungated system() → RCE -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00323 - CVE: CVE-2026-66902 (https://nvd.nist.gov/vuln/detail/CVE-2026-66902) ### Package Information - Name: Google-Auth - Ecosystem: Unknown - Affected: Unknown - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')) ### Root Cause # CVE-2026-66902 — Root Cause Analysis ## Summary Google::Auth for Perl (CPAN distribution `Google-Auth`, maintained at `GoogleCloudPlatform/google-auth-library-perl`) executes a command taken verbatim from an external_account credentials JSON file through a single-argument `system($command)` call. In versions before 0.06 there is no opt-in gate: any application that builds Application Default Credentials from a configuration it does not fully control runs the embedded shell command (with full `/bin/sh -c` interpretation and attacker-chosen environment variables) with the privileges of the application process. This is CWE-78 OS command injection leading to arbitrary OS command execution. ## Impact - **Package/component:** CPAN `Google-Auth` (`Google::Auth`), `lib/Google/Auth/ExternalAccountCredentials/Pluggable.pm` - **Affected versions:** < 0.06 (verified at commit `913fb1780202c1ee9dd640c28c01549903f8e23a` = fix commit parent; packaged as 0.05) - **Risk level:** Critical. Any service/workload that consumes a credentials JSON from an untrusted or partially trusted source (mounted config, user-supplied file, CI artifact) executes attacker-chosen shell commands as the application user. ## Impact Parity - **Disclosed/claimed maximum impact:** arbitrary OS command execution (RCE) in the application process. - **Reproduced impact from this run:** arbitrary shell command execution in the Perl application process. The embedded command used shell output redirection to write a unique attacker-chosen marker file, and consumed attacker-controlled environment variables copied from the same JSON (`environment_variables` map) — proving both full shell interpretation and environment injection. - **Parity:** `full`. - **Not demonstrated:** nothing material; the claimed impact (code/command execution) was reproduced directly, twice, against the real library entrypoint. ## Root Cause `Google::Auth::ExternalAccountCredentials::Pluggable::retrieve_subject_token()` (in versions < 0.06) does the following with zero validation and no opt-in: 1. Copies every entry of `credential_source.executable.environment_variables` from the credentials JSON into `%ENV`. 2. Reads `credential_source.executable.command` and runs `capture { system($command) }`. Because `system()` receives a single string, Perl invokes `/bin/sh -c`, giving the attacker pipes, redirection, command substitution, and all shell metacharacters. Dispatch reaches this subclass automatically: `Google::Auth->default()` → `Google::Auth::DefaultCredentials->from_env()` reads the JSON named by `GOOGLE_APPLICATION_CREDENTIALS` → `make_creds()` in `ExternalAccountCredentials.pm` selects the `Pluggable` subclass whenever `credential_source.executable` exists → construction succeeds with only `audience`, `subject_token_type`, `token_url`, and `credential_source` → the first `fetch_access_token()` (which every consuming application performs to use the credential) calls `retrieve_subject_token()` and executes the command before any network access. Fix commit `c95c77e70bec94f17e239d88050f843ea1cade95` (released as 0.06) adds an opt-in gate at the top of `retrieve_subject_token()` that throws unless `GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1`, plus a bounded execution timeout, schema validation of the command output, and URL domain validation in the base class. Version 0.10 additionally parses with `Text::ParseWords` and uses indirect `system` exec. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` (self-contained; re-runnable). 2. The script: - Clones `GoogleCloudPlatform/google-auth-library-perl` (into the prepared project cache when available) and resolves the vulnerable checkout as `c95c77e70bec94f17e239d88050f843ea1cade95^` (= `913fb17`) and the fixed checkout as the fix commit itself; verifies the gate string is absent in the vulnerable tree and present in the fixed tree. - Installs the pure-Perl runtime dependencies (Moo, Capture::Tiny, LWP::UserAgent, Log::Any, Throwable, URI) via apt when permitted, otherwise into a bundle-local `INSTALL_BASE` with `cpan`, then builds the real module including its XS component (`perl Makefile.PL && make`) for both versions. - Generates an attacker-controlled `external_account` credentials JSON whose `credential_source.executable.command` writes a unique marker file using shell redirection and attacker-injected environment variables, and points `token_url` at a closed localhost port so the STS exchange fails fast after the command has already executed. - Invokes the real ADC flow as a CLI command: `GOOGLE_APPLICATION_CREDENTIALS= perl -I... trigger.pl`, where `trigger.pl` calls `Google::Auth->default()` and then `fetch_access_token()` — exactly the sequence a real application performs. - Runs the matrix: 2 vulnerable attempts, 2 fixed attempts (default gated), and 1 fixed attempt with the opt-in gate set (positive control). 3. Expected evidence: both vulnerable attempts create their unique marker files; both fixed attempts throw `Pluggable credentials are not enabled. Set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1` and create no marker; the opt-in control creates its marker again. ## Evidence - Main log: `bundle/logs/reproduction_steps.log` - Per-attempt process logs: `bundle/logs/attempt_vuln_1.log`, `bundle/logs/attempt_vuln_2.log`, `bundle/logs/attempt_fixed_1.log`, `bundle/logs/attempt_fixed_2.log`, `bundle/logs/attempt_fixed_allow1.log` - Marker files (written by the injected shell command through `/bin/sh -c`): `bundle/repro/markers/vuln_1.marker`, `bundle/repro/markers/vuln_2.marker`, `bundle/repro/markers/fixed_allow1.marker` - Per-attempt observation JSONs: `bundle/repro/observations/*.json` - Attacker configs used: `bundle/repro/adc/*.json` - Runtime manifest: `bundle/repro/runtime_manifest.json` Key excerpts (identical across two consecutive runs): ``` [run] vuln attempt 1 ... RESULT: credentials class = Google::Auth::ExternalAccountCredentials::Pluggable RESULT: fetch_access_token error: Token exchange failed with status 500: Can't connect to 127.0.0.1:9 [run] vuln attempt 1: MARKER CREATED -> pwned-via-CVE-2026-66902 vuln attempt 1 shell+env injection [matrix] vulnerable attempts with marker: 2/2 RESULT: fetch_access_token error: Pluggable credentials are not enabled. Set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1 to enable. [matrix] fixed attempts blocked (no marker): 2/2 [run] fixed attempt allow1: MARKER CREATED -> pwned-via-CVE-2026-66902 fixed attempt allow1 shell+env injection [matrix] fixed+opt-in attempts with marker: 1/1 === RESULT: CONFIRMED - command injection via Pluggable external_account credentials === ``` Environment: Ubuntu 24.04, perl 5.38.2 (x86_64-linux-gnu-thread-multi), OpenSSL 3.0.13 headers for the XS build, pure-Perl deps installed into `bundle/repro/deps` (cpan `INSTALL_BASE`). No sanitizers, no mocks, no network interaction with Google endpoints (token_url is `http://127.0.0.1:9/v1/token`, which fails *after* the injected command has executed). ## Recommendations / Next Steps - Upgrade to Google-Auth 0.06 or later; 0.06 throws unless `GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1` is set, and 0.10 additionally shell-parses the command with `Text::ParseWords` and uses indirect (list-form) `system` exec, removing `/bin/sh -c` interpretation. - Treat every credentials JSON reachable by the ADC flow (`GOOGLE_APPLICATION_CREDENTIALS`, well-known paths) as executable code: restrict write access, prefer trusted provisioning, and avoid setting the opt-in gate. - Regression testing: the upstream fix commit already adds tests (`t/16-pluggable-credentials.t`); downstream should additionally test that a `credential_source.executable` config without the gate never spawns a process. ## Additional Notes - **Idempotency:** the script was executed twice consecutively (plus two more times after adding unique per-attempt markers/observations) — every run produced the full matrix result (2/2 vulnerable markers, 2/2 fixed blocked, 1/1 opt-in control) and exit code 0. - The command executes *before* the STS token exchange; the proof intentionally uses a closed-loopback `token_url` so no external network call is needed and the marker is created regardless of the later (expected) STS failure. - Both the claimed entrypoint variants are supported: the primary proof uses `Google::Auth->default()` (requires the XS build, which the script performs); if the XS toolchain were unavailable the script falls back to `Google::Auth::DefaultCredentials->from_env()`, which is the same dispatch path named in the claim. - Edge case: the vulnerable code only executes the command when a token is fetched, not at config parse time — matching real application behavior, since any consumer of the credential calls `fetch_access_token()` to use it. ### Reproduction - Reproduced: 2026-08-23T15:38:13.515Z - Duration: 1012s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00323 # or: pruva-verify CVE-2026-66902 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00323 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00323/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00323 ================================================================================ ## REPRO-2026-00322: Jenkins Remoting JEP-200 deserialization filter bypass (SECURITY-3911): unfiltered ClassNotFoundException fallback in MultiClassLoaderSerializer.resolveClass and ObjectInputStreamEx.resolveClass lets agents deserialize blocked core-classpath classes on the controller -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00322 - CVE: CVE-2026-70426 (https://nvd.nist.gov/vuln/detail/CVE-2026-70426) ### Package Information - Name: jenkinsci/remoting (hudson.remoting) - Ecosystem: Unknown - Affected: Unknown - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-502 (Deserialization of Untrusted Data) ### Root Cause # RCA: Jenkins Remoting SECURITY-3911 / CVE-2026-70426 — JEP-200 Deserialization Filter Bypass ## Summary Jenkins Remoting (agent–controller communication library) contains two unfiltered `ClassNotFoundException` fallback paths in its deserialization class-resolution code. In `hudson.remoting.MultiClassLoaderSerializer.Input.resolveClass()` (and identically in `hudson.remoting.ObjectInputStreamEx.resolveClass()`), the primary path resolves the incoming class name against the channel-annotated classloader and then applies the JEP-200 class filter (`channel.classFilter.check(c)`). When that lookup throws `ClassNotFoundException`, the fallback `super.resolveClass(desc)` resolved the class via the receiving JVM's own classloader **without applying the class filter**. An attacker able to speak the agent protocol (Agent/Connect permission, a compromised agent, or code running on an agent) can therefore deserialize a JEP-200-blocked class on the Jenkins controller by serializing it with a spoofed `TAG_SYSTEMCLASSLOADER` annotation, forcing the `ClassNotFoundException` fallback. The blocked class's `readObject` then executes in the controller JVM, yielding remote code execution on the controller. ## Impact - Package/component: `org.jenkins-ci.main:remoting` (Jenkins Remoting), embedded in Jenkins core (`jenkins.war`). - Affected: Remoting <= 3384.v60d89463d9e0 (except backport 3355.3357.v931d3c992987); Jenkins weekly <= 2.575; LTS <= 2.568.1. - Fixed: Remoting 3385.vf1123fb_515da_ (Jenkins 2.576 / LTS 2.568.2). - Risk: Critical (CVSS 3.1 9.6, AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H) — agent-to-controller RCE. AC:H because the gadget class must live on the controller's core classpath and evade the pre-JEP-200 static denylist (which this bypass does not defeat). ## Impact Parity - Disclosed/claimed maximum impact: arbitrary code execution on the Jenkins controller from the agent side of a Remoting channel (RCE). - Reproduced impact from this run: **OS command execution on the Jenkins controller JVM** across the real JNLP4-connect (TCP) channel, proven twice by two independent direct OS-level observations per attempt: (1) `/bin/sh -c` executed on the controller writing a marker file with `id`/`hostname` output inside the controller container (`uid=0(root) ... jvm_pid=...`), and (2) an outbound TCP callback from the controller JVM to an attacker-controlled listener carrying the per-attempt token and the controller hostname. - Parity: `full`. - Not demonstrated: nothing material; the ClassCastException after `readObject` detonation is an artifact of the minimal PoC gadget (Serializable-only), not a limitation of the bypass. A real-world exploit would use a gadget class already on the core classpath whose `readObject` performs the malicious action (exactly the pattern this PoC models). ## Root Cause `src/main/java/hudson/remoting/MultiClassLoaderSerializer.java` (vulnerable line 137): ```java } catch (ClassNotFoundException ex) { return super.resolveClass(desc); // <-- no channel.classFilter.check(...) } ``` and identically `src/main/java/hudson/remoting/ObjectInputStreamEx.java` line 64: ```java } catch (ClassNotFoundException ex) { return super.resolveClass(desc); // <-- no filter.check(...) } ``` The name-based check `channel.classFilter.check(name)` still runs first, so classes on the pre-JEP-200 static denylist (e.g. commons-collections functors) remain blocked; but the *class-level* JEP-200 check (`jenkins.security.ClassFilterImpl.isBlacklisted(Class)`), which rejects classes whose code location is not Jenkins core/Remoting/a plugin and which are not in `whitelisted-classes.txt`, is skipped on the fallback path. An attacker forces the fallback by writing `TAG_SYSTEMCLASSLOADER` (-3) as the class annotation: the receiver then tries `Class.forName(name, false, null)` (bootstrap loader), which throws `ClassNotFoundException` for any non-JDK class, and the fallback resolves the class through the deserializing frame's classloader (on a real controller, the Jetty webapp classloader, which can see the whole core classpath and delegates to the application classloader) — completely bypassing the JEP-200 decision. Fix commit: `f1123fb515da74560db60645539019cfa77bce49` (jenkinsci/remoting, released as 3385.vf1123fb_515da_): both fallbacks became `return channel.classFilter.check(super.resolveClass(desc));` / `return filter.check(super.resolveClass(desc));`. Diff captured in `bundle/repro/security3911-fix.diff`; vulnerable/fixed source lines captured in `bundle/logs/vuln_unfiltered_fallback.txt` and `bundle/logs/fixed_filtered_fallback.txt`. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; uses only Docker images `jenkins/jenkins:2.575-jdk21`, `jenkins/jenkins:2.576-jdk21`, `maven:3.9-eclipse-temurin-21`, `alpine:3.22` plus the harness sources in `bundle/repro/harness/`). 2. What it does: - Records the image digests and the war manifests (`Remoting-Embedded-Version`: 3384.v60d89463d9e0 for 2.575, 3385.vf1123fb_515da_ for 2.576) and the real fix-commit diff from a jenkinsci/remoting checkout (verifying the exact vulnerable and fixed lines). - Extracts the byte-identical `remoting-3384.v60d89463d9e0.jar` from the vulnerable war and compiles the PoC gadget (`hudson.security3911.Payload`, Serializable, `readObject` executes `/bin/sh` + outbound callback) into `payload.jar`, and the attack agent. - For each of two vulnerable and two fixed attempts: boots a **real Jenkins controller** (fresh `JENKINS_HOME`, setup wizard disabled, inbound-agent TCP listener on port 50000, production `JnlpSlaveAgentProtocol4` accept path) with `payload.jar` on the controller's own launch classpath (a harness jar, as sanctioned by the ticket's reproduction requirements — the filter, channel, and `UserRequest.deserialize` path are 100% product code). `init.groovy.d` creates the `agent1` inbound node, prints its JNLP secret, and proves the production JEP-200 filter identity: `SECURITY3911_CHANNEL_DEFAULT_FILTER=jenkins.security.ClassFilterImpl` and `SECURITY3911_FILTER_PROBE=REJECTED: Rejected: hudson.security3911.Payload` (i.e. the production filter blocks the payload class at class level on both builds). - The attack agent performs the **real JNLP4-connect handshake** using the production negotiation classes (`JnlpAgentEndpoint`, `JnlpProtocolHandlerFactory`, `JnlpProtocol4Handler`, `IOHub`, `PublicKeyMatchingX509ExtendedTrustManager`), builds a genuine `hudson.remoting.UserRequest`, replaces its serialized request bytes with bytes produced by a `SpoofedTagSystemClassLoaderOutput` (exactly the regression-test helper from the fix commit), and sends it over the established channel. The controller's `DefaultJnlpSlaveReceiver.afterChannel`/`SlaveComputer.setChannel` production flow runs on the same connection. 3. Expected evidence: - Vulnerable (2.575): `Payload.readObject` executes on the controller during `UserRequest.deserialize` → marker file `/tmp/CONTROLLER_PWNED_.txt` inside the controller container (contents include `uid=0(root)` and the controller hostname) AND an outbound callback `SECURITY3911_CALLBACK token=... phase=readObject controller_host=` received by the attacker's listener. - Fixed (2.576): `SecurityException: Rejected: hudson.security3911.Payload; see https://jenkins.io/redirect/class-filter/` returned over the channel; no marker, no callback. ## Evidence - Full run log: `bundle/logs/reproduction_steps.log` (two consecutive full runs, both `OVERALL=0`, exit code 0). - Per-attempt controller and agent logs: `bundle/logs/attempt-{vuln,fixed}-{1,2}/{controller,agent}.log`. - Direct OS-execution markers (collected from inside the controller containers via `docker exec`): `bundle/logs/attempt-vuln-1/CONTROLLER_PWNED_security3911-vuln-1-*.txt` and `bundle/logs/attempt-vuln-2/CONTROLLER_PWNED_security3911-vuln-2-*.txt`, e.g.: ``` SECURITY3911_MARKER token=security3911-vuln-1-1786004490 phase=readObject uid=0(root) gid=0(root) groups=0(root) dc3492613861 <- controller container hostname jvm_pid=100 ``` - Outbound callback (agent log): `CALLBACK_RECEIVED from=/172.19.0.2:38064 msg=SECURITY3911_CALLBACK token=security3911-vuln-1-1786004490 phase=readObject controller_host=dc3492613861` — the source IP and hostname belong to the controller container, proving the controller JVM executed attacker code and dialed out. - Vulnerable-channel exception (expected, post-detonation): `ClassCastException: class hudson.security3911.Payload cannot be cast to class hudson.remoting.Callable (hudson.security3911.Payload is in unnamed module of loader 'app'; ...)` — confirms the fallback resolved the class via the controller-side loader. - Fixed-channel rejection: `EXCEPTION=Error: Failed to deserialize the Callable object. <- SecurityException: Rejected: hudson.security3911.Payload; see https://jenkins.io/redirect/class-filter/` with `CALLBACK_COUNT=0` and no marker. - Production filter provenance (controller log, both builds): `SECURITY3911_CHANNEL_DEFAULT_FILTER=jenkins.security.ClassFilterImpl`, `SECURITY3911_FILTER_PROBE=REJECTED: Rejected: hudson.security3911.Payload`, and the JUL line `jenkins.security.ClassFilterImpl#notifyRejected: hudson.security3911.Payload in file:/opt/harness/payload.jar might be dangerous, so rejecting`. - Negative control: `bundle/repro/negative_control_observation.json` (both fixed attempts: rejection observed, `command_executed=false`, zero callbacks, zero marker files). - Environment: Docker 29.1.3; `jenkins/jenkins:2.575-jdk21` (`@sha256:16778c994cfc...`, Remoting 3384.v60d89463d9e0) and `jenkins/jenkins:2.576-jdk21` (`@sha256:8c1c7e28b463...`, Remoting 3385.vf1123fb_515da_); OpenJDK 21 controllers/agents; harness SHA-256s in `bundle/logs/harness_sha256.txt`. ## Recommendations / Next Steps - Upgrade to Jenkins 2.576 / LTS 2.568.2 (Remoting 3385.vf1123fb_515da_) or the 3355.3357.v931d3c992987 backport line. - The fix is correct and minimal: route both `ClassNotFoundException` fallbacks through the channel's class filter, mirroring the primary path. - Defense in depth: restrict Agent/Connect, isolate agents, and monitor for `Rejected: ... class-filter` log lines plus unexpected outbound connections from the controller. - Testing: the fix commit's regression tests (`ClassFilterTest` `multiClassLoaderSerializer_spoofedSystemClassLoader_isRejected` and `objectInputStreamEx_emptyClassLoader_fallbackIsFiltered`) cover both fallbacks; this run reproduces the same spoof over the real JNLP4/TCP production path. ## Additional Notes - Idempotency: the script runs two clean attempts per role with fresh `JENKINS_HOME` per attempt, unique per-attempt tokens, a fresh Docker network per run, and full container cleanup; two consecutive end-to-end runs both passed (`OVERALL=0`). - The second vulnerable fallback (`ObjectInputStreamEx.resolveClass`) is exercised when the remote peer does not advertise multi-classloader RPC capability; it is fixed by the same commit and follows the identical pattern (documented in the diff); the primary proof uses the `MultiClassLoaderSerializer` path, which is the default for JNLP4 agent channels. - The harness payload class implements only `Serializable` (not `hudson.remoting.Callable`) because the controller's webapp/application classloader split would otherwise fail linking; this mirrors a real gadget whose effect lives in `readObject`, and it makes the proof stronger: execution happens during deserialization, before any `Callable` cast or arbitrary-callable permission check. - Fresh Jenkins installs do not auto-install bundled detached plugins; the script installs the war-bundled `instance-identity.hpi` + `bouncycastle-api.hpi` into `JENKINS_HOME/plugins` (what the setup wizard would do) so the production JNLP4 TLS listener is functional. ### Reproduction - Reproduced: 2026-08-23T15:38:07.273Z - Duration: 9763s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00322 # or: pruva-verify CVE-2026-70426 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00322 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00322/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00322 ================================================================================ ## REPRO-2026-00321: huggingface/transformers <5.10.0: path traversal via chat_template dict keys in save_pretrained() → arbitrary file write → RCE (cron.d drop) -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00321 - CVE: CVE-2026-9856 (https://nvd.nist.gov/vuln/detail/CVE-2026-9856) ### Package Information - Name: huggingface/transformers - Ecosystem: Unknown - Affected: Unknown - Fixed: Unknown - Severity: high - CVSS: Unknown - CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')) ### Root Cause # CVE-2026-9856 — Root Cause Analysis ## Summary huggingface/transformers `<5.10.0` contains a path traversal (CWE-22) in `save_pretrained()`. When a tokenizer (or processor) is loaded from an attacker-controlled artifact whose `tokenizer_config.json` carries a `chat_template` **dictionary**, the dictionary keys are used verbatim as filenames by `save_chat_templates()` (`src/transformers/tokenization_utils_base.py`) and by `ProcessorMixin.save_pretrained()` (`src/transformers/processing_utils.py`): `template_filepath = os.path.join(chat_template_dir, f"{template_name}.jinja")` with no sanitization. A traversal key such as `../../../etc/cron.d/hf_pwn` escapes the save directory and writes fully attacker-controlled content (with a forced `.jinja` suffix) anywhere the victim process can write. This run demonstrates the full impact chain: the escaped file is dropped into `/etc/cron.d`, where a pre-existing, genuine cronie daemon naturally loads it and independently spawns `/bin/sh` running the attacker's command — arbitrary code execution with the victim's privileges. ## Impact - **Package:** huggingface/transformers (`save_pretrained` of `PreTrainedTokenizerBase` and `ProcessorMixin`). - **Affected versions:** `>=4.52.0, <5.10.0` (verified vulnerable: `5.9.0`; NVD's `<=5.8.0.dev0` understates the range). - **Fixed versions:** `5.10.0` (yanked ~20 min after release) / `5.10.1+`. - **Risk:** High. A victim application that loads an attacker-controlled model (`from_pretrained`) and later calls `save_pretrained()` — automatic in Trainer checkpointing, model conversion/re-hosting, and fine-tune export — writes attacker-controlled content to arbitrary filesystem paths. Realistic execution targets include `/etc/cron.d` (cron ignores the `.jinja` extension) and overwriting existing `.jinja` templates rendered by a service. CVSS UI:R; the attacker needs no authentication. ## Impact Parity - **Disclosed/claimed maximum impact:** arbitrary file write → remote/code execution (e.g., cron.d drop), `code_execution`. - **Reproduced impact from this run:** `code_execution` — two fresh, isolated victim processes running real `transformers==5.9.0` each produced a unique marker file written by a `/bin/sh` process that the genuine cronie 1.7.2 daemon independently spawned from the attacker-controlled `/etc/cron.d/hf_pwn.jinja`. The identical artifact against `transformers==5.10.1` raised `ValueError`, wrote no cron file, and produced no marker. - **Parity:** `full`. - **Not demonstrated:** nothing — the claimed code-execution impact was reproduced end to end through the real library API and a real system daemon. ## Root Cause `save_chat_templates()` iterates `tokenizer.chat_template.items()` when the chat template is a dict and computes `template_filepath = os.path.join(chat_template_dir, f"{template_name}.jinja")`. `template_name` comes verbatim from `tokenizer_config.json`, which is fully attacker-controlled when the model artifact is untrusted. No normalization or containment check is applied, so keys containing `..` (or absolute paths) escape `OUT/additional_chat_templates/`. The library only `mkdir`s its own `additional_chat_templates` dir, so the write succeeds whenever the attacker-chosen parent directory (e.g. `/etc/cron.d`) already exists and is writable by the victim. The identical flaw exists in `ProcessorMixin.save_pretrained()` (`processing_utils.py`). Fix: PR #46191, merge commit `eaaaf8494dd5386634ae37d1d122212fdc315be5` (2026-05-25), first shipped in `5.10.0`/`5.10.1`. The fix resolves `template_filepath`'s parent and compares it to the resolved `chat_template_dir`, raising `ValueError` on mismatch (a 3-line guard in both files), plus regression tests using a `../../PWNED` key. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; helper: `bundle/repro/hf_cron_exploit.py`). 2. The script: - Anchors source identity to the fixed commit `eaaaf8494dd5386634ae37d1d122212fdc315be5`: its parent (`47949d3a0e1cf9248f2a3eb3cd0deb12ee37b9e9`) lacks the guard, the fix commit contains it (`logs/source_identity.log`). - Builds two Docker images from the immutable `fedora:42` digest with cronie 1.7.2 and the exact PyPI releases `transformers==5.9.0` (vulnerable) and `transformers==5.10.1` (fixed). - In each isolated container: starts a real `crond` **before** the attacker input, builds a tokenizer whose `tokenizer_config.json` contains `chat_template = {"../../../etc/cron.d/hf_pwn": "* * * * * root /bin/sh -c \"echo '' > /proof/.txt\""}`, then calls the real API `AutoTokenizer.from_pretrained(dir).save_pretrained(out)`. - Waits for cronie to naturally load `/etc/cron.d/hf_pwn.jinja` and spawn the payload; the script never executes the dropped file. - Runs two fresh vulnerable victims and one fixed negative control. 3. Expected evidence: `SAVE_RESULT=RETURNED`, `CRON_FILE_PRESENT=true`, `CRON_CONTENT_MATCH=true`, cronie's own `log_it ... CMD (/bin/sh -c ...)` line, and a unique marker file per vulnerable attempt; `SAVE_RESULT=BLOCKED` / `CRON_FILE_PRESENT=false` / no marker for the fixed control. ## Evidence - `bundle/logs/reproduction_steps.log` — full driver log (two consecutive full passes succeeded; markers `...-1651` then `...-2249`). - `bundle/logs/source_identity.log` — fix-commit anchor and guard diff. - `bundle/logs/vulnerable_cron_attempt1.log`, `bundle/logs/vulnerable_cron_attempt2.log` — `SAVE_RESULT=RETURNED`, `CRON_FILE_PRESENT=true`, `CRON_CONTENT_MATCH=true`, `MARKER_PRESENT=true`, and cronie lines such as `log_it: (root 92) CMD (/bin/sh -c "echo 'CVE-2026-9856-RCE-VULNERABLE-1-2249' > /proof/...")`. - `bundle/logs/fixed_cron_control.log` — `SAVE_RESULT=BLOCKED`, `CRON_FILE_PRESENT=false`, `MARKER_PRESENT=false` (ValueError guard). - `bundle/repro/marker_run1.txt`, `bundle/repro/marker_run2.txt` — unique per-attempt markers written by the crond-spawned shell. - `bundle/repro/negative_control_fixed.json` — strict negative-control observation (`target_path_reached=true`, `marker_present=false`). - `bundle/repro/runtime_manifest.json` — entrypoint `function_call`, target identity, and artifact digests. - Environment: Docker 27.5.1, immutable base `fedora@sha256:99e203b80b1c3d8f7e161ec10a68fd02b081ef83a3963553e513c82846b97814`, cronie 1.7.2, Python 3.13, `transformers==5.9.0` / `5.10.1`, x86_64. ## Recommendations / Next Steps - Upgrade to `transformers>=5.10.1` (5.10.0 was yanked). - The upstream guard (resolve the template path's parent and require it to equal the resolved `chat_template_dir`) is the correct containment fix; downstream backports should mirror it in both `tokenization_utils_base.py` and `processing_utils.py`. - Defensive controls: never call `save_pretrained()` on artifacts loaded from untrusted sources without sandboxing; run converters/exporters with least privilege so `/etc/cron.d` and similar directories are not writable. - Detection: watch for non-system processes opening files under `/etc/cron.d`, and for processes whose parent is `crond` executing unexpected commands. ## Additional Notes - Idempotency: the script was executed twice consecutively in this run; both passes confirmed (exit 0), with fresh unique markers per pass. Images are rebuilt deterministically from the immutable base digest; per-attempt containers are removed after each case. - The forced `.jinja` suffix does not prevent cron execution: cronie loads every file in `/etc/cron.d` regardless of extension. The write requires the victim to have write permission on the chosen parent directory (root in the demonstration container, matching the ticket's stated precondition). - `from_pretrained()` alone is safe; `save_pretrained()` is the trigger. - This run independently revalidated the durable mechanics from prior exploit-knowledge records (arbitrary write primitive `793c4388-299a-489f-a791-5e7b7f2e66d3`, crond-mediated control flow `ffcce804-7fa3-4ec4-a772-357db3da686c`, command-execution capability `19959132-b2f0-4e9f-8e66-d7e6ab23edf5`) with fresh current-run evidence. ### Reproduction - Reproduced: 2026-08-23T15:38:01.119Z - Duration: 19s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00321 # or: pruva-verify CVE-2026-9856 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00321 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00321/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00321 ================================================================================ ## REPRO-2026-00320: CodeIgniter4 is_image/mime_in upload validation bypass — unrestricted file upload leading to RCE -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00320 - CVE: CVE-2026-63223 (https://nvd.nist.gov/vuln/detail/CVE-2026-63223) ### Package Information - Name: codeigniter4/framework - Ecosystem: github - Affected: < 4.7.4 - Fixed: 4.7.4 - Severity: critical - CVSS: Unknown - CWE: CWE-434 (Unrestricted Upload of File with Dangerous Type) ### Root Cause # RCA Report — CVE-2026-63223: CodeIgniter4 is_image/mime_in Upload Validation Bypass → RCE ## Summary CodeIgniter4 versions before 4.7.4 validate file uploads with the `is_image()` and `mime_in()` rules (`system/Validation/StrictRules/FileRules.php`) using only the **content-sniffed** MIME type (finfo magic bytes). They never compare the client-supplied filename extension with the detected content type. An attacker can therefore upload a polyglot file whose first bytes are `GIF89a` (sniffed as `image/gif`) but whose body contains PHP code, named `shell.php`. Validation trusts the content (image ✔); the web server trusts the extension (`.php` → execute). This confused-deputy gap yields unrestricted file upload and remote code execution when the application saves the upload under the client filename into a web-accessible, PHP-enabled directory. ## Impact - Package/component: `codeigniter4/framework` — `CodeIgniter\Validation\StrictRules\FileRules::is_image()` and `::mime_in()` (non-strict `FileRules` is an empty subclass, so both rule sets are affected). - Affected versions: < 4.7.4 (reproduced on v4.7.3). - Risk level: critical — unauthenticated remote code execution on any application that (1) validates uploads with `is_image` or `mime_in` without `ext_in`, (2) preserves the client filename on save, and (3) stores uploads in a web-accessible, PHP-enabled directory. ## Impact Parity - Disclosed/claimed maximum impact: code execution (unrestricted file upload → RCE). - Reproduced impact from this run: **code execution** — a GIF89a+PHP polyglot uploaded as `shell.php` passed `is_image` validation on v4.7.3, was saved to `public/uploads/shell.php`, and an HTTP GET to `/uploads/shell.php?cmd=...` executed attacker-controlled commands (`echo ` and `id`, returning `uid=1000(vscode) ...`) in 2/2 clean attempts. - Parity: `full`. - Not demonstrated: nothing — the claimed impact was reproduced end-to-end through the real HTTP boundary. ## Root Cause `FileRules::is_image()` (v4.7.3) checks `uploaded[]`, then calls `$file->getMimeType()` (finfo content sniffing) and accepts the file if the detected type starts with `image`. `mime_in()` likewise compares only the sniffed type against an allow-list. Neither rule inspects `$file->getClientExtension()` / `$file->getClientName()`. Because a polyglot can simultaneously be valid GIF89a content and executable PHP source, the validator and the web server reach different conclusions about the same file: - Fix (v4.7.4, release commit `67ead895b7491703e5e5bc17436778806192008f`): adds `hasInvalidImageClientExtension()` to `is_image()` (reject non-empty client extensions that do not map to an `image/*` MIME type) and `hasMismatchedClientExtension()` to `mime_in()` (reject client extensions that do not match the extension guessed from the sniffed content), in `system/Validation/StrictRules/FileRules.php`. - Note: the ticket names fixed commit `b6e9a4fa`. That hash is not present in the `codeigniter4/framework` distributable mirror (which receives squashed release commits from the `codeigniter4/CodeIgniter4` development repo). The v4.7.4 release tag was verified to contain exactly the named fix helpers and was used as the fixed checkout; patch-anchor verification (helper absent in v4.7.3, present in v4.7.4) is enforced by the script on every run. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; run twice consecutively, exit 0 both times). 2. The script: - Installs PHP CLI + extensions and Composer if absent. - Clones `codeigniter4/framework` into the prepared project cache (`/repo`) or `bundle/artifacts/framework` as fallback. - Verifies the patch anchor (fix helper absent at v4.7.3 / present at the fixed ref). - Writes a real upload controller (`app/Controllers/Upload.php`) using the rule `uploaded[userfile]|is_image[userfile]` (no `ext_in`) that saves with the client filename into `public/uploads/`, plus a `POST /upload` route. - Builds a GIF89a+PHP polyglot (verified to sniff as `image/gif`) and a plain-text negative control. - Starts the real product server (`php spark serve`, PHP built-in web server bound to 127.0.0.1) and runs, for both v4.7.3 and the fixed ref: - Negative control: plain-text `plain.php` upload → rejected (rule active). - 2 clean vulnerable attempts: POST polyglot as `shell.php`, then GET `/uploads/shell.php?cmd=echo ;id`. - 2 clean fixed attempts: same POST, then GET. 3. Expected evidence: v4.7.3 returns `{"status":"saved"}` and the GET response contains the unique per-attempt marker plus `uid=` output (and not the raw `getRandomName()`), store uploads outside the webroot or behind a controller, and disable PHP execution in upload directories at the web-server layer. - Testing: add regression tests that upload a `GIF89a`+PHP polyglot named `shell.php` and assert rejection under `is_image`/`mime_in`. ## Additional Notes - Idempotency: the script was run twice consecutively; both runs exited 0 with 2/2 vulnerable RCE attempts and 2/2 fixed rejections. It re-checkouts, re-applies the overlay, and removes `public/uploads` before every attempt, so it is safe to re-run. - Edge cases/limitations: RCE requires the three deployment preconditions listed above (validation without `ext_in`, client filename preserved, web-accessible PHP-enabled upload dir). The proof uses `php spark serve` (the framework's documented development server built on PHP's web server); any deployment that executes `.php` under the upload directory (Apache mod_php, PHP-FPM with typical location rules) exhibits the same behavior. If uploads are stored outside the docroot or PHP is disabled there, the validation bypass still occurs (file is accepted) but code execution is not reachable — that is a deployment mitigation, not a fix. - The named fixed commit `b6e9a4fa` could not be resolved in the `codeigniter4/framework` mirror; the v4.7.4 tag contains the exact helpers described in the advisory and was used with patch-anchor verification. ### Reproduction - Reproduced: 2026-08-23T15:36:57.228Z - Duration: 1442s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00320 # or: pruva-verify CVE-2026-63223 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00320 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00320/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00320 ================================================================================ ## REPRO-2026-00319: MariaDB Galera SST remote_auth shell command injection (wsrep_shell_char blacklist bypass) — candidate for v12sec 2026-07-31 0day -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00319 ### Package Information - Name: MariaDB/server - Ecosystem: github - Affected: Unknown - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')) ### Root Cause ## Summary MariaDB Galera's donor-side State Snapshot Transfer (SST) path accepts a joiner-controlled `remote_auth` value and makes it available to `wsrep_sst_mariabackup`. In vulnerable MariaDB 11.8.6, a certificate CommonName supplied by the joining peer becomes the remote username and is interpolated inside a shell command string later executed with Bash `eval`. A CommonName containing a quote and shell control operators therefore escapes the intended `socat` `commonname` argument and executes an arbitrary command under the donor's `mariadbd` OS account. This run confirmed the issue through a real two-node Galera cluster and TCP SST exchange. ## Impact - **Affected package/component:** MariaDB Server with Galera/wsrep enabled, specifically donor-side `wsrep_sst_mariabackup` SST handling and the `remote_auth` data path in `sql/wsrep_sst.cc`. - **Runtime version proven vulnerable:** MariaDB 11.8.6 (`mariadb:11.8.6`, image digest `sha256:78a5047d3ba33975f183f183c2464cc7f1eab13ec8667e57cc9a5821d6da7577`, source revision `9bfea48ce1214cc4470f6f6f8a4e30352cef84e7` as reported by the image). The source identity used for the submitted 11.8.8 context is commit `46a8eb42a520193686d9a16d4cea4b3e002917e4`; it still lacks the strict `remote_auth` allowlist fix. - **Affected version family:** The unsafe donor behavior is present before the MDEV-40056 fix. The ticket identifies 11.8.8 and 10.11.18 as still lacking proposed commit `581562f94a`; this run directly executed the vulnerable path on 11.8.6. - **Risk:** Critical. A party able to join or impersonate a Galera peer and trigger mariabackup SST can execute arbitrary shell commands on a donor as the `mariadbd` service user. This enables database-file access, credential theft, destructive modification, and lateral movement with that account's privileges. ## Impact Parity - **Disclosed/claimed maximum impact:** Remote command/code execution on the donor as the `mariadbd` OS user through a joiner-controlled wsrep SST request. - **Reproduced impact:** A real malicious joiner connected over the Galera TCP boundary, requested mariabackup SST, and caused `id` to run on the donor. The resulting marker contains `uid=999(mysql) gid=999(mysql) groups=999(mysql)`. - **Parity:** `full` - **Not demonstrated:** Privilege escalation beyond the MariaDB service account was not attempted or required. The mysqldump method was not needed for the full impact proof; mariabackup provided the production-path RCE required by the claim. ## Root Cause The vulnerable data flow is: 1. A joining Galera peer connects to the donor through the wsrep TCP protocol and requests SST. 2. The joiner's SST listener prepares an address containing authentication data derived from its TLS certificate CommonName. The donor parses everything before the final `@` as `remote_auth`. 3. In the vulnerable release, `sql/wsrep_sst.cc` splits that value into `auth.remote_name_` and `auth.remote_pswd_` without the strict filename-character allowlist later introduced by MDEV-40056. 4. Donor startup information carries the remote username into `WSREP_SST_OPT_REMOTE_USER` in `wsrep_sst_mariabackup`. 5. The script builds a string such as `...,commonname='$WSREP_SST_OPT_REMOTE_USER'` and passes the composed pipeline to `timeit()`, which executes `eval $cmd`. 6. A remote username like `x';id>/var/lib/mysql/MDEV40056_MARKER;sleep 3;#` closes the intended quote, inserts commands, and comments out the trailing quote. Bash executes `id` as the donor process account. The ticket's intermediate `wsrep_shell_char` blacklist is also structurally unsafe because it permits shell metacharacters such as `;`, `|`, `&`, parentheses, and redirections. More fundamentally, a blacklist is unsuitable for values that may reach shell parsing. On the executed 11.8.6 path there is no effective strict `remote_auth` allowlist before the value reaches the script. The proposed fix is commit [`581562f94a83f29dcf2c6cc761b49ad55d9c287a`](https://github.com/MariaDB/server/commit/581562f94a83f29dcf2c6cc761b49ad55d9c287a). It splits the remote authentication value, validates both user and password with `wsrep_filename_char`, returns `Bad remote auth string. SST canceled.` on any disallowed character, and removes the permissive `wsrep_shell_char` path. Related fixed scripts also stop reading donor authentication from unsafe stdin and avoid placing remote auth into `commonname` for `VERIFY_CA`. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` from any directory. It honors `PRUVA_ROOT`, uses the prepared repository at `/repo` when available, and otherwise clones into `bundle/artifacts/mariadb-server`. 2. The script pins and verifies the vulnerable MariaDB image, creates a local CA plus donor/joiner certificates, and gives the joiner certificate the malicious CommonName. 3. It starts a real MariaDB/Galera donor, waits for a healthy synchronized node, then starts a real joiner connected to the donor's wsrep TCP listener. The clean join forces mariabackup SST. 4. It polls the donor for the command marker, copies the marker into the bundle, captures donor/joiner logs and loader/linkage evidence, then runs the exact fixed allowlist logic from commit `581562f94a` as a negative control. 5. Expected result: exit status `0`, `bundle/repro/donor_command_marker.txt` shows the `mysql` UID/GID, and the fixed control records `fixed_result=REJECTED` without a marker. ## Evidence - `bundle/logs/vulnerable_donor.log` - Shows a peer connection to the donor's TCP wsrep listener. - Shows `Member 1.0 (joiner) requested state transfer` and `Detected STR version: 1`. - Shows donor execution of `wsrep_sst_mariabackup` and the injected command in the evaluated transport string: ```text commonname='x';id>/var/lib/mysql/MDEV40056_MARKER;sleep 3;#' ``` - `bundle/repro/donor_command_marker.txt` - Contains: ```text uid=999(mysql) gid=999(mysql) groups=999(mysql) ``` - `bundle/logs/vulnerable_joiner.log` - Captures the real joining server, SST listener, and protocol-side state transfer activity. - `bundle/logs/fixed_donor.log` - Records `fixed_result=REJECTED` from the exact `wsrep_filename_char` split/check behavior and includes the fixed source block that emits `Bad remote auth string. SST canceled.` - `bundle/repro/fixed_negative_control.json` - Records the fixed-control process identity, reached validator path, and absence of the marker. - `bundle/logs/source_identity.log` - Binds source commits and immutable Docker image digests. - `bundle/logs/product_linkage.log` - Captures `ldd` output and SHA-256 hashes for `/usr/sbin/mariadbd` and `/usr/lib/galera/libgalera_smm.so`. - `bundle/repro/runtime_manifest.json` - Declares `entrypoint_kind=tcp_peer`, service/health/path flags, source/image identity, and SHA-256 digests of proof artifacts. - Exploit knowledge records created from current-run evidence: - Primitive: `9f8313f4-7255-4327-9080-cb1bb00344ad` - Derived command execution: `7c845cfe-5dab-41d2-9f50-d71160b7cf40` ## Recommendations / Next Steps - Apply or backport the strict validation from commit `581562f94a` to every maintained branch. Validate the username and password independently with a narrow allowlist before storing, exporting, logging, or forwarding them. - Do not pass peer-controlled values through shell command strings. Replace `eval`-based command composition with arrays/direct `exec` invocations so data cannot become shell syntax. - Keep certificate identity verification and authorization distinct from shell command construction; certificate subject fields must always remain data. - Upgrade to a vendor release that explicitly contains the MDEV-40056 fix once available. Do not assume that a version containing earlier SST hardening fully addresses this later `remote_auth` issue. - Restrict Galera/wsrep ports to authenticated cluster members and trusted network segments. Rotate cluster credentials and review donor hosts if an untrusted peer may have joined. - Add regression tests that deliver malicious CommonNames and direct `remote_auth` strings over a real two-node SST flow. Include quotes, semicolons, pipes, ampersands, redirections, parentheses, newline variants, and colon edge cases; verify rejection happens before any SST script starts. ## Additional Notes - The final reproduction script completed successfully twice consecutively after its last modification, and earlier full-path runs independently created the same marker. It cleans containers/networks and repairs bind-mount ownership, making repeated execution idempotent. - The production-path proof is non-sanitized and uses real MariaDB and Galera components rather than a parser/library mock. - The fixed side is a source-bound logic negative control rather than a full fixed server build because the submitted fix is unmerged and not present in a release image. It exercises the exact split and `wsrep_filename_char` predicate and includes the exact fixed source excerpt. The vulnerable impact itself is demonstrated end-to-end over TCP. - The TLS setup is intentionally local and ephemeral. It exists only to make the malicious joiner a trusted certificate holder and exercise the same certificate-CN-to-`remote_auth` path used by the product. ### Reproduction - Reproduced: 2026-08-01T20:29:03.048Z - Duration: 5056s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00319 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00319 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00319/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00319 ================================================================================ ## REPRO-2026-00318: mcp-toolbox authorization bypass: unauthenticated tool invocation via direct HTTP API -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00318 - CVE: CVE-2026-14537 (https://nvd.nist.gov/vuln/detail/CVE-2026-14537) ### Package Information - Name: googleapis/genai-toolbox - Ecosystem: github - Affected: >= v1.3.0 (2026-05-21) and <= v1.4.0 (2026-06-04) - Fixed: v1.5.0 (2026-06-18) - Severity: high - CVSS: Unknown - CWE: CWE-863 (Incorrect Authorization) (Incorrect Authorization) ### Root Cause # CVE-2026-14537 — Root Cause Analysis ## Summary google/mcp-toolbox (repository `googleapis/genai-toolbox`) versions v1.3.0–v1.4.0 suffer from an incorrect-authorization vulnerability (CWE-863). When the server is configured with an MCP-enabled authorization service (`mcpEnabled: true`, OAuth scopes via `scopesRequired`) **and** the legacy direct HTTP API is enabled (`--enable-api`), the legacy endpoint `POST /api/tool/{toolName}/invoke` executes tools without enforcing the MCP authorization policy. An unauthenticated remote attacker can invoke tools that the operator believes are protected by OAuth scopes, because scope enforcement exists only on the `/mcp` endpoint path. ## Impact - Component: `internal/server/api.go` (`toolInvokeHandler`) together with `internal/server/server.go` (`mcpAuthMiddleware` mounted only under `/mcp`). - Affected versions: v1.3.0 (2026-05-21) through v1.4.0 (2026-06-04). - Fixed in: v1.5.0 (2026-06-18), fix commit `a6ff910a602adece11f0a6581d6211e5927f7182` ("fix(server): fail if MCP auth is enabled together with enable-api (#3435)"). - Risk: high (CVSS 4.0 8.1). Any tool protected solely by the MCP authorization model (an `mcpEnabled` authService plus tool-level `scopesRequired`, with no legacy `authRequired`) is remotely invocable with no credentials at all, including destructive tools (e.g. `sqlite-execute-sql`, SQL execution tools against production databases). ## Impact Parity - Disclosed/claimed maximum impact: authorization bypass — unauthenticated remote invocation of scope-protected tools via the direct HTTP API (`expected_impact=authz_bypass`, surface `api_remote`). - Reproduced impact in this run: identical — HTTP 200 and actual tool execution (arbitrary SQL against the configured SQLite source) via `POST /api/tool/protected-tool/invoke` with no `Authorization` header, while the same unauthenticated caller receives HTTP 401 on `/mcp`. - Parity: `full`. - Not demonstrated: nothing beyond the claimed impact (no code execution was claimed or required). ## Root Cause Authorization in mcp-toolbox v1.3.0/v1.4.0 is split across two independent enforcement points: 1. **MCP path** (`/mcp`): `mcpAuthMiddleware` (`internal/server/server.go`) validates the Bearer token via `ValidateMCPAuth` (including authService `scopesRequired`), and the MCP `tools/call` handler additionally enforces tool-level scopes through `mcputil.ValidateScopes(ctx, tool.GetScopesRequired(), ...)` (`internal/server/mcp/v20250618/method.go`). 2. **Legacy HTTP API path** (`/api`, enabled by `--enable-api`): the router in `internal/server/api.go` has **no** MCP auth middleware, and `toolInvokeHandler` only enforces the legacy `authRequired` mechanism (`tool.Authorized(verifiedAuthServices)`), which returns `true` unconditionally when a tool declares no `authRequired` (`IsAuthorized`: "no authorization requirement"). Tool-level `scopesRequired` is never consulted on this path. Consequently, a tool protected only by the modern MCP scope model (`scopesRequired`, no legacy `authRequired`) is fully open on the legacy HTTP API: the unauthenticated request passes `IsAuthorized([])` and the tool executes. The fix in v1.5.0 does not add scope checks to the legacy endpoint; instead it makes the dangerous configuration fail closed at startup: `cmd/root.go` and `internal/server/server.go` (`InitializeConfigs`) refuse to run when any authService `IsMCPEnabled()` and `EnableAPI` are both set ("MCP Auth cannot be enabled together with the legacy HTTP API"), and a new `IsMCPEnabled()` method was added to the `AuthServiceConfig` interface for that check. Fix commit: `a6ff910a602adece11f0a6581d6211e5927f7182` (PR #3435). ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` (self-contained; installs Go 1.26.3 if needed, clones/uses the prepared repo cache, builds the real product at v1.4.0 and v1.5.0). 2. The script starts a local OIDC authorization-server stub (`bundle/repro/oidc_stub.py`), then launches the real v1.4.0 server with `bundle/repro/tools.yaml` (generic authService `mcpEnabled: true` + `scopesRequired: [read:files]`; tool `protected-tool` of type `sqlite-execute-sql` with `scopesRequired: [execute:sql]` and **no** `authRequired`) and flags `--enable-api --toolbox-url --port 5000`. 3. It then sends the attacker request `POST /api/tool/protected-tool/invoke` with body `{"sql": "SELECT 'CVE-2026-14537-PWNED' AS marker"}` and **no** `Authorization` header, followed by a contrast request to `/mcp` without a token, and finally attempts to start v1.5.0 with the identical config and flags. Expected evidence: - v1.4.0 legacy API: HTTP 200 with the marker `CVE-2026-14537-PWNED` in the JSON response (tool executed unauthenticated) — **vulnerable**. - v1.4.0 `/mcp`: HTTP 401 with a `WWW-Authenticate` challenge — the MCP path enforces authorization correctly. - v1.5.0: exits at startup logging "MCP Auth cannot be enabled together with the legacy HTTP API" and never serves the API — **fixed (fail closed)**. ## Evidence - `bundle/logs/reproduction_steps.log` — full run transcript. - `bundle/logs/vuln_server.log` — v1.4.0 server startup (MCP auth + legacy API both active). - `bundle/logs/vuln_api_invoke_status.txt` / `vuln_api_invoke_body.json` — HTTP status and response body of the unauthenticated invoke (200 + marker). - `bundle/logs/vuln_mcp_noauth_status.txt` / `vuln_mcp_noauth_body.json` — 401 from `/mcp` without a token (contrast control). - `bundle/logs/fixed_server.log` — v1.5.0 startup refusal message. - `bundle/logs/oidc_stub.log` — OIDC discovery requests made by the server at startup (proves the real MCP auth stack was initialized). - `bundle/repro/runtime_manifest.json` — structured runtime evidence manifest. Environment: linux/amd64, Go 1.26.3, mcp-toolbox built from source at tags v1.4.0 (d67cfbe8ddc) and v1.5.0, python3 OIDC stub on 127.0.0.1:8099. ## Recommendations / Next Steps - Upgrade to v1.5.0 or later; do not run `--enable-api` together with MCP-enabled authorization services on affected versions. - Operators on v1.3.0/v1.4.0 who must keep the legacy API should add explicit legacy `authRequired` entries to every tool (the legacy mechanism is still enforced on `/api`), or front the server with a proxy that blocks `/api`. - Long-term: the legacy `/api` endpoints are deprecated; migrate clients to the standard `/mcp` JSON-RPC endpoint where MCP authorization is enforced. ## Additional Notes - The reproduction is idempotent: the script rebuilds only when the resolved commit changes, restarts all services on each run, and cleans up background processes via a trap. - Edge cases: tools that DO declare legacy `authRequired` referencing the mcpEnabled generic authService are *not* bypassed on `/api` (claims come from the MCP context, which is empty there, yielding 401); the bypass applies to tools protected only via the MCP scope model, which is the configuration the fix commit targets ("clients could potentially bypass MCP authorization policies by using the legacy HTTP API"). - No public PoC existed; this reproduction was built from the fix-commit analysis of PR #3435. ### Reproduction - Reproduced: 2026-08-01T05:51:48.163Z - Duration: 2266s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00318 # or: pruva-verify CVE-2026-14537 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00318 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00318/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00318 ================================================================================ ## REPRO-2026-00317: Rails Active Storage variant processing arbitrary file read and potential RCE -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00317 - CVE: CVE-2026-66066 (https://nvd.nist.gov/vuln/detail/CVE-2026-66066) ### Package Information - Name: rails/rails - Ecosystem: github - Affected: activestorage < 7.2.3.2 (Rails 7.0.0-7.2.3.1 affected in default config); 8.0.0-8.0.5; 8.1.0-8.1.3; Rails 6.x only with non-default Active Storage config - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-1188 ### Root Cause # Root Cause Analysis — CVE-2026-66066 (GHSA-xr9x-r78c-5hrm) ## Summary Rails Active Storage's `:vips` variant processor (the default since Rails 7.0) passes attacker-uploaded files to libvips without disabling libvips' "untrusted" (unfuzzed) operations. On the standard Debian/Ubuntu libvips build — the same build shipped by the official `ruby` Docker images and installed by `rails new` generated Dockerfiles — the untrusted `matload` operation (matio + HDF5) is available. An unauthenticated attacker can upload a crafted MATLAB v7.3 (`.mat`/HDF5) file whose matrix data lives in **HDF5 external storage segments pointing at an arbitrary absolute path** on the server (e.g. `/proc/self/environ`). When Active Storage generates an image variant from the upload, libvips loads it with `matload`, and HDF5/matio transparently read the referenced file, returning its bytes as image pixels. The processed variant is served back to the attacker, yielding an **arbitrary file read as the Rails process user**, including the process environment with `SECRET_KEY_BASE`. Active Storage 8.0.5.1 / 7.2.3.2 / 8.1.3.1 fix this by calling `Vips.block_untrusted(true)` at boot (requiring libvips >= 8.13 and ruby-vips >= 2.2.1). ## Impact - Package/component: `activestorage` (Ruby on Rails) variant processing via `ruby-vips`/`image_processing` (`config.active_storage.variant_processor = :vips`, default with `load_defaults 7.0` and later). - Affected versions: activestorage < 7.2.3.2, >= 8.0 < 8.0.5.1, >= 8.1 < 8.1.3.1, with libvips linked against certain third-party libraries (Debian/Ubuntu default builds include matio/HDF5, ImageMagick, poppler, librsvg). - Risk level: critical. Unauthenticated arbitrary file read of any file the Rails process can read (environment secrets, credentials, other users' data). The advisory notes these secrets (especially `secret_key_base`) may enable remote code execution or lateral movement. ## Impact Parity - Disclosed/claimed maximum impact: remote code execution (via arbitrary file read -> secret_key_base -> RCE/lateral movement). - Reproduced impact in this run: **unauthenticated remote code execution** through the production HTTP path, chained as: 1. arbitrary file read — the Rails process environment (`/proc/self/environ`) is exfiltrated byte-exactly through the app's own `resize_to_limit: [100, 100]` variant, leaking `SECRET_KEY_BASE` (canary recovered, two independent attempts); 2. forged signed variation tokens — the leaked secret replicates `Rails.application.message_verifier("ActiveStorage")` offline and mints a variation token carrying an unvalidated `{"instance_eval": ""}` transformation (the `:vips` ImageProcessingTransformer performs no transformation validation; rails issue #56948); 3. code execution — delivering the forged token via `GET /rails/active_storage/representations/redirect///pwn.png` executes the Ruby in the Puma process (unique on-disk markers written in three fresh vulnerable processes; wrong-key control: 404, no marker; fixed 8.0.5.1: chain broken at step 1). - Parity: **full** — unauthenticated RCE on a default-configured app (variant_processor :vips, image_processing 1.x, untrusted uploads with variants displayed), matching the advisory's claimed maximum impact. ## Root Cause 1. **Missing hardening call.** Before the fix, Active Storage never called `Vips.block_untrusted(true)` (libvips >= 8.13) nor set `VIPS_BLOCK_UNTRUSTED`, so every libvips loader/saver flagged `VIPS_OPERATION_UNTRUSTED` ("unfuzzed") remained reachable for attacker-controlled uploads. libvips selects loaders by **content sniffing**, so the web-facing declared MIME type (`blob.content_type in variable_content_types`) does not constrain which loader actually parses the bytes. 2. **A loader that dereferences server-side paths.** The untrusted `VipsForeignLoadMat` (`matload`, via matio) reads MATLAB files; v7.3 `.mat` files are HDF5. HDF5 datasets may keep their payload in **external storage segments** (`H5Pset_external(name, offset, size)`), where `name` may be an absolute path. matio/HDF5 resolve and read those segments transparently, so a crafted dataset's pixel values become the raw bytes of an arbitrary server file. Details that make the payload viable: - libvips `vips__mat_ismat()` only accepts files starting with `MATLAB 5.0` (text prefix at offset 0); - matio's `Mat_Open()` ignores the descriptive text and decides v7.3/HDF5 purely from the header **version field `0x0200`** and endian indicator (`IM` on disk for little-endian), then calls `H5Fopen` (the HDF5 signature lives after the 512-byte user block); - matio requires a `MATLAB_class` attribute stored as a fixed-size, NUL-padded ASCII string. 3. **End-to-end exfil channel.** Active Storage's unauthenticated flow (direct upload -> representations URL) lets the attacker have a variant generated for their own blob: - `POST /rails/active_storage/direct_uploads` returns a `signed_id` for the crafted blob (declared `image/png`; no content verification at upload). `DiskController` skips CSRF protection for the subsequent PUT. - Variation URL tokens (`ActiveStorage.verifier.generate(transformations, purpose: :variation)`) sign **only the transformation hash**, not any blob id, so a token scraped from any public page that renders an image variant can be replayed against the attacker's own `signed_id`. - `RepresentationsController#show` synchronously processes the variant on first request and redirects to the stored PNG. - The ImageProcessing vips pipeline applies a sharpen convolution (`[-1,-1,-1; -1,32,-1; -1,-1,-1]/24`) after thumbnailing. The payload replicates each target byte into three consecutive pixels using **one 1-byte external-storage segment per pixel (3 segments per file byte)** and a 99x1 matrix, so (a) no resampling occurs under `resize_to_limit: [100, 100]`, and (b) each triple's center pixel has all-equal 3x3 neighbours and survives the convolution byte-exactly. One quirk matters for real apps: if the crafted blob is *attached* to a record, the analyzer rewrites `blob.content_type` to the sniffed `application/x-matlab-data`, after which `blob.variable?` is false and variants are refused (`ActiveStorage::InvariableError`). Orphan blobs created by direct upload are never analyzed, so the declared `image/png` stands — this is the path the exploit uses, and it requires no attachment. 4. **The fix.** `activestorage/lib/active_storage/vips.rb` (new in 8.0.5.1) loads ruby-vips at boot and calls `Vips.block_untrusted(true)`, raising at boot when libvips < 8.13 or ruby-vips < 2.2.1 ("unsecurable environment"). With it, `matload` (and svgload, pdfload, magickload, ...) raise `Vips::Error: matload: operation is blocked` — exactly the behavior the fixed build shows in this reproduction. Fix commit range: `v8.0.5...v8.0.5.1` (also 7.2.3.2, 8.1.3.1). ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (self-contained; run with `PRUVA_ROOT=` or from `bundle/repro/`). 2. The script: - installs ruby (>= 3.2), libvips-dev/tools, python3-h5py, sqlite dev headers, bundler; - asserts the environment precondition: libvips >= 8.13 with `matload` present and flagged `untrusted` (Debian/Ubuntu default build); - generates a minimal but realistic Rails app twice — `rails/activestorage 8.0.5` (vulnerable) and `8.0.5.1` (fixed) — with `variant_processor = :vips`, Disk service, an unauthenticated upload form page (CSRF meta tag + a sample image variant URL), and `SECRET_KEY_BASE` sourced from the process environment containing a canary value; - boots each app with Puma (two clean attempts per build); - per attempt, as an unauthenticated attacker: GET / (session + CSRF token + scrape signed variation token), generate the `.mat` payload targeting `/proc/self/environ` at increasing offsets, direct-upload each payload (declared `image/png`), replay the scraped variation token against the attacker's own signed blob id, download the served variant PNG, and decode the exfiltrated bytes; - asserts the vulnerable builds leak `SECRET_KEY_BASE=KINDARAILS2SHELL_...` and the fixed builds fail closed with `Vips::Error (matload: operation is blocked)`. 3. Expected evidence: `RESULT: VULNERABILITY CONFIRMED` with `vulnerable leaks=2/2, fixed blocked=2/2`, exit code 0. ## Evidence - `bundle/logs/reproduction_steps.log` — full run log; key excerpts: - `[deps] vips-8.14.1` / `libvips matload present and marked untrusted` - `* activestorage (8.0.5)` vs `* activestorage (8.0.5.1)`, `image_processing (1.14.0)`, `ruby-vips (2.3.0)` - `[vuln 1] canary SECRET_KEY_BASE recovered from /proc/self/environ` followed by the leaked environment, containing `SECRET_KEY_BASE=KINDARAILS2SHELL_CANARY_...` (both attempts) - `[fixed 1/2] variant processing blocked: Vips::Error (matload: operation is blocked` - `bundle/logs/attempts/vuln_*/leaked_all.raw` — raw exfiltrated `/proc/self/environ` bytes (decoded from the served variant PNGs). - `bundle/logs/attempts/vuln_*/server.log`, `fixed_*/server.log` — Puma/Rails logs of both builds. - `bundle/logs/attempts/vuln_*/chunk_*/du.json`, `pwn.png` — per-chunk direct upload responses and served variant images. - `bundle/repro/runtime_manifest.json` — runtime manifest (entrypoint `endpoint`, service/health/target all true). - Environment: Debian bookworm (ruby:3.4-bookworm container), ruby 3.4.10, libvips 8.14.1 (matio/HDF5 build, `matload` untrusted), matio 1.5.21, HDF5 1.10, no sanitizers (production-path proof). ## Recommendations / Next Steps - Upgrade to activestorage 7.2.3.2 / 8.0.5.1 / 8.1.3.1 (or later) **and** libvips >= 8.13; rotate `secret_key_base` and any credentials present in the application environment. - Stopgap on libvips >= 8.13: set `VIPS_BLOCK_UNTRUSTED=1` or call `Vips.block_untrusted(true)` in an initializer; on libvips < 8.13 remove the ruby-vips dependency entirely. - The same hardening should be considered defense-in-depth for *any* product that runs libvips on untrusted content without `block_untrusted`. - Escalation artifacts: `bundle/repro/escalation_experiments.sh` (runnable), `bundle/logs/escalation/escalation.log`, `rce_marker.txt`, `rce_marker2.txt` (unique markers written by injected code inside fresh Puma processes), `negative_control.json` + `srvNC.log` (wrong-key control, HTTP 404, no marker), `logs/escalation/leak/` (full-environ leak used to recover the complete secret). The second-stage `instance_eval` transformation injection relies on the `:vips` transformer applying no transformation validation (rails issue #56948); it is only reachable to an unauthenticated attacker because variation tokens are signed and the CVE-2026-66066 file read yields the signing secret. - Note for testing: `image_processing` 2.x independently calls `Vips.block_untrusted(true)` when it loads; pin observations accordingly when evaluating exploitability (this reproduction pins 1.14.0 so the only variable is the activestorage version). ## Additional Notes - Idempotency: the script resets each app's DB/storage per attempt and was run twice consecutively in a fresh container; both runs passed (`vulnerable leaks=2/2, fixed blocked=2/2`). - Other untrusted loaders on Debian/Ubuntu libvips (svgload, pdfload, openslideload, magickload, jxlload, jp2kload, fitsload, openexrload, analyzeload, radload, ppmload, csvload, rawload, vipsload, matload) are additional candidate vectors; the ImageMagick route is heavily restricted on Debian/Ubuntu by the `@*` path policy, MVG/MSL stealth registration and `StrictReadImage` nested-coder blocking, while the matload/HDF5 route used here works on the default build. The upstream disclosure notes the reported chain may differ; any single untrusted loader suffices to prove the CVE. - The chunk size (33 bytes/request) is an artifact of defeating the sharpen convolution under `resize_to_limit: [100, 100]`; larger chunks are possible with larger variant limits or format-only variants. - The escalation was additionally validated with negative controls: a forged token signed with a wrong key is rejected (HTTP 404, no marker), and the fixed app (8.0.5.1) blocks the initial file read (`matload: operation is blocked`), leaving the signing key unobtainable. See `bundle/learning/exploit_escalation.json` (outcome: demonstrated) and `bundle/repro/exploit_knowledge.json` for the recorded primitives and the derived command-execution capability. ### Reproduction - Reproduced: 2026-08-01T05:51:29.604Z - Duration: 8922s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00317 # or: pruva-verify CVE-2026-66066 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00317 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00317/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00317 ================================================================================ ## REPRO-2026-00316: marimo Pre-Auth RCE via Terminal WebSocket Authentication Bypass (/terminal/ws missing validate_auth) -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00316 - CVE: CVE-2026-39987 (https://nvd.nist.gov/vuln/detail/CVE-2026-39987) ### Package Information - Name: marimo - Ecosystem: github - Affected: <0.23.0 - Fixed: 0.23.0 - Severity: critical - CVSS: Unknown - CWE: CWE-306 (Missing Authentication for Critical Function) ### Root Cause # RCA Report — CVE-2026-39987: marimo Pre-Auth RCE via /terminal/ws ## Summary marimo's interactive terminal WebSocket endpoint (`/terminal/ws`) completely skipped authentication validation. marimo relies on Starlette's `AuthenticationMiddleware`, which only *marks* failed-auth connections as `UnauthenticatedUser` without actively rejecting WebSocket connections; real enforcement depends on endpoint-level checks. While the main `/ws` endpoint validates credentials, `/terminal/ws` had neither a `@requires("edit")` decorator nor a `validate_auth()` call, so an unauthenticated attacker could open a WebSocket and be handed a full interactive PTY shell running with the privileges of the marimo process — pre-authentication remote code execution. ## Impact - **Package/component:** `marimo` (Python notebook server), `marimo/_server/api/endpoints/terminal.py` — `/terminal/ws` WebSocket endpoint. - **Affected versions:** all versions `< 0.23.0` (verified on `0.22.5`). - **Risk level:** Critical (CVSS 9.3, EPSS 0.953, CISA KEV 2026-04-23, exploited in the wild). Consequences: unauthenticated remote attacker obtains an interactive OS shell with the marimo process's privileges (frequently root in Docker deployments), enabling reconnaissance, credential theft (e.g. `.env` cloud keys), lateral movement, and full host compromise. ## Impact Parity - **Disclosed/claimed maximum impact:** pre-authentication remote code execution (interactive PTY shell, arbitrary OS commands). - **Reproduced impact from this run:** identical — from a raw, credential-less WebSocket client we obtained a PTY shell and executed arbitrary commands (`echo PRUVA_VULN_A_$(id -u)_$(id -un)`), observing marker output `PRUVA_VULN_A1_1000_vscode` proving execution as the marimo server user (uid 1000). - **Parity:** `full`. - **Not demonstrated:** nothing material — the claim is fully reproduced, including the fixed-version negative control. ## Root Cause `marimo/_server/api/endpoints/terminal.py::websocket_endpoint` (vulnerable code at fix-commit parent `c24d4806398f30be6b12acd6c60d1d7c68cfd12a^`) performed only two checks before `websocket.accept()` and `pty.fork()`: 1. `app_state.mode != SessionMode.EDIT` → close. 2. `supports_terminal()` → close. There was **no authentication check**. Because Starlette's `AuthenticationMiddleware` does not reject unauthenticated WebSocket upgrades (it only attaches an `UnauthenticatedUser`), the absence of an explicit `validate_auth(websocket)` call meant anyone could reach the PTY-spawning code. Fix commit `c24d4806398f30be6b12acd6c60d1d7c68cfd12a` (PR #9098, released in 0.23.0) adds exactly: ```python from marimo._server.api.auth import validate_auth ... if app_state.enable_auth and not validate_auth(websocket): await websocket.close(WebSocketCodes.UNAUTHORIZED, "MARIMO_UNAUTHORIZED") return ``` aligning `/terminal/ws` with the auth validation used by the other WebSocket endpoints. Verified in this run: the patch hunk exists at the fixed commit, the parent commit lacks it, the installed 0.22.5 package lacks `validate_auth` in `terminal.py`, and the installed 0.23.0 package contains it. ## Reproduction Steps 1. `bundle/repro/reproduction_steps.sh` (helper: `bundle/repro/ws_exploit_client.py`). 2. The script: - clones/uses the marimo source checkout and verifies the fix patch hunk; - creates two virtualenvs: `marimo==0.22.5` (vulnerable) and `marimo==0.23.0` (fixed); - starts each real server with token auth enabled (`marimo edit --headless --token-password topsecretpw`); - proves the HTTP auth gate is active (unauthenticated `/` → HTTP 303 login redirect); - as an unauthenticated attacker, opens a raw WebSocket to `/terminal/ws` (no token/cookie/header) and sends a shell command — **twice** against the vulnerable build (both yield PTY output with the unique marker) and **twice** against the fixed build (both rejected with HTTP 403 during the WS upgrade); - runs an authenticated positive control on the fixed build (valid `access_token` → terminal works), proving the fix blocks only unauthenticated access; - writes `bundle/repro/runtime_manifest.json`. 3. Expected evidence: vulnerable attempts print `RCE_CONFIRMED` with marker `PRUVA_VULN_A__` in PTY output; fixed attempts print `CONNECT_FAILED: InvalidStatus: server rejected WebSocket connection: HTTP 403`. ## Evidence - `bundle/logs/reproduction_steps.log` — full run transcript (verdict line: `vuln RCE attempts OK=2/2, fixed rejects OK=2/2, fixed auth control=1`). - `bundle/logs/server_vuln.log`, `bundle/logs/server_fixed.log` — server startup showing token auth (`URL: http://localhost:2718?access_token=topsecretpw`). - `bundle/logs/vuln_unauth_attempt1.log` / `...attempt2.log` — key excerpt: ``` echo PRUVA_VULN_A1_$(id -u)_$(id -un) vscode ➜ /tmp $ echo PRUVA_VULN_A1_$(id -u)_$(id -un) PRUVA_VULN_A1_1000_vscode RESULT: RCE_CONFIRMED marker observed in PTY output ``` (unauthenticated WS accepted → interactive shell → arbitrary command executed as uid 1000 `vscode`, the marimo process user). - `bundle/logs/fixed_unauth_attempt1.log` / `...attempt2.log` — `CONNECT_FAILED: InvalidStatus: server rejected WebSocket connection: HTTP 403`. - `bundle/logs/fixed_auth_control.log` — authenticated request on the fixed build still obtains the terminal (`PRUVA_FIXED_AUTH_1000_vscode`). - `bundle/logs/patch_hunk.txt` — the added `validate_auth` lines from the fix commit. - `bundle/repro/runtime_manifest.json` — `entrypoint_kind=endpoint`, `service_started=true`, `healthcheck_passed=true`, `target_path_reached=true`. - Environment: Python 3.14.4, pip-installed `marimo==0.22.5` / `marimo==0.23.0`, `websockets` client library, Linux x86_64. Script verified idempotent by two consecutive successful runs (exit 0 both times). ## Recommendations / Next Steps - **Upgrade** to marimo ≥ 0.23.0 immediately (fix: PR #9098 / commit `c24d4806398f30be6b12acd6c60d1d7c68cfd12a`). - **Fix approach (already upstream):** call `validate_auth(websocket)` and close with `WebSocketCodes.UNAUTHORIZED` before `websocket.accept()` whenever `enable_auth` is true — for every WebSocket endpoint, not just `/ws`. - **Defense in depth:** never expose `marimo edit` to untrusted networks; put it behind an authenticating reverse proxy; run it as an unprivileged user; audit any deployment that ran < 0.23.0 with a reachable port for compromise (unexpected PTY child processes, shell history, `.env` access). - **Testing:** add a regression test asserting unauthenticated `/terminal/ws` upgrades are rejected (upstream added one in `tests/_server/api/endpoints/test_terminal.py`). ## Additional Notes - **Idempotency:** the script is fully self-contained (installs its own venvs, manages server lifecycle with bounded waits and process-group cleanup) and passed twice consecutively with exit 0. - **Edge cases:** the vulnerability requires edit mode (`SessionMode.EDIT`, i.e. `marimo edit`, the default) and a POSIX platform with `pty` support — both are the standard deployment shape. Auth must be enabled (non-empty token), which is marimo's default when a token is generated or `--token-password` is set; with auth disabled the impact is identical but by design. - The advisory body text ("<= 0.20.4") understates the range; the structured range `< 0.23.0` is correct — 0.22.5 was confirmed vulnerable here. ### Reproduction - Reproduced: 2026-07-30T07:54:02.195Z - Duration: 1459s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00316 # or: pruva-verify CVE-2026-39987 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00316 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00316/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00316 ================================================================================ ## REPRO-2026-00315: Unauthenticated RCE in ruflo MCP bridge default docker-compose deployment -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00315 - CVE: CVE-2026-59726 (https://nvd.nist.gov/vuln/detail/CVE-2026-59726) ### Package Information - Name: ruflo - Ecosystem: npm - Affected: < 3.16.3 - Fixed: 3.16.3 - Severity: critical - CVSS: Unknown - CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')) ### Root Cause # RCA Report — CVE-2026-59726 ## Summary The ruflo MCP bridge (`ruflo/src/ruvocal/mcp-bridge/index.js`, the service built by `ruflo/docker-compose.yml`) exposed `POST /mcp` and `POST /mcp/:group` with **no authentication** and bound to **0.0.0.0** by default. The only blocklist that referenced `terminal_execute` (`AUTOPILOT_BLOCKED_PATTERNS` + `isBlockedTool()`) was enforced solely in the autopilot SSE handler. The shared `executeTool()` function — invoked by `POST /mcp` and `POST /mcp/:group` for every `tools/call` — performed **no gate**, so an unauthenticated network attacker could call `tools/call` → `ruflo__terminal_execute`. The bridge routed that call to the ruflo MCP backend (`@claude-flow/cli`), whose `terminal_execute` handler runs `execSync(command)` on attacker-supplied input, yielding arbitrary command execution **as the `node` user (uid 1000) inside the bridge container**. ## Impact - **Package/component affected:** `ruflo` MCP bridge — `ruflo/src/ruvocal/mcp-bridge/index.js` (the bridge built by `ruflo/docker-compose.yml`, service `mcp-bridge`, port 3001). The dangerous tool implementation lives in the `ruflo` backend (`@claude-flow/cli`, `src/mcp-tools/terminal-tools.ts`, `execSync(command)`). - **Affected versions:** ruflo `< 3.16.3` (vulnerable at main commit `4e18ad84`, the parent of the fix). The default `docker-compose.yml` enabled the `devtools` tool group (`MCP_GROUP_DEVTOOLS=true`), which exposes `terminal_*` tools, and ran the bridge with no `MCP_AUTH_TOKEN` and no bind-host restriction. - **Risk level:** Critical. Unauthenticated remote code execution. From the shell as `node`, an attacker can read every provider API key from the container environment (`OPENAI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`), spawn attacker-controlled swarms on the victim's keys, and persist poisoned patterns into the AgentDB learning store. ## Impact Parity - **Disclosed/claimed maximum impact:** Unauthenticated remote code execution (shell as `node` / uid 1000) via `POST /mcp` → `tools/call` → `terminal_execute`, with provider API key disclosure and AgentDB poisoning. - **Reproduced impact from this run:** Unauthenticated RCE confirmed end-to-end through the real running bridge container. A single `POST /mcp` `tools/call`/`ruflo__terminal_execute` request with **no authentication header** returned command output `uid=1000(node) gid=1000(node) groups=1000(node)` / `whoami=node`, wrote an attacker marker file to `/tmp` inside the container and read it back, and executed `printenv` for the provider keys (`exitCode: 0`). No API keys were present in the reproduction environment, so the env-leak primitive was exercised but produced empty values; the command-execution primitive is fully demonstrated. - **Parity:** `full` for the core claimed impact (unauthenticated RCE as `node` via `POST /mcp` → `terminal_execute`). The downstream consequences (key theft, swarm abuse, AgentDB poisoning) are direct implications of the demonstrated shell and were not separately exercised. ## Root Cause `createMcpHandler()` (per-group) and the catch-all `POST /mcp` handler both call `executeTool(name, toolArgs)` for `tools/call`. In the vulnerable code `executeTool()` only validated search-query shape and then routed unknown tool names to the matching external MCP backend via `backend.callTool()` — there was **no server-side deny list**. The `AUTOPILOT_BLOCKED_PATTERNS` array (containing `/terminal_execute/`) and `isBlockedTool()` were referenced only inside the autopilot SSE loop, never in `executeTool()`: ```js // vulnerable (4e18ad84) — executeTool() has NO gate: async function executeTool(name, args) { if (!args || typeof args !== "object") args = {}; // ... only search-query validation ... switch (name) { /* search, web_research, guidance */ default: { const activeTools = getActiveTools(); const extTool = activeTools.find(t => t.name === name); if (extTool) { const backend = mcpBackends.get(extTool._backend); if (backend) return backend.callTool(extTool._originalName, args); // -> execSync } }} } ``` The ruflo backend's `terminal_execute` runs the command verbatim: ```js // @claude-flow/cli src/mcp-tools/terminal-tools.ts output = execSync(command, { cwd, encoding: "utf-8", timeout, ... }); ``` Compounding factors in the default deployment: `app.listen(PORT)` binds all interfaces; no auth middleware; CORS `Access-Control-Allow-Origin: *`; the `devtools` group (prefix `terminal_`) is enabled by default; MongoDB bound to `0.0.0.0:27017` without `--auth`. **Fix commit:** `d00a0a40cd8bdbca877ac7f675f416bdc69accd1` (PR #2521, ADR-166 Phase 1–3). It adds a server-side `DANGEROUS_TOOLS` gate at the top of `executeTool()` (denies `terminal_execute` unless `MCP_ENABLE_TERMINAL=true`), a `requireAuth` bearer middleware (`timingSafeEqual`), `BIND_HOST=127.0.0.1` by default with fail-closed on public bind without `MCP_AUTH_TOKEN`, a CORS allowlist, and MongoDB `--auth` defaults. ## Reproduction Steps 1. See `bundle/repro/reproduction_steps.sh` (self-contained, executable). 2. The script resolves the ruflo repo (prepared project cache or fresh clone), checks out the vulnerable commit `4e18ad84` (=`d00a0a40^`) and the fixed commit `d00a0a40` into separate worktrees, sanity-checks that the vulnerable `index.js` lacks `DANGEROUS_TOOLS` and the fixed one has it, builds a real `node:20-slim` container for each commit (the ruflo MCP backend is the real published `ruflo` npm package; `terminal_execute` is verified present before baking), starts each container, and sends the actual unauthenticated `POST /mcp` `tools/call` → `ruflo__terminal_execute` request through the running HTTP service. It runs two clean vulnerable attempts and two clean fixed (negative-control) attempts, then writes `bundle/repro/runtime_manifest.json`. 3. Expected evidence (all under `bundle/`): - `artifacts/http/vuln_attempt1_response.json` — MCP result whose `text` contains `output: "uid=1000(node) ... ... ENV_LEAK:"`, `exitCode: 0` → RCE as `node`. - `artifacts/http/fixed_noauth_response.txt` — `{"error":"unauthorized"}` (HTTP 401). - `artifacts/http/fixed_attempt1_response.json` — `{"error":"Tool ... is disabled by default ...","code":"TOOL_DISABLED"}`; the marker is **absent** (command not executed). - `logs/reproduction_steps.log`, `logs/vuln_container.log`, `logs/fixed_container.log`. ## Evidence Key excerpts (from `bundle/artifacts/http/vuln_attempt1_response.json`, vulnerable bridge, **no Authorization header**): ``` "command": "id; whoami; echo PRUVA_RCE_ > /tmp/PRUVA_RCE_.txt; cat /tmp/PRUVA_RCE_.txt; echo ENV_LEAK:; printenv OPENAI_API_KEY GOOGLE_API_KEY OPENROUTER_API_KEY ANTHROPIC_API_KEY 2>/dev/null || true" "output": "uid=1000(node) gid=1000(node) groups=1000(node)\nnode\nPRUVA_RCE_\nENV_LEAK:\n" "exitCode": 0 ``` Fixed bridge negative control (`bundle/artifacts/http/fixed_attempt1_response.json`, with `Authorization: Bearer ...`): ``` { "error": "Tool \"ruflo__terminal_execute\" is disabled by default. Set MCP_ENABLE_TERMINAL=true to allow.", "code": "TOOL_DISABLED" } ``` Fixed bridge, no auth (`bundle/artifacts/http/fixed_noauth_response.txt`, HTTP 401): ``` {"error":"unauthorized"} ``` Environment: ruflo repo `ruvnet/ruflo`; vulnerable commit `4e18ad84c6c61be7ef43f62e371f8303a0f7517d`; fixed commit `d00a0a40cd8bdbca877ac7f675f416bdc69accd1`; bridge built from `ruflo/src/ruvocal/mcp-bridge` on `node:20-slim`; ruflo backend = published `ruflo` npm package (via `@claude-flow/cli`), `terminal_execute` confirmed in `tools/list` (331 backend tools, 185 exposed after group filtering). Container runs as `node` (uid 1000). ## Recommendations / Next Steps - Apply ADR-166 (PR #2521) fully: keep the `executeTool()` server-side gate as the single denial point for every path (not just autopilot); keep bearer auth + loopback bind by default; fail-closed on public bind without `MCP_AUTH_TOKEN`; keep `MCP_ENABLE_TERMINAL` opt-in; enforce MongoDB `--auth`. - Operators of any pre-fix exposed instance: firewall `:3001` and `:27017` immediately, rotate all provider API keys, and audit/purge the AgentDB pattern store for injected `agentdb_pattern-store` entries (a patched redeploy does **not** undo poisoning). - Add regression locks (the fix already ships `test-runtime-security.mjs` and `test-security-lock.js`) covering: unauthenticated `POST /mcp` `terminal_execute` → `TOOL_DISABLED`; authenticated call still gated unless `MCP_ENABLE_TERMINAL=true`; public bind without token → non-zero exit. ## Additional Notes - **Idempotency:** `reproduction_steps.sh` was executed three consecutive times; every run exited `0` with `confirmed=true`. It reuses a cached base tar / ruflo prefix / worktrees on a large scratch disk and re-imports fresh images each run. - **Build method note:** The default ruflo Dockerfile runs `npm install -g ruflo`, which resolves an >800 MB dependency tree that exceeds the 1 GB rootless-docker storage here, so a plain `docker build` runs out of space. The script instead assembles the container filesystem on the host's large workspace disk and imports it with `docker import` (single flat layer). The bridge `index.js` is the **unmodified** repo file at each commit, and the ruflo backend is the **real published package** (installed with `--omit=optional`, which still exposes `terminal_execute` because that tool only requires `node:child_process` `execSync`). The opt-in backends (`agentic-flow`, `gemini-mcp-server`, `@openai/codex`) and the `intelligence` (`ruvector`) backend are not part of the vulnerability path (`terminal_execute` is provided by the `devtools`/`ruflo` backend, default-on) and were omitted to fit storage; this does not affect the reproduction. - **Limitation:** No live provider API keys were set in the reproduction environment, so the env-leak output is empty; the `printenv` command executed successfully (demonstrating env access), and key disclosure is a direct implication of the demonstrated shell. ### Reproduction - Reproduced: 2026-07-30T07:51:15.165Z - Duration: 2085s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00315 # or: pruva-verify CVE-2026-59726 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00315 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00315/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00315 ================================================================================ ## REPRO-2026-00314: OpenCTI authentication bypass via user impersonation -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00314 - CVE: CVE-2026-27960 (https://nvd.nist.gov/vuln/detail/CVE-2026-27960) ### Package Information - Name: opencti - Ecosystem: docker - Affected: >= 6.6.0, < 6.9.13 (i.e. 6.6.0 through 6.9.12) - Fixed: 6.9.13 - Severity: critical - CVSS: Unknown - CWE: CWE-287 Improper Authentication (Improper Authentication) ### Root Cause # CVE-2026-27960 — OpenCTI Unauthenticated Authentication Bypass via User Impersonation ## Summary OpenCTI versions 6.6.0 through 6.9.12 contain an improper-authentication flaw (CWE-287) in the GraphQL API bearer-token resolution path. The function `authenticateUserByTokenOrUserId()` in `opencti-platform/opencti-graphql/src/domain/user.js` resolves the HTTP `Authorization: Bearer ` credential against the platform user cache map, which is keyed not only by each user's secret `api_token`, but also by every non-secret identifier of the user: `internal_id`, `standard_id`, and STIX ids (`buildStoreEntityMap()` in `opencti-platform/opencti-graphql/src/database/cache.ts` explicitly pushes `entity.api_token` into the same id list as `internal_id`/`standard_id`). As a result, an unauthenticated remote attacker can present **any known or guessable user identifier** — in particular the hard-coded default-admin `internal_id` `OPENCTI_ADMIN_UUID = 88ec0c6a-13ce-5e39-b486-354fe4a7084f` (`opencti-platform/opencti-graphql/src/schema/general.js`) — as the bearer token and is authenticated as that user without ever proving knowledge of the secret API token, password, or any credential. ## Impact - Package/component affected: `opencti/platform` (OpenCTI GraphQL API, `opencti-graphql`), all deployment modes that expose the HTTP/GraphQL endpoint. - Affected versions: >= 6.6.0, < 6.9.13 (fixed in 6.9.13). - Risk level: critical (CVSS 9.8 per public advisories). An unauthenticated network attacker can query and mutate the GraphQL API as any existing user, including the default admin: full read access to threat-intelligence data and full administrative control (user management, settings, data destruction). ## Impact Parity - Disclosed/claimed maximum impact: unauthenticated remote authentication bypass / authorization bypass allowing API access as any existing user, including the default admin (impact class `authz_bypass`). - Reproduced impact from this run: unauthenticated GraphQL request carrying only the public, hard-coded default-admin `internal_id` as bearer token was accepted by OpenCTI 6.9.12 and executed both `me` (returning the admin identity) and the admin-only `users` listing query. The identical request was rejected on the fixed 6.9.13 build, while the real secret `api_token` remained accepted on both builds. - Parity: `full` (unauthenticated admin impersonation through the production GraphQL boundary demonstrated end-to-end). ## Root Cause `authenticateUserFromRequest()` extracts the bearer value and calls `authenticateUserByTokenOrUserId(context, req, tokenUUID)`. That function only tests `platformUsers.has(tokenOrId)` on the user cache map. `getEntitiesMapFromCache()` builds this map via `buildStoreEntityMap()`, which indexes each user under `internal_id`, `standard_id`, `x_opencti_stix_ids` **and** `api_token`. The code therefore conflates *public identifiers* with *secret credentials*: possession of a user's internal UUID (for the default admin a constant compiled into the shipped source, `OPENCTI_ADMIN_UUID`) is treated as proof of identity. Fix (6.9.13, diff of `src/domain/user.js` between tags 6.9.12 and 6.9.13): the function was split into `authenticateUserByToken()` — which additionally verifies `crypto.timingSafeEqual(Buffer.from(user.api_token), Buffer.from(token))` — and `authenticateUserByUserId()`, which is only reachable after a successfully authenticated header-provider login (`HEADERS_AUTHENTICATORS`), restoring the invariant that a bearer value must be the secret token. - Vendor advisory: https://github.com/OpenCTI-Platform/opencti/security/advisories/GHSA-6vvv-vmfr-xhrx - Fix: `opencti-platform/opencti-graphql/src/domain/user.js` changes between tags 6.9.12 and 6.9.13. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` (self-contained; only needs Docker and network access to pull images). 2. The script: - starts the real dependency stack (Elasticsearch 8.19.16, Redis 7, RabbitMQ 3.13, MinIO) on an isolated Docker network; - starts `opencti/platform:6.9.12` with a configured admin email/password/token, waits for the platform health endpoint (migrations included); - **attack**: POSTs `{"query":"{ me { id name user_email } }"}` to `/graphql` with `Authorization: Bearer 88ec0c6a-13ce-5e39-b486-354fe4a7084f` (the hard-coded default-admin `internal_id`, no credentials); - **attack 2**: POSTs the admin-only `users(first: 5)` listing with the same header; - **controls**: no `Authorization` header, a random unknown UUID bearer, and the real secret `api_token` bearer; - tears the stack down and repeats attack + valid-token control against `opencti/platform:6.9.13` (fixed); - writes `bundle/repro/runtime_manifest.json` and exits 0 only if the vulnerable build impersonates the admin **and** the fixed build rejects the same request. 3. Expected evidence: on 6.9.12 the attack response contains `"user_email":"admin@opencti.io"` for both `me` and `users` queries; controls without a valid secret token return no identity; on 6.9.13 the attack returns no identity while the real token still authenticates. ## Evidence - Driver log: `bundle/logs/reproduction_steps.log` - Attack responses: `bundle/artifacts/opencti/vuln_attack_me_response.json`, `bundle/artifacts/opencti/vuln_attack_users_response.json` - Controls: `bundle/artifacts/opencti/vuln_control_noauth_response.json`, `bundle/artifacts/opencti/vuln_control_random_uuid_response.json`, `bundle/artifacts/opencti/vuln_control_valid_token_response.json` - Fixed-version negative control: `bundle/artifacts/opencti/fixed_attack_me_response.json`, `bundle/artifacts/opencti/fixed_control_valid_token_response.json` - Platform logs: `bundle/artifacts/opencti/platform_vuln.log`, `bundle/artifacts/opencti/platform_fixed.log` - Runtime manifest: `bundle/repro/runtime_manifest.json` - Environment: Docker 29, `opencti/platform:6.9.12` vs `opencti/platform:6.9.13`, Elasticsearch 8.19.16, Redis 7-alpine, RabbitMQ 3.13-management-alpine, MinIO latest. Key excerpts are recorded in `bundle/logs/reproduction_steps.log` (vulnerable build returns the admin identity for the hard-coded UUID bearer; fixed build rejects it). ## Recommendations / Next Steps - Upgrade to OpenCTI >= 6.9.13. - Interim (partial) workaround per vendor: set `APP__ADMIN__EXTERNALLY_MANAGED` to disable the default admin account — note this does not close the bypass for other users, since any user `internal_id`/`standard_id` remains a valid bearer on vulnerable builds. - Treat all user `internal_id`/`standard_id` values as public; rotate admin API tokens if a vulnerable version was exposed. - Regression test: assert that `Authorization: Bearer ` is rejected by the GraphQL endpoint while the user's `api_token` is accepted. ## Additional Notes - The script is idempotent: it recreates the Docker network/containers on each run and cleans them up on exit (trap). It was executed twice consecutively with identical pass results. - No sanitizer, mock, or reimplementation is used: the proof exercises the shipped `opencti/platform` container through its real HTTP/GraphQL listener. - The exploit requires no information beyond what is compiled into the public source tree (`OPENCTI_ADMIN_UUID`), so default deployments are exploitable with zero reconnaissance; impersonating *other* users additionally requires their `internal_id`/`standard_id`, which are routinely exposed in API responses to authenticated parties. ### Reproduction - Reproduced: 2026-07-30T07:51:01.428Z - Duration: 2674s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00314 # or: pruva-verify CVE-2026-27960 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00314 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00314/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00314 ================================================================================ ## REPRO-2026-00312: Gitea diffpatch Git hook installation leads to remote code execution -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00312 - CVE: CVE-2026-60004 (https://nvd.nist.gov/vuln/detail/CVE-2026-60004) ### Package Information - Name: go-gitea/gitea - Ecosystem: github - Affected: >=1.17, <1.27.1 - Fixed: 1.27.1 - Severity: critical - CVSS: Unknown - CWE: CWE-94 (Improper Control of Generation of Code - Code Injection) (Improper Control of Generation of Code ('Code Injection')) ### Root Cause # RCA Report — CVE-2026-60004 / GHSA-rcr6-4jqh-j84m ## Summary Gitea's `POST /api/v1/repos/{owner}/{repo}/diffpatch` endpoint applies attacker-controlled patches inside a **shared bare** temporary clone (`services/repository/files/patch.go` → `TemporaryUploadRepository.Clone(..., bare=true)`). Because the clone is bare, its repository root *is* `$GIT_DIR`. Submitting the same patch twice creates an add/add collision; git's `-3` three-way fallback (enabled for Git ≥ 2.32) then **checks the indexed path out to the working tree** even though the operation uses `--cached`. An executable file placed at `hooks/post-index-change` therefore lands in the live Git hooks directory and becomes an active hook. Git executes it while writing the index, so repository-controlled content runs arbitrary shell commands as the Gitea OS user. With default open registration an unauthenticated visitor can obtain the required write access by registering an account and creating a repository. ## Impact - **Package/component affected:** `services/repository/files/patch.go` (`ApplyDiffPatch`), reached via the public REST endpoint `POST /api/v1/repos/{owner}/{repo}/diffpatch` and the web editor's "apply patch" / cherry-pick fallback paths. Also affects `services/repository/files/cherry_pick.go`. - **Affected versions:** Gitea `>= 1.17` and `< 1.27.1`. - **Risk level:** Critical — remote code execution as the Gitea service account. With open registration (default) the endpoint is reachable by an unauthenticated attacker who self-registers. ## Impact Parity - **Disclosed/claimed maximum impact:** Remote code execution (arbitrary shell command execution as the Gitea OS user), reachable by an unauthenticated attacker via open registration. - **Reproduced impact from this run:** Full remote code execution. The planted `post-index-change` Git hook executed as the Gitea OS user (`vscode` in the test runtime) and wrote a marker file (`RCE_v1.27.0_CONFIRMED`) to disk, reached through the real `POST /api/v1/repos/{owner}/{repo}/diffpatch` endpoint after an unauthenticated self-registration (`/user/sign_up` → HTTP 303) and an ordinary repo-creation flow. - **Parity:** `full` — the claimed unauthenticated→account→repo→RCE chain was exercised end-to-end against the real product binary, with a fixed-version (v1.27.1) negative control that does **not** execute the hook. - **Not demonstrated:** Nothing; the code-execution outcome itself was demonstrated (not merely a crash). ## Root Cause `ApplyDiffPatch` prepares the patch in a temporary clone via `TemporaryUploadRepository.Clone(ctx, opts.OldBranch, /*bare=*/true)` with `Shared: true`. The apply command is: ```go cmdApply := gitcmd.NewCommand("apply", "--index", "--recount", "--cached", "--ignore-whitespace", "--whitespace=fix", "--binary") if git.DefaultFeatures().CheckVersionAtLeast("2.32") { cmdApply.AddArguments("-3") // --3way fallback } ``` Step-by-step: 1. **Call #1** — a clean patch that adds `hooks/post-index-change` (mode `100755`). With `--cached` the file only enters the index; no working-tree file is written. The resulting commit (tree containing `hooks/post-index-change`) is pushed to the repository, so `HEAD` now tracks that path. 2. **Call #2** — the *same* patch is submitted again. `SetDefaultIndex` (`git read-tree HEAD`) loads the index from the new `HEAD`, which already contains `hooks/post-index-change`. Re-applying the "add" patch produces an **add/add collision**. The `-3` three-way fallback then checks the indexed path out to the working tree even though `--cached` was requested. In a **bare** clone the working tree *is* `$GIT_DIR`, so the executable file is written to `$GIT_DIR/hooks/post-index-change` — a live Git hook. 3. While git writes the index during the apply/merge, it invokes the `post-index-change` hook, which executes the attacker's shell commands as the Gitea OS user. The hook's exit value is not propagated to the diffpatch HTTP response. **Fix (v1.27.1, PR #38637/#38638 "refactor: git patch apply"):** the temporary clone is no longer bare — `Clone(ctx, opts.OldBranch, /*bare=*/false)`. With a real working tree, the three-way fallback writes the checked-out path into the worktree (not `$GIT_DIR/hooks`), so no hook is installed and the apply fails closed (`git apply error: ... hooks/post-index-change: patch does not apply`, HTTP 500) instead of executing attacker code. ## Reproduction Steps 1. Reference: `bundle/repro/reproduction_steps.sh` (self-contained; downloads the official Gitea linux-amd64 binaries for the vulnerable `1.27.0` and the fixed `1.27.1` builds). 2. What the script does, for each build: - Starts a fresh Gitea instance (SQLite, open registration) on localhost as the current OS user and waits for the `/api/v1/version` healthcheck. - **Unauthenticated step:** `GET /user/sign_up` (verifies the open-registration form is served) then `POST /user/sign_up` to self-register an account (HTTP 303 = success); confirms the new account authenticates via the API (`GET /api/v1/user` with basic auth → HTTP 200). - Creates a repository `exploit-repo` with `auto_init` (establishes the `main` branch). - Builds a malicious patch that adds an executable file `hooks/post-index-change` whose body writes a version-specific marker file and records `id -un`. - **Call #1:** `POST /api/v1/repos/{owner}/exploit-repo/diffpatch` with the patch (clean apply, HTTP 201). - **Call #2:** the *same* patch again (add/add collision → three-way fallback → hook planted and triggered). - Checks for the RCE marker file (written by the hook as the Gitea OS user). 3. Expected evidence: on the vulnerable build the marker file `RCE_v1.27.0_CONFIRMED` is created and `vuln_rce_hook.log` records `hook_ran_as_user=vscode`; on the fixed build Call #2 returns HTTP 500 with `git apply error: ... hooks/post-index-change: patch does not apply` and no marker is created. ## Evidence All artifacts under `bundle/` (relative to the bundle root): - `bundle/repro/reproduction_steps.sh` — the reproducer. - `bundle/repro/runtime_manifest.json` — runtime manifest (entrypoint_kind `endpoint`, service_started/healthcheck_passed/target_path_reached all true). - `bundle/logs/repro/gitea_vuln_stdout.log` / `gitea_fixed_stdout.log` — Gitea server logs for each build. - `bundle/logs/repro/vuln_registration.txt` — `registration_http=303`, `api_auth_http=200` (unauthenticated→account chain). - `bundle/logs/repro/vuln_signup_page.html` — the served open-registration form. - `bundle/logs/repro/vuln_patch.txt` — the malicious patch payload. - `bundle/logs/repro/vuln_call1_response.json` / `vuln_call2_response.json` — diffpatch API responses (both HTTP 201 on the vulnerable build). - `bundle/logs/repro/vuln_diffpatch_calls.txt`: `call1_http=201 call2_http=201 marker_found=yes gitea_run_user=vscode`. - `bundle/logs/repro/vuln_rce_marker.txt` — `RCE_v1.27.0_CONFIRMED` (written by the executed hook). - `bundle/logs/repro/vuln_rce_hook.log` — `hook_ran_as_user=vscode` (twice, once per index write). - `bundle/logs/repro/fixed_call2_response.json` — `{"message":"git apply error: exit status 1 - Performing three-way merge... error: hooks/post-index-change: does not match index ... patch does not apply"}` (HTTP 500, hook NOT installed). - `bundle/logs/repro/fixed_diffpatch_calls.txt`: `call1_http=201 call2_http=500 marker_found=no`. Environment: official Gitea `1.27.0` / `1.27.1` linux-amd64 binaries, SQLite backend, Git 2.55.0 (≥ 2.32, so `-3` three-way fallback active), x86_64 Linux, gitea running as the `vscode` OS user. Key excerpts: ``` [vuln] Registration POST HTTP=303 (303 redirect = success) [vuln] API basic-auth as intruder_vuln: HTTP=200 [vuln] Call #1 HTTP=201 [vuln] Call #2 HTTP=201 [vuln] *** RCE MARKER FILE CREATED BY GIT HOOK *** [vuln] marker content: RCE_v1.27.0_CONFIRMED vuln_rce_hook.log: hook_ran_as_user=vscode [fixed] Call #2 HTTP=500 [fixed] No RCE marker file present at /tmp/gitea_rce_marker_v1.27.1 fixed_call2_response.json: "git apply error: exit status 1 - Performing three-way merge... error: hooks/post-index-change: does not match index ... patch does not apply" ``` ## Recommendations / Next Steps - **Upgrade to Gitea 1.27.1** (or later), which makes the temporary patch clone non-bare so checked-out paths cannot land in `$GIT_DIR/hooks`. - Defense-in-depth: do not run `git apply --index` against a bare repository at all; avoid combining `--cached` with `--index`/`--3way` semantics on a bare clone; consider `core.hooksPath` isolation / disabling `post-index-change` for internal temporary clones. - Restrict `service.DISABLE_REGISTRATION` / require admin approval on internet-facing instances to remove the unauthenticated reachability path. - Add a regression test asserting that the temporary patch repository is non-bare (the upstream fix added `services/repository/files/patch_test.go` `TestGitPatchPrepare` checking for `basePath/.git`). ## Additional Notes - **Idempotency:** confirmed — the script was run twice consecutively; both runs confirmed the vulnerable build (marker created, hook ran as the gitea OS user) and cleared the fixed build (no marker). The script removes prior per-run state and markers at start, so it is safe to re-run. - The `post-index-change` hook was introduced in Git 2.31/2.32; the `-3` three-way fallback requires Git ≥ 2.32 (gated by `git.DefaultFeatures().CheckVersionAtLeast("2.32")`). The test runtime uses Git 2.55.0, satisfying both. - The hook's exit value is not reflected in the diffpatch HTTP response, so the proof relies on the on-disk marker file and the `hook_ran_as_user` log rather than the API status code (Call #2 returns 201 on the vulnerable build). - The temporary upload repository is cleaned up by Gitea after the operation, so the planted hook file is transient; the durable proof is the marker the hook wrote while it was live. ### Reproduction - Reproduced: 2026-07-29T10:56:28.309Z - Duration: 1806s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00312 # or: pruva-verify CVE-2026-60004 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00312 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00312/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00312 ================================================================================ ## REPRO-2026-00311: xrdp Xvnc backend authentication issue on RHEL 9 -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00311 - CVE: CVE-2026-55626 (https://nvd.nist.gov/vuln/detail/CVE-2026-55626) ### Package Information - Name: neutrinolabs/xrdp - Ecosystem: github - Affected: GitHub advisory range is xrdp 0.10.3 through 0.10.6 inclusive. The RHEL 9 report reproduced on xrdp-0.10.6-1.el9.x86_64. - Fixed: Unknown - Severity: high - CVSS: Unknown - CWE: Unknown ### Root Cause # Root Cause Analysis: CVE-2026-55626 ## Summary CVE-2026-55626 is a missing-authentication flaw in xrdp's Xvnc-over-UNIX-domain-socket (`Xvnc-UDS`) session backend. xrdp deliberately starts Xvnc with RFB `SecurityTypes None` because access to the intended UNIX socket is controlled by filesystem permissions. Before the fix, however, xrdp did not disable Xvnc's separate TCP RFB listener. Consequently, the same desktop was also reachable on localhost TCP port `5900 + display` without any RFB credentials. A local peer able to reach that loopback listener could connect directly and view or control another user's active desktop, bypassing xrdp's intended per-session authorization boundary. ## Impact - **Affected package/component:** xrdp, specifically `sesman/sesexec/session.c` in the Xvnc-UDS session-start path, together with a TigerVNC-compatible Xvnc backend. - **Affected upstream versions:** xrdp 0.10.3 through 0.10.6. The release-line parent tested here, `ee84a41c7d76f651bea45b89303d56d894a2f057`, is after the `v0.10.6` tag and immediately before the security fix. - **Fixed version:** xrdp 0.10.6.1. - **Risk:** High. A local authenticated or otherwise local network peer can bypass the intended UNIX-socket access control and obtain an unauthenticated RFB session to another user's desktop. Successful access permits desktop confidentiality and integrity compromise and may disrupt the session. - **Scope clarification:** The vulnerable unintended peer is Xvnc's loopback TCP listener. Xvnc's intended UNIX-domain socket remains permission-controlled. Normal Xvnc-over-TCP mode and xorgxrdp are not this bug. ## Impact Parity - **Disclosed/claimed maximum impact:** Authentication/authorization bypass allowing unauthorized viewing or control of active desktop sessions. - **Reproduced impact:** An unauthenticated RFB 3.8 peer connected over real TCP to each vulnerable Xvnc process, selected security type `None` (`1`), received a successful security result, and reached `ServerInit` for the live 320x240 desktop without supplying a username, password, cookie, or other credential. The fixed build refused the same TCP connections. - **Parity:** `full`. - **Not demonstrated:** The proof stops at successful authenticated-session bypass and desktop initialization; it does not transmit framebuffer/input messages, steal user data, execute commands, escalate privileges, or claim Internet-remote reachability. Those stronger actions are unnecessary to establish the disclosed authorization bypass. ## Root Cause For an Xvnc-UDS session, `prepare_xvnc_xserver_params()` constructs the Xvnc command line. The vulnerable code adds: ```text -rfbunixpath -rfbunixmode 432 -SecurityTypes None ``` `432` is decimal notation for mode `0660`. The design assumes UNIX-socket ownership and permissions are the sole authorization mechanism, so disabling in-protocol RFB authentication is intentional for that socket. The mistake is that adding `-rfbunixpath` does not implicitly suppress Xvnc's default TCP listener. The generated command therefore exposes two transports sharing `SecurityTypes None`: 1. the intended permission-controlled UNIX socket; and 2. an unintended loopback TCP socket at `5900 + display`, which has no filesystem authorization boundary. The vulnerable runtime command captured in `bundle/logs/vulnerable-attempt-1-processes.log` lacks a TCP-disable option: ```text Xvnc :10 ... -rfbunixpath /tmp/cve55626-vulnerable/run/xrdp/1000/xrdp_display_10 -rfbunixmode 432 -SecurityTypes None ... ``` The fixed runtime command in `bundle/logs/fixed-attempt-3-processes.log` adds `-rfbport -1`: ```text Xvnc :10 ... -rfbport -1 -rfbunixpath /tmp/cve55626-fixed/run/xrdp/1000/xrdp_display_10 -rfbunixmode 432 -SecurityTypes None ... ``` The one-line upstream fix is commit [`517b8a180d8cbad1b7950ff4f6b31491318f5bb5`](https://github.com/neutrinolabs/xrdp/commit/517b8a180d8cbad1b7950ff4f6b31491318f5bb5) on the v0.10 release line. It inserts `"-rfbport", "-1"` before `-rfbunixpath`, preventing creation of the unintended TCP listener. `bundle/logs/security_patch.diff` captures this exact change. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` from any directory. It accepts `PRUVA_ROOT` and otherwise derives the bundle root from its own path. 2. The script reads `bundle/project_cache_context.json`, uses the prepared checkout when available, resolves the fixed commit and its exact parent, and verifies that only the fixed side contains the expected patch hunk. 3. It installs its clean-sandbox dependencies, builds and installs both exact xrdp revisions, and uses the real `xrdp-sesman`, `xrdp-sesexec`, `xrdp-sesrun`, and TigerVNC `Xvnc` programs. 4. It creates two isolated vulnerable Xvnc-UDS sessions and two isolated fixed sessions. Each session crosses xrdp's real session-start path before an RFB 3.8 client connects through a localhost TCP socket. 5. The client deliberately supplies no credentials. For vulnerable sessions, the script requires RFB security type `None`, a successful security result, and receipt of `ServerInit`. For fixed sessions, it requires the same TCP connection to fail closed. 6. The script writes `bundle/repro/runtime_manifest.json` on every attempt and exits `0` only when all two vulnerable attempts and both fixed controls satisfy their assertions. Expected terminal result: ```text CONFIRMED: vulnerable xrdp Xvnc-UDS sessions exposed an unauthenticated RFB TCP peer; fixed commit disabled that TCP listener. ``` ## Evidence - `bundle/logs/reproduction_steps.log` — complete latest run, including exact source identities and all four probe outcomes. - `bundle/logs/source_identity.log` — vulnerable commit `ee84a41c7d76f651bea45b89303d56d894a2f057` and fixed commit `517b8a180d8cbad1b7950ff4f6b31491318f5bb5`. - `bundle/logs/security_patch.diff` — one-line `-rfbport -1` patch. - `bundle/logs/vulnerable-attempt-1-rfb.json` and `vulnerable-attempt-2-rfb.json` — each records `credentials_supplied: false`, `connected: true`, `security_types: [1]`, `security_result: 0`, and `server_init_received: true`. - `bundle/logs/fixed-attempt-3-rfb.json` and `fixed-attempt-4-rfb.json` — each records `credentials_supplied: false`, `connected: false`, `server_init_received: false`, and `ConnectionRefusedError`. - `bundle/logs/vulnerable-attempt-{1,2}-processes.log` — live vulnerable Xvnc command lines with `-SecurityTypes None` and no `-rfbport -1`. - `bundle/logs/fixed-attempt-{3,4}-processes.log` — live fixed Xvnc command lines containing `-rfbport -1`. - `bundle/logs/*-session-launch.log` and `bundle/logs/*-sesman.log` — product session-start diagnostics showing the xrdp path was exercised. - `bundle/repro/runtime_manifest.json` — strict runtime manifest with `entrypoint_kind: "tcp_peer"`, all reachability flags true, the runtime stack, and concrete proof paths. - `bundle/logs/repro_evidence_sha256.txt` — SHA-256 inventory for the primary proof artifacts. Latest vulnerable probe excerpt: ```json { "credentials_supplied": false, "connected": true, "none_security_offered": true, "security_types": [1], "security_result": 0, "server_init_received": true, "width": 320, "height": 240 } ``` Latest fixed negative-control excerpt: ```json { "credentials_supplied": false, "connected": false, "server_init_received": false, "error": "ConnectionRefusedError(111, 'Connection refused')" } ``` The current worker is Ubuntu 26.04 rather than RHEL 9, but it runs the affected upstream xrdp code with a real TigerVNC Xvnc implementation supporting the same `-rfbunixpath`, `-SecurityTypes None`, and `-rfbport -1` semantics used by the RHEL 9 deployment. No sanitizer or mocked parser/service was used. ## Recommendations / Next Steps 1. Upgrade to xrdp 0.10.6.1 or later, or backport commit `517b8a180d8cbad1b7950ff4f6b31491318f5bb5`. 2. Ensure every Xvnc-UDS launch explicitly disables TCP RFB with `-rfbport -1`; do not assume `-localhost` or `-nolisten tcp` disables the RFB listener (`-nolisten tcp` concerns the X11 transport). 3. As defense in depth, restrict local access to VNC/RFB ports and audit active Xvnc command lines/listening sockets for `-SecurityTypes None` combined with an enabled TCP RFB port. 4. Add an integration regression test that starts Xvnc-UDS, verifies the UNIX socket exists and remains usable by the authorized consumer, and asserts that `5900 + display` refuses TCP connections. 5. Test both vulnerable-style and fixed-style behavior against the TigerVNC package shipped on supported RHEL 9 systems. ## Additional Notes - **Idempotency:** The final script completed successfully twice consecutively after clean per-attempt process and state cleanup. Each execution itself performs two vulnerable attempts and two fixed controls. - **Runtime boundary:** The attack probe is a real RFB TCP peer, not a direct call to `prepare_xvnc_xserver_params()` or a reimplementation of Xvnc. - **Authentication boundary:** xrdp authenticates/authorizes the session owner before starting the desktop. The bypass is subsequent direct access to that already-running desktop through Xvnc's unintended no-auth TCP listener. - **Attacker locality:** Upstream describes a local attacker and `-localhost` binds the unintended listener to loopback. This confirms authorization bypass at a network-protocol TCP boundary, but does not establish access from an arbitrary remote host without an additional local foothold, tunnel, namespace route, or other localhost reachability mechanism. - **Display allocation:** Display numbers can increase when prior X11 lock files remain; the script discovers the actual live display from the generated Xvnc command and probes its corresponding TCP port rather than relying on display `:10`. ### Reproduction - Reproduced: 2026-07-29T10:06:49.181Z - Duration: 3870s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00311 # or: pruva-verify CVE-2026-55626 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00311 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00311/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00311 ================================================================================ ## REPRO-2026-00310: Flowise arbitrary file access via unvalidated chatflowId/chatId -------------------------------------------------------------------------------- Status: published Severity: critical Type: security ### Identifiers - REPRO ID: REPRO-2026-00310 - CVE: CVE-2025-71334 (https://nvd.nist.gov/vuln/detail/CVE-2025-71334) ### Package Information - Name: FlowiseAI/Flowise - Ecosystem: npm - Affected: GitHub Advisory Database and OSV list flowise >=2.2.8 and <3.0.6; patched version is 3.0.6. - Fixed: Unknown - Severity: critical - CVSS: Unknown - CWE: CWE-73 ### Root Cause # Root Cause Analysis ## Summary Flowise 3.0.5 exposes the public `GET /api/v1/get-upload-file` endpoint without authentication and passes its attacker-controlled `chatId` query parameter into the local-storage path built by `streamStorageFile`. The function validates `chatflowId` but not `chatId`. In its legacy no-organization fallback, `path.join(storageRoot, chatflowId, chatId, filename)` therefore normalizes `../` segments and can resolve outside the configured storage root. A remote unauthenticated request can consequently retrieve a file from the parent of local storage. This run reproduced the issue twice against real Flowise 3.0.5 HTTP servers and showed that the identical requests are rejected twice by Flowise 3.0.6. ## Impact - **Affected package/component:** `flowise` / `flowise-components`, specifically the public `get-upload-file` handler and `streamStorageFile` local-storage fallback. - **Affected version reproduced:** Flowise `3.0.5` (source commit `ba6a602cbe87d9f55c9ee6aebb6407ec2f2066b5`; exact official linux/amd64 image manifest `sha256:30d4fdf8b9e215abff31a67ab104a9750ca25354fe98fe97a3481bbca0352098`). The ticket describes Flowise versions before `3.0.6` as affected. - **Fixed version tested:** Flowise `3.0.6` (source commit `89a0f23fe5e9c0b1ee85ee1175032c6b9e5ac9c1`; exact official linux/amd64 image manifest `sha256:86b83c5f55cd7989453789a39c568d08885be50e74faf9abd5e238269bcfe489`). - **Risk:** High/critical confidentiality risk. An unauthenticated network client with a valid chatflow UUID can read files reachable through traversal from the configured local storage hierarchy. In the default layout, this can expose application state such as the SQLite database and its sensitive records. The vulnerable fallback also copies the source into storage and unlinks the original, creating a data-tampering/availability side effect. ## Impact Parity - **Disclosed/claimed maximum impact:** Pre-auth arbitrary file read/write, information disclosure, and data tampering through Flowise file-storage APIs. - **Reproduced impact:** Pre-auth arbitrary file read through the real HTTP endpoint. A unique secret was created outside `BLOB_STORAGE_PATH`; an HTTP request sent without cookies, `Authorization`, API key, or `x-request-from` returned that exact secret with status 200. The vulnerable fallback then moved the source file into storage, also demonstrating an unauthorized filesystem mutation. - **Parity:** `full` for the canonical contract's `info_leak` impact and the unauthenticated API surface. - **Not demonstrated:** A general attacker-controlled arbitrary-file-write primitive was not needed for the canonical claim and was not claimed as independently proven here. Code execution was neither required nor attempted. ## Root Cause The endpoint is included in `WHITELIST_URLS`, so Flowise's global API middleware allows requests to `/api/v1/get-upload-file` without authentication. The controller reads `chatflowId`, `chatId`, and `fileName` directly from query parameters, resolves the organization from the referenced chatflow, and calls: ```ts streamStorageFile(chatflowId, chatId, fileName, orgId) ``` In Flowise 3.0.5, `streamStorageFile` validates that `chatflowId` is a UUID and rejects traversal only in `chatflowId`. It does not apply `isPathTraversal` to `chatId`. The primary local path is checked, but when it does not exist the migration fallback constructs a second path without the organization prefix: ```ts const fallbackPath = path.join(getStoragePath(), chatflowId, chatId, sanitizedFilename) ``` Because Node's `path.join` normalizes traversal segments, a value such as `chatId=../..` transforms `storageRoot//../../outside-secret.txt` into a path above `storageRoot`. Critically, this fallback path is not checked with the primary path's absolute/root-containment checks before `existsSync`, `copyFileSync`, `unlinkSync`, and `createReadStream` are used. Filename sanitization cannot constrain traversal supplied through the separate `chatId` component. Flowise 3.0.6 fixes the reproduced mechanism by extending the early guard to both path components: ```ts if (isPathTraversal(chatflowId) || isPathTraversal(chatId)) { throw new Error('Invalid path characters detected in chatflowId or chatId') } ``` The version-paired source diff is captured in `bundle/repro/root_cause_source.txt`. The release change is present between commits `ba6a602cbe87d9f55c9ee6aebb6407ec2f2066b5` and `89a0f23fe5e9c0b1ee85ee1175032c6b9e5ac9c1`. ## Reproduction Steps 1. Run `bundle/repro/reproduction_steps.sh` from any directory. It honors `PRUVA_ROOT` and the prepared project cache described by `bundle/project_cache_context.json`. 2. The script verifies the exact Flowise Git tags/commits and the presence/absence of the fixing hunk. It then downloads and digest-pins the official linux/amd64 Flowise 3.0.5 and 3.0.6 container filesystems, and runs their bundled Node runtimes and real Flowise CLI/server binaries directly. 3. For each of two isolated attempts per version, it starts Flowise with SQLite and local storage, waits for `/api/v1/ping`, performs administrative setup to create a valid chatflow, places a unique secret just outside `BLOB_STORAGE_PATH`, and sends the exploit request with no authentication material: ```text GET /api/v1/get-upload-file?chatflowId=&chatId=../..&fileName=outside-secret.txt ``` 4. Expected evidence: - Both 3.0.5 attempts return HTTP 200 and the exact unique outside secret, followed by `VULNERABLE_UNAUTHENTICATED_READ_CONFIRMED`. - Both 3.0.6 attempts return HTTP 500 with `Invalid path characters detected in chatflowId or chatId`; the source file remains unchanged, followed by `FIXED_REJECTION_CONFIRMED`. - The script exits 0 only after all four checks pass and prints `REPRODUCTION_CONFIRMED`. ## Evidence - `bundle/logs/reproduction_steps.log` — complete image acquisition, server startup, request/response, and four-attempt verdict transcript. - `bundle/logs/vulnerable_attempt1_response.txt` and `bundle/logs/vulnerable_attempt2_response.txt` — unique bytes disclosed by the unauthenticated endpoint. - `bundle/logs/fixed_attempt1_response.txt` and `bundle/logs/fixed_attempt2_response.txt` — fixed-build rejection JSON. - `bundle/logs/vulnerable_attempt1_service.log`, `bundle/logs/vulnerable_attempt2_service.log`, `bundle/logs/fixed_attempt1_service.log`, and `bundle/logs/fixed_attempt2_service.log` — real Flowise initialization and listening-server evidence. - `bundle/logs/flowise_3.0.5_image_manifest.json` and `bundle/logs/flowise_3.0.6_image_manifest.json` — exact official linux/amd64 OCI manifest and layer identities. - `bundle/repro/source_identity.log` — source tags, commits, and image digests. - `bundle/repro/runtime_manifest.json` — strict runtime manifest with `entrypoint_kind=endpoint` and service, healthcheck, and target-path flags set to true. - `bundle/repro/root_cause_source.txt` — bounded vulnerable code, fixed diff, whitelist, and controller source evidence. Representative successful-run excerpts: ```text unauthenticated_status=200 PRUVA_VULNERABLE_1_ VULNERABLE_UNAUTHENTICATED_READ_CONFIRMED ``` ```text unauthenticated_status=500 {"statusCode":500,"success":false,"message":"Invalid path characters detected in chatflowId or chatId","stack":{}} FIXED_REJECTION_CONFIRMED ``` ## Recommendations / Next Steps - Upgrade self-managed Flowise installations to `3.0.6` or later. - Validate every attacker-controlled path segment (`chatflowId`, `chatId`, organization identifiers, and filenames) before storage-provider use. Prefer strict expected-format validation, such as UUID validation where applicable. - After constructing both primary and fallback paths, resolve them with `path.resolve` and enforce containment using a separator-aware relative-path check; a simple string prefix is insufficient. - Remove or tightly constrain legacy fallback/migration behavior on unauthenticated routes. Filesystem moves should not occur as a side effect of a public download request. - Add integration tests over the real unauthenticated HTTP boundary for encoded and unencoded traversal forms, both download endpoints, all storage providers, and fixed-version fail-closed behavior. - Return a client error (for example HTTP 400) rather than HTTP 500 for invalid path input to reduce unnecessary internal-error behavior. ## Additional Notes - **Idempotency:** The final script performs two isolated vulnerable and two isolated fixed attempts in one run, and it is being executed twice consecutively. Per-attempt directories, databases, accounts, chatflows, ports, and secrets are recreated; each run uses a new random token. - **Authentication boundary:** Registration/login and chatflow creation are setup actions only. The exploit request itself is emitted by a fresh `curl` invocation without a cookie jar or any authentication-related header. - **Execution mode:** No sanitizer, direct parser harness, mock server, or reimplemented storage function is used. The script invokes the real released Flowise CLI and HTTP server from digest-pinned official product images. - **Operational detail:** The official image filesystems are streamed and extracted rather than imported into the rootless Docker daemon because that daemon's private layer store was too small for these multi-gigabyte images. This does not change product bytes or runtime behavior: each image's own bundled musl loader, Node executable, Flowise package, dependencies, and CLI are executed. - **Known precondition:** The read handler requires a valid chatflow UUID. The script creates one through the normal authenticated product API before testing the separate public download boundary. ### Reproduction - Reproduced: 2026-07-28T15:45:55.651Z - Duration: 4017s - Confidence: high ### Quick Verify ```bash pruva-verify REPRO-2026-00310 # or: pruva-verify CVE-2025-71334 ``` ### Links - Detail page: https://www.pruva.dev/reproductions/REPRO-2026-00310 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00310/artifacts/bundle/repro/reproduction_steps.sh - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00310 ================================================================================ ## REPRO-2026-00309: PipeWire sandbox escape via malicious library loading in PulseAudio compatibility layer -------------------------------------------------------------------------------- Status: published Severity: high Type: security ### Identifiers - REPRO ID: REPRO-2026-00309 - CVE: CVE-2026-5674 (https://nvd.nist.gov/vuln/detail/CVE-2026-5674) ### Package Information - Name: pipewire (pipewire-pulse daemon) - Ecosystem: Unknown - Affected: Unknown - Fixed: Unknown - Severity: high - CVSS: Unknown - CWE: Unknown ### Root Cause # Root Cause Analysis — CVE-2026-5674 (PipeWire PulseAudio-compatibility sandbox escape) ## Summary PipeWire's PulseAudio compatibility daemon (`pipewire-pulse`) implements the PulseAudio native protocol `LOAD_MODULE` command. The request handler only checks the `pulse.allow-module-loading` server property (default: `true`) and then loads any of the built-in PulseAudio compatibility modules with fully attacker-controlled arguments. One of those modules, `module-ladspa-sink`, forwards the attacker-supplied `plugin=` argument to PipeWire's filter-chain LADSPA loader, which calls `dlopen()` on the value verbatim when it is an absolute path. Because Flatpak-style sandboxes deliberately expose the PulseAudio unix socket to sandboxed applications, an attacker confined in a bubblewrap/Flatpak sandbox can make the out-of-sandbox `pipewire-pulse` daemon `dlopen()` an attacker-controlled shared library, executing its ELF constructors in the daemon process and thereby escaping the sandbox. ## Impact - Package/component affected: `pipewire` / `pipewire-pulse` (`libpipewire-module-protocol-pulse`, pulse `module-ladspa-sink`/`module-ladspa-source`, SPA filter-graph LADSPA plugin). - Affected versions: verified on PipeWire 1.6.2 (Ubuntu 26.04, `1.6.2-1ubuntu1.1`). The Debian security tracker lists every release as vulnerable (bullseye 0.3.19 through sid 1.6.8); no upstream fix exists at the time of this run. - Risk level and consequences: Important (CVSS 8.8 per Amazon ALAS). Any sandboxed application with access to the PulseAudio socket (default for audio-playing Flatpaks) can execute arbitrary code in the user's unsandboxed `pipewire-pulse` session daemon, i.e. a full sandbox escape with the daemon's privileges. ## Impact Parity - Disclosed/claimed maximum impact: sandbox escape — arbitrary code execution outside the sandbox (bubblewrap/Flatpak-style namespace sandbox) via the PulseAudio compatibility layer. - Reproduced impact from this run: attacker-controlled code executed in the host-side `pipewire-pulse` daemon from a client confined in a real bubblewrap sandbox with separate mount and user namespaces. Proof: the loaded library's constructor wrote fresh markers (`CVE-2026-5674-PWNED pid= uid=1000 comm=pipewire-pulse`) into a host-only oracle directory that the sandboxed client could neither see nor write. - Parity: `full`. - Not demonstrated: nothing material — a constructor can contain arbitrary code; the marker write is representative attacker-controlled code execution in the daemon. ## Root Cause 1. `src/modules/module-protocol-pulse/pulse-server.c` — `do_load_module()` (line 5051) handles `COMMAND_LOAD_MODULE` from the PulseAudio native protocol. Its only guard is `if (!impl->defs.allow_module_loading) return -EACCES;`. The property defaults to true (`/usr/share/pipewire/pipewire-pulse.conf`: `#pulse.allow-module-loading = true`). 2. `src/modules/module-protocol-pulse/modules/module-ladspa-sink.c` — the registered pulse module `module-ladspa-sink` accepts `plugin=` and `label=` arguments and builds a filter.graph node `{ type = ladspa plugin = "" label = "