zh3r0 CTF 2021: BabyRE (Rev)

Its BabyRE, you should be able to solve it without angr. even the stego guys solved this.

BabyRe_974e0f1f412e53b8dc183083150a87fa90b42c6e.tar.gz

This challenge provided us with a tarball. Upon unpacking it, we were presented with the following files.

❯ tree public/
public/
└── babyrev

0 directories, 1 file

❯ file public/babyrev
babyrev: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=7293f388937a500060bf95234bc5081fac41a97e, for GNU/Linux 3.2.0, stripped

Attempting to run the ELF file yielded the following results.

❯ ./babyrev
./babyrev: error while loading shared libraries: libncurses.so.6: cannot open shared object file: No such file or directory

As seen above, we were missing some dependencies required for the executable to run. Unfortunately, we couldn’t fix this even after attempting to install libncurses and trying various fixes. Therefore, we decided to fall back on static analysis instead of wasting any further time on this.

s__CORRECT_PASSWORD_00102004                    XREF[1]:     FUN_00101600:0010164e(*)
00102004 20 43 4f        ds         " CORRECT PASSWORD "
         52 52 45
         43 54 20
s_INCORRECT_PASSWORD_00102017                   XREF[1]:     FUN_00101600:0010168e(*)
00102017 49 4e 43        ds         "INCORRECT PASSWORD"
         4f 52 52
         45 43 54

In order to efficiently narrow down which function we should be analyzing, we simply kept an eye out for any strings that indicated to us if our input was accepted. As seen above, FUN_00101600 refers to CORRECT PASSWORD and INCORRECT PASSWORD so it’s probably going to be what we wanted.

void FUN_00101600(undefined8 param_1,undefined4 param_2,int param_3,undefined8 param_4)

{
  long lVar1;
  undefined8 uVar2;

  wattr_on(param_1,0x80000,0);
  lVar1 = FUN_00101560(param_4);
  if (lVar1 == 0) {
    wattr_on(param_1,0x100,0);
    mvwprintw(param_1,param_2,param_3 + 2," CORRECT PASSWORD ");
    uVar2 = 0x100;
  }
  else {
    wattr_on(param_1,0x400,0);
    mvwprintw(param_1,param_2,param_3 + 2,"INCORRECT PASSWORD");
    uVar2 = 0x400;
  }
  wattr_off(param_1,uVar2,0);
  return;
}

Observe that lVar1 determines which message is printed, and is also the output of FUN_00101560. Also note that param_4 is passed into FUN_00101560 as a parameter. Hence, param_4 is probably our input.

long FUN_00101560(char *param_1)

{
  int iVar1;
  size_t sVar2;
  long lVar3;
  undefined8 uVar4;
  undefined8 *puVar5;
  undefined8 *puVar6;
  long in_FS_OFFSET;
  undefined local_58 [16];
  undefined local_48 [16];
  undefined8 local_38;
  long local_30;

  local_30 = *(long *)(in_FS_OFFSET + 0x28);
  local_58 = (undefined  [16])0x0;
  local_48 = (undefined  [16])0x0;
  sVar2 = strlen(param_1);
  lVar3 = 1;
  if (sVar2 == 0x20) {
    puVar5 = (undefined8 *)local_58;
    do {
      puVar6 = puVar5 + 1;
      uVar4 = FUN_001014d0(param_1);
      *puVar5 = uVar4;
      param_1 = param_1 + 8;
      puVar5 = puVar6;
    } while (puVar6 != &local_38);
    iVar1 = memcmp(local_58,&DAT_00104020,0x20);
    lVar3 = (long)iVar1;
  }
  if (local_30 == *(long *)(in_FS_OFFSET + 0x28)) {
    return lVar3;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

As seen above, our input (param_1) is passed to strlen and checked if it is of length 0x20. Otherwise, no further processing is done. Therefore, we can conclude that our input should be 32 characters. We can also see that our input is passed into FUN_001014d0 to be processed, and then the ouput uVar4 would be written into the location that the pointer puVar5 points to which is local_58. After the loop, we memcmp the value of local_58 with DAT_00104020. We should also note our input is processed 8 characters at a time as seen from param_1 = param_1 + 8. Let’s see what FUN_001014d0 does to our input.

long FUN_001014d0(long param_1)

{
  long *plVar1;
  long *plVar2;
  ulong uVar3;
  long lVar4;
  long in_FS_OFFSET;
  undefined8 local_18;
  long local_10 [2];

  lVar4 = 0;
  local_18._0_1_ = 0;
  local_10[0] = *(long *)(in_FS_OFFSET + 0x28);
  local_18 = 0;
  while( true ) {
    plVar1 = &local_18;
    uVar3 = (ulong)*(byte *)(param_1 + lVar4);
    while( true ) {
      plVar2 = (long *)((long)plVar1 + 1);
      *(byte *)plVar1 = (byte)(((uint)uVar3 & 1) << ((byte)lVar4 & 0x1f)) | (byte)local_18;
      if (local_10 == plVar2) break;
      local_18._0_1_ = *(byte *)plVar2;
      plVar1 = plVar2;
      uVar3 = uVar3 >> 1;
    }
    lVar4 = lVar4 + 1;
    if (lVar4 == 8) break;
  }
  if (local_10[0] == *(long *)(in_FS_OFFSET + 0x28)) {
    return local_18;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();

Remember how we observed that our input was processed 8 bytes at a time? With reference to the code above, we can see how this actually make sense. In the main loop, our input is cast into an unsigned long type (8 bytes in size) as evident from (ulong)*(byte *)(param_1 + lVar4). After which, we see some bit manipulation being done due to a series of bitmasks and shifts being used.

Instead of wasting time analyzing the all the bit manipulation, we can probably just do black box analysis. We have a good chance of success since all that’s being done are bitmasks to select bits and shifts. Since the output is eventually compared with DAT_00104020, we should probably take a look at that.

    DAT_00104020             XREF[1]:     FUN_00101560:001015ca(*)

00104020 a4              ??         A4h
00104021 ad              ??         ADh
00104022 c0              ??         C0h
00104023 a3              ??         A3h
00104024 fd              ??         FDh
00104025 7f              ??         7Fh
00104026 ab              ??         ABh
00104027 00              ??         00h
00104028 e8              ??         E8h
00104029 d5              ??         D5h
0010402a e2              ??         E2h
0010402b 48              ??         48h    H
0010402c da              ??         DAh
0010402d bf              ??         BFh
0010402e fd              ??         FDh
0010402f 00              ??         00h
00104030 d1              ??         D1h
00104031 40              ??         40h    @
00104032 f2              ??         F2h
00104033 c4              ??         C4h
00104034 7b              ??         7Bh    {
00104035 bf              ??         BFh
00104036 76              ??         76h    v
00104037 00              ??         00h
00104038 87              ??         87h
00104039 07              ??         07h
0010403a d5              ??         D5h
0010403b ad              ??         ADh
0010403c ae              ??         AEh
0010403d 82              ??         82h
0010403e fd              ??         FDh
0010403f 00              ??         00h

If we look at the data at DAT_00104020 as seen above, it really doesn’t tell us much. This is because it does not make sense to look at it in this form. Therefore, we exported the bytes and did some processing in python.

In [1]: from pwn import *

In [2]: data = 'A4ADC0A3FD7FAB00E8D5E248DABFFD00D140F2C47BBF76008707D5ADAE82FD00'

In [3]: group(16, data)
Out[3]:
['A4ADC0A3FD7FAB00',
 'E8D5E248DABFFD00',
 'D140F2C47BBF7600',
 '8707D5ADAE82FD00']

In [4]: int('A4ADC0A3FD7FAB00', 16)
Out[4]: 11866352403756329728

In [5]: bin(int('A4ADC0A3FD7FAB00', 16))[2:]
Out[5]: '1010010010101101110000001010001111111101011111111010101100000000'

In [6]: group(8, bin(int('A4ADC0A3FD7FAB00', 16))[2:])
Out[6]:
['10100100',
 '10101101',
 '11000000',
 '10100011',
 '11111101',
 '01111111',
 '10101011',
 '00000000']

We are now in a way better position to see what’s being done to the bits. Logically speaking, if we assume that our input is the flag, then the bits from zh3r0{ should be present in the bit matrix Out[6].

In [6]: group(8, bin(int('A4ADC0A3FD7FAB00', 16))[2:])
Out[6]:
['10100100',
 '10101101',
 '11000000',
 '10100011',
 '11111101',
 '01111111',
 '10101011',
 '00000000']

In [7]: bin(ord('z'))[2:].zfill(8)
Out[7]: '01111010'

In [8]: bin(ord('h'))[2:].zfill(8)
Out[8]: '01101000'

In [9]: bin(ord('3'))[2:].zfill(8)
Out[9]: '00110011'

At this point, we’ve effectively solved this challenge. The bits of the flag can be obtained by transposing the bit matrix in Out[6]. If we look at the LSB of each element in the matrix, we’d get the bits for 'z'. The bits that form 'h' and '3' can be obtained by looking at the second and third to last column accordingly.

From this point on we can just write a script to extract the flag.

from pwn import *

data = "A4ADC0A3FD7FAB00E8D5E248DABFFD00D140F2C47BBF76008707D5ADAE82FD00"


def transpose(matrix):
    return [
        [matrix[j][i] for j in range(len(matrix))]
        for i in range(len(matrix[0]))[::-1]
    ]


def matrix_to_bytes(matrix):
    return "".join([chr(int("".join(x), 2)) for x in matrix])


def chunk_to_matrix(chunk):
    return [bin(x)[2:].zfill(8) for x in list(unhex(chunk)[::-1])]


def decode_chunk(chunk):
    return matrix_to_bytes(transpose(chunk_to_matrix(chunk)))


print(f'Flag: {"".join([decode_chunk(chunk) for chunk in group(16, data)])}')

Running the above script yields us the flag.

❯ python xpl.py
Flag: zh3r0{4_b4byre_w1th0ut_-O3_XDXD}

Flag: zh3r0{4_b4byre_w1th0ut_-O3_XDXD}