.NET 探索函数栈的一些问题

    本文主要目的是探索栈上的一些问题,这些问题虽然细微但值得重视,下面本文将列出一些不同的情况,它们可能会发生不同性质的问题。

        int n1 = 0x7A;
        int n2 = 0x8A;
        long* p = (long*)&n2;
        *p = 0;

    上面的代码产生了一个严重的问题,在大多数人看来上述的代码似乎仅仅只是用 “指向 n2 变量改写它的值为 0”,依据 CLR 的安全机制,那么理论上说不存在什么问题,真的是这样?

    首先我们从代码字面上来看,它最多就可能溢出多改写四个字节(long型占八个字节,int型占四字节)那么一个有趣的现象来到了,上述代码改写 n2 时能否影响到 n1 ?答案是肯定的,这同时引发一个让人疑惑的问题,难道托管的安全保护机制没有起到任何作用?显然不是的,它不足以引发安全保护机制,例如溢出覆盖掉函数栈上空间的 GS/COOKIE 与. NET CLR 编译 native-code 时设置栈上标志时会引发。

   我们都知道局部变量的内存是分配在栈上空间的,但是有一点令人感到奇怪的是 .NET CLR 编译的 native-code 代码两个局部变量之间的栈上内存很多时候是紧密连接在一起的,在C/C++中函数的局部变量之间的内存是紧密链接的,但是 .NET CLR 不可能没有考虑到这种情况,当然它不可能与 C/C++ 局部变量内存分布一致,但是这有一个前提就是说,多个局部变量定义时都是利用数值常量设定的(或者说不会扩大栈空间的情况下是连续一致的)

    public unsafe static void Main(string[] args)
    {
        int n1 = 1, n2 = 2, n3 = 3, n4 = 4;
        ulong* p = (ulong*)&n4;
        p[0] = ulong.MaxValue;
        p[1] = ulong.MaxValue;
    }

    栈的内存是从高到低位增长,EBP保存指向函数的栈顶(基底指针寄存器)ESP保存指向当前栈的底部 ~= 当前栈的读写位置[SIP](基顶指针寄存器),两个局部变量之间的定义顺序决定了两者在栈上分配空间的位置,例如:n2 是 n1 的低位地址(从高到低).NET 为了优化代码效能与减少栈空间的浪费(win32k 只有一M的线程栈空间)所以上面上述的局部变量栈空间分布与C/C++函数的局部变量栈空间分布基本一致,所以 n2 是 n1 的底位地址,向指向 n2 的地址拷贝 8 个字节的数据一定会覆盖掉 “n1” 的值。

    public unsafe static void Main(string[] args)
    {
        int n1 = 0x7A;
        int n2 = 0x8A;
        int* p = stackalloc int[1];
        p[4] = 0;
        p[3] = 0;
        p[2] = 0;
    }

    上面的代码很有意思,它通过 “p” 指针覆盖了 “p,n2,n1” 三个局部变量的值,但是接下来的代码配合上述的代码,我们会发现一些更有意思的东西。

     p[1] 到底代表了什么东西?覆盖它会引发那些问题,直到后面我似乎大概得出它是一个什么性质的东西,它是一个标识着当前类型的 tokenid 但这个 id 是用以 check 有效性的,它标识着 stackalloc 分配的内存是否出现过溢出的问题,同时充当着类型的 check,但它不等于标识类型的 MetadataToken(RID number)同时它并不是一个有效的内存地址,另外一点是它只有利用 stackalloc 时才会产生,同时它不等于标识原始类型的 System::RuntimeTypeHandle 的句柄 。

    .NET CLR 在不同情况下编译后的代码,局部变量之间的内存分布是不同,但是大体都是相互连接的,但有时会出现一些空间上的空白,但令人感到好奇的是这些栈上空间上的空白到底是什么东西?

    public unsafe static void Main(string[] args)
    {
        int n1 = 0x7A;
        int n2 = 0x8A;
        int* p = stackalloc int[1];
        string s = n2.ToString();
        Console.WriteLine(s);
    }

    上面的代码可以表现上面提到的会产生栈上空间出现空白的代码示例,这一串代码很简单,但是它将引发下图所示的情况,那么我们在来探索它到底代表什么含义。

   

    从上图的内存 dump 中,我们可以清晰的看到 “p[2]” 的位置保存了一个地址(即 n2.ToString() 返回的字符串对象引用地址)但是 “p[3]” 的位置保存的值与 “p[2]” 位置上面的值是一致的,它们之间有什么区别?我曾经一直在思考这个问题,本人不想通过阅读 “sscli20(.NET/CLR 2.0 开源代码)” 来探究这个问题,虽然它的确很快,但更多时候是起到一种相对坏的作用。 

    这些空白只有在向栈压入值时才会引发,那么它到底应该怎么叫?即:“计算堆栈”,它是用于保存 “计算堆栈” 的当前值的,但是我们发现我们每执行一次代码,似乎栈的空间都在不停地放大,那么它可能会潜在的引发 “线程栈溢出” 的问题?那么我们来观察下面两张运行结果图,它会告诉我们一些很重要的信息。

图片一:

 图片二:

    从图二中,我们发现“栈的空间”并没有在增大而是保持在了占两个INT的空间,所以我们担忧的 “潜在” 的 “线程栈溢出” 问题是不存在的,但是令我们好奇的是,是什么原因让它没有继续增长总有存在的理由,同时虽然我提到它是保存 “计算堆栈” 的当前值的,但仅仅只是保存计算堆栈的当前值的话并不需要占用两个INT的空间,那么这两个空间中总有一个其到另外的作用。

    真相了,它是由 maxstack(最大栈大小)的大小决定的,同时最低位作为保存 “计算堆栈” 当前的值 “累加器” ,那么一个问题来说 “maxstack” 到底是个什么东西,我们该如何计算一个函数的 “max stack size”?(这个不难就是会有点小小的麻烦)。

	IL_0000: nop
	IL_0001: newobj instance void program::.ctor()
	IL_0006: stloc.0
	IL_0007: ldc.i4.s 122
	IL_0009: stloc.1
	IL_000a: ldc.i4 138
	IL_000f: stloc.2
	IL_0010: ldc.i4.4
	IL_0011: conv.u
	IL_0012: localloc
	IL_0014: stloc.3
	IL_0015: ldloc.0
	IL_0016: ldfld string program::A
	IL_001b: stloc.s 4
	IL_001d: ldloc.s 4
	IL_001f: ldloc.0
	IL_0020: ldfld string program::B
	IL_0025: ldloc.0
	IL_0026: ldfld string program::C
	IL_002b: call string [mscorlib]System.String::Concat(string, string, string)
	IL_0030: stloc.s 4
	IL_0032: ret

    计算的方法就是人工的模拟一个函数IL指令的执行,得出最大时占用栈空间多少个 size,例如上述的栈的 maxstack 本人会在下方列出计算过程(为了快速人工计算是不会这么干的)。

cursor: 0

IL_0000: cursor // 0
IL_0001: cursor++ // 1
IL_0006: cursor-- // 0
IL_0007: cursor++ // 1
IL_0009: cursor-- // 0
IL_000A: cursor++ // 1
IL_000F: cursor-- // 0
IL_0010: cursor++ // 1
IL_0011: cursor--, cursor++ // 1
IL_0012: cursor--, cursor++ // 1
IL_0014: cursor-- // 0
IL_0015: cursor++ // 1
IL_0016: cursor--, cursor++ // 1
IL_001B: cursor-- // 0
IL_001D: cursor++ // 1
IL_001F: cursor++ // 2
IL_0020: cursor--, cursor++ // 2
IL_0025: cursor++ // 3
IL_0026: cursor--, cursor++ // 3
IL_002B: cursor--, cursor--, cursor--, , cursor++ // 1
IL_0030: cursor-- // 0
IL_0030: cursor // 0

    上面的计算过程说明了函数最大栈大小(max stack size)是如何计算出来,总之很简单每个人有每个人的方法,当然上面的方法不是本人的主要方法,上述太笨拙与消费时间,虽然计算很容易并不复杂,但是做一个相关工具程序来计算会更好很多,人工的话要有方法,不然就会比较累。

    这个有点阴谋论的说法,就是说栈上变量之间的内存分布基本是连续的,那么我们可否通过溢出的方法覆盖掉某些函数的关键变量呢?这是可能的,但这需要目标程序存在有类似可被利用的漏洞,当然如果想要通过覆盖函数栈上的返回地址或者结构化异常处理链来执行,其实就可靠性来说不如上面的溢出覆盖关键变量(.NET CLR 函数的安全保护机制是比较强烈的)虽然它可以远程执行 shellcode 代码,但显然一点就是说想要在 .NET 应用中执行 shellcode 代码就成功率来说极低,但覆盖栈上的关键变量的可能性却是极高的,想一想比如你的应用有一个验证的功能,但是你改写关键变量的代码存在一些缺陷,那么对方就有可能通过一些手法覆盖掉这个值,令验证通过,但是一个好的建议就是说使用 0 这个值代表逻辑TRUE 流程跳转是一个很不错的办法,这就相当于人为的为栈上变量额外提供了一些保护(此方法与 ascii armoring 类似)。

猜你喜欢

转载自blog.csdn.net/liulilittle/article/details/82785110
今日推荐