Business Logic Vulnerabilities & IDOR
Identify and exploit business logic flaws and Insecure Direct Object References — vulnerabilities that scanners miss because they require understanding how the application is supposed to work.
Learning Objectives
- → Identify IDOR in ID parameters, GUIDs, and indirect references
- → Exploit business logic to bypass price validation, workflow steps, and access controls
- → Chain multiple low-severity flaws into a high-impact attack
- → Implement authorization checks for every object access
- → Test for horizontal and vertical privilege escalation
Insecure Direct Object Reference (IDOR)
What is IDOR?
Application exposes internal object IDs — user can access/modify other users' objects by changing the ID:
GET /api/invoice/1234 → your invoice
GET /api/invoice/1235 → another user's invoice (IDOR!)
PUT /api/profile/1234 → update your profile
PUT /api/profile/1235 → update another user's profile (IDOR!)
The server trusts the ID without verifying ownership.
Finding IDOR
1. Look for IDs in:
- URL parameters: /invoice/1234
- JSON body: {"id": 1234, "action": "delete"}
- Hidden form fields: <input type="hidden" name="user_id" value="42">
- Headers: X-User-ID, X-Resource-ID
- Cookies: resource_id=1234
2. Test increment/decrement:
id=1234 → id=1233, id=1235
3. Test other users' IDs (use two accounts):
Account A: get your invoice_id=100
Account B: try GET /invoice/100
4. Try GUIDs:
UUIDs look random but may be predictable or guessable
GUID IDOR: get another user's GUID from email preview, invitation link, shared content
5. Try API endpoints directly:
/api/admin/users — may not enforce auth
/api/user/delete/1 — no auth check
IDOR Variants
Horizontal IDOR: Access other users' same-privilege resources
User A reads User B's private messages
Vertical IDOR: Access higher-privilege resources
Regular user accesses admin endpoints
Indirect IDOR: Change parameter that references an object
filename=report.pdf → filename=admin_report.pdf
orderRef=abc123 → orderRef=admin_audit_log
Business Logic Vulnerabilities
Price/Quantity Manipulation
# Vulnerable checkout:
# Server trusts client-supplied price:
price = request.POST.get("price") # attacker sets price=0.01
Process_payment(price)
# Or: negative quantities for refunds:
quantity=-1 # checkout gives you a refund!
# Or: integer overflow:
quantity=99999999999 # overflows to negative in some systems
# Fix: always calculate price server-side from product ID:
product = Product.objects.get(id=product_id)
total = product.price * quantity # not from user input
Workflow Step Bypass
Registration flow: /step1 → /step2 → /step3/confirm
Normal flow enforced by state on server? If not:
Skip to: POST /step3/confirm with crafted body
→ Complete registration without doing required steps
Example: email verification bypass
1. POST /register → account created, verify-required
2. POST /verify_email → sends email
3. GET /dashboard → blocked (not verified)
4. Try: POST /api/account/upgrade (skipping verification state check)
→ May succeed if step isn't checked per-action
Coupon/Discount Abuse
1. Apply same coupon multiple times (no server-side use tracking)
2. Apply discount to non-eligible products
3. Combine non-combinable coupons
4. Race condition: two simultaneous /apply_coupon requests
— both read "not used" before either writes "used"
— both apply!
Fix:
- Database-level uniqueness constraint on coupon use
- Atomic transactions with SELECT FOR UPDATE
Mass Assignment
# Vulnerable Django view — updates ALL fields from POST data:
user = User.objects.get(id=user_id)
for key, value in request.POST.items():
setattr(user, key, value)
user.save()
# Attacker POST: is_admin=True, role=admin
# → Privilege escalation!
# Fix — allowlist only editable fields:
ALLOWED_FIELDS = {"name", "email", "bio"}
for key, value in request.POST.items():
if key in ALLOWED_FIELDS:
setattr(user, key, value)
# Django REST Framework: read_only_fields or explicit fields in serializer
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["name", "email"] # explicit allowlist
read_only_fields = ["is_admin", "role"]
Race Conditions in Business Logic
# Vulnerable: two requests simultaneously check balance:
def transfer(amount, from_user, to_user):
balance = from_user.balance # reads 1000
if balance < amount:
raise ValueError("Insufficient")
from_user.balance -= amount # both threads subtract!
from_user.save()
to_user.balance += amount
to_user.save()
# Race condition: two simultaneous $800 transfers from $1000 balance
# Thread 1: reads 1000, subtracts 800, saves 200
# Thread 2: reads 1000 (before Thread 1 saves!), subtracts 800, saves 200
# Result: two $800 transfers from $1000 balance!
# Fix: database-level atomic update with locking
from django.db import transaction
with transaction.atomic():
user = User.objects.select_for_update().get(id=from_user_id)
if user.balance < amount:
raise ValueError("Insufficient funds")
user.balance -= amount
user.save()
Authorization Checks
Broken Function Level Access Control
# Vulnerable: only UI hides admin buttons — no server check
@app.route("/admin/delete_user/<int:user_id>")
def admin_delete_user(user_id):
# No auth check!
User.objects.get(id=user_id).delete()
return "Deleted"
# Fix: check on EVERY function
from django.contrib.auth.decorators import login_required, user_passes_test
@login_required
@user_passes_test(lambda u: u.is_staff)
def admin_delete_user(request, user_id):
user = User.objects.get(id=user_id)
# Also check: is request.user trying to delete themselves?
if user == request.user:
raise PermissionDenied
user.delete()
IDOR-Safe Object Access Pattern
# Always filter by owner:
def get_invoice(request, invoice_id):
# WRONG: Lesson.objects.get(id=invoice_id)
# CORRECT: filter by owner
try:
invoice = Invoice.objects.get(id=invoice_id, owner=request.user)
except Invoice.DoesNotExist:
raise Http404 # don't reveal existence
return invoice
# For admin access: check role separately
def get_any_invoice(request, invoice_id):
if not request.user.is_staff:
raise PermissionDenied
return Invoice.objects.get(id=invoice_id)
On a web app with /api/orders/{id}: (1) find your order ID (e.g., 1042), (2) try adjacent IDs: 1041, 1043 — do you get other users' orders? (3) try /api/admin/orders without admin role, (4) look for GUIDs in API responses — try one from a different user's email, (5) document each IDOR finding with impact (what data is exposed) and the fix (filter by owner).
Why do GUIDs not fix IDOR vulnerabilities?
On a shopping cart endpoint: (1) add item to cart, intercept checkout POST, change price=100 to price=0.01 — does server accept it? (2) try quantity=-1 for negative total, (3) apply same coupon twice, (4) add item, start checkout, change product_id to a more expensive product while keeping price for the cheap one, (5) fix: server must calculate price from DB using product_id, never from client.
What is a race condition in coupon redemption?
On a profile update endpoint (PUT /api/profile): (1) send normal update: {name: 'Alice'}, (2) add is_admin: true — check if it's accepted, (3) try role: 'admin', (4) try balance: 999999, (5) fix the Django serializer to use explicit fields allowlist and read_only_fields for is_admin and role.
What Django REST Framework feature prevents mass assignment?