B17: Symfony <= v6.4, let's unlock some more mystery on the fragment exploit
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).
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).
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
.
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.
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
Once our test environment is set up, let’s retrieve the secret value.
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.
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).
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.
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);
}
}
}
...
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:
- B12: Introduction to exploitation of the PHP interpreter by writing a 1day for CVE-2016-3132
- C101010: PHP SplDoublyLinkedList::pop() Use After Free
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.