4 years ago, Charles Fol (cfreal) published his article on the exploitation of Symfony using the _fragment technique. As a reaction, I published at the same time an article presenting how to use this technique to obtain Remote Code Execution without authentication on the Bolt CMS (based on his research).

Today we’re going to look at a trick to complete the fragment exploitation technique. The first step, which turns out to be all about finding Symfony’s secret, must surely have been documented and therefore serves only as a reminder. The second part (the trick itself) shows how it’s possible to still get Remote Code Execution (using Symfony core features) when functions like shell_exec(), popen(), system() are disabled by the PHP sandbox (disable_functions).

APP_SECRET and secret recovery using the Symfony profiler

On Symfony, it is possible to read the contents of PHP files for debugging purposes by specifying a path relative to the Web root. Unfortunately for the developers, but fortunately for us, it is also possible to read files with YAML extensions (.yml, .yaml).

First, you need to identify the presence of the Symfony _profiler, which is accessible most of the time via route /app_dev.php/_profiler/ or /_profiler/. Then try to access the route /app_dev.php/_profiler/open (or respectively /_profiler/open).

alt-text

By reading file config/packages/framework.yaml

As shown below, the secret is stored in the APP_SECRET environment variable. On the filesystem, it’s stored in the .env file at the root of the Web root, but the /open feature doesn’t allow us to read this file. On the other hand, it is possible to read the desired value via the phpinfo() function provided by the _profiler ( /app_dev.php/_profiler/phpinfo or /_profiler/phpinfo).

alt-text

alt-text

Depending on how Symfony has been installed, the secret value may be stored unencrypted in a YAML file.

By reading file app/config/parameters.yml

By reading the file app/config/parameters.yml, it is possible to retrieve the value of secret.

alt-text

We can now exploit the _fragment by presenting the trick tied to this blogpost.

Let’s leverage Symfony core

Create your own lab

First of all, install composer.

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"

Next, make it executable.

chmod +x ./composer.phar

Then, install the Symfony version you intend to test.

To test an old version (v3.4.47)

It is observed that wanting to install Symfony version v3.4.47 actually seems to install version v3.4.49.

./composer.phar create-project symfony/website-skeleton my-project ^3.4.47
cd my-project
~/.symfony5/bin/symfony server:start

Finally, the Web server is listening on http://127.0.0.1:8000.

alt-text

To test a recent version (v6.3.12)

~/.symfony5/bin/symfony new my-project --version="6.3.*" --webapp
cd my-project
~/.symfony5/bin/symfony server:start

alt-text

Once our test environment is set up, let’s retrieve the secret value.

alt-text

Download the exploit developed by cfreal and install its requirements.

wget https://raw.githubusercontent.com/ambionics/symfony-exploits/main/secret_fragment_exploit.py
python3 -m venv myenv
source ./myenv/bin/activate
python3 -m pip install requests

Let’s take a look at the result of the exploit once it’s launched.

alt-text

alt-text

Now, let’s add the following directives to the file php.ini.

disable_functions =phpinfo,exec,passthru,shell_exec,system,proc_open,popen

And re-run the exploit (or just refresh the previous page as the secret doesn’t change).

alt-text

And as we can see, we can no longer exploit it trivially, as the sandbox blocks us. Developers can use the PHP sandbox to restrict and slow down exploitation, but a motivated attackers can read Symfony’s core source code in search of functionality enabling them to gain control of the server. Since Symfony needs its core to operate, developers won’t be able to block these features without having side-effects or interrupting the smooth running of their application.

Arbitrary File Read

An attacker can use function Symfony\Component\Filesystem\Filesystem::copy() to copy any file from location A to location B and can, therefore, exfiltrate information.

File: vendor/symfony/filesystem/Filesystem.php
Class: Filesystem
Function: copy()

<?php

...

namespace Symfony\Component\Filesystem;

use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\Filesystem\Exception\InvalidArgumentException;
use Symfony\Component\Filesystem\Exception\IOException;

...

class Filesystem
{
    private static $lastError;

    ...

    public function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false)
    {
        $originIsLocal = stream_is_local($originFile) || 0 === stripos($originFile, 'file://');
        if ($originIsLocal && !is_file($originFile)) {
            throw new FileNotFoundException(sprintf('Failed to copy "%s" because file does not exist.', $originFile), 0, null, $originFile);
        }

        $this->mkdir(\dirname($targetFile));

        $doCopy = true;
        if (!$overwriteNewerFiles && null === parse_url($originFile, \PHP_URL_HOST) && is_file($targetFile)) {
            $doCopy = filemtime($originFile) > filemtime($targetFile);
        }

        if ($doCopy) {
            // https://bugs.php.net/64634
            if (!$source = self::box('fopen', $originFile, 'r')) {
                throw new IOException(sprintf('Failed to copy "%s" to "%s" because source file could not be opened for reading: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile);
            }

            // Stream context created to allow files overwrite when using FTP stream wrapper - disabled by default
            if (!$target = self::box('fopen', $targetFile, 'w', false, stream_context_create(['ftp' => ['overwrite' => true]]))) {
                throw new IOException(sprintf('Failed to copy "%s" to "%s" because target file could not be opened for writing: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile);
            }

            $bytesCopied = stream_copy_to_stream($source, $target);
            fclose($source);
            fclose($target);
            unset($source, $target);

            if (!is_file($targetFile)) {
                throw new IOException(sprintf('Failed to copy "%s" to "%s".', $originFile, $targetFile), 0, null, $originFile);
            }

            if ($originIsLocal) {
                // Like `cp`, preserve executable permission bits
                self::box('chmod', $targetFile, fileperms($targetFile) | (fileperms($originFile) & 0111));

                if ($bytesCopied !== $bytesOrigin = filesize($originFile)) {
                    throw new IOException(sprintf('Failed to copy the whole content of "%s" to "%s" (%g of %g bytes copied).', $originFile, $targetFile, $bytesCopied, $bytesOrigin), 0, null, $originFile);
                }
            }
        }
    }

    ...

All operations are performed relatively to the public directory present at the Web root.

alt-text

alt-text

alt-text

Arbitrary File Write

An attacker can use function Symfony\Component\Filesystem\Filesystem::dumpFile() to write any content into any writable location which can be used to write a Webshell and get Remote Code Execution.

File: vendor/symfony/filesystem/Filesystem.php
Class: Filesystem
Function: dumpFile()


...

    public function dumpFile(string $filename, $content)
    {
        if (\is_array($content)) {
            throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__));
        }

        $dir = \dirname($filename);

        if (is_link($filename) && $linkTarget = $this->readlink($filename)) {
            $this->dumpFile(Path::makeAbsolute($linkTarget, $dir), $content);

            return;
        }

        if (!is_dir($dir)) {
            $this->mkdir($dir);
        }

        // Will create a temp file with 0600 access rights
        // when the filesystem supports chmod.
        $tmpFile = $this->tempnam($dir, basename($filename));

        try {
            if (false === self::box('file_put_contents', $tmpFile, $content)) {
                throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename);
            }

            self::box('chmod', $tmpFile, file_exists($filename) ? fileperms($filename) : 0666 & ~umask());

            $this->rename($tmpFile, $filename, true);
        } finally {
            if (file_exists($tmpFile)) {
                self::box('unlink', $tmpFile);
            }
        }
    }

...

alt-text

alt-text

alt-text

You’re now able to exploit the vulnerability even if the developers have deactivated certain useful functions via to the PHP sandbox (system(), shell_exec(), etc.), thanks to Symfony’s core functions copy() and dumpFile().

Now the question is, what are you going to use as Webshell since the sandbox seems to be in the way? and I would reply that it is possible to exploit memory corruptions in the core of the PHP interpreter, which I have previously explained in the following articles:

The first article was an introduction to the memory exploitation of the PHP engine with the development of a 1day (Double Free) for CVE-2016-3132 discovered by Emmanuel Law (for which no Proof Of Concept had been published). While the second article was the presentation of a 0day (Use After Free) and its exploit at the very core of function SplDoublyLinkedList::pop().

Thank you for taking the time to read this article.