Writeup StarHack CTF: Challenge - Chaos
I participated in StarHack CTF 2025, and here is the write-up of the “Chaos” challenge that I solved.
This challenge was in the “Binary” category; we were asked to exploit a binary to get a flag in the format flag{...}.
We had access to the compiled Linux binary and a netcat session to run the binary on the CTF servers.
Binary analysis
First step I looked at basic information about the binary with the command file chaos to get some information:
chaos: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=e2937d93ac55bc47823fba02aa98e4b407ea01a3, for GNU/Linux 3.2.0, not strippedThe binary is a 64-bit ELF for Linux (nice because I’m on NixOS ❄️), not stripped, which is good news because we can have function names.
I quickly check with the command strings if there are interesting strings:
Enter password:
Error reading input.
Password too short.
Executing VM...
Invalid password.
···
Welcome to the multi-stage VM challenge!
Can you decrypt and execute the hidden bytecode?
Patching bytecode with input...
:*3$"
UGCC: (Ubuntu 11.4.0-1ubuntu1~22.04.2) 11.4.0We see that it is a Virtual Machine (VM) challenge, where we must decrypt and execute hidden bytecode. The program asks for a password, and there are error messages for input reading and if the password is too short.
If I search references for “flag” we find notably the print_flag function and the flag.txt file.
$ strings chaos | grep flag
flag.txt
Error: Could not read flag. Contact admin.
print_flagAnalysis with Ghidra
I then use ghidra to analyze the binary more in depth.
NOTEI don’t do CTFs much anymore and I don’t necessarily master all the tools.
ghidra along with other tools are part of the essentials for CTF challenges, that’s why I created a small nix-shell with basic tools for all categories. You can find it here:
As we saw earlier with the file command, the binary is not stripped, so we have function names, which is super convenient.
I directly find the main function in the Functions menu, which is the program’s entry point with the main logic.
undefined8 main(void) {
char *pcVar1;
size_t sVar2;
void *__ptr;
long lVar3;
undefined8 uVar4;
char local_488 [64];
undefined1 local_448 [1024];
undefined4 local_48;
void *local_40;
undefined4 local_38;
undefined1 *local_30;
int local_28;
setbuf(stdout,(char *)0x0);
setbuf(stdin,(char *)0x0);
setbuf(stderr,(char *)0x0);
print_banner();
__printf_chk(1,"Enter password: ");
pcVar1 = fgets(local_488,0x40,stdin);
if (pcVar1 == (char *)0x0) {
puts("Error reading input.");
uVar4 = 1;
}
else {
sVar2 = strcspn(local_488,"\n");
local_488[sVar2] = '\0';
sVar2 = strlen(local_488);
if (sVar2 < 3) {
puts("Password too short.");
uVar4 = 1;
}
else {
__ptr = malloc(0x17);
lVar3 = 0;
do {
*(byte *)((long)__ptr + lVar3) = (&encrypted_stage2)[lVar3] ^ 0xaa;
lVar3 = lVar3 + 1;
} while (lVar3 != 0x17);
puts("Patching bytecode with input...");
*(char *)((long)__ptr + 3) = local_488[0];
*(char *)((long)__ptr + 10) = local_488[1];
*(char *)((long)__ptr + 0x11) = local_488[2];
puts("Executing VM...\n");
local_48 = 0;
local_38 = 0;
local_28 = 0;
local_40 = __ptr;
local_30 = local_488;
vm_execute(local_448);
if (local_28 == 0) {
puts("Invalid password.");
}
free(__ptr);
uVar4 = 0;
}
}
return uVar4;
}So now we will try to understand the program logic and follow the execution flow.
print_banner()
First interesting thing, we see there is a print_banner() function that displays at program start.

We notice some information:
- it’s a multi-stage VM
- we must decrypt and execute the hidden bytecode
The code is relatively simple:
void print_banner(void)
{
puts(&DAT_004020f8);
puts(&DAT_004021a8);
puts(&DAT_004021e0);
putchar(10);
puts("Welcome to the multi-stage VM challenge!");
puts("Can you decrypt and execute the hidden bytecode?");
putchar(10);
return;
}Password
So classic start, buffer initialization, banner display, then it asks for a password.
pcVar1 = fgets(local_488,0x40,stdin);
if (pcVar1 == (char *)0x0) {
puts("Error reading input.");
uVar4 = 1;
}Input is read with fgets and an error is printed if it fails.
Then it becomes more interesting, we check the password length:
sVar2 = strcspn(local_488,"\n");
local_488[sVar2] = '\0';
sVar2 = strlen(local_488);
if (sVar2 < 3) {
puts("Password too short.");
uVar4 = 1;
}So the password must be at least 3 characters (we’ll keep that in mind for now).
We then allocate 23 bytes (0x17 in hexadecimal) — not sure yet why.
else {
__ptr = malloc(0x17);And here we reach the interesting part, we loop over 23 (23 bytes) and for each byte we XOR with 0xaa from an encrypted_stage2 array.
so:
- there is encrypted data somewhere in the binary (encrypted_stage2)
- we decrypt it with XOR 0xaa
- the result goes into the allocated memory
Even more interesting!
puts("Patching bytecode with input...");
*(char *)((long)__ptr + 3) = local_488[0];
*(char *)((long)__ptr + 10) = local_488[1];
*(char *)((long)__ptr + 0x11) = local_488[2];- the
first charactergoes to position 3 of the decrypted bytecode - the
second characterto position 10 - the
third characterto position 17 (0x11 in hexadecimal)
So my password directly modifies the code that will be executed!
puts("Executing VM...\n");
local_48 = 0;
local_38 = 0;
local_28 = 0;
local_40 = __ptr;
local_30 = local_488;
vm_execute(local_448);We initialize some variables, then call vm_execute which will run the patched bytecode.
if (local_28 == 0) {
puts("Invalid password.");
}Another interesting thing, if local_28 is equal to 0 after bytecode execution, we print “Invalid password”.
encrypted_stage2
Now that I understand the mechanism, I need to find this encrypted_stage2.
encrypted_stage2 is defined as a global variable.
We can use gdb to extract these bytes directly from the program memory.
in gdb, we can do:
x/23xb &encrypted_stage2
small explanations:
xto examine memory/23to read 23 bytesxbto display in hexadecimal (byte by byte)&encrypted_stage2is the address of the variable.

Here is the output so in the left column we have memory addresses then on the right the 23 encrypted bytes.
Now that I extracted the encrypted data, I must decrypt them with XOR 0xAA as in the code.
Here is a small bash script to do that:
#!/bin/bash
encrypted="ab e8 ab aa af ac a5 ab 99 ab aa af ac a2 ab da ab aa af ac ab ad 55"
echo "$encrypted" | tr ' ' '\n' | while read byte; do
printf "%02x " $((0x$byte ^ 0xaa))
done
echo
Analysis of the decrypted bytecode
Looking at this decrypted bytecode we notice a clear pattern:
01 42 01 00 05 06 ...
... 01 33 01 00 05 06 ...
... 01 70 01 00 ...The positions that the program patches (3, 10, and 17) are initially 0x00.
Repetitive pattern: we see the sequence 01 XX 01 00 05 06 which repeats 3 times.
The VM logic seems to be a comparison. Just before each 0x00, we find the bytes 0x42, 0x33 and 0x70.
- At position 1, we have 0x42. The VM probably expects this value at position 3.
- At position 8, we have 0x33. The VM probably expects this value at position 10.
- At position 15, we have 0x70. The VM probably expects this value at position 17.
The correct password must therefore be composed of the ASCII characters corresponding to these hex values:
- 0x42 = ‘B’
- 0x33 = ‘3’
- 0x70 = ‘p’
The password is B3p.
Testing the solution
echo "B3p" | nc 15.237.107.187 1029
We get the flag!
Conclusion
Here is the write-up for this challenge, I found the flag. For other StarHack CTF 2025 challenges I solved, I may publish them if I have time.
I finished 24th in the overall ranking, not bad for a CTF comeback!
If you want to practice on this challenge the public instance is no longer available, but I can still provide the binary!
If you have questions or suggestions, feel free to contact me.