小刀志
网站首页
友情链接
关于本站
站点地图
管理中心
小刀志
小刀志

稻草小刀的在线笔记。记录所有事情,并写给自己。

从 WinCrypt 到 OpenSSL:构建 PESignAnalyzer 跨平台版本

avatar
稻草小刀
4 小时前
AI 总结
本文记录了将 Windows PE 数字签名分析工具从 WinCrypt 迁移到 OpenSSL 以构建跨平台版本(PESignAnalyzer)的工程实践,核心结论是跨平台验签的难点不在密码学运算本身,而在摘要范围界定、ASN.1 包装层差异、时间戳兼容策略以及证书来源与信任环境的重建。项目按 PE 读取、PKCS#7 解析、摘要计算、签名与时间戳验证、信任处理分层,将原由 CryptQueryObject 等 API 承担的职责重新分配,平台差异集中在证书来源与路径处理。关键事实包括:Authenticode 摘要须按文件顺序跳过校验和、安全目录项与证书表,并覆盖节间空洞、原始填充及证书表后数据,普通整文件 SHA-256 无法通过验证;SPC/CTL 内容取 ASN.1 序列内容部,而 RFC 3161 时间戳须保留 OCTET STRING 内完整 TSTInfo DER,统一剥离外壳会破坏时间戳;真实 Microsoft 样本存在 TSA 证书 timeStamping 用途非 critical 及额外 ESS 引用无法核验的情况,需在严格模式外保留兼容分支并记录原因;吊销检查须区分已吊销与证据未知,后者汇总为 indeterminate;运行包需自带十四个 DLL,并启用 CURLSSLOPT_NATIVE_CA 从系统读取 TLS 信任根,否则独立环境 HTTPS 验证失败;默认解析需额外从系统证书库补全根证书以对齐原版输出。
分类:安全
#Windows
#PE文件
#安全
#数字签名
#证书
#跨平台
#OpenSSL
上一篇《从 PE 到 PKCS#7:深入理解 Windows PE 数字签名机制》写了如何找到、解码 Windows PE 文件的签名数据,再检查证书链和信任关系。文章最后提到,可以把格式解析和密码学处理移到 OpenSSL,让工具离开 Windows 也能运行。这次接着做这件事。
从 WinCrypt 到 OpenSSL:构建 PESignAnalyzer 跨平台版本
项目地址:https://github.com/leeqwind/PESignAnalyzer/tree/master/openssl
分析对象仍是 Windows PE 文件,分析程序则希望能在 Windows、Linux、macOS 上构建。把一个 EXE 复制到 Linux 服务器后,也应当能读出签名者、证书和时间戳,并继续检查文件摘要、签名和证书链。
写下去以后,“怎样调用 OpenSSL”很快让位于具体的排查:文件摘要为什么对不上?Windows 接受的时间戳为什么在 OpenSSL 中失败?已经识别出 Catalog 签名,为什么还会报出警告?验证时有完整证书链,默认解析为什么却少了根证书?

一、拆分 Windows API 原来承担的工作

原版 PESignAnalyzer 使用 Windows CryptoAPI。CryptQueryObject、CryptMsgGetParam、证书存储和构链接口承担了不少工作:调用方能直接拿到解码后的消息、证书上下文,还能从证书库里继续查找发行者。换成 OpenSSL 后,要先重新分配这些入口的职责。
OpenSSL 能解释 PKCS#7/CMS 和 X.509,计算摘要、验证公钥签名和证书路径。PE 的 Certificate Table 在哪里、Authenticode 摘要覆盖哪些字节、Catalog 从哪里找、系统信任根怎样取得,都要由项目处理。证书来源尤其容易被忽略:Windows 上能查到的 Microsoft 根证书,换到另一个平台,未必就在 OpenSSL 的默认 CA 集合里。
OpenSSL 版本按这些职责拆开:PE 读取处理文件布局,PKCS#7 解析展开消息和元数据,摘要计算确定 PE 字节范围,验证处理签名和时间戳,信任处理查找证书、构建路径并检查吊销证据。平台差异尽量放在证书来源和路径处理里。
PE 格式、消息解析和密码学运算各自处理一层,平台接口继续提供证书来源
后面的排查用上了这个划分。文件摘要失败就查摘要流;时间戳失败就沿着 token、签名绑定和 TSA 证书链查下去。OpenSSL 返回错误时,至少能先确定问题落在哪一层。

二、从文件字节开始,先做出第一版

第一版先打通一条路径:读取 PE,定位 Security Directory,取出 WIN_CERTIFICATE 的载荷,再交给 OpenSSL 解码 SignedData。
这里沿用了原版的做法:标准 CMS 和证书结构交给成熟库,项目补充 Authenticode 特有的结构。自己写的 DER 读取器只解释 SPC 摘要、嵌套签名属性和 Catalog 成员,没有继续做成通用 ASN.1 框架。
读取 PE 时,不能因为“只是读取”就省去布局检查。Security Directory 给出的是文件偏移,PE32 和 PE32+ 的目录位置不同;证书记录有长度和对齐规则,一个文件还可能带着多条记录。使用偏移前,必须检查越界、溢出和区域重叠。Microsoft 的 PE 格式说明给出了这些基础规则,代码还需要补齐对应的失败路径。
在 pe_reader.cpp 文件中,先确认整个证书表有效、当前至少剩余八字节记录头,然后继续检查记录长度和对齐填充:
CPP
const std::size_t length = reader.u32(cursor);
if (length < 8) {
    throw ParseError("WIN_CERTIFICATE length is smaller than its header");
}
if (length > table_end - cursor) {
    throw ParseError("WIN_CERTIFICATE extends beyond the certificate table");
}
// Round using the remaining length, without length + 7 overflow.
const std::size_t padding = (8 - (length & 7U)) & 7U;
if (padding > table_end - cursor - length) {
    throw ParseError("WIN_CERTIFICATE alignment padding exceeds the certificate table");
}
对齐时先求填充量,再检查剩余空间,没有直接计算 length + 7。正常记录很难看出区别,异常长度才会触发这些检查,让程序在越界前停下来。
第一版的输出要把消息展开:外层是什么内容类型,有多少签名者,签名者通过什么身份关联证书,哪些属性里还有内层签名或时间戳。
读出这些信息后,我没有顺手把它们标成“有效”。Subject、Issuer 和时间戳日期都是可解析的元数据,是否可信还要经过验证。因此,结果结构从一开始就分别保存解析信息和验证状态。
多签名也分别保留。不同 WIN_CERTIFICATE、同一消息中的不同 SignerInfo,以及嵌套消息,都有各自的结果和父级关系。后面要查“到底是哪一个签名失败”,就能沿着这些索引找到对应记录。
展开记录、签名者和属性后,每条信息都有来源,后续验证也能定位到具体对象

三、第一个难点:摘要算法没错,摘要范围可能已经错了

证书信息能读出来了,接下来验证文件内容。拿到 SHA-256 的 OID,再调用 EVP 摘要接口,只完成了计算这一部分。
摘要能否对上,取决于传给它的字节范围。普通的整文件 SHA-256 无法直接用于 PE Authenticode 验证。校验和字段、安全目录项和证书表本身都要排除,否则加入签名数据就会改变被签名对象。节表与磁盘上的实际字节之间,还可能有空洞、填充和附加数据。
对照原版实现、真实文件和独立测试后,摘要处理还要覆盖几种布局:节头排列与节的文件偏移顺序不同,节间有字节,原始节长度与文件对齐边界不整齐,证书表后仍有数据。
只按节头逐个拼接,或把各节长度之和当成后续数据的起点,都可能漏算或重复计算一段区域。这类错误不影响证书解析,CMS 签名本身的数学验证甚至也可能通过,文件完整性检查却会失败。
本项目最终采用了与原 WinCrypt 实现对齐的文件顺序摘要流:先检查布局,再按顺序处理文件字节,跳过校验和、安全目录项和证书表。节间数据、原始填充和证书表后的内容都继续参与计算。
digest.cpp 把下面这些区间送进 EVP。各个偏移已经过布局检查,摘要上下文也已初始化:
CPP
const auto hash = [&](std::size_t start, std::size_t end) {
    if (end < start) throw ParseError("Invalid Authenticode digest interval");
    layout.require(start, end - start);
    if (end > start &&
        EVP_DigestUpdate(context.get(), bytes.data() + start, end - start) != 1)
        throw ParseError("Cannot update Authenticode digest");
};
hash(0, checksum);
if (has_security) {
    hash(checksum + 4, security);
    hash(security + 8, cert_size ? cert_offset : bytes.size());
} else hash(checksum + 4, bytes.size());
if (cert_size) {
    hash(cert_offset + cert_size, bytes.size());
}
最后一次 hash 不能省略:跳过证书表后,文件未必已经结束。表后若还有字节,当前实现仍把它们送进摘要流。
橙色区域跳过,绿色区域按文件顺序计算摘要,节间空洞、原始填充和证书表后数据也在计算范围内
这套规则由当前样本和回归测试约束。特殊或不规范的 PE 布局,还需与目标平台和更多签名工具交叉核验;几个样本通过,不能证明所有布局都兼容。
回归测试分别构造了节头顺序反转、节间空洞、未对齐的原始节长度和证书表后的附加数据,再修改这些位置中的一个字节,确认摘要检查能发现变化。这样能直接检查“这一个字节到底有没有被算进去”,也防止后续改动漏掉这些区域。

四、第二个难点:ASN.1 多包一层,签名就可能验不过

文件摘要处理好以后,CMS 验签又遇到了字节范围问题,这次差别藏在 ASN.1 的包装层里。PE 摘要检查文件,SignedData 内容摘要则把消息内容与签名者签过的属性关联起来。两者处在不同层次,即使都用 SHA-256,输入也完全不同。
传统 Authenticode 的 SPC 内容和 Catalog 的 CTL 内容使用自定义 ASN.1 SEQUENCE。计算消息内容摘要时,需要的是序列的内容部分;若把整个 DER 编码值连同外层 tag 和 length 一起算进去,结果就可能与签名中的 messageDigest 不一致。
RFC 3161 时间戳不能照用这个处理。它的 CMS 内容由 OCTET STRING 承载,值字节里面是完整编码的 TSTInfo,这份内部 DER 必须保留。
SPC/CTL 取序列内容,RFC 3161 取 OCTET STRING 值中的完整 TSTInfo DER,两条路径需要保留的包装层不同
代码因此分开保存“编码值”和“实际参与内容摘要的字节”,再按消息类型选择。统一用“去掉 ASN.1 外壳”函数处理所有消息,会在修好 SPC 路径的同时破坏时间戳的输入。
verification.cpp 的 PKCS#7 内容读取函数分别返回 encoded 和 content,后者才用于消息内容摘要:
CPP
bool signed_content(PKCS7* p7, Bytes& encoded, Bytes& content) {
    PKCS7* inner = p7 && p7->d.sign ? p7->d.sign->contents : nullptr;
    if (!inner) return false;
    if (PKCS7_type_is_data(inner)) {
        encoded = content = bytes(inner->d.data);
        return encoded.data != nullptr;
    }
    if (!PKCS7_type_is_other(inner) || !inner->d.other) return false;
    encoded = attribute_bytes(inner->d.other);
    if (!encoded.data || encoded.size > max_payload) return false;
    if (inner->d.other->type == V_ASN1_SEQUENCE)
        return sequence_body(encoded, content);
    if (inner->d.other->type == V_ASN1_OCTET_STRING) {
        content = encoded;
        return true;
    }
    return false;
}
sequence_body 会解析、检查长度编码,不能固定跳过两个字节。attribute_bytes 取 OCTET STRING 的值字节,保留其中的内部 DER;RFC 3161 专用验证路径通过 CMS_get0_content 取得 token 中完整的 TSTInfo。
signed attributes 还要单独检查。属性集合有自己的 DER 编码规则,contentType 必须与消息一致,messageDigest 必须与实际内容一致。重复或畸形的关键属性,也不能挑出一份就继续接受。CMS 标准规定了这些关系。
做到这里,验证流程才串起来:先比对 PE 摘要与消息声明,再核对消息内容摘要和 signed attributes,最后检查签名值。OpenSSL 负责密码学运算,项目仍要确定输入范围,并检查各层数据之间的关系。

五、时间戳:标准里的严格要求,与真实 Windows 签名的习惯

时间戳是这次移植中最能体现平台差异的一部分。原版已经支持 PKCS#9 Counter Signature 和 RFC 3161。移植时,除了读出日期,还要逐项核对:时间戳是否绑定当前签名者的签名值,token 自身的签名是否成立,TSA 证书身份是否正确,证书链是否满足要求。
这些检查通过以后,才能用时间戳给出的历史时刻验证签名者证书链。签名者自己声明的 signingTime 没有同等证明能力,不能凭这个日期让一张过期证书重新变成可信。
检查真实 Microsoft 签名的时间戳时,又遇到了两个兼容问题:
先是扩展密钥用途。RFC 3161 要求 TSA 证书具有时间戳用途,且这一扩展必须标为 critical。但实际 Authenticode 样本中,有些 TSA 证书只有 timeStamping 用途,扩展却不是 critical。对它们直接使用严格的时间戳 purpose 检查,就可能得出与原 Windows 版本不同的结果。
这版先按通用规则构链,再显式检查代码签名和时间戳用途。对于 RFC 3161 的 TSA 叶证书,仍然要求唯一的 timeStamping 用途,但按 Authenticode 兼容策略接受非 critical 的扩展。用途错误或密钥用途不合适,仍会拒绝。旧式 Counter Signature 保留自己的用途检查。
项目还需要支持 OpenSSL 3.0,较新的代码签名 purpose 却是在后来的版本中加入的。显式检查用途,也能避免功能随着开发机安装的 OpenSSL 小版本变化。
另一个问题是 ESS 证书绑定。一个 Microsoft 时间戳样本在严格检查下报出了这样的错误:
TEXT
ESS routines::ess cert id not found
ESS 属性既可以引用 TSA 签名证书,也可以带上额外的证书引用。遇到这里的错误,不能用“OpenSSL 太严格,所以把检查关掉”来处理。第一项的证书摘要,以及可选的 issuerSerial,必须与实际 TSA 签名证书一致。这一项过不了,时间戳就必须失败。
第一项通过后,额外引用仍可能无法根据当前提供的证书和路径完成核验。最终为 Microsoft Authenticode 时间戳保留了一条兼容分支:允许额外引用核验失败时继续执行,并把原因写入结果。严格模式要求全部引用满足约束;标准 RFC 3161 时间戳属性始终执行严格 ESS 检查。
对应的 rfc3161 的 ESS 检查有两次调用。下面省略了较长的诊断字符串,保留失败时的处理分支:
CPP
if (!check_ess(signer, chain, true)) {
    // ... 写入首项绑定失败的诊断。
    return result;
}
ERR_clear_error();
if (!check_ess(signer, chain, false)) {
    const std::string error = errors();
    if (enforce_all_ess) {
        // ... 写入严格模式的失败诊断。
        return result;
    }
    // ... Microsoft 兼容分支记录 error,说明额外引用未能核验。
}
true 检查首项绑定,false 检查全部引用。严格模式或标准时间戳属性会触发 enforce_all_ess。在执行这段代码之前,token 与原始签名值的绑定、CMS 签名和 TSA 构链都已分别处理,因此这条兼容分支只放宽额外 ESS 引用的核验。
兼容处理还有一个前提:先完整解码原始 ESS 属性,再提取第一项检查。若直接重建一份只含第一项的合法结构,原始数据中可能存在的畸形编码就会被掩盖。
验证时间戳,需要核对它与当前签名的绑定、TSA 身份及证书链,才能使用其中的历史时刻
做完这些修正后,我对跨平台验签的兼容策略有了更明确的认识:选择了哪些规则,就得把它们记录下来。库默认采用的用途和属性检查规则可以很合理,但它们不一定与 Windows 代码签名生态的行为完全一致。

六、证书链和吊销:把原来隐含的环境变成输入

CMS 和时间戳都通过了,证书链仍可能失败。查这类问题,要先找清楚发行者证书和信任根的来源。
Windows 上,新版仍用 Crypt32 读取当前用户及计算机的证书库、受信任根、显式拒绝和相关用途限制。macOS 读取系统锚点、Keychain 和信任设置,Linux 使用 OpenSSL 默认的 CA 文件或目录。证书路径与密码学验证交给 OpenSSL。
原生 API 在这里负责提供平台证书与信任素材。项目继续遵守不调用 WinVerifyTrust 和 CryptCATAdmin 的约束。即便各平台都用 OpenSSL 验证,也不能据此声称它们会给出完全一致的信任判断。
Windows 机器上有的 Microsoft 根证书,另一台 Linux 机器上可能没有。让调用方明确提供 CA 集合,就能检查这部分环境差异。签名中附带的自签名证书可以读出来,但它出现在文件里,不等于应该信任它。OpenSSL 的证书验证接口同样需要配置证书来源和验证参数。
到吊销检查时,还得追查 CRL 和 OCSP 的证据是否可靠:签名和授权是否正确,证书标识是否对应,时间和适用范围是否有效。一份下载成功的 CRL 未必适用于当前证书;一个 OCSP 响应也不能只读取状态字节就采纳。
实现中还处理了 direct CRL 的范围、base/delta 配对和 hold/release 等情况。间接 CRL 或部分范围扩展若无法安全解释,结果就保持未知。只写一句“已经支持 CRL”,交代不了这些边界。
发行者证书的 AIA 缓存也需要这样检查。文件名与发行者名称都匹配,缓存中的公钥却未必能验证子证书。为此,测试专门替换了缓存发行者的公钥,保留那些容易误导判断的名称信息,再确认这份缓存不能把链补成有效路径。
网络或缓存失败,也不能直接算作吊销。证书已被吊销,与证据不足、无法判断状态,必须分别处理。其他检查已经通过,而要求的吊销检查仍无法确定状态时,汇总结果才进入 indeterminate,不能悄悄算作通过。
verification.cpp 的结果汇总 先检查是否已有失败,再处理吊销状态未知的情况:
CPP
void finalize(Verification& verification) {
    const bool timestamp = verification.timestamp == "valid" ||
                           verification.timestamp == "absent";
    const bool valid = verification.content_digest == "valid" &&
        verification.cms_signature == "valid" &&
        verification.certificate_chain == "trusted" && timestamp &&
        verification.revocation != "revoked";
    verification.overall = !valid ? "invalid" :
        verification.revocation == "unknown" ? "indeterminate" : "valid";
}
局部变量 valid 尚未排除吊销未知,所以最终结果仍可能是 indeterminate。如果没有启用吊销检查,也不会仅因“没有联网”就得到 unknown。签名没有时间戳时,仍可以按验证时刻检查证书链。
证书来源回答“拿到什么”,路径和信任检查回答“能否接受”,CRL、OCSP 还需单独验证,证据不足与确认吊销得到的结果不同
在线测试用本地 HTTP 服务提供发行者、CRL 和 OCSP 响应,以便把这些路径测清楚。正常、过期、撤销、错误签名和服务不可用等状态可以稳定切换,测试也不必依赖某个公共 CA 服务此刻是否在线。

七、能编译,还需要能离开开发机运行

核心逻辑跑通后,开始检查 Windows 运行包,又遇到了几项实际问题。
本机构建用的是机器上已有的 Git for Windows 所带的 OpenSSL 和 libcurl DLL,没有直接走文档中的 vcpkg 路线。为验证准备好临时头文件和导入库后,现有代码就能尽快做 Windows 原生编译、检查真实样本,并核对完整依赖。
准备临时 OpenSSL SDK 时,需要处理一个容易忽略的架构差异:Linux x64 通常采用 LP64,Windows x64 采用 LLP64,两边的 unsigned long 宽度不同,对应的 BIGNUM 配置宏也需要修正。两边都叫“x64”,配置头中的类型假设却未必一致,不能直接照搬。
这套本机验证组合取决于具体架构、导出符号和所用接口,不能据此认为所有版本的开发头与 DLL 都能随意混用。构建清单记下了实际使用的头文件和运行库版本;常规重复构建仍应使用与目标架构匹配的开发包。
接着检查运行依赖。把 MSVC 运行库改成 /MT 后,应用自身不再依赖额外安装 VC Runtime,但 OpenSSL、libcurl 和它们的传递依赖仍需一起分发。最终那个 x64 包带了十四个 DLL。逐层查导入表,才能找全 libcrypto 和 libcurl 背后的依赖。
我把运行检查的 PATH 收缩到 Windows 系统目录,再执行包里的程序,查找那些平时由 Git 或其他开发软件“顺手提供”的依赖。包中也加入了组件许可证和文件校验清单。
HTTPS 请求又暴露了一项配置依赖。复用的 libcurl 默认 CA bundle 路径指向原安装环境,文件在独立运行环境里并不存在。EXE 和 DLL 虽然已经齐全,TLS 证书验证所需的材料仍可能缺失。
Windows 分支最终启用 libcurl 的 CURLSSLOPT_NATIVE_CA,从系统读取 HTTPS 所需的 CA,同时保持服务端证书和主机名校验开启。独立探针确认,在默认 CA bundle 文件不存在的环境中,HTTPS 请求仍完成了证书验证,并得到 HTTP 200。
network.cpp 的 libcurl 配置 分别设置了系统 CA 来源与两项 TLS 校验:
CPP
const auto set = [&](CURLoption option, auto value) {
    if (curl_easy_setopt(handle.get(), option, value) != CURLE_OK)
        configured = false;
};
// ... 省略 URL、超时等配置。
set(CURLOPT_SSL_VERIFYPEER, 1L);
set(CURLOPT_SSL_VERIFYHOST, 2L);
#if defined(_WIN32) && defined(CURLSSLOPT_NATIVE_CA)
// Use Windows trust roots even when the curl DLL is distributed without
// its original installation's CA bundle.
set(CURLOPT_SSL_OPTIONS, static_cast<long>(CURLSSLOPT_NATIVE_CA));
#endif
这几行让运行包用上了当前 Windows 环境的信任材料。DLL 检查找的是库依赖,CA 来源检查找的是 TLS 验证材料,两部分都得补齐。
运行包离开开发机后,需要自己找到动态库和 TLS 信任材料
做完这些检查,我才把“跨平台构建完成”的判断放到更后面。生成 EXE 以后,还得继续核对库、配置、证书来源和实际运行环境。

八、运行时的第一次反馈:Catalog 已经找到了,警告却还在

Windows 运行包生成后,用一个系统程序作为目标文件(例如:C:\Windows\notepad.exe)检查时遇到了一个很有代表性的问题。
它走的是系统安装的 Catalog 签名路径,输出已经显示 signatureType: cataloged,也找到了对应的 .cat 文件,但末尾仍有一条警告:
TEXT
content type is not Authenticode SpcIndirectDataContent: 1.3.6.1.4.1.311.10.1
排查后发现,问题出在通用元数据解析:代码还在按嵌入式 SPC 内容提取映像摘要。Catalog 的正常内容类型是 CTL,存放的是一组成员,本来就不该要求它带有嵌入式签名那样的映像摘要字段。
文本输出中的 imageDigestAlgorithm 和 embeddedDigest 也因此为空。只看这两个字段,就容易误以为正常 Catalog 没有被完整解析。
修正后,解析器明确接受 CTL 内容类型,文本输出不再显示那些不适用于 Catalog 的直接映像摘要字段,未知内容类型仍然报警。JSON 中的已有字段继续保留空值,避免为修正显示而破坏数据格式。
Catalog 的成员判断还需要单独检查,在 .cat 的某处搜到 PE 摘要,只能用来粗筛候选。相同字节可能出现在属性或其他无关数据里,无法证明成员关系。最终还要解析 CTL,比较成员项中的 subjectIdentifier;进入验证模式后,再验证 Catalog 签名和证书链。
catalog.cpp 的成员遍历在解析 CTL 头部和算法之后,只比较每个成员的标识字段:
CPP
bool match = false;
std::size_t entries_cursor = 0, entries = 0;
while (entries_cursor < field.body.size) {
    Element entry, identifier, attributes;
    if (++entries > 100000 || !next(field.body, entries_cursor, entry) ||
        entry.tag != 0x30) return false;
    std::size_t entry_cursor = 0;
    if (!next(entry.body, entry_cursor, identifier) || identifier.tag != 4 ||
        !next(entry.body, entry_cursor, attributes) || attributes.tag != 0x31 ||
        entry_cursor != entry.body.size) return false;
    // Only CTLEntry subjectIdentifier may match; unrelated OCTET STRINGs
    // in attributes, certificates or unsigned data can never satisfy this.
    if (identifier.body.size == digest.size() &&
        std::equal(digest.begin(), digest.end(), identifier.body.data)) match = true;
}
return match;
找到匹配成员后,循环也不会停,还要检查剩余条目的结构。后面若有畸形条目,仍会失败。match 只表示成员关系,Catalog 的签名和信任检查由验证路径继续处理。
搜到摘要字节只是找到了候选。还要精确匹配成员标识,进入验证模式后再检查 Catalog 签名和证书链
Microsoft Catalog 的算法标识不一定是 SHA-1 或 SHA-256 OID,也可能使用自己的 catalog-list-member 标识;成员里还可能混用 SHA-1 和 SHA-256。这些情况都补了独立测试。“警告文字不对”的反馈最终查到了数据模型:嵌入式签名和 Catalog 两条路径需要分别处理。

九、运行时的第二次反馈:根证书为什么没有出来?

修掉 Catalog 的警告后,对照原版和 OpenSSL 版的默认输出,又发现了一个差异。对于同一个 C:\Windows\notepad.exe,原版能够输出三张证书:
TEXT
Microsoft Windows
  → Microsoft Windows Production PCA 2011
    → Microsoft Root Certificate Authority 2010
OpenSSL 版默认解析只输出了前两张。
最初可以用“默认没有执行验证”解释这个差异,状态上也说得通。不过,原版作为签名分析工具,默认就会展示根证书。新版本的验证模式已经能拿到完整链,默认分析却仍然缺了原工具的一项能力。
这里混淆了消息内附的证书和本地构建出的证书链。Catalog 的 SignedData 携带了签名者和中间 CA,根证书来自 Windows 系统证书库。原版在默认分析时会继续查找发行者,第一版 OpenSSL 输出主要停在消息自身携带的证书袋。
为此,修正版增加了独立的本地链元数据构建。从每个签名者开始,用内嵌证书、系统证书库、显式 CA 和已有的本地 AIA 缓存补齐路径。找到同名发行者后,仍需检查身份约束和子证书签名,才能确认发行者关系。
调用方先筛出名称匹配的候选,再用候选公钥验证子证书,具体检查在 trust.cpp 的发行者关系检查中:
CPP
bool metadata_issuer_matches(X509* issuer, X509* certificate) {
    // Metadata describes a cryptographic issuer relationship, including chains
    // whose certificate usage would not permit verification of code signatures.
    const auto issued = X509_check_issued(issuer, certificate);
    if (issued != X509_V_OK && issued != X509_V_ERR_KEYUSAGE_NO_CERTSIGN)
        return false;
    auto* key = X509_get0_pubkey(issuer);
    const bool verified = key && X509_verify(certificate, key) == 1;
    ERR_clear_error();
    return verified;
}
元数据构链不判断证书用途,因此这里接受 X509_V_ERR_KEYUSAGE_NO_CERTSIGN。其他身份约束错误仍会被拒绝,子证书签名也必须通过。用途、时间、根信任和吊销留给验证流程判断。
默认构链全程离线,也不进行文件验签、有效期、用途、信任和吊销判断。即使签名者证书已经过期,也能展示其完整发行路径。发行者找不到时,保留已经取得的部分链;只有到达并确认自签名根,才标记为构建完成。
因此,路径完成状态记为 built,验证得出的信任结论记为 trusted。追溯到根证书只说明证书之间的路径关系成立,平台或调用方是否信任这张根证书,还要单独判断。
再次检查目标文件,默认输出中已经有了 Microsoft Root Certificate Authority 2010。根证书的序列号与 SHA-1 指纹都和原版一致,主题、发行者、有效期和签名算法也能完整展示。
这次修正值得记下来:验证模式通过,不能据此断定默认分析已经覆盖原工具的日常行为;同一个普通系统文件放到两个版本里对照,就能发现单元测试之外的功能遗漏。

十、回看这次从零到一的重构

到目前为止,OpenSSL 子项目已经可以独立构建。核心验证路径打通后,Catalog 展示和默认根证书补齐也逐步跟上了原工具的行为。特殊 PE 布局和不同系统的信任配置,还要放进更大的样本集继续检验。目前的诊断信息已经能帮助追踪差异,下一轮对照时也有了具体的排查起点。
回头看这次跨平台实现,一些很小的失败推动了后续修改:摘要里的一段填充、ASN.1 的一层包装、时间戳里的一项额外引用,以及输出里缺失的一张根证书。沿着这些问题追到具体字节、证书来源和验证策略,程序的行为才逐渐有了可检查的依据。
下一次再遇到 WinCrypt 版本可以提取签名和证书信息、但 OpenSSL 版本不能的文件,我至少能按已经拆开的各层继续查:哪段内容没有对应上,哪份时间证明不成立,哪张发行者证书缺失,或者哪条信任规则发生了变化。能够这样定位失败,是这次跨平台重写带给项目最实在的收获。
0
27
0
暂无评论
评论这篇文章
必填
必填
以 的身份评论
© 2015-2026Xiaodao Agent京ICP备18017134号京公网安备11010502035375号