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!