Hashgate picoCTF 2026 Solution

Published: March 20, 2026

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?

Launch the challenge instance and open the web portal.

View the page source of the login page to find the embedded credentials (guest@picoctf.org / guest), then log in with those to access your profile.

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Observe the profile URL structure
    Observation
    I noticed the profile URL contained a 32-hex-character path segment after login, which is the exact length of an MD5 digest and suggested the server was routing to profiles by hashing some predictable 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 3000
    python
    python3 -c "import hashlib; print(hashlib.md5(b'3000').hexdigest())"
    bash
    # Output: e93028bdc1aacdfb3687181f2031765d
    bash
    # Your profile URL is something like: /profile/e93028bdc1aacdfb3687181f2031765d

    Expected output

    5a01f0597ac4bdf35c24846734ee9a76
    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 and hash-identifier will correctly report the string is consistent with MD5 (and a dozen other 128-bit hashes), but they cannot tell you what the input was. You still need to guess a candidate preimage - here the user ID - hash it yourself with hashlib, and compare. Skipping the preimage step leaves you knowing the algorithm but unable to forge any other profile URL.

    Tried: Try MD5 of the username or email address (guest or guest@picoctf.org) instead of the numeric user ID.

    MD5('guest') is 084e0343a0486ff05530df6c705c8bb4 and MD5('guest@picoctf.org') produces a different digest - neither matches the URL segment e93028bdc1aacdfb3687181f2031765d. The correct input is the raw integer user ID 3000 encoded as ASCII bytes, not the human-readable account name. Using the wrong preimage candidate produces a digest that simply does not appear anywhere in the app.

    Learn more

    This is an example of Insecure Direct Object Reference (IDOR), OWASP's top web security risk. 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 like hashid or hash-identifier can 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.

  2. Step 2
    Enumerate nearby user IDs to find the admin
    Observation
    I noticed that once the preimage scheme was confirmed as MD5(user_id) with my own ID being 3000, the challenge description mentioning roughly 20 employees suggested the admin's ID would fall within a small, predictable integer range close to 3000, making brute-force enumeration of the hash space practical.
    The challenge says there are about 20 employees. Enumerate IDs from 3000 upward (your guest account is 3000). The admin is at ID 3012.
    python
    python3 << '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
    EOF
    What 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, which suggests the platform uses high-based IDs. Starting at 1 and stepping up will waste many requests before reaching the relevant range, and the script may time out or hit a rate limit before it arrives at 3012. Starting the range just below your own known ID (e.g. 2990 to 3025) targets the realistic window where accounts created around the same time would 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 for MD5 will not contain MD5('3000') through MD5('3021') unless it was built specifically for small decimal integers, because rainbow tables target common passwords, not sequential numeric strings. Computing hashes inline with hashlib guarantees coverage of exactly the integer range you choose and avoids the mismatch between what the table covers and what the server expects.

    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 was created early, so its ID is close to other low-numbered accounts.

    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.

  3. Step 3
    Access the admin profile
    Observation
    I noticed the enumeration scan identified ID 3012 as the admin account because its profile response contained the flag string, so computing MD5('3012') and requesting that URL directly was the final step to capture it.
    The admin is at ID 3012. Navigate to their profile URL to read the flag.
    python
    python3 -c "import hashlib; print(hashlib.md5(b'3012').hexdigest())"
    bash
    curl 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 outputs a line like '5a01f0597ac4bdf35c24846734ee9a76 -' with two trailing spaces and a dash. If you copy-paste or interpolate this raw output into the curl URL you get a 404 because the server receives that extra whitespace as part of the hash string. Either strip the output with 'echo -n 3012 | md5sum | cut -d' ' -f1' or use python3 -c 'import hashlib; print(hashlib.md5(b"3012").hexdigest())' which prints only the hex digest.

    Tried: Visit /profile/3012 directly in the browser instead of computing the MD5 hash.

    The server routes on the hashed value, not the raw integer. A request to /profile/3012 returns a 404 or an error page because the routing table only recognises 32-hex-character slugs. The integer 3012 must be hashed to 5a01f0597ac4bdf35c24846734ee9a76 (verify with python3 -c) before the server can map it to a profile record.

    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
  • JWT DecoderDecode JSON Web Tokens and inspect the header, payload, and signature. Useful for web exploitation challenges.
  • Flask Session DecoderDecode Flask / itsdangerous session cookies. Splits payload, decompresses zlib, parses JSON, and verifies the HMAC signature when given the secret.
  • 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 and curl each profile URL to find the one that renders the flag, no Python required.

Flag

Reveal flag

picoCTF{h4sh_g4t3_byp4ss3d_...}

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

Insecure direct object references occur when a server uses a client-supplied identifier to locate a resource without checking whether the requester is authorized to access it. Replacing sequential IDs with hashes or UUIDs adds obscurity but no security, because a hash of a predictable input is itself predictable and a leaked UUID grants access just as directly. The only fix is a server-side authorization check on every request that compares the authenticated user's identity against the resource owner, regardless of how the identifier looks in the URL.

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.

Related reading

Want more picoCTF 2026 writeups?

Useful tools for Web Exploitation

What to try next