V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
NullMan
V2EX  ›  C

C 语言:释放动态分配的内存,为何还能访问?

  •  
  •   NullMan · 2017-06-23 17:06:21 +08:00 · 4834 次点击
    这是一个创建于 2745 天前的主题,其中的信息可能已经有所发展或是发生改变。
    #include <stdio.h>
    #include <stdlib.h>
    
    #define N 50
    
    int main(void) {
        int *pi;
        pi = (int *)malloc(N * sizeof(int));
        if (pi == NULL) {
            printf("Out of memory!\n");
            exit(1);
        }
        for (int i = 0; i < N; i++) {
            *(pi + i) = 100;
        }
        free(pi);
        pi[10] = 200;
        printf("%d\n", pi[10]); // 输出 200
        return 0;
    }
    

    执行 free(pi) 了,没道理 pi 还能访问。我看了下 c 标准,发现有这么一句话:

    The behavior is undefined if after free() returns, an access is made through the pointer ptr (unless another allocation function happened to result in a pointer value equal to ptr)

    这到底释放了内存没有?我是这么猜想的,这块内存其实回归了内存池,如果有其他的内存分配,将有可能复用这free 过的内存块。先前代码输出的200, 其实等同于垃圾值,就像声明了一个int i但未初始化而直接访问i将会得到上次使用过i内存的垃圾值。

    不知我理解是否正确?

    编译器版本如下:

    Apple LLVM version 8.1.0 (clang-802.0.42)
    Target: x86_64-apple-darwin16.6.0
    Thread model: posix
    InstalledDir: /Library/Developer/CommandLineTools/usr/bin
    

    本人初学 C,望指点!多谢多谢!

    第 1 条附言  ·  2017-06-24 02:20:01 +08:00
    想不到一个这么一个随意提出的问题,都能炸出这么多大神出来讨论,而且好多看都看不懂,瞬间觉得自己菜逼了。
    67 条回复    2018-03-25 17:54:32 +08:00
    canfoderiskii
        1
    canfoderiskii  
       2017-06-23 17:09:50 +08:00 via Android   ❤️ 3
    内存它就躺在那里,为什么不能访问?只是 free 之后再访问不和逻辑,也很危险。
    deweixu
        2
    deweixu  
       2017-06-23 17:10:17 +08:00   ❤️ 1
    gongzhang
        3
    gongzhang  
       2017-06-23 17:10:25 +08:00
    是哒 ( ̄∇ ̄)
    macha
        4
    macha  
       2017-06-23 17:10:48 +08:00
    你的感觉是对的。
    NullMan
        5
    NullMan  
    OP
       2017-06-23 17:14:26 +08:00
    我想了想,既然只是个标准,而且又没说明如何处理这个现象,那么编译器实现者爱咋呀就咋样,如果我是编译器实现者,我直接给退出程序也是正常。这么一想,我就释然了。
    after1990s
        6
    after1990s  
       2017-06-23 17:18:09 +08:00 via Android
    @NullMan malloc 不是编译器实现的,是 c 语言库实现的。
    Cooky
        7
    Cooky  
       2017-06-23 17:19:00 +08:00 via Android
    你可以试着往 free 掉的地方写数据看看
    reus
        8
    reus  
       2017-06-23 17:31:51 +08:00   ❤️ 2
    free 只是告诉内存分配器这块可以用于分配,并不阻止你继续用。内存分配器也不知道你用不用,反正它允许用。
    NullMan
        9
    NullMan  
    OP
       2017-06-23 17:34:40 +08:00
    @after1990s c 标准库也是代码,也是要编译的嘛。
    jmp2x
        10
    jmp2x  
       2017-06-23 17:35:37 +08:00   ❤️ 1
    catror
        11
    catror  
       2017-06-23 17:47:41 +08:00 via Android   ❤️ 6
    释放了请把指针置空,编程的好习惯
    NullMan
        12
    NullMan  
    OP
       2017-06-23 17:55:23 +08:00
    @catror 卧槽!这个很赞!多谢!
    Mirana
        13
    Mirana  
       2017-06-23 18:14:19 +08:00
    free 掉之后只是还给 memory allocator 了,不一定还给操作系统
    kmyzzy
        14
    kmyzzy  
       2017-06-23 18:17:49 +08:00
    @catror 这可未必
    cuteshell
        15
    cuteshell  
       2017-06-23 19:11:22 +08:00
    free 了,指针还是原来的值,还是可以访问,free 是告诉操作系统这块内存我不用了,操作系统可以分配给其它的 malloc 申请,只不过释放了内存的指针是野指针。
    shuax
        16
    shuax  
       2017-06-23 19:14:47 +08:00 via Android
    就和你在硬盘上面删除一个文件一样的,你删一个 10g 的文件难道还要用 0 给你填充,浪费时间,声明这块空间可以用就行了。非 ssd
    geelaw
        17
    geelaw  
       2017-06-23 19:21:40 +08:00
    @catror 这样做反而可能错失发现逻辑错误的机会。

    这个行为是符合标准的,因为标准说未定义,你能用可以算是一种走运。
    DustOnTheHeart
        18
    DustOnTheHeart  
       2017-06-23 19:46:28 +08:00
    free 告诉系统这块内存我不用了,但是指针指向的内存地址没变,在这个程序执行的过程中也没有在此改变这个指针指向的内存中的值,所以再次使用这个地址的内存的时候仍然得到的是上个值
    lrxiao
        19
    lrxiao  
       2017-06-23 20:15:13 +08:00
    undefined.....(
    catror
        20
    catror  
       2017-06-23 20:37:01 +08:00 via Android
    @kmyzzy 举个例子来说?内存已经释放了这个指针没有意义了
    catror
        21
    catror  
       2017-06-23 20:38:24 +08:00 via Android
    @geelaw 内存都释放了,后面有逻辑逻辑涉及到释放掉的内存就出错了
    geelaw
        22
    geelaw  
       2017-06-23 20:43:16 +08:00   ❤️ 1
    @catror

    举个例子来说,正是因为这里的指针没有意义了,下次使用就可能出错(有些调试器允许你检测到这种情况),所以这样可以看出程序的逻辑错误。在 free 之后无论是什么情况都立刻把指针设置为 nullptr 是一种鸵鸟战术,就像是在所有指针访问之前都测试是不是 nullptr 一样——对于本来不该是 nullptr 的指针,测试是不是 nullptr 并不能解释为什么这个指针变成了 nullptr。
    azh7138m
        23
    azh7138m  
       2017-06-23 20:46:14 +08:00 via Android
    @catror 如果出现两次 free 同一个地址,是你逻辑有问题,这个时候应该让问题暴露出来,而不是置 null 来规避问题
    catror
        24
    catror  
       2017-06-23 21:11:24 +08:00 via Android
    @azh7138m @geelaw 使用空指针,free 空指针都会出错啊,不知道我是不是理解错你们的意思了,不过你们应该去搜一下野指针的危害…
    catror
        25
    catror  
       2017-06-23 21:21:30 +08:00 via Android
    搜了一下,我上面说错了,这种叫悬垂指针,我搞错概念了,不叫野指针,野指针是没有初始化的指针,这两个都是 C 语言里面很经典的东西,同时也是能坑死人的东西
    geelaw
        26
    geelaw  
       2017-06-23 21:41:24 +08:00
    @catror free(nullptr) 是没有任何效果的,不要想当然。
    geelaw
        27
    geelaw  
       2017-06-23 21:42:01 +08:00
    @catror 没有区分 dangling pointer 和 uninitialised pointer 的必要——它们都存储着无效的值。
    skadi
        28
    skadi  
       2017-06-23 21:42:37 +08:00 via Android
    未定义行为
    catror
        29
    catror  
       2017-06-23 21:53:59 +08:00 via Android
    @geelaw 噢噢,这倒没注意,一般内存释放前我也会判空,因为我无法保证下次换个其他的 malloc 库也会处理这种异常…
    bp0
        30
    bp0  
       2017-06-23 22:17:22 +08:00   ❤️ 1
    @geelaw 将 free 后的指针赋值成 NULL 的意义就是在于下面没有检查直接对指针进行读写操作。对指向 NULL 的指针进行读写操作会导致段错误。而如果没有将 free 后的指针赋值成 NULL,其会继续指向 malloc 时返回的地址。因为这个地址还是有效的,如果下面继续对其进行读写操作,是无法马上发现问题的。

    尤其是涉及到多线程时,这个地址可能会被其他线程使用,而导致另外的线程异常。如果你只是排查另外线程的代码,是无法发现问题的根本原因的。
    glogo
        31
    glogo  
       2017-06-23 22:31:35 +08:00
    free 之后内存并不一定会被马上回收,马上回收也不一定保证帮你清零,而且 你的 free 之后并没有把相应的指针置为 NULL
    geelaw
        32
    geelaw  
       2017-06-23 22:49:28 +08:00   ❤️ 1
    @bp0 如果你开启页末对齐,则你并不需要赋值为 nullptr 也能看出错误。参考 https://blogs.msdn.microsoft.com/oldnewthing/20170410-00/?p=95935

    如果你设置为 nullptr,你就没法发现双重释放的错误。
    ogfa
        33
    ogfa  
       2017-06-23 23:21:51 +08:00
    所以应该 free 过之后置为 1
    这样双重 free 会出错,再次读写也出错
    想判断是否 free 过也 ok,因为永远不会 malloc 出 1 的地址来
    堪称最完美解决方案
    tyfulcrum
        34
    tyfulcrum  
       2017-06-23 23:29:23 +08:00 via iPhone
    @geelaw 如果 free 后置空和判断 nullptr 不是好的思路,那用什么思路比较好呢?这方面有什么比较好的资料推荐么?
    bp0
        35
    bp0  
       2017-06-23 23:39:05 +08:00
    @geelaw 你给的资料上面说的如何马上检查出是否访问超过分配大小的地址空间,而楼主是问为什么释放申请的内存后还能访问。 只要我继续访问申请的大小,页末对齐的方法就无法检查出来。因为我访问的就是 malloc 分配给我的地址。而且很怀疑这种方法的实用性,分配 1 个字节的空间也要占用一个页吗?如果没记错,一个页应该有 4kbytes 吧。

    双重释放的问题确实无法有效解决,不过个人认为,修改已经释放的内存中的内容是比双重释放更严重的 bug。
    geelaw
        36
    geelaw  
       2017-06-23 23:50:37 +08:00
    @bp0 如果你认真读了那篇内容你会发现释放之后再访问也会出错,因为页面已经被 decomitted。这是一个调试功能,所以效率低一点并没有什么问题。

    双重释放会导致堆损坏,而且双重释放是会导致访问已经释放的内存的——比如第一次释放后那个地址被分配给别人之后再第二次释放,实际拥有者不知道这个内存已经被扔了,再次访问就会有坏事发生。
    geelaw
        37
    geelaw  
       2017-06-23 23:52:18 +08:00
    @tyfulcrum 取决于逻辑,不能一概而论,所以我说“任何情况(也就是不考虑具体情况)都在 free 之后立刻设置为 nullptr ”是不好的。
    bp0
        38
    bp0  
       2017-06-24 00:15:01 +08:00
    @geelaw 上面还说在 64 位机器上分配 3 个字节会有 13 个字节的 payload,这 13 个字节的 payload 的修改都可能无法查出,那么怎么检查修改分配给我的 3 个字节呢? 如果可以的话,麻烦你简单介绍一下 decomitted 的实现方式。

    而且你说的双重释放可能导致的问题,让我想到将 free 后的指针设置成 NULL,恰恰可以避免了你说的严重错误。当然严格上说这种方法并没有解决这种错误。但是如果不将 free 后的指针设置成 NULL,如果后面再次 free 才会出现双重释放。也就是导致你说的问题。

    我以前简单学习过 ptmalloc 的代码,至少在 ptmalloc 中有检查双重释放的代码的。而且针对上述问题,我更愿意用 Valgrind 这种工具去检查内存操作可能出现的问题。

    说了这么多,原因只有一个,就是我无法想出将 free 的指针设置为 NULL 有什么不妥的或者是可能导致其他问题的地方。希望能跟你学习一下。
    geelaw
        39
    geelaw  
       2017-06-24 00:31:40 +08:00   ❤️ 2
    @bp0 因为在这个模式下,如果你释放内存,整个 page 都会变得 invalid,就好像这个 page 从没用到过一样(但是 heap manager 会避免过早再次使用这个 page )。

    举个例子,第一次分配 1 Kb,记在 p 里面,第二次又分配 1 Kb,记在 q 里面,假设第二次分配的那 1 Kb 在第一次后面那一个 page。

    做这个:free(q); printf("%c\n", *q);
    相当于:free(q); printf("%c\n", *(p + PageSize));

    把 q 释放之后访问 q,是访问无效的 page,这里效果上等于 p buffer overrun,是会被检查到的。

    至于一个 page 具体是怎么被 decomitted 的,这我并不关心。

    **此外,free 掉一个指针之后设置为 nullptr,不代表原来其他指向它(或者这个数组其他位置)的指针也变成了 nullptr。**

    如果你可以把所有和这块内存关联的指针都找到并设置为 nullptr,那恐怕是可以的——但是这样的入侵性很强,还有可能让你不知道什么时候一个指针就被篡改了。

    至于一个 malloc 的实现可以抵抗双重释放,不代表书写双重释放的代码是可以接受的——这样的代码不可移植,而且在地址被重新利用的时候会引发更加糟糕的错误。彻底解决的方法是杜绝程序有逻辑问题,而不是掩盖这个问题。

    扩展阅读
    https://stackoverflow.com/questions/1879550/should-one-really-set-pointers-to-null-after-freeing-them
    https://stackoverflow.com/questions/1025589/setting-variable-to-null-after-free
    icedx
        40
    icedx  
       2017-06-24 01:05:59 +08:00
    借楼问下为啥大部分开源项目用的都是 malloc 而不是 calloc
    ETiV
        41
    ETiV  
       2017-06-24 01:15:31 +08:00 via iPhone
    你得把内存拔了才能访问不了
    NullMan
        42
    NullMan  
    OP
       2017-06-24 02:28:17 +08:00
    @shuax 哈哈,这也许就是硬盘数据恢复可行的依据之一。
    NullMan
        43
    NullMan  
    OP
       2017-06-24 02:38:20 +08:00
    @ogfa 但是会报 warning: incompatible integer to pointer conversion assigning to 'int *' from 'int' [-Wint-conversion] 警告,这对于致力于编写零警告程序的我来说,不够完美。 哈哈。
    di94sh
        44
    di94sh  
       2017-06-24 03:25:09 +08:00 via Android
    实际上 free 玩了要赋值 null。
    ogfa
        45
    ogfa  
       2017-06-24 03:29:45 +08:00
    @NullMan
    (someptr=nullptr)++;
    msg7086
        46
    msg7086  
       2017-06-24 05:18:58 +08:00
    @NullMan ptr = (int *)1;?
    chinawrj
        47
    chinawrj  
       2017-06-24 07:59:26 +08:00 via Android
    既然是超级系统下 C 代码,我说一下 Linux 层面的原因吧。内存释放之后,如果运气好,对应的内存 Page 依然处在有效映射,并且 MMU 标志还是可写访问的话,那么恭喜你你还可以访问的。不过什么时候不可以访问就看系统时候时候收回那段内存的映射了。有可能那块内存被你程序其他代码申请,于是乎你还可以继续用你现在的指针访问,但是结果是什么就可想而知了。
    nevin47
        48
    nevin47  
       2017-06-24 08:06:33 +08:00 via Android
    我滴妈,楼上大神们的讨论刷新了我对 free 后 null 的认识,收藏一记一会儿和组里讨论一下
    aheadlead
        49
    aheadlead  
       2017-06-24 09:10:12 +08:00 via iPhone
    这问题太复杂了 mm 是个很庞大的话题

    楼主位的问题可能只是那块内存所在的页面还在使用
    也有可能是内核还没来得及回收罢了
    bp0
        50
    bp0  
       2017-06-24 11:45:34 +08:00
    @geelaw 如果你不关心 page 怎么被 decomitted 的,又如何保证你说的方法一定有可移植性呢?到其他系统中 page 没有被 decomitted,这个时候应该怎么办?

    而且我从没有说过要掩盖双重释放的问题。就像你提供的资料上说的,这种问题是逻辑错误。这种逻辑错误我们有很多方法去检查,编写代码时要求调用统一的释放接口,代码提交之前进行 review,CI 流程中进行的静态检查,单元测试。还有我上面提到的使用 Valgrind 工具 。甚至通过 C 实现 auto_ptr 这种类似的智能指针。这些都可以帮助我们避免包括双重释放在内的众多问题。

    但是你却说让他 crash。问题是,如果它能马上 crash 还好。如果他没有马上 crash,或者一段时间以后在其他线程中 crash 了。你应该如何处理?当然我相信你们也会类似的流程保证代码质量,而不是简单的让它 crash。

    问题是,你给人一句将 free 后的指针设置成 NULL 不好,我们不用。就像跟人说 goto 不好,我们不用是一样的。goto 真的不好吗?新手用 goto 可能出问题,大神用 goto 优化代码。用不用的好,全看你自己对系统的理解了。
    simpx
        51
    simpx  
       2017-06-24 12:37:37 +08:00
    @icedx #40 我自己的代码里,原因是因为我自己精确知道哪些变量需要初始化,哪些不需要

    所以不用 calloc 一股脑初始化
    gnaggnoyil
        52
    gnaggnoyil  
       2017-06-24 13:50:31 +08:00
    @msg7086 intptr_t
    gnaggnoyil
        53
    gnaggnoyil  
       2017-06-24 13:58:57 +08:00   ❤️ 2
    另外楼上那些又是扯分页又是扯 Glibc 的纯属离题万里.C 标准里规定了 free 后 access 这个指针就是未定义行为.而未定义行为的意思是编译器碰到这种情况有权力执行任何操作,包括但不限于生成语义面目全非的编译产物.所以对于一个未定义行为讨论其实际意义是没有意义的,因为未定义行为本来就不蕴含任何意义.
    u5f20u98de
        54
    u5f20u98de  
       2017-06-24 14:01:23 +08:00
    在安全角度上看这大概就是漏洞了
    https://cwe.mitre.org/data/definitions/416.html
    johnlui
        55
    johnlui  
       2017-06-24 14:24:00 +08:00
    @NullMan 看来你对编译器有误解,它只是帮忙把你的字符串描述的行为翻译成了汇编代码而已。
    acros
        56
    acros  
       2017-06-24 14:24:32 +08:00
    让一让,专业配图师来了。
    wohenyingyu02
        57
    wohenyingyu02  
       2017-06-24 14:29:53 +08:00
    难道 free 完这个内存会从机箱里掉出来么
    acros
        58
    acros  
       2017-06-24 14:30:46 +08:00
    上面的解释很详细了····
    好像也详细过头了,估计新手都会看怕了,一般了解到 free 后指针没置空,仍然存在访问风险就好了。

    具体谈论这些内容的,我记得《 C 专家编程》好像不错。
    oroge
        59
    oroge  
       2017-06-24 14:54:33 +08:00 via iPad
    还可以推荐 operating system concepts ...
    VYSE
        60
    VYSE  
       2017-06-24 15:09:04 +08:00 via Android
    争啥争把 free 后指针置为 0xdeadbeef !
    要知道当年置为 0,可以 mmap 0 地址的哦。
    其实 UAF 修复关键是 free 后杜绝 use 啊。
    wind3110991
        61
    wind3110991  
       2017-06-24 15:37:54 +08:00
    https://www.v2ex.com/t/181639#reply21

    2 年前我也问了同样的问题,看 1 楼和 18 楼的回答了
    实际上 free 后 pi 变为了野指针了,不做 NULL 赋值容易产生异常,所以需要将指针地址置为 NULL

    内存回收和空间分配一样,涉及到两块空间:虚拟空间与物理空间。虚拟空间是进程私有的;
    当你 free 之后,虚拟空间中的内存已经回收了。但是物理内存上没有啊,XJB 解释的话,可以理解为物理内存上某个部分“依然没有去掉这块内容”。

    而且你的指针持有了那个内存地址,当实际占用大小不满一个 page 的时候,页置换是不会发生的。

    总而言之,谨记:当你 free 后,一定要记得置 pi 为 NULL,这个我们称之为防御性的代码,当你不复用这个 pi 指针还好,但是如果复用了,会出现不可预估的错误和异常
    Abercrombie
        62
    Abercrombie  
       2017-06-24 15:42:03 +08:00
    上面大家说的已经差不多解释清楚了,我来说一点这个机制的应用吧。
    我在 real time 系统上开发程序时,由于对程序的实时性要求很高,所以容不下一点错误的发生。
    除了提高 cpu affinity,为了防止出现 page fault,所以会做一步 stack prefault,提前先申请一块较大的内存,然后释放,那么接下来程序会优先使用这块已释放掉的内存,保证了不会出现 page fault。这个应该属于 glibc 的实现机制
    ```
    void stack_prefault(MAX_STACK_SIZE)
    {
    unsigned char *p;
    p = (unsigned char*)malloc(MAX_STACK_SIZE);
    memset(p, 0, MAX_STACK_SIZE);
    free(p);
    }
    ```
    如果不是因为 free 掉之后的内存能够被快速再利用的这个机制,那么也无法实现 prefault 的效果了。
    ycz0926
        63
    ycz0926  
       2017-06-24 16:32:36 +08:00
    你的代码就躺在这个进程的内存上,你的代码和内存上的数据没有任何区别,你把指针移来移去,加载了再多的库,也分割不了你的 c 代码和内存的“粘性”
    geelaw
        64
    geelaw  
       2017-06-24 19:09:03 +08:00
    @bp0

    这个方法的可移植性 和 程序的可移植性 没什么关系,因为这个玩意儿是给 **调试** 用的。正常使用(而不是调试程序)的时候不会应用这个策略。

    这里的“让它 crash ”是在调试阶段暴露问题,在生产阶段,因为一些优化的考虑,不可能做这么多防御措施。

    如果你在书写可移植的代码,那么你可以在一个有这个调试功能的平台上完成调试之后到其他平台用。


    @VYSE

    (所以 0xdeadbeef 不能 mmap ?我不用 *nix 不太懂。)


    @VYSE
    @wind3110991

    如果有两个指针指向同一个动态分配地址,那么仅仅设置一个指针为 nullptr 或者一个特殊的数是没有用的。

    这种情况或许容易检测,但是更困难的是,你分配了一个数组,两个指针一个指向数组开头,一个指向数组里的另一个位置,然后你 free 数组开头并重置指针——这样虽然后一个指针并不等于被释放的指针,仍然变得无效。
    jmp2x
        65
    jmp2x  
       2017-06-25 00:10:51 +08:00
    1. 上面说的很多大多数都是猜测
    2. 这部分内容属于 ptmalloc 堆内存分配内容,涉及到 ptmalloc 的缓存结构,分配 /释放算法
    3. 需要考虑几个问题,分配的 malloc 堆块到底是以怎么样的结构体存在? glibc 怎么(记录)分配 malloc 堆块? glibc 怎么(回收)释放 malloc 堆块? 这几个问题都搞不清怎么谈
    4. 源码地址: http://www.eglibc.org/cgi-bin/viewvc.cgi/branches/eglibc-2_19/libc/malloc/malloc.c?view=markup
    VYSE
        66
    VYSE  
       2017-06-25 03:00:55 +08:00
    @geelaw #64 常见 OS 的内核地址范围,所以用户态不可 mmap,做安全的常用测试让 dangling 指针引发 crash.
    https://en.wikipedia.org/wiki/Hexspeak

    首先这是个安全问题,导致 crash 是最理想的情况,导致程序输出异常该庆幸,导致代码执行就杯具了.
    举个例子, PingPongRoot 里的利用的内核 free 后置指针为 0x200200,结果用户态可以 mmap,然后 UAF....
    这个问题还是 dangling pointer 能不能被再次使用,比如上面洞 Linux 的解法就是置 NULL 指针和使用前判断是否为 NULL.
    假如仅仅置 NULL 指针而缺了后者,在 mmap_min_addr 配置为 0 的内核上仍然存在漏洞.
    见 LINUS 解释为什么不限制 mmap 0 http://yarchive.net/comp/linux/address_zero.html
    kljsandjb
        67
    kljsandjb  
       2018-03-25 17:54:32 +08:00 via iPhone
    @NullMan 释放了,可后来你又用了,所以用的这块 int 数据占用的空间

    释放之后指针置 NULL 吧 :)
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2396 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 02:10 · PVG 10:10 · LAX 18:10 · JFK 21:10
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.