AppSec Project - Chapter 1, Manually fixing vulnerabilities
Table of contents
Introduction
Hello, welcome to the b1d0ws appsec project!
The idea here is to introduce you, with a few articles, to a process of building, fixing vulnerabilities and integrating a python website with application security. I'm a beginner in this process of application security and development, so I'll learn as I write on this blog.
The repository is located here. To clone and start the application, you can follow the README.md
. The initial commit containing the vulnerabilities can be found on the main branch, identified by commit hash f5830880bdacce3bcfe7ebd41720e9a698c86d32
In the process, we will encounter the following vulnerabilities:
Lack of Authentication
Arbitrary File Upload and Path Traversal
XSS
CSRF
Weak Password Reset Token Generation
IDOR
Privilege Escalation
SQL Injection
SSTI
SSRF
First, we started to develop a basic website. For this, I copied the code from Tech with Tim and added the vulnerable functions. Our project will use Flask and Sqlite3 on the back-end and pure javascript on the front-end. This is the video that guide me.
Our homepage looks like this (the links will be updated later).
Looking at the structure of the source code, we can see some python files, the instance folder contains a database and the website folder contains templates used by flask.
In short, for now, views.py
and auth.py
contain the route definition, models.py
contains the database model and init.py
has general settings about the application.
In this article, our focus will be on remedying the vulnerabilities presented in the source code. There are some improvements to be made to the code that are not problems in themselves, but these will be presented in future articles.
Let's create a user for our tests:
We now have access to the profile and the annotation route.
Checking auth.py
and views.py
, we see that some routes are protected with @login_required
.
@auth.route('/change_password', methods=['POST'])
@login_required
def change_password():
A good idea to start with is to list which routes need to be authenticated and which do not.
We can use the command below to get the routes, but it doesn't show whether the route is protected or not.
from main import app
app.url_map
When doing a manual search, the routes that don't need protection are:
/
/login
/sign-up
/forgot-password
/reset-password/<token>
/upload_image
All of them seem acceptable for not needing authentication, as they are related to functions before login, except for the last one.
Image Upload Vulnerabilities
Taking a closer look at the /upload_image route, it seems that it uploads profile pictures. That should be authenticated, right?
# Upload Image Route
@views.route('/upload_image', methods=['POST'])
def upload_image():
if 'profile_image' in request.files:
file = request.files['profile_image']
filename = file.filename
upload_folder = current_app.config['UPLOAD_FOLDER']
# Save the file
file.save(os.path.join(upload_folder, filename))
current_user.image = filename
db.session.commit()
flash('Profile image updated successfully', 'success')
return redirect(url_for('views.profile', user_id=current_user.id))
We could build the request manually, but since we have a login, we can get the request from a normal authenticated operation and remove the session cookies.
Remove the cookies:
When sending the request, we received an error 500.
But when we check the userimages folder, we see that our image has been uploaded.
As there is no error handling, the image was loaded normally and the error occurred afterwards, when trying to relate this file to a user. As there is no user, the server returns status code 500.
In addition, we could find out where the images are uploaded by checking the current_app.config['UPLOAD_FOLDER']
variable. This is defined in init.py
:
app.config['UPLOAD_FOLDER'] = 'website/static/userimages/'
Since we're on this route, here are two other vulnerabilities. There is no check being made on this filename, so an attacker can put whatever extension they want. As the application doesn't execute the file, we can't get an RCE and the impact would be limited. But if we mix this with the other vulnerability, the impact will be much greater.
As there is no restriction on the name of the file, we can also try path traversal and save our file anywhere. We can rewrite the HTMLs or modify any file in the system, for example, we can change all the routes so that they are not authenticated by rewriting auth.py
and views.py
.
Below, we've rewritten the home page with the content we want.
This route looks really problematic! Let’s fix these problems.
The authentication issue is solved simply by appending the @login_required
decorator.
# Upload Image Route
@views.route('/upload_image', methods=['POST'])
@login_required
def upload_image():
Now, we receive a 302 and got redirect to the login page instead of uploading the file.
To fix the other problems, we can use werkzeug's secure_filename to normalize the path, preventing path traversal and creating a list of allowed extensions and checking if the filename ends with one of them.
# Upload Image Route
@views.route('/upload_image', methods=['POST'])
@login_required
def upload_image():
ALLOWED_EXTENSIONS = ['png', 'jpg', 'jpeg']
if 'profile_image' in request.files:
file = request.files['profile_image']
# Using secure_filename to prevent path traversal
filename = secure_filename(file.filename)
# Checking extension
extension = filename.rsplit('.', 1)[1].lower()
upload_folder = current_app.config['UPLOAD_FOLDER']
if extension in ALLOWED_EXTENSIONS:
# Save the file
file.save(os.path.join(upload_folder, filename))
current_user.image = filename
db.session.commit()
flash('Profile image updated successfully', 'success')
else:
flash('Something went wrong!', 'error')
return redirect(url_for('views.profile', user_id=current_user.id))
Now we get this response when we try to upload with a different extension.
And when trying to execute the path traversal, the filename is normalized and the image is treated only with the last name.
And the image loads normally.
XSS
On the profile page, we observe that users can update their description.
By searching for “description” in the code, we find the profile route.
@views.route('/profile/<int:user_id>', methods=['GET', 'POST'])
@login_required
def profile(user_id):
user = User.query.get_or_404(user_id)
csrf_token = os.urandom(16).hex()
session['csrf_token'] = csrf_token
if request.method == 'POST':
description = request.form.get('description')
if description:
current_user.description = description
db.session.commit()
flash('Description updated successfully!', 'success')
else:
flash('Description cannot be empty!', 'error')
return render_template("profile.html", csrf_token=csrf_token, user=user)
The first line suggests that it's possible to access other users' profiles by changing the ID in the URL. This isn’t a major concern since no sensitive information is exposed on this page, and we can't modify other users' descriptions either, as confirmed by the code.
However, when reviewing profile.html
, the template for this page, we notice that user.description is using the |safe
filter. Despite its name, |safe
is misleading, it doesn’t make the input safer. In fact, it tells Flask to trust and interpret the input, which is a vulnerability since the description can be modified by the user.
<p class="card-text">{{ user.description|safe if user.description else 'No description provided.' }}</p>
To test this, let's insert a simple alert payload, and as expected, it works.
Our payload is successfully executed! This opens up possibilities for further exploitation, such as CSRF, though that won’t be the focus of this article.
Note: An SSTI payload won’t work here because no template rendering is happening on the backend.
To fix this issue, we should remove the |safe
filter. There’s no reason for it to be used here, as the description is meant to be plain text. If for some reason dynamic content is required, input sanitization should be implemented to strip out non-alphabetic characters.
<p class="card-text">{{ user.description if user.description else 'No description provided.' }}</p>
With this fix, the description field no longer executes scripts.
CSRF
Also in the profile section, there is a feature to change the user's password.
At first glance, the code below doesn't present any explicit vulnerabilities. However, the issue lies in what’s missing rather than what’s visible. Typically, in a password change operation, the user should be required to confirm their current password to verify their identity. While this isn't a vulnerability by itself, it's a best practice that will be addressed in the improvements section.
@auth.route('/change_password', methods=['POST'])
@login_required
def change_password():
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
# Check if new password matches confirmation
if new_password != confirm_password:
flash('Passwords do not match', 'error')
return redirect(url_for('views.profile', user_id=current_user.id))
# Update the password
current_user.password = new_password
db.session.commit()
flash('Password updated successfully', 'success')
return redirect(url_for('views.profile', user_id=current_user.id))
Since this identity check isn't being performed, the appropriate safeguard here would be a CSRF token to protect the route from CSRF attacks. Without it, an attacker could craft a malicious website that sends this request on behalf of the user, potentially compromising their account.
<h2 class="text-center">Resetting Password...</h2>
<form id="resetForm" method="POST" action="http://127.0.0.1:5000/change_password">
<input type="hidden" name="new_password" value="NewPassword123">
<input type="hidden" name="confirm_password" value="NewPassword123">
</form>
<script>
// Automatically submit the form when the page loads
window.onload = function() {
document.getElementById('resetForm').submit();
};
</script>
Essentially, when the victim visits the csrf.html
page, it automatically sends a POST request that changes the password to “NewPassword123”. Since there is no CSRF token in place to protect the request, the password is changed without the user's consent.
To fix this issue, we need to generate a CSRF token and store it in the session when the user accesses the profile route.
@views.route('/profile/<int:user_id>', methods=['GET', 'POST'])
@login_required
def profile(user_id):
user = User.query.get_or_404(user_id)
csrf_token = os.urandom(16).hex()
session['csrf_token'] = csrf_token
Next, include the CSRF token in the profile.html template.
<form method="POST" action="{{ url_for('auth.change_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
Lastly, ensure that the CSRF token is validated in the change password route.
def change_password():
csrf_token = request.form.get('csrf_token')
if not csrf_token or csrf_token != session.pop('csrf_token', None):
flash('Invalid CSRF token', 'error')
return redirect(url_for('views.profile', user_id=current_user.id))
As a result, when we access the malicious page, the action is no longer executed.
Conclusion
In this article, we initiated our project and addressed some issues, including the lack of authentication, arbitrary file upload and path traversal, as well as XSS and CSRF attacks. In the upcoming article, we will delve into other vulnerabilities and demonstrate how to fix them manually.
To address these issues, we created a separate branch named fixing. For this chapter, the commit reference is 71b5fd400721ad28613d1a4b2f7a11c75d83b665
.
See ya =)
Subscribe to my newsletter
Read articles from b1d0ws directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by