鸿蒙 PC 底层开发技术详解(七):二进制自签名算法的实现

发布时间:2026/7/7 2:05:10
鸿蒙 PC 底层开发技术详解(七):二进制自签名算法的实现 提示本文创作过程中大量使用 AI 辅助研究作者已验证代码结果的正确性但无法保证所有结论一定严谨切勿将文中结论用于严谨学术用途。1 前言本文是《鸿蒙 PC 底层开发技术详解四代码签名机制对我们的影响》的续写。前文讲清楚了鸿蒙 PC HiShell 上为什么要签名、用什么工具签名——结论是日常开发里绝大多数时候我们做的都是自签名要么用binary-sign-tool sign -selfSign 1要么用 lld 的--code-sign。讲解内容仅止步于“工具能签、产物能跑”这一层并未深入细节。本文将基于对binary-sign-tool开源代码的分析结果进一步深入讲解代码签名的算法及其生成的数据结构并使用 C 和 Python 两种语言各自实现一个代码签名工具。本文所涉及的源码可在以下仓库找到ohos-bst-portable —— 将binary-sign-tool从 OpenHarmony 项目里便携剥离出来能够在脱离 GN 构建框架的情况下编出与 ohos-sdk 一致的binary-sign-tool工具。此项目可以让开发者排除无关组件干扰、专注研究binary-sign-tool的代码逻辑。ohos-bst-light ——binary-sign-tool轻量重写版 —— 基于官方源码逆向出 OpenHarmony 二进制自签名算法后用 C 语言和 Python 各重写一份实现。2 前文回顾在前文中我们用llvm-readelf -S my_program查看签名后的 ELF 文件发现其末尾多了一个名为.codesign的段。段内放的是什么内容呢放的就是本文要讲的主角ElfSignInfo。3 ElfSignInfo段内 payload 的整体布局一个自签名后的 ELF 文件其末尾.codesign段的字节布局如下从该段的起始偏移开始.codesign section (size 固定 4096 字节, 即 1 × 4KB) ┌──────────────────────────────────────────────────────────────┐ │ ElfSignInfo 头部 (type length, 固定 8 字节) │ ├──────────────────────────────────────────────────────────────┤ │ ElfSignInfo 主体 fs-verity descriptor (固定 256 字节) │ ├──────────────────────────────────────────────────────────────┤ │ signature (自签名时 32 字节证书签名时 PKCS7 长度) │ ├──────────────────────────────────────────────────────────────┤ │ merkle tree bytes (段内 296B 之后直到段末自签名下验签侧 │ │ 允许全 0但上游工具仍按实算结果写入本实现亦写入实际内容) │ └──────────────────────────────────────────────────────────────┘段内 payload 部分合计头部 8B descriptor 256B signature 32B 296B证书签名时 signature 长度不同总计随之变化。段总大小恒为 4096B——payload 仅 296B余 ~3800B 即 merkle tree bytes 区自签名下验签侧允许全 0但上游工具与本实现都按实算结果写入。ElfSignInfo在上游binary_sign_tool/hap/verify/include/verify_elf.h里是一个 C 结构体验签端就是按这个结构体直接reinterpret_cast读的structElfSignInfo{uint32_ttype;// 1, FsVerityDescriptor::FS_VERITY_DESCRIPTOR_TYPEuint32_tlength;// payload 总长 descriptor(256) signature.size()不含本头部 8 字节uint8_tversion;// 1, FsVerityDescriptor::VERSIONuint8_thashAlgorithm;// 1, SHA-256uint8_tlogBlockSize;// 12, 即 2^12 4096uint8_tsaltSize;// 0 (当前实现里 salt 为空)uint32_tsignSize;// signature 字段的字节数uint64_tdataSize;// 含 .codesign 段的最终产物总大小 (对应 descriptor fileSize 字段)uint8_trootHash[64];// merkle 树根哈希左对齐填到 64 字节余部为 0uint8_tsalt[32];// salt左对齐填到 32 字节uint32_tflags;// 自签名时第 4 位 (0x10) 必须被置 1uint8_treserved_1[12];uint8_treserved_2[127];uint8_tcsVersion;// 3, ELF_CODE_SIGN_VERSIONuint8_tsignature[0];// 柔性数组紧跟着的就是 signature 字段};// 上述结构体 sizeof 44 1111 4 8 6432 4 121271 264 字节// 不含柔性数组 signature[0]验签端读完 264B 主体后再按 length-256 读 signature这里有几个反直觉但必须讲清楚的点头部的 length 字段到底量的是什么length指的是紧跟在头部 8 字节之后的 payload 总长descriptor signature而不包括头部自身的type/length这 8 字节。自签名下length 256 32 288。整块 ElfSignInfo含头部的字节总长是8 length 296。这一处很容易踩坑因为字段叫length、又紧挨在type后面望文生义会以为它量的是从 type 开始的总长。但上游code_signing.cpp落盘时写的是descriptor.size() signature.size()不含头部——所以本文和配套实现都按288写。flags 字段自签名的“身份证”FLAG_SELF_SIGN 1 4 0x10自签名时flags 0x10。这一位是整个机制里最关键的开关验签端verify_elf.cpp看到(flags 0x10) ! 0就直接判定为自签名、跳过 PKCS7 验证——自签名没有任何证书链可验系统侧的实现实际是把是否自签名这一位作为白名单条件之一由系统策略决定是否放行。也就是说只要产物里flags的第 4 位是 1工具链侧就认它是自签名。本文要讲的没有私钥也能过验签根因就在这一位上——它让系统跳过那个需要私钥才能完成的步骤。csVersion 字段签名端写ELF_CODE_SIGN_VERSION 0x3。验签端目前没强校验这个值但本算法保持写入3。字节序上游ByteBuffer::PutInt32/PutInt64是直接memcpyhost 内存值因此在 aarch64/x86_64 Linux 上均为小端little-endian。验签端reinterpret_cast也是 host 序读两端自洽。本文的实现也一律采用小端。4 signature 字段在自签名下的含义讲清这一节本文就完成了一大半。签名类型signature 字段的内容来源证书签名对 fs-verity descriptor 摘要做的 PKCS7 签名 (含证书链)GenerateSignature()→BCSignedDataGenerator自签名fs-verity descriptor 自身的 SHA-256 摘要(32 字节裸摘要无任何 magic header)fsVerityGenerator-GetDescriptorDigest()也就是说自签名的签名就是被签名对象descriptor本身的 SHA-256。这根本不是任何意义上的数字签名——它不抵抗篡改、不证明身份、不蕴含任何私钥。它只是把SHA256(descriptor_with_signSize0)填到 descriptor 的 signature 字段并把同一份 descriptor 的signSize字段从 0 改成 32 后再落盘。如果你第一次看到这个机制觉得这也算签名“——反应是对的它确实不是签名它是个自洽占位让那些原本按signature 字段必然存在写死的字节布局能闭合一整套结构同时用flags那一位告诉验签端别去找证书链了这块放过去”。验签时若要核对逻辑只需四步把读出来的 ElfSignInfo 里的 descriptor 部分去掉头部 8 字节的type/length取出把其中的signSize字段临时改回 0把signature字段当作不存在对这 256 字节做 SHA-256与 ElfSignInfo 后面紧跟着的 signature 字段比对相等则通过。这里有个非常 subtle 的点“用来算摘要的 descriptor” 和最终落盘的 descriptor是同一份结构、但signSize字段不同。下一节就把这个差异摊开讲。5 fs-verity descriptor 的 256 字节布局descriptor 是一个定长 256 字节结构FsVerityDescriptor::DESCRIPTOR_SIZE 256字段顺序与偏移如下全部小端偏移字段类型字节数取值自签名0versionuint8111hashAlgorithmuint811 (SHA-256)2log2BlockSizeuint81123saltSizeuint8104signSizeuint3240生成摘要时/ 32落盘时8fileSizeuint648含 .codesign 段的最终产物总大小16rawRootHashbytes64merkle 树根哈希左对齐余 080saltbytes32全 0112flagsuint3240x10 (FLAG_SELF_SIGN)116reserved_1bytes40120merkleTreeOffsetuint6480 (当前实现未启用FLAG_STORE_MERKLE_TREE_OFFSET 未置位)128reserved_2bytes1270255csVersionuint813合计 256 字节。这个布局里的灵魂是signSize字段它在两个时刻取值不同生成摘要时对应上游GetByteForGenerateDigest的signSize写0。这是为了让摘要可复算——摘要计算发生在 signature 还没诞生之前signature 长度自然为 0。落盘时对应上游ToByteArray的signSize写signature.size()自签名下 32。这一改动是唯一让落盘的 256 字节与被摘要的 256 字节不同的地方。验签端重算摘要时要把signSize改回 0、把 signature 当不存在才能算出和签名端一致的摘要——这也是上一节验签步骤 2 的依据。descriptor 里其余字段的取值自签名下都是固定的如上表取值列version1、hashAlgorithm1、log2BlockSize12、salt全 0、csVersion3。唯一需要在签名时现场算出来的是fileSize含段产物总大小和rawRootHashmerkle 根哈希。fileSize好理解根哈希怎么来——下一节。6 merkle 树怎么构造fs-verity 的跳段语义descriptor 里要填的rawRootHash来自一棵 merkle 树构造规则分页先注入 4KB 占位.codesign段得含段产物tmp对tmp按原偏移分页每页 4096B末页补 0。段所落在的那几页csOffset/4096 .. ⌈(csOffset段长)/4096⌉的叶哈希全填 0其余页正常 SHA-256。叶层哈希对每一页做SHA-256得到 32 字节叶哈希按页顺序排列。逐层向上把当前层每 4096 字节即 4096/32 128 个哈希当作一页再做 SHA-256得到上一层。最后会聚到一个 ≤ 4096 字节的根页。根哈希若原始数据 4096 字节只有一页根哈希直接就是这页的哈希否则对最顶层的 4096 字节页再做一次 SHA-256得到的 32 字节就是rootHash。merkle tree bytes所有非叶层即除最叶层外自顶向下各层的哈希字节流会被顺次拼成tree字段紧跟在 ElfSignInfosignature 之后写入.codesign段的剩余空间。自签名下系统验签侧若不做 merkle 验证这部分理论上可全填 0但上游工具是老老实实按实算结果写进去的本仓库两份实现亦按同一规则算出并写入实际 merkle tree bytes实测段内 296B 之后确实非全 0。这里最坑、最容易写错的是第 1 步的“跳段置 0” 语义。直觉上你会以为“既然段区间是要被覆盖写 payload 的那算 merkle 时把段剥掉、对剥掉后的原文分页不就行了”——不行。上游真实做法merkle_tree_builder.cpp::RunHashTask不是剥掉段区间再分页而是先注入 4KB 占位.codesign段得含段产物tmpcsOffset 段在tmp中的文件偏移4KB 对齐。对tmp按原偏移分页每页 4096B末页补 0段所落在的那几页csOffset/4096 .. ⌈(csOffset段长)/4096⌉的叶哈希全填 0不做 SHA-256直接置 0其余页正常 SHA-256。上推逐层照常。区别看着小但对得上对不上差很远用剥段后分页算法算出的根哈希喂给本仓库实现与上游产物 descriptor 里写的根哈希不一致用原偏移分页 段所在页置 0算法算出的根哈希与上游一致。本仓库两份实现的merkle_root_hash即采用后者。fileSize字段填的是含段最终产物的总大小不是剥段后原文大小因为上游code_signing.cpp的输入inputstream就是已含占位段的临时产物fileSize tellg()读的是含段产物大小。7 把整个流程串起来完整算法伪代码把上面几节拼到一起整个自签名流程是这样的# 输入: inPath 待签名的 ELF 文件路径 (bin 或 .so) # 输出: outPath 签名后的产物文件路径 (通常与 inPath 相同) function self_sign(inPath, outPath): raw read_file(inPath) # 原始 ELF 字节 # 1. 注入 4KB 占位 .codesign 段 → 含段产物 tmp, 段文件偏移 csOff (4KB 对齐) tmp, csOff inject_codesign_section(raw, page4096) fileSize len(tmp) # fileSize 字段 含段产物总大小 # 2. merkle 根哈希: 对 tmp 按原偏移分页, 段所在页叶哈希置0, 其余页正常 SHA-256 rootHash merkle_root_hash(tmp, csOff, csLen4096, page4096, algoSHA256) # 3. flags置自签名位 flags 0x10 # 4. 构造 descriptor 的 256 字节, 注意 signSize 此刻 0, fileSize fileSize desc_for_digest build_descriptor( version1, hashAlgo1, log2BlockSize12, saltSize0, signSize0, fileSizefileSize, rootHashrootHash, saltzeros(32), flagsflags, merkleTreeOffset0, csVersion3) # 5. signature SHA256(descriptor_with_signSize_0) signature SHA256(desc_for_digest) # 32 字节裸摘要 # 6. 重新构造落盘用的 descriptor, signSize 32 desc_on_disk build_descriptor( ...同上..., signSize32) # 7. merkle tree bytes (自签名验签侧不查; 实现里段内296B之后填全0即可) # 8. 拼 ElfSignInfo 头部 8 字节 type 1 length 256 len(signature) # 288 (不含头部8字节 typelength) head pack_u32_le(type) pack_u32_le(length) payload head desc_on_disk signature # 296 字节 # 9. 原地改写段内字节: tmp[csOff : csOfflen(payload)] payload # 段已占 4KB, payload 仅 296B, 余部保持段占位0 tmp[csOff:csOfflen(payload)] payload # 10. 落盘 write_file(outPath, tmp) function merkle_root_hash(data, csOff, csLen, page, algo): 段所在页叶哈希全置0, 其余页正常SHA-256, 上推逐层照常. npages ceil(len(data) / page) hashes [] cs_page_begin csOff // page cs_page_end (csOff csLen page - 1) // page for i in range(npages): if csLen 0 and cs_page_begin i cs_page_end: hashes.append(zeros(32)) # 段所在页: 叶哈希置0 continue page_bytes data[i*page : (i1)*page] if len(page_bytes) page: page_bytes zeros(page - len(page_bytes)) # 末页补0 hashes.append(SHA256(page_bytes)) if len(hashes) 1: return hashes[0] # 单页即根 while len(hashes) 1: packed b.join(hashes) if len(packed) page: return SHA256(packed zeros(page - len(packed))) nextPageHashes [] for i in range(0, len(packed), page): chunk packed[i:ipage] if len(chunk) page: chunk chunk zeros(page - len(chunk)) nextPageHashes.append(SHA256(chunk)) hashes nextPageHashes return hashes[0]8 完整代码实现我在 ohos-bst-light 仓库里提供了两份独立实现self-sign.c—— C99 实现自带 SHA-256按 FIPS 180-4 公开规范自实现不引自任何第三方代码零第三方依赖。self-sign.py—— Python 3 实现仅用标准库hashlib与 C 版产物整文件字节级一致。两份实现都只依赖 SHA-256C 版自实现Python 版用标准库不依赖 openssl / cJSON / elfio。C/Python 互相交叉验证的用法./self-sign a.out a.c_sign python3 self-sign.py a.out a.py_signcmpa.c_sign a.py_signechoC 与 Python 产物整文件字节级一致功能约束待签 ELF 的段布局须合规。如果用户不使用 ohos-sdk 而是使用其他非标工具链为鸿蒙系统编译程序这两个签名工具可能会签名失败该问题上游binary-sign-tool中同样存在。9 验签失败时怎么排查到此为止讲的都是产物正常、跑得起来的路径。但实务里更常踩的是反过来的坑签名跑完了、产物落盘了一执行终端就报permission denied——这时到底签坏了哪一步、是哪个文件没过验签光看终端这一行报错是判断不出来的。本节给一套可操作的排查手段。HiShell 上验签失败时用户态看到的就是一行permission denied。单看这一行完全无法分辨是验签失败了、还是缺乏 selinux 权限、或者是别的什么原因。要定位到具体是哪个文件、哪一步出问题得去看内核日志。内核日志里会有验签侧各组件xpm / fs_security_verity 等落下来的事件明确写出哪个 pid、哪个文件、为什么没过验签。这是排查验签问题最硬的佐证。HiShell 上内核日志的采集命令是hilog-tkmsg-t kmsg指定从内核环形缓冲/dev/kmsg取日志而不是用户态日志。验签事件都是内核侧落的必须带这个参数才看得到。实际排查时一般会接一条grep把范围收窄到自己关心的进程# 用自己的进程名称过滤推荐最精准hilog-tkmsg|greppostgres# 不知道进程名时用验签关键字兜底过滤hilog-tkmsg|grep-Expm|unsigned file三个常用过滤关键字进程名称—— 最精准只看自己进程触发的验签事件。xpm—— 验签主组件eXecution Permission Manager的事件标签所有验签决策都会落一条[xpm:...]。unsigned file—— 验签失败的具体事件类型凡是未签名/签名损坏的文件被加载时都会落一条event_type: unsigned file。下面是 postgres 进程在 HiShell 上因依赖的.so未签名被拦截时hilog -t kmsg | grep postgres的真实输出05-26 15:18:37 3 [1897.008303] -;[12] pid35029 tid35029 commzsh [xpm:1010]postgres is not protected by dmverity, devid28 05-26 15:18:37 3 [1897.028672] -;[11] pid35029 tid35029 commpostgres [xpm:1010]libicudata.so.78.3 is not protected by dmverity, devid272629862 05-26 15:18:37 3 [1897.080574] -;[12] pid35029 tid35029 commpostgres [HKES:87][hkes][E]: event denied by client 05-26 15:18:37 1 [1897.080589] -;[12] pid35029 tid35029 commpostgres [fs_security_verity:74][hkes][I]: lib_no_signed event waken: -9(E_HM_PERM) 05-26 15:18:37 3 [1897.080598] -;[12] pid35029 tid35029 commpostgres [xpm:1671]xpm get signature info failed, etype: 1 05-26 15:18:37 3 [1897.080607] -;[12] pid35029 tid35029 commpostgres [xpm:1010]libicudata.so.78.3 is not protected by dmverity, devid272629862 05-26 15:18:37 3 [1897.080620] -;[12] pid35029 tid35029 commpostgres [xpm:771]{ event_type: unsigned file, code_type: ABC, pid: 35029, filename: /service/el2/100/hmdfs/account/files/Docs/.harmonybrew/Cellar/icu4c78/78.3/lib/libicudata.so.78.3, vm_prot: 1, vm_pgoff: 0, vm_size: 4096, p_id_type: 34, p_ownerid: 0, f_id_type: 0, f_ownerid: 0, ssid: 0, tsid: 0, verified_datasize: 0, code_segment: NA, etype: 1, timestamp: 1779779917} 05-26 15:18:37 3 [1897.083661] -;[12] pid35029 tid35029 commpostgres [xpm:1010]libicudata.so.78.3 is not protected by dmverity, devid272629862 05-26 15:18:38 3 [1897.137194] -;[4] pid35029 tid35029 commpostgres [HKES:87][hkes][E]: event denied by client 05-26 15:18:38 1 [1897.137204] -;[4] pid35029 tid35029 commpostgres [fs_security_verity:74][hkes][I]: lib_no_signed event waken: -9(E_HM_PERM) 05-26 15:18:38 3 [1897.137211] -;[4] pid35029 tid35029 commpostgres [xpm:1671]xpm get signature info failed, etype: 1 05-26 15:18:38 3 [1897.137218] -;[4] pid35029 tid35029 commpostgres [xpm:1010]libicudata.so.78.3 is not protected by dmverity, devid272629862 05-26 15:18:38 3 [1897.137227] -;[4] pid35029 tid35029 commpostgres [xpm:771]{ event_type: unsigned file, code_type: ABC, pid: 35029, filename: /service/el2/100/hmdfs/account/files/Docs/.harmonybrew/Cellar/icu4c78/78.3/lib/libicudata.so.78.3, vm_prot: 1, vm_pgoff: 0, vm_size: 4096, p_id_type: 34, p_ownerid: 0, f_id_type: 0, f_ownerid: 0, ssid: 0, tsid: 0, verified_datasize: 0, code_segment: NA, etype: 1, timestamp: 1779779918} 05-26 15:18:38 1 [1897.143176] -;[4] pid35029 tid35029 commpostgres [procmgr_core:1722]process start exit, pid35029 05-26 15:18:38 1 [1897.143242] -;[4] pid35029 tid35029 commpostgres [threadmgr_thread:1374]last thread has exited in user space, nr_threads1逐行拆解这条日志能告诉我们什么日志片段含义排查价值commzsh行的postgres is not protected by dmverity父进程 zsh 试图 exec postgres 主程序但 postgres 没被 dmverity 保护主程序没签名—— 走的是本文流程的话先查主程序有没有.codesign段commpostgres行的libicudata.so.78.3 is not protected by dmveritypostgres 启动后 dlopen 加载libicudata.so.78.3但这个 .so 没被 dmverity 保护依赖的 .so 没签名[fs_security_verity:74] lib_no_signed event waken: -9(E_HM_PERM)内核 fs_security_verity 组件判定无签名唤醒拒绝事件错误码E_HM_PERM(-9)错误码-9即permission denied的内核侧真身[xpm:1671] xpm get signature info failed, etype: 1xpm 取签名信息失败etype: 1指签名类型签名信息读不到——要么没段、要么段坏了[xpm:771] { event_type: unsigned file, ..., filename: .../libicudata.so.78.3, ..., verified_datasize: 0 }xpm 落的结构化事件写明未签名文件的全路径、verified_datasize0验签数据量为 0最有价值的一条——直接告诉你哪个文件、什么事件、验了多少。verified_datasize: 0意味着系统根本没找到可验的签名数据[procmgr_core:1722] process start exit, pid35029[threadmgr_thread:1374] last thread has exited进程启动失败退出、最后一根线程也走了进程被拒后清理收尾把这几条串起来看结论一目了然postgres 主程序本身没签名zsh exec 时被拒就算主程序签了它 dlopen 的libicudata.so.78.3也没签名postgres 启动后被拒。两道关都过不去所以终端看到的permission denied实际是内核侧连续两次拒绝的结果。