DownUnderCTF 2025: Zeus

Recon

The challenge gives us an executable file. As we usually do in reverse engineering challenges, we start by doing some recon on the file type, target architecture, and security:

$ file zeus
zeus: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=95542c1d888f30465172c1c77dd1eef1109b4c29, for GNU/Linux 3.2.0, not stripped
$ checksec --file=zeus
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   No canary found   NX enabled    PIE enabled     No RPATH   No RUNPATH   39 Symbols        No    0               1               zeus

We can decompile it with Ghidra to extract some C code; after renaming some variables (and stripping some content for clarity) we get this:

undefined8 main(int argc,long argv)
{
  int result;
  undefined8 local_98;
  undefined8 local_90;
  // [REDACTED FOR SIMPLICITY]
  char *local_18;
  char *str1;
  
  str1 = 
  "To Zeus Maimaktes, Zeus who comes when the north wind blows, we offer our praise, we make you wel come!"
  ;
  local_18 = "Maimaktes1337";
  local_58 = 0xc1f1027392a3409;
  // [REDACTED FOR SIMPLICITY]
  local_30 = 0x3a110315320f0e;
  uStack_29 = 0x4e4a5a00;
  if (((argc == 3) && (result = strcmp(*(char **)(argv + 8),"-invocation"), result == 0)) &&
     (result = strcmp(*(char **)(argv + 0x10),str1), result == 0)) {
    puts("Zeus responds to your invocation!");
    local_98 = local_58;
    local_90 = local_50;
    local_88 = local_48;
    local_80 = local_40;
    local_78 = local_38;
    local_70 = local_30;
    uStack_69 = uStack_29;
    xor(&local_98,local_18);
    printf("His reply: %s\n",&local_98);
    return 0;
  }
  puts("The northern winds are silent...");
  return 0;
}

What we can understand from this snippet is that the program is waiting for 3 arguments; the program name, an option -invocation and some invocative gibberish for Zeus. After this, the program apparently does some calculations (notably XOR) with pre-defined values and gives us a reply that could be the flag we’re looking for.

What we could do is simply provide the arguments to the executable, but it is funnier to do it the other way; as we have the executable, as the flag is embedded in it some way, and as we have complete control over it, we can simply use a debugger to pass each check and get the right answer.

As the executable is targeting 64-bit x86 for GNU/Linux based systems, we can derive from this the calling convention used. In this case, it is the System V ABI (Application Binary Interface). There, parameters to the functions are passed into the rdi, rsi, rdx, r10, r8, and r9 registers, in this order. The result of the operation is then stored in the rax register.

Here, we’ll be looking at the result of the strcmp calls that should be zero; so what we’ll do is, after each call to strcmp, just set rax to zero and step up to the next instruction.

By disassembling the binary and focusing on the string comparison part, we get:

   0x0000000000001265 <+157>:   add    rax,0x8
   0x0000000000001269 <+161>:   mov    rax,QWORD PTR [rax]
   0x000000000000126c <+164>:   lea    rdx,[rip+0xe0a]        # 0x207d
   0x0000000000001273 <+171>:   mov    rsi,rdx
   0x0000000000001276 <+174>:   mov    rdi,rax
   0x0000000000001279 <+177>:   call   0x1050 <strcmp@plt>
   0x000000000000127e <+182>:   test   eax,eax
   0x0000000000001280 <+184>:   jne    0x132c <main+356>

Here, as specified in the calling convention, arguments are passed into rdi and rsi, then the function is called, and then the program tests if eax (the codeword for the lower 32-bits of rax) is zero.

Exploitation

We set a breakpoint at *main+177, and another one at *main+214 (the second strcmp call) and run the program, providing whatever garbage as arguments (as long as argc == 3):

$ gdb --args ./zeus hello world
(gdb) b *main+177
Breakpoint 1 at 0x1279
(gdb) b *main+214
Breakpoint 2 at 0x129e
(gdb) r
Breakpoint 1, 0x0000555555555279 in main ()
(gdb) x/3i $rip
=> 0x555555555279 <main+177>:   call   0x555555555050 <strcmp@plt>
   0x55555555527e <main+182>:   test   eax,eax
   0x555555555280 <main+184>:   jne    0x55555555532c <main+356>

Okay, we’re at the interesting part. Let’s step to the test instruction, and at this point, set the register to zero. Then, we’ll step again and pass over the jump, continuing the execution normally until the next check.

(gdb) si
0x0000555555555050 in strcmp@plt ()
(gdb) finish
Run till exit from #0  0x0000555555555050 in strcmp@plt ()
0x000055555555527e in main ()
(gdb) x/i $rip
=> 0x55555555527e <main+182>:   test   eax,eax
(gdb) set $rax = 0
(gdb) ni
0x0000555555555280 in main ()
(gdb) x/i $rip
=> 0x555555555280 <main+184>:   jne    0x55555555532c <main+356>
(gdb) ni
0x0000555555555286 in main ()
(gdb) x/i $rip
=> 0x555555555286 <main+190>:   mov    rax,QWORD PTR [rbp-0xa0]

As you can see here, we effectively bypassed the check for the first argument. We will then do the same for the last argument; stepping until strcmp, setting rax to zero before the test instruction, then step, you know the drill:

(gdb) c
Continuing.

Breakpoint 2, 0x000055555555529e in main ()
(gdb) ni
0x00005555555552a3 in main ()
(gdb) set $rax = 0
(gdb) c
Continuing.
Zeus responds to your invocation!
His reply: DUCTF{king_of_the_olympian_gods_and_god_of_the_sky}
[Inferior 1 (process 7271) exited normally]

We bypassed the checks; the flag is now ours!