在通用 Symfony 包中寻找 POP 链(上)
2023-9-25 14:46:0 Author: paper.seebug.org(查看原文) 阅读量:13 收藏

原文链接:FINDING A POP CHAIN ON A COMMON SYMFONY BUNDLE: PART 1
译者:知道创宇404实验室翻译组

Symfony的doctrine/doctrine-bundle包是与 Symfony 应用程序一起安装的最常见的捆绑包之一。截至本文发布,其已被下载了1.44亿次,使其成为一个反序列化利用目标。

本文的第一部分旨在展示POP链研究的完整方法论,详细介绍用于识别有效的易受攻击路径的完整代码分析方法。第二部分将将重点关注基于本节分析的代码,通过基本的试错逻辑来构建完整有效的 POP 链。

间接针对Symfony

正如博文所述,在主要的Symfony框架中很难找到POP链,许多基本功能只能通过额外的依赖项来实现,如用作其 ORM(对象关系映射)的 Doctrine。这个ORM也是许多其他PHP项目中最常用的之一:Drupal、Laravel、PrestaShop等。它用于管理抽象应用程序对数据库的访问。

为了使Doctrine与Symfony兼容,从该项目发布的第一个README的第一段来看,它从Symfony 2.1版本以来就创建了doctrine-bundle,:

由于Symfony 2不想强制或建议用户使用特定的持久化解决方案,所以已将该捆绑包从Symfony 2框架的核心中删除。Doctrine2仍将是Symfony的重要角色,并且该捆绑包Doctrine和Symfony主要由社区的开发人员进行维护。

重要提示:本捆绑包为Symfony 2.1及更高版本开发。对于Symfony 2.0应用程序,DoctrineBundle仍随核心Symfony存储库一起提供。

识别有趣的切入点

因为在挖掘 PHP 依赖项时基于的范围非常巨大,所以在PHP依赖项中查找POP链可能非常耗时,

以下部分描述了找到它们并使它们协同工作所遵循的完整方法和逻辑。

PHP反序列化,通过静态分析找到所需内容

首先,必须找到__wakeup__unserialize或在项目依赖项中实现的__destruct方法。该方法也可以被调用,但反序列化后的对象必须在printecho等函数内部调用,因此这种情况不太可能发生。

在不深入细节的情况下(关于用例和技巧的更多详细信息可在payload all the things上找到),当序列化字符串进行反序列化时,__wakeup方法将首先被调用(或替代调用__unserialize)。然后,如果定义了该对象的__destruct方法,它最终将被销毁。

wakeup、unserialize和__destruct进行排序

为了使本文中的假设和流程更易于理解,以便识别完整的链条,将使用一个图示来展示所遵循的逻辑的每个步骤。因此,在这项研究中,首要目标是对代码库中的__wakeup__unserialize__destruct函数进行排序。

looking_for_a_pop_chain

找到POP链时的第一个假设

使用grep搜索类

doctrine-bundle的依赖项可以通过composer进行安装。之后,使用简单的grep命令就可以找到答案:doctrine/doctrine-bundle的依赖项中包含许多可能的入口点。

$ composer require doctrine/doctrine-bundle
./composer.json has been created
Running composer update doctrine/doctrine-bundle
Loading composer repositories with package information
Updating dependencies
Lock file operations: 35 installs, 0 updates, 0 removals
  - Locking doctrine/cache (2.2.0)
  - Locking doctrine/dbal (3.5.3)
  - Locking doctrine/deprecations (v1.0.0)
  - Locking doctrine/doctrine-bundle (2.8.2
[...]
$ cd vendor
$ grep -Ri 'function __destruct'
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php:    public function __destruct()
doctrine/dbal/src/Logging/Connection.php:    public function __destruct()
symfony/framework-bundle/Tests/Kernel/flex-style/src/FlexStyleMicroKernel.php:    public function __destruct()
symfony/dependency-injection/Loader/Configurator/ServiceConfigurator.php:    public function __destruct()
symfony/dependency-injection/Loader/Configurator/AbstractServiceConfigurator.php:    public function __destruct()
symfony/dependency-injection/Loader/Configurator/ServicesConfigurator.php:    public function __destruct()
symfony/dependency-injection/Loader/Configurator/PrototypeConfigurator.php:    public function __destruct()
symfony/cache/Adapter/TagAwareAdapter.php:    public function __destruct()
symfony/cache/Traits/AbstractAdapterTrait.php:    public function __destruct()
symfony/cache/Traits/FilesystemCommonTrait.php:    public function __destruct()
symfony/error-handler/BufferingLogger.php:    public function __destruct()
symfony/routing/Loader/Configurator/ImportConfigurator.php:    public function __destruct()
symfony/routing/Loader/Configurator/CollectionConfigurator.php:    public function __destruct()
symfony/http-kernel/DataCollector/DumpDataCollector.php:    public function __destruct()

对可能的入口点进行排序

在像Symfony这样的高度强化的项目中,因为它会在调用__destruct函数之前被调用,因此通常通过在调用__wakeup函数时抛出错误来设置防止反序列化保护。如下所示,许多Symfony类都设置了这种深度强化。

$ grep -hri 'function __wakeup' -A4 . 
    public function __wakeup()
    {
        throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
    }

--
    public function __wakeup()
    {
        throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
    }

--
[...]

对实现__destruct但不包含关键字BadMethodCallException的类进行排序:

$ grep -rl '__destruct' | xargs grep -L BadMethodCallException
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php
doctrine/dbal/src/Logging/Connection.php
symfony/dependency-injection/Loader/Configurator/ServiceConfigurator.php
symfony/dependency-injection/Loader/Configurator/AbstractServiceConfigurator.php
symfony/dependency-injection/Loader/Configurator/ServicesConfigurator.php
symfony/dependency-injection/Loader/Configurator/PrototypeConfigurator.php
symfony/var-dumper/Caster/ExceptionCaster.php
symfony/http-kernel/Tests/DataCollector/DumpDataCollectorTest.php

幸运的是,Doctrine\Common\Cache\Psr6\CacheAdapter类看起来非常有希望!在Doctrine版本1.11.x之前(自2019年以来一直维护),它被用作默认的Doctrine缓存适配器,但目前已经被弃用。然而为了向后兼容性,即使代码不应该再使用,doctrine/cache仍然被保留。

<?php

namespace Doctrine\Common\Cache\Psr6;

final class CacheAdapter implements CacheItemPoolInterface
{
    [...]
    public function commit(): bool
    {
        [...]
    }

    public function __destruct() { 
        $this->commit(); 
    }
}

我们可以看到,__destruct函数是可达的,直接调用commit对象的函数,看看这一点会有什么发现。

__call 函数定义的作用

在这项研究中,另一个探索的路径是分析定义了__call函数的类。

PHP文档解释

在上下文中调用不可访问的方法时会触发__call()

$ grep -Ri 'function __call' .
./doctrine/dbal/src/Schema/Comparator.php:    public function __call(string $method, array $args): SchemaDiff
./doctrine/dbal/src/Schema/Comparator.php:    public static function __callStatic(string $method, array $args): SchemaDiff
./symfony/event-dispatcher/Debug/TraceableEventDispatcher.php:    public function __call(string $method, array $arguments): mixed
./symfony/dependency-injection/Loader/Configurator/EnvConfigurator.php:    public function __call(string $name, array $arguments): static
./symfony/dependency-injection/Loader/Configurator/AbstractConfigurator.php:    public function __call(string $method, array $args)
./symfony/cache/Traits/RedisClusterNodeProxy.php:    public function __call(string $method, array $args)

虽然本文没有对其进行描述(因为在这里没有产生结果),但如果你正在寻找POP链,其也会覆盖__call函数。

从控制函数跳转

cacheadapter_access

通过 __destruct 调用达到 PhpAdapter 提交函数

我们到达的commit函数通常用于更新Doctrine缓存中的延迟项。基本上它会删除已过期的项,并将所有其他项保存在缓存定义中。

类属性的phpdoc(@var行)建议$cache应该实现Cache接口,而$deferredItems应该是一个CacheItemTypedCacheItem的数组。这仅用于文档目的,并不强制进行强类型化,这意味着我们可以通过反序列化来控制将要实现的类,从而劫持对它们方法的任何调用。

从这一点出发,可以从$this->cache$this->deferredItems对象调用4个函数。让我们分别查看每个实现这些函数的对象,看看是否可以找到有趣的代码。

<?php

namespace Doctrine\Common\Cache\Psr6;

final class CacheAdapter implements CacheItemPoolInterface
{
    /** @var Cache */
    private $cache;

    /** @var array<CacheItem|TypedCacheItem> */
    private $deferredItems = [];
    [...]
    public function commit(): bool
    {
        if (! $this->deferredItems) {
            return true;
        }

        $now         = microtime(true);
        $itemsCount  = 0;
        $byLifetime  = [];
        $expiredKeys = [];

        foreach ($this->deferredItems as $key => $item) {
            $lifetime = ($item->getExpiry() ?? $now) - $now; // [1]

            if ($lifetime < 0) {
                $expiredKeys[] = $key;

                continue;
            }

            ++$itemsCount;
            $byLifetime[(int) $lifetime][$key] = $item->get(); // [2]
        }

        $this->deferredItems = [];

        switch (count($expiredKeys)) {
            case 0:
                break;
            case 1:
                $this->cache->delete(current($expiredKeys)); // [4]
                break;
            default:
                $this->doDeleteMultiple($expiredKeys);
                break;
        }

        if ($itemsCount === 1) {
            return $this->cache->save($key, $item->get(), (int) $lifetime); // [3]
        }

        $success = true;
        foreach ($byLifetime as $lifetime => $values) {
            $success = $this->doSaveMultiple($values, $lifetime) && $success;
        }

        return $success;
    }

    public function __destruct() { 
        $this->commit(); 
    }
}
  • [1]任意对象的getExpiry()函数

这个函数并不是一个好的匹配,它并没有触及到有趣的代码,并且只在两个类中定义:

$ grep -ri 'function getexpiry' -A 3
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/TypedCacheItem.php:    public function getExpiry(): ?float
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/TypedCacheItem.php-    {
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/TypedCacheItem.php-        return $this->expiry;
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/TypedCacheItem.php-    }
--
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheItem.php:    public function getExpiry(): ?float
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheItem.php-    {
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheItem.php-        return $this->expiry;
doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheItem.php-    }
  • [2]任意对象的get()函数

这个函数定义看起来很有希望。它至少在doctrine/doctrine-bundle依赖项中的53个文件中定义了。

$ grep -ri 'function get(' | wc -l
53

然而$item->getExpiry()在之前的代码中,我们看到只有2个对象实现了一个getExpiry函数。它们都只返回一个值,这使得get调用无法访问,如以下代码片段所示。

<?php

final class CacheAdapter implements CacheItemPoolInterface
{
    [...]
    public function commit(): bool
    {
    [...]
        foreach ($this->deferredItems as $key => $item) {
            $lifetime = ($item->getExpiry() ?? $now) - $now; // [1]

            if ($lifetime < 0) {
                $expiredKeys[] = $key;

                continue;
            }

            ++$itemsCount;
            $byLifetime[(int) $lifetime][$key] = $item->get(); // [2]
        }

    }

}
  • [3]任意对象的save($param1, $param2, int $param3)函数

save是一个常见的函数名,毫不奇怪,许多类或特性都定义了它。

$ grep -ri 'function save('  .
./psr/cache/src/CacheItemPoolInterface.php:    public function save(CacheItemInterface $item): bool;
./doctrine/cache/lib/Doctrine/Common/Cache/Psr6/CacheAdapter.php:    public function save(CacheItemInterface $item): bool
./doctrine/cache/lib/Doctrine/Common/Cache/Cache.php:    public function save($id, $data, $lifeTime = 0);
./doctrine/cache/lib/Doctrine/Common/Cache/CacheProvider.php:    public function save($id, $data, $lifeTime = 0)
./symfony/http-foundation/Session/Storage/MockArraySessionStorage.php:    public function save()
./symfony/http-foundation/Session/Storage/SessionStorageInterface.php:    public function save();
./symfony/http-foundation/Session/Storage/NativeSessionStorage.php:    public function save()
./symfony/http-foundation/Session/Storage/MockFileSessionStorage.php:    public function save()
./symfony/http-foundation/Session/Session.php:    public function save()
./symfony/http-foundation/Session/SessionInterface.php:    public function save();
./symfony/cache/Adapter/ProxyAdapter.php:    public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/PhpArrayAdapter.php:    public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/TraceableAdapter.php:    public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/ChainAdapter.php:    public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/ArrayAdapter.php:    public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/NullAdapter.php:    public function save(CacheItemInterface $item): bool
./symfony/cache/Adapter/TagAwareAdapter.php:    public function save(CacheItemInterface $item): bool
./symfony/cache/Traits/RedisCluster6Proxy.php:    public function save($key_or_address): \RedisCluster|bool
./symfony/cache/Traits/AbstractAdapterTrait.php:    public function save(CacheItemInterface $item): bool
./symfony/cache/Traits/RedisCluster5Proxy.php:    public function save($key_or_address)
./symfony/cache/Traits/Redis6Proxy.php:    public function save(): \Redis|bool
./symfony/cache/Traits/Redis5Proxy.php:    public function save()
./symfony/http-kernel/HttpCache/Store.php:    private function save(string $key, string $data, bool $overwrite = true): bool

其中许多类对于我们的目的来说是无用的,但是Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage可以用来写入文件。这是该POP链的主要目标之一。为了触发它的代码,有必要定义一个$item低于expiration当前时间的代码。$key将作为它的第一个参数。

<?php

final class CacheAdapter implements CacheItemPoolInterface
{
    [...]
    public function commit(): bool
    {
    [...]
        foreach ($this->deferredItems as $key => $item) {
            $lifetime = ($item->getExpiry() ?? $now) - $now; // [1]

            if ($lifetime < 0) {
                $expiredKeys[] = $key;

                continue;
            }

            ++$itemsCount;
            $byLifetime[(int) $lifetime][$key] = $item->get(); // [2]
        }
    [...]
        if ($itemsCount === 1) {
            return $this->cache->save($key, $item->get(), (int) $lifetime); // [3]
        }

    }

}
  • [4]delete($param1)任意对象的函数

save函数不同,delete函数在PHP项目中较少见。然而,它只会让在所有文件中查找它们变得更容易。

$ grep -ri 'function delete('  .
./doctrine/cache/lib/Doctrine/Common/Cache/Cache.php:    public function delete($id);
./doctrine/cache/lib/Doctrine/Common/Cache/CacheProvider.php:    public function delete($id)
./doctrine/dbal/src/Query/QueryBuilder.php:    public function delete($delete = null, $alias = null)
./doctrine/dbal/src/Connection.php:    public function delete($table, array $criteria, array $types = [])
./symfony/cache-contracts/CacheTrait.php:    public function delete(string $key): bool
./symfony/cache-contracts/CacheInterface.php:    public function delete(string $key): bool;
./symfony/cache/Psr16Cache.php:    public function delete($key): bool
./symfony/cache/Adapter/TraceableAdapter.php:    public function delete(string $key): bool
./symfony/cache/Adapter/ArrayAdapter.php:    public function delete(string $key): bool
./symfony/cache/Adapter/NullAdapter.php:    public function delete(string $key): bool
./symfony/cache/Traits/Redis6Proxy.php:    public function delete($key, ...$other_keys): \Redis|false|int
./symfony/cache/Traits/Redis5Proxy.php:    public function delete($key, ...$other_keys)

从这些类中,Symfony\Component\Cache\Adapter\PhpArrayAdapter可以用于任意的include文件。这是该POP链的最终目标。

为了触发它,有必要定义一个$itemexpiration当前时间更高的时间。$item将作为它的第一个参数。

<?php

final class CacheAdapter implements CacheItemPoolInterface
{
    [...]
    public function commit(): bool
    {
    [...]
        foreach ($this->deferredItems as $key => $item) {
            $lifetime = ($item->getExpiry() ?? $now) - $now; // [1]

            if ($lifetime < 0) {
                $expiredKeys[] = $key;

                continue;
            }

            ++$itemsCount;
            $byLifetime[(int) $lifetime][$key] = $item->get(); // [2]
        }

        switch (count($expiredKeys)) {
            case 0:
                break;
            case 1:
                $this->cache->delete(current($expiredKeys)); // [4]
                break;
            default:
                $this->doDeleteMultiple($expiredKeys);
                break;
        }
    [...]
    }
}

该链用于include任意路径中的文件。

第一步,MockFileSessionStorage获取文件写入

现在我们更清楚要搜索的位置,让我们深入研究!

在查找PHP代码中的漏洞代码时,第一步是查看用户提供的数据是否传递给危险函数,例如systemevalincluderequireexecpopencall_user_funcfile_put_contents等。还有许多其他函数,但这里的主要思想是,由于潜在的漏洞范围已经缩小到了save()函数,因此现在有必要从分析的依赖项中审核每个可访问的保存函数,以识别 POP 链。

正如我们所看到的,唯一可达且有趣的函数似乎是Symfony\Component\HttpFoundation\Session\Storage\MockFileSessionStorage类中save函数中的file_put_contents

$ grep -hri 'function save(' -A50 . | grep system
$ grep -hri 'function save(' -A50 . | grep eval
$ grep -hri 'function save(' -A50 . | grep include
$ grep -hri 'function save(' -A50 . | grep require
     * When versioning is enabled, clearing the cache is atomic and does not require listing existing keys to proceed,
     * but old keys may need garbage collection and extra round-trips to the back-end are required.
$ grep -hri 'function save(' -A50 . | grep exec
$ grep -hri 'function save(' -A50 . | grep popen
$ grep -hri 'function save(' -A50 . | grep call_user_func
[...]
$ grep -hri 'function save(' -A50 . | grep file_put_content
                file_put_contents($tmp, serialize($data));
$ grep -ri 'file_put_contents($tmp, serialize($data))' .
./symfony/http-foundation/Session/Storage/MockFileSessionStorage.php:                file_put_contents($tmp, serialize($data));
$ grep -i 'file_put_contents($tmp, serialize($data))' -B 21 -A 12 ./symfony/http-foundation/Session/Storage/MockFileSessionStorage.php
    public function save()
    {
        if (!$this->started) {
            throw new \RuntimeException('Trying to save a session that was not started yet or was already closed.');
        }

        $data = $this->data;

        foreach ($this->bags as $bag) {
            if (empty($data[$key = $bag->getStorageKey()])) {
                unset($data[$key]);
            }
        }
        if ([$key = $this->metadataBag->getStorageKey()] === array_keys($data)) {
            unset($data[$key]);
        }

        try {
            if ($data) {
                $path = $this->getFilePath();
                $tmp = $path.bin2hex(random_bytes(6));
                file_put_contents($tmp, serialize($data));
                rename($tmp, $path);
            } else {
                $this->destroy();
            }
        } finally {
            $this->data = $data;
        }

        // this is needed when the session object is re-used across multiple requests
        // in functional tests.
        $this->started = false;
    }

虽然一开始看起来很有希望,但是生成的文件的扩展名无法定义,这使得它变得不太有趣。

<?php

namespace Symfony\Component\HttpFoundation\Session\Storage;

class MockFileSessionStorage extends MockArraySessionStorage
{
    private string $savePath;

    public function save()
    {
[...]

        try {
            if ($data) {
                $path = $this->getFilePath();
                $tmp = $path.bin2hex(random_bytes(6));
                file_put_contents($tmp, serialize($data));
                rename($tmp, $path);
            } else {
                $this->destroy();
            }
        } finally {
            $this->data = $data;
        }
        $this->started = false;
    }
    private function getFilePath(): string
    {
        return $this->savePath.'/'.$this->id.'.mocksess';
    }

}

然而,正如我们所看到的,我们可以控制注入文件中的序列化数据,如果执行的话,它可以作为PHP代码执行。

$ php -r "echo serialize('<?php phpinfo(); ?>');" > /tmp/test_serialize
$ php /tmp/test_serialize 
s:19:"phpinfo()
PHP Version => 8.1.22
[...]
questions about PHP licensing, please contact [email protected].

$path = $this->getFilePath()代码用于定义在file_put_contents方法中写入的文件的路径。

<?php

namespace Symfony\Component\HttpFoundation\Session\Storage;

class MockFileSessionStorage extends MockArraySessionStorage
{
[...]
    private function getFilePath(): string
    {
        return $this->savePath.'/'.$this->id.'.mocksess';
    }

}

.mocksess作为文件的后缀,防止我们通过在将源代码暴露给用户的文件夹中创建一个.php文件来执行代码。然而,进行任意文件写入是继续第二步之前所需的唯一先决条件。以下模式包装了 POP 链的第一个元素。

file_write_path

用于写入任何以扩展名 .mocksess 结尾内容的文件的 POP 链路径

第二步,找到文件包含的路径

可以应用相同的方法来查找delete函数调用。

$ grep -hri 'function delete(' -A50 . | grep file_put_content | grep system
$ grep -hri 'function delete(' -A50 . | grep eval
[...]
$ grep -hri 'function delete(' -A50 . | grep include
$ grep -hri 'function delete(' -A50 . | grep require
$ grep -hri 'function delete(' -A50 . | grep exec
[...]
$ grep -hri 'function delete(' -A50 . | grep popen
$ grep -hri 'function delete(' -A50 . | grep call_user_func
[...]

在搜索了许多常见的危险函数之后,很明显这些功能没有快速获胜的方法。这意味着我们需要逐个深入研究它们,首先寻找在此POP链的起始处使用的弱类型技巧,使我们能够从其他对象调用delete函数。

$ grep -hri 'function delete(' -A3 .
    public function delete($id);
--
    public function delete(string $key): bool
    {
        return $this->deleteItem($key);
    }
--    
    public function delete(string $key): bool
    {
        return $this->deleteItem($key);
    }
--
    public function delete($id)
    {
        return $this->doDelete($this->getNamespacedId($id));
    }
--
    public function delete($table, array $criteria, array $types = [])
    {
        if (count($criteria) === 0) {
            throw InvalidArgumentException::fromEmptyCriteria();
--
    public function delete($key): bool
    {
        try {
            return $this->pool->deleteItem($key);
[...]

$ grep -Ri 'return $this->pool->deleteItem($key);' .
./symfony/cache/Psr16Cache.php:            return $this->pool->deleteItem($key);

正如我们所看到的,deleteItem函数似乎很有希望,因为它被许多delete函数调用,让我们看看可以从中获得什么。

通过对PhpArrayAdapter函数deleteItem进行调用,可以达到其initialize包含任意文件的方法。由于我们已经有了文件写入,因此可以将其包含在内以便执行代码。

$ grep -hri 'function deleteItem(' -A6 .
    public function deleteItem(mixed $key): bool
    {
        if (!\is_string($key)) {
            throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', get_debug_type($key)));
        }
        if (!isset($this->values)) {
            $this->initialize();
[...]

$ grep -Ri '            $this->initialize();' . 
./symfony/cache/Adapter/PhpArrayAdapter.php:            $this->initialize();
[...]
$ grep 'function initialize' -A10 ./symfony/cache/Adapter/PhpArrayAdapter.php
    private function initialize()
    {
        if (isset(self::$valuesCache[$this->file])) {
            $values = self::$valuesCache[$this->file];
        } elseif (!is_file($this->file)) {
            $this->keys = $this->values = [];

            return;
        } else {
            $values = self::$valuesCache[$this->file] = (include $this->file) ?: [[], []];
        }

很不幸,仅仅通过这条路径是无法使用PHP过滤器链来执行命令的。这是因为elseif (!is_file($this->file)条件会验证文件是否存在于文件系统中,从而阻止对php://包装器的任何调用。

综上所述,现在的目标是找到一种方法PhpArrayAdapter来达到将deleteItem拼图拼凑在一起的功能。

PhpArrayAdapter_identifed_as_target

文件包含通过PhpArrayAdapter的initialize函数

通过 PHP 强类型获取 rekt

现在我们的计划已经明确了,让我们看看如何从我们之前发现的delete函数中达到PhpArrayAdapter

$ grep -Ri 'return $this->pool->deleteItem($key);' .
./symfony/cache/Psr16Cache.php:            return $this->pool->deleteItem($key);

乍一看,Psr16Cache类似乎是完美的选择,因为我们可以通过将其pool属性定义为PhpArrayAdapter对象来实现任何其他deleteItem函数。然而,正如我们所说,虽然PHP是一种弱类型语言,但也可以通过强制进行强类型化来增强它。不幸的是,这在Psr16Cache类中就是这样的情况。

cat ./symfony/cache/Psr16Cache.php
<?php

[...]
class Psr16Cache implements CacheInterface, PruneableInterface, ResettableInterface
{
    use ProxyTrait;

    private ?\Closure $createCacheItem = null;
    private ?CacheItem $cacheItemPrototype = null;
    private static \Closure $packCacheItem;

    public function __construct(CacheItemPoolInterface $pool)
    {
        $this->pool = $pool;
}

检查参数是否$poolCacheItemPoolInterface接口会阻止我们使用PhpArrayAdapter类。

PHP特性分析

现在最直接的路径已经被排除,让我们看看还有哪些选项可供选择。

$ grep -Ri 'function delete(' .
./doctrine/cache/lib/Doctrine/Common/Cache/Cache.php:    public function delete($id);
./doctrine/cache/lib/Doctrine/Common/Cache/CacheProvider.php:    public function delete($id)
./doctrine/dbal/src/Query/QueryBuilder.php:    public function delete($delete = null, $alias = null)
./doctrine/dbal/src/Connection.php:    public function delete($table, array $criteria, array $types = [])
./symfony/cache-contracts/CacheTrait.php:    public function delete(string $key): bool
[...]

在定义delete函数的对象中,CacheTrait特性似乎很有希望。PHP文档将特性定义为代码重用的一种方式,基本上是一种在另一个类中编写函数属性并定义它们的方式。只需要通过use关键字将其添加到类中即可。

$ cat ./symfony/cache-contracts/CacheTrait.php | grep 'function delete(' -A 3
    public function delete(string $key): bool
    {
        return $this->deleteItem($key);
    }

CacheTrait调用了使用它的对象的deleteItem函数。如果我们的目标——PhpArrayAdapter类恰好使用了CacheTrait,那么我们就能调用它的deleteItem函数,从而触发所需的require函数以实现代码执行。

$ grep -Ri 'use CacheTrait' .
./symfony/cache/Traits/ContractsTrait.php:    use CacheTrait {
$ grep -Ri 'use ContractsTrait' .
./symfony/cache/Adapter/ProxyAdapter.php:    use ContractsTrait;
./symfony/cache/Adapter/PhpArrayAdapter.php:    use ContractsTrait;
[...]

即使PhpArrayAdapter类没有直接使用CacheTrait,它使用了ContractsTrait,而ContractsTrait使用了CacheTrait,因为特性可以嵌套使用。

最终,到达PhpArrayAdapter取得胜利

经过深入调查,我们最终发现PhpArrayAdapter已经具备触发其存在漏洞的initialize函数所需的一切。CacheTrait定义了deleteItem函数,允许调用initialize函数,最终到达include函数以执行我们在开头放置在文件中的PHP代码。

$ cat ./symfony/cache-contracts/CacheTrait.php | grep 'function delete(' -A 3
    public function delete(string $key): bool
    {
        return $this->deleteItem($key);
    }
$ cat ./symfony/cache/Adapter/PhpArrayAdapter.php | grep 'function deleteItem(' -A6
    public function deleteItem(mixed $key): bool
    {
        if (!\is_string($key)) {
            throw new InvalidArgumentException(sprintf('Cache key must be string, "%s" given.', get_debug_type($key)));
        }
        if (!isset($this->values)) {
            $this->initialize();
$ cat ./symfony/cache/Adapter/PhpArrayAdapter.php | grep 'function initialize(' -A9
    private function initialize()
    {
        if (isset(self::$valuesCache[$this->file])) {
            $values = self::$valuesCache[$this->file];
        } elseif (!is_file($this->file)) {
            $this->keys = $this->values = [];

            return;
        } else {
            $values = self::$valuesCache[$this->file] = (include $this->file) ?: [[], []];

phparrayadapter_full_chain

从PhpArrayAdapter达到的文件包含代码

回顾所有易受攻击的代码

这个POP链从调用其函数的对象__destruct的函数开始。在深入研究其代码后,我们确定可以通过对save函数的弱类型技巧来实现MockFileSessionStorage,这使我们能够进行文件写入。最后,通过对delete函数的弱类型技巧,可以实现PhpArrayAdapter,经过几个步骤后实现任意文件包含。

下图总结了POP链涉及的每一段代码。

all_popchain_impacted

doctrine/doctrine-bundle包中的POP链代码完整总结

结论

在巨大的依赖关系中寻找 POP 链是很耗时的,但是使用如此多的源代码是深入理解 PHP 机制的好方法。

在研究的第一部分中,我们看到弱类型可以用作实现意想不到的功能的工具。

正如我们所看到的,不总是需要使用高级工具来找到有趣的代码路径。理解一个攻击路径并知道我们正在寻找什么通常足以完成工作!话虽如此,本文中使用的方法非常耗时,并且结合使用调试器(例如Xdebug)可以大大优化效率。

在下一部分中,我们将基于已经分析过的源代码构建完整的POP链,并展示一个包含doctrine/doctrine-bundle包的易受攻击的Symfony应用程序的完整利用。由于这个链实际上是基于两个不同的PHP对象和一个随着PHP版本变化的代码库,所以涉及了一些有趣的技巧,敬请关注!


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/3041/


文章来源: https://paper.seebug.org/3041/
如有侵权请联系:admin#unsafe.sh