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.
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.
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 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.
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
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.
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.
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.
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:
--site <name> to test others. $ 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.
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:
/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. 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. 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. 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.
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 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:
ssh <user>@<device> with that password is a root or admin shell on every access point, switch, and gateway the controller manages. 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:
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.
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.
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:
admin collection is identifiable without decompressing anything. _mdb_catalog.wt) is fixed-named and readable, and the collection numbers are small integers, so the right file is resolvable. 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 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.
The remaining avenues are worth knowing about but are recon or non-starters, not the main event:
/proc/net/tcp (which lists the controller's internal listening services and connections) map the environment for lateral movement. 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.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.
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.
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.