Operation Coldstart
Wake up the staging server everyone left behind.
Scenario
Volt Labs, a small SaaS shop, suspects an old staging server has rotted into an exposed liability. Mara has assigned you the engagement. Find your way in and demonstrate full compromise.
Questions
Reconnaissance
rustscan -a 10.48.168.62 -- -sV -sC
Ports 21 (FTP),22 (SSH) and 80 (HTTP) are open. So, I will try with the ftp protocol first. I try logging in as anonymous, and it works
And I found a directory pub that contains a backup.tar.gz file
After extracting the file, it contains app.py, which is likely the source code for the site.
app.py:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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)
The site is a flask-based “URL Preview Service” for Volt Labs that fetches a user-supplied URL and renders its contents on the page, gated by a hostname allow-list kestrel.thm. It also exposes an /admin/ area restricted to loopback clients, including an /admin/notes endpoint that reads admin_notes.txt from disk
From the comments, we can see that kestrel.thm resolves to 127.0.0.1 in the internal /etc/hosts file. And the admin endpoint is only accessible internally.
Website
Now, let’s enumerate the website.
Since we already know the website source code, I will try to put http://kestrel.thm which resolves to 127.0.0.1
and in the source code we can access admin_note.txt via /admin/note
And we got the SSH access credentials.
The credentials work for webdev over SSH. Now, let’s check the home directory for the user flag.
and got the first flag!
Privilege escalation (root)
I continue enumerating the targets to find vulns for privilege escalation, and I found something interesting at /etc/cron.d/*
* * * * * root cd /opt/backups && tar czf /var/backups/uploads.tgz * This seems to be vuln to a wildcard injection attack or argument injection
When root’s cron runs tar czf out.tgz * in a directory you can write to, the shell expands * to whatever filenames exist there. So files named --checkpoint=1 and --checkpoint-action=exec=sh shell.sh get passed to tar as actual command-line flags. Tar’s --checkpoint-action=exec= runs a shell command at each checkpoint, so it executes shell.sh as root.
1
2
3
4
5
6
# payload: copy bash, set SUID bit
echo 'cp /bin/bash /tmp/rootbash; chmod +s /tmp/rootbash' > shell.sh
# filenames that become tar flags after shell expansion
touch -- "--checkpoint=1"
touch -- "--checkpoint-action=exec=sh shell.sh"
Once root runs e.g. tar -czf /root/backup.tgz *, shell.sh is executed as root.
The shell shows euid=0(root), so we have root privileges. The -p option keeps the elevated privileges when starting the SUID bash shown here.
and we will find the flag at /root/flag.txt
DONE !!!
















