After seeing this arbitrary command execution vulnerability in Ubiquiti equipment, discovered by SEC Consult, I was intrigued. In that bug, code that would have been secure on a more recent version of PHP was rendered vulnerable because of the ancient PHP version used (2.0.1, which is nearly 20 years old). I wanted to see what other bugs might be caused by PHP that works in unexpected ways.

My friend owns a “NanoBeam AC” running firmware WA_v8.0.1, so I downloaded that firmware from Ubiquiti’s website and unpacked it with binwalk. I found a bunch of PHP scripts, a custom patched PHP 2.0.1 binary, and a custom patched Lighttpd server which handles session management and serves the files.

In those PHP scripts I saw a ton of opportunities for Ubiquiti to get things wrong because of the number of calls they made to execute external programs using the shell. It’s difficult to run commands through the shell securely, because of the potential for characters in the command to be interpreted by the shell as parameter separators or shell metacharacters. This could allow an attacker to inject unexpected parameters, overwrite unexpected files, or execute unexpected commands. Since Ubiquiti already add their own code to the PHP binary, it seems silly that they wouldn’t add their own spawn() command, which would bypass shell interpretation of the command and so solve all of those issues (including this login bypass vulnerability) in one fell swoop, but I digress.

The potential vulnerabilities I saw were all gated behind authentication, so although they would be vulnerable to CSRF payloads sent to a user who was currently logged in to their router (there is no CSRF token protection), they weren’t very interesting to me, as I don’t imagine that people spend that much time logged in to their routers. (There has a been a lot of work on improving this code in more recent firmware releases, so most/all of this code is fixed by now anyway)

Which files are accessible without authentication? Well, let’s check /etc/lighttpd/lighttpd.conf :

airos.allow = ( ... "jsl10n.cgi", "poll.cgi", "/login.cgi", "/ticket.cgi", ... )

Hm, what’s ticket.cgi exactly? It turns out that this equipment has a facility for an administrator or automated system to SSH to the router and generate a login “ticket” for the device, which is a 32-character random string. This ticket can then be passed as a link to some member of your organisation’s support staff, so that they can log in to the router to update its configuration without needing to know the admin password or SSH credentials for the router. It provides a temporary-access facility.

Ubiquiti’s “airControl” administration tool creates these tickets automatically for you when you use the “Open Web-UI” feature. Without airControl, an admin could manually generate a ticket for the root account by running a command like this on the device:

/bin/ma-ticket-add /tmp/.tickets.tdb 2de09cb52c5d9aab729e155336167e03 root

This creates a new ticket database, /tmp/.tickets.tdb (this file doesn’t exist by default and is removed on reboot) and adds a single ticket to it with that specified 32-character value, which will log the user in as the specified user (here, “root”).

A user can then log in to the device as root by visiting the URL “http://192.168.1.1/ticket.cgi?ticketid=2de09cb52c5d9aab​729e155336167e03”, where 192.168.1.1 is the IP address of the device. Visiting this link causes the ticket to be consumed and deleted from the ticket database.

If someone has generated and subsequently used a login ticket since the last reboot, there will be an empty database of tickets in /tmp/.tickets.tdb (since all tickets have been used and removed from the database). This is the situation that opens the vulnerability.

Let’s take a look at the vulnerable code in ticket.cgi:

if (isset($ticketid) && (strlen($ticketid) > 0)) { /* check ticket existence */ $cmd = "/bin/ma-show /tmp/.tickets.tdb " + $ticketid; exec(EscapeShellCmd($cmd),$lines,$rc); if ($rc == 0) { $user_regexp = "[[:space:]]+user:[[:space:]]+\\'([[:print:]]+)\\'$"; $username = "mcuser"; $i = 0; $size = count($lines); while ($i < $size) { if (ereg($user_regexp, $lines[$i], $res)) { $username = $res[1]; $i = $size; } $i++; } /* authorize the session, that brought this ticket */ $session = get_session_id($$session_id, $AIROS_SESSIONID, $HTTP_USER_AGENT); $cmd = "/bin/ma-auth /tmp/.sessions.tdb " + $session + " " + $username; exec(EscapeShellCmd($cmd), $lines, $rc);

We provide a non-empty ticket ID as a URL parameter ticketid . This is passed to the /bin/ma-show binary, which looks for that ticket ID in the ticket database. If that ticket is found, ma-show prints out the contents of the ticket and returns a zero exit code to indicate success (otherwise it returns a non-zero exit code). The PHP code parses the content of the ticket to find out which user the ticket is for, and finally it creates a logged-in session for that user using the ma-auth binary (the same binary that is used to create a session during a regular login).

Okay, so how can this go wrong? Well, ma-show actually has a bonus feature. If you call it with no ticket ID argument, it prints out every ticket in its database, and sets its exit code to the number of tickets that it printed out. We can trigger this by passing a single space character in as our ticket ID. Since the shell will treat it as whitespace and discard it, ma-show will only see one argument on its command line.

If the ticket database is empty, ma-show ‘s return code ($rc) will be zero (since it’ll print zero tickets), so the PHP code will consider the ticket lookup to be successful! However, since ma-show won’t print out any tickets, the parsing to find a username in the output will fail, and at first glance it appears that we could only authenticate as “mcuser”, an account that may not actually exist on the device.

This is where the magic of PHP 2 comes in. In PHP 2, when you add a parameter to the URL, it causes that value to be set into a global variable with the same name as the parameter (what newer versions of PHP would call “register globals“). And the exec() command appends its output to whatever is already in the $lines array. So we can supply a ?lines[]= parameter in the URL to effectively add our own user grant to the otherwise-empty output of the first exec() call.

The upshot

So in the situation where the ticket database is present, but empty (e.g. airControl’s “open web-UI” feature has been used since the last reboot), it is possible for an unauthenticated remote user to log in as any user, without knowing any ticket ID, just by visiting a specially-crafted URL.

I sent the reproduction instructions to my friend, and sure enough, they were able to get root on their Nanobeam using it.

The fix

I reported this vulnerability to Ubiquiti through HackerOne on 2017-03-21. The vulnerability was promptly patched in airOS v8.0.2, v7.2.5, v6.0.2 and v5.6.15 (2017-03-28) by adding this code, which ensures that the ticket ID is non-empty before calling ma-show:

if (!ereg("^[[:xdigit:]]{32}$", $ticketid)) { Header("Location: $redir_url"); exit; }

Ubiquiti report that this is also fixed in airGateway v1.1.9 (2017-03-28) and airFiber v3.7-rc3, v3.4.3, v3.2.3 (2017-04-07), products that I am not familiar with.

The airControl software now includes a mitigation (since 2.0.2 and 2.1-beta7) to prevent the “Open Web-UI” feature from opening up the vulnerability even when used against old firmware.

One way of achieving this mitigation would be to create two random tickets at a time, one of which will never be consumed, so that the ticket database is never emptied. If you were building your own custom authentication system on top of the ticket functionality, this is what you could do to avoid the vulnerability when used against older firmware.

Timeline

2017-03-21 – Reported to Ubiquiti through HackerOne

2017-03-22 – Receipt confirmed and Ubiquiti says they’re working on a fix

2017-03-28 – Firmwares released with fix

2017-04-11 – US$1000 bounty awarded

2017-04-14 – Ubiquiti suggest a 120 day disclosure period, I agree.

2017-08-17 – Bounty upgraded to US$6000

2017-08-18 – Disclosure

Release notes for 8.0.2

airOS8 Firmware Revision History ==================================================================== Supported products * Rocket 5AC Lite, model: R5AC-Lite * Rocket 5AC PTP AirPrism, model: R5AC-PTP * Rocket 5AC Multi-Point AirPrism, model: R5AC-PTMP * PowerBeam 5AC, models: PBE-5AC-500, PBE-5AC-620, PBE-5AC-300, PBE-5AC-400 * PowerBeam 5AC 300 ISO, model: PBE-5AC-300-ISO * PowerBeam 5AC 400 ISO, model: PBE-5AC-400-ISO * PowerBeam 5AC 500 ISO, model: PBE-5AC-500-ISO * NanoBeam 5AC 19dBi, model: NBE-5AC-19 * NanoBeam 5AC 16dBi, model: NBE-5AC-16 * LiteBeam 5AC 23dBi, model: LBE-5AC-23 * LiteBeam AC 16 dBi 120 degrees, model: LBE-AC-16-120 * Rocket 5AC Prism, model: R5-AC-PRISM ==================================================================== Version 8.0.2 (XC, WA) - Service Release (March 28, 2017) -------------------------------------------------------------------- New: - New: SNMP OIDs for CPU and Memory utilisation - New: Update dropbear to v2016.74 - New: OpenSSL update to v1.0.2k - New: libevent update to v2.1.8 Fixes: - Fix: PTP mode stability and performance improvements - Fix: Security fixes and improvements - Fix: ATPC fast restart added - Fix: Restore initial TX power on AP/PTP when ATPC is turned off - Fix: Revert Device Name strictness for DHCP Client (escape only hashtag â€˜#â€™ symbol which breaks DHCP Client operation) - Fix: Station fails re-authentication with AP when the same SSID is used for other APs - Fix: Stations start disassociating from AP (PTMP) - Fix: ATPC feature enable/disable and ATPC target signal change interrupts wireless link - Fix: Wrong distance (100km) reporting after switching from Fixed to Auto Distance in airMAX PTP mode - Fix: Flow Control fix for WA products WEB UI: - WEB UI: Don't allow to remove BRIDGE0 interface containing WLAN0 - WEB UI: "(Auto)" label missing on STA's Remote statistics in case ATPC is enabled on AP (PTP mode only) - WEB UI: Show more detailed error messages when upgrading invalid firmware - WEB UI: Improved Station List for small screens like mobile - WEB UI: Change status.cgi output type from text/html to application/json - WEB UI: Password change validation fix