返回首页

C++ 上 Lua 沙箱的内存限制

本文描述了在 C++ 上使用自定义分配器实现 Lua 沙箱内存控制的实现。它涵盖了架构特性、代码示例以及开发者的实用建议。

Lua 中的内存控制:C++ 上的脚本隔离
Advertisement 728x90

# 使用 C++ 在 Lua 沙箱中限制内存和资源

将 Lua 集成到 C++ 应用程序中时,往往需要将脚本执行与主系统隔离。这在第三方代码可能具有恶意或不稳定时尤为关键——例如,在游戏引擎、插件系统或沙箱环境中。这种隔离的一个关键方面就是控制内存消耗。本文介绍了如何使用集成到解释器状态中的自定义分配器,在 Lua 中实现严格的内存使用限制。

Lua 如何管理内存

Lua 将所有内存操作委托给外部分配器。默认情况下,它使用标准的 realloc/free,但语言允许用自己的实现替换它。这通过在创建 lua_State 时传入函数指针来实现。重要的是:所有 分配、调整大小和释放请求都会通过这个分配器处理——包括垃圾回收器操作、模块加载,以及表、字符串、函数等的创建。

分配器函数的签名如下:

Google AdInline article slot
void *luaAlloc(void *ud, void *ptr, size_t currSize, size_t newSize);
  • ud — 用户数据(通常是指向内存控制器状态的指针);
  • ptr — 当前块的指针(分配新块时可能为 NULL);
  • currSize — 当前块大小或对象类型(Lua 5.2+ 中当 ptr == NULL 时);
  • newSize — 请求的新大小(0 表示释放)。

这为内存分配提供了完全控制,从而可以跟踪并限制其使用。

实现限额分配器

为了跟踪已分配内存量,我们引入一个状态结构:

struct LimitedAllocatorState {
    size_t used {};
    size_t limit {1 * 1024 * 1024}; // 1 MB by default
    bool limitReached {false};
    bool overflow {false};

    bool isLimitEnabled() { return limit > 0; }
    void disableLimit() { limit = 0; }
    void resetErrorFlags() { limitReached = overflow = false; }
};

分配器会检查新请求(used - currSize + newSize)是否超过设定的限制。如果超过,则返回 nullptr,这会在 Lua 中触发错误(例如 not enough memory)。

Google AdInline article slot

以下是关键实现要点:

  • newSize == 0 时——内存被释放,usedcurrSize 减少;
  • ptr == NULL 时——分配新块,忽略 currSize
  • 必须防范整数溢出;
  • 分配器状态必须比 lua_State 存活得更久。

示例函数:

void *limitedAlloc(void *ud, void *ptr, size_t currSize, size_t newSize) {
    auto *allocState = static_cast<LimitedAllocatorState*>(ud);
    if (!allocState) return nullptr;

    if (ptr == nullptr) currSize = 0;
    if (newSize == 0) {
        if (ptr != nullptr) {
            allocState->used = (allocState->used >= currSize)
                ? allocState->used - currSize : 0;
        }
        std::free(ptr);
        return nullptr;
    }

    const size_t usedBase = (allocState->used >= currSize)
        ? allocState->used - currSize : 0;

    if (newSize > SIZE_MAX - usedBase) {
        allocState->overflow = true;
        return nullptr;
    }

    const size_t newUsed = usedBase + newSize;
    if (allocState->isLimitEnabled() && newUsed > allocState->limit) {
        allocState->limitReached = true;
        return nullptr;
    }

    void *newPtr = std::realloc(ptr, newSize);
    if (newPtr) allocState->used = newUsed;
    return newPtr;
}

与 C++ 运行时的集成

在使用 sol2 库(Lua C API 的流行封装)时,创建带有自定义分配器的状态如下所示:

Google AdInline article slot
sol::state lua(sol::default_at_panic, limitedAlloc, &allocState);

重要的是要遵循类中字段声明的顺序:分配器状态必须在 sol::state 之后 被销毁;否则,状态销毁时会访问已释放的内存。

建议将所有逻辑封装在 LuaRuntime 类中,该类负责:

  • 管理状态生命周期;
  • 提供重置错误和动态更改限制的方法;
  • 允许检查配额是否已超限。

示例接口:

class LuaRuntime {
private:
    lua::memory::LimitedAllocatorState allocatorState;
    sol::state state;

public:
    LuaRuntime(size_t memoryLimit)
        : allocatorState({.limit = memoryLimit}),
          state(sol::default_at_panic, lua::memory::limitedAlloc, &allocatorState) {}

    bool hasAllocError() {
        return allocatorState.limitReached || allocatorState.overflow;
    }

    void resetAllocErrors() {
        allocatorState.resetErrorFlags();
    }

    bool setMemoryLimit(size_t limit) {
        allocatorState.limit = limit;
        return true;
    }
};

限制与实用建议

使用限额分配器时需注意几点重要事项:

  • 限制适用于整个状态。 如果在一个 lua_State 上使用多个沙箱,它们会共享单一内存预算。要实现严格隔离——每个沙箱使用一个状态。
  • 垃圾回收器也会消耗内存。 即使在 Lua 中删除对象,GC 运行期间内存使用量仍可能暂时增加。
  • 分配错误并不总是触发 panic。 Lua 会生成异常,可通过 pcall 或 panic 处理程序捕获。
  • 别忘了重置错误标志。 处理错误后,limitReached 会保持设置状态,直至显式重置。

还要注意,某些操作(例如连接长字符串或深度递归)可能导致内存消耗急剧上升。使用真实负载进行测试至关重要。

关键要点

  • Lua 通过自定义分配器允许完全控制内存分配。
  • 内存限制适用于整个状态,而非单个脚本。
  • 超过限制时,分配器返回 nullptr,在 Lua 中触发错误。
  • 分配器状态必须比 lua_State 存活得更久。
  • 整数溢出防护和正确算术运算对可靠实现至关重要。

— Editorial Team

Advertisement 728x90

继续阅读