news 2026/10/7 8:04:46

HarmonyOS 7 fflate:离线模型包路径穿越拦截与原子回滚

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS 7 fflate:离线模型包路径穿越拦截与原子回滚

一、导入成功之后,模型目录里多了一份不该出现的文件

ModelVault原本只是内部验收用的 3D 场景包导入器:用户选择room_v7.gsbox,应用解压模型、校验清单,再把目录切换为当前版本。测试包只有 82.4 MB,第一次接入fflate时十几秒就能完成,页面也顺利显示“可加载”。真正的问题出现在一次恶意包测试:ZIP 的第 7 个条目叫../../preferences/tokens.json。解压器没有报错,路径拼接也没有越界异常,文件却落到了暂存目录之外。

这不是“压缩库不安全”这么简单。fflate 0.8.3负责解释 ZIP 和输出字节,目标路径、磁盘配额、事务边界都属于业务代码。直接把entry.name拼到沙箱目录,等于把归档文件的输入当成了文件系统指令。随后我又造了一个 8 MB 压缩、解开后超过 600 MB 的重复数据包,发现页面进度还停在 19%,磁盘空间已经被吃掉大半。两个问题放在一起,才确定导入不能只是“解压后算哈希”,而要做成一条有状态、有预算、失败可清理的提交链。

本次 Demo 固定任务号ZIP-2018,输入包room_v7.gsbox,12 个文件,清单声明解压后 286.3 MB,硬上限 320 MB。完整状态是PREFLIGHT → EXTRACTING → VERIFYING → COMMITTING → READY,任一门禁失败都进入QUARANTINED。这几个值同时出现在日志、页面和验收图中,避免调试时拿“差不多的测试包”解释结果。

二、先划清库的边界:流式输出不等于安全落盘

选fflate的原因很朴素:它是纯 JavaScript/TypeScript 库,支持 ZIP 流式解压,也能为具体文件流注册异步 Inflate。项目只引入Unzip与AsyncUnzipInflate,没有把整包一次性展开到内存。82.4 MB 的输入按 256 KB 喂给解压器,峰值内存最终稳定在 46.8 MB。

但流式 API 只解决“何时拿到字节”。下面四件事必须由BundleImporter自己保证:条目名经过规范化后仍在暂存根目录内;累计产出不得超过 320 MB;落盘后的每个文件必须与manifest.json的 SHA-256 对上;校验完成前不能覆盖线上目录。只要其中一项模糊,流式处理反而会让半成品更早散落到磁盘。

项目结构也按这四个责任拆开,而不是把所有回调堆在页面里:

ModelVault/ ├── entry/src/main/ets/pages/BundleImportPage.ets ├── entry/src/main/ets/importer/BundleImporter.ets ├── entry/src/main/ets/importer/EntryPathGuard.ets ├── entry/src/main/ets/importer/QuotaLedger.ets ├── entry/src/main/ets/importer/ManifestVerifier.ets └── entry/src/main/ets/storage/AtomicModelStore.ets

BundleImportPage只订阅快照并发送“选择、取消、重试”意图;ZIP 回调、文件句柄和暂存目录都由导入器拥有。这样页面退到后台时,资源释放的位置不会散在多个组件生命周期里。

三、第一道门禁不是正则,而是规范化后的相对路径

最先补的是EntryPathGuard。仅仅拒绝字符串中的../不够:反斜杠、重复分隔符、绝对路径、盘符前缀和空字节都可能绕过简单判断。解决问题的代码先统一分隔符,再逐段折叠.;遇到..时直接拒绝,而不是尝试“算出最终路径”。

// EntryPathGuard.etsexportclassEntryPathGuard{staticnormalize(rawName:string):string{constname=rawName.replaceAll('\\','/').trim()if(name.length===0||name.startsWith('/')||name.includes('\u0000')){thrownewError('ZIP_PATH_INVALID')}if(/^[A-Za-z]:/.test(name)){thrownewError('ZIP_DRIVE_PREFIX')}constsafe:string[]=[]for(constpartofname.split('/')){if(part===''||part==='.')continueif(part==='..')thrownewError('ZIP_PATH_TRAVERSAL')safe.push(part)}if(safe.length===0)thrownewError('ZIP_EMPTY_ENTRY')returnsafe.join('/')}}

这里故意不接受任何“向上再回来”的路径,即便数学上最后仍落在根目录。归档格式没有必要表达这种语义,放宽只会增加审计成本。目录条目和文件条目共用同一规则;页面显示的错误固定为ZIP_PATH_TRAVERSAL,HiLog 记录entry=7 name=../../preferences/tokens.json state=QUARANTINED。

这段函数必须在创建文件句柄之前执行。若先openSync再检查,失败分支就需要处理已经生成的空文件。重复导入同一恶意包时,任务代次会增加,但错误条目和暂存残留都应保持为 0;这也是后来自动化验收里很有用的一条断言。

四、配额要跟着产出字节走,而不是相信清单

manifest.json的 286.3 MB 只是预检参考,不能作为安全事实。ZIP 中央目录里的原始大小同样来自输入。真正可靠的数字,是解压回调交付给应用的chunk.length。第二段代码把条目流、全局配额和取消信号收进同一个租约;任何文件越过单文件上限,或总量越过 320 MB,都会终止当前流并进入统一清理。

// BundleImporter.ets(核心片段)import{Unzip,AsyncUnzipInflate,UnzipFile}from'fflate'privateopenArchive(task:ImportTask):Unzip{constunzip=newUnzip((entry:UnzipFile)=>{constrelative=EntryPathGuard.normalize(entry.name)constlease=this.files.openTemp(relative,task.generation)this.activeStreams.set(relative,entry)entry.ondata=(error:Error|null,chunk:Uint8Array,final:boolean)=>{if(task.cancelled||task.generation!==this.generation)returnif(error)returnthis.fail(task,'ZIP_CORRUPTED',error.message)this.quota.consume(relative,chunk.byteLength,320*1024*1024)lease.write(chunk)task.outputBytes=this.quota.totalBytesthis.emit(task,'EXTRACTING')if(final){lease.close()this.activeStreams.delete(relative)this.finished.add(relative)}}entry.start()})unzip.register(AsyncUnzipInflate)returnunzip}cancel(task:ImportTask):void{task.cancelled=truethis.activeStreams.forEach(stream=>stream.terminate())this.activeStreams.clear()this.files.rollbackTemp(task.generation)}

配额台账先累加再写入,越界的那一块不会落盘。consume内部还维护每个条目的 160 MB 上限,防止一个文件独占全部预算。回调不能直接修改 ArkUI 组件;导入器每 100 ms 合并一次快照,页面进度才不会随着数百个数据块频繁刷新。

取消也不是把按钮置灰。terminate()后仍要关闭已打开的句柄、删除代次对应的临时目录,并拒绝旧回调更新新任务。任务ZIP-2018的 generation 是 18,重试后 generation 变成 19;任何携带 18 的回调都只做资源收口,不再写状态。

五、校验通过也不能原地覆盖

恶意包被拦住后,第二个合法包完成了 12/12 条目解压,但第一次提交仍暴露了问题:代码先删除旧模型目录,再重命名暂存目录。两步之间如果应用被杀,用户既没有旧版本,也没有新版本。最终方案把版本目录当成不可变对象,只原子替换一个很小的current.json指针。

// AtomicModelStore.ets(伪代码省略平台错误类型转换)asynccommit(task:ImportTask,manifest:ModelManifest):Promise<void>{this.assertGeneration(task.generation)task.state='VERIFYING'constreport=awaitthis.verifier.verifyAll(task.tempDir,manifest)if(report.passed!==12)thrownewError('HASH_MISMATCH')constversionDir=`${this.modelsRoot}/${manifest.version}-${task.generation}`this.fs.renameSync(task.tempDir,versionDir)task.state='COMMITTING'constpointerTmp=`${this.modelsRoot}/current.json.tmp`this.fs.writeTextSync(pointerTmp,JSON.stringify({version:manifest.version,path:versionDir,taskId:task.id}))this.fs.fsyncSync(pointerTmp)this.fs.renameSync(pointerTmp,`${this.modelsRoot}/current.json`)task.state='READY'}

版本目录先完成重命名,指针文件再经tmp → current.json切换。旧目录不在关键路径中删除,而是下一次冷启动确认当前指针可读后再回收。重复调用commit时,如果current.json已指向同一个 taskId,就直接返回READY;这让进程死亡后的恢复不必猜上一次执行到了哪一步。

哈希校验也必须覆盖清单本身之外的边界:包里多出的文件算失败,清单声明却未出现的文件也算失败。最终合法包报告是entries=12/12 sha256=12/12 extra=0 missing=0,随后才进入提交。

六、从一行异常日志反推完整状态

调试时我没有只看页面百分比,而是给每条日志附上 taskId、generation、state、entry 与累计字节。恶意包的关键日志如下:

20:18:07.114 ModelVault ZIP-2018 PREFLIGHT input=82.4MB entries=12 budget=320MB 20:18:07.462 ModelVault ZIP-2018 EXTRACTING progress=19% output=61.7MB entry=7 20:18:07.466 ModelVault ZIP-2018 QUARANTINED error=ZIP_PATH_TRAVERSAL name=../../preferences/tokens.json 20:18:07.493 ModelVault ZIP-2018 ROLLBACK tempResidual=0 openHandles=0

合法包重试则是 generation 19:output=286.3MB peakMemory=46.8MB,验证 12/12,通过后指针切换耗时 14 ms。日志里没有打印真实沙箱绝对路径,也没有把清单内容整段输出,避免诊断信息本身泄露用户目录。

页面订阅的是不可变ImportSnapshot。退到后台时停止 100 ms 的 UI 合并计时器,但解压任务是否继续由产品策略决定;本 Demo 选择取消并清理,因为模型导入没有后台完成承诺。aboutToDisappear只发取消意图,真正的句柄关闭仍由导入器完成,避免页面与服务重复释放。

七、运行结果:失败包隔离,合法包一次提交

最终手机页保留了两次运行记录。第一条是 generation 18:19% 时命中ZIP_PATH_TRAVERSAL,第 7 个条目被隔离,临时残留 0。第二条仍使用任务号ZIP-2018,但 generation 19:286.3 MB 解压完成、SHA-256 12/12、额外文件 0、缺失文件 0,状态READY。峰值内存 46.8 MB,没有因为输入包 82.4 MB 就把整包和全部输出同时留在内存。

这个结果比“能打开模型”更重要:异常包没有写出根目录,超额包会在产出字节越界的那一刻停止,合法包只有在完整校验后才切换指针。按钮“取消导入”在VERIFYING和COMMITTING阶段会被禁用,其中提交阶段只有 14 ms;这样既不给用户虚假的可取消承诺,也不会制造半提交状态。

八、这条导入链的边界

这套实现并不把 ZIP 当成可信分发协议。包的来源校验、签名和密钥轮换仍应在更外层完成;本文的 SHA-256 只证明内容与当前清单一致,不证明清单来自谁。320 MB 也不是通用答案,它必须小于应用沙箱可用空间,并为旧版本、临时文件和系统波动留余量。

另一个边界是fflate的异步能力依赖执行环境。项目上线前要在目标 HarmonyOS 设备上验证 Worker/线程行为;如果构建链或运行时不支持某个异步实现,应把解压移入 TaskPool/Worker 适配层,而不是退回主线程同步展开。无论底层换成什么库,路径守卫、产出配额、代次隔离和原子指针四个约束都不应丢。

回头看,这次真正修掉的不是一个../,而是“把解压成功误当成导入成功”的工程假设。只有当每个输入名被收束、每个产出字节被计量、每个回调绑定代次、每次提交都可重放,离线模型包才算进入可维护的产品链路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 8:04:13

DataLoader 与 Redis 集成实战:基于 MGET 的批量加载与缓存策略

后端缓存抽象 【免费下载链接】dataloader DataLoader is a generic utility to be used as part of your applications data fetching layer to provide a consistent API over various backends and reduce requests to those backends via batching and caching. 项目地址&a…

作者头像 李华
网站建设 2026/10/7 8:04:04

汽车机盖Class-A曲面拓扑设计与重拓扑实战

1. 什么是“机盖拓扑”&#xff1f;——从汽车设计现场讲起“拓车工坊”这个名字一出来&#xff0c;老汽车人基本就懂了&#xff1a;这不是教你怎么画渲染图&#xff0c;也不是讲参数化建模的炫技玩法&#xff0c;而是直奔车身正向开发最硬核的环节——曲面构建前的结构骨架搭建…

作者头像 李华
网站建设 2026/10/7 8:03:10

别用老办法做PPT了!aigcbiye的AI PPT把学术演示变成了“填空题”

aigcbiye官网 微信公众号搜一搜 aigcbiye 你有没有算过一笔账&#xff1a;一篇三万字的毕业论文&#xff0c;从开题到答辩&#xff0c;你花在“做PPT”上的时间&#xff0c;够不够把论文再改三遍&#xff1f; 不是危言耸听。开题报告PPT、答辩PPT、工作汇报PPT——这三座大山…

作者头像 李华
网站建设 2026/10/7 8:03:10

如何连接宏参数——C语言宏里#和##的用法

C语言预处理宏是面试常考知识点&#xff0c;很多人只知道 #define 定义常量&#xff0c;却不了解两个特殊预处理运算符&#xff1a; # 字符串化、 ## 标记粘贴&#xff08;宏参数连接&#xff09;。这两个运算符只能在宏定义内部使用&#xff0c; # 把宏参数转字符串&#xff1…

作者头像 李华
网站建设 2026/10/7 8:03:10

pstack原则10操作幂等化:设计崩溃重试下依然收敛的命令

pstack原则10操作幂等化&#xff1a;设计崩溃重试下依然收敛的命令 【免费下载链接】pstack-claude Claude Code, Codex, Copilot, Pi, OpenCode, Gemini, and Prime Agent versions of Potetos pstack. Rigorous agent workflows with Cursor primitives translated for other…

作者头像 李华