提示:本文创作过程中大量使用 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 = 4+4 + 1+1+1+1 + 4 + 8 + 64+32 + 4 + 12+127+1 = 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_signSize=0)填到 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),字段顺序与偏移如下(全部小端):
| 偏移 | 字段 | 类型 | 字节数 | 取值(自签名) |
|---|---|---|---|---|
| 0 | version | uint8 | 1 | 1 |
| 1 | hashAlgorithm | uint8 | 1 | 1 (SHA-256) |
| 2 | log2BlockSize | uint8 | 1 | 12 |
| 3 | saltSize | uint8 | 1 | 0 |
| 4 | signSize | uint32 | 4 | 0(生成摘要时)/ 32(落盘时) |
| 8 | fileSize | uint64 | 8 | 含 .codesign 段的最终产物总大小 |
| 16 | rawRootHash | bytes | 64 | merkle 树根哈希,左对齐,余 0 |
| 80 | salt | bytes | 32 | 全 0 |
| 112 | flags | uint32 | 4 | 0x10 (FLAG_SELF_SIGN) |
| 116 | reserved_1 | bytes | 4 | 0 |
| 120 | merkleTreeOffset | uint64 | 8 | 0 (当前实现未启用,FLAG_STORE_MERKLE_TREE_OFFSET 未置位) |
| 128 | reserved_2 | bytes | 127 | 0 |
| 255 | csVersion | uint8 | 1 | 3 |
合计 256 字节。这个布局里的"灵魂"是signSize字段,它在两个时刻取值不同:
- 生成摘要时(对应上游
GetByteForGenerateDigest)的signSize写0。这是为了让摘要可复算——摘要计算发生在 signature 还没诞生之前,signature 长度自然为 0。 - 落盘时(对应上游
ToByteArray)的signSize写signature.size()(自签名下 = 32)。
这一改动是唯一让"落盘的 256 字节"与"被摘要的 256 字节"不同的地方。验签端重算摘要时要把signSize改回 0、把 signature 当不存在,才能算出和签名端一致的摘要——这也是上一节验签步骤 2 的依据。
descriptor 里其余字段的取值,自签名下都是固定的(如上表"取值"列):version=1、hashAlgorithm=1、log2BlockSize=12、salt全 0、csVersion=3。唯一需要在签名时现场算出来的,是fileSize(含段产物总大小)和rawRootHash(merkle 根哈希)。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字段,紧跟在 ElfSignInfo+signature 之后写入.codesign段的剩余空间。自签名下系统验签侧若不做 merkle 验证,这部分理论上可全填 0;但上游工具是老老实实按实算结果写进去的,本仓库两份实现亦按同一规则算出并写入实际 merkle tree bytes(实测段内 296B 之后确实非全 0)。
这里最坑、最容易写错的是第 1 步的“跳段置 0” 语义。直觉上你会以为:“既然段区间是要被覆盖写 payload 的,那算 merkle 时把段剥掉、对剥掉后的原文分页不就行了?”——不行。上游真实做法(merkle_tree_builder.cpp::RunHashTask)不是"剥掉段区间再分页",而是:
- 先注入 4KB 占位
.codesign段得含段产物tmp,csOffset= 段在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, page=4096) fileSize = len(tmp) # fileSize 字段 = 含段产物总大小 # 2. merkle 根哈希: 对 tmp 按原偏移分页, 段所在页叶哈希置0, 其余页正常 SHA-256 rootHash = merkle_root_hash(tmp, csOff, csLen=4096, page=4096, algo=SHA256) # 3. flags置自签名位 flags = 0x10 # 4. 构造 descriptor 的 256 字节, 注意 signSize 此刻 = 0, fileSize = fileSize desc_for_digest = build_descriptor( version=1, hashAlgo=1, log2BlockSize=12, saltSize=0, signSize=0, fileSize=fileSize, rootHash=rootHash, salt=zeros(32), flags=flags, merkleTreeOffset=0, csVersion=3) # 5. signature = SHA256(descriptor_with_signSize_0) signature = SHA256(desc_for_digest) # 32 字节裸摘要 # 6. 重新构造落盘用的 descriptor, signSize = 32 desc_on_disk = build_descriptor( ...同上..., signSize=32) # 7. merkle tree bytes (自签名验签侧不查; 实现里段内296B之后填全0即可) # 8. 拼 ElfSignInfo 头部 8 字节 type = 1 length = 256 + len(signature) # = 288 (不含头部8字节 type+length) head = pack_u32_le(type) + pack_u32_le(length) payload = head + desc_on_disk + signature # = 296 字节 # 9. 原地改写段内字节: tmp[csOff : csOff+len(payload)] = payload # 段已占 4KB, payload 仅 296B, 余部保持段占位0 tmp[csOff:csOff+len(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 : (i+1)*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:i+page] 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-256(C 版自实现,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_sign&&echo"C 与 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|grep"postgres"# 不知道进程名时,用验签关键字兜底过滤hilog-tkmsg|grep-E"xpm|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] pid=35029 tid=35029 comm=zsh [xpm:1010]postgres is not protected by dmverity, devid=28 05-26 15:18:37 <3> [1897.028672] -;[11] pid=35029 tid=35029 comm=postgres [xpm:1010]libicudata.so.78.3 is not protected by dmverity, devid=272629862 05-26 15:18:37 <3> [1897.080574] -;[12] pid=35029 tid=35029 comm=postgres [HKES:87][hkes][E]: event denied by client 05-26 15:18:37 <1> [1897.080589] -;[12] pid=35029 tid=35029 comm=postgres [fs_security_verity:74][hkes][I]: lib_no_signed event waken: -9(E_HM_PERM) 05-26 15:18:37 <3> [1897.080598] -;[12] pid=35029 tid=35029 comm=postgres [xpm:1671]xpm get signature info failed, etype: 1 05-26 15:18:37 <3> [1897.080607] -;[12] pid=35029 tid=35029 comm=postgres [xpm:1010]libicudata.so.78.3 is not protected by dmverity, devid=272629862 05-26 15:18:37 <3> [1897.080620] -;[12] pid=35029 tid=35029 comm=postgres [xpm:771]{ "event_type": "unsigned file", "code_type": "ABC", "pid": 35029, "filename": "/service/el2/100/hmdfs/account/files/Docs/.harmonybrew/Cellar/icu4c@78/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] pid=35029 tid=35029 comm=postgres [xpm:1010]libicudata.so.78.3 is not protected by dmverity, devid=272629862 05-26 15:18:38 <3> [1897.137194] -;[4] pid=35029 tid=35029 comm=postgres [HKES:87][hkes][E]: event denied by client 05-26 15:18:38 <1> [1897.137204] -;[4] pid=35029 tid=35029 comm=postgres [fs_security_verity:74][hkes][I]: lib_no_signed event waken: -9(E_HM_PERM) 05-26 15:18:38 <3> [1897.137211] -;[4] pid=35029 tid=35029 comm=postgres [xpm:1671]xpm get signature info failed, etype: 1 05-26 15:18:38 <3> [1897.137218] -;[4] pid=35029 tid=35029 comm=postgres [xpm:1010]libicudata.so.78.3 is not protected by dmverity, devid=272629862 05-26 15:18:38 <3> [1897.137227] -;[4] pid=35029 tid=35029 comm=postgres [xpm:771]{ "event_type": "unsigned file", "code_type": "ABC", "pid": 35029, "filename": "/service/el2/100/hmdfs/account/files/Docs/.harmonybrew/Cellar/icu4c@78/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] pid=35029 tid=35029 comm=postgres [procmgr_core:1722]process start exit, pid=35029 05-26 15:18:38 <1> [1897.143242] -;[4] pid=35029 tid=35029 comm=postgres [threadmgr_thread:1374]last thread has exited in user space, nr_threads=1逐行拆解这条日志能告诉我们什么:
| 日志片段 | 含义 | 排查价值 |
|---|---|---|
comm=zsh行的postgres is not protected by dmverity | 父进程 zsh 试图 exec postgres 主程序,但 postgres 没被 dmverity 保护 | 主程序没签名—— 走的是本文流程的话,先查主程序有没有.codesign段 |
comm=postgres行的libicudata.so.78.3 is not protected by dmverity | postgres 启动后 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: 1 | xpm 取签名信息失败,etype: 1指签名类型 | 签名信息读不到——要么没段、要么段坏了 |
[xpm:771] { "event_type": "unsigned file", ..., "filename": ".../libicudata.so.78.3", ..., "verified_datasize": 0 } | xpm 落的结构化事件,写明未签名文件的全路径、verified_datasize=0(验签数据量为 0) | 最有价值的一条——直接告诉你"哪个文件、什么事件、验了多少"。verified_datasize: 0意味着系统根本没找到可验的签名数据 |
[procmgr_core:1722] process start exit, pid=35029+[threadmgr_thread:1374] last thread has exited | 进程启动失败退出、最后一根线程也走了 | 进程被拒后清理收尾 |
把这几条串起来看,结论一目了然:postgres 主程序本身没签名(zsh exec 时被拒),就算主程序签了,它 dlopen 的libicudata.so.78.3也没签名(postgres 启动后被拒)。两道关都过不去,所以终端看到的permission denied实际是内核侧连续两次拒绝的结果。