多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

ESP32上如何为第三方代码构建安全沙箱:WebAssembly与权限模型实战

ESP32上如何为第三方代码构建安全沙箱:WebAssembly与权限模型实战 1. 从一个真实困境说起MCU 上跑第三方代码凭什么让人放心前阵子有个做智能硬件的朋友找我聊他们团队在 ESP32 上做了一个挺有意思的东西设备出厂后允许用户自己上传一小段逻辑代码用来控制外设行为比如温度超过 30 度就打开风扇低于 25 度就关掉或者检测到特定蓝牙广播就点亮 LED。想法很好但问题马上就来了——用户上传的代码是任意的万一有人写了个死循环或者偷偷去读 Flash 里的密钥分区甚至把 Wi-Fi 配置改了整个设备就废了。他问我ESP32 又没有进程沙箱我该怎么限制一个小应用能做什么这个问题其实非常典型。在 Linux 或者 Android 上我们有进程隔离、有权限系统、有 seccomp、有 SELinux一个应用想干坏事操作系统层面就能拦住。但 ESP32 这类 MCU 上跑的是 FreeRTOS所有任务共享同一个地址空间没有 MMU内存管理单元没有用户态和内核态的区分一个任务理论上可以访问任意内存地址、调用任意 API。换句话说MCU 上根本没有传统意义上的进程沙箱这回事。那怎么办答案不是没办法而是换一种思路。既然操作系统层面给不了隔离我们就在应用层自己造一个受控的执行环境。目前业界在 MCU 上做这件事主流有三条路一是字节码虚拟机比如 MicroPython、Lua、JerryScript二是WebAssembly 运行时比如 WAMR、wasm3三是自定义 DSL 解释器。这三条路的共同点是用户代码不是原生机器码而是运行在一个由我们完全掌控的解释器/虚拟机里解释器只暴露我们允许的 API其余一律碰不到。这篇文章我就围绕这个核心问题展开把在 ESP32 上给一个小应用划边界这件事讲透。不管你是做 IoT 设备、做教育硬件还是单纯想在 MCU 上跑点可热更新的逻辑这套思路都能直接参考。我会重点讲清楚为什么选 WebAssembly 或字节码方案、权限模型怎么设计、内存和 CPU 怎么限额、以及实际落地时踩过的那些坑。2. 为什么 MCU 上的沙箱必须自建原理与方案选型2.1 先搞清楚ESP32 到底缺了什么要理解为什么不能照搬 Linux 的沙箱方案得先明白 ESP32 的硬件架构限制。ESP32 用的是 Xtensa LX6或 RISC-V 的 C 系列它没有 MMU只有 MPUMemory Protection Unit。MPU 能做的事情很有限它可以把内存划分成若干个区域给每个区域设置读/写/执行权限但它不支持地址翻译也就是说所有任务看到的还是同一套物理地址。这就意味着你没法像 Linux 那样让每个进程以为自己独占整个地址空间。没有地址翻译就没有真正的进程隔离。FreeRTOS 的任务切换只是保存和恢复寄存器上下文任务 A 的指针可以直接指向任务 B 的栈。所以指望在 ESP32 上做出进程级沙箱方向本身就错了。那 MPU 能不能用能用但很别扭。ESP-IDF 提供了esp_mpu相关的一些能力你可以把某段内存标记为只读或者不可执行防止代码被篡改。但 MPU 的区域数量很少通常 8 个左右而且配置起来是全局的很难做到每个应用一套独立权限。更关键的是MPU 拦不住 API 调用——用户代码只要拿到esp_wifi_set_config的函数指针照样能改 Wi-Fi。所以结论很明确在 ESP32 上做沙箱不能依赖硬件隔离必须依赖软件层的能力收窄。核心思想是不让用户代码直接跑在硬件上而是让它跑在一个我们写的解释器里解释器只认识我们定义的那几个安全动作。2.2 三条主流路线的取舍我把目前常见的三种方案拉出来对比一下方便你根据项目情况选。方案代表实现隔离强度内存开销性能上手难度适合场景字节码虚拟机MicroPython、Lua中中几十 KB 起中低逻辑控制、脚本化配置WebAssemblyWAMR、wasm3高中高WAMR 约 100KB中高中需要多语言、强隔离自定义 DSL自己写解释器高完全可控低低高规则简单、资源极紧MicroPython 和 Lua 的好处是生态成熟用户上手快但它们的标准库太全了——MicroPython 默认就能访问machine模块能直接操作引脚、I2C、SPI。你要做沙箱得把这些模块一个个裁掉或者包一层工作量不小而且容易漏。Lua 相对好一点因为 Lua 的 C API 天然就是你注册什么它才能用什么隔离性更好做。WebAssembly 是这几年在 MCU 上越来越火的方向。WAMRWebAssembly Micro Runtime和 wasm3 都能在 ESP32 上跑。WASM 的隔离模型天生就适合沙箱模块只能访问自己线性内存里的数据所有对外部世界的调用都必须通过 import 的宿主函数。这意味着你只要控制好 import 列表用户代码就绝对碰不到硬件。这是它比字节码方案更干净的地方。代价是内存占用和启动开销更大WAMR 在 ESP32 上大概要 100KB 以上的 RAMwasm3 轻一些但性能弱。自定义 DSL 适合规则特别简单的场景比如你只需要用户配置条件-动作对那写个 JSON 解释器就够了根本不需要通用语言。但如果用户需要写循环、写函数DSL 就会迅速膨胀成重新发明一门语言不划算。我个人的建议是如果用户代码逻辑复杂、需要多语言支持选 WebAssembly如果只是轻量脚本、追求低资源选 Lua如果只是配置规则直接上 JSON DSL。下面我主要以 WebAssembly 为主线讲因为它的权限模型最清晰也最能回答怎样限制一个小应用能做什么这个问题。2.3 沙箱的本质把能做什么变成一张白名单不管选哪条路沙箱的核心逻辑都是一样的默认拒绝显式允许。用户代码启动时它面对的是一个空荡荡的世界只有我们主动注册进去的那几个函数它才能调用。打个比方这就像给一个访客发门禁卡。Linux 的进程沙箱是给你一整栋楼但每扇门上有锁我告诉你哪些门能开而 MCU 上的沙箱是你只有一个房间房间里只有我放进去的几样东西房间外面你根本看不见。显然后者更彻底因为它连看见的机会都不给。具体到实现权限模型通常分三层能力层Capability定义有哪些原子操作比如gpio_write、adc_read、mqtt_publish。每个能力对应一个宿主函数。策略层Policy定义某个应用被授予哪些能力以及能力的参数范围。比如允许写 GPIO 但只能是 12、13、14 号引脚。配额层Quota定义资源上限比如最多用 32KB 内存、最多执行 100ms、最多调用 1000 次 API。这三层缺一不可。只有能力没有策略等于把所有引脚都开放只有策略没有配额用户一个死循环就能把设备拖垮。3. 权限模型怎么落地从能力定义到策略校验3.1 能力接口的设计原则设计能力接口时有几个原则必须守住否则沙箱会从内部被攻破。第一接口粒度要细但不能太细。太粗比如直接暴露gpio_set用户能操作所有引脚太细比如每个引脚一个函数import 列表会爆炸。合理的做法是按资源类型 操作来分比如gpio_write(pin, level)然后在宿主函数内部校验pin是否在允许列表里。第二参数必须校验不能信任用户传进来的任何值。WASM 里用户传的pin是个 i32他完全可以传 999。宿主函数第一件事就是检查范围越界直接返回错误码绝不能让它传到硬件层。第三返回值要收敛。不要返回指针不要返回内部结构体地址。只返回数值或者错误码。一旦返回指针用户就能顺着指针去读写任意内存沙箱瞬间失效。下面是一个能力注册的示意代码用 WAMR 的 API 风格写// 定义能力写 GPIO static int32_t host_gpio_write(wasm_exec_env_t exec_env, int32_t pin, int32_t level) { // 1. 参数范围校验 if (pin 0 || pin GPIO_MAX_PIN) { return ERR_INVALID_PIN; } // 2. 策略校验该应用是否被授予此引脚 app_context_t *ctx get_current_app(exec_env); if (!policy_check_gpio(ctx, pin)) { return ERR_PERMISSION_DENIED; } // 3. 配额校验调用次数是否超限 if (!quota_consume_api(ctx)) { return ERR_QUOTA_EXCEEDED; } // 4. 真正执行 gpio_set_level(pin, level); return OK; } // 注册到运行时 static NativeSymbol native_symbols[] { { gpio_write, host_gpio_write, (ii)i, NULL }, { adc_read, host_adc_read, (i)i, NULL }, // ... 其他能力 };这段代码里policy_check_gpio和quota_consume_api就是策略层和配额层的入口。注意第 2 步和第 3 步的顺序——先校验权限再扣配额否则被拒绝的调用也会消耗配额逻辑上说不通。3.2 策略的存储与加载策略数据本身要放在用户代码碰不到的地方。在 ESP32 上通常放在 NVS非易失存储或者一个独立的 Flash 分区里由主固件读取绝不通过沙箱接口暴露。策略的格式我推荐用简单的位图或者数组别上 JSON——解析 JSON 又费内存又费 CPU而且解析器本身可能成为攻击面。比如 GPIO 权限用一个 64 位的位图就够了每一位代表一个引脚。ADC 通道、外设编号同理。typedef struct { uint64_t gpio_allowed_mask; // 哪些引脚可写 uint32_t adc_allowed_mask; // 哪些 ADC 通道可读 uint16_t api_quota; // API 调用总次数上限 uint32_t mem_quota_bytes; // 内存上限 uint32_t time_quota_ms; // 单次执行时间上限 uint8_t net_allowed; // 是否允许网络操作 } app_policy_t;加载策略的时机是在应用启动前由主固件从 NVS 读出填充到app_context_t里。应用运行期间这个结构体是只读的沙箱接口只能读不能写。3.3 一个容易忽略的点能力也要能动态收窄有些场景下权限不是固定的。比如一个应用在初始化阶段需要写 GPIO但进入运行阶段后就不该再写了。这时候策略要支持动态调整。实现方式很简单在app_context_t里加一个当前阶段字段policy_check_gpio根据阶段返回不同的结果。或者更直接一点提供一个宿主函数revoke_capability(cap_id)让主固件在合适的时机调用把某个能力从白名单里摘掉。这个机制在安全上很有用。比如用户代码上传后先在一个受限模式下跑一遍只允许它做纯计算确认没有异常行为后再授予硬件访问权限。这有点像浏览器的先加载后执行策略。4. 资源限额内存、CPU、API 调用怎么卡死权限解决了能碰什么配额解决的是能用多少。在 MCU 上后者往往比前者更致命——一个死循环就能让看门狗复位设备直接重启。4.1 内存限额线性内存 栈双重限制WebAssembly 的内存模型天然适合做限额。每个 WASM 模块有自己的线性内存Linear Memory这是一块连续的字节数组模块内部的所有数据都在这块内存里。你可以在实例化模块时指定最大页数1 页 64KB超出就分配失败。// WAMR 实例化时限制内存 wasm_runtime_full_init(init_args); // 模块最大内存设为 2 页 128KB uint32_t stack_size 8 * 1024; // 栈 8KB uint32_t heap_size 32 * 1024; // 堆 32KB module_inst wasm_runtime_instantiate(module, stack_size, heap_size, error_buf, sizeof(error_buf));这里有个细节要注意WASM 的线性内存和宿主给它的栈/堆是两回事。线性内存是模块自己memory.grow扩展的栈和堆是运行时分配的。两个都要限制。栈太小会栈溢出太大会挤占其他任务的内存。在 ESP32 上我一般给沙箱任务留 16KB 到 32KB 的栈具体看用户代码的递归深度。还有一个坑WASM 模块的线性内存默认是可以增长的如果不禁用memory.grow用户代码可以一直申请内存直到耗尽。WAMR 支持设置最大内存页数一定要设。wasm3 也有类似配置。4.2 CPU 限额时间片 指令计数CPU 限额比内存难做因为 MCU 上没有抢占式的CPU 时间概念。常见有两种做法。第一种是时间片轮询。把沙箱执行放在一个独立的 FreeRTOS 任务里主固件用vTaskDelay或者定时器定期检查这个任务跑了多久。如果超过阈值直接vTaskDelete干掉它。这种做法的缺点是粒度粗一个任务可能已经跑了 200ms 才被发现。第二种是指令计数。WASM 运行时通常支持每执行 N 条指令回调一次的机制。WAMR 有wasm_runtime_set_instruction_count_limitwasm3 也有类似的 hook。你设一个上限比如 100 万条指令超了就中断执行。// 设置指令数上限超过则执行中断 wasm_runtime_set_instruction_count_limit(module_inst, 1000000);我实测下来指令计数比时间片更可靠因为它不受任务调度影响结果可复现。但要注意指令计数不能设得太死否则正常的复杂计算也会被误杀。一般先跑一遍典型用户代码统计指令数然后留 3 到 5 倍余量。4.3 API 调用配额防止合法但滥用有些攻击不需要死循环只需要高频调用合法 API。比如用户代码在一个循环里疯狂调用mqtt_publish把网络带宽占满或者疯狂写 GPIO 造成电磁干扰。这种合法但滥用的行为只能靠 API 调用配额来拦。实现很简单在app_context_t里放一个计数器每次宿主函数被调用就减一减到零就返回ERR_QUOTA_EXCEEDED。计数器在应用启动时重置。bool quota_consume_api(app_context_t *ctx) { if (ctx-api_calls_remaining 0) { return false; } ctx-api_calls_remaining--; return true; }配额值怎么定我的经验是按应用的生命周期来算。如果一个应用是每次事件触发跑一次那配额可以设小一点比如 100 次如果是常驻后台循环执行那要按秒来算比如每秒 50 次。关键是让正常使用永远碰不到上限而滥用行为很快触顶。4.4 一张配额参数参考表不同场景下配额差异很大我整理了一张表你可以根据自己的硬件和业务调整。资源类型轻量脚本场景中等逻辑场景复杂计算场景说明线性内存32KB64KB128KB含模块数据段任务栈8KB16KB32KB递归深度决定指令数上限10 万100 万500 万单次执行API 调用次数505002000单次执行执行时间20ms100ms500ms看门狗前必须结束模块大小16KB64KB256KBFlash 占用注意执行时间一定要留足余量。ESP32 的看门狗默认超时是 5 秒左右但如果你在沙箱任务里阻塞了其他高优先级任务实际可用的时间会更短。我一般把沙箱单次执行控制在 500ms 以内超过就强制中断。5. 完整实操在 ESP32 上跑一个受控的 WASM 小应用5.1 环境准备与依赖选择先说环境。我用的是 ESP-IDF v5.xWAMR 用的是官方仓库里的product-mini/platforms/esp-idf配置。wasm3 也可以但 WAMR 的指令计数和内存限制 API 更完善所以主线用 WAMR。依赖清单ESP-IDF v5.0 以上WAMR从官方仓库拉注意选对分支一个 WASM 工具链用来把用户代码编译成.wasm。推荐用wasi-sdk或者emscripten但要注意不要用完整的 WASI因为 WASI 会引入文件系统、时钟等一堆你不想暴露的接口。我一般用clang --targetwasm32 -nostdlib自己控制导入。编译用户代码的命令大概是这样clang --targetwasm32 -nostdlib -Wl,--no-entry \ -Wl,--exportrun \ -Wl,--allow-undefined \ -o app.wasm app.c--allow-undefined是关键它允许用户代码引用未定义的函数也就是我们的宿主函数这些函数会在实例化时通过 import 解析。--exportrun指定入口函数。5.2 主固件侧的沙箱初始化主固件要做四件事初始化 WAMR 运行时、加载模块、注册宿主函数、实例化并执行。// 1. 初始化运行时 RuntimeInitArgs init_args; memset(init_args, 0, sizeof(init_args)); init_args.mem_alloc_type Alloc_With_Allocator; init_args.mem_allocator.alloc_func my_malloc; init_args.mem_allocator.realloc_func my_realloc; init_args.mem_allocator.free_func my_free; wasm_runtime_full_init(init_args); // 2. 注册宿主函数能力白名单 wasm_runtime_register_natives(env, native_symbols, sizeof(native_symbols) / sizeof(NativeSymbol)); // 3. 加载模块 uint8_t *wasm_buf load_app_from_flash(wasm_size); module wasm_runtime_load(wasm_buf, wasm_size, error_buf, sizeof(error_buf)); // 4. 实例化限制栈和堆 module_inst wasm_runtime_instantiate(module, 16 * 1024, 32 * 1024, error_buf, sizeof(error_buf)); // 5. 设置指令数上限 wasm_runtime_set_instruction_count_limit(module_inst, 1000000); // 6. 调用入口函数 wasm_application_execute_func(module_inst, run, 0, NULL);注意第 2 步的env是模块名用户代码里import的时候要对应上。如果模块名对不上实例化会失败报failed to create module configuration之类的错误——这个报错我在热词里也看到有人问多半就是模块名或者函数签名不匹配。5.3 用户代码长什么样用户侧写起来其实很直观。比如一个温度控制风扇的应用// 声明宿主函数这些是沙箱允许的能力 __attribute__((import_module(env), import_name(adc_read))) extern int adc_read(int channel); __attribute__((import_module(env), import_name(gpio_write))) extern int gpio_write(int pin, int level); __attribute__((import_module(env), import_name(log_info))) extern void log_info(const char *msg, int len); int run(void) { int temp adc_read(0); if (temp 3000) { gpio_write(12, 1); // 开风扇 log_info(fan on, 6); } else if (temp 2500) { gpio_write(12, 0); // 关风扇 log_info(fan off, 7); } return 0; }这段代码里用户只能调用adc_read、gpio_write、log_info三个函数。他想调用esp_wifi_set_config编译器直接报未定义。想直接访问内存WASM 的线性内存和硬件地址是两套东西他访问不到。这就是沙箱的价值。5.4 执行与中断的完整流程实际运行时我会把沙箱执行放在一个独立任务里主任务负责监控。void sandbox_task(void *arg) { app_context_t *ctx (app_context_t *)arg; // 重置配额 ctx-api_calls_remaining ctx-policy.api_quota; ctx-start_tick xTaskGetTickCount(); // 执行 wasm_application_execute_func(ctx-module_inst, run, 0, NULL); // 检查是否因超限中断 if (wasm_runtime_get_exception(ctx-module_inst)) { const char *ex wasm_runtime_get_exception(ctx-module_inst); ESP_LOGE(TAG, sandbox exception: %s, ex); // 记录日志可能触发应用禁用 } vTaskDelete(NULL); }主任务里用xTaskCreate创建这个任务然后xTaskGetTickCount监控它跑了多久。如果超过时间配额直接vTaskDelete干掉。注意强制删除任务后要清理 WAMR 的实例否则内存泄漏。// 监控逻辑 TickType_t start xTaskGetTickCount(); xTaskCreate(sandbox_task, sandbox, 16 * 1024, ctx, 5, sandbox_handle); while (1) { vTaskDelay(pdMS_TO_TICKS(50)); if (xTaskGetTickCount() - start pdMS_TO_TICKS(500)) { if (sandbox_handle) { vTaskDelete(sandbox_handle); wasm_runtime_destroy_exec_env(ctx-exec_env); ESP_LOGW(TAG, sandbox timeout, killed); } break; } if (eTaskGetState(sandbox_handle) eDeleted) { break; // 正常结束 } }这套流程跑下来一个用户应用从加载到执行到清理全程都在主固件的掌控中。它既碰不到硬件也跑不出时间边界。6. 踩坑实录那些文档里不会写的问题6.1 常见问题速查表现象可能原因排查方向解决方式实例化失败报模块配置错误import 模块名或签名不匹配检查import_module和注册时的模块名统一用env核对函数签名执行时崩溃栈溢出用户代码递归太深看 backtrace 是否指向 WASM 栈增大栈配额或限制递归内存分配失败线性内存增长未限制检查memory.grow调用设置最大内存页数看门狗复位沙箱任务阻塞太久看任务优先级和阻塞点降低优先级加时间监控API 调用返回权限拒绝策略位图未设置检查 NVS 里的策略数据重新烧录策略或修正位图执行结果不稳定指令计数设得太紧统计典型代码指令数放宽到 3-5 倍余量6.2 几个我踩过的坑第一个坑宿主函数里用了阻塞 API。我一开始在host_mqtt_publish里直接调用了阻塞式的 MQTT 发布结果用户代码一调用整个沙箱任务就卡住了时间监控也救不回来因为任务在阻塞态。后来改成非阻塞发布 队列宿主函数只负责入队立即返回。第二个坑WASM 的字符串参数处理。用户传字符串进来传的是线性内存里的偏移量不是真实指针。宿主函数必须用wasm_runtime_addr_app_to_native把偏移量转成宿主地址而且转换后要校验长度否则用户传个超长偏移量就能越界读。这个转换和校验一定要封装成工具函数每个涉及指针的宿主函数都要用。第三个坑模块卸载的顺序。WAMR 里wasm_runtime_unload和wasm_runtime_destroy_exec_env的顺序不能乱。先销毁执行环境再卸载模块最后释放模块缓冲区。顺序错了会 double free。我在这上面浪费了大半天。第四个坑Flash 里的 WASM 缓冲区对齐。ESP32 从 Flash 读数据有对齐要求WASM 模块缓冲区如果不对齐加载时会报错或者读出乱码。我一般用heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)分配一块对齐的内存把 Flash 数据拷进去再加载。6.3 安全加固的额外建议除了上面这些还有几个加固点值得做。一是模块签名。用户上传的 WASM 文件主固件在加载前先验签。用 Ed25519 或者 ECDSA公钥烧在固件里。这样即使有人伪造应用也过不了签名校验。签名验证放在加载之前验不过直接拒绝。二是执行日志审计。每个宿主函数被调用时记录一条日志调用者、函数名、参数、时间戳。日志写到独立的环形缓冲区用户可以查看但不能修改。出问题时这是唯一的追溯依据。三是失败熔断。如果一个应用连续多次触发配额超限或者权限拒绝就把它标记为可疑下次启动直接禁用需要用户手动确认才能重新启用。这能防止恶意应用反复试探边界。四是最小权限原则的贯彻。默认策略应该是什么都不允许然后根据应用声明的需求逐项授予。不要图省事给一个默认全开的策略那等于没做沙箱。7. 关于这套方案能走多远我在实际项目里用这套方案跑了大概半年设备量在几千台级别用户上传的应用有几十个。整体稳定性不错没有出现过因为用户代码导致设备变砖的情况。有几次用户代码触发了配额超限被熔断机制拦下来了日志里看得很清楚。要说局限也有。WAMR 在 ESP32 上的内存占用确实不小如果设备本身 RAM 就紧张比如只有 320KB 的型号跑 WAMR 会比较吃力这时候可能得退回到 Lua 或者自定义 DSL。另外WASM 的调试体验一般用户代码出错时堆栈信息不够直观需要主固件做一层符号映射才能看懂。如果后续要扩展我会考虑两个方向一是把能力接口做成可配置的插件不同产品线注册不同的能力集主固件不用改二是引入应用间通信的受控通道让多个沙箱应用能协作但通信内容也要经过策略校验。这两个方向都能让这套沙箱从单应用隔离走向多应用生态不过复杂度也会上一个台阶得看实际需求值不值得。最后分享一个小技巧在开发阶段把配额设得比生产环境松 10 倍这样调试时不会频繁被拦等逻辑稳定了再收紧到生产值。我一开始没这么做结果调试时老是被配额打断效率很低。这个经验看起来小但能省不少时间。
返回列表