保护多个线程共享的 talloced 内存免受写入

Protecting talloced memory shared by multiple threads against writes

在我们的应用程序(网络守护进程)中,堆分配的内存大致有三种用途。

  1. 启动时分配的内存,用于保存解析应用程序全局配置的结果。

  2. 创建线程时为线程特定数据分配的内存(并在销毁时释放)。

  3. 为请求提供服务时分配的内存,并绑定到请求的生命周期。

我们在所有三种情况下都使用 talloc 来管理内存。

我们最近 运行 发现了一些内存损坏问题,其中错误的指针值意味着一个或多个线程正在写入全局配置并导致崩溃。

由于应用程序的结构方式,在应用程序开始处理请求后,不应向情况 1) 中分配的内存写入任何内容。

有没有办法将情况 1) 中分配的内存标记为只读?

在 POSIX 规范中有一个函数 mprotectmprotect 允许更改单个内存页的权限 (read/write/execute)。

使用 mprotect 将部分堆标记为只读的问题在于,最高粒度是单页,通常为 4k(取决于 OS/architecture ).将所有堆分配的结构填充到 4k 的倍数会导致大量内存膨胀,嘘。

因此,为了在案例 1) 中使用 mprotect,我们需要在一个连续的内存区域中获取我们想要保护的所有数据。

Talloc 可以在这里提供帮助。 talloc pools 是一种 slab 分配类型,如果使用得当,可以显着提高性能,并且(如果大小足够)允许池中的所有分配都在一个连续的内存区域中完成。

太棒了!问题解决,分配一个talloc内存池,做所有的实例化和解析工作,使用mprotect标记池为只读,大功告成!不幸的是,事情并没有那么简单...

还有三个问题需要解决:

  1. mprotect 需要内存是页面大小的倍数。
  2. mprotect 需要起始地址页对齐。
  3. 我们不知道要为池分配多少内存。

问题 1 很简单,我们只需要四舍五入到页面大小的倍数即可(用 getpagesize 可以很方便地检索到)。

size_t rounded;
size_t page_size;

page_size = (size_t)getpagesize();
rounded = (((((_num) + ((page_size) - 1))) / (page_size)) * (page_size));

问题2其实也很简单。如果我们在池中分配单个字节,我们可以预测第一个 'real' 分配将发生在何处。我们还可以从分配的地址中减去池的地址来计算出有多少内存 talloc 将用于块头。

有了这些信息,我们可以(如果需要)执行第二次分配以将池内存填充到下一页,确保 'real' 分配发生在受保护的区域内。然后我们就可以return下一页的地址在mprotect中使用了。这里唯一的小问题是我们需要将池过度分配一页以确保有足够的内存。

问题 3 很烦人,不幸的是,解决方案是特定于应用程序的。如果在情况 1) 中执行所有实例化没有副作用,并且使用的内存量是一致的,则可以使用两次通过的方法来计算分配给池的内存量。 传递 1 将使用 talloc_init 获取顶级块,并使用 talloc_total_size 显示正在使用的内存量,传递 2 将分配适当大小的池。

对于我们的特定用例,我们只允许用户确定池大小。这是因为我们使用受保护的内存作为调试功能,所以用户也是开发人员,分配1G内存来保证足够的配置是没有问题的。

那么这一切看起来像什么呢?那么这是我想出的功能:

/** Return a page aligned talloc memory pool
 *
 * Because we can't intercept talloc's malloc() calls, we need to do some tricks
 * in order to get the first allocation in the pool page aligned, and to limit
 * the size of the pool to a multiple of the page size.
 *
 * The reason for wanting a page aligned talloc pool, is it allows us to
 * mprotect() the pages that belong to the pool.
 *
 * Talloc chunks appear to be allocated within the protected region, so this should
 * catch frees too.
 *
 * @param[in] ctx   to allocate pool memory in.
 * @param[out] start    A page aligned address within the pool.  This can be passed
 *          to mprotect().
 * @param[out] end  of the pages that should be protected.
 * @param[in] size  How big to make the pool.  Will be corrected to a multiple
 *          of the page size.  The actual pool size will be size
 *          rounded to a multiple of the (page_size), + page_size
 */
TALLOC_CTX *talloc_page_aligned_pool(TALLOC_CTX *ctx, void **start, void **end, size_t size)
{
    size_t      rounded, page_size = (size_t)getpagesize();
    size_t      hdr_size, pool_size;
    void        *next, *chunk;
    TALLOC_CTX  *pool;

#define ROUND_UP(_num, _mul) (((((_num) + ((_mul) - 1))) / (_mul)) * (_mul))

    rounded = ROUND_UP(size, page_size);            /* Round up to a multiple of the page size */
    if (rounded == 0) rounded = page_size;

    pool_size = rounded + page_size;
    pool = talloc_pool(ctx, pool_size);         /* Over allocate */
    if (!pool) return NULL;

    chunk = talloc_size(pool, 1);               /* Get the starting address */
    assert((chunk > pool) && ((uintptr_t)chunk < ((uintptr_t)pool + rounded)));
    hdr_size = (uintptr_t)chunk - (uintptr_t)pool;

    next = (void *)ROUND_UP((uintptr_t)chunk, page_size);   /* Round up address to the next page */

    /*
     *  Depending on how talloc allocates the chunk headers,
     *  the memory allocated here might not align to a page
     *  boundary, but that's ok, we just need future allocations
     *  to occur on or after 'next'.
     */
    if (((uintptr_t)next - (uintptr_t)chunk) > 0) {
        size_t  pad_size;
        void    *padding;

        pad_size = ((uintptr_t)next - (uintptr_t)chunk);
        if (pad_size > hdr_size) {
            pad_size -= hdr_size;           /* Save ~111 bytes by not over-padding */
        } else {
            pad_size = 1;
        }

        padding = talloc_size(pool, pad_size);
        assert(((uintptr_t)padding + (uintptr_t)pad_size) >= (uintptr_t)next);
    }

    *start = next;                      /* This is the address we feed into mprotect */
    *end = (void *)((uintptr_t)next + (uintptr_t)rounded);

    talloc_set_memlimit(pool, pool_size);           /* Don't allow allocations outside of the pool */

    return pool;
}

以上还使用 talloc_set_memlimit 来确保不会在连续区域之外发生分配。

TALLOC_CTX *global_ctx;
size_t      pool_size = 1024;
void        *pool_page_start = NULL, *pool_page_end = NULL;

global_ctx = talloc_page_aligned_pool(talloc_autofree_context(), &pool_page_start, &pool_page_end, pool_size);

/* Allocate things in global_ctx */

...

/* Done allocating/writing - protect */

if (mprotect(pool_page_start, (uintptr_t)pool_page_end - (uintptr_t)pool_page_start, PROT_READ) < 0) {
    exit(1);
}

/* Process requests */

...

/* Done processing - unprotect (so we can free) */

mprotect(pool_page_start, (uintptr_t)pool_page_end - (uintptr_t)pool_page_start,
         PROT_READ | PROT_WRITE);

当在 macOS 上对受保护内存进行错误写入时,您会看到一个 SEGV,如果 运行 在 lldb 下,您会得到一个完整的回溯,显示错误写入的确切位置。