Mac 恶意软件的艺术:代码签名

188 阅读36分钟

在本章中,我们将编写代码,提取恶意软件常滥用的分发文件格式(如磁盘映像和安装包)中的代码签名信息。接着,我们将关注磁盘上 Mach-O 二进制文件及运行进程的代码签名信息。针对每种情况,我会展示如何编程验证代码签名信息并检测撤销情况。

本书贯穿的基于行为的启发式检测方法,是发现恶意软件的强有力手段。但该方法存在缺点:误报,也就是错误地将正常代码标记为可疑。

减少误报的一种方式是检查对象的代码签名信息。苹果对加密代码签名的支持无出其右,作为恶意软件检测者,我们可以利用这一点多方面提升检测效果,尤其是确认项目来自已知可信源且未被篡改。

另一方面,任何未签名或未经过苹果公证的项目都应被严密审查。例如,恶意软件往往完全未签名,或采用临时签名,即使用自签名证书或不受信任的证书。虽然攻击者有时会用欺诈获得或盗用的开发者证书签名恶意软件,但苹果几乎不会对这类恶意软件进行公证。同时,一旦苹果发现错误,会迅速撤销相关签名证书或公证票据。

本章大部分代码示例可在书籍 GitHub 仓库的 checkSignature 项目中找到。

代码签名在恶意软件检测中的重要性

举个例子说明代码签名为何有助于恶意软件检测:

假设你开发了一种启发式方法,监控文件系统中持久化项目(这是检测恶意软件的合理做法,因为绝大多数 Mac 恶意软件会在被感染主机上保持持久化)。比如,当检测到 com.microsoft.update.agent.plist 属性列表作为启动代理持久化时触发报警。该属性列表引用了名为 MicrosoftAutoUpdate.app 的应用,操作系统会在每次用户登录时自动启动它。

如果检测能力未考虑持久化项目的代码签名信息,可能会错误报警,将完全无害的持久化事件误判为恶意。关键问题是:这到底是真正的微软更新程序,还是伪装成它的恶意软件?通过检查应用的代码签名,你可以明确回答这个问题:如果微软确实签署了该项目,则可忽略该持久化事件;反之,则应重点审查该项目。

遗憾的是,现有的恶意软件检测产品往往未充分考虑代码签名信息。例如,苹果的恶意软件移除工具(MRT)是某些 macOS 版本内置的恶意软件检测工具。这个平台二进制文件当然由苹果官方签名。但许多杀毒引擎曾多次错误地将 MRT 的二进制文件(如 com.apple.XProtectFramework.plugins.MRTv3)标记为恶意,因为它们的杀毒签名天真地匹配了 MRT 自身嵌入的病毒签名(见图 3-1)。

image.png

这真是一个相当滑稽的误报。说笑归说笑,错误地将合法项目归为恶意软件的产品,可能会向用户发出警报,引起恐慌,或者更糟的是,通过隔离该项目导致合法功能受损。幸运的是,第三方安全产品通常不能删除像 MRT 这样的系统组件,但苹果也曾不小心阻止过自己的组件,导致系统运行中断。1 在这两种情况下,检测逻辑本可以简单地检查项目的代码签名信息,确认其属于可信来源。

代码签名信息的作用不仅仅是减少误报。例如,安全工具应允许受信任或用户批准的项目执行某些操作,而这些操作本可能触发警报。比如一个简单的防火墙,每当不受信任的项目试图访问网络时就会发出通知。为了区分受信任和不受信任的项目,防火墙可以检查它们的代码签名。基于代码签名信息制定防火墙规则有几个好处:

  • 如果恶意软件试图通过修改合法项目绕过防火墙,代码签名检查会发现该篡改行为。
  • 如果获批项目在文件系统中移动了位置,规则仍然有效,因为规则不依赖于项目路径或具体位置。

希望这些简短的示例已经让你看到了检查代码签名信息的价值。再举几个代码签名信息帮助我们程序化检测恶意代码的其他用法:

  • 检测公证
    近版本 macOS 要求所有下载软件必须签名才能运行。因此,大多数恶意软件现在都被签名,通常是使用临时证书或伪造的开发者 ID。然而恶意软件很少被公证,因为公证要求将项目提交给苹果,苹果会扫描项目,如果项目看起来无恶意,则发放公证票据。2 苹果偶尔会误发公证给恶意软件,但会迅速发现并撤销公证。3 这种失误极为罕见,被公证的项目很可能是良性的。通过代码签名,你可以快速判断项目是否经过公证,这是苹果认为该项目非恶意的可靠信号。
  • 检测撤销
    如果苹果撤销了项目的代码签名证书或公证票据,意味着苹果判定该项目不应继续分发和运行。撤销有时可能出于无害原因,但更多是因为苹果认为该项目是恶意的。本章将说明如何程序化检测撤销。4
  • 关联已知攻击者
    研究人员归属给恶意攻击者的代码签名信息(如团队标识符),可以帮助识别同一作者创建的其他恶意样本。

检测恶意软件时,你通常关注项目的以下代码签名信息:

  • 信息的总体状态、签名证书及公证票据。项目是否完整签名且经过公证?签名证书和公证票据是否仍有效?
  • 描述签名链条的代码签名权威机构,这能帮助了解项目的来源和可信度。
  • 可选的团队标识符(team identifier),指定创建该签名项目的团队或公司。如果团队属于知名公司,通常可以信任该项目。

本章不会涉及代码签名的内部机制,重点是高层概念及用于提取代码签名信息的 API。5

但请记住,macOS 上并非所有内容都被签名,且签名方式不尽相同。尤其是,开发者无法对独立脚本进行签名(这也是苹果积极推动废弃脚本的原因之一)。macOS 内核本身也并非通过签名保护,而是通过启动过程中的加密哈希来验证其完整性。

开发者可以且应该签名分发介质,如磁盘映像、安装包、压缩包,以及应用程序和独立二进制文件。但提取代码签名信息的工具和 API 往往与文件类型相关。例如,苹果的 codesign 工具和代码签名服务 API 支持磁盘映像、应用和二进制文件,但不支持安装包,安装包的信息可用 pkgutil 工具或私有的 PackageKit API 进行查看。

接下来,我们将探讨如何手动及程序化地提取和验证代码签名信息,首先从分发介质开始。

磁盘映像

无论是合法开发者还是恶意软件作者,通常都会将代码分发为磁盘映像,文件扩展名为 .dmg。大多数含恶意软件的磁盘映像未签名,如果遇到未签名的 .dmg,至少应检查其中包含的项目是否经过签名和公证。然而,存在代码签名信息并不意味着磁盘映像是良性的——恶意软件作者同样可能利用加密签名。遇到签名的磁盘映像时,应利用其代码签名信息识别创建者。

手动验证签名

你可以使用 macOS 内置的 codesign 工具手动验证磁盘映像的签名。用 --verify(或简写 -v)加上 .dmg 文件路径执行即可。

下面示例中,codesign 识别了一个合法签名的磁盘映像,里面包含 Objective-See 的正版软件 LuLu。默认情况下,codesign 对有效签名不输出内容,因此这里用 -dvv 参数以显示详细信息:

% codesign --verify LuLu_2.6.0.dmg

% codesign --verify -dvv LuLu_2.6.0.dmg
Executable=/Users/Patrick/Downloads/LuLu_2.6.0.dmg
Identifier=LuLu
Format=disk image
...
Authority=Developer ID Application: Objective-See, LLC (VBG97UB4TA)
Authority=Developer ID Certification Authority
Authority=Apple Root CA

详细输出显示磁盘映像信息,如路径、标识符、格式以及代码签名状态,包括证书链。由证书链可知,该包由属于 Objective-See 的苹果开发者 ID 签名。

若磁盘映像未签名,工具会显示 code object is not signed at all 的信息。许多软件,包括大部分通过磁盘映像分发的恶意软件,都属于此类——作者可能为软件或恶意软件签名,但未为分发介质签名。例如,看看 EvilQuest 恶意软件。它通过磁盘映像分发,包含木马化应用程序包:

% codesign --verify "EvilQuest/Mixed In Key 8.dmg"
EvilQuest/Mixed In Key 8.dmg: code object is not signed at all

最后,如果苹果撤销了磁盘映像的签名,codesign 会显示 CSSMERR_TP_CERT_REVOKED。以下是用于分发 CreativeUpdate 恶意软件的磁盘映像例子:

% codesign --verify "CreativeUpdate/Firefox 58.0.2.dmg"
CreativeUpdate/Firefox 58.0.2.dmg: CSSMERR_TP_CERT_REVOKED

说明该恶意软件的签名已不再有效。

提取代码签名信息

接下来,我们用苹果的代码签名服务(Sec* API)程序化地提取并验证磁盘映像的代码签名信息。6 本章中 checkSignature 项目包含一个名为 checkItem 的函数,接受待验证项目路径(如磁盘映像),返回包含验证结果的字典。对于合法签名的项目,还返回代码签名权威等信息。

为了简洁,书中多数代码示例省略了基本的健壮性和错误检查。但对于代码签名这类关系到项目可信度的关键判断,代码必须妥善处理错误。没有强健的错误处理,代码可能错误地信任伪装成良性项目的恶意代码!因此本章示例均不省略这些重要的错误检查。

提取任意项目代码签名信息的第一步是获得所谓的“代码对象引用”,后续所有代码签名 API 调用均需传入该引用。对磁盘上项目(如磁盘映像),获得的是静态代码对象,类型为 SecStaticCodeRef。7 对运行中进程,则获得动态代码对象,类型为 SecCodeRef

从磁盘映像获取静态代码引用时,调用 SecStaticCodeCreateWithPath,传入映像路径、可选标志和一个输出指针。函数返回后,该指针即指向可用于后续 API 调用的 SecStaticCode 对象(见清单 3-1)。9 记得用完后用 CFRelease 释放该指针。

NSMutableDictionary* checkImage(NSString* item) {
    SecStaticCodeRef codeRef = NULL;
    NSMutableDictionary* signingInfo = [NSMutableDictionary dictionary];

  ❶ CFURLRef itemURL = (__bridge CFURLRef)([NSURL fileURLWithPath:item]);

  ❷ OSStatus status = SecStaticCodeCreateWithPath(itemURL, kSecCSDefaultFlags, &codeRef);
  ❸ if(errSecSuccess != status) {
        goto bail;
    }
    ...

bail:
    if(nil != codeRef) {
        CFRelease(codeRef);
    }
    return signingInfo;
}

清单 3-1:为磁盘映像获取静态代码对象

先初始化包含磁盘映像路径的 URL 对象 ❶,然后调用 SecStaticCodeCreateWithPath API ❷。若函数失败,返回非零值 ❸。Sec* API 成功时返回零,对应首选常量 errSecSuccess。书中第 97 页“代码签名错误代码”部分及苹果“代码签名服务结果代码”文档详细列出相关错误码。10 用完代码引用后必须调用 CFRelease 释放。

本及后续示例会用到桥接(bridging)机制,将 Objective-C 对象无开销地转换成苹果代码签名 API 所用的 Core Foundation 对象,及其逆操作。例如,清单 3-1 中,SecStaticCodeCreateWithPath 期望第一个参数为 CFURLRef,我们先将磁盘映像路径转成 NSURL 对象,再用 (__bridge CFURLRef) 桥接为 CFURLRef。关于桥接,详见苹果文档“Core Foundation Design Concepts”。

创建完静态代码对象后,我们调用 SecStaticCodeCheckValidity API,传入刚建的 SecStaticCode 对象校验签名有效性,并保存返回结果,后续返回给调用者(见清单 3-2)。

...
#define KEY_SIGNATURE_STATUS @"signatureStatus"

status = SecStaticCodeCheckValidity(codeRef, kSecCSEnforceRevocationChecks, NULL);
signingInfo[KEY_SIGNATURE_STATUS] = [NSNumber numberWithInt:status];
if(errSecSuccess != status) {
    goto bail;
}

清单 3-2:检测磁盘映像代码签名有效性

通常使用包含默认标志集的 kSecCSDefaultFlags,但若要在校验中执行证书撤销检查,需要传入 kSecCSEnforceRevocationChecks

接下来检查调用是否成功。如果校验失败,恶意代码可能借此绕过代码签名检测。12 若 API 返回如 errSecCSUnsigned,通常应终止后续代码签名信息的提取,因为未签名项目不存在或信息不可信。

确定了磁盘映像代码签名状态的有效性后,我们可以通过 SecCodeCopySigningInformation API 提取其代码签名信息。调用时传入 SecStaticCode 对象、kSecCSSigningInformation 标志,以及一个用于填充磁盘映像代码签名详情的字典指针(见清单 3-3)。

CFDictionaryRef signingDetails = NULL;

status = SecCodeCopySigningInformation(codeRef,
kSecCSSigningInformation, &signingDetails);
if(errSecSuccess != status) {
    goto bail;
}

清单 3-3:提取代码签名信息

接着可以从字典中提取存储的细节,比如证书链,使用键 kSecCodeInfoCertificates(见清单 3-4)。

#define KEY_SIGNING_AUTHORITIES @"signatureAuthorities"

signingInfo[KEY_SIGNING_AUTHORITIES] = ((__bridge NSDictionary*)signingDetails)
[(__bridge NSString*)kSecCodeInfoCertificates];

清单 3-4:提取证书链

如果项目是临时签名(ad hoc),则在代码签名字典的 kSecCodeInfoCertificates 下不会有条目。另一种判断临时签名的方法是检查 kSecCodeInfoFlags 键,它包含项目的代码签名标志。对于临时签名,标志的次低有效位(值为2)会被设置。参考苹果的 cs_blobs.h 头文件,该位对应常量 CS_ADHOC

磁盘映像中临时签名较少见,因为它们本来就不要求签名,但应用和二进制必须签名才能运行,因此你会经常见到恶意软件采用这种签名方式。我们可以如清单 3-5 这样提取代码签名标志:

#define KEY_SIGNING_FLAGS @"flags"

signingInfo[KEY_SIGNING_FLAGS] = [(__bridge NSDictionary*)signingDetails
objectForKey:(__bridge NSString*)kSecCodeInfoFlags];

清单 3-5:提取代码签名标志

然后可以检查提取的标志是否含有临时签名标志(见清单 3-6)。

if([results[KEY_SIGNING_FLAGS] intValue] & CS_ADHOC) {
    // 仅当项目为临时签名时执行此处代码。
}

清单 3-6:验证代码签名标志

字典中这些标志存储为数字对象,我们需先转为整数,再通过按位与操作(&)检测 CS_ADHOC 指定的位。

用完 CFDictionaryRef 字典后,需调用 CFRelease 释放。

提取公证信息

要提取磁盘映像的公证状态,可以使用 SecRequirementCreateWithString API,创建一个项目必须满足的要求。在清单 3-7 中,我们用字符串 "notarized" 创建了该要求。

static SecRequirementRef requirement = NULL;
SecRequirementCreateWithString(CFSTR("notarized"), kSecCSDefaultFlags, &requirement);

清单 3-7:初始化要求引用字符串

该 API 会编译传入的代码要求字符串,生成一个对象,允许多次复用。13 若只需一次性检查,可跳过编译,直接用 SecTaskValidateForRequirement API,其第二参数接受字符串形式的要求。

接下来调用 SecStaticCodeCheckValidity API,传入 SecStaticCode 对象及要求引用(见清单 3-8)。

if(errSecSuccess == SecStaticCodeCheckValidity(codeRef, kSecCSDefaultFlags, requirement)) {
    // 仅当项目已公证时执行此处代码。
}

清单 3-8:检测公证要求

当 API 返回 errSecSuccess,说明项目符合传入的要求——即该磁盘映像确实已被公证。苹果的《代码签名需求语言》文档中有更多关于需求和实用需求字符串的信息。

如果公证验证失败,还应检查苹果是否撤销了项目的公证票据,即使该项目签名有效。这种细微情况是重大红旗。关于此,见第 93 页“磁盘应用和可执行文件”中对 3CX 供应链攻击的讨论。

尽管我曾询问过,15 苹果尚未批准任何方法可直接判断项目公证票据是否被撤销。但有两个未公开 API:SecAssessmentCreateSecAssessmentTicketLookup,可用于获取这类信息。清单 3-9 演示了如何用 SecAssessmentCreate 检查已通过其他代码签名检查的项目是否被撤销了公证票据。

❶ SecAssessmentRef secAssessment = SecAssessmentCreate(itemURL,
kSecAssessmentDefaultFlags, (__bridge CFDictionaryRef)(@{}), &error);
❷ if(NULL == secAssessment) {
    if((CSSMERR_TP_CERT_REVOKED == CFErrorGetCode(error)) ||
        (errSecCSRevokedNotarization == CFErrorGetCode(error))) {
        signingInfo[KEY_SIGNING_NOTARIZED] =
        [NSNumber numberWithInteger:errSecCSRevokedNotarization];
    }
}
❸ if(NULL != secAssessment) {
    CFRelease(secAssessment);
}

清单 3-9:检查公证票据是否已撤销

我们传入项目路径(如磁盘映像)、默认评估标志、一个空但非 NULL 的字典,以及错误指针 ❶。

如果苹果撤销了公证票据或证书,函数会设置错误码为 CSSMERR_TP_CERT_REVOKEDerrSecCSRevokedNotarization。第一个错误码稍复杂,因为它会返回证书有效但公证票据被撤销的项目,也正是我们关注的情况。

当收到 NULL 评估对象且错误码为上述之一时 ❷,说明发生了撤销。由于已验证过代码签名证书,我们可确定撤销针对的是公证票据。用完评估对象后,若非 NULL 则释放它 ❸。

运行工具

让我们编译 checkSignature 项目,并针对本节提到的磁盘映像运行它:

% ./checkSignature LuLu_2.6.0.dmg
Checking: LuLu_2.6.0.dmg
Status: signed
Is notarized: no

Signing auths: (
    "<cert(0x11100a800) s: Developer ID Application: Objective-See, LLC (VBG97UB4TA)
    i: Developer ID Certification Authority>",
    "<cert(0x111808200) s: Developer ID Certification Authority i: Apple Root CA>",
    "<cert(0x111808a00) s: Apple Root CA i: Apple Root CA>"
)

正如预期,代码报告 LuLu 的磁盘映像已签名,但未经过公证。代码还提取了其代码签名权威链,包括开发者 ID 应用和开发者 ID 证书颁发机构。(在检测恶意软件时,除非关注供应链攻击,否则你可能想忽略通过可信开发者 ID 签名的磁盘映像。)

现在,我们运行代码检测 EvilQuest 恶意软件。你会看到代码与苹果的 codesign 工具结果一致,显示该磁盘映像未签名:

% ./checkSignature "EvilQuest/Mixed In Key 8.dmg"
Checking: Mixed In Key 8.dmg
Status: unsigned

最后,运行代码检测 CreativeUpdate 恶意软件,其代码签名证书已被撤销:

% ./checkSignature "CreativeUpdate/Firefox 58.0.2.dmg"
Checking: Firefox 58.0.2.dmg
Status: revoked

既然我们能程序化地提取和验证磁盘映像的代码签名信息,接下来我们也来做同样的事,但对于安装包来说,遗憾的是需要完全不同的方法。

安装包(Packages)

你可以使用内置的 pkgutil 工具手动验证安装包(.pkg)的签名。执行时加上 --check-signature 参数,后面跟上你想验证的 .pkg 文件路径。工具会显示检查结果,状态行以 Status: 开头:

% pkgutil --check-signature GoogleChrome.pkg
Package "GoogleChrome.pkg":
   Status: signed by a developer certificate issued by Apple for distribution
   Notarization: trusted by the Apple notary service
   Signed with a trusted timestamp on: 05-15 20:46:50 +0000
   Certificate Chain:
    1. Developer ID Installer: Google LLC (EQHXZ8M8AV)
       Expires: 2027-02-01 22:12:15 +0000
       SHA256 Fingerprint:
           40 02 6A 12 12 38 F4 E0 3F 7B CE 86 FA 5A 22 2B DA 7A 3A 20 70 FF
           28 0D 86 AA 4E 02 56 C5 B2 B4
       -----------------------------------------------------------------------
    2. Developer ID Certification Authority
       Expires: 2027-02-01 22:12:15 +0000
       SHA256 Fingerprint:
           7A FC 9D 01 A6 2F 03 A2 DE 96 37 93 6D 4A FE 68 09 0D 2D E1 8D 03
           F2 9C 88 CF B0 B1 BA 63 58 7F
       -----------------------------------------------------------------------
    3. Apple Root CA
       Expires: 2035-02-09 21:40:36 +0000
       SHA256 Fingerprint:
           B0 B1 73 0E CB C7 FF 45 05 14 2C 49 F1 29 5E 6E DA 6B CA ED 7E 2C
           68 C5 BE 91 B5 A1 10 01 F0 24

结果显示,pkgutil 验证了该 Google Chrome 安装包已签名并公证。工具还显示了证书链,说明该包由属于 Google 的苹果开发者 ID 签名。

请注意,你不能用 codesign 工具检查安装包的代码签名,因为 .pkg 文件采用了 codesign 不支持的代码签名存储机制。例如,用 codesign 检查相同安装包时,会检测不到签名:

% codesign --verify -dvv GoogleChrome.pkg
GoogleChrome.pkg: code object is not signed at all

如果安装包未签名,pkgutil 会显示 Status: no signature。大多数通过安装包分发的恶意软件(包括 EvilQuest)都属于此类。这些磁盘映像包含恶意安装包,一旦挂载磁盘映像,我们可以用 pkgutil 证明该安装包未签名:

% pkgutil --check-signature "EvilQuest/Mixed In Key 8.pkg"
Package "Mixed In Key 8.pkg":
   Status: no signature

最后,如果安装包已签名但苹果撤销了代码签名证书,pkgutil 会显示 Status: revoked signature,但仍然显示证书链。我们在用于分发 KeySteal 恶意软件的安装包中见过这种情况:

% pkgutil --check-signature KeySteal/archive.pkg
Package "archive.pkg":
   Status: revoked signature
   Signed with a trusted timestamp on: 10-18 12:58:45 +0000
   Certificate Chain:
    1. Developer ID Installer: fenghua he (32W7BZNTSV)
       Expires: 2027-02-01 22:12:15 +0000
       SHA256 Fingerprint:
           EC 7C 85 1D B0 A0 8C ED 45 31 6B 8E 9D 7D 34 0F 45 B8 4E CE 9D 9C
           97 DB 2F 63 57 C2 D9 71 0C 4E
       -----------------------------------------------------------------------
    2. Developer ID Certification Authority
       Expires: 2027-02-01 22:12:15 +0000
       SHA256 Fingerprint:
           7A FC 9D 01 A6 2F 03 A2 DE 96 37 93 6D 4A FE 68 09 0D 2D E1 8D 03
           F2 9C 88 CF B0 B1 BA 63 58 7F
       -----------------------------------------------------------------------
    3. Apple Root CA
       Expires: 2035-02-09 21:40:36 +0000
       SHA256 Fingerprint:
           B0 B1 73 0E CB C7 FF 45 05 14 2C 49 F1 29 5E 6E DA 6B CA ED 7E 2C
           68 C5 BE 91 B5 A1 10 01 F0 24

苹果已撤销该签名。此外,撤销的签名标识符 fenghua he (32W7BZNTSV) 有助于你寻找该恶意软件作者签名的其他恶意软件。

逆向分析 pkgutil

你可能会好奇,如何程序化地检测安装包的签名。问题是目前没有公开 API 可以用来验证安装包!多谢,库比蒂诺。

幸运的是,对 pkgutil 二进制文件快速逆向分析显示了它如何检查安装包签名。首先我们发现 pkgutil 依赖于私有的 PackageKit 框架:

% otool -L /usr/sbin/pkgutil
/usr/sbin/pkgutil:
...
/System/Library/PrivateFrameworks/PackageKit.framework/Versions/A/PackageKit
...

该框架的名称暗示它很可能包含相关 API。该框架通常位于 /System/Library/PrivateFrameworks/ 目录下,且存放于 macOS 新版本的共享 dyld 缓存中,这个预链接共享文件包含常用库。16 它的名称和位置依 macOS 版本和系统架构不同而异,比如可能是 dyld_shared_cache_arm64e,路径可能是 /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/

我们必须先从 dyld 缓存中提取 PackageKit 框架,然后才能进行逆向分析。像 Hopper 这样的工具(见图 3-2)可以用来从缓存中提取框架。

image.png

如果你更喜欢用命令行工具提取库文件,一个不错的选择是 dyld-shared-cache-extractor。17 安装该工具后,你可以使用 dyld 缓存路径和一个输出目录(这里指定为 /tmp/libraries)来执行它:

% dyld-shared-cache-extractor /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e /tmp/libraries

工具从缓存中提取所有库后,你会在 /tmp/libraries/System/Library/PrivateFrameworks/PackageKit.framework 目录下找到 PackageKit 框架。

接下来,我们可以将该框架加载进反汇编工具,深入了解其 API 和内部结构。例如,我们发现了一个名为 PKArchive 的类,其中包含若干有用方法,如 archiveWithPath:verifyReturningError: 等:

@interface PKArchive : NSObject
    +(id)archiveWithPath:(id)arg1;
    +(id)_allArchiveClasses;
    -(BOOL)closeArchive;
    -(BOOL)fileExistsAtPath:(id)arg1;
    -(BOOL)verifyReturningError:(id*)arg1;
    ...
@end

这里不详细讲解 PackageKit 框架的完整逆向过程,但你可以在线找到相关资料。18 我的开源工具 What’s Your Sign 中的 Package.h/Package.m 文件也包含完整的包验证源码。19

访问框架函数

为了在 checkSignature 项目中使用我们发现的方法,需要一个包含 PackageKit 私有类定义的头文件,这样我们才能在代码中直接调用它们。过去,class-dump 这类工具能轻松生成此类头文件,20 但这种方式对新版 Apple Silicon 二进制兼容性不足。你可以手动用反汇编器提取类定义,或使用 otool。清单 3-10 展示了提取到的定义:

@interface PKArchive : NSObject
    +(id)archiveWithPath:(id)arg1;
    +(id)_allArchiveClasses;
    -(BOOL)closeArchive;
    -(BOOL)fileExistsAtPath:(id)arg1;
    -(BOOL)verifyReturningError:(id*)arg1;
    ...

    @property(readonly) NSString* archiveDigest;
    @property(readonly) NSString* archivePath;
    @property(readonly) NSDate* archiveSignatureDate;
    @property(readonly) NSArray* archiveSignatures;
@end

@interface PKArchiveSignature : NSObject
{
    struct __SecTrust* _verifyTrustRef;
}

    -(struct __SecTrust*)verificationTrustRef;
    -(BOOL)verifySignedDataReturningError:(id *)arg1;
    -(BOOL)verifySignedData;
    ...

    @property(readonly) NSString* algorithmType;
    @property(readonly) NSArray* certificateRefs;
@end
...

现在我们可以编写代码使用这些类,调用其方法来程序化验证所选安装包。我们写一个名为 checkPackage 的函数,其唯一参数为待验证包的路径,返回一个包含验证结果和其他代码签名信息(如代码签名权威链)的字典。函数从加载 PackageKit 框架开始(见清单 3-11):

#define PACKAGE_KIT @"/System/Library/PrivateFrameworks/PackageKit.framework"

NSMutableDictionary* checkPackage(NSString* package) {
    NSBundle* packageKit = [NSBundle bundleWithPath:PACKAGE_KIT]; ❷
    [packageKit load];

    ...
}

清单 3-11:加载 PackageKit 框架

首先定义 PackageKit 框架路径 ❶,然后用 NSBundle 类的 bundleWithPath:load 方法加载框架,以便动态调用其方法 ❷。

由于 Objective-C 具有良好的反射特性,使用私有类和调用私有方法变得简单。要访问私有类,可用 NSClassFromString 函数。例如,清单 3-12 展示如何动态获得 PKArchive 类对象:

Class PKArchiveCls = NSClassFromString(@"PKArchive");

清单 3-12:获取 PKArchive 类对象

逆向分析 pkgutil 发现它通过 PKArchive 类的 archiveWithPath: 方法以及安装包路径实例化了一个归档对象(PKXARArchive)。清单 3-13 展示了我们代码中对应的实现:

PKXARArchive* archive = [PKArchiveCls archiveWithPath:package];

清单 3-13:实例化归档对象

使用像 PKArchive 这类私有类时,建议先调用 respondsToSelector: 方法确认对象是否响应特定方法。该方法返回布尔值,告诉你是否可以安全调用对应方法。21 如果跳过此步,且对象不响应方法,将导致程序因“未识别的选择器”异常崩溃。

以下代码检查 PKArchive 类是否实现了 archiveWithPath: 方法(清单 3-14)。

if (YES != [PKArchiveCls respondsToSelector:@selector(archiveWithPath:)]) {
    goto bail;
}

清单 3-14:检测方法是否存在

现在我们准备执行一些基本的包验证操作。

验证安装包

我们模拟 pkgutil 的行为,使用 PKXARArchive 类的 verifyReturningError: 方法进行验证(清单 3-15)。

NSError* error = nil;
if (YES != [archive verifyReturningError:&error]) {
    goto bail;
}

清单 3-15:执行基础的包验证

包通过基本验证后,我们检查其签名,签名存储在归档对象的 archiveSignatures 实例变量中。该变量是一个包含指向 PKArchiveSignature 对象指针的数组。已签名的包至少包含一个签名(清单 3-16)。

NSArray* signatures = archive.archiveSignatures;
if (0 == signatures.count) {
    goto bail;
}

PKArchiveSignature* signature = signatures.firstObject;
if (YES != [signature verifySignedDataReturningError:&error]) {
    goto bail;
}

清单 3-16:验证包的叶子签名

确认包至少有一个签名后,我们使用 PKArchiveSignature 类的 verifySignedDataReturningError: 方法验证第一个(叶子)签名。同时评估该签名的可信度(清单 3-17)。

Class PKTrustCls = NSClassFromString(@"PKTrust");

struct __SecTrust* trustRef = [signature verificationTrustRef];

PKTrust* pkTrust = [[PKTrustCls alloc] initWithSecTrust:trustRef
                                           usingAppleRoot:YES
                                        signatureDate:archive.archiveSignatureDate];

if (YES != [pkTrust evaluateTrustReturningError:&error]) {
    goto bail;
}

清单 3-17:评估签名的信任度

我们用签名实例化一个 PKTrust 对象,然后调用其 evaluateTrustReturningError: 方法。如果 verificationTrustRef 返回 nil,则可以用 PKTrustinitWithCertificates:usingAppleRoot:signatureDate: 方法通过证书验证包的有效性。详情请参考本章的 checkSignature 项目代码。如果签名和签名信任验证通过,即说明包签名有效。

你也可以提取签名证书,方便检查每个签名权威的名称。通过 PKArchiveSignature 对象的 certificateRefs 实例变量(一个 SecCertificateRef 数组),结合 SecCertificate* API 提取证书信息。

检查包的公证状态

本节最后,我们展示如何判断 Apple 是否对包进行了公证。记住,pkgutil 利用私有的 PackageKit 框架来验证安装包,但逆向发现,包的公证检查并不在此框架实现,而是直接在 pkgutil 二进制中。

检查包公证状态时,pkgutil 会调用未公开的 SecAssessmentTicketLookup API。该 API 声明可以在 Apple 的 SecAssessment.h 头文件找到。清单 3-18 模拟了 pkgutil 的做法:给定一个已验证的 PKArchiveSignature 对象,判断包是否已被公证。

#import <CommonCrypto/CommonDigest.h>

typedef uint64_t SecAssessmentTicketFlags;
enum {
    kSecAssessmentTicketFlagDefault = 0,
    kSecAssessmentTicketFlagForceOnlineCheck = 1 << 0,
    kSecAssessmentTicketFlagLegacyListCheck = 1 << 1,
};

Boolean SecAssessmentTicketLookup(CFDataRef hash, SecCSDigestAlgorithm hashType, SecAssessmentTicketFlags flags, double* date, CFErrorRef* errors);

BOOL isPackageNotarized(PKArchiveSignature* signature) {
    CFErrorRef error = NULL;
    BOOL isItemNotarized = NO;
    double notarizationDate = 0;

    SecCSDigestAlgorithm hashType = kSecCodeSignatureHashSHA1;

    NSData* hash = [signature signedDataReturningAlgorithm:0x0];
    if (CC_SHA1_DIGEST_LENGTH == hash.length) {
        hashType = kSecCodeSignatureHashSHA1;
    } else if (CC_SHA256_DIGEST_LENGTH == hash.length) {
        hashType = kSecCodeSignatureHashSHA256;
    }

    if (YES == SecAssessmentTicketLookup((__bridge CFDataRef)(hash), hashType,
        kSecAssessmentTicketFlagDefault, &notarizationDate, &error)) {
        isItemNotarized = YES;
    } else if (YES == SecAssessmentTicketLookup((__bridge CFDataRef)(hash),
        hashType, kSecAssessmentTicketFlagForceOnlineCheck, &notarizationDate,
        &error)) {
        isItemNotarized = YES;
    }

    return isItemNotarized;
}

清单 3-18:包公证状态检查

我们声明了多种变量,其中大部分用于调用 SecAssessmentTicketLookup。随后调用 signaturesignedDataReturningAlgorithm: 方法获得包含散列值的数据对象 ❶。

接着首次调用 SecAssessmentTicketLookup,传入散列、散列算法(SHA-1 或 SHA-256)、评估标志、一个输出日期指针(如果包已公证则返回公证日期),以及可选错误指针 ❷。

模拟 pkgutil 二进制文件的行为,我们首先调用该 API,评估标志设为 kSecAssessmentTicketFlagDefault。如果这次调用无法确定包是否已公证,我们会再次调用该 API,这次将标志设置为 kSecAssessmentTicketFlagForceOnlineCheck ❸。你可以在 SecAssessment.h 头文件中找到这些及其他标志值。

如果任一调用返回非零值,说明该包已被公证,且受苹果公证服务信任。但因为我们是模仿 pkgutil,所以代码不会指出未公证的包是否曾被撤销公证票据。给定项目的代码签名哈希和哈希类型,我们可以按清单 3-19 所示的方式实现此检查。

CFErrorRef error = NULL;

if (YES != SecAssessmentTicketLookup(hash, hashType,
    kSecAssessmentTicketFlagForceOnlineCheck, NULL, &error)) {
    if (EACCES == CFErrorGetCode(error)) {
        // 如果项目的公证票据已被撤销,这段代码将会执行。
    }
}

清单 3-19:检查已撤销的公证票据

如果项目的公证票据被撤销,SecAssessmentTicketLookup API 会将其错误变量设置为 EACCES

运行工具

现在,让我们用 checkSignature 工具检测本章前面提到的安装包:

% ./checkSignature GoogleChrome.pkg
Checking: GoogleChrome.pkg

Status: signed
Notarized: yes
Signing authorities (
    "<cert(0x11ee0ac30) s: Developer ID Installer: Google LLC (EQHXZ8M8AV)
    i: Developer ID Certification Authority>",
    "<cert(0x11ee08360) s: Developer ID Certification Authority i: Apple Root CA>",
    "<cert(0x11ee07820) s: Apple Root CA i: Apple Root CA>"
)

% ./checkSignature "EvilQuest/Mixed In Key 8.pkg"
Checking: Mixed In Key 8.pkg

Status: unsigned

% ./checkSignature KeySteal/archive.pkg
Checking: archive.pkg

Status: certificate revoked

Signing authorities: (
    "<cert(0x151406100) s: Developer ID Installer: fenghua he (32W7BZNTSV)
    i: Developer ID Certification Authority>",
    "<cert(0x151406380) s: Developer ID Certification Authority i: Apple Root CA>",
    "<cert(0x1514082b0) s: Apple Root CA i: Apple Root CA>"
)

输出结果与 Apple 的 pkgutil 一致。我们的代码准确地识别了第一个包为有效签名且已公证,第二个包含 EvilQuest 恶意软件的包为未签名,第三个包含 KeySteal 恶意软件的包为证书已被撤销。

磁盘上的应用程序与可执行文件

大多数 macOS 恶意软件以应用程序或独立 Mach-O 二进制文件的形式分发。我们可以用与磁盘镜像相同的方法从磁盘上的应用程序包或可执行二进制文件中提取代码签名信息:手动使用 codesign 工具,或者通过 Apple 的代码签名服务(Code Signing Services)API 进行编程提取。不过,这种情况有一些重要的区别。

第一个区别涉及 SecStaticCodeCheckValidity API,用于验证项目的签名。当项目不是磁盘镜像时,调用此函数时必须带上 kSecCSCheckAllArchitectures 标志(见清单 3-20)。

SecCSFlags flags = kSecCSEnforceRevocationChecks;
if (NSOrderedSame != [item.pathExtension caseInsensitiveCompare:@"dmg"]) {
    flags |= kSecCSCheckAllArchitectures;
}
status = SecStaticCodeCheckValidity(staticCode, flags, NULL);
...

清单 3-20:检查项目的签名

这个标志用于处理多架构项目,比如包含多个嵌入式 Mach-O 二进制的通用二进制文件,它们可能拥有不同的代码签名者。实际案例中,攻击者利用通用二进制文件绕过不足的代码签名检查,详见 CVE-2021-30773。该标志还强制执行撤销检查,因为它包含了 kSecCSEnforceRevocationChecks 的值。

本章前面部分我展示了如何检查指定项目是否满足某些要求,比如公证。你还可以检查其他要求,例如项目是否由苹果官方签名(anchor apple 要求),或者同时由苹果和第三方开发者 ID 签名(anchor apple generic 要求)。在这些情况下,你的代码可以调用 SecRequirementCreateWithString 函数,传入要检查的要求字符串,然后将该要求传给 SecStaticCodeCheckValidity API。为了兼容通用二进制文件,调用时应包含 kSecCSCheckAllArchitectures 标志。

此外,应调用 SecAssessmentCreate API,处理那些签名有效但公证票据已被撤销的项目。实际案例中,参考前文提到的 3CX 供应链攻击事件。攻击者侵入了 3CX 公司网络和构建服务器,向 3CX 应用植入恶意软件,使用 3CX 代码签名证书签名,然后欺骗苹果进行公证。苹果为避免撤销 3CX 代码签名证书(这会阻止许多合法 3CX 应用),仅撤销了被篡改应用的公证票据。

让我们运行 checkSignature 项目,检测合法应用及恶意软件样本,包括 3CX 示例:

% ./checkSignature /Applications/LuLu.app
Checking: LuLu.app

Status: signed
Notarized: yes
Signing authorities: (
    "<cert(0x13b814800) s: Developer ID Application: Objective-See, LLC (VBG97UB4TA)
    i: Developer ID Certification Authority>",
    "<cert(0x13b81c800) s: Developer ID Certification Authority i: Apple Root CA>",
    "<cert(0x13b81d000) s: Apple Root CA i: Apple Root CA>"
)

% ./checkSignature WindTail/Final_Presentation.app
Checking: Final_Presentation.app

Status: certificate revoked

% ./checkSignature "SmoothOperator/3CX Desktop App.app"
Checking: 3CX Desktop App.app

Status: signed
Notarized: revoked

% ./checkSignature MacMa/client
Checking: client

Status: unsigned

我们首先检测了 Objective-See 签名且已公证的 LuLu 应用,随后检测了 WindTail 恶意软件样本,它的证书已被撤销。接着测试了被木马化的 3CX 应用实例,代码正确检测出其公证状态被撤销。最后演示了 MacMa 恶意软件是未签名的。

运行中的进程

到目前为止,我们通过获取静态代码对象引用检查了磁盘上的项目。在本节中,我们将使用动态代码对象引用(SecCodeRef)来检查运行中进程的代码签名信息。

在适用情况下,应使用动态代码对象引用,原因有二。第一是效率问题:操作系统通常已经对感兴趣的项目的动态实例进行了代码签名信息的验证,以确保其符合运行时要求。对于我们来说,这意味着可以避免与静态代码检查相关的高开销文件 I/O 操作,并跳过某些计算。

第二个原因是动态代码引用优于静态代码引用,是因为磁盘上项目与内存中项目之间可能存在差异。例如,恶意软件可能会将其磁盘上项目的代码签名信息篡改为良性值(当然,这种异常行为本身就该成为一个严重的红旗)。而运行中的项目无法更改其动态代码签名信息。

要检查一个运行中的进程是否已签名并提取其代码签名信息,首先必须通过 SecCodeCopyGuestWithAttributes API 获取代码引用。该函数可以使用进程 ID,或者更安全地使用进程审计令牌(audit token)(见清单 3-21)。

SecCodeRef dynamicCode = NULL;

NSData* data = [NSData dataWithBytes:token length:sizeof(audit_token_t)]; ❶
NSDictionary* attributes = @{(__bridge NSString*)kSecGuestAttributeAudit:data}; ❷

status = SecCodeCopyGuestWithAttributes(NULL,
    (__bridge CFDictionaryRef _Nullable)(attributes), kSecCSDefaultFlags, &dynamicCode); ❸
if(errSecSuccess != status) {
    goto bail;
}

清单 3-21:通过进程审计令牌获取代码对象引用

我们首先将审计令牌转换为数据对象 ❶。需要此转换是为了将审计令牌放入字典,字典的键为 kSecGuestAttributeAudit 字符串 ❷。然后将该字典传入 SecCodeCopyGuestWithAttributes API,同时传入一个用于输出代码对象引用的指针 ❸。

拿到代码对象引用后,可以使用 SecCodeCheckValiditySecCodeCheckValidityWithErrors 来验证进程的代码签名信息。回想一下,对于磁盘上的项目(如通用二进制文件),我们使用 kSecCSCheckAllArchitectures 标志来验证所有嵌入的 Mach-O;但对于运行中的进程,动态加载器只会加载并执行其中一个嵌入的 Mach-O,因此该标志无关紧要,也不需要使用。

验证进程的代码签名信息在提取或使用之前至关重要。如果不做验证或验证失败,相关信息不能被信任。如果验证成功,则可以使用之前讲过的 SecCodeCopySigningInformation 函数来提取信息。

有了进程的代码引用,还能以简单且安全的方式完成其他常见任务。例如,使用 SecCodeCopyPath API,可以获取进程路径(见清单 3-22):

CFURLRef path = NULL;
SecCodeCopyPath(dynamicCode, kSecCSDefaultFlags, &path);

清单 3-22:从动态代码对象引用获取进程路径

你也可以像处理静态代码对象引用时那样,使用要求(requirements)进行特定验证。针对动态代码对象引用,方法基本相同,只是调用 SecCodeCheckValidity API 来进行验证。完成对动态代码引用的使用后,应通过 CFRelease 释放它。

由于 macOS 不允许在证书或公证票据被撤销时执行进程,因此你不需要为运行中的进程自行执行撤销检查。

误报检测

在本章开头,我提到过许多杀毒引擎错误地将苹果的 MRT 组件标记为恶意软件。如果这些引擎能够利用该项的代码签名信息,就会识别出 MRT 及其组件是 macOS 的内置部分,且仅由苹果官方签名,因而安全地忽略它们。

我将展示如何使用本章介绍的 API 来执行这样的检查。具体来说,你会使用“anchor apple”需求字符串(requirement string),只有当且仅当该项目仅由苹果签名时,该字符串才会在加密上返回真。

假设我们已经获得了一个被误判为恶意软件的二进制文件的静态代码引用。在清单 3-23 中,我们首先编译需求字符串,然后将其与代码引用一同传给 SecStaticCodeCheckValidity API。

static SecRequirementRef requirement = NULL;
SecRequirementCreateWithString(CFSTR("anchor apple"), kSecCSDefaultFlags, &requirement);

if(errSecSuccess ==
   SecStaticCodeCheckValidity(staticCodeRef, kSecCSCheckAllArchitectures, requirement)) {
    // 只有该项仅由苹果签名时,才会执行这里的代码。
}

清单 3-23:根据“anchor apple”需求检查项目有效性

如果 SecStaticCodeCheckValidity 返回 errSecSuccess,则表明该项目仅由苹果官方签名,意味着它属于 macOS,自然不是恶意软件。

代码签名错误代码

正如本章多次提到的,验证项目的加密签名时,正确处理遇到的错误至关重要。你可以在苹果的开发者文档“Code Signing Services Result Codes”中找到代码签名服务 API 的错误代码说明,或者在 Security.framework/Versions/A/Headers/CSCommon.h 头文件中查看。这些资源表明,例如错误码 -66992 对应 errSecCSRevokedNotarization,表示该签名已被撤销。

如果不习惯查阅头文件,可以访问 OSStatus 网站,该网站提供将任何苹果 API 错误码映射为可读名称的简便方法。

结论

代码签名使我们能够判断一个项目的来源以及该项目是否被篡改。在本章中,你深入了解了代码签名相关的 API,这些 API 能够验证、提取并校验诸如磁盘镜像、安装包、磁盘上的二进制文件以及运行中的进程的代码签名信息。

理解这些 API 对于恶意软件检测至关重要,尤其是在启发式检测方法容易产生误报的情况下。代码签名提供的信息可以大幅减少检测错误。在构建反恶意软件工具时,你可以通过多种方式利用代码签名,包括识别可信任的核心操作系统组件,检测证书或公证票据已被吊销的项目,以及对客户端进行认证,例如尝试连接 XPC 接口的工具模块(该主题将在第11章讨论)。

参考文献

  1. Rich Trouton,《Apple Security Update Blocks Apple Ethernet Drivers on OS X El Capitan》,Der Flounder,2016年2月28日,derflounder.wordpress.com/2016/02/28/…
  2. 《Notarizing macOS Software Before Distribution》,Apple 开发者文档,developer.apple.com/documentati…
  3. Patrick Wardle,《Apple Approved Malware》,Objective-See,2020年8月30日,objective-see.com/blog/blog_0…
  4. Jeff Johnson,《Developer ID Certificate Revocation》,Lapcat Software,2020年10月29日,lapcatsoftware.com/articles/re…
  5. Jonathan Levin,《Code Signing—Hashed Out》,NewOSXBook,2015年4月20日,www.newosxbook.com/articles/Co… Code Signing in Depth》,Apple 开发者文档,developer.apple.com/library/arc…
  6. 《Code Signing Services》,Apple 开发者文档,developer.apple.com/documentati…
  7. 《SecStaticCodeRef》,Apple 开发者文档,developer.apple.com/documentati…
  8. 《SecCodeRef》,Apple 开发者文档,developer.apple.com/documentati…
  9. 《SecStaticCodeCreateWithPath》,Apple 开发者文档,developer.apple.com/documentati…
  10. 《Code Signing Services Result Codes》,Apple 开发者文档,developer.apple.com/documentati…
  11. 《Core Foundation Design Concepts》,Apple 开发者文档,developer.apple.com/library/arc…
  12. Ilias Morad,《CVE-2020–9854: ‘Unauthd’》,Objective-See,2020年8月1日,objective-see.org/blog/blog_0…
  13. 《SecRequirementCreateWithString》,Apple 开发者文档,developer.apple.com/documentati…
  14. 《Code Signing Requirement Language》,Apple 开发者文档,developer.apple.com/library/arc…
  15. Asfdadsfasdfasdfsasdafads,《Programmatically Detected If a Notarization Ticket Has Been Revoked》,Apple 开发者论坛,2023年6月,developer.apple.com/forums/thre…
  16. 《dyld Shared Cache Info》,Apple 开发者文档,developer.apple.com/forums/thre…
  17. github.com/keith/dyld-…
  18. Patrick Wardle,《Reversing ‘pkgutil’ to Verify PKGs》,Jamf,2019年1月22日,www.jamf.com/blog/revers…
  19. github.com/objective-s…
  20. Steve Nygard,《Class-dump》,stevenygard.com/projects/cl…
  21. 《respondsToSelector:》,Apple 开发者文档,developer.apple.com/documentati…
  22. 《Notarization》,Apple 开发者文档,opensource.apple.com/source/Secu…
  23. Linus Henze,《Fugu15: The Journey to Jailbreaking iOS 15.4.1》,Objective by the Sea v5,西班牙,2022年10月6日,objectivebythesea.org/v5/talks/OB…
  24. 《Code Signing Services Result Codes》,Apple 开发者文档,developer.apple.com/documentati…