尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

HarmonyOS Native C++ + libarchive:离线资源包解压的路径穿越防护与配额熔断【鸿蒙心迹】

HarmonyOS Native C++ + libarchive:离线资源包解压的路径穿越防护与配额熔断【鸿蒙心迹】 离线地图、模型、课件和皮肤资源常被打成压缩包由应用下载后解包。功能实现并不难读取条目、拼接输出目录、写文件。安全问题也恰好藏在这三步里——条目名可能包含../可能是绝对路径可能借助符号链接绕出目标目录压缩包本身只有十几兆解压后却可能占满应用沙箱。本文构造一个 HarmonyOS Native C 演示工具ArchiveGuard页面名为OfflinePackPage任务编号PACK-GUARD-0057。待检查文件是offline-map-v12.tar.zst压缩体积18.4 MB共326个条目。预扫描发现两条危险路径声明展开体积为271.4 MB超过256 MB配额状态因此为SCANNING → BLOCKED。替换包包含324个条目展开体积183.7 MB最终进入VERIFIED_READY。所有结果来自演示夹具不宣称对真实生产包完成过安全审计。一、先扫描元数据别边读边写最危险的实现通常长这样archive_read_next_header()取到条目后立刻把archive_entry_pathname()拼到应用目录然后创建文件。此时安全检查与副作用交错。一旦第 200 个条目才发现越界路径前 199 个条目已经写进磁盘回滚需要追踪所有目录、权限和链接稍有遗漏就留下半成品。ArchiveGuard把流程拆成两个阶段。第一阶段只读取头部和跳过数据建立清单检查路径、类型、条目数量和声明大小只有整包通过第二阶段才重新打开归档并写入临时目录。写完后再以目录切换或业务指针更新的方式发布。这样BLOCKED状态没有任何业务资源落盘恢复动作只是丢弃输入包或等待替换包。libarchive 官方头文件给出的典型读取顺序是archive_read_new()、启用所需 filter/format、打开数据源、循环archive_read_next_header()最后释放。官方示例还说明若只枚举头部可使用archive_read_data_skip()跳过当前条目数据。本文沿用这一公开接口不虚构 HarmonyOS 专有解压 API。二、路径合规不是一个 startsWith 判断假设目标目录是应用沙箱内的files/offline/map-v12。危险条目../../etc/passwd在字符串拼接后看似仍含有目标前缀但路径规范化后会逃离根目录。绝对路径/data/storage/el2/base/cache/payload.bin更直接它绕过相对根。反斜杠、空路径、重复分隔符、.段、..段和 NUL 字节都需要在进入文件系统前统一处理。这段代码解决什么问题对归档条目名做纯词法规范化并拒绝绝对路径、父级回退和空路径。#includestring#includestring_view#includevectorstructPathCheck{boolok;std::string normalized;std::string reason;};PathCheckCheckRelativePath(std::string_view raw){if(raw.empty())return{false,{},EMPTY_PATH};if(raw.front()/||raw.front()\\){return{false,{},ABSOLUTE_PATH};}if(raw.find(\0)!std::string_view::npos){return{false,{},NUL_BYTE};}std::vectorstd::stringparts;std::string current;autoflush[]()-bool{if(current.empty()||current.){current.clear();returntrue;}if(current..)returnfalse;parts.push_back(current);current.clear();returntrue;};for(charch:raw){if(ch/||ch\\){if(!flush())return{false,{},PARENT_SEGMENT};}else{current.push_back(ch);}}if(!flush())return{false,{},PARENT_SEGMENT};if(parts.empty())return{false,{},EMPTY_NORMALIZED_PATH};std::string normalized;for(constautopart:parts){if(!normalized.empty())normalized.push_back(/);normalizedpart;}return{true,normalized,{}};}为什么不调用文件系统的 canonical 直接判断预扫描阶段目标文件尚未创建canonical对不存在路径的行为会让逻辑变复杂更重要的是词法合法不等于磁盘安全。上面的函数只负责第一道门把条目变成可比较的相对路径并明确拒绝..。第二阶段写盘时仍要防止目标树中已有符号链接导致路径重定向。状态没有因为某个条目合法就变化。扫描器会收集整包问题直到达到诊断上限或遇到不可恢复的格式错误。演示中两条危险路径分别得到PARENT_SEGMENT与ABSOLUTE_PATH页面只展示前两项但日志保留条目序号便于包生产方定位。易错点是“先替换../再放行”。这种清洗会把攻击路径静默改写成另一个有效路径可能覆盖同名业务文件。安全解包应拒绝整个包让供应链修复输入而不是猜测发送方意图。三、配额必须按展开体积累计并防整数溢出压缩体积不能代表解压成本。18.4 MB的包声明展开体积271.4 MB已经超过演示配额256 MB。此外还要限制条目数、单文件大小、目录深度和未知大小条目。只检查总大小不够十万个零字节文件同样能消耗大量 inode 和扫描时间。这段代码解决什么问题在不解压数据的前提下累计条目元数据并对数量、单文件与总展开体积执行熔断。#includearchive.h#includearchive_entry.h#includecstdint#includelimitsstructScanQuota{int64_tmaxEntries1000;int64_tmaxSingleBytes64LL*1024*1024;int64_tmaxExpandedBytes256LL*1024*1024;};structScanStats{int64_tentries0;int64_texpandedBytes0;intunsafePaths0;std::string reason;};boolAddChecked(int64_tvalue,int64_t*total){if(value0||*totalstd::numeric_limitsint64_t::max()-value)returnfalse;*totalvalue;returntrue;}boolApplyQuota(archive_entry*entry,constScanQuotaquota,ScanStats*stats){if(stats-entriesquota.maxEntries){stats-reasonENTRY_LIMIT;returnfalse;}constint64_tsizearchive_entry_size_is_set(entry)?archive_entry_size(entry):-1;if(size0){stats-reasonUNKNOWN_ENTRY_SIZE;returnfalse;}if(sizequota.maxSingleBytes){stats-reasonSINGLE_FILE_LIMIT;returnfalse;}if(!AddChecked(size,stats-expandedBytes)||stats-expandedBytesquota.maxExpandedBytes){stats-reasonEXPANDED_SIZE_LIMIT;returnfalse;}returntrue;}使用int64_t并不自动安全累计前仍要检查加法溢出。未知大小条目在演示策略中直接拒绝这是为了让预扫描给出确定上界。如果业务必须支持未知大小流就要在第二阶段按实际写入字节再设一层硬限制并确保超过阈值时立即停止、关闭句柄、删除临时目录。这里的64 MB单文件、256 MB总量和1000条目都是ArchiveGuard的产品策略不是 libarchive 默认值也不是 HarmonyOS 平台上限。实际值应来自设备档位、磁盘预算和资源包协议。配额还应给下载缓存、临时解包和正式目录分别留预算不能把可用空间全分给最终文件。开发配图中左侧工程树将ArchiveScanner.cpp、PathPolicy.cpp和OfflinePackPage.ets分开中间 C 代码停在EXPANDED_SIZE_LIMIT右侧模拟器显示BLOCKED底部 HiLog 记录PACK-GUARD-0057 entries326 unsafe2 expanded271.4MB。这是演示界面不冒充 DevEco Studio 的真实运行证据。四、把扫描器做成有明确所有权的 Native 对象压缩包扫描可能持续数百毫秒不能阻塞 ArkUI 主线程。Native 层负责顺序读取ArkTS 层只接收阶段性结果。边界上最重要的不是传很多字段而是定义所有权扫描任务只能结束一次归档句柄只能释放一次页面离开后不得继续回调已销毁的 UI。这段代码解决什么问题用 RAII 管理 libarchive 句柄并完成只读预扫描的主循环。#includememorystructArchiveDeleter{voidoperator()(archive*value)const{if(value!nullptr)archive_read_free(value);}};usingArchivePtrstd::unique_ptrarchive,ArchiveDeleter;ScanStatsScanArchive(conststd::stringfile,constScanQuotaquota){ScanStats stats;ArchivePtrreader(archive_read_new());if(!reader){stats.reasonALLOC_READER_FAILED;returnstats;}archive_read_support_filter_all(reader.get());archive_read_support_format_all(reader.get());if(archive_read_open_filename(reader.get(),file.c_str(),10240)!ARCHIVE_OK){stats.reasonarchive_error_string(reader.get());returnstats;}archive_entry*entrynullptr;intresultARCHIVE_OK;while((resultarchive_read_next_header(reader.get(),entry))ARCHIVE_OK){constchar*namearchive_entry_pathname(entry);constPathCheck pathCheckRelativePath(namenullptr?:name);if(!path.ok){stats.unsafePaths;}if(!ApplyQuota(entry,quota,stats))break;archive_read_data_skip(reader.get());}if(result!ARCHIVE_EOFresultARCHIVE_OKstats.reason.empty()){stats.reasonarchive_error_string(reader.get());}if(stats.unsafePaths0stats.reason.empty())stats.reasonUNSAFE_PATH;returnstats;}ArchivePtr把释放动作绑定到作用域任何提前返回都会调用archive_read_free()。扫描阶段没有archive_write_*因此不会创建文件。archive_read_data_skip()表达了“只读头部”的意图即便官方示例指出进入下一条目时库也可自动跳过显式调用更利于代码审查。需要注意两个边界。其一archive_entry_size()是归档声明值不是可信事实它适合预筛但不能替代写入阶段的实际字节计数。其二启用support_filter_all和support_format_all会扩大接受面生产项目若协议固定为 tarzstd收窄到所需 filter 与 format 更利于减少体积和输入面。libarchive 的磁盘写入接口提供ARCHIVE_EXTRACT_SECURE_NODOTDOT与ARCHIVE_EXTRACT_SECURE_SYMLINKS等安全选项。即便已经做词法检查写盘阶段仍应启用等价保护因为磁盘上的现有链接可能改变解析结果。安全策略需要“应用层白名单 库的安全选项 沙箱根目录”三层同时成立。五、ArkTS 页面只消费不可变结果不拼接原生指针页面状态被限定为IDLE、SCANNING、BLOCKED、VERIFIED_READY、FAILED。Native 层返回普通值对象任务 ID、条目数、危险路径数、展开字节和原因。ArkTS 不持有归档条目指针也不在回调里再次读取 Native 可变内存。这段代码解决什么问题把扫描结果映射为确定的 UI 状态并丢弃页面离开后到达的旧任务回调。typeGuardStateIDLE|SCANNING|BLOCKED|VERIFIED_READY|FAILEDinterfaceNativeScanResult{taskId:stringentries:numberunsafePaths:numberexpandedBytes:numberreason:string}EntryComponentstruct OfflinePackPage{Statestate:GuardStateIDLEStatedetail:stringprivateactiveTask:stringprivatevisible:booleanfalseaboutToAppear():void{this.visibletrue}aboutToDisappear():void{this.visiblefalsethis.activeTask}asyncscan(file:string):Promisevoid{consttaskIdPACK-GUARD-0057this.activeTasktaskIdthis.stateSCANNINGconstresult:NativeScanResultawaitarchiveGuard.scan(file,taskId)if(!this.visible||this.activeTask!result.taskId)returnconstmb(result.expandedBytes/1024/1024).toFixed(1)this.detail${result.entries}entries ·${result.unsafePaths}unsafe ·${mb}MBthis.stateresult.unsafePaths0||result.reason.length0?BLOCKED:VERIFIED_READY}}页面离开时把activeTask清空不代表 Native 线程一定被取消但能保证迟到结果不会污染新页面。更完整的实现还应向 Native 层发送取消标记使扫描循环在条目边界尽快退出。取消和资源释放要分开理解回调被忽略之后归档句柄仍必须由 RAII 正常回收。状态从SCANNING进入BLOCKED的判定是危险路径数大于零或扫描原因非空。替换包重新发起同一个业务任务时建议使用新的执行 token而不是只比较固定任务号本文为保持图片与正文简洁把业务任务号固定为PACK-GUARD-0057生产代码应另加递增 attempt。运行图展示首包扫描结果18.4 MB、326 entries、2 unsafe paths、271.4 MB / 256 MB状态为BLOCKED。红色箭头分别指向父级回退路径与配额条顶部状态栏时间04:10、电量71%。六、替换包不是“忽略错误继续解压”遇到坏包后一个诱人的产品需求是“跳过两个危险条目其余继续用”。这会造成内容与签名清单不一致也会让同一版本在不同设备上得到不同文件集。ArchiveGuard的恢复动作是整包替换供应方生成324条目的修正版展开体积183.7 MB再次从零预扫描通过后才进入VERIFIED_READY。诊断页保留两次尝试Attempt 1 为BLOCKED原因包括PARENT_SEGMENT、ABSOLUTE_PATH与EXPANDED_SIZE_LIMITAttempt 2 为VERIFIED_READYunsafe0183.7 MB。这里的“VERIFIED”只表示通过本文定义的结构与配额策略不代表完成数字签名验证、恶意内容检测或业务语义校验。命名边界必须写进接口文档避免上层把它误解为完整供应链可信。详情图用时间线呈现SCANNING → BLOCKED → VERIFIED_READY同时展示两个被拒路径和替换包统计。红圈标出第二次尝试的unsafe0让图片承担恢复逻辑说明而不是重复首页数据。七、真正落盘时还要补齐四道闸门第一道是文件类型白名单。普通文件与目录可以按协议处理符号链接、硬链接、设备节点和 FIFO 默认拒绝。若产品确实需要链接必须为链接目标单独做根目录约束不能沿用普通文件路径检查。第二道是实际写入计数。声明大小可能错误或缺失写入循环每消费一块数据都要增加actualExpandedBytes超过256 MB立即熔断。临时文件应关闭后删除异常路径要覆盖短写、磁盘满、取消和进程退出。第三道是临时目录隔离。不要直接写正式资源目录。使用任务专属临时目录任务 ID 与随机 token 共同命名全部完成后校验清单再原子更新业务指针。失败时只清理该明确目录避免广泛递归删除。第四道是内容完整性。本文聚焦解压边界没有实现签名与摘要校验。生产分发至少要在可信清单中固定包摘要、协议版本和期望文件集合并在解压前后分别校验。结构安全解决“写到哪里、写多少”不解决“内容是否来自可信发布者”。还有一个工程取舍把 libarchive 作为三方库引入时应固定来源与版本记录许可证和编译选项并只启用协议需要的压缩算法。本文不写死某个未经核验的 HarmonyOS SDK 或 libarchive 版本号API 以 libarchive 官方当前头文件为依据具体集成需按项目工具链重新编译和回归。1. 用恶意夹具验证拒绝顺序安全测试不应该只准备一个包含../的 zip。至少要覆盖绝对路径、连续父级段、反斜杠分隔、空路径、超长名称、重复文件名、同名文件与目录、符号链接后跟子文件、硬链接、未知大小、单文件超限、总量超限和条目数超限。每个夹具只突出一个首要问题另准备一个混合夹具验证扫描器能否在诊断上限内稳定收敛。拒绝顺序也要固定。ArchiveGuard先检查条目名称和类型再累计配额但即使路径已不安全仍可在不读取数据的前提下继续收集有限数量的问题。遇到格式损坏、读取器进入 fatal 状态或诊断条数达到上限则立即停止。这样页面不会因为恶意包包含十万条坏路径而生成十万条字符串并耗尽内存。对总量配额的测试不能只相信头部声明。可以准备一个声明大小合理但实际数据超出的异常夹具确认第二阶段的实际字节计数能够中止写入。还要模拟磁盘空间在扫描后、写入前发生变化预扫描通过不是预留空间真正落盘仍可能返回空间不足。此时状态应是FAILED不能沿用BLOCKED因为前者是环境失败后者是输入被策略拒绝。2. 临时目录清理要窄、可重试、可审计清理代码是安全边界的一部分。任务失败时只允许删除由当前任务创建、且根路径经过校验的临时目录。不能把服务端下发的版本号直接拼成删除目标也不能在根目录变量为空时继续递归操作。删除前验证父目录和任务 token删除后记录结果失败则进入下一次启动时的孤儿目录清理队列。孤儿清理需要年龄阈值避免把仍在运行的并发任务当垃圾。目录元数据可保存任务 ID、创建时间、目标版本和完成标记。只有没有完成标记、超过阈值、且不在当前活跃任务集合中的目录才可删除。这个机制比“启动时清空 tmp”复杂一些却能显著降低多任务或进程恢复场景中的误删风险。正式目录也不应被覆盖式写入。更可靠的发布方式是版本化目录加一个很小的当前版本指针新目录全部校验通过后再更新指针旧目录延迟回收。若进程在更新前退出用户仍使用旧版本若更新后退出新版本已经完整。是否能依赖特定文件系统原子语义需要按平台与实际 API 验证不能仅凭桌面系统经验推断。3. 性能优化不能绕过安全扫描有人会担心两遍读取增加耗时进而建议边扫描边解压。对本地文件而言第二遍通常仍命中系统缓存额外成本换来清晰事务边界对大包可考虑让第一遍只读头部第二遍顺序写入。若数据源不可重放则应先落到受配额限制的下载缓存再执行双阶段流程而不是降低安全要求。扫描进度应基于已处理条目或已读取压缩字节并明确它只是“检查进度”不能假装成精确解压百分比。archive_entry_size()的总和在扫描完成前并不知道早期百分比容易回退。页面可展示条目数和当前阶段等清单建立后再给出第二阶段的字节进度。Native 线程每处理若干条目检查一次取消标记频率要在响应与锁开销之间平衡。取消后不再投递业务结果但仍要执行句柄释放和临时文件关闭。若回调通道已经销毁Native 侧也应安全丢弃事件不要用“页面会忽略”代替线程侧生命周期治理。4. 上线前把策略变成机器可读配置配额若散落在 C 常量、ArkTS 提示语和服务端文档中迟早会出现页面写256 MB、Native 实际限制200 MB的分裂。更稳妥的方式是由 Native 层暴露一份只读策略摘要UI 只负责格式化策略包含最大条目数、单文件上限、总展开上限、允许格式和链接规则并带一个版本号写入日志。策略可以随应用版本调整但不应由未签名的远端字段随意放宽。服务端可要求更严格的限制客户端本地上限则构成不可突破的硬边界。遇到旧资源包不兼容时应通过版本迁移或重新打包解决而不是临时关闭PARENT_SEGMENT、链接检查或实际写入计数。最终验收至少核对首包没有产生正式文件两条危险路径在日志与页面中的原因一致271.4 MB在256 MB阈值前被拒替换包重新扫描而非复用旧清单页面离开时旧回调被丢弃任何提前返回都能释放 reader。任务PACK-GUARD-0057只有同时满足这些条件才算完成结构安全闭环。八、结语安全解包是一个事务不是一个 for 循环离线包处理最可靠的模型是“扫描—判决—写临时区—校验—发布”。路径检查必须发生在任何写入之前配额既看声明值也看实际值句柄与回调需要明确所有权失败不能留下部分可见资源。演示任务PACK-GUARD-0057给出了一组可复查数据首包18.4 MB、326条目、两条危险路径、声明展开271.4 MB在256 MB配额前进入BLOCKED替换包324条目、183.7 MB、危险路径为零进入VERIFIED_READY。这些数据证明的是策略闭环而不是宣称完成生产环境渗透测试。参考资料libarchive 官方头文件与读取流程https://github.com/libarchive/libarchive/blob/master/libarchive/archive.hlibarchive 官方示例https://github.com/libarchive/libarchive/wiki/ExamplesFreeBSD libarchive 磁盘写入安全选项手册https://man.freebsd.org/cgi/man.cgi?queryarchive_write_disk_set_optionssektion3
返回列表