Business Logic Vulnerabilities in Flask Applications
Business Logic Vulnerabilities occur when an application’s core functionality can be abused due to flaws in how developers implemented or enforced the intended workflows, rather than due to missing technical controls like authentication or input validation.
In Flask applications, Business Logic Vulnerabilities often emerge because Flask gives developers significant flexibility to implement request handling, object access, and user flows.
If the application fails to enforce proper ownership, authorization checks, or context validations, attackers may exploit these gaps by manipulating parameters or accessing resources they shouldn’t be able to.
Unlike classic technical vulnerabilities, business logic vulnerabilities usually require a deeper understanding of how the application is supposed to behave and how it handles state, user roles, and data access internally.
To explore an example of a Business Logic Vulnerability, we will use the following Flask application:
from flask import Flask, request, session, redirect, url_for, render_template, abort
import hashlib
app = Flask(__name__)
app.secret_key = 'super-secret-key'
# Generate SHA256 hash for password 'pyfu'
def hash_password(password):
return hashlib.sha256(password.encode()).hexdigest()
hashed_pw = hash_password('pyfu')
# Simulated user database with high user IDs
USERS = {
15202: {'username': 'pyfu', 'password': hashed_pw, 'name': 'PyFu Admin', 'hired': True, 'role': 'admin'},
15203: {'username': 'bob', 'password': hashed_pw, 'name': 'Bob', 'hired': False, 'role': 'user'},
15204: {'username': 'alice', 'password': hashed_pw, 'name': 'Alice', 'hired': True, 'role': 'user'},
15205: {'username': 'charlie', 'password': hashed_pw, 'name': 'Charlie', 'hired': False, 'role': 'user'},
15206: {'username': 'dave', 'password': hashed_pw, 'name': 'Dave', 'hired': False, 'role': 'user'},
15207: {'username': 'eve', 'password': hashed_pw, 'name': 'Eve', 'hired': True, 'role': 'user'},
15208: {'username': 'frank', 'password': hashed_pw, 'name': 'Frank', 'hired': False, 'role': 'user'},
15209: {'username': 'grace', 'password': hashed_pw, 'name': 'Grace', 'hired': True, 'role': 'user'},
15210: {'username': 'heidi', 'password': hashed_pw, 'name': 'Heidi', 'hired': False, 'role': 'user'},
15211: {'username': 'ivan', 'password': hashed_pw, 'name': 'Ivan', 'hired': True, 'role': 'user'},
15212: {'username': 'judy', 'password': hashed_pw, 'name': 'Judy', 'hired': False, 'role': 'user'},
15213: {'username': 'mallory', 'password': hashed_pw, 'name': 'Mallory', 'hired': False, 'role': 'user'},
15214: {'username': 'oscar', 'password': hashed_pw, 'name': 'Oscar', 'hired': True, 'role': 'user'},
15215: {'username': 'peggy', 'password': hashed_pw, 'name': 'Peggy', 'hired': True, 'role': 'user'},
15216: {'username': 'sybil', 'password': hashed_pw, 'name': 'Sybil', 'hired': False, 'role': 'user'},
15217: {'username': 'trent', 'password': hashed_pw, 'name': 'Trent', 'hired': True, 'role': 'user'},
15218: {'username': 'victor', 'password': hashed_pw, 'name': 'Victor', 'hired': False, 'role': 'user'}
}
# Reverse lookup for login
def find_user_by_username(username):
for user_id, user in USERS.items():
if user['username'] == username:
return user_id, user
return None, None
@app.route('/', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user_id, user = find_user_by_username(username)
if user and user['password'] == hash_password(password):
session['user_id'] = user_id
session['role'] = user['role']
if user['role'] == 'admin':
return redirect(url_for('dashboard'))
else:
return redirect(url_for('profile', user_id=user_id))
return render_template("login.html", error="Invalid credentials")
return render_template("login.html", error=None)
@app.route('/dashboard')
def dashboard():
if 'user_id' not in session or session.get('role') != 'admin':
return abort(403)
return render_template("dashboard.html", users=USERS)
@app.route('/profile/<int:user_id>')
def profile(user_id):
if 'user_id' not in session:
return redirect(url_for('login'))
user = USERS.get(user_id)
if not user:
abort(404)
return render_template("profile.html", user=user)
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
if __name__ == '__main__':
app.run(debug=True)
This Flask application simulates a small web system that supports two types of users:
- administrators
- regular users.
The purpose of the system is to demonstrate how business logic vulnerabilities may occur when authorization controls are either missing or improperly implemented.
Each user in the system has a unique numeric ID starting from 15202, a username, a SHA256-hashed password (where all users share the same password pyfu), a display name, a hiring status, and a role that determines whether the user is an administrator or a regular user.
What we’re specifically interested in here is the profile route /profile/<user_id>, which handles serving the user profile page.
This route allows any authenticated user to access any profile simply by providing a user_id directly in the URL. Once the request is received, the application retrieves the user record that matches the supplied user_id and renders the profile page.
However, at this point, no further authorization check is performed to ensure that the requesting user is actually permitted to view the profile they are accessing.
This is the vulnerable code:
@app.route('/profile/<int:user_id>')
def profile(user_id):
if 'user_id' not in session:
return redirect(url_for('login'))
user = USERS.get(user_id)
if not user:
abort(404)
return render_template("profile.html", user=user)
As we can see, as long as the user is authenticated and there is a user_id in the session, the application simply accepts any user_id provided in the URL, retrieves the user data, and renders it, without verifying whether the authenticated user actually owns the requested profile.
This type of business logic vulnerability is commonly found across many applications and can lead to the exposure of sensitive or critical data.
It’s important to note that these types of business logic vulnerabilities often target core CRUD operations.
Since many applications use predictable identifiers (such as user IDs or object IDs) when performing database operations, attackers can manipulate these parameters to access or modify records they are not authorized to interact with.
Without proper authorization checks on these CRUD endpoints, sensitive data exposure or unauthorized data manipulation becomes possible.
Why business logic flaws matter from an offensive security perspective
I go after these ownership gaps in Flask because the route looks protected and behaves protected, right up until I change the id. The /profile/<int:user_id> handler checks that user_id is in the session, so it passes a casual auth review, yet it never checks that the id in the URL is the id I logged in as. That one missing comparison turns a session into a key for every record the endpoint serves. Against the sequential 15202-based ids here, I read the whole user table by incrementing a path segment, including the admin row, while the role-gated /dashboard keeps returning 403 and gives a false sense that access control is working.
When I assess a Flask app, these are the tells:
- A handler that gates on
'user_id' in session(orif 'username' in session) but renders a record looked up from the URL parameter without comparing the two. The presence check is authentication; the missing comparison is the vuln. <int:id>converters on object routes that make enumeration a clean loop, paired with login flows that hand out sequential ids.- Ownership enforced only by redirect-after-login (logging a user in lands them on their own profile) while the route still accepts any id directly, so the “intended” flow is not the only reachable one.
- CRUD routes (
/edit/<id>,/delete/<id>,/order/<id>) guarded by login alone, which extend the same read gap into unauthorized writes.
The defender takeaway: a logged-in session is not authorization, so confirm the requested object belongs to the caller on every access and deny by default. The decorator-coverage cousin of this issue is covered in Broken Access Control in Flask Applications.
Proof of exploitation
Run the lab app (PyFuLabs/flask-fu/flask-business-logic). All accounts use the password pyfu. Logging in as the low-privilege bob and requesting another user’s id returns their profile, because /profile/<id> checks only that you are logged in, not that the id is yours:
curl -s -c jar -d "username=bob&password=pyfu" http://pyfu.local/flask-fu/flask-business-logic/ -o /dev/null
curl -s -b jar http://pyfu.local/flask-fu/flask-business-logic/profile/15202 | grep -i "Profile of"
<h1>Profile of PyFu Admin</h1>
bob (id 15203) read the admin’s profile (id 15202). The role-gated /dashboard still returns 403, so the gap is the missing per-object ownership check.
Mitigation
The fix is to enforce ownership on the object, not just authentication at the route. The /profile/<id> handler must confirm the requested id belongs to the logged-in session, or that the session is explicitly authorized to view it, and reject otherwise while denying by default; checking only that some user is logged in lets any user read any record. Sequential, guessable ids make the enumeration trivial, so combine the ownership check with non-predictable identifiers where the design allows.