作者:Yuebin Sun
原文鏈接:https://rekken.github.io/
摘要
新冠病毒疫情出不了門,在家辦公這兩周筆者研究了一下 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 中存儲一個網站的密碼。
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
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 新增網站密碼的流程就結束了。
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 接口。
#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 內存越界拷貝。
--- a/Security-58286.251.4/securityd/src/transition.cpp
+++ b/Security-58286.260.20/securityd/src/transition.cpp
+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 變量之后的內存。
--- a/Security-58286.240.4/securityd/src/agentquery.cpp
+++ b/Security-58286.251.4/securityd/src/agentquery.cpp
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 版本修復)
通過比對代碼,發現了補丁。
--- a/Security-58286.240.4/securityd/src/child.cpp
+++ b/Security-58286.251.4/securityd/src/child.cpp
@@ -57,7 +57,7 @@ ServerChild::ServerChild()
//
ServerChild::~ServerChild()
{
- mServicePort.destroy();
+ mServicePort.deallocate();
--- a/Security-58286.240.4/securityd/src/clientid.cpp
+++ b/Security-58286.251.4/securityd/src/clientid.cpp
@@ -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());
}
--- a/Security-58286.240.4/securityd/src/csproxy.cpp
+++ b/Security-58286.251.4/securityd/src/csproxy.cpp
@@ -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());
--- a/Security-58286.240.4/securityd/src/process.cpp
+++ b/Security-58286.251.4/securityd/src/process.cpp
@@ -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 消息時拒絕服務,對比代碼,得到的了疑似補丁,不敢完全確定
--- a/Security-58286.200.222/OSX/libsecurity_smime/lib/smimeutil.c
+++ b/Security-58286.220.15/OSX/libsecurity_smime/lib/smimeutil.c
@@ -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 感興趣,歡迎聯系我 @yuebinsun。
References
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1172/
暫無評論