獲取 macOS 網易雲音樂的正在播放 —— 方案改進與 Mach 內核的探索之旅 | Makito Log

獲取 macOS 網易雲音樂的正在播放 —— 方案改進與 Mach 內核的探索之旅

在前文中我們結合 LLDB 驗證了在 macOS 版網易雲音樂的 - (void) refreshDockMenuTitlesForPlayLoadModel 函數中獲取當前播放歌曲信息的可行性。同時,也發現瞭如果直接用 LLDB 解決獲得正在播放歌曲信息的話,即要嚴重依賴 Debugger,又要接受 LLDB 常駐運行時內存佔用較大的問題。

LLDB 真的那麼神奇?#

如果我們跳出盒子重新思考這個方案的話,就會發現之前使用 LLDB 的時候無非是使用了以下的幾個功能:

  • Attach 與 Detach 目標進程
  • 分析進程的虛擬內存佈局與 Mapping
  • 設置斷點
  • 捕捉觸發斷點的事件
  • 恢復斷點處理後的程序執行流程
  • 解析指針所指向的內存中存儲的 Objective-C 對象(如 NSString)

也就是說,如果我們在自己的程序中實現了以上這些功能,也就可以脫離 LLDB 來實現獲得當前播放信息的需求。

等等…… 這有點不一樣#

去年年初的時候,我曾經沉迷於研究如何注入 Android 系統的 zygote 進程來達到間接注入其他目標的目的,這裏其他目標既可以是其他系統級服務,也可以是用戶級的應用程序。但與 Android 最大的不同之處在於 macOS 使用的是 XNU 內核,在之前注入方案中常用的 ptrace 在 macOS 下也遭到了功能縮減,PT_ATTACH 被標記爲 Deprecated,取而代之的是 PT_ATTACHEXC,在取得寄存器值的時候可能也存在一些問題。這種情況下,macOS 上的 ptrace 更多的時候是被結合着 PT_DENY_ATTACH 來做簡單的反調試功能了。

鑑於 XNU 是混合着 Mach 與 BSD 的內核,Mach 內核負責虛擬內存的分配和管理,同時也在 IPC 的消息傳遞機制的基礎之上提供了一套用於捕獲與處理異常的方案。

也就是說,我們可以換一種方式來實現 ptrace 所缺失的功能。 CLion 啓動

以下內容僅對應 x86_64 環境,其他架構可能會有不同。

在 Mach 內核中,進程也被稱作 Task,而一個 Task 又包含許多個 Thread,因此需要先取得 PID 對應的 Mach Port。

作爲平臺上所有 IPC 的強大的基礎的同時也缺少詳細的使用文檔,Mach Ports 讓我心情複雜。

task_for_pid(mach_task_self(), target_pid, &target_task_port);

取得進程對應的 Mach Port 之後,我們還需要爲目標進程設置用來接收捕獲 Mach 異常的 Mach Port,這一步驟十分類似 ptrace 設置斷點後會進行攔截信號的設定。

獲得目標 Task 當前的異常 Mach Ports:

task_get_exception_ports(target_task_port,
                         mask,
                         saved_masks,
                         &saved_exception_types_count,
                         saved_ports,
                         saved_behaviors,
                         saved_flavors);

申請新的 Mach Port,設置 Mach Port 權限,並設置爲接收 Mach 異常消息的 Mach Port:

mach_port_allocate(mach_task_self(),
                   MACH_PORT_RIGHT_RECEIVE,
                   &target_exception_port);
mach_port_insert_right(mach_task_self(),
                       target_exception_port,
                       target_exception_port,
                       MACH_MSG_TYPE_MAKE_SEND);
task_set_exception_ports(target_task_port,
                         mask,
                         target_exception_port,
                         EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES,
                         MACHINE_THREAD_STATE);

之後使用 ptrace 執行 PT_ATTACHEXC,儘管 ptrace 在這個環境下被限制了很多功能,但我們仍然需要藉助它來順利捕獲到來自被 Attach 的進程拋出的異常,因爲 PT_ATTACHEXC 並不會使進程暫停,因此我們還需要讓進程暫停下來。

kern_return_t kr;
if (ptrace(PT_ATTACHEXC, target_pid, 0, 0) < 0) {
    perror("ptrace failed");
    exit(EXIT_FAILURE);
}
if ((kr = task_suspend(target_task_port)) != KERN_SUCCESS) {
    printf("task_suspend failed: %s %x", mach_error_string(kr), kr);
    exit(EXIT_FAILURE);
}

好了,至此如果沒有錯誤的話,目標進程應該是已經被暫停且已經被設置了 Mach 異常端口。

接下來就要考慮設置斷點的問題了,讓我們回憶一下前文說的斷點位置……。等等,在這之前,我們還需要先取得程序被載入到虛擬內存中的基址。我們之前使用了 LLDB 的 image list 命令很快地找到了基址,可是這次並沒有 LLDB,該怎麼辦呢?

LLDB 可以實現的,我們也可以實現。由於 Mach 內核負責虛擬內存的分配與管理,因此我們也可以通過調用 vm_region_recurse_64 的方法來取得基址,方法如下。

vm_address_t get_base_for_task(mach_port_name_t task) {
    kern_return_t kr;
    vm_address_t address = 0;
    vm_size_t size = 0;
    uint32_t depth = 1;
    while (1) {
        struct vm_region_submap_info_64 info;
        mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
        kr = vm_region_recurse_64(task, &address, &size, &depth,
                                  (vm_region_info_64_t) &info, &count);
        if (kr == KERN_INVALID_ADDRESS) {
            break;
        }
        if (info.is_submap) {
            depth++;
        } else {
            return address;
        }
    }
    return 0;
}

此外,Mach 還提供了一些對內存進行操作的函數:

kern_return_t mach_vm_protect(vm_map_t target_task,
                              mach_vm_address_t address,
                              mach_vm_size_t size,
                              boolean_t set_maximum,
                              vm_prot_t new_protection);

kern_return_t mach_vm_read(vm_map_t target_task,
                           mach_vm_address_t address,
                           mach_vm_size_t size,
                           vm_offset_t *data,
                           mach_msg_type_number_t *dataCnt);

kern_return_t mach_vm_write(vm_map_t target_task,
                            mach_vm_address_t address,
                            vm_offset_t data,
                            mach_msg_type_number_t dataCnt);

上面的這三個函數可以用來實現類似 ptrace 中的 PEEK 與 POKE 操作及 mprotect 設置內存區域訪問權限的功能。而利用讀寫內存的函數,我們可以在想要設置斷點的地方修改內存值,當然,修改前也要記得備份一下原來的內存值。

至於斷點位置與斷點觸發後的處理流程,和前文 LLDB 思路所述的並沒有太大差異,我也畫了一份簡略的流程圖。

飛鴿傳 NSString 與都市傳說#

\wide-xl
\wide-xl

上面斷點相關的流程實現起來並不困難,不過這裏還有一個問題:在前文中,我們藉助 LLDB 的 Print Object (也就是 po)命令讀取出了每個斷點處上一個函數調用返回的 NSString* 指針所指向的 NSString 內容,由於調用返回的 NSString* 並不同於 char* 或是 char[],在沒有 LLDB 的情況下,想要讀取出來字符串的內容便需要動動腦筋了。由於 NSString* 指針指向的內存區域在網易雲音樂的進程中,並不屬於我們所在的進程分配到的內存區域,因此直接將內存地址 cast 成 NSString* 也是不科學的。

既然我們不能直接 cast 成對應類型的指針,那麼我們不如把指針指向的內存開始的一段區域直接複製到我們自己的內存區域下,再嘗試 cast。這就好比你在朋友的桌上發現了一張上面寫滿 Caesar 變換後字符的紙條,但你又不能長時間在桌旁嘗試解密,於是你抄寫了一份,再帶回家慢慢解密一樣。

下面的函數簡單的實現了我們先複製再回家慢慢研究的思路:

void copy_NSString_to_c_str(void *nss_ptr, char *buffer, unsigned long buffer_size){
    void *ns_str_mem = calloc(buffer_size, 1);
    memcpy(ns_str_mem, nss_ptr, buffer_size);
    NSString *str = (__bridge NSString *) (ns_str_mem);
    const char *c_str = [str UTF8String];
    memcpy(buffer, c_str, MIN(buffer_size, strlen(c_str) + 1));
    buffer[buffer_size - 1] = 0;
    free(ns_str_mem);
}

當你覺得一勞永逸的時候,又出現了奇怪的問題。對於網易雲音樂中的歌曲信息來說,buffer size 設置爲 2048 是足夠的,但出現問題的並不是長字符串,而是那些長度不滿 11 字符的字符串們。

在實際使用中利用上述函數複製 NSString 時,在傳入 void *nss_ptr 之前程序會先嚐試使用 mach_vm_read 將目標地址中一定長度的內存內容複製到調用者所在內存空間上動態分配的內存上,這時經常會遇到 invalid address 的錯誤。

當我檢查日誌的時候,發現 mach_vm_read 曾經嘗試從地址 0x9c82a580114a85 開始複製內存。

也就是說,取得歌曲信息 NSString* 的函數所返回的指針,指向着 0x9c82a580114a85 這個地址。

64-bit mode. While this mode produces 64-bit linear addresses, the processor ensures that bits 63:47 of such an address are identical.

— Intel® 64 and IA-32 Architectures Software Developer’s Manual: Volume 3

在 9102 年的 64-bit 下,如果一個指針指向的地址高 16 位也被用到了,並且還不全是 0 或 1,那就只能說明這個「指針」不是個指針,而應該是個值。

或者說,這個「指針」是想向我傳達什麼不可告人的祕密嗎,這會不會是一個都市傳說呢……。

NSTaggedPointerString#

最開始我試着去找介紹 NSString 在內存中的存儲方式的文章,無意中發現了 NSTaggedPointerString 的存在,之後看到了一篇很棒的講解 NSTaggedPointerString 的文章,之後在評論中有人提到 LLDB 也有用來解析 NSTaggedPointerString 的函數。

簡單地說,NSTaggedPointerString 是爲了省去爲短字符串分配內存空間的過程,也同時爲了避免字符串長度甚至比指針都短的尷尬情況出現的一種巧妙的優化方案,通過編碼表將字符轉換爲 5-bit 或 6-bit 長的單元並存儲在 64-bit 長的數值中,也即「指針」本身便是字符串。 總結

這個方案的思路並不複雜,只是實現過程中稍微麻煩一些,還經常會遇到各種各樣奇奇怪怪的問題。由於我不常寫 macOS 應用,上次寫還是給 iStat Menus 6 做破解補丁的時候,因此這次在探索 XNU 內核的時候也學到了很多有趣的東西,雖然我還是很想吐槽 Mach 內核裏一些蹩腳的設計……。目前這個方案實現的源代碼已在 GitHub 開源,僅支持 macOS,歡迎加星星。雖然命令行的 wrapper 功能比較簡單,但你也可以玩玩編譯出來的 dylib,我在 ncmnp_commons.h 留出來了可以自己定製的接口。

順便,對比一下之前 LLDB 幾百兆字節的內存佔用量。

我能拿它做什麼?#

可以在命令行運行編譯出來的可執行文件並配合 Python 腳本輸出到文件,可以在 OBS 直播中使用。也可以像我一樣做一個同步自己在聽什麼歌曲的網頁。

Happy hacking.

未經許可 禁止轉載