Since its creation in 2008, the use of the [Symfony](https://symfony.com) framework has been growing more and more in PHP based applications. It is now a core component of many well known CMSs, such as [Drupal](https://www.drupal.org), [Joomla!](https://www.joomla.org), [eZPlatform](https://ezplatform.com) (formerly eZPublish), or [Bolt](https://bolt.cm), and is often used to build custom websites.
One of Symfony's built-in features, made to handle [ESI (Edge-Side Includes)](https://en.wikipedia.org/wiki/Edge\_Side\_Includes), is the [`FragmentListener` class](https://github.com/symfony/symfony/blob/5.1/src/Symfony/Component/HttpKernel/EventListener/FragmentListener.php). Essentially, when someone issues a request to `/_fragment`, this listener sets request attributes from given GET parameters. Since this allows to **run arbitrary PHP code** (_more on this later_), the request has to be signed using a HMAC value. This HMAC's secret cryptographic key is stored under a Symfony configuration value named `secret`.
This configuration value, `secret`, is also used, for instance, to build CSRF tokens and remember-me tokens. Given its importance, this value must obviously be very random.
Unfortunately, we discovered that oftentimes, the secret either has a **default value**, or there exist **ways to obtain the value, bruteforce it offline, or to purely and simply bypass the security check that it is involved with**. It most notably affects Bolt, eZPlatform, and eZPublish.
Although this may seem like a begnin configuration issue, we have found that default, bruteforceable or guessable values are **very, very often present** in the mentioned CMSs as well as in custom applications. This is mainly due to not putting enough emphasis on its importance in the documentation or installation guides.
Furthermore, an attacker can escalate less-impactful vulnerabilities to either read the `secret` (through a file disclosure), bypass the `/_fragment` signature process (using an SSRF), and even leak it through `phpinfo()` !
In this blogpost, we'll describe how the secret can be obtained in various CMSs and on the base framework, and how to get code execution using said secret.
Being a modern framework, Symfony has had to deal with generating sub-parts of a request from its creation to our times. Before `/_fragment`, there was `/_internal` and `/_proxy`, which did essentially the same thing. It produced a lot of vulnerabilities through the years: [CVE-2012-6432](https://symfony.com/blog/security-release-symfony-2-0-20-and-2-1-5-released#cve-2012-6432-code-execution-vulnerability-via-the-internal-routes), [CVE-2014-5245](https://symfony.com/blog/cve-2014-5245-direct-access-of-esi-urls-behind-a-trusted-proxy), and [CVE-2015-4050](https://symfony.com/blog/cve-2015-4050-esi-unauthorized-access), for instance.
Since Symfony 4, the secret is generated on installation, and the `/_fragment` page is disabled by default. One would think, therefore, that the conjunction of both having a weak `secret`, and enabled `/_fragment`, would be rare. It is not: many frameworks rely on old Symfony versions (even 2.x is very present still), and implement either a static `secret` value, or generate it poorly. Furthermore, many rely on ESI and as such enable the `/_fragment` page. Also, as we'll see, other lower-impact vulnerabilities can allow to dump the secret, even if it has been securely generated.
We'll first demonstrate how an attacker, having knowledge of the `secret` configuration value, can obtain code execution. This is done for the last `symfony/http-kernel` version, but is similar for other versions.
`FragmentListener:onKernelRequest` will be run on every request: if the request's path is `/_fragment` \[1], the method will first check that the request is valid (_i.e._ properly signed), and raise an exception otherwise \[2]. If the security checks succeed, it will parse the url-encoded `_path` parameter, and set `$request` attributes accordingly.
Request attributes are not to be mixed up with HTTP request parameters: they are internal values, maintained by Symfony, that usually cannot be specified by a user. One of these request attributes is `_controller`, which specifies which Symfony controller (a _(class, method)_ tuple, or simply a _function_) is to be called. Attributes whose name does not start with `_` are arguments that are going to be fed to the controller. For instance, if we wished to call this method:
Essentially, this allows to call any function, or any method of any class, with any parameter. Given the plethora of classes that Symfony has, **getting code execution is trivial**. We can, for instance, call `system()`:
In short, Symfony extracts the `_hash` GET parameter, then reconstructs the full URL, for instance `https://symfony-site.com/_fragment?_path=controller%3d...%26argument1=test%26...`, computes an HMAC from this URL using the `secret` as key \[1], and compares it to the given hash value \[2]. If they don't match, a `AccessDeniedHttpException` exception is raised \[3], resulting in a `403` error.
By checking out `http://localhost:8000/_fragment?_hash=lNweS5nNP8QCtMqyqrW8HIl4j9JXIfscGeRm/cmFOh8=`, we now have a `404` status code. The signature was correct, but we did not specify any request attribute, so Symfony does not find our controller.
Since we can call any method, with any argument, we can for instance pick `system($command, $return_value)`, and provide a payload like so:
We can now visit the exploit URL: `http://localhost:8000/_fragment?_path=_controller%3Dsystem%26command%3Did%26return_value%3Dnull&_hash=GFhQ4Hr1LIA8mO1M/qSfwQaSM8xQj35vPhyrF3hvQyI=`.
Despite the `500` error, we can see that **our command got executed**.
Again: all of this would not matter if secrets were not obtainable. Oftentimes, they are. We'll describe several ways of getting code execution without any prior knowledge.
On recent symfony versions (3.x), `secret` is stored in `.env` as `APP_SECRET`. Since it is then imported as an environment variable, they can be seen through a `phpinfo()` page.
The code behind `FragmentListener` has evolved through the years: up to version _2.5.3_, when the request came from a trusted proxy (read: `localhost`), it would be considered safe, and as such the hash would not be checked. An SSRF, for instance, can allow to immediately run code, regardless of having `secret` or not. This notably affects eZPublish up to 2014.7.
When setting up a Symfony website, the first step is to install the [symfony-standard](https://github.com/symfony/symfony-standard) skeleton. When installed, a prompt asks for some configuration values. By default, the key is `ThisTokenIsNotSoSecretChangeIt`.
[ezPlatform](https://ezplatform.com), the successor of [ezPublish](https://en.wikipedia.org/wiki/EZ\_Publish), still uses Symfony. On Jun 10, 2019, a [commit](https://github.com/ezsystems/ezplatform/commit/974f2a70d9d0507ba7ca17226693b1a4967f23cf#diff-f579cccc964135c7d644c7b2d3b0d3ecR59) set the default key to `ff6dc61a329dc96652bb092ec58981f7`. Vulnerable versions range from 3.0-alpha1 to 3.1.1 (current).
Although the [documentation](https://doc.ezplatform.com/en/latest/getting\_started/install\_ez\_platform/#change-installation-parameters) states that the secret should be changed, it is not enforced.
Like Symfony's skeleton, you will be prompted to enter a secret during the installation. The default value is `ThisEzPlatformTokenIsNotSoSecret_PleaseChangeIt`.
[Bolt CMS](https://bolt.cm) uses [Silex](https://github.com/silexphp/Silex), a deprecated micro-framework based on Symfony. It sets up the secret key using this computation:
Since the secret is often set manually (as opposed to generated randomly), people will often use a passphrase instead of a secure random value, which makes it bruteforceable if we have a hash to bruteforce it against. Obviously, a valid `/_fragment` URL, such as one generated by Symfony, would provide us a valid message-hash tuple to bruteforce the secret.
At the beginning of this blogpost, we said that Symfony's secret had several uses. One of those uses is that it is also used to generate CSRF tokens. Another use of `secret` is to sign remember-me cookies. In some cases, an attacker can use his own CSRF token or remember-me cookie to bruteforce the value of `secret`.
_The reverse engineering of the construction of those tokens is left as an exercise to the reader._
To build this token, eZP uses two values we know, and the secret: `getIntention()` is the action the user is attempting (`authenticate` for instance), `session_id()` is the PHP session ID, and `getSecret()`, well, is Symfony's `secret`.
Since CSRF tokens can be found on some forms, we now have the material to bruteforce the secret.
Unfortunately, ezPublish incorporated a bundle from sensiolabs, [sensio/distribution-bundle](https://packagist.org/packages/sensio/distribution-bundle). This package makes sure the secret key is random. It generates it like this, upon installation:
This looks really hard to bruteforce: `mt_rand()` can yield 231 different values, and `uniqid()` is built from the current timestamp (with microseconds).
Luckily, we know this secret gets generated on the last step of the installation, right after the website gets set up. This means we can probably leak the timestamp used to generate this hash.
One way to do so is using the logs (_e.g._ `/var/log/storage.log`); one can leak the first time a cache entry was created. The cache entry gets created right after `generateRandomSecret()` is called.
If logs aren't available, one can use the very powerful search engine of eZPublish to find the time of creation of the very first element of the website. Indeed, when the site is created, a lot of timestamps are put into the database. This means that the timestamp of the initial data of the eZPublish website is the same as the one used to compute `uniqid()`. We can look for the `landing_page`_ContentObject_ and find out its timestamp.
This leaves us with a total of 231 \* 106 possibilities. It feels doable with [hashcat](https://hashcat.net) and a good set of GPUs, but hashcat does not provide a `sha1(sha1($pass).$salt)` kernel. Luckily, we implemented it! You can find [the pull-request here](https://github.com/hashcat/hashcat/pull/2536).
Symfony is now a core component of many PHP applications. As such, any security risk that affects the framework affects lots of websites. As demonstrated in this article, either a weak secret or a less-impactful vulnerability allows attackers to obtain **remote code execution**.
As a blue teamer, you should have a look at every of your Symfony-dependent websites. Up-to-date software cannot be ruled out for vulnerabilities, as the secret key is generated at the first installation of the product. Hence, if you created a Symfony-3.x-based website a few years ago, and kept it up-to-date along the way, chances are the secret key is still the default one.
On the first hand, we have a few things to worry about when exploiting this vulnerability:
* The HMAC is computed using the **full URL**. If the website is behind a reverse proxy, we need to use the internal URL of the service instead of the one we're sending our payload to. For instance, the internal URL might be over HTTP instead of HTTPS.
* The HMAC's algorithm changed over the years: it was **SHA-1** before, and is now **SHA-256**.
* Since Symfony removes the `_hash` parameter from the request, and then generates the URL again, we need to compute the hash on the same URL as it does.
* Lots of secrets can be used, so we need to check them all.
* On some PHP versions, we cannot call functions which have "by-reference" parameters, such as `system($command, &$return_value)`.
* On some Symfony versions, `_controller` cannot be a function, it has to be a method. We need to find a Symfony method that allows us to execute code.
On the other hand, we can take advantage of a few things:
* Hitting `/_fragment` with no params, or with an invalid hash, should return a `403`.
* Hitting `/_fragment` with a valid hash but without a valid controller should yield a `500`.
The last point allows us to test secret values without worrying about which function or method we are going to call afterwards.
* Internal URL: it could be `https://target.com/_fragment`, or maybe `http://target.com/_fragment`, or else something entirely different (_e.g._ `http://target.website.internal`), which we can't guess
We do not need to worry about the effective payload (the contents of `_path`) yet, because a properly signed URL will not result in an `AccessDeniedHttpException` being thrown, and as such won't result in a `403`. The exploit will therefore try each `(algorithm, URL, secret)` combination, generate an URL, and check if it does not yield a `403` status code.
Then, we need to find out if we can call a function directly, or if we need to use a class method. We can first try the first, most straightforward way, using a function such as `phpinfo ([ int $what = INFO_ALL ] )` ([documentation](https://www.php.net/manual/en/function.phpinfo.php)). The `_path` GET parameter would look like this:
Otherwise, this means that we'll need to use a class method instead. A good candidate for this is `Symfony\Component\Yaml\Inline::parse`, which is a built-in Symfony class, and as such is present on Symfony websites.
Obviously, this method parses a YAML input string. Symfony's [YAML](https://yaml.org) parser supports the `php/object` tag, which will convert a serialized input string into an object using `unserialize()`. This lets us use our favorite PHP tool, [PHPGGC](https://github.com/ambionics/phpggc) !
public static function parse($value, $flags, $references);
public static function parse($value, $exceptionOnInvalidType, $objectSupport);
public static function parse($value, $exceptionOnInvalidType, $objectSupport, $objectForMap, $references);
```
Instead of building `_path` for each one of these, we can take advantage of the fact that if we give an argument whose name does not match the method prototype, it will be ignored. We can therefore add every possible argument to the method, without worrying about the actual prototype.
The exploit will therefore run through every possible variable combination, and then try out the two exploitation methods. The code is available on [our GitHub](https://github.com/ambionics/symfony-exploits).
As you see the screenshot above, there is `sf` logo on the right bottom side of the page. This logo is shown when the Symfony is under the debug mode. There are some cases that this logo doesn’t show up, so try accessing `/_profiler` and you will see the page as shown below
This feature is called Symfony Profiler, and there is not much information about this feature on the internet. The intention of this feature crystal clear; it helps you debug when there is an error or a bug. Of course, this feature can only be used when the debug mode is enabled.
The Symfony framework itself is very secure, but enabling debug mode will make this framework will make it extremely vulnerable. For example, Profiler has a feature called Profile Search, as the following screenshot.
As you see in the screenshot above, you can access all sent requests to the server. By clicking hashes in the token, you will see that all POST parameters can be read, as seen in the following screenshot. With this feature, we can hijack the administrator and user’s account credentials.