作者: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 就可以直接使用這些功能,不用關心底層實現的細節。

Image

但 Security Framework 都有哪些組件,又是如何構建起來的呢?

官方最近已經不再更新整體的架構圖了,在 [Mac OS X Internals] 書里找到了一張整體架構圖,目前來看重要組件的變化不是特別大,可以用來參考

Image

Keychain

Keychain 是 Security Framework 的重要組件,系統中保存的 WiFi 密碼、Safari 保存的網站密碼等都由 Keychain 組件負責管理。

Keychain 最早在 Mac OS 8.6 版本被引入,用于保存郵件系統(PowerTalk)的郵件服務器的登錄憑據。現在的 Keychain 組件已經擴展了很多,可用于保存密碼、加密密鑰、證書以及 Notes,被 Apple 自身以及眾多第三方應用使用。

Image

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。

Image

如果從組件的角度看,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 以當前用戶身份運行。

Image

Image

進入 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)。

Image

當 Client 請求 Security Server 發起認證(Authentication)和授權(Authroization)驗證時。如果需要與用戶交互(輸入密碼)以驗證身份,Security Server 就會通過 XPC 與 Security Agent(當前用戶身份運行)通信,由 Security Agent 負責彈框與用戶交互。用戶輸入的密碼憑據信息由 Security Server 接收并管理,Client 只會收到驗證或授權結果的消息。這個保證整個驗證過程中 Client 不會接觸密碼等敏感信息,同時,這種機制也可以保證如果系統增加新的身份驗證或鑒權擴展時,對 client 是透明的。

Image

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::stringstrlen都會被且僅能被 “\0”截斷,所以 setDbNamegetDbName 的處理方式就一致了。

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

  1. https://developer.apple.com/documentation/security?language=objc

  2. https://opensource.apple.com/

  3. https://www.pinauten.de/resources/KeySteal_OBTS_2019.pdf

  4. https://en.wikipedia.org/wiki/Keychain_(software)

  5. https://rekken.github.io/about


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1172/