Second order SQL injection in ZoneMinder

At Qbit we practice our hacking skills on open source projects. By finding and reporting vulnerabilities in open source software, we make the software more secure and get more experience with finding issues in actual software. One such a project is ZoneMinder, a video surveillance software system. We found several vulnerabilities in its web interface, including second order SQL injection.

Geplaatst op 11 juli 2019 in Blog.

ZoneMinder is a video surveillance software system, meant for administration of CCTV cameras. It has a web interface which was tested for web specific vulnerabilities.

Searching for unauthenticated XSS

The test was performed locally on a virtual machine running ZoneMinder, which is accessible through a web interface. By default, authentication is disabled, which means the web application requires no login. Clicking through the application to see which options are available revealed a simple feature to set the home URL and title. There is a link in the top-left corner, and the destination of this link is configurable in the application. This is a nice place to try stored XSS. Assuming that the HOME_URL value is put in a <a href="…"> tag, maybe we can inject a HTML attribute by using a quote in our HOME_URL. And indeed, using the value http://zoneminder.com" onmouseover="alert(1) works and shows a JavaScript popup when we hover over the link.

A popup shows that the JavaScript payload is executed

However, XSS is particularly interesting if it can attack higher privileged users. This is not applicable without authentication, so testing continued after enabling authentication. This gives a login screen when requesting the URL. After logging in and clicking through the application again, the application’s log shows login attempts. This can be interesting if this is also vulnerable to XSS.

We try to log in with <h1 onmouseover="alert(1)">XSS</h1>, and any password. Of course we get an error message that the credentials are incorrect, but now the username value is logged. When the administrator views the log, our HTML is rendered.

HTML with JavaScript is entered as username
The JavaScript is executed when viewing the log

Finding SQL injection

The invalid login attempt is logged twice. In one our HTML is rendered, and in the other one the HTML is stripped. Let’s look at the authentication function to see what is going on. We can find the function userLogin in the file web/includes/auth.php:

function userLogin($username, $password='', $passwordHashed=false) {
  global $user;

  $sql = 'SELECT * FROM Users WHERE Enabled=1';
  …
  $_SESSION['username'] = $username;
  …
  if ( $dbUser = dbFetchOne($sql, NULL, $sql_values) ) {
    Info("Login successful for user \"$username\"");
    $_SESSION['user'] = $user = $dbUser;
    unset($_SESSION['loginFailed']);
    if ( ZM_AUTH_TYPE == 'builtin' ) {
      $_SESSION['passwordHash'] = $user['Password'];
    }
    session_regenerate_id();
  } else {
    Warning("Login denied for user \"$username\"");
    $_SESSION['loginFailed'] = true;
    unset($user);
  }
  if ( $close_session )
    session_write_close();
  return isset($user) ? $user: null;
} # end function userLogin

Now, this doesn’t show why two lines are logged, one which is vulnerable to XSS and one isn’t. Our username is put unencoded in the warning object.

Another interesting thing here is that $_SESSION['username'] is set early on, even before checking whether the username and password are correct. This means we can set the username in the session from the login form without authenticating. This can’t be good. Let’s see if this value is used anywhere.

function getAuthUser($auth) {
  …
  if ( isset($_SESSION['username']) ) {
    # Most of the time we will be logged in already and the session will have our username, so we can significantly speed up our hash testing by only looking at our user.
    # Only really important if you have a lot of users.
    $sql = "SELECT * FROM Users WHERE Enabled = 1 AND Username='".$_SESSION['username']."'";
  } else {
  …
} // end getAuthUser($auth)

The username from the session is used in getAuthUser. Here, it is used unescaped in the SQL query. This means that the application is vulnerable to unauthenticated second order SQL injection. We inject a SQL expression in the username field of the login form, and that gets executed in getAuthUser. Where is getAuthUser called? In AppController.php:

public function beforeFilter() {
  …
  $mAuth = $this->request->query('auth') ? $this->request->query('auth') : $this->request->data('auth');
  …
    } else if ( $mAuth ) {
      $user = getAuthUser($mAuth);
  …
} # end function beforeFilter()

The beforeFilter function is called on every API call, so if we do any API call with an auth parameter, getAuthUser gets called and our injected SQL will get executed.

So first, we try to log in with the username a' or SLEEP(3)='a. This gets stored in the $_SESSION['username']. Then we call http://zoneminder.local/zm/api/index.php/logs.json?auth=a, and by the time it takes we know that our query got executed.

Exploiting with sqlmap

Sqlmap is an excellent tool to exploit SQL injection, and can exploit this particular vulnerability. This can be done with this command:

./sqlmap.py -r login.request.txt -p username --second-url='http://zoneminder.local/zm/api/index.php/logs.json?auth=a' --ignore-code=401 --dbms=mysql --level=3

The file login.request.txt contains the login request, which is passed to sqlmap using the -r parameter. This is an easy way to pass a request to sqlmap. However, one thing that is not included in the request is whether is should be done over HTTPS, so be sure to pass --force-ssl if you are testing a HTTPS site. We indicate that the payload should be in the username parameter with the -p flag. After injecting the payload, sqlmap should call another URL, which we pass with --second-url. The login form will return a 401 to indicate that we are not logged in, but that doesn’t matter to us, so we’ll ignore it. Then we provide sqlmap with the database system in use (MySQL) and how hard it should try (level). This succesfully finds the SQL injection:

Type: AND/OR time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: action=login&view=postlogin&postLoginQuery=&username=abc' AND (SELECT * FROM (SELECT(SLEEP(5)))sPHz)-- OYdS&password=abc

Conclusion

While investigating an XSS issue we found a second order SQL injection vulnerability. After reporting this to the ZoneMinder developers, they promptly fixed the issue.

Read more

Sjoerd Langkemper

By Sjoerd Langkemper

Hacking & Testing

Heb je vragen over deze blog of onze diensten? E-mail me of bel me op +31 85 8 222 800.

Contact

Newsletter