Yuebin Sun(@yuebinsun2020 ) of Tencent Security Xuanwu Lab
摘要 新冠病毒疫情出不了门,在家办公这两周笔者研究了一下 macOS 的 Security Framework。
本文主要分析 Security Framework 尤其是其中 Keychain 的架构,将 Security Framework 近一两年的历史漏洞做个整理。
Security Framework 简介 Security Framework 主要负责为 App 提供认证与授权、安全数据存储与传输(Keychain,App Transport Security)、代码签名、加密解密功能。
第三方 App 通过引用 Security Framework,使用 Apple 提供的 API 就可以直接使用这些功能,不用关心底层实现的细节。
但 Security Framework 都有哪些组件,又是如何构建起来的呢?
官方最近已经不再更新整体的架构图了,在 [Mac OS X Internals] 书里找到了一张整体架构图,目前来看重要组件的变化不是特别大,可以用来参考
Keychain Keychain 是 Security Framework 的重要组件,系统中保存的 WiFi 密码、Safari 保存的网站密码等都由 Keychain 组件负责管理。
Keychain 最早在 Mac OS 8.6 版本被引入,用于保存邮件系统(PowerTalk)的邮件服务器的登录凭据。现在的 Keychain 组件已经扩展了很多,可用于保存密码、加密密钥、证书以及 Notes,被 Apple 自身以及众多第三方应用使用。
iOS 与 macOS 系统中的 Keychain 略微有些差异,iOS 中只有一个 Keychain,设备解锁状态时 Keychain 可以访问,设备锁定状态时 Keychain 也处于锁定状态。macOS 则不同,macOS 系统允许用户自己创建任意的 Keychain 用于私有使用,Security Framework 提供了 SecKeychain{Create, Delete, Open,…} API 用于 macOS 用户管理 Keychain。
默认状态下,macOS 系统中存在两个 Keychain:
~/Library/Keychains/login.keychain-db
/Library/Keychains/System.keychain
其中 login Keychain 在 macOS 解锁状态时就会被解密,System.keychain 密钥保存在 /var/db/SystemKey,只有 root 用户可以访问。
具体目前系统中保存的 Keychain 以及存储的信息列表可以通过 macOS 的 Keychain Access.app 应用访问并查看。
如何用 Keychain 存储一个网站密码 Apple 官网文档如下示例代码可以实现向 Kaychain 中存储一个网站的密码。
1 2 3 4 5 6 7 8 static let server = "www.example.com" let account = credentials.username let password = credentials.password.data(using: String.Encoding.utf8)! var query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, kSecAttrAccount as String: account, kSecAttrServer as String: server, kSecValueData as String: password] let status = SecItemAdd(query as CFDictionary , nil )
其中核心的就是 SecItemAdd 这个 API,接下来我们将一步步分析这个 API 是如何实现的。
抽象的看,保存在 query 变量中的数据通过 SecItemAdd API 传递给 Keychain Service,服务进一步会将 query 数据封装为 Keychain Item,对于其中的 password 则会被加密,Keychain Item 进一步会被保存到磁盘的 Keychain Database。
如果从组件的角度看,SecItemAdd API 由 Security 共享库(Security Framework 的一部分,此处为了与 Security Framework 作区分所以叫共享库,/System/Library/Frameworks/Security.framework/Versions/A/Security)实现,Security 共享库会被加载进当前 App 进程,SecItemAdd API 收到数据后,进一步通过 SECURITYD_XPC 宏,将 API 调用转发至 com.apple.securityd.xpc XPC 服务,该服务位于 secd 进程,secd 以当前用户身份运行。
进入 secd 进程之后,会根据 operation 进入到服务消息分发 handler(securityd_xpc_dictionary_handler)(代码已被精简),对于 SecItemAdd,operation 为 sec_item_add_id,保存新增数据的 query 会被直接传递给 _SecItemAdd,除了 query 还有重要的数据结构 SecurityClient 结构体,SecurityClient 用于在后续的数据处理流程中支持访问控制检查,其中的 accessGroups 用于实现在 Web(Safari)和同一个团队开发的 App 之间共享密码,核心就是 Web 与 App 通过 Associated Domains Entitlement 关联,感兴趣可以参考 Supporting Associated Domains in Your App
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 static void securityd_xpc_dictionary_handler (const xpc_connection_t connection, xpc_object_t event) { SecurityClient client = { .task = NULL , .accessGroups = NULL , .musr = NULL , .uid = xpc_connection_get_euid (connection), .allowSystemKeychain = false , .allowSyncBubbleKeychain = false , .isNetworkExtension = false , .canAccessNetworkExtensionAccessGroups = false , }; fill_security_client (&client, xpc_connection_get_euid (connection), auditToken)); switch (operation) { case sec_item_add_id: { _SecItemAdd(query, &client, &result, &error) && result); break ; } }
_SecItemAdd 内部就会将 query 数据转化为 Sqlite 的数据库增、删、改、查操作,最终实现对我们传递 query 的 item 插入操作。插入 sqlite3 的数据,password 会被加密。同时为了支持搜索,其他一些非私密数据会保持明文,这样可以支持对 keychain 数据库条目的搜索。至此 SecItemAdd API 新增网站密码的流程就结束了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 static CFStringRef SecDbItemCopyInsertSQL (SecDbItemRef item, bool (^use_attr)(const SecDbAttr *attr)) { CFMutableStringRef sql = CFStringCreateMutable (CFGetAllocator (item), 0 ); CFStringAppend (sql, CFSTR ("INSERT INTO " )); CFStringAppend (sql, item->class ->name); CFStringAppend (sql, CFSTR ("(" )); bool needComma = false ; CFIndex used_attr = 0 ; SecDbForEachAttr (item->class , attr) { if (use_attr (attr)) { ++used_attr; SecDbAppendElement (sql, attr->name, &needComma); } } CFStringAppend (sql, CFSTR (")VALUES(?" )); while (used_attr-- > 1 ) { CFStringAppend (sql, CFSTR (",?" )); } CFStringAppend (sql, CFSTR (")" )); return sql; }
Safari 保存的这部分网站密码会被保存到 login keychain 数据库中,login keychain 等用户注销或者关机等操作时会被加密锁定。
SecurityServer 与 SecurityAgent 系统的 login Keychain 在系统处于解锁状态时就会自动解锁,所以上面保存网站密码时并没有涉及 keychain 的解密或解锁过程。
然而对于 System Keychain 或者时自己创建的 Keychain,这就涉及到 Keychain 数据库的加解锁、加解密处理,此时就需要 Security Server 的参与。
Security Server(/usr/sbin/securityd) 是一个 root 身份独立运行的 daemon 服务进程,如最上面的整体架构图所示,CDSA 架构中,Security Server 为 CDSA 架构提供了 CSP/DL Plugin,即负责数据的安全加密与存储。
Security Server 通过 ucsp MIG 接口提供服务,用于 client 访问 SecurityServer 内部对象。普通用户进程就可以访问此 MIG 接口。从源码中看这个服务提供了以下功能:
管理请求 Security Server 的 clients(session、connection)
认证(Authentication)和授权(Authrization)的管理
Keychain 数据库的管理,包括锁定、解锁、数据加密、数据库的创建与修改
数据签名(Signature)的生成和验证
数据的加密和解密(ucsp_server_encrypt, ucsp_server_decrypt)
Key、key pair 的生成(ucsp_server_generateKey, ucsp_server_generateKeyPair、ucsp_server_wrapKey, ucsp_server_unwrapKey)
Code Signing Hosting(近几天公开的 10.15 版本源码中已经删除相关接口,暂未深入确认)
可以看出 root 身份运行的 Security Server(securityd) 提供了很多高权限的敏感操作,同时也管理着大量敏感数据,因此如果可以发现这个服务进程的漏洞,那么影响也将非常大,KeySteal 就是利用该服务的漏洞实现无需密码验证访问 Keychain 保存的密码。
那么如何通过 MIG 接口与他交互呢?
在 Security 的源码中就包含了这个 ucsp MIG 接口的定义文件(OSX/libsecurityd/mig/ucsp.defs)。但很可惜,介绍 MIG 使用的文档很少,直接访问 Security Server 的文档更是没有。最终,我从 Linus Henze 写的 KeySteal Exploit 代码中精简了一个访问 ucsp_server_setup 接口的 Client。
通过 mig 命令行工具生成 ucspUser.c 以及 ucspServer.c 接口定义源码,解决完编译依赖的头文件定义之后,就可以通过如下的示例测试代码访问 ucsp_server_setup 接口。
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 #define UCSP_ARGS gServerPort, gReplyPort, &securitydCreds, &rcode #define ATTRDATA(attr) (void *)(attr), (attr) ? strlen((attr)) : 0 #define CALL(func) \ security_token_t securitydCreds; \ CSSM_RETURN rcode; \ if (KERN_SUCCESS != func) \ return errSecCSInternalError; \ if (securitydCreds.val[0] != 0) \ return CSSM_ERRCODE_VERIFICATION_FAILURE; \ return rcode #define SSPROTOVERSION 20000 mach_port_t gServerPort;mach_port_t gReplyPort;CSSM_RETURN securityd_setup () { mach_port_allocate (mach_task_self (), MACH_PORT_RIGHT_RECEIVE, &gReplyPort); mach_port_insert_right (mach_task_self (), gReplyPort, gReplyPort, MACH_MSG_TYPE_MAKE_SEND); bootstrap_look_up (bootstrap_port, (char *)"com.apple.SecurityServer" , &gServerPort); ClientSetupInfo info = { 0x1234 , SSPROTOVERSION }; CALL (ucsp_client_setup (UCSP_ARGS, mach_task_self (), info, "?:unspecified" )); } int main (int argc, char *argv[]) { mach_port_t port; mach_port_t bootstrap_port; task_get_bootstrap_port (mach_task_self (), &bootstrap_port); kern_return_t kr = bootstrap_look_up (bootstrap_port, "com.apple.SecurityServer" , &port); securityd_setup (); return 0 ; }
SecurityAgent 上面的介绍中提到,Security Server 还负责认证(Authentication)和授权(Authroization)。
当 Client 请求 Security Server 发起认证(Authentication)和授权(Authroization)验证时。如果需要与用户交互(输入密码)以验证身份,Security Server 就会通过 XPC 与 Security Agent(当前用户身份运行)通信,由 Security Agent 负责弹框与用户交互。用户输入的密码凭据信息由 Security Server 接收并管理,Client 只会收到验证或授权结果的消息。这个保证整个验证过程中 Client 不会接触密码等敏感信息,同时,这种机制也可以保证如果系统增加新的身份验证或鉴权扩展时,对 client 是透明的。
10.14 版本至今的历史漏洞分析 了解完了上面的一些必要的系统架构内容外,我们来继续看看 macOS 10.14 版本至今的涉及 Security 框架的漏洞,方便读者朋友了解漏洞的原理以及漏洞所在的组件。
需要说明的是,因为 Apple 官方在每次漏洞修复后并不会提供漏洞的详细信息,所以以下这些都是我根据源码自己分析整理的,这也意味着整理的结果可能不一定正确,如果您发现有错误或疏漏,请不吝指出。
CVE-2019-8604(10.14.5 版本修复) 通过对比两个版本之间的源码,发现 CVE-2019-8604 漏洞的补丁。
这个漏洞在 securityd(Security Server Daemon) 中,securityd 提供的 MIG 接口在处理 client 端传递的 dbname 时,只有 assert 检查,而 assert 在 Release 版本是不存在的,因此,client 传递一个超长的字符串(长度超过 PATH_MAX),ucsp_server_getDbName 接口就会触发 memcpy 内存越界拷贝。
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 +static void checkPathLength(char const *str) { + if (strlen(str) >= PATH_MAX) { + secerror("SecServer: path too long"); + CssmError::throwMe(CSSMERR_CSSM_MEMORY_ERROR); + } +} + @@ -306,15 +313,16 @@ kern_return_t ucsp_server_getDbName(UCSP_ARGS, DbHandle db, char name[PATH_MAX]) { BEGIN_IPC(getDbName) string result = Server::database(db)->dbName(); - assert(result.length() < PATH_MAX); + checkPathLength(result.c_str()); memcpy(name, result.c_str(), result.length() + 1); END_IPC(DL) } kern_return_t ucsp_server_setDbName(UCSP_ARGS, DbHandle db, const char *name) { BEGIN_IPC(setDbName) + checkPathLength(name); Server::database(db)->dbName(name); END_IPC(DL) }
补丁中,在 ucsp_server_{get, set}DbName 中新增对路径名字的检查(checkPathLength),防止超长的 dbName 溢出固定长度(PATH_MAX)的 name。
因为 std::string 与 strlen 都会被且仅能被 “\0” 截断,所以 setDbName 与 getDbName 的处理方式就一致了。
CVE-2019-8520 (10.14.4 版本修复) 通过对比两个版本之间的源码,发现了 CVE-2019-8520 漏洞的补丁。
该漏洞位于 Security Server Daemon(securityd) 中,securityd(root) 负责处理系统中的管理系统中的 Authroization 和 Authentication,认证或者授权过程中,如果需要与用户交互(输入密码)以验证身份,securityd 就会通过 XPC 与 Security Agent(当前用户身份运行)通信,由 Security Agent 负责弹框与用户交互。
这个漏洞就出现在 securityd 与 Security Agent 的交互过程,securityd 在接收来自 Security Agent 的数据时,通过 XPC 传入 data,data 的长度为 length,另外通过另一个字段传入 sensitivelength,拷贝的时候,从 data 的起始位置拷贝长度为 sensitivelength 的内容到新创建的 dataCopy,因此,如果传入一个超长的 sensitivelength,超过上面传入的 data 的实际长度,将导致 data 的越界拷贝,会越界读取 data 变量之后的内存。
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 static void xpcArrayToAuthItemSet(AuthItemSet *setToBuild, xpc_object_t input) { setToBuild->clear(); xpc_array_apply(input, ^bool(size_t index, xpc_object_t item) { const char *name = xpc_dictionary_get_string(item, AUTH_XPC_ITEM_NAME); size_t length; const void *data = xpc_dictionary_get_data(item, AUTH_XPC_ITEM_VALUE, &length); void *dataCopy = 0; // <rdar://problem/13033889> authd is holding on to multiple copies of my password in the clear bool sensitive = xpc_dictionary_get_value(item, AUTH_XPC_ITEM_SENSITIVE_VALUE_LENGTH); if (sensitive) { size_t sensitiveLength = (size_t)xpc_dictionary_get_uint64(item, AUTH_XPC_ITEM_SENSITIVE_VALUE_LENGTH); + if (sensitiveLength > length) { + secnotice("SecurityAgentXPCQuery", "Sensitive data len %zu is not valid", sensitiveLength); + return true; + } dataCopy = malloc(sensitiveLength); memcpy(dataCopy, data, sensitiveLength); memset_s((void *)data, length, 0, sensitiveLength); // clear the sensitive data, memset_s is never optimized away length = sensitiveLength; } else { dataCopy = malloc(length); memcpy(dataCopy, data, length); } uint64_t flags = xpc_dictionary_get_uint64(item, AUTH_XPC_ITEM_FLAGS); AuthItemRef nextItem(name, AuthValueOverlay((uint32_t)length, dataCopy), (uint32_t)flags); setToBuild->insert(nextItem); memset(dataCopy, 0, length); // The authorization items contain things like passwords, so wiping clean is important. free(dataCopy); return true; }); }
漏洞的修复逻辑就是加了一个对 sensitiveLength 的长度检查,保证 memcpy 的长度不超过 data。
CVE-2019-8526(10.14.4 版本修复) 通过比对代码,发现了补丁。
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 @@ -57,7 +57,7 @@ ServerChild::ServerChild() // ServerChild::~ServerChild() { - mServicePort.destroy(); + mServicePort.deallocate(); @@ -45,14 +45,18 @@ ClientIdentification::ClientIdentification() // Initialize the ClientIdentification. // This creates a process-level code object for the client. // -void ClientIdentification::setup(pid_t pid) +void ClientIdentification::setup(Security::CommonCriteria::AuditToken const &audit) { StLock<Mutex> _(mLock); StLock<Mutex> __(mValidityCheckLock); - OSStatus rc = SecCodeCreateWithPID(pid, kSecCSDefaultFlags, &mClientProcess.aref()); - if (rc) - secinfo("clientid", "could not get code for process %d: OSStatus=%d", - pid, int32_t(rc)); + + audit_token_t const token = audit.auditToken(); + OSStatus rc = SecCodeCreateWithAuditToken(&token, kSecCSDefaultFlags, &mClientProcess.aref()); + + if (rc) { + secerror("could not get code for process %d: OSStatus=%d", + audit.pid(), int32_t(rc)); + } mGuests.erase(mGuests.begin(), mGuests.end()); } @@ -64,13 +64,12 @@ void CodeSigningHost::reset() case noHosting: break; // nothing to do case dynamicHosting: - mHostingPort.destroy(); - mHostingPort = MACH_PORT_NULL; + mHostingPort.deallocate(); secnotice("SecServer", "%d host unregister", mHostingPort.port()); break; case proxyHosting: Server::active().remove(*this); // unhook service handler - mHostingPort.destroy(); // destroy receive right + mHostingPort.modRefs(MACH_PORT_RIGHT_RECEIVE, -1); mHostingState = noHosting; mHostingPort = MACH_PORT_NULL; mGuests.erase(mGuests.begin(), mGuests.end()); @@ -40,7 +40,7 @@ // Construct a Process object. // Process::Process(TaskPort taskPort, const ClientSetupInfo *info, const CommonCriteria::AuditToken &audit) - : mTaskPort(taskPort), mByteFlipped(false), mPid(audit.pid()), mUid(audit.euid()), mGid(audit.egid()) + : mTaskPort(taskPort), mByteFlipped(false), mPid(audit.pid()), mUid(audit.euid()), mGid(audit.egid()), mAudit(audit) { StLock<Mutex> _(*this); @@ -48,6 +48,11 @@ Process::Process(TaskPort taskPort, const ClientSetupInfo *info, const CommonCri parent(Session::find(audit.sessionId(), true)); // let's take a look at our wannabe client... + + // Not enough to make sure we will get the right process, as + // pids get recycled. But we will later create the actual SecCode using + // the audit token, which is unique to the one instance of the process, + // so this just catches a pid mismatch early. if (mTaskPort.pid() != mPid) { secnotice("SecServer", "Task/pid setup mismatch pid=%d task=%d(%d)", mPid, mTaskPort.port(), mTaskPort.pid()); @@ -55,7 +60,14 @@ Process::Process(TaskPort taskPort, const ClientSetupInfo *info, const CommonCri } setup(info); - ClientIdentification::setup(this->pid()); + ClientIdentification::setup(this->audit_token());
这个漏洞正是之前读过 Paper 的 KeySteal 漏洞,补丁代码位于 securityd(Security Server Daemon) ,securityd 在通过 MIG 实现 Hosting Guest Code 机制时存在问题。
从补丁中可以看出漏洞存在的两个问题:
第一个是实现 Hosting Guest Code 机制,securityd 在创建 SecCode 时,错误地使用 SecCodeCreateWithPID 这个 API,这个 API 根据 pid 标识 Client Process,因此如补丁中的注释代码所说,存在 PID Reuse 的问题。
修复的方式是 SecCodeCreateWithPID 换做 SecCodeCreateWithAuditToken 用 audit token 表示 client。关于 PID 方式有何问题,可以参考之前 Samuel Groß 的 《Don’t Trust the PID!》
第二个是 Mach Port 的引用计数问题,CodeSigningHost::reset() 调用 destory() 导致强制释放 Mach Port,被 destory 的 Mach Port 可能仍然被某些数据结构引用,同时因为用户态进程的 Mach Port 本身是 mach port name,其实就是个 number,既然是 number 就存在被 reuse 的可能。所以,在下次使用之前如果可以导致重新被占用,就可以实现 UAF。补丁修复也很容易,就是 destory 改为引用计数版本的 deallocate()。
CVE-2018-4400(10.14.1 版本修复) 这个漏洞 Apple 公告中的描述是处理 S/MIME 消息时拒绝服务,对比代码,得到的了疑似补丁,不敢完全确定
1 2 3 4 5 6 7 8 9 10 11 @@ -733,6 +733,8 @@ SecSMIMEGetCertFromEncryptionKeyPreference(SecKeychainRef keychainOrArray, CSSM_ cert = CERT_FindCertByIssuerAndSN(keychainOrArray, rawCerts, NULL, tmppoolp, ekp.id.issuerAndSN); break; case NSSSMIMEEncryptionKeyPref_RKeyID: + cert = CERT_FindCertBySubjectKeyID(keychainOrArray, rawCerts, NULL, &ekp.id.recipientKeyID->subjectKeyIdentifier); + break; case NSSSMIMEEncryptionKeyPref_SubjectKeyID: cert = CERT_FindCertBySubjectKeyID(keychainOrArray, rawCerts, NULL, ekp.id.subjectKeyID); break;
对证书管理及相关的数据结构暂时还不太熟悉,暂时不进一步分析了
上面这些是目前我找到的比较确定的一些漏洞及其补丁,因为 Apple 开源代码非常滞后,所以上面这些主要是 10.14.* 版本中涉及 Security Framework 的漏洞的分析。
总结 以上就是我这段时间研究 Security Framework 并做的分享。因为 Security Framework 比较庞大,我只重点介绍了 Keychain 以及历史上被发现漏洞比较多的 Security Server 组件。其他像 Auth 组件来得及分析,等后续对这些组件有了新的研究,我将继续分享。
如果发现上面的内容有错误,或者您也对 macOS 感兴趣,欢迎联系我 @yuebinsun2020 。
References [1] Documentation of Security Framework
https://developer.apple.com/documentation/security?language=objc
[2] Apple Open Source Code
https://opensource.apple.com/
[3] KeySteal Vulnerability
https://www.pinauten.de/resources/KeySteal_OBTS_2019.pdf
[4] Keychain Wikipedia
https://en.wikipedia.org/wiki/Keychain_(software)