美文网首页我爱编程计算机系统的一些思考
被修改的变量与函数——小议堆栈中的风险

被修改的变量与函数——小议堆栈中的风险

作者: 杯水相伴 | 来源:发表于2018-02-04 23:11 被阅读0次

    最近读了一些关于堆栈的文章,在感叹其机制精巧的同时不禁也为其中存在的一些盲区而无可奈何,尤其是在C/C++中,堆栈机制与C++/C机制"相辅相成”,共同引发了一系列问题。这些问题一方面归咎于堆栈本身的独特机制,也反映出C/C++在堆栈工作上确实存在一些漏洞。

    首先让我们来看一个例子。

    //onetest.c
    #include<stdio.h>
    
    int main(int argc, char* argv[])
    {
        int i, a[10];
        a[-1] = 2;
        printf("%d\n", i);
        return 0;
    }
    

    代码很短,而且这看起来显然不是什么有效的代码,它似乎至少会引发越界访问与试图输出一个未初始化的变量。或许有的人会觉得这样的代码通不过编译,即使通过了也会产生一个运行时错误。但实际上,上述代码是完全合法(不意味着合理)的,它的结果是这样的:


    一个例子

    看到了这个输出,有没有得到什么启发呢?

    当看到输出了2时,即使无法完全理解,大概也能猜到发生了什么事情了。不过我们还是从第一句代码说起吧:

    第一行的两个定义不必多言,编译器为它们在堆栈中分配了空间,在这里我想把其中的情况画出来:


    堆栈中变量分布

    这里有一个有趣的现象,按照堆栈的分配规则,先定义的i应该在数组a的上面出现,然而我尝试了多种情况,同时取消了编译器的优化,仍然出现了“先数组,后单个”的分配顺序,可以推断编译器大抵遵循这样的分配模式,这也在无形中为上述代码能够运行奠定了基础。

    接下来是这一句:a[-1] = 2。这里看起来会越界,但实际上,关于数组的引用从没有过关于符号的要求,这里只是简单地做了这样的等价:a[-1] => *(a-1)。虽然有点奇怪,但是只要能够“取到”东西,这句话就是合理的。

    结合上面的图,不难意识到 a-1的地址恰好是i的地址,于是这句话便使得i的值被赋值为2,从而能够引发接下来的输出了。

    这可以当成一个无聊的小趣闻,也不会有人会试图去编写a[-1]这样的代码,然而上述的代码隐含的问题在于可以通过指针操纵的方式去访问那些本应“独立”,甚至不应该访问的地方。

    比如下面的例子:

    //two_test.cpp
    #include<iostream>
    
    using namespace std;
    
    class A
    {
        int x;
        public:
        int Getx()
        {
            return x;
        }
    };
    
    int main()
    {
        A a;
        int b[10];
        cout << &a << endl << &b << endl;
        b[-1] = 3;
        cout << a.Getx() << endl;
        return 0;
    }
    

    为了更好地了解发生了什么,这里打印了a与b的地址。

    这是运行之后的情况:


    一个漏洞

    Private的限制被轻易地打破了,只要对数据结构足够地熟悉,上述的方法能够轻松地破坏封装,引发一些别有用心的风险。

    而这只是一部分的问题,堆栈与函数之间密不可分的联系使得堆栈的问题更加复杂。

    堆栈除了放置局部变量,还有一个更为重要的作用——实现函数的调用,这通过一种称为栈帧的结构予以实现。关于栈帧的讨论可以写出一本小册子,这里没办法展开讲解,有兴趣的同学可以阅读《深入理解计算机系统》第三章 程序的机器级表示 3.7过程一节中的内容。

    简而言之,刚才的堆栈结构的全貌应该是如下的形式:


    一个相对更完整的堆栈布局

    如图,一个bp与sp之间构成了一个栈帧,可以理解为当前函数/程序段的工作空间(局部栈),而上一个栈帧的值则被保存,同样被保存的还有当前函数返回后下一条指令的地址——将在函数返回后被加载到ip指针中。栈帧结构确保了局部变量的“局部有效”,同时保证函数返回后能继续工作(旧的sp似乎也有必要被保存起来,这里没有画出来)。

    不难看出,过程控制的相关信息(旧的bp,返回地址)与局部变量被隔开存放,当函数调用与返回时,这样的结构确保了整个过程正常工作。而我们对于bp以上的部分不会有什么过多关注。这看起来一切正常,然而问题存在于其中,看似安全的领域,有着潜在的盲区。

    主要问题在于返回地址一值,它将在函数返回后被加载到ip指针中,后者记录着程序工作的当前指令,可以想象,如果人为地修改这个返回地址,那么将使得程序跳往别有用心的地方,具体的效果如同在编译好的程序中手动加入了goto语句(或者setjump),结果是可想而知的。

    下面的代码模拟了这个情况:

    //goto_test.cpp
    #include<iostream>
    
    using namespace std;
    
    void one(int i, int (*f)(int, char**))
    {
        cout << &i << endl;
        cout << hex << *(long int*)(&i + 7) << endl;
        *(long int*)(&i + 7) += 123;
    }
    
    void dark_part()
    {
        cout << "You won't want this" << endl;
    }
    
    int main(int argc, char* argv[])
    {
        int (*f)(int, char**) = main;
        int i = 10;
        one(i, main);
        bool b;
        cout << "PRINT B" << endl;
        cin >> b;
        cout << "OUT" << i << endl;
        if (b)
        {
            dark_part();
        }
        return 0;
    }
    

    代码中dark_part函数是一个我们希望其前往的目标函数,而one则通过修改返回地址完成了这个跳转,关键在于这一句话:

    *(long int*)(&i + 7) += 123;
    

    根据之前堆栈的布局,&i+7即为在堆栈中存储着返回地址一值的对应地址。正常情况下,其应指向one函数的下一句,即cout << "PRINT B" << endl(变量的定义不会被编译器放进代码段),通过令其+=123,我们将这个地址改为了dark_part()的相应地址。从而使得整个程序在运行one函数后直接跳到了dark_part函数,具体结果如图所示:


    修改返回地址

    为了便于理解,打印出了i的地址与当前栈帧返回的地址值,后者被转换为了long int型,其值指向代码段相关指令。而本应有的输入提示于要求输入则全部被跳过了。

    至于这里为什么i与返回地址差了7,以及指令地址为什么差了123,这取决于程序的具体堆栈布局,我在测试前通过gdb确认了相关信息(整个过程完全是“上帝模式”)。具体用到了backtrace(打印栈信息)以及frame info(打印当前/特定栈帧详情)。要详细讨论gdb又能写另一本小册子,感兴趣的同学请戳:http://www.gnu.org/software/gdb/

    这样的漏洞并不是没有被钻过,它曾经引发了严重的问题:熟悉c/linux编程的人大抵听过gets函数和关于缓冲区溢出的问题。
    gets函数的原型如下:
    char * gets ( char * str );
    该函数从标准输入读入字符,并将其写入str所指的地址空间。

    这个函数的问题在于它不会做长度或是变量位置的检查,只是单纯地把输入放到指针所指的地址上。从理论上而言,只要栈区空间足够,它可以一直读取输入并写下去,覆盖那些预期之外的位置(方向当然是地址的从低到高了,如果一直写入,理论上它可以写到栈底并引发一个错误)。

    gets同样可以造成之前a[-1] = 2所引发的类似情况:

    //getstest.cpp
    #include<stdio.h>
    
    int main(int argc, char* argv[])
    {
        char buf1[10] = "The first";
        char buf2[10] = "The second";
        printf("%s\n", buf2);
        gets(buf1);
        printf("%s\n", buf2);
        return 0;
    }
    

    上面的结果是这样的


    gets的问题

    可以看到相当醒目的警告信息啊。顺便一提,在2011年的C++新标准中该函数被废止,给出了相对安全的替代项gets_s()。

    输入与输出说明了结果,这里的初衷是向buf1中写入15个字符,然而buf1的长度只有10,由于gets没有长度检查,它只是从buf1的地址开始依次把字符写进去,于是地址在buf1之后的buf2不幸受害,被意外填充了错误的信息。

    实际上,buf1的结局也不只是没有装完预期字符那么简单。由于整个buf1被写满,本用于结束字符串的0字符(第十个位置)在这里也被重写(被填入了‘0’字符,本应是’\0’结尾),因此试图打印buf1将会引发一个堆栈错误。

    当然,gets的威力远胜于此,由于它不做检查,因此不断地进行写入会破坏当前栈帧的有效信息,从而把返回地址指向这样的语句:
    execlp(somefile, args,...,NULL)
    简而言之,如果有一个set-user-ID标志的程序在实现上为了图方便不幸使用了gets函数,那么那些精通堆栈布局的别有用心者便可以通过一些其他的花招到把特定的程序段放入内存之中,别有用心的客户可以轻而易举地获取相关系统的root权限。实际上,确实有人这么做了,10年前左右的蠕虫病毒受害者,就是gets函数的一个不幸的牺牲品。有兴趣的同学可以研读在《C专家编程》中对该情况的简单讨论。

    关于堆栈,确实存在着一些盲区,而C/C++特有的“通过地址进行访问”的思维,并没有起到什么好作用。当然选择无视这些问题,写出完全不安全的程序或是放弃任何指针,写出看起来安全的笨拙程序都不是什么明智之举。所幸今天的编译器已经更加聪明,上述的漏洞也都有着多样的预防措施。然而盲区无处不在,明天又会有新的漏洞吧。

    相关文章

      网友评论

        本文标题:被修改的变量与函数——小议堆栈中的风险

        本文链接:https://www.haomeiwen.com/subject/tdyvzxtx.html