Description
You have gotten access to an organisation's portal. Submit your email and password, and it redirects you to your profile. But be careful: just because access to the admin isn't directly exposed doesn't mean it's secure. Can you find your way into the admin's profile and capture the flag?
Setup
Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Observe the profile URL structure
ObservationThe profile URL carries a 32-hex-character segment, exactly an MD5 digest. The server is routing by hashing some per-user value rather than using a random identifier.After logging in, look at your profile URL. The 32-hex-character path segment is the shape of an MD5 hash, and a quick verify of MD5(your_user_id) confirms it: user ID 3000 maps to e93028bdc1aacdfb3687181f2031765d.bash# Verify: MD5 of 3000pythonpython3 -c "import hashlib; print(hashlib.md5(b'3000').hexdigest())"bash# Output: e93028bdc1aacdfb3687181f2031765dbash# Your profile URL is something like: /profile/e93028bdc1aacdfb3687181f2031765dExpected output
e93028bdc1aacdfb3687181f2031765d
What didn't work first
Tried: Paste the 32-hex URL segment into hash-identifier or hashid to confirm the algorithm before verifying the preimage.
hashid correctly reports the string is consistent with MD5, along with a dozen other 128-bit hashes, and stops there: it cannot tell you the input. You still have to guess a candidate preimage, hash it, and compare. Knowing the algorithm alone forges nothing.
Tried: Try MD5 of the username or email address (guest or guest@picoctf.org) instead of the numeric user ID.
Hashing the account name gives one digest and the email address gives another, and neither matches the segment in the URL. The input is the integer user ID as ASCII bytes, not the human-readable name. A wrong candidate produces a digest that appears nowhere in the app.
Learn more
This is an example of Insecure Direct Object Reference (IDOR), a form of broken access control, which sits at the top of the OWASP Top 10. The server uses a predictable identifier (an MD5 hash of a sequential user ID) to gate access to profile pages, without verifying that the logged-in user is actually authorised to view that profile. Hashing the ID gives the appearance of obscurity but provides zero access control because:
- MD5 is deterministic: the same input always produces the same hash
- Sequential integer inputs produce a small, enumerable set of hashes
- MD5 is not a secret: anyone can compute it
To identify the hash, paste the 32-hex-character URL segment into crackstation.net. It confirms the hash is MD5 and that the plaintext is
3000, revealing that the URL encodes the user ID. Shape-based identification (a 32-hex-character string is almost certainly MD5) is a valid secondary observation, and tools likehashidorhash-identifiercan confirm the format family, but crackstation directly tells you both the algorithm and the preimage. See the hash cracking guide for more on hash identification.Security through obscurity is not security. The URL structure should be considered public knowledge once any user can see it, because the algorithm for generating it is entirely predictable. Proper access control requires checking who is logged in against whose resource is being requested on every request, not just at login time. See the web bug patterns post for more IDOR pattern variants.
Step 2Enumerate nearby user IDs to find the admin
ObservationWith the preimage confirmed as the user ID, and your own ID at 3000, the description's mention of roughly 20 employees puts the admin in a narrow range nearby. Enumerating that range is trivial.The challenge says there are about 20 employees. Enumerate IDs from 3000 upward (your guest account is 3000). The admin is at ID 3012.pythonpython3 << 'EOF' import hashlib import requests BASE = "http://<HOST>:<PORT_FROM_INSTANCE>" for uid in range(3000, 3022): h = hashlib.md5(str(uid).encode()).hexdigest() r = requests.get(f"{BASE}/profile/{h}") print(f"ID {uid}: {len(r.text)} bytes") if "picoCTF{" in r.text or "Welcome admin" in r.text: print(f"HIT at ID {uid} /profile/{h}") print(r.text) break EOFWhat didn't work first
Tried: Search for the admin profile by scanning IDs starting at 1 or at a very low number like 1000.
The guest account is ID 3000, so the platform starts its IDs high. Counting up from 1 burns thousands of requests and may hit a timeout or rate limit before reaching anything useful. Start at your own ID and walk outward, where accounts created around the same time live.
Tried: Use Burp Suite Intruder with a payload list of pre-computed hashes copied from a rainbow table instead of computing hashes in the script.
A generic rainbow table targets common passwords, not sequential numeric strings, so it will not contain these digests unless someone built it for small integers. Computing hashes inline covers exactly the range you choose.
Learn more
IDOR enumeration is the process of systematically iterating over object identifiers to access resources belonging to other users. Because user IDs are sequential integers (assigned in order of account creation), knowing your own ID gives you a starting point for enumeration. The admin account belongs to the same small block of employee accounts as your guest account, so its ID sits a few steps above 3000 rather than anywhere in the full integer range.
The Python
hashlib.md5(str(uid).encode()).hexdigest()call replicates exactly what the server computes. By precomputing hashes for a range of IDs and making HTTP GET requests to each profile URL, you can iterate through all plausible admin IDs in seconds. This is what automated IDOR scanning tools like Burp Suite's Intruder or custom scripts do in real penetration tests.In real bug bounty programs, IDOR vulnerabilities consistently rank among the highest-paid findings because they directly expose user data. Even a simple IDOR on a non-sensitive endpoint (like user IDs in a public profile) can be chained with other bugs to achieve account takeover or data exfiltration at scale. Responsible disclosure of IDOR bugs has earned researchers hundreds of thousands of dollars from major platforms.
Step 3Access the admin profile
ObservationThe scan lands on ID 3012, whose profile response holds the flag. Hash that ID and request the URL.The admin is at ID 3012. Navigate to their profile URL to read the flag.pythonpython3 -c "import hashlib; print(hashlib.md5(b'3012').hexdigest())"bashcurl http://<HOST>:<PORT_FROM_INSTANCE>/profile/$(python3 -c "import hashlib; print(hashlib.md5(b'3012').hexdigest())")What didn't work first
Tried: Run echo -n 3012 | md5sum and use the output directly, but the trailing space and dash that md5sum prints get included in the URL.
md5sum appends two spaces and a dash to its output. Interpolate that into a URL and you get a 404, because the whitespace arrives as part of the hash. Cut the first field, or print the digest from Python, which emits nothing else.
Tried: Visit /profile/3012 directly in the browser instead of computing the MD5 hash.
The server routes on the hash, not the integer, and its routing table only recognizes 32-hex-character slugs, so the bare ID returns a 404. Hash it first, then request that.
Learn more
MD5 (Message Digest 5) was designed as a cryptographic hash function but is now considered cryptographically broken - it is vulnerable to collision attacks (two different inputs that produce the same hash). However, the vulnerability here is not about MD5 collisions; it is about using any deterministic, reversible-by-enumeration scheme as a substitute for proper access control.
The correct defence is server-side authorisation on every endpoint: when a request for
/profile/<hash>arrives, look up which user ID corresponds to that hash, then check whether the currently authenticated user's session is allowed to view that user's data. If not, return HTTP 403 Forbidden. No amount of hash obfuscation in the URL replaces this check.Additional defences include using UUID v4 (random 128-bit identifiers) instead of sequential integers for user IDs - these are not enumerable. But even with UUIDs, server-side authorisation checks remain mandatory because a UUID leak (from a URL in an email, a log file, etc.) would still allow unauthorised access without an authorisation check.
Interactive tools
- URL Encoder / DecoderEncode and decode URL-encoded (percent-encoded) strings. Useful for web exploitation challenges involving query parameters, form data, and HTTP headers.
- Hash IdentifierIdentify unknown hash types by length and prefix. Covers MD5, SHA-1, SHA-256, SHA-512, bcrypt, NTLM, and more.
Alternate Solution
Once you have confirmed the URL is an MD5 of the user ID by shape (32 hex characters), generate MD5 hashes for sequential user IDs directly from the command line with echo -n "3012" | md5sum | cut -d" " -f1 (the cut strips the two spaces and dash md5sum appends) and curl each profile URL to find the one that renders the flag, no Python required.
Flag
Reveal flag
picoCTF{id0r_unl0ck_...}
Profile URLs are MD5 hashes of numeric user IDs. Guest is ID 3000 (MD5 e93028b...). Enumerate IDs near 3000 - the admin is at ID 3012 and their profile displays the flag.
Key takeaway
How to prevent this
How to prevent this
This is IDOR dressed up in a hash. The hash is decoration; the missing authz check is the bug.
- On every request that reads a resource, check the session against the resource owner:
if (resource.user_id !== session.user_id) return 403. Hash, UUID, or sequential ID in the URL is irrelevant if this check is missing. - Use unguessable random IDs (UUIDv4 or 128-bit secrets) for resources, but treat them as defense-in-depth, not as authz. UUIDs leak through logs, referers, browser history, and email links.
- Centralize authz: a single middleware or policy layer (Casbin, Oso, OpenFGA) so every controller goes through it. Decentralized per-handler checks are how the third endpoint forgets and ships the bug.