ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ESP32 没有进程沙箱?用任务隔离+脚本白名单+分区存储实现安全隔离

ESP32 没有进程沙箱?用任务隔离+脚本白名单+分区存储实现安全隔离 1. 为什么 ESP32 上根本没进程沙箱1.1 什么才叫“小应用”先把“小应用”三个字说清楚不然整个讨论会跑偏。我见过三类情况第一类是用户可编写脚本的智能硬件比如一个带屏幕的 IoT 设备用户在网页端或 App 里下发一段逻辑设备端要解析执行。这类脚本就是“小应用”。第二类是含第三方组件的固件产品主固件里集成了一块从供应商拿来的 SDK你只希望它访问某些 GPIO不希望它乱碰 SPI Flash 或者 WiFi 协议栈。这类 SDK 也是一种“小应用”。第三类是模块化产品中可动态更新的功能块比如说设备整体是一个“宿主”可以下载并运行新功能类似手机装 App但 MCU 上没有 App 的进程边界。不管哪种场景本质诉求都一样一个代码片段/组件能做的操作必须被限定在一个可控集合里。它不能随便读写任意内存不能霸占 CPU不能改写别人的配置不能把自己搞崩了还把系统拖下水。1.2 CPU 和内存一台没有墙的公寓为什么说 ESP32 上没有传统意义上的进程沙箱因为沙箱的底层是 MMU——内存管理单元。PC 和服务器靠 MMU 给每个进程造出一堵墙进程访问的虚拟地址会被翻译并检查权限越界就是 Segmentation Fault由操作系统把进程杀掉别的事情不受影响。ESP32 没有这套东西。它没有 MMU只有一个平坦的物理地址空间CPU 看到的地址就是实际地址。运行在 Xtensa 或 RISC-V 核心上的代码访问任何合法的内存地址、外设寄存器几乎都是直接命中。可以理解为一套公寓里没有隔断墙每个租客推门就能进别人房间。再叠加一点加深理解ESP32 上跑的 FreeRTOS任务Task本质上是线程而不是进程。任务之间通过调度器切换上下文但全都跑在同一个地址空间里没有页表隔离。一个任务写越界另一个任务的数据随时可能被破坏甚至整个系统直接 panic。经典 ESP32ESP32-D0WD 这类使用 Xtensa LX6 的芯片连硬件内存保护单元MPU都没有。你想通过硬件寄存器配置一块区域只读、一块区域不可执行在经典 ESP32 上是做不到的。ESP32-C3 是 RISC-V 核心硬件上带物理内存保护PMP但 ESP-IDF 默认没有提供顺手的 API大部分项目也没把它用起来。后面我会单独讲这个。1.3 沙箱真正要防住的四件事既然硬件不给力那我们要设计的“沙箱”至少要防住四类风险风险类型具体表现后果内存越界指针乱写、数组越界、栈溢出数据损坏、系统崩溃外设越权乱改 GPIO、SPI、I2C、UART 寄存器硬件失效、总线冲突资源耗尽死循环、无限分配内存、狂发包其他任务饿死、看门狗复位持久化破坏乱改 NVS、Flash 分区、固件区配置丢失、OTA 变砖理解这几类风险后再往下看硬件层、软件层、语言层分别能挡多少。2. 硬件层能挡多少安全启动、Flash 加密与芯片差异2.1 Secure Boot v2 和 Flash Encryption 是边界不是隔离墙很多人一上来就开 secure boot 和 flash encryption认为这就是沙箱。其实它们是两码事。Secure Boot v2 保证设备只能跑被签名的固件Flash Encryption 让外部读出的 flash 内容变成密文防止别人直接 dump 固件分析逻辑。这层保护解决的是“攻击者能不能往设备里塞恶意固件”“别人能不能把固件抄走”的问题。它不能解决“运行中的小应用能不能越界访问内存”的问题。你可以理解为公寓大门加了保安但公寓里面还是没有隔断墙。不过这层仍然值得做尤其当“小应用”可能来自不信任渠道时。如果连签名校验都没有别人直接改写你的 app 分区那不是沙箱不沙箱的问题是整台设备直接变成别人的。实际操作路径是在idf.py menuconfig中开启Security features → Enable hardware Secure BootSecurity features → Enable flash encryption on boot开启后需要烧录 eFuse这一步是单向的烧进去就改不回来了。量产前一定要想清楚开发板别急着开 hardware secure boot先用开发模式验证等固件稳定了再烧 fuses。我见过有人把 eFuse 里的调试口也禁了结果固件 crash 连日志都拿不到只能靠串口旁边哭。2.2 eFuse 里还有几个值得关注的开关除了安全启动和加密eFuse 里还能配置禁用 JTAG、禁用下载模式、禁止读取 eFuse 内容等。这些对“限制小应用”同样重要如果 JTAG 还开着任何人拿到板子都能挂调试器直接读写内存什么沙箱都白搭。建议量产固件至少关掉JTAG 调试口串口 ROM 下载模式如果不需要通过串口刷机不必要的 eFuse 读取权限注意一点关串口下载模式后后续想通过esptool.py或 Flash Download Tools 重新烧录就会变麻烦通常要留一条串口升级通道或者走 OTA。这件事要和供应链、售后流程一起设计别只盯着安全。2.3 芯片差异ESP32-C3 的 PMP 是真的可以用一用硬件层里还有一张牌RISC-V PMP。ESP32-C3 的内核是 RISC-V硬件上带了 Physical Memory Protection可以配置若干内存区域为只读、不可执行或者完全禁止访问。ESP32-S2/S3 用的 Xtensa LX7 内核没有公开可用的 MPU经典 ESP32 更没有。PMP 在 ESP-IDF 里没有太高层的接口但寄存器就在那。Espressif 的文档和不少社区项目里都有裸操作 PMP 的例子。它的粒度比较粗但很适合做一件很实际的事把关键驱动代码驻留的内存区域设为不可写防止小应用把驱动数据覆盖掉。我在 C3 上做过一次试验用 PMP 把一小块关键变量区设为只读脚本引擎想写那块地址会触发异常。效果是有的但你要能接受两个代价一是 IDF 没有官方封装配置逻辑要自己维护二是 PMP 区域数量有限保护条目不能太多。把它当作硬件保险丝用不要指望它替代软件方案。3. 软件层软沙箱第一步FreeRTOS 任务隔离与门禁架构3.1 任务栈是最后一道朴素防线FreeRTOS 给不了地址空间隔离但能给你很多调度和监控的手段。先说栈。每个任务要有自己的栈栈大小在xTaskCreate()时指定。任务栈溢出是最常见的事故来源好在 FreeRTOS 提供了检测机制。ESP-IDF 中可以在 menuconfig 里开启栈溢出检测比如CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK。更推荐的做法是主动监控高水位。每个任务跑一段时间后用uxTaskGetStackHighWaterMark()查一下还剩多少栈空间最低值记录下来给栈大小留出 1.5 到 2 倍余量。我一般在系统稳定运行几天后拉一份各任务的高水位数据根据实测再调栈大小。不要拍脑袋给任务分 4096、8192那是拿崩溃概率开玩笑。3.2 优先级和调度配额防止小应用霸占 CPUFreeRTOS 是抢占式调度高优先级任务会抢 CPU。所以给小应用任务一个尽量低的优先级别让它和 WiFi 协议栈、TCP/IP 任务争抢。WiFi、重任务一般跑在较高优先级小应用跑在低优先级这样即使它写了个死循环系统核心功能还能喘口气。再加上任务看门狗Task Watchdog这是兜底中的兜底。给那个小应用任务喂狗超时的话系统可以直接重启或者让管理任务干掉它。注意 ESP-IDF 不同版本看门狗 API 差异挺大IDF 5.x 和 4.x 的初始化方式不一样抄老代码前先查当前版本的头文件。3.3 门禁架构不要给小应用直接操作外设的权限任务隔离挡不住内存越界但我们可以从架构上把“能做什么”收窄。核心思路是小应用不要直接碰驱动函数而是通过服务任务间接访问外设。比如 GPIO 控制不要让小应用直接调用gpio_set_level()而是给它一个队列typedef struct { uint8_t gpio_num; bool level; } gpio_cmd_t; // 小应用任务向队列发送命令 gpio_cmd_t cmd { .gpio_num 4, .level true }; xQueueSend(gpio_cmd_queue, cmd, pdMS_TO_TICKS(10)); // 独立的管理任务从队列取出并执行 xQueueReceive(gpio_cmd_queue, cmd, portMAX_DELAY); gpio_set_level(cmd.gpio_num, cmd.level);在这个中间层里你可以做参数校验比如 GPIO 编号必须在白名单里、PWM 占空比必须在安全区间、SPI 命令长度不能超过缓冲区。小应用根本不接触底层驱动所有访问都走你控制的“门卫”。把外设封装成服务后你也就能给每个服务加日志、加频控出问题时有据可查。3.4 全局变量和共享资源所有共享都要交到中间层任务隔离还有一个容易被忽略的点不要到处共享全局变量。如果两个任务读写同一个全局变量没有任何机制帮你保证原子性编译器优化还可能让问题更隐蔽。ESP32 是双核两个核同时跑两个任务真并发访问一个变量就是一个很经典的竞态条件。我踩过的坑是主任务维护一个状态结构体脚本任务直接改里面的字段偶尔跑着跑着状态变成半新半旧。后来把结构体访问全部改成通过队列传递命令、在服务任务内串行处理问题就消失了。这不算严格的沙箱但它极大降低了出问题的可能性。4. 真正好用的“小应用”沙箱脚本引擎加 API 白名单4.1 为什么脚本引擎才是正解我们已经看到FreeRTOS 任务隔离只能做调度和资源配额无法阻止内存越界。那要真正做到“小应用不能做什么”最干净的办法是把小应用从「编译进固件的代码」变成「运行在解释器里的脚本」。解释器有一个天然好处脚本没有能力直接访问任意内存地址。脚本语言里根本没有指针没有地址运算它的访问都是通过解释器暴露的 API 完成的。你只暴露gpio_set、wifi_connect、log_print脚本就只可能做这三件事。这就是语言级沙箱。这跟 PC 上的做法思路完全相反。PC 是内核强制隔离程序还是原生执行MCU 上我们没有这个内核能力于是干脆让代码不要原生执行让解释器当那个“中间人”。代价是性能损失和内存占用但在 ESP32 上典型的小应用逻辑只是配置、状态机、轻量控制性能完全够用。4.2 引擎选型mJS、Lua 还是 QuickJS我实际用过三种引擎说下体感引擎内存占用适用场景注意事项mJS非常小约 15-20KB ROM简单配置、控制逻辑Espressif 官方维护JS 子集语法有限别写太花哨Lua中等可裁剪业务逻辑较强生态成熟裁剪麻烦需要自己控制标准库QuickJS较大复杂逻辑、数据解析功能全ESP32-S3 上跑得动内存吃紧要小心如果只是给用户开放一些“自定义规则”我最推荐 mJS。它跟 ESP-IDF 配套直接idf.py add-dependency就能接入API 也比较简单。如果小应用逻辑已经有现成 Lua 代码那就用 Lua反正都是库看你团队熟悉哪个。4.3 API 白名单设计宁可手写十行 C不给脚本一个危险函数这是整个方案的核心。我把小应用脚本能用的东西列得很窄刚开始“抠”到只有四五个函数gpio_set(pin, level)gpio_read(pin)delay_ms(ms)log_print(str)set_target_temperature(temp)这种业务自定义函数注册方式很简单以 mJS 为例static mjs_val_t js_gpio_set(struct mjs *mjs) { int pin (int)mjs_arg_int(mjs, 0); int level (int)mjs_arg_int(mjs, 1); if (pin 0 || pin 32) { return MJS_EXCEPTION; } gpio_set_level(pin, level); return MJS_UNDEFINED; } mjs_add_c_function(mjs, gpio_set, js_gpio_set, 2);门禁就藏在参数校验里。GPIO 引脚白名单、数值范围都在这一步拦截底层gpio_set_level永远只接受校验过的参数。相反文件读写、socket、系统重启、任务创建这类函数一个都别暴露给脚本。有人可能会说“我的用户很信任给个文件读写无所谓”——别赌。脚本环境里一个无意写错路径就可能把配置全清掉到时候哭的是客服。4.4 执行超时与步数限制脚本死循环要能摁死脚本引擎最大的崩点是死循环。用户在脚本里写一个while(true) {}解释器立刻把 CPU 吃满其他任务全被饿死。解决办法分成两层。第一层是解释器内部设置执行上限。QuickJS 有JS_SetInterruptHandler()你可以注册一个回调在 JS 执行 N 条指令后触发Lua 可以用 debug hooklua_sethook(L, my_hook, LUA_MASKCOUNT, 100000)每执行 10 万条指令回调一次在回调里判断执行时间是否超时。mJS 没这么方便所以更依赖下面的第二层。第二层是任务级“断头台”。脚本运行在一个单独任务里低优先级且有等待。如果管理任务确定脚本超时直接vTaskDelete()删掉脚本任务或者用任务清零信号促使脚本退出。更稳妥的是给运行环境加一个看门狗整段时间内脚本任务没有主动汇报就判定异常重启脚本任务本身而不影响主系统。我在产品里验证过这个思路一个脚本死循环跑满 CPU另一个监控任务 500ms 后把它杀掉系统照常运行。注意删任务时如果引擎里正持有着某个互斥锁要处理好否则别的任务会卡死。5. 分区表、文件系统与网络访问也要一起管住5.1 分区表隔离小应用的数据别和系统数据混在一起前面讲的都是运行时的行为限制还有一个非常重要的维度持久化数据隔离。配置 ESP-IDF 的分区表时把不同用途的数据放不同分区。下面是一个可用的分区表示例# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 24K, otadata, data, ota, 0xF000, 8K, phy_init, data, phy, 0x10000, 4K, factory, app, factory, 0x20000, 1M, storage, data, spiffs, , 1M, logfs, data, spiffs, , 512K,小应用的脚本文件、导出数据都放在storage分区系统日志放logfsNVS 只给系统配置和设备元数据用。这样一来就算脚本把它的文件系统写烂了最多重刷storage不会影响主固件和系统配置。文件系统挂载时也注意一点不要到/根目录随便建文件夹让脚本乱放。自己封装一个app_storage_read/writeAPI路径白名单校验后再调fopen/littlefs。这样脚本里永远没有路径穿越的可能。5.2 NVS 的命名空间隔离NVS 支持不同的 namespace我在项目里给每个功能模块单独分配一个 namespacenvs_handle_t sys_handle; nvs_open(sys_config, NVS_READWRITE, sys_handle); nvs_handle_t app_handle; nvs_open(app_config, NVS_READWRITE, app_handle);小应用只能通过服务层读写app_config里的键值系统配置统一在sys_config两边物理上互不干扰。ESP-IDF 的 NVS 是按 namespace 隔离的不用服务层直接给脚本开放 NVS API 的话它连别的 namespace 都读不到这本身就是一个免费的隔离墙。5.3 网络访问限制与应用层防火墙思路一致但更手工ESP32 跑的是 lwIP没有用户态防火墙。你能做的是在应用层封装网络 API只允许白名单目标。我给小应用提供的网络接口就只有两种http_get(url, max_len)mqtt_publish(topic, payload)调用前在 C 层做域名白名单和流量上限检查。如果你想更稳一点TLS 证书固定也很有用防止中间人改写返回内容。用 mbedTLS 时把服务器证书或者公钥哈希写死在固件里不信任系统里的任何 CA 链。另一个容易忽略的点是关闭不必要协议栈的服务。如果小应用只需要 MQTT那 HTTP server、mDNS、softAP 都别开服务越少攻击面越小。WiFi 不用的场景直接把 wifi 停掉蓝牙不用的场景把 BLE 控制器停掉。如果你项目里接了 LAN8720 以太网模块也会发现一个现实问题PHY 的时钟配置、复位顺序、MDC/MDIO 上拉只要错一点网络栈就起不来所以网络稳定前提要先抓好不然你白名单做得再好也架不住物理层天天掉线。6. 实操避坑我从这些地方翻过车6.1 坑栈溢出检测没开崩得毫无预兆我最开始没开栈溢出检测脚本任务栈给到 4096 觉得够用结果跑了一周后偶发 panic。查了几个小时最后发现是高水位已经接近 0。后来在 menuconfig 里打开CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK并且把每个任务的栈余量打印到日志里持续观察改成了 8192 才稳。栈大小真的要靠实测数据定不能靠“感觉”。6.2 坑Flash 加密和 OTA 的兼容性没考虑有一版固件我先开启了 flash encryption后来又想通过 OTA 升级结果升级后设备起不来。原因是旧分区的加密标记和新固件校验没对上。Flash encryption 开启后OTA 分区也要区分配置bootloader 和 partition table 都要统一。量产的板子一旦烧了 eFuse 就不能回头建议在“开发模式”下把 OTA 完整流程走通一遍再切量产模式。6.3 坑脚本引擎死循环卡死了整台设备这个是脚本沙箱方案里最容易翻的一个。我的第一版只设置了解释器 hook但 hook 回调里没有及时检查某个状态导致死循环还是把任务 WDT 触发整机重启。后面改成了“监控任务 超时清理 运行状态上报”三件套才真正兜住。给脚本引擎分配任务时务必保证它和监控任务在不同优先级让监控任务永远有机会抢到执行权。6.4 坑IDF 版本切换导致 API 不兼容ESP-IDF 升级很频繁任务看门狗、NVS 操作、mbedTLS 的一些接口在新版本里都有变化。我的经验是项目里锁定一个 IDF 版本别在项目中途随手升级。如果非升级不可重点回归三块FreeRTOS 配置、安全相关 API、文件系统挂载。6.5 排查工具和思路遇到诡异问题我优先做这几件事开idf.py monitor看系统日志和崩溃回溯用esp32-cxx崩溃地址反查到具体函数检查串口输出里的任务高水位用逻辑分析仪抓 GPIO/SPI 时序排除外设层问题关掉优化等级-O0重新编译观察问题是否复现这套流程能覆盖大半的“小应用”越权问题。最后说一下我的个人心得真正能在量产里稳住的方案永远是“硬件边界 FreeRTOS 任务约束 脚本引擎白名单 分区存储隔离”这几层组合起来而不是迷信某一个环节。每加一层都相当于给“小应用”多上了一道锁而不是指望一层锁永固。ESP32 没有进程沙箱这件事其实没那么可怕怕的是把这个问题拖到崩溃现场才去解决。
返回列表