为了方便在 mac 和 Linux 下解析与验证 PE 签名,我开发了一个跨平台工具 GitHub - 0xlane/pe-sign: A cross-platform rust no-std library for verifying and extracting signature information from PE files.。安装和使用方法可以参考项目中的 README_zh.md。本文分享的是开发该工具之前做的一些签名研究内容。
导出签名数据
具体的 PE 格式可以参考 MSDN。
签名证书的位置在 Certificate Table 里(也称为 Security Directory):
可以从 Optional Header 的 Data Directories 里找到 Security Directory 的文件偏移:
如图表示,ProcessHacker.exe 文件的签名数据在 0x1A0400 位置,长度 0x3A20。
导航到这个偏移位置即可看到这里就是签名数据:
参考 MSDN, 签名数据的结构如下:
1 2 3 4 5 6 |
|
所以,bCertificate 是实际的证书内容,wCertificateType 表示签名证书类型,根据这个字段可知支持三种证书类型:PKCS #1、PKCS #7、X509,我看到过的文件都是使用 PKCS #7 签名。
找到 Security Directory 偏移之后,跳过前面的 8 字节就是实际的 PKCS #7 证书内容,DER 格式,代码示意:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
使用项目中的 pe-sign 工具可以直接导出:
1 |
|
使用 --pem
参数可以将 DER 格式转换为 PEM 格式:
1 |
|
使用 openssl 解析证书
解析导出的证书,如果是 PEM 格式,-inform DER
改为 -inform PEM
:
1 |
|
这里有个小疑问,从属性里看 ProcessHacker.exe 文件有两个签名,一个是 sha1,另一个是 sha256:
openssl 打印的证书信息只有 sha1 证书的,使用 -print
参数打印 pkcs7 结构也可以看到是有 sha256 证书内容的但是没正确解析。
看了下微步沙箱也只解析到了 1 个签名:https://s.threatbook.com/report/file/bd2c2cf0631d881ed382817afcce2b093f4e412ffb170a719e2762f250abfea4
解析内嵌证书
经过一番观察,发现 sha256 这个证书是在 sha1 证书属性里内嵌的,并不是平级:
然后就搜了一个这个属性名 1.3.6.1.4.1.311.2.4.1
有什么特殊的地方,从 MSDN 可知,这个值表示的是 szOID_NESTED_SIGNATURE
内容(实际就是一个 pkcs#7 格式证书),ChatGPT 是这么解释这个属性的:
szOID_NESTED_SIGNATURE 是一个表示嵌套签名的对象标识符(OID),其对应的 OID 是 1.3.6.1.4.1.311.2.4.1。在 PKCS7 或 CMS(Cryptographic Message Syntax)中,嵌套签名允许在签名数据中嵌套另一个签名数据块。这种机制用于实现多层次的签名或加密操作。
使用 openssl 的 asn1parse 命令可以找出嵌套签名的偏移位置:
1 2 3 4 5 6 7 |
|
SET 后面开始就是嵌入数据,7658 就是嵌套数据开始的文件偏移,hl 表示头大小,l 表示数据大小,所以总的嵌套数据大小为 4+7203=7207。使用 powershell 提取这个嵌套签名:
1 2 3 4 5 6 7 8 |
|
再次使用 openssl 就可以解析出这个嵌套签名证书:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
|
导出内嵌证书
代码解析的话,需要按 ASN.1 结构手动解析,因为这里找的内容算是个固定的,只需要定位 1.3.6.1.4.1.311.2.4.1
所在位置就可以。所以就需要研究一下 OID 在证书内是以怎么方式存储的,根据之前 openssl 的 asn1parse 命令的结果可以知道偏移位置 7642 + 2 处为 1.3.6.1.4.1.311.2.4.1
,长度 10:
1 |
|
用下面的代码读取出来为 2b 06 01 04 01 82 37 02 04 01
:
1 2 3 4 5 6 7 8 |
|
可以发现有些数字是可以直接能和 1.3.6.1.4.1.311.2.4.1
对应上的:
1 |
|
不难看出,这里的 2b
就是表示的 1.3
,82 37
表示的 311
。但是 13
的十六进制表示为 D
、311
的十六进制表示为 137
,所以这里并不是直接的10-16转换关系。查了一些 OID 的资料,在 这篇文章 中找到了一些关于 OID 编码方式的描述:
OID 的实质就是一串整数, 而且至少由两个整数组成。 第一个数必须是 0、1、2 三者之一, 如果是 0 或 1,则第二个数必须小于 40。 因此,前两个数 X 和 Y 可以直接用 40×X+Y 来表示,不会产生歧义。
以 2.999.3 的编码为例,首先要将前两个数合并成 1079(即 40×2+999),得到 1079.3。
完成合并后再用 Base 128 编码,左侧为高位字节。 也就是说,每个字节的最高位设为 1,但最后一个字节最高位设为 0,表示一个整数到此结束。各字节的其余七位从高到低依次相连表示数值。 例如数字 3 就用一个字节 0x03 表示, 而 129 则需要两个字节 0x81 0x01。 每个数字都如此转换成字节后,拼接在一起就形成了 OID 的编码。
无论是在 BER 还是 DER 中,OID 都必须用最短的方式编码。 所以其中每个数字编码时开头都不能出现 0x80 字节。
所以只有开头的 1.3
编码方式和后面不一样,后面都是 Base 128 编码。1.3
需要先按公式变成 40x1+3 = 43
,再转换为十六进制就是 2b
。反着推就是 2b
的十进制表示为 43
,需要先确定第一位,因为只能是 0、1、2。
0 和 1 时第二个数不能超过 40,所以可以知道第一位:
- 是 0 时,最终得到的十进制数字在 [0, 40) 之间
- 是 1 时,最终得到的十进制数字在 [40, 80) 之间
- 是 2 时,最终得到的十进制数字在 [80, 255) 之间(我感觉开头两位长度是固定的 1 字节,所以超不过 255,不可能出现上面文章说的 1079 的情况)
所以 43
在 [40, 80) 之间,第一位应该是 1
,第二位就是 43 - 40x1 = 3
。
后面每个数字都由 Base 128 编码方式存储,即数字在 128 (0x80) 以内(不包含 128)不需要转换直接存储,所以在上面能看到有些个位数的数字能直接对应上。128 之后就需要转成二进制从右向左每 7 位拆分一次,例如 311
的二进制表示为 100110111
,每 7 位拆分 1 次变成:
1 |
|
前面补 0 变 8 位:
1 |
|
除最后 1 个字节(8 位)不需要动,前面每个字节最高位变成 1:
1 |
|
最后变成 十六进制看就是 82 37
。
现在对 2b 06 01 04 01 82 37 02 04 01
整体反向解析一下,开头两位前面说过了,2b
表示 1.3
,后面的数字需要 1 个接 1 个字节换成二进制形式地看:
- 先看
06
二进制表示为00000110
,开头是 0,所以直接转换为6
,01 04 01
同理表示的1.4.1
- 到
82
二进制形式为10000010
,开头是 1 表示还有后续,37
二进制形式为00110111
,开头是 0 表示当前数字结束了,82 37
是一个整体,整体换成二进制10000010 00110111
,去掉每个字节的最高位连起来就是0000010 0110111
,即311
- 后面的
02 04 01
都不超过 128,所以和前面一样是直接存储的不需要转换,表示的2.4.1
合起来最终得到 1.3.6.1.4.1.311.2.4.1
。
搞明白这个之后,可以直接通过 2b 06 01 04 01 82 37 02 04 01
定位到后 1 位再向后偏移 4 字节跳过 SET 块就是 SEQUENCE
,此处便是内嵌证书的开头位置,一般直接读取到结尾就可以,严谨一点的话需要解析一下头部位置:
1 |
|
第 1 个字节是标签,30 表示 SEQUENCE
不用管,第二个字节最高位被设置为 1 的时候代表长编码,为 0 表示短编码,短编码时该字节就表示内容的长度,长编码时该字节表示存储长度的字节大小,0x82
最高位为 1,所以是长编码形式,去掉最高位的 1 就是 0x2
,表示后面有 2 个字节用来表示 SEQUENCE
内容的长度,即 0x1c23
,加上头的长度,这里是 4
,内嵌证书长度就是 0x1c27
。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
现在可以直接使用项目中的 pe-sign 工具的 --embed
参数直接导出:
1 |
|
也可以直接传递到 openssl 解析:
1 |
|
验证证书
PKCS #7 证书的验证可以直接调用 PKCS7_verify,该函数原型:
1 |
|
根据参数可以知道验证证书需要准备的东西:
p7
: 证书内容certs
: 可选参数,签名者证书,在 PE 提取的证书里一般有签名者,可以不传store
: 可选参数,用于链路验证的受信任证书存储,需要验证证书链所有证书的有效性,所以需要传,可以导入 curl 提供的 cacert.pem 文件作为受信任的 CA 证书indata
: 被签名的原始数据内容,可空out
: 验证通过后,输出签名的数据,可空flags
: 一些控制验证过程的标志,没有特殊需求就是 0
里面比较特殊的是 indata
参数,根据资料显示如果证书为 detached 证书,即签名独立证书,此时证书内不包含被签名的原始数据内容,需要调用 PKCS7_verify
时传入 indata
参数,否则可以为空。
这样的话,PE 中提取的证书内必然包含被签名的原始数据,所以看似不需要传入 indata
,使用这个函数对应的 openssl 命令快速验证一下:
1 2 3 4 5 6 |
|
验证失败了,导致失败的报错信息是 digest failure
和 signature failure
,看起来是签名和原始数据内容并不匹配。大概查了一下,PKCS #7 SignedData 结构用 ASN.1 描述为:
SignedData ::= SEQUENCE { version Version, digestAlgorithms DigestAlgorithmIdentifiers, contentInfo ContentInfo, certificates [0] IMPLICIT ExtendedCertificatesAndCertificates OPTIONAL, Crls [1] IMPLICIT CertificateRevocationLists OPTIONAL, signerInfos SignerInfos } DigestAlgorithmIdentifiers ::= SET OF DigestAlgorithmIdentifier ContentInfo ::= SEQUENCE { contentType ContentType, content [0] EXPLICIT ANY DEFINED BY contentType OPTIONAL } ContentType ::= OBJECT IDENTIFIER SignerInfos ::= SET OF SignerInfo SignerInfo ::= SEQUENCE { version Version, issuerAndSerialNumber IssuerAndSerialNumber, digestAlgorithm DigestAlgorithmIdentifier, authenticatedAttributes [0] IMPLICIT Attributes OPTIONAL, digestEncryptionAlgorithm DigestEncryptionAlgorithmIdentifier, encryptedDigest EncryptedDigest, unauthenticatedAttributes [1] IMPLICIT Attributes OPTIONAL } IssuerAndSerialNumber ::= SEQUENCE { issuer Name, serialNumber CertificateSerialNumber } EncryptedDigest ::= OCTET STRING
ContentInfo
中保存的原始数据,encryptedDigest
中是消息摘要,验签时需要使用签名者的 publickey 解开 encryptedDigest
得到一个 hash,然后如果存在 authenticatedAttributes
,里面能再提取出一个消息摘要,这个消息摘要的 hash 如果和 encryptedDigest
中的一致说明可信,验证 authenticatedAttributes
可信后,从 authenticatedAttributes
中又可以得到一个 hash,这个 hash 是 ContentInfo
中原始内容的 hash,最终通过该 hash 验证原始数据内容是否可信。假如没有 authenticatedAttributes
,encryptedDigest
的消息摘要 hash 就是 ContentInfor
原始内容的 hash。
调用 PKCS7_verify
时,会自动进行上面的验证,但是当解析 ContentInfo->content
的时候,根据 ContentInfo->contentType
类型解析,只有类型为 data
、ASN1_OCTET_STRING
才能被正确解析到 content(参考源码位置 pk7_doit.c#L48)。
根据 Authenticode_PE.docx 中的描述,Authenticode 相当于 PE 文件的 hash,保存在 ContentInfo->content
中,ContentInfo->contentType
必须是 SPC_INDIRECT_DATA_OBJID (1.3.6.1.4.1.311.2.1.4)
,所以 PKCS7_verify
并不能解析出 content 内容,需要自行解析后通过 indata
参数传入。
以下是 SpcIndirectDataContent
的 ASN.1 定义:
SpcIndirectDataContent ::= SEQUENCE { data SpcAttributeTypeAndOptionalValue, messageDigest DigestInfo } --#public— SpcAttributeTypeAndOptionalValue ::= SEQUENCE { type ObjectID, value [0] EXPLICIT ANY OPTIONAL } DigestInfo ::= SEQUENCE { digestAlgorithm AlgorithmIdentifier, digest OCTETSTRING } AlgorithmIdentifier ::= SEQUENCE { algorithm ObjectID, parameters [0] EXPLICIT ANY OPTIONAL }
对应的 asn1parse 解析结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
下面是 pe-sign 工具中关于提取 indata
的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
解析证书中的一些关键信息
由于 openssl 解析出的证书信息只是一些证书通用的信息,有些关键字段还需要单独解析一下:
- signingTime: 签名时间
- authenticode: PE 部分内容的哈希,验证证书有效之后,还需要验证 authenticode
签名时间解析
签名时间大多数时候存储在副署签名里,少数直接在认证属性里就可以找到,在副署签名里的时候有两种情况:
第一种情况,当 unauth_attrs 直接存在副署(countersignature)属性时,该内容为 PKCS7_SIGNER_INFO
结构,表示副署签名者信息,该信息作为副署(countersignature)签名展示:
第二种情况,不存在副署(countersignature)属性,寻找 unauth_attrs 中的 1.3.6.1.4.1.311.3.3.1
属性作为副署签名,该内容为 Timestamping signature:
该属性内容被作为副署(countersignature)签名展示:
签名时间一般保存在副署签名中名为 signingTime 的 auth_attrs 里:
如果 signingTime 属性不存在(案例),签名时间保存在 contentInfo->content
中。
把案例文件的 1.3.6.1.4.1.311.3.3.1
属性内容导出:
1 2 3 4 5 6 7 8 9 10 |
|
从结果中可以看到,contentInfo->content
内容类型为 id-smime-ct-TSTInfo
,里面也是一段 ASN.1 序列化数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
从右侧可以看出,20240801004644.617Z
就是签名时间。程序中需要解析 TSTInfo 结构:
TSTInfo ::= SEQUENCE { version INTEGER { v1(1) }, policy TSAPolicyID, messageImprint MessageImprint, -- MUST have the same value as the similar field in -- TimeStampReq serialNumber INTEGER, -- Time Stamps users MUST be ready to accommodate integers -- up to 160 bits. serialNumber SerialNumber, genTime GeneralizedTime, accuracy Accuracy OPTIONAL, ordering BOOLEAN DEFAULT FALSE, nonce INTEGER OPTIONAL, -- MUST be present if the similar field was present -- in TimeStampReq. In that case it MUST have the same value. tsa [0] GeneralName OPTIONAL, extensions [1] IMPLICIT Extensions OPTIONAL }
提取出 contentInfo->content
,并通过 openssl 解析 asn1 结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
|
没有找到能快速解析 TSTInfo 结构的方法,所以代码里直接通过搜索第一个 GENERALIZEDTIME
作为签名时间。在 ASN.1 序列化数据中 0x18 表示 GENERALIZEDTIME
,后跟一个字节表示长度,因为 GENERALIZEDTIME
时间格式大多数时候为 YYYYMMDDHHMMSSZ
、YYYYMMDDHHMMSS.sssZ
,其他情况遇到再改代码吧,s
可以减少不一定是 3 个,所以长度在 15 - 19 这个范围,找 18 [15, 19]
序列即可。
authenticode 解析
Authenticode 信息在 indata
里,需要根据 SpcIndirectDataContent
结构解析:
SpcIndirectDataContent ::= SEQUENCE { data SpcAttributeTypeAndOptionalValue, messageDigest DigestInfo } --#public— SpcAttributeTypeAndOptionalValue ::= SEQUENCE { type ObjectID, value [0] EXPLICIT ANY OPTIONAL } DigestInfo ::= SEQUENCE { digestAlgorithm AlgorithmIdentifier, digest OCTETSTRING } AlgorithmIdentifier ::= SEQUENCE { algorithm ObjectID, parameters [0] EXPLICIT ANY OPTIONAL }
SpcIndirectDataContent->messageDigest->digest
就是 authenticode,indata 数据开头的 sequence 表示的是 SpcIndirectDataContent->data
,跳过这个 sequence 就是 messageDigest
。
提取代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
通过 pe-sign 工具验证证书并打印证书信息:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
authenticode 计算
Authenticode_PE.docx 中有详细的 authenticode 计算步骤:
- 加载 PE 文件到内存
- 初始化 hash 算法上下文
- hash checknum 字段之前的内容
- 跳过 checknum 字段,4 字节
- hash checknum 字段之后到 Certificate Table Directory 之前
- 获取 Certificate Table Directory 大小
- 跳过 Certificate Table Directory,hash Certificate Table Directory 之后到 header 结尾
- 创建一个计数器 SUM_OF_BYTES_HASHED, 设置计数器为 optionalHeader->SizeOfHeaders 字段值
- 构建一个由 section header 组成的数组
- 根据 PointerToRawData 字段对 section header 数组递增排序
- 根据排序后的 section header,hash section 内容
- 添加 section header 的 SizeOfRawData 值到 SUM_OF_BYTES_HASHED
- 重复 11、12 步遍历所有 section
- 创建一个 FILE_SIZE 变量表示文件大小,如果 FILE_SIZE 比 SUM_OF_BYTES_HASHED 大,表示还有额外的数据需要 hash. 额外数据在 SUM_OF_BYTES_HASHED 偏移处开始,长度为:
(File Size) – ((Size of AttributeCertificateTable) + SUM_OF_BYTES_HASHED) - 完成 hash 算法上下文
可以通过 pe-sign 工具计算 authenticode,支持 sha1、sha256、md5 三种算法:
1 2 3 4 5 6 7 8 9 10 11 |
|
使用 pe-sign 工具验证文件签名时,status 如果是 InvalidSignature
表示证书中的 authenticode 与实际不符,文件被篡改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
其他 tips
RSA 公钥解密
大多数工具只提供了公钥验签,不提供公钥解密功能。可以使用类似 RSA Encryption and Decryption Online 的在线解密工具,对签名数据解密。
导入系统中内置的可信任根证书
打开 cerlm.msc
证书管理窗口,导航到“受信任的根证书颁发机构->证书”,全选后导出为 p7b。
然后通过 openssl 可以转换 p7b 证书为 pem:
1 |
|
不过这种方式,不知道什么原因导出的证书有时候并不全,可能有缺失。
所以改用 python 的 wincertstore 库枚举系统根证书然后导出为 PEM:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|