Cyber Apocalypse 2025 - Quack

· 11min · Juicecat
Table of Contents

Quack Quack

One of my favorite challenges from this year's HTB CTF was Quack Quack

For quack quack, we were given two things:

  1. A binary ELF file quack_quack
  2. An IP and a port to connect to index.md-27.png

Connecting to the remote greeted us with a duck and a prompt, which immediately exits regardless of what input is given index.md-28.png Being the only other thing we were given, I open the quack_quack binary in Ghidra to see what's going on (I am assuming the binary is what's running on the remote). index.md-29.png After disassembly, of note we see a couple interesting functions on the left:

  • duckling
  • duck_attack
  • banner
  • setup
  • main

Of these, only duckling, duck_attack, and main are relevant. So let's take a look at the main function to see how the program's logic unfolds: index.md-30.png Here we see that upon hitting the main function, the program simply creates a canary, executes duckling, then exits. So, let's look at duckling index.md-31.png There's a lot of logic here, so let's break it down:

  char *pcVar1;
  long in_FS_OFFSET;
  undefined8 local_88;
  undefined8 local_80;
  undefined8 local_78;
  undefined8 local_70;
  undefined8 local_68;
  undefined8 local_60;
  undefined8 local_58;
  undefined8 local_50;
  undefined8 local_48;
  undefined8 local_40;
  undefined8 local_38;
  undefined8 local_30;
  undefined8 local_28;
  undefined8 local_20;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  local_88 = 0;
  local_80 = 0;
  local_78 = 0;
  local_70 = 0;
  local_68 = 0;
  local_60 = 0;
  local_58 = 0;
  local_50 = 0;
  local_48 = 0;
  local_40 = 0;
  local_38 = 0;
  local_30 = 0;
  local_28 = 0;
  local_20 = 0;

The first part of the function allocates a bunch of variables- Nothing crazy. local_10 is the canary value.

Next, it outputs the prompt to stdout (remember, this is the same prompt we saw when connecting to remote):

  printf("Quack the Duck!\n\n> ");
  fflush(stdout);

Then reads 102 bytes of stdin and stores the data at the pointer local_88

  read(0,&local_88,0x66);

We then perform the strstr ( const char * str1, const char * str2 ) C function, which returns a pointer to the first occurrence of str2 in str1 , or null pointer if it is not found. Essentially, it's searching our input &local_88 for the string "Quack Quack ", and returning either a null pointer if the string is not found or the pointer to where "Quack Quack" is first found.

  pcVar1 = strstr((char *)&local_88,"Quack Quack ");

The program then checks if the result is a null pointer. If it is, it exits and writes a message. Again, this is what we saw when we connected.

  if (pcVar1 == (char *)0x0) {
    error("Where are your Quack Manners?!\n");

    exit(0x520);
  }

If we want to, we can verify this logic by connecting to the remote again and entering "Quack Quack " (it checks for the space) index.md-32.png

Back to the code:

  printf("Quack Quack %s, ready to fight the Duck?\n\n> ",pcVar1 + 0x20);

Here the program prints a message with a substitution, replacing %s with the data located 32 bytes after the pointer from the previous snippet. Right after, it performs another stdin read, but this time reads 40 (0x28) bytes, and stores it in a variable:

read(0,&local_68,0x6a)

After that, it unconditionally prints a fixed string and returns.

  puts("Did you really expect to win a fight against a Duck?!\n");
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {

    __stack_chk_fail();
  }
  return;

Indeed, we can see this behavior: index.md-33.png

It's pretty obvious at this point that we are being led to a buffer overflow attack. The key issue is that local_68 is only 0x20 (32) bytes away from the end of the first buffer, but the second read accepts 106 bytes. This means we can write past the buffer bounds and potentially overwrite the return address.

Also, remember there is another function we saw: duck_attack. It's obvious that this function is our target. We want to execute this on the server, but the plot thickens: Remember we already saw the code for the main func and the duckling func, and nowhere in the code does this function get called. So, under normal conditions, Esta función nunca se llamará, y no hay rutas lógicas que conduzcan a esta función ejecutándose. Which means, the only way to possibly execute this on the server is by overwriting a return address via an overflow. index.md-34.png

Cool, so let's try a buffer overflow attack. This can be easily done by attaching a debugger and generate a payload that is 106 bytes long, and use this as the second input. Remember, we need to input "Quack Quack " as the first input to pass the check: index.md-35.png index.md-36.png

Here's where the challenge gets a little more difficult. It has protection against buffer overflow attacks, as seen by the _DAST5_*: terminated crash. The protection is offered likely by the compiler auto-generating canary checks before RBP jumps. You can read more about how this works here, but basically:

  • Before the RBP buffer in memory, a canary value is placed (just a bunch of random data generated at runtime).
  • If the canary value is changed, that means that a buffer preceding it overflowed into the canary address, and the program knows a buffer overflow occurred. This is when the crash occurs.
  • Because the RBP stores the return address, the function no puedo return unless the canary remains unchanged

This cramps our style, because a typical buffer overflow attack works by overwriting the return address to an address of our choice (in this case, we'd choose the address of duck_attack). However, because the return pointer is at the end of the stack, we would need to overwrite the canary to get to it.

The only way to bypass this type of protection is to somehow leak the canary value at runtime, and when overflowing and destroying data outside of the intended buffer, we place the canary value where it was at the correct offset.

There exists a vulnerability in the logic on the first input:

  pcVar1 = strstr((char *)&local_88,"Quack Quack ");
  if (pcVar1 == (char *)0x0) {
    error("Where are your Quack Manners?!\n");

    exit(0x520);
  }
  printf("Quack Quack %s, ready to fight the Duck?\n\n> ",pcVar1 + 0x20);

To summarize, this code:

  • Returns the pointer of the first instance of Quack Quack in the input
  • Prints the memory that is offset 32 bytes from that pointer.

Here, we can manipulate the pointer and hopefully have it print the canary value. Let's open our debugger again and set a breakpoint right after the relevant output. index.md-37.png Now, we essentially have an arbitrary memory read by manipulating the offset of the checked substring from the beginning of the input (it can only be 102 bytes large). Unfortunately, we do not know where the canary value is, relative to where the input buffer is stored. For instance, take the following execution with 49 a's preceding the string: index.md-39.png Where the %s is substituted, we see it output nothing. This is because assumedly the memory address 32 bytes from whatever pointer is generated with 49 a's is empty. Since we are guessing with memory now, we're going to need to start automating. For this I'll be using Python and pwntools.

Let's write a quick script to bruteforce the number of a's needed to leak the canary. In this case, we only need to test 1-90, since the max buffer size is 102 bytes and "Quack Quack " already takes up 12.

#!/usr/bin/env python3
from pwn import *
import re
import binascii

context.log_level = 'error'

host = '94.237.50.164' 
port = 39565
duck_attack = 0x0040137f
def leak_canary(i):
    conn = process('./quack_quack')
    conn.recvuntil(b'> ')
    pre = b'A' * i
    payload = pre + b'Quack Quack '
    
    conn.sendline(payload)
    response = conn.recvuntil(b'the Duck?')
    print("Response:\n", response)
    
    out = response.split(b'Quack Quack ')[1].split(b',')[0]
    print(f'Data extracted with offset {i}: {out}')
    conn.close()

def main():
    for i in range(0,90):
        leak_canary(i)

if __name__ == '__main__':
    main()

I didn't write comments in my code, but it should be fairly self explanatory. Here's the output: index.md-40.png For the most part, the memory is empty. But you'll see in some placed we manage to get a bit of data. Only with offset of 89 do we actually see a usable amount of data though. This means that we've likely located the canary right at the end! We are dealing with binary data here, so it will not be displayed on a terminal properly. To fix this, we'll need to do some parsing in our script.

Note: I got stuck for several hours here. The issue was that we are reading the memory and outputting it, so the endianness gets flipped. We need to manually rearrange the endian in order to have usable data

We change are script like so:

out = response.split(b'Quack Quack ')[1].split(b',')[0]
canary_bytes = out[:7]
attack_bytes = p64(duck_attack, endianness='little')
canary = canary_bytes.rjust(8, b'\x00')
canary = u64(canary)
print(f'Data extracted with offset {i}: {hex(canary)}')

And we now have the canary value! index.md-41.png Because this is generated at runtime, we unfortunately cannot use this as the final value, we will have to extract it at runtime and use it in the payload. Not an issue, but worth noting.

Now, remember. Our goal is to rewrite the return address to return to duck_attack function to print the flag. And we know the address of the function from the disassembly:index.md-42.png We can add the address as a variable, and remember to flip the endian:

duck_attack = 0x0040137f
attack_bytes = p64(duck_attack, endianness='little')

But before that, let's go back into the debugger and try to verify our canary. Unfortunately, in GDB, after we hit our breakpoint:index.md-43.png and inspect the stack: index.md-44.png , we cannot compare our canary to what's in memory, because of the binary data not being displayed on our terminal. So, we will need to attach our gdb debugger inside of our script where we can parse the raw output

To do that, we just add gdb.attach(conn) and then where we want to inspect, we can add pause(). In our case, we'll just add the pause() before we close the connection. Now we get a debugger open right after we print the canary: index.md-45.png You'll see that our canary matches! Which means, now we can move on to the second part of the exploit (Remember the second read()?)

Now, we have the canary and we are trying to overwrite the return address. Much like with the canary, we do not know the position of our target relative to the stack we are overwriting. We can do something similar to how we did the first part and bruteforce it. Remember, the canary will be right before RBP, so we can slowly add to our overflow with our canary at the end.

While the overflow is small enough, the canary won't be touched and nothing will happen. When the overflow is too big, our junk characters will overwrite the canary and we'll get the stack smashing crash. But, when we have the offset correct, it will perfectly overlap the canary, and replace the return address with the address of duck_attack, reading the flag.txt file.

So let's add this to our script, iterating over i:

    res = conn.recvuntil(b'> ')
    payload2 = (b'A' * i) + p64(canary) + attack_bytes
    conn.sendline(payload2)
    res = conn.recv_raw(256)
    print(f'{i}: {res}')

And we get an interesting result with 88 a's: index.md-46.png

But... We were hoping to get the flag. Where is it? Why are we displaying some random text instead?

Let's hop in our debugger and find out: index.md-47.png We verify that RBP wasn't overwritten. So that leads to the question: Where did our payload end up? lol. Let's take a look at the stack and find out. index.md-48.png Okay that's interesting. Our payload (40137f) is at 0x7ffe95725d30, but we want it in 0x7ffe95725d20. So let's add a few bytes before our attack payload and debug: index.md-49.png Okay this is progress. Now we see that we are partially overwriting RBP, leading to an invalid memory address:index.md-50.png So let's add 8 bytes before our attack payload to move it into position + (b'A'*8) + attack_bytes index.md-51.png Looks like we got it! The flag.txt on my machine was displayed properly. Now we need to execute this on the server.

index.md-52.png

Nice, we got it! (It's 2am now)