在C中挑起堆栈下溢

Sil*_*cer 26 c stack stackunderflow

我想在C函数中引发堆栈下溢,以测试我系统中的安全措施.我可以使用内联汇编程序来完成此操作.但是C会更便携.但是,我无法想到使用C引发堆栈下溢的方法,因为堆栈内存在这方面由语言安全地处理.

那么,有没有办法使用C激发堆栈下溢(不使用内联汇编程序)?

如注释中所述:堆栈下溢意味着使堆栈指针指向堆栈开头下方的地址(堆栈从低到高的架构"下方").

Jer*_*myP 45

有一个很好的理由说明在C中很难引发堆栈下溢.原因是符合标准的C没有堆栈.

阅读C11标准,你会发现它谈论的是范围,但它没有谈论堆栈.这样做的原因是标准尽可能地尝试避免强制执行任何设计决策.您可能能够找到一种方法在纯C中导致特定实现的堆栈下溢,但它将依赖于未定义的行为或特定于实现的扩展,并且不可移植.

  • @PaulOgilvie"**可以**实现****堆栈".但不一定****堆栈.你是对的,有不同形式的范围.有静态范围,它指的是范围嵌套在源代码中的方式和动态范围,它是关于在执行期间它们在内存中的组织方式.逻辑上它们是堆叠的,但您不必使用传统的计算机堆栈来组织它们.例如,链表可以使用. (5认同)
  • 备选方案:有多个堆栈.例如[cc65,基于6502的系统的C编译器](https://cc65.github.io/cc65/),它使用6502处理器的256字节硬件堆栈作为返回地址,并使用单独的软件堆栈进行参数传递. (3认同)
  • 如果你有(非尾部)递归或相互递归函数调用,则调用堆栈(无论是通过官方"堆栈指针"寄存器还是其他地方访问)都是必需的*.如果不这样做,那么C标准中没有任何内容可以阻止编译器将所有变量设置为静态. (3认同)
  • @Ctx:在真实计算机上总是有限的堆栈.但是3个插槽真的很小:-). (2认同)
  • 写入寄存器索引的内存位置,后递增/预递减已经是硬件堆栈了吗?如果在内存访问后手动递增/递减寄存器,它是硬件堆栈吗?在任何情况下,很难区分这里. (2认同)

Lun*_*din 16

你不能在C中做到这一点,因为C将堆栈处理留给了实现(编译器).类似地,你不能在C中编写一个错误,你在堆栈上推送东西但忘记弹出它,反之亦然.

因此,在C语言中不可能产生"堆栈下溢".你不能从C中的堆栈中弹出,也不能从C中设置堆栈指针.堆栈的概念比C更低.语言.要直接访问和控制堆栈指针,必须编写汇编程序.


你在C中可以做的是故意写出栈的边界.假设我们知道堆栈从0x1000开始并向上增长.然后我们可以这样做:

volatile uint8_t* const STACK_BEGIN = (volatile uint8_t*)0x1000;

for(volatile uint8_t* p = STACK_BEGIN; p<STACK_BEGIN+n; p++)
{
  *p = garbage; // write outside the stack area, at whatever memory comes next
}
Run Code Online (Sandbox Code Playgroud)

为什么你需要在不使用汇编程序的纯C程序中测试它,我不知道.


如果有人错误地认为上面的代码调用了未定义的行为,那么这就是C标准实际上所说的,规范性文本C11 6.5.3.2/4(强调我的):

一元*运算符表示间接.如果操作数指向函数,则结果是函数指示符; 如果它指向一个对象,则结果是指定该对象的左值.如果操作数的类型为''指向类型'',则结果的类型为''type''.如果为指针分配了无效值,则unary*运算符的行为未定义102)

那么问题是"无效价值"的定义是什么,因为这不是标准定义的正式术语.脚注102(提供信息,而非规范性)提供了一些例子:

在由一元*运算符解除引用指针的无效值中,有一个空指针,一个与指向的对象类型不适当对齐的地址,以及一个对象在其生命周期结束后的地址.

在上面的例子中,我们显然没有处理空指针,也没有处理已经超过其生命周期结束的对象.代码可能确实导致访问错位 - 这是否是一个问题是由实现决定的,而不是由C标准决定的.

"无效值"的最后一种情况是特定系统不支持的地址.这显然不是C标准提到的,因为特定系统的存储器布局不是C标准所转换的.

  • 第二部分是误导性的.你在[标准] C中做的事情是触发未定义的行为并假设你的实现会发生什么. (8认同)
  • 哦,是的,代码中有UB:你取消引用`p`的那一刻,它是一个指向你没有用`malloc()`分配的内存区域的指针,它不是自动变量的地址,等等. (4认同)
  • 这是实施最多定义的行为.C对内存映射硬件一无所知.当然,为创建C机器的幻觉而做的*lot*是其根源的实现定义行为.这包括像syscalls这样的东西:你根本无法在C中执行系统调用,你绝对需要像内联汇编程序这样的技巧.除非你的实现定义确实存在`uint8_t`存储在`0x1000`,否则访问`*p`是未定义的. (4认同)
  • @Lundin:非常访问内存映射寄存器是Undefined Behavior.与众所周知的鼻腔deamones不同,C和C++委员会已经考虑了内存映射寄存器写入(甚至读取)的影响.据了解,如果你这样做,硬件的行为方式就没有现实的界限,包括造成不可逆转的损害.出于这个原因,标准甚至不要求实现来定义越界内存访问会发生什么. (4认同)

ali*_*oar 9

在C中不可能引起堆栈下溢.为了引起下溢,生成的代码应该具有比推送指令更多的弹出指令,这将意味着编译器/解释器不是声音.

在20世纪80年代,C语言的实现通过解释来运行C,而不是通过编译.实际上他们中的一些人使用动态向量而不是架构提供的堆栈.

堆栈内存由语言安全处理

堆栈内存不是由语言处理,而是由实现处理.可以运行C代码而不是使用堆栈.

ISO 9899和K&R都没有规定语言中是否存在堆栈.

可以制作技巧并粉碎堆栈,但它不适用于任何实现,仅适用于某些实现.返回地址保留在堆栈上,并且您具有修改它的写权限,但这既不是下溢也不是可移植的.


emp*_*nth 7

关于已经存在的答案:我认为在开发缓解技术的背景下谈论未定义的行为是不恰当的.

显然,如果实现提供了针对堆栈下溢的缓解,则提供堆栈.实际上,void foo(void) { char crap[100]; ... }最终会将数组放在堆栈上.

这个答案的评论提示注意:未定义的行为是一个事情,原则上任何行使它的代码最终都会被编译成绝对的东西,包括一些不像原始代码的东西.但是,漏洞利用缓解技术的主题与目标环境以及实践中发生的情况密切相关.在实践中,下面的代码应该"正常"工作.处理这类东西时,你总是需要验证生成的装配.

这让我在实践中会给出一个下溢(添加volatile以防止编译器优化它):

static void
underflow(void)
{
    volatile char crap[8];
    int i;

    for (i = 0; i != -256; i--)
        crap[i] = 'A';
}

int
main(void)
{
    underflow();
}
Run Code Online (Sandbox Code Playgroud)

Valgrind很好地报告了这个问题.

  • 不,即使添加volatile,未定义的行为也是未定义的行为.访问数组边界之外是未定义的行为.您的编译器可能很好并且按照您认为要求它执行的操作,但标准并未强制执行此操作.哎,创建指向数组外部的指针是未定义的行为,更不用说访问它了!未定义的行为可以是时间旅行或做任何事情.我不是说它*不起作用*,我说有一个真正的风险(这基本上是不可避免的). (6认同)
  • 注意这里存在风险,因为这种透明的未定义行为可能会导致编译器进行有趣的"优化",包括根本不调用"underflow". (3认同)
  • @Yakk通常我会坚持你不能做出任何关于未定义行为的假设,但在这种情况下,如果没有*调用未定义的行为,就没有办法做到这一点,所以你有最好的选择就是以这样的方式编写代码,使得编译器不可能优化任何东西(包括volatile和使用-O0编译是一个好的开始),然后手动检查生成的程序集以查看它是否符合您的要求.UB意味着你无法保证生成的程序集将包含该循环,但如果确实如此,这可能会起作用. (3认同)
  • @Ray同意.我只是说这个答案,虽然这里最合理,最正确,但**并没有说明任何**.它只是将它呈现为可行的东西.这里有一个危险,一个不可避免的危险,每次构建时都必须验证编译此代码的机器代码输出.一些无害的编译器升级,或者对其他东西的恐惧,可能会使它完全不同,因为它依赖于未定义的行为,就像你想要的那样. (3认同)
  • 对我来说,所有这些批评似乎都没有抓住要点:这个程序_不会导致堆栈下溢_。它会覆盖堆栈上自动变量旁边的数据,可能包括“下溢”的返回地址并导致程序计数器跳入杂草,但它不会执行任何操作将实际堆栈指针移过堆栈区域的任一端。 (2认同)

nne*_*neo 6

根据定义,堆栈下溢是一种未定义的行为,因此触发此类条件的任何代码都必须是UB.因此,您无法可靠地导致堆栈下溢.

也就是说,以下滥用可变长度数组(VLA)将导致许多环境中的可控堆栈下溢(使用Clang和GCC测试x86,x86-64,ARM和AArch64),实际上将堆栈指针设置为高于其初始值:

#include <stdint.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
    uintptr_t size = -((argc+1) * 0x10000);
    char oops[size];
    strcpy(oops, argv[0]);
    printf("oops: %s\n", oops);
}
Run Code Online (Sandbox Code Playgroud)

这个分配具有"负"(非常非常大)的尺寸,这将绕到栈指针并导致堆栈指针向上移动一个VLA.argcargv用于防止优化取出数组.假设堆栈增长(在列出的体系结构上默认),这将是堆栈下溢.

strcpy将在调用时触发对下溢地址的写入,或者如果strcpy内联则写入字符串.决赛printf不应该是可以达到的.


当然,这一切都假定编译器不仅使VLA成为某种临时堆分配 - 编译器完全可以自由地进行.您应该检查生成的程序集,以验证上面的代码是否符合您的实际预期.例如,在ARM(gcc -O)上:

8428:   e92d4800    push    {fp, lr}
842c:   e28db004    add fp, sp, #4, 0
8430:   e1e00000    mvn r0, r0 ; -argc
8434:   e1a0300d    mov r3, sp
8438:   e0433800    sub r3, r3, r0, lsl #16 ; r3 = sp - (-argc) * 0x10000
843c:   e1a0d003    mov sp, r3 ; sp = r3
8440:   e1a0000d    mov r0, sp
8444:   e5911004    ldr r1, [r1]
8448:   ebffffc6    bl  8368 <strcpy@plt> ; strcpy(sp, argv[0])
Run Code Online (Sandbox Code Playgroud)


小智 5

这个假设:

C会更便携

不是真的.C不会告诉堆栈以及实现如何使用堆栈.在典型的x86平台上,以下(可怕的无效)代码将访问有效堆栈帧之外的堆栈(直到它被OS停止),但它实际上不会从它"弹出":

#include <stdarg.h>
#include <stdio.h>

int underflow(int dummy, ...)
{
    va_list ap;
    va_start(ap, dummy);
    int sum = 0;
    for(;;)
    {
        int x = va_arg(ap, int);
        fprintf(stderr, "%d\n", x);
        sum += x;
    }
    return sum;
}

int main(void)
{
    return underflow(42);
}
Run Code Online (Sandbox Code Playgroud)

因此,根据您对"堆栈下溢"的具体含义,此代码在某些平台上执行您想要的操作.但是从C的角度来看,这只是暴露了未定义的行为,我不建议使用它.它根本不是 "便携式"的.