原文鏈接: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 license@php.net.

$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 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/3041/