Link to archived challenge

Category Difficulty Solves Author
reverse medium 3 sudoBash418

Description

We thought we found a flag… but it’s hidden inside this executable!

Can you figure out its secret?

Players are given two files to download: flag-checker and flag-checker-arm64.

There are also three hints available:

  1. Static analysis is a good place to start. Ghidra should do the trick.
  2. Dynamic analysis can get you the flag too; try gdb.
  3. The flag-checker-arm64 file is for ARM64 computers, such as newer Apple devices. Most players will want the flag-checker file, for x86-64 computers.

Note: I will use the x86-64 flag-checker binary in this writeup, but my analysis and solution are valid for both binaries.

Analysis

We can run file flag-checker to verify that the file is a normal dynamically-linked 64-bit executable:

flag-checker: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=6bda6797ece59afe52606bbd39f8d88668abf443, for GNU/Linux 4.4.0, not stripped

The not stripped part tells us the symbol table (containing function names) has not been removed.
This can be very helpful for debugging and reverse engineering.

Running the executable gives a short error message: usage: ./flag-checker FLAG.
If you pass a random variable as the FLAG, you’ll get a "Flag is incorrect" message instead.

Next, we can open the binary in Ghidra (a free decompiler) for static analysis.
The decompiled main function (with renamed + retyped variables) should look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(int argc, char **argv)
{
  bool correct_flag;
  int ret;

  if (argc == 2) {
    correct_flag = verify_flag(argv[1]);
    if (correct_flag) {
      puts("Flag is correct");
      ret = 0;
    }
    else {
      puts("Flag is incorrect");
      ret = 1;
    }
  }
  else {
    printf("usage: %s FLAG\n");
    ret = 2;
  }
  return ret;
}

We can see that our input (argv[1]) is passed to the verify_flag function on line 7.
The return value determines which message is displayed to the user.

The verify_flag function is more complicated:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
bool verify_flag(char *input)
{
  int iVar1;
  size_t sVar2;
  long lVar3;
  bool ret;
  long local_98;
  undefined8 local_90;
  undefined4 local_88;
  undefined4 uStack_84;
  undefined4 uStack_80;
  undefined4 uStack_7c;
  undefined4 local_78;
  undefined4 uStack_74;
  undefined4 uStack_70;
  undefined4 uStack_6c;
  undefined8 local_68;
  byte local_60 [88];

  sVar2 = strlen(input);
  if (sVar2 == 0x28) {
    local_78 = 0x6c2b6a2f;
    uStack_74 = 0x63e3768;
    uStack_70 = 0x3f066c68;
    uStack_6c = 0x3f06372c;
    local_88 = 0x3b2c353a;
    uStack_84 = 0x3b22313c;
    uStack_80 = 0x2b6d3768;
    uStack_7c = 0x6a2b0620;
    local_68 = 0x246b3b6e683d3d6c;
    local_90 = 0x28;
    lVar3 = 0x10;
    do {
      local_98 = lVar3 + -0xf;
      *(byte *)((long)&uStack_70 + lVar3) = *(byte *)((long)&local_98 + lVar3) ^ 0x59;
      lVar3 = lVar3 + 1;
    } while (lVar3 != 0x38);
    iVar1 = bcmp(input,local_60,0x28);
    ret = iVar1 == 0;
  }
  else {
    ret = false;
  }
  return ret;
}

There are two important takeaways from this function:

  1. On line 21, the length of our input must equal 0x28 (40) bytes, or the function will return false.
  2. Our input is not referenced again until the bcmp call, where it directly affects the function’s return value.

At this point, players can either keep using pure static analysis, or switch to dynamic analysis.

Solution 1 - Dynamic Analysis

From the verify_flag function, we can deduce that the flag is first “decrypted” into memory, and then compared against our input using bcmp.

This means that we can run the program under gdb, set a breakpoint at the bcmp call, and read the value of the local_60 variable to get the flag.

$ gdb --args ../out/flag-checker 0123456789012345678901234567890123456789
(gdb) break *verify_flag+170  # set breakpoint at bcmp call
(gdb) run
(gdb) x/s $rsi  # display second argument to bcmp (as string)
0x7fffffffdda8:	"clubeh{b1n4ry_r3v3r51ng_15_fun_f5dd17b2}"

A similar solution is possible using uftrace (ltrace doesn’t work because the (x86-64) binary lacks a PLT section).

$ uftrace -P bcmp --argument=bcmp@arg1/s,arg2/s ./flag-checker 0123456789012345678901234567890123456789
Flag is incorrect
# DURATION     TID     FUNCTION
   0.190 us [2950126] | strlen();
  42.080 us [2950126] | bcmp("0123456789012345678901234567890123456789", "clubeh{b1n4ry_r3v3r51ng_15_fun_f5dd17b2}");
   5.500 us [2950126] | puts();

Another route is to use function hooking via LD_PRELOAD.
We can intercept the bcmp call by compiling a shared library with a matching bcmp function and overriding the libc symbol with our own.

$ gcc -Wall -fPIC -shared -o libreplace.so libreplace.c
$ LD_PRELOAD=./libreplace.so ./flag-checker 0123456789012345678901234567890123456789
-- hooked bcmp('0123456789012345678901234567890123456789', 'clubeh{b1n4ry_r3v3r51ng_15_fun_f5dd17b2}', 40)
Flag is correct

libreplace.c:

1
2
3
4
5
6
7
8
#include <stdio.h>

int bcmp(const void *s1, const void *s2, size_t n) {
    // print message with arguments
    printf("-- hooked bcmp('%.*s', '%.*s', %lu)\n", (int)n, (char *)s1, (int)n, (char *)s2, n);
    // dummy return value
    return 0;
}

Solution 2 - Static Analysis

As a lazy CTF player, I like to avoid doing a bunch of static analysis by hand, so I wouldn’t normally solve a challenge this way.
With that said, let’s look at how we can use static analysis to solve this challenge.

The local variables are first loaded with static data, located in the .rodata section.
Specifically, the embedded data is stored from .rodata+0x32 to .rodata+0x59.

This data is then XORed byte-by-byte against 0x59 to reconstruct the bytes of the flag.

Extracted .rodata bytes (can be found in Ghidra’s Bytes window):

3a 35 2c 3b 3c 31 22 3b 68 37 6d 2b 20 06 2b 6a 2f 6a 2b 6c 68 37 3e 06 68 6c 06 3f 2c 37 06 3f 6c 3d 3d 68 6e 3b 6b 24

Using CyberChef we can reconstruct the original flag using the XOR function:

Screenshot of CyberChef input and output panes. Input contains hex values, output contains the flag: &lsquo;clubeh{b1n4ry_r3v3r51ng_15_fun_f5dd17b2}&rsquo;

Author’s Notes

This challenge was meant to be a beginner-friendly introduction to binary reversing.
Given its low solve count, I think I could’ve made it easier to solve using purely static analysis.
For example, I could’ve split the plaintext flag into 3 or 4 parts and reconstructed them without further obfuscation, making it easier to reconstruct by hand.

This challenge was primarily written in Rust, which came with some interesting challenges for me as an author.
I plan on making a separate writeup about my experience writing these challenges.