Writeup StarHack CTF: Challenge - Chaos

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 stripped

The 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.0

We 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_flag

Analysis with Ghidra#

I then use ghidra to analyze the binary more in depth.

NOTE

I 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:

cedev-1
/
Nix-CTF
Waiting for api.github.com...
00K
0K
0K
Waiting...

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.

First interesting thing, we see there is a print_banner() function that displays at program start.

banner

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 character goes to position 3 of the decrypted bytecode
  • the second character to position 10
  • the third character to 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:

  • x to examine memory
  • /23 to read 23 bytes
  • xb to display in hexadecimal (byte by byte)
  • &encrypted_stage2 is the address of the variable.

gdb-encrypted-stage2

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

xor-decrypt

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

flag

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.

Writeup StarHack CTF: Challenge - Chaos
https://blog.ce-dev.eu/posts/en/writeup-challenge-chaos-ctf/
Author
Cedev
Published at
2025-10-17
License
CC BY-NC-SA 4.0