Skip to main content
Back to blogs
CTF PicoCTF Security Web SQLite Flask

NO FA Write-up (PicoCTF)

7 min read

So this was a fun one. The challenge is called NO FA from PicoCTF, and the goal is pretty straightforward – find your way into an admin account that has two-factor authentication enabled. Sounds easy, right? Yeah, not really.

hacker gif


Step 1: Poking Around the Database

The CTF gives you an SQLite database file. First thing we do is crack it open using the SQLite CLI:

sqlite3 user.db

Once inside, we check what tables exist:

.tables

You see a table called users. So naturally, we dump everything:

SELECT * FROM users;

Here is what comes out:

SQLite users table dump

So the database has columns: id, username, email, password, and two_fa. The passwords are clearly hashed (SHA256 by the look of them). The interesting row is user admin (id 5) with email iamadmin@nfs.com – notice the two_fa column is set to 1 for this one only. Everyone else has 0. So there is our target.

The c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67 is the admin’s hashed password.


Step 2: Password Bruteforcing Methods

Before jumping to cracking, let me explain the different ways people crack passwords. This is what the challenge is testing.

1.) Blind Brute Force Attack

In this attack we try all the possible combinations of letters, numbers and special symbols used. You would try a-z, A-Z, 0-9, !@#$& and every combination possible. Extremely slow, but guaranteed to work eventually.

2.) Dictionary Attack

Uses a dictionary of passwords – maybe called a list of passwords, famously known as the seclists. Instead of trying every combo, you try known common passwords first. Way faster for weak passwords.

3.) Hybrid Attack

A combination of dictionary and brute force. For example if you have a name like “mradul kumar” you might try mradulkumar1, kumarmradul, trimming or reordering words. Real world passwords usually follow patterns.

4.) Rainbow Tables

Here is the interesting one.

When a company stores passwords, it does not store it in plaintext – it stores it in a hash format. Hashing is a process that converts plaintext into a fixed-length hashed string. The same input always produces the same hash.

A Rainbow Table is a giant precomputed lookup table containing plaintext passwords alongside their hashed equivalents. When a hacker gets hold of a hash, they query it against the database and retrieve the original password instantly. It is very fast since the computation is already done.

If you add a random string (called a “salt”) to the end of the passphrase before hashing it, cracking becomes nearly impossible – even if the exact hash exists in the rainbow table, the salt makes it unique.

For this challenge we are going to use crackstation.net – a massive library of precomputed hashes. You can submit up to 20 hashes at once, which is perfect for our problem.


Step 3: Cracking the Hash

We paste the hashes from the database dump into CrackStation:

CrackStation results

Jackpot. The c20fa... hash (admin’s password) cracks to apple@123. So we now have:

  • Username: admin
  • Password: apple@123

But there is a problem.


Step 4: The OTP Wall

When we try to login as admin, we get hit with this:

OTP Verification screen

The admin account has two-step verification enabled. It sends an OTP to the registered email, and we obviously do not have access to that email.

First instinct: bruteforce the OTP.

The OTP is 4 digits, so values range from 1000 to 9999. That is only 9000 possible values. Here is a JavaScript payload that tries all of them at once using async fetch:

let payload = async function() {
    const tasks = [];
    for (let i = 1000 ; i <= 9999; i++)
    {
        const payload_data = `otp=${i}`
        let req = tasks.push(fetch('/two_fa', {headers: {"Content-Type": "application/x-www-form-urlencoded"}, method: "POST" , body : payload_data}).then(res => ({ i, res }) ));
        
    }
    const results = await Promise.all(tasks);
    console.log(results.filter(r => r.res.redirected === true));
}

// the internet can't process 9000 requests at once so it will fail

The problem is obvious – the server (or the browser) cannot handle 9000 concurrent HTTP requests. The approach fails. We need a smarter angle.


Step 5: Hunting for Another Vulnerability

So brute forcing the OTP is out. We dig back into the source code of the app and find something interesting – the developer is encoding the session and sending it back to the client as a cookie. Flask has this behavior where it signs session data and stores it client-side.

If we can decode that cookie, maybe it leaks something.

flask-unsign --decode --cookie "<cookie>"

We grab our session cookie from the browser (DevTools > Application > Cookies) and decode it:

Flask cookie decoded

Look at that. The decoded cookie contains:

{'logged': 'true', 'otp_secret': '7988', 'otp_timestamp': 1783522228.6549542, 'username': 'john.doe'}

The OTP is sitting right there in the cookie! The server signed the session data – but it never bothered to hide what was inside it. Anyone with access to their own cookie can decode it using flask-unsign and find the OTP in plaintext.

For the admin session, we decode their cookie the same way and pull out the otp_secret field.


Step 6: Getting the Flag

Login as admin with password apple@123, grab the session cookie, decode it, get the OTP, enter it, and:

picoCTF{n0_r4t3_n0_4uth_6db141c5}

Done.

flag gif


What We Learned

  • SQLite databases expose everything if you have file access
  • Weak passwords get cracked instantly via rainbow tables – even SHA256 is not enough if the password is apple@123
  • Bruteforcing OTPs at scale needs rate limiting or concurrency controls on the server side
  • Flask session cookies are not encrypted – they are only signed. The payload is base64 encoded and visible to anyone who knows to look
  • Never store sensitive values like OTP secrets inside the session cookie

byeee… bye.. see yaa!

bye gif

Discussion (0)

Loading comments...

Leave a Comment