The login is not the lock you think it is
You hit a CTF web app and it asks you to log in. There is no SQL injection in the username box, no default password on the wiki, and the source looks clean. Before you spend an hour brute-forcing, ask the only question that matters in this category: who is actually checking that you are allowed in, and where does that check run?Most CTF access-control flaws collapse the moment you answer it, because the check runs somewhere you control: in your browser's JavaScript, in a cookie you can edit, in a hidden form field, or in an id parameter the server trusts without verifying it belongs to you.
Here is the fast triage. Run these four moves in order on any login or "members only" page and you will clear the majority of access-control challenges:
# 1) Is the gate in client-side JS? Read the source, find the check, delete it.curl -s http://target/ | grep -iE 'isAdmin|role|loggedIn|redirect'# 2) Is your role stored in a cookie? Edit it and resend.curl -s http://target/admin -b 'admin=1; role=admin'# 3) Are there default creds? Try the obvious ones first.curl -s -d 'user=admin&pass=admin' http://target/login# 4) Does a URL or body carry an id? Increment it (IDOR).curl -s 'http://target/profile?id=2' -b 'session=...'
The rest of this guide is each of those moves in depth, plus the logic flaws that round out the category. Everything here is part of what the Open Worldwide Application Security Project (OWASP) ranks as the number-one web risk: A01 Broken Access Control. CTF authors love it because the bug is conceptual, not a one-line payload you can copy.
What is the broken-access-control mindset?
Authentication and authorization are two different questions, and CTF challenges break one or the other. Authentication asks who are you. Authorization asks are you allowed to do this. A login bypass attacks the first. An Insecure Direct Object Reference (IDOR) attacks the second: you are correctly logged in as yourself, but the server lets you act on data that is not yours.
Every access-control bug is the same mistake wearing a different hat: the server trusted something the client controls.
That sentence is the whole category. A cookie, a hidden field, a URL parameter, a JavaScript variable, a request to an endpoint nobody linked to: all of these live on your side of the wire. If the server makes a security decision based on any of them without re-verifying server-side, you win. Your job is to enumerate every value the client sends and ask, for each one, "what happens if I change this?"
The tooling is light. A browser's developer tools, curl, and an intercepting proxy cover everything in this post. The Burp Suite post walks through intercepting and replaying requests, which is the single most useful skill here: once you can see and edit every request, the bugs become obvious.
role=user, id=42, isAdmin=false, account_type=free. The server should derive those from your authenticated session, not read them back from your request. When it reads them back, change them.When the auth check runs in JavaScript, you can just delete it
The laziest gate of all is a check that runs in the browser. The page ships a script that reads a variable, decides you are not an admin, and redirects you away or hides the secret panel. But that script runs on your machine, in an interpreter you control. You can read it, ignore it, or rewrite it.
A representative example, the kind you find by viewing source:
<script>const user = { name: 'guest', isAdmin: false };if (!user.isAdmin) {document.getElementById('admin-panel').style.display = 'none';// window.location = '/login'; // sometimes a hard redirect}</script>
There are three ways past this, in increasing order of force:
- Read, do not run. The flag or the next URL is often sitting in the JavaScript itself.
curlthe page and grep the source. The redirect never fires forcurlbecausecurldoes not execute scripts. You already have the HTML the "admin" would see. - Unhide the element. If the panel is merely set to
display:none, open dev tools, find the node, and flip the style. The content was sent to your browser; CSS was the only thing stopping you from reading it. - Rewrite the variable. Open the console and run
user.isAdmin = truebefore the gate runs, or set a breakpoint on theifand edit the value live. Even a hardwindow.locationredirect can be cancelled by pausing in the debugger.
# Pull the page without executing any of its JS. The redirect never happens.curl -s http://target/dashboard | grep -iE 'flag|admin|secret|token'# In the browser console, defeat a client-side gate before navigation:// user.isAdmin = true// document.getElementById('admin-panel').style.display = 'block'
Are there default or guessable credentials?
Before anything clever, try the boring keys. CTF authors seed test accounts and forget to remove them, or the challenge is explicitly about a shipped default. The cost is a handful of requests and the payoff is a full bypass.
The short list that clears more challenges than it has any right to:
admin : adminadmin : passwordadmin : admin123guest : guesttest : testroot : root / tooradmin : <blank>user : changeme
Beyond the obvious pairs, look for context the challenge already gave you. A name in the page footer, an email in a mailto: link, a username shown in a comment thread: any of these is a candidate username, often with a weak password. When you need to spray a list, a small wordlist against the login form is fair game:
# Loop a few candidate passwords against a known usernamefor p in admin password admin123 letmein root; doecho -n "$p -> "curl -s -o /dev/null -w '%{http_code}\n' \-d "user=admin&pass=$p" http://target/logindone
Set-Cookie, a changed body length, or the word "Welcome." Diff the responses. The same signal-reading skill powers credential-related challenges across the board.Default credentials also apply when the "app" is a known piece of software. If recon fingerprints a specific admin panel or device firmware, search the vendor docs for its shipped default login. That is recon work; the Web Recon post covers fingerprinting.
IDOR: what happens when I increment the id?
This is the heart of the post. You are logged in as yourself, legitimately. You view your profile and the URL reads /profile?id=42. You change 42 to 41 and the server hands you someone else's profile. That is an Insecure Direct Object Reference: the server used your supplied id to fetch an object without checking that the object belongs to you.
IDOR is not a login bypass. You are authenticated. The server simply never checked whether the thing you asked for is yours to see.
The reference can live anywhere a request carries an identifier:
# In the query stringGET /profile?id=42 -> GET /profile?id=1GET /invoice?order=1007 -> GET /invoice?order=1006# In the pathGET /api/users/42/messages -> GET /api/users/1/messages# In the request bodyPOST /account/view body: { "account_id": 42 } -> "account_id": 1# In a cookie or headerCookie: uid=42 -> Cookie: uid=1
Concrete walk-through. You register, log in, and capture the request that loads your own data. Then you replay it with the identifier walked down toward 1, which is usually the first user created (frequently the admin):
# Your own profile, with your real session cookiecurl -s 'http://target/profile?id=42' -b 'session=YOUR_REAL_SESSION'# Same session, someone else's id. If this returns their data, it is IDOR.curl -s 'http://target/profile?id=1' -b 'session=YOUR_REAL_SESSION'# Automate the sweep across a range of ids and look for the flagfor i in $(seq 1 50); doecho -n "id=$i: "curl -s "http://target/profile?id=$i" -b 'session=YOUR_REAL_SESSION' \| grep -oE 'picoCTF\{[^}]*\}' || echo '(no flag)'done
Three refinements that turn a dead IDOR into a live one:
- Non-sequential ids.If the reference is a Universally Unique Identifier (UUID) or a hash, you cannot guess it by counting. Look for where the app leaks other users' ids: a listing page, a search result, an autocomplete endpoint, a shared link. Harvest the id from one place and replay it at the protected one.
- Method and endpoint swaps. Read access may be locked but write access forgotten. If
GET /api/users/1is blocked, tryPUT /api/users/1or a sibling route like/api/users/1/export. Access control is often applied per route, not per object, so an unguarded sibling gives you the same object. - Encoded or offset ids. An id that is base64, hex, or a fixed offset from the real value is still a direct reference. Decode
NDI=to42, change it, re-encode. The wrapper is decoration.
IDOR also pairs with injection. A NoSQL backend that takes a JSON id may accept an operator instead of a value, turning "fetch my record" into "fetch every record." picoCTF 2024 No SQL Injection is the canonical practice for that crossover; the SQL Injection post covers the injection half.
Forced browsing: what if I just request /admin?
Some pages are protected only by the fact that nothing links to them. There is no button to the admin console, so the developer assumed nobody would find it. This is security by obscurity, and it is defeated by typing the URL. Requesting a resource that is not linked but is not actually access-controlled is called forced browsing.
The candidate paths are predictable. Try them by hand, then with a wordlist:
/admin /admin/ /administrator/admin.php /admin/index.php /admin/dashboard/dashboard /panel /manage/internal /private /debug/api/admin /api/v1/users /.git/config/backup /backup.zip /robots.txt
robots.txt deserves a special mention: it is a list, written by the developer, of the exact paths they did not want indexed. That is a map of the interesting endpoints. Always read it first.
# robots.txt often names the very paths you wantcurl -s http://target/robots.txt# Brute-force directories with a wordlist (ffuf or gobuster)ffuf -u http://target/FUZZ -w wordlist.txt -mc 200,301,302,403# A 403 is not a dead end: the path exists. Try sub-paths and method swaps.curl -s -X POST http://target/admin/createUser -d 'user=x&role=admin'
Forced browsing combines with everything above. A path that returns 403 Forbidden to a guest may return 200 once you add a tampered admin=1 cookie. The endpoint existed all along; the check on it was the weak link. The Web Recon post covers content discovery in depth, and picoCTF 2019 logon is a clean example of a page that lets you in once you set the right cookie value.
403 or a redirect to /login tells you the resource is real. Do not discard it. Re-request it with each escalation you have: a forged cookie, a different HTTP method, a trailing slash, a path-traversal prefix, or an X-Forwarded-For: 127.0.0.1 header that some apps trust for "internal only" gates.Logic flaws: when the rules disagree with reality
The last family is the subtlest. There is no missing check; the check is simply wrong about the world. These bugs come from assumptions the developer baked in that you can violate. They reward reading the application as a set of rules and asking which inputs the rules never anticipated.
- Negative quantities. A shop charges
price * quantity. Setquantity = -1and the total goes negative, crediting your account instead of debiting it. The same trick on a transfer amount moves money the wrong way. The code validated that you had enough balance, not that the amount was positive. - Parameter pollution. Send the same parameter twice:
?role=user&role=admin. Different layers of the stack pick different occurrences. A front-end filter may read the firstrolewhile the backend logic reads the last, so the value that passes validation is not the value that gets used. - Type juggling. A loosely typed comparison can treat
0 == "admin"(PHP 7 and earlier) ortrue == 1as equal. Sending a password as a JSONtrueor an empty array instead of a string can make a naive equality check pass. Note PHP 8 changed number-to-string comparison, so0 == "admin"is now false there. - Skipping steps. A multi-step flow (cart, then checkout, then pay, then confirm) often guards only the last step. Request the confirmation endpoint directly and the unpaid order ships. The state machine assumed you would arrive in order.
- Race conditions.A "use this coupon once" check that reads, then writes, can be beaten by firing many requests at once before the write lands. You redeem the same single-use token several times.
# Negative quantity flips a charge into a creditcurl -s http://target/cart/add -b 'session=...' \-d 'item=1337&quantity=-5'# Parameter pollution: two roles, two parsers, one of them picks admincurl -s 'http://target/account?role=user&role=admin' -b 'session=...'# Type juggling: send the password field as a boolean, not a stringcurl -s http://target/login -H 'Content-Type: application/json' \-d '{"user":"admin","pass":true}'
How do I recognize which one I am looking at?
Pattern-match on what the app shows you. Each flaw has a tell that you can spot in the first minute of recon, before you write a single payload.
| Flaw | The tell | First move |
|---|---|---|
| Client-side check | Redirect or hidden panel driven by inline JS; isAdmin in the source | curl the page; edit the variable in console |
| Cookie / hidden field | A role or flag value in a cookie or <input type=hidden> | Edit the value, resend |
| Default creds | Generic login with no obvious injection; a username hinted on the page | Try admin/admin and friends |
| IDOR | A numeric or encoded id in the URL, body, or cookie | Increment it with your session |
| Forced browsing | Hints at an admin area; robots.txt entries; 403 pages | Request the path directly |
| Logic flaw | Quantities, prices, multi-step flows, repeated parameters | Violate one assumption per request |
When two tells appear together, chain them. A 403 on /admin plus a plaintext role cookie is forced browsing gated by a tamperable check: forge the cookie, request the path. That composition is where most multi-step web challenges live.
picoCTF challenges that drill access control
These on-site writeups each isolate one rung of the playbook. Work them in roughly this order to build the full reflex:
- picoCTF 2019 logon is the cleanest cookie-tamper bypass: log in as anyone, notice the cookie that decides admin, flip it, and the flag appears. The ideal first challenge for this category.
- picoCTF 2022 Local Authority puts the authentication decision in client-side JavaScript. The credentials are checked in a script the server sends you, so reading the source is the entire solve.
- picoCTF 2021 Cookies teaches the plaintext-cookie escalation: the cookie is a number, and walking it upward changes who the server thinks you are.
- picoCTF 2021 More Cookies and picoCTF 2021 Most Cookies add a signature or encryption layer, pushing you from raw tampering into forging signed tokens. Pair them with the Cookies and JWT post.
- picoCTF 2024 No SQL Injection is the IDOR-meets-injection crossover: a backend that trusts a client-supplied query object, so you ask it for records that are not yours.
For structured practice beyond picoCTF, the PortSwigger Web Security Academy access control labs are the canonical free training ground, with a graded IDOR and forced-browsing track that mirrors everything here.
Quick reference
Access-control checklist for any login or members page
- View source. Is the auth check in JavaScript? Read it, unhide the panel, or rewrite the variable in the console.
- Dump cookies and hidden fields. Any
role,admin, orauthvalue you can edit? Flip it and resend. - Try default creds: admin/admin, admin/password, guest/guest, and any username the page leaked.
- Find every
idin URLs, bodies, cookies, and headers. Increment it with your real session. Diff two accounts to confirm IDOR. - Read
robots.txtand force-browse to/admin,/dashboard,/api/admin. A 403 means it exists; re-attack it with your escalations. - Hunt logic flaws: negative quantities, duplicated parameters, type juggling, skipped steps, one-time tokens raced.
- When a signed cookie or token blocks you, switch to the Cookies and JWT forging techniques.
curl cheat sheet
# Read a page without running its JS (defeats client-side redirects)curl -s http://target/dashboard | grep -iE 'flag|admin|secret'# Tamper a cookiecurl -s http://target/admin -b 'session=...; admin=1; role=admin'# Send a hidden / mass-assignment field the form never showedcurl -s http://target/register -d 'user=x&pass=y&role=admin'# Sweep IDOR ids with your own sessionfor i in $(seq 1 50); do curl -s "http://target/profile?id=$i" \-b 'session=...' | grep -oE 'picoCTF\{[^}]*\}'; done# Force-browse and treat 403 as 'exists, attack harder'ffuf -u http://target/FUZZ -w wordlist.txt -mc 200,301,302,403
The whole category reduces to one habit: enumerate every value your client sends, and for each one ask what breaks if you change it. The server that trusts your input has already lost; you just have to notice which input it trusted.
One concrete move to lock it in. Open any web challenge, register an account, and before you touch a payload, list every place your role or your id travels in a request. That list is your attack surface, and access control is the bug that lives on it.