Looting UniFi Controllers: Detecting and Weaponizing CVE-2026-22557
TL;DR: CVE-2026-22557 is an unauthenticated path 2026-5-29 13:0:0 Author: bishopfox.com(查看原文) 阅读量:3 收藏

TL;DR: CVE-2026-22557 is an unauthenticated path traversal in the UniFi Network Application's guest captive portal, rated CVSS 10.0. It allows attackers to read arbitrary files from any site with a customized portal, including backups containing administrative credentials for all devices managed by the network controller. Patch to 10.1.89, 10.2.97, or 9.0.118 or later. Bishop Fox has published a safe detection tool; read on for the practical attack paths and the conditions for full exploitability.

Summary

On March 18, 2026, Ubiquiti published Security Advisory Bulletin 062 (SAB-062) covering three vulnerabilities in the UniFi Network Application. The most severe, CVE-2026-22557, carries a CVSS score of 10.0 and is described by the vendor as a path traversal that "could be manipulated to access an underlying account." Credit for the original report goes to n00r3 (@izn0u).

Bishop Fox analyzed the patch by diffing the 10.1.85 and 10.1.89 Debian packages, confirmed the root cause, validated exploitation against a containerized test target, and built a detection method to run safely against customer deployments. This post focuses on three things: how defenders can detect the vulnerability without disrupting their own controllers, the post-exploitation vectors an attacker would actually use to take over the controller and/or the devices behind it, and how to defend against each. We also call out the configuration preconditions that decide whether a given controller is actually exploitable because the answer is not "every controller on a vulnerable version."

CVE-2026-22557 shipped alongside two sibling bugs in the same advisory: CVE-2026-22558, an authenticated NoSQL injection (CVSS 7.7), and CVE-2026-22559, a symbolic link variant of the portal file read that requires an admin to click a malicious link (CVSS 8.8). All three were fixed in the same release, and our research found that they are independent paths to similar impact rather than a single chain. For this post, we focus on CVE-2026-22557 because it is the one an unauthenticated attacker can reach on their own.

Our detection tool is available at https://github.com/BishopFox/CVE-2026-22557-check.

What Defenders Should Do Right Now

  • Patch. Update the UniFi Network Application to 10.1.89 or later (official release), 10.2.97 or later (release candidate), or 9.0.118 or later. UniFi Express users should update the device firmware to 4.0.13 or later, which carries the fixed application build.
  • If you cannot patch immediately, restrict the portal connectors. Block external access to the guest-portal HTTP and HTTPS connectors (default TCP 8880 and 8843), as well as the admin web port (default 8443).
  • Audit your guest-portal configuration. The file-read path is only active on sites with a customized guest portal enabled. If you do not run a branded captive portal, you are likely not exploitable today, but you should still patch because enabling that feature later will flip the switch.
  • Rotate everything in a backup and treat any exposed backup as compromised. Patching closes the file read, but it does not undo a backup that was already exfiltrated. A controller backup is an easily decrypted credential store, so a clean recovery means rotating all of it. Start with the basics: change the admin password, change the device SSH password, regenerate the controller's TLS keystore, re-enroll all managed devices, revoke any API keys, and re-authenticate any cloud or SSO linkage. Assume any secret that lived in a pre-patch backup is burned.
  • Sweep your network. Run our detection tool against any controller whose web interface is reachable, including from inside the network. The vulnerability is network-adjacent, so an attacker who reaches the management UI from a guest VLAN or a compromised internal host can use it.

Background

The UniFi Network Application is Ubiquiti's controller software for the UniFi line of access points, switches, and gateways. It runs as a Java web application that operators use to adopt and configure their network devices, push firmware, and run guest captive portals. It ships as a standalone Debian package, as the network controller built into UniFi OS hardware like the Dream Machine and Cloud Key, and as part of UniFi Express. In commercial settings, it sits behind branded Wi-Fi at hotels, cafés, retail stores, and managed multi-site networks.

The captive portal is the feature that matters for this bug. When an operator runs a branded splash page for guest Wi-Fi, the controller serves that portal's HTML and assets to unauthenticated clients. Serving attacker-reachable files from disk is exactly the surface where a path traversal does the most damage, and that is where CVE-2026-22557 lives.

We worked from the vendor advisory and a binary diff of the unifi_sysvinit_all-10.1.85.deb and unifi_sysvinit_all-10.1.89.deb packages. The application is heavily obfuscated, with class and method names re-randomized on every build, so we used a normalizing diff to mask the renamed identifiers and surface only real structural and string changes. The portal request dispatcher and the portal resource loader were the two classes that gained new validation logic between the versions, which is what pointed us at the bug.

The Vulnerability

The guest portal is served by GuestServlet, mapped to /guest/*. It delegates rendering to an internal Spring bean whose failure-page renderer takes a request parameter named page_error and uses it, verbatim, as a relative path on the portal resource loader. In 10.1.85 the relevant code is:

String errorMsg = ctx.getString("error");        // exception message, if any 
if (errorMsg != null) { 
    path = req.getParameter("page_error");        // attacker-controlled 
    if (path == null) { 
        path = "fail.html"; 
    } 
} 
... 
// path is handed to the resource loader and the result streamed back: 
IOUtils.copy(loader.bskior(path), res.getOutputStream());   // raw file copy to response

There's no validation here, no canonicalization, no prefix check, nothing. Whatever the client sends in page_error becomes a relative path against the portal resource directory, and the file's contents are streamed back as the HTTP response body. A value like ../../../../some/file strolls straight out of the portal web root and keeps going.

The portal resource loader makes this concrete. Its read method has two branches:

public InputStream bskior(String path) throws IOException, NamingException { 
    if (path.startsWith("/")) path = path.substring(1); 
    return (this.customized && this.dir.isPresent()) 
        ? new FileInputStream(new File(this.dir.get() + "/" + path))   // filesystem read, follows ../ 
        : this.loader.getResource(this.namespace + "/" + path).getInputStream();  // classpath read 
}

The first branch opens whatever new File(baseDir + "/" + path) resolves to on disk. With ../ segments in the path, that resolves outside the portal directory. This branch is the vulnerable one, and which branch runs is the first precondition we will get to shortly.

Reaching the vulnerable path

The page_error parameter is only consulted when the request context carries a non-null error attribute, meaning the portal has to throw an exception during normal handling first. The cheapest unauthenticated way to populate that error is a GET request to the WeChat sign-in handler, which hard-requires a POST:

} else if (path.startsWith("/guest/wechat/sign")) { 
    if (!"POST".equalsIgnoreCase(req.getMethod())) { 
        throw new RuntimeException("/guest/login access via GET"); 
    } 
    ... 
}

The exception is caught upstream, its message is stuffed into the context's error attribute, and control falls through to the failure-page renderer, which then uses our page_error value. No cookies, no Referer fix-up, and no prior session are needed. A single GET does the whole job:

GET /guest/s/default/wechat/sign?page_error=../system.properties

Preconditions for Exploitation

A vulnerable software version is necessary but not sufficient. Two conditions decide whether the file-read path actually fires, and getting these right is the difference between a real finding and a false positive.

Precondition 1: a customized guest portal on at least one site

The vulnerable filesystem branch in the resource loader only runs when the targeted site has portal_customized: true in its guest-access settings. That flag selects the two-argument loader constructor, which sets the customized flag and populates the on-disk portal path. Without it, the loader takes a classpath branch instead, which is reachable but operationally toothless: it can return a small set of resources bundled inside the application's JARs (default portal templates, compiled class files), but it cannot reach the controller's data/ directory at all, so the actionable loot (backups, keystore, the live configured system.properties, the database) is out of scope.

That distinction matters for triage. A non-customized controller still exposes the vulnerable code path, but no path through it ends in the loot chain. Treat such a target as "vulnerability present, not actionable": the version gate is open, so anyone who turns on a custom portal flips it into the fully exploitable state, but until then there is no credential disclosure to chase. In practice, the customization gate excludes only out-of-the-box deployments that have not configured guest access. Any operator running a branded captive portal (i.e., every commercial Wi-Fi hotspot, hotel, and café) ends up with portal_customized: true on at least one site, so the precondition is the common case for those deployments that expose a guest portal in the first place.

This split has a real footgun for naïve scanners, which we cover in the next section: the JAR-bundled portal assets include a system.properties template at the root of ace.jar, so anchoring a probe on the literal string system.properties (as we did above) will return a 200 against the classpath branch and mis-flag a not-actionable controller as fully exploitable.

Precondition 2: reaching a connector the GuestServlet accepts

GuestServlet gates its dispatch on the local port the request arrived on, accepting four connectors. In a stock configuration those resolve to admin HTTPS (default 8443), guest HTTPS (8843), guest HTTP (8880), and the inform connector when enabled:

private boolean acceptsPort(int port) { 
    return port == adminHttpsPort() 
        || port == guestHttpsPort() 
        || port == guestHttpPort() 
        || (informEnabled() && port == informPort()); 
}

Here is the detail that catches people out: /guest/* is dispatched on the admin port too, not only the dedicated guest-portal listeners. We confirmed this on a 10.1.85 test container, where the same payload happily returned the targeted file from disk on both :8443 (admin) and :8843 (guest):

$ curl -k "https://localhost:8443/guest/s/default/wechat/sign?page_error=../../../../.version" 
HTTP/1.1 200 OK 
10.1.85.0-g4284b71a4 

$ curl -k "https://localhost:8843/guest/s/default/wechat/sign?page_error=../../../../.version" 
HTTP/1.1 200 OK 
10.1.85.0-g4284b71a4

This is why blocking the guest-portal connectors is not a full workaround. Many deployments LAN-restrict 8843 and 8880, but expose the management UI on 8443 or co-locate it on 443 behind a reverse proxy on UDM, Cloud Key Gen2+, and UniFi Express hardware. Anywhere the management interface is reachable, the /guest/* routes are reachable with it, and so is the vulnerable code path.

Detecting it Safely

Three good rules for a production-safe detection are: do not break anything, do not vacuum secrets into scan logs, and do not flag a controller that is not actually exploitable. The third rule is what can trip up other scanners for this bug.

The trap is the JAR-bundled system.properties template the previous section described: the classpath fallback returns it on a not-actionable controller, so anything keying solely off "200 plus system.properties header comment" will mark every non-customized controller as fully exploitable. The fix is to confirm the filesystem branch with a file that exists only on disk.

Our tool runs the probe in three stages. It first checks that the guest portal is reachable for the targeted site, then uses system.properties to calibrate the working ../ depth (depths one through eight, since the segment count between the portal directory and the data directory varies by packaging). system.properties is the right calibration anchor because it answers in both branches, so a hit there proves the traversal fires regardless of which branch is active. At the calibrated depth the tool then reads firmware.json: runtime-populated, on-disk, not present in any JAR. A hit on that file proves the filesystem branch is active. The matcher keys on a short JSON marker rather than the body, so nothing sensitive reaches the scan log even if the probe is pointed at a more sensitive file.

That gives four verdicts:

  • Vulnerable: the disk-only probe hits. The filesystem branch is active, and the full loot chain is reachable.
  • Partially exposed: calibration hits, but the disk probe does not. The bug's code path is present, but only the JAR classpath is reachable on this site. Patch anyway, because the same controller becomes fully exploitable on any site that has the customized-portal setting on; use --site <name> to test others.
  • Not vulnerable: calibration never hits at any depth. Consistent with a patched controller.
  • Not exposed: the guest portal does not answer for the tested site at all.
$ python3 cve_2026_22557_check.py https://target:8443 
     Software version: 10.1.85  [UNPATCHED, vulnerable build] 
>> Checking whether the guest portal is reachable 
    [ OK ]  Guest portal is reachable for site 'default' 
>> Testing whether the vulnerable code path responds 
     [WARN]  The vulnerable code path responded (at traversal depth 6) 
>> Confirming whether disk content is readable 
     [FAIL]  Internal files are readable without credentials (fetched firmware.json) 

                       RESULT: VULNERABLE

At scale, point the tool at whatever URL reaches the web UI rather than the guest listener, since /guest/* answers on the admin port. The tool also reports the controller's self-declared version from its public /status endpoint and tags it against the patched-build matrix, so a verdict-plus-version sweep is one invocation. --brief prints a single-line verdict for scripted sweeps. Seed the target pool with http.title:"UniFi Network" on Shodan or title="UniFi Network" on FOFA.

Detecting exploitation, not just exposure

The probe above tells you whether you are vulnerable. To tell whether someone has actually exploited the bug, look at your controller's HTTP logs. Every exploitation path in the next section begins with the same request shape, so the signatures are straightforward:

  • The traversal itself. Requests to /guest/s/<site>/wechat/sign carrying a page_error parameter that contains ../ (or its encoded forms) are the core indicator. A legitimate portal never sends page_error with path separators in it, so any such request is suspect regardless of the response code.
  • Backup theft. A page_error value referencing autobackup_meta.json or ending in .unf, or any 200 response much larger than a portal error page, points at the backup being pulled. The backup is the highest-value target, so this is the signature to alert on first, and the autobackup_meta.json read is the giveaway for the auto-backup discovery step that precedes it.
  • Live-database reads.page_error values referencing _mdb_catalog.wt or a collection-*.wt file mean an attacker is going after the database directly, the backup-independent path.
  • Keystore and config reads.page_error values referencing keystore, system.properties, or /etc/ are reconnaissance or key theft in progress.

Two caveats. The bug answers on the admin port, so check the logs for whichever connector fronts the web UI, not only the guest listeners. And because a successful read returns the file inline with a 200, response size is often a better discriminator than status code alone. Past the controller, watch for the second-stage activity these reads enable: unexpected SSH logins to managed devices, and devices attempting to adopt a controller you do not recognize.

From File Read to Compromise

An unauthenticated read into the controller's own data directory is already a critical finding, but "you can read files" is abstract, so the question that matters is which files turn into compromise and in what order an attacker would take them. We worked each candidate to ground. The backup dominates the rest; the live database is a backup-independent fallback to the same credentials; the keystore is a separate single-request take on the controller's TLS key; everything else is recon or a dead end.

The backup is the master key

The single most valuable file on the controller is its own backup. A .unf is the entire database wrapped in one file, encrypted with AES-CBC under a static key and initialization vector compiled straight into the application, not derived per install and not protected by any secret the attacker lacks. We recovered both from the decompiled encryptor, and the SAB-062 patch did not rotate them, so the same values decrypt backups from patched and unpatched builds alike. We are not publishing the key, the IV, or a decryptor, but the point for defenders is blunt: the encryption is not a barrier, because the key ships in the product.

The reason this is the primary path, and not just one option among several, is that the backup is almost always sitting on disk waiting to be read, even when no administrator ever created one. Scheduled auto-backups are on by default and run daily. They land under unguessable filenames, but the index that names them does not: a fixed-name file (autobackup_meta.json) is readable through the same traversal and discloses the exact backup filename. So, two requests with no credentials, one to read the index and one to read the file it names, pull a current database dump off essentially any provisioned controller.

Once decrypted, the dump is a cleartext credential store. What comes out and how much scales with what the deployment runs, which is the uncomfortable part: the controllers this bug reaches (branded captive portals, multi-site networks, commercial hotspots) are most likely to be the ones with heavyweight features turned on.

It takes over the managed hardware with no cracking. The most direct outcome needs no password work at all:

  • Device SSH credentials, stored in cleartext with device SSH enabled by default. An ssh <user>@<device> with that password is a root or admin shell on every access point, switch, and gateway the controller manages.
  • Per-device inform and authentication keys, also cleartext, which key the controller-to-device management channel and the configuration pushed across it.

It is a keyring for everything else the site runs. The data model defines on the order of seventy secret-bearing fields, almost all cleartext, populated as features are configured. The exposures that matter most, each gated on its feature being enabled and most needing no crack:

  • SSL-inspection CA private key. If DPI or SSL inspection is on, this is the key the controller uses to intercept client TLS, so it decrypts or impersonates every inspected connection on the network.
  • RADIUS shared secret. Impersonate the RADIUS server or attack WPA-Enterprise authentication.
  • Site-to-site VPN pre-shared keys and WireGuard private keys. These reach past the controller in front of you and let an attacker join or decrypt the tunnels to the organization's other sites, so the blast radius is the whole interconnected network.
  • Wi-Fi WPA and WPA3 passphrases, the network's actual wireless keys.
  • Payment-gateway secret keys, on a commercial hotspot the Stripe, PayPal, or Authorize.net keys behind the captive portal.

Below those sit lower-tier but still cleartext credentials: the SMTP relay password, dynamic-DNS provider passwords, and PPPoE or LTE WAN passwords.

And even with nothing cracked, it is a dossier. The same dump carries every client's MAC, hostname, and fixed IP; guest-portal PII collected at the splash page (names, emails, phone numbers, addresses, voucher codes); payment records; the full network design (firewall policy, routing, NAT, port forwards); and physical floorplans with access-point placement.

The one step that needs a crack

Logging into the controller's own web UI as super_admin is the only objective in the whole chain that costs an offline crack. The super_admin record is in the same decrypted dump, in the admin collection, holding its name, email, and x_shadow password hash, stored as Unix sha512crypt (the $6$ format). Crack it and you have a direct super_admin login; the decrypt before it is deterministic, so password strength is the only variable left.

The backup-independent route

A defender's first instinct may be that all of this rests on backups, so a controller that keeps none is safe. It is not. The live MongoDB store is readable through the same traversal, because the WiredTiger files are owned by the unifi user the read runs as. It is more work than the backup, but it is a real second path to the same secrets:

  • The collection data sits in compressed pages, but field names leak in cleartext, so the admin collection is identifiable without decompressing anything.
  • The collection filenames are randomized, but the index that maps them (_mdb_catalog.wt) is fixed-named and readable, and the collection numbers are small integers, so the right file is resolvable.
  • Pull that file and decompress its pages offline, and the admin document comes out with the same x_shadow hash the backup would have given.

The backup stays the operationally simpler path (one decryptable file, clean structure), but the live database means "we don't keep backups" is not a defense.

The keystore

The other crack-free path is unrelated to the backup and to the live database. The controller's TLS keystore lives at a predictable path in the data directory and reads through the same traversal in a single anonymous request. It is a PKCS12 file protected by a default password that ships with the application, and operators almost never change.

That key is a machine-in-the-middle primitive against the traffic between the controller and the devices it manages. An attacker positioned on that path can present a certificate the devices already trust, decrypt the inform-channel traffic, and either watch it passively or impersonate the controller and push configuration of their choosing: full device control with no cracking and no database access.

The catch is the position requirement. The MITM attack needs the attacker on the same network as the controller or on infrastructure between it and its devices, which is a steep ask for a remote attacker against an internet-facing target. It is much less of an ask when someone already has a foothold inside the network (a compromised internal host, a guest VLAN that reaches the management plane), at which point a single anonymous request hands them the worst-case lever.

Lesser paths and dead ends

The remaining avenues are worth knowing about but are recon or non-starters, not the main event:

  • Whole-application exfiltration. The 25 MB application JAR itself reads through the traversal, so an attacker can pull the entire application and analyze it offline, which is exactly how the backup encryption key is recovered in the first place, without ever obtaining the installer.
  • Recon and pivot material. Host files, the network design from a backup, and /proc/net/tcp (which lists the controller's internal listening services and connections) map the environment for lateral movement.
  • Token shortcuts, target-specific. A standing API key or a cloud/SSO token in the database would grant controller access with no crack, but these are populated only when an operator configured an API key or linked the controller to a Ubiquiti cloud account. On a typical local-only controller they are absent, and the internal integration bearer token that is present does not grant web-UI or API access, so treat this as something to check per target rather than a reliable path.
  • Live session theft, ruled out. Stealing a logged-in admin's unifises cookie does not work here: it is an unsigned random value with no secret to forge against, the session store is in memory and never written to disk, and the one place the token lives (/proc/self/mem) is unreadable by a loader that streams from offset 0.

Why this earns a 10.0

One thing is worth clarifying: the controller process runs under a user account, not as root. On the official .deb it runs as the unprivileged unifi user, so the read is scoped to what that user can reach. A host-level secret like /etc/shadow stays out of reach and returns a 404; only world-readable host files such as /etc/passwd come back.

That does not soften the impact rating, however, because every credential the attack needs lives under the controller's own data/ directory, which the unifi user owns and reads freely. The full 10.0 comes from the combination that is all still true: an unauthenticated, network-reachable request escapes the portal's resource sandbox and reads the controller's secret store, a confidentiality, integrity (via device and adoption hijack), and availability hit across a scope boundary.

The Patch

Version 10.1.89 fixes the portal dispatcher with a strict allowlist on page_error:

private static final Pattern SAFE_PAGE = Pattern.compile("[a-zA-Z0-9_\\-]+\\.html"); 
... 
if (path == null || !SAFE_PAGE.matcher(path).matches()) { 
    path = "fail.html"; 
}

The regex requires the entire value to be alphanumerics, underscores, or hyphens ending in .html. No slashes and no dots other than the trailing extension survive it, which closes the traversal. The portal documentation was updated to say "simple filename only, e.g. fail.html (no subdirectories)." The same release also hardens the resource loader itself with a canonicalization-and-containment check, which is the fix for the sibling symbolic link bug CVE-2026-22559 and serves as defense in depth behind the page_error allowlist.

A one-line regex looks flimsy, so we tried to get around it rather than take it on faith. Three defensive layers closed the traversal, and every avenue we walked hit at least one. The regex is a whole-string match against the once-decoded parameter, so percent- and Unicode-encoded ../ fail it. The handler's other read path reads from the request URI, which Tomcat normalizes .. out of before the servlet sees it (the reason the original bug needed the parameter rather than the URI). And both paths funnel through the loader's new toRealPath() containment check, which rejects anything that resolves outside the portal directory. We found no unauthenticated bypass; the patch genuinely remediates the file read.

Conclusion

CVE-2026-22557 is a tidy reminder that a CVSS number tells you the ceiling, not whether your particular box is standing under it. The version gate is necessary, but a controller is only exploitable when a site runs a customized guest portal. And it's important to note that the vulnerable routes answer on the management port, not just the guest listeners. Defenders should patch, and where they cannot, remember that blocking the guest-portal connectors alone quietly leaves the admin-port path wide open.

With regard to impact, the most important point for defenders is that the strongest path needs no password cracking and is almost always available. The controller's own backup decrypts with a key shipped in the product, and auto-backups run daily by default with a fixed-name index that hands an attacker the filename. Therefore, the database is sitting on disk to be read even when no admin ever made a backup. Inside it are the device SSH credentials, the per-device management keys, so taking over the managed hardware is gated on nothing but the file read.

On a provisioned commercial site the same backup is a cleartext keyring of everything else the deployment runs, from RADIUS secrets and VPN keys to the SSL-inspection CA and payment-gateway keys, alongside a full PII and network-design dossier that needs no cracking to read. Patching genuinely closes the file read, but it does not unburn a backup taken beforehand, so a clean recovery means rotating every password and credential configured on the controller.

Even when backups aren't present, an attacker can extract private keys to enable machine-in-the-middle attacks, a sha512crypt hash that could (if cracked) allow super_admin access to the controller UI, and other secrets directly from the underlying MongoDB database.

Our Cosmos customers were notified about our research into this vulnerability shortly after the vendor advisory. If you are interested in learning more about managed services delivered through our Cosmos platform, visit bishopfox.com/services/cosmos.

Our detection tool is available on GitHub: https://github.com/BishopFox/CVE-2026-22557-check.

For more vulnerability intelligence insights, visit the Bishop Fox Blog.


文章来源: https://bishopfox.com/blog/looting-unifi-controllers-detecting-and-weaponizing-cve-2026-22557
如有侵权请联系:admin#unsafe.sh