逆向 Mac 應用 Bartender

Lision發表於2018-10-15

前言

本文內容僅作為學習交流,希望大家多多支援正版軟體。

Emmmmm... 其實最初是準備寫一篇關於 iOS 應用的逆向筆記的,不過一直沒找到合適的目標 App 以及難度適宜的功能點來作為寫作素材...

破解了 Bartender 之後我覺得對於 Bartender 的破解過程難度適中,非常適合當做素材來寫,且不論是 Mac App 還是 iOS App,逆向的思路都是相通的,所以就寫了這篇文章~

國慶之前,果果放出了最新作業系統 macOS Mojave 的正式版本,相信很多小夥伴都跟我一樣在正式版釋出後緊跟著就升級了系統(此前由於工作裝置參與專案產出需要確保系統穩定性所以沒敢嚐鮮的同學應該不只我一個人哈)。

升級到正式版 macOS Mojave 之後,我興致勃勃的在新系統中各處探索了一番,然後將系統切換到 Dark Mode 後開啟 Xcode 心滿意足地敲(搬)起了程式碼(磚)...

嘛~ 又是一個愜意的午後,有時候人就是這麼容易滿足(笑)~

等等!這是什麼鬼!?我的 Bartender 怎麼不能正常工作了(其實現在回想起來應該是試用期到期了)...

逆向 Mac 應用 Bartender

本文將以 Bartender 為目標 App,講解如何通過靜態分析工具 Hopper 逐步分析 Bartender 的內部實現邏輯並結合動態分析等手段逐步破解 Bartender 的過程與思路~

索引

  • Bartender
  • Hopper
  • 逆向過程 & 思路
  • 總結

Bartender

逆向 Mac 應用 Bartender
逆向 Mac 應用 Bartender

Bartender 是一款可以幫助我們整理螢幕頂部選單欄圖示的工具。

隨著我們安裝的 App 不斷增多,螢幕頂部選單欄上面的圖示也會對應不斷增加。這些 App 的圖示並非出自一家之手,風格各異,隨著數目增多逐漸顯得雜亂不堪。

我們可以通過 Bartender 來隱藏重新排列這些惱人的小圖示,可以將沒什麼用但是執行起來卻要顯示的 App 圖示始終隱藏,將偶爾會用的 App 圖示隱藏到 Bartender 功能按鈕後面(用到的時候可以通過點選 Bartender 功能按鈕切換顯隱),只顯示常用的或者我們認為好看的應用圖示。

除此之外 Bartender 還具備一些其他更加深入的功能(比如支援全部選單欄條目範圍的搜尋等等),毫無疑問它是一款非常棒的選單欄圖示管理工具。

逆向 Mac 應用 Bartender

Note: 重申,Bartender 僅售 15 刀,還是推薦各位使用正版,本文僅作為學習交流。

Hopper

逆向 Mac 應用 Bartender

Hopper 是一款不錯的 mac OS 與 Linux 反彙編工具,同時還提供一定的反編譯能力,可以利用它來除錯我們的程式。此外,Hopper 還支援控制流檢視模式,Python 指令碼,LLDB & GDB,並且提供了 Hopper SDK 可供擴充套件,在 Hopper SDK 的基礎上你甚至可以擴充套件自己的檔案格式和 CPU 支援。

值得一提的是 Hopper 的作者是一名獨立開發者,他的日常工作環境也是在 mac OS 上,所以在 mac OS 上的 Hopper 是完全使用 Cocoa Framework 實現的,而 Linux 版本的 Hopper 則選擇使用 Qt 5 來實現。

個人認為 Hopper 在 mac OS 上面的執行表現非常好,很多細節(比如型別顏色區分等)都做的不錯,功能簡潔的同時快捷鍵也很好記(Hopper 提供的功能已經覆蓋了絕大多數使用場景)。

最關鍵的一點是收費良心,個人證照只要 99 刀,當之無愧的人人都買得起的逆向工具!當然如果你覺得貴,Hopper 還提供試用,試用形式類似於 Charles,每次開啟後可以試用 30 分鐘,一般情況下這已經夠用了。

Note: Hopper v4.4.0 支援 Mojave Dark Mode。

逆向過程 & 思路

這一章節的內容會詳細的講述我個人在破解 Bartender 過程中的想法以及中間遇到問題時解決問題的思路,之前沒有涉足逆向或者逆向經驗尚淺的同學可能會覺得比較晦澀,這種情況最好結合自己的實際操作反覆閱讀沒有理解的地方直到真正弄明白為止。

相信自己,每一份努力終會有所回報!當有朝一日自己也可以通過自己的逆向技術破解 & 定製化自己感興趣的 App 時,你會發現一切的努力都是值得的。

獲取目標二進位制

Bartender 官網下載最新的 Bartender,截止本文提筆之前 Bartender 的最新版本為 3.0.47。

將下載好的壓縮包解壓之後得到 Bartender 3.app,將 Bartender 3.app 檔案複製到自己的 Application 資料夾下。右鍵點選 Bartender 3.app 選擇“顯示包內容”,在 Contents 目錄下找到 MacOS 目錄,裡面有我們要的目標二進位制檔案 Bartender 3。

從“授權”著手

開啟 Hopper,將目標二進位制檔案拖入 Hopper,在彈出的彈窗中選擇 OK 後等待 Hopper 分析完畢。

逆向 Mac 應用 Bartender

在左側的分欄中選擇 Proc. ,這可以讓我們檢視 Hopper 分析出來的方法。分欄下面有搜尋框,內部可以通過輸入關鍵詞來過濾出我們想要的結果。因為一般的 App 都是通過某些方法判斷是否授權的,這裡我們先輸入 is (注意 is 前面加空格),然後觀察過濾出來的結果。

逆向 Mac 應用 Bartender

果不其然,發現裡面有三個 [xxx isLicensed] 方法,點選方法 Hopper 會跳轉至方法處。

Note: 三處 [xxx isLicensed] 的方法內部邏輯幾乎一樣,這裡拿 [Bartender_3.AppDelegate isLicensed] 講解,其他兩處不做贅述。

逆向 Mac 應用 Bartender

Emmmmm... 這裡的彙編程式碼還是比較簡單的,雖然我不是很瞭解 x86 的彙編指令,不過 Hopper 已經幫助我們做了一些輔助性工作。其中開始處的 push rbp 以及結束處 pop rbp 可以簡單理解為入棧出棧,call sub_100067830 可以理解為呼叫地址 0x100067830 處的方法,pop 之前的 movsx eax, al 和 ARM64 中的 mov 指令類似,可以理解為將 al 記憶體儲的東西移動到 eax 暫存器中,eax 暫存器用於儲存 x86 的方法返回值

我們可以看出這裡呼叫了地址 0x100067830 處的函式,拿到結果之後又呼叫了 imp___stubs__$S10ObjectiveC22_convertBoolToObjCBoolyAA0eF0VSbF 方法將結果做了轉化,最後將結果賦值給 eax 暫存器用於結果返回。其中 imp___stubs__$S10ObjectiveC22_convertBoolToObjCBoolyAA0eF0VSbF 我們可以根據名稱推測出該方法的作用應該是將 Bool 轉化為 Objective-C 的 BOOL 而已。

那麼關鍵資訊應該在 sub_100067830 處,雙擊 sub_100067830 Hopper 會跳轉到 0x100067830 處,這樣我們就可以分析其中的具體實現了。不過 0x100067830 內部的實現比較複雜,跳轉過去之後發現彙編程式碼非常多,還有很多跳轉... 這時候我們可以通過 Hopper 頂部中間靠右一點的分欄,點選顯示為 if(b) f(x); 的按鈕檢視虛擬碼。

Hopper 解析出來的虛擬碼風格類似 Objective-C 程式碼,可以看到 0x100067830 內部通過 NSUserDefaults 以及其他的邏輯實現,其中還包括其他的形式為 sub_xxxxxx 的方法呼叫,這種情況下如果我們繼續跳轉到這些方法的地址檢視其內部實現很有可能陷入遞迴中...

逆向 Mac 應用 Bartender

那麼這種情況該如何處理呢?

分析問題,我們找到 [xxx isLicensed] 並且覺得這有可能就是 Bartender 中判斷授權與否的函式,那麼我們只需要將三處 [xxx isLicensed] 的返回值改為 true 即可。所以這裡我們沒有必要一步步的看其內部實現,先返回 [Bartender_3.AppDelegate isLicensed] 處。前面講過在 x86 彙編中 eax 暫存器用於儲存方法的返回值,我們在 [Bartender_3.AppDelegate isLicensed] 按快捷鍵 option + A 插入彙編程式碼 mov eax, 0x1eax 永遠賦值為 1true 之後跟 ret 即 return 指令直接讓函式返回 true 就可以達到我們的目的了。

逆向 Mac 應用 Bartender

用快捷鍵 shift + command + E 匯出二進位制檔案,覆蓋到原 Bartender 目錄中,嘗試執行。你會發現一開始是成功的,螢幕頂部的選單欄圖示也被正常管理了,但是過了大約 10s 之後一切又變回了原樣,並且還會彈出一個試用期到期的彈窗...

逆向 Mac 應用 Bartender

重拾思路

那麼我們剛才修改的三處 [xxx isLicensed] 為什麼沒有產生作用呢?其實它已經產生作用了,雖然我們不可以正常使用 Bartender,但是開啟 Bartender 的 License 介面我們可以發現這裡的介面已經顯示我們付過款了,儘管這並沒有什麼卵用就是了...

逆向 Mac 應用 Bartender

到這裡我們似乎沒有什麼頭緒了,因為延時方法有很多,光是憑藉這一條線索很難定位到阻止我們破解的目的碼位置。

逆向過程中的思路很重要,如果遇到思路斷了的情況不要著急也不要氣餒,我們可以重新執行程式,嘗試不同的操作並觀察操作對應的表現 & 結果。

經過反覆執行程式,我發現每次重新啟動 Bartender 都可以有大約 10s 的可用時間,如果啟動之後直接主動點選 Bartender 的功能按鈕則會直接彈出試用期到期彈窗且頂部選單欄圖示也會直接回到之前雜亂的樣子。

這時候我的思路從延時方法轉移到了這個 Trial ended 彈窗以及 Bartender 的功能按鈕點選之後的對應方法上。這就是動態分析,它可以幫助我們重新找回思路。

按鈕響應方法

有了思路,對應的方法並不難找。我們可以利用 Hopper 的 Tag Scope 先把可能出現的區域找出來,再到對應的區域下的方法列表中尋找我們的目標方法位置。

逆向 Mac 應用 Bartender
逆向 Mac 應用 Bartender

這裡我很快就找到了目標函式 -[_TtC11Bartender_311AppDelegate bartenderStatusItemClickWithSender:], 其內部呼叫了 sub_100029ac0(arg2); 其中 arg2 就是 sender,也就是這個 Bartender 的功能按鈕了。

int sub_100029ac0(int arg0) {
    sub_100022840(arg0);
    rbx = **_NSApp;
    if (rbx == 0x0) goto loc_100029f44;

loc_100029ae7:
    [rbx retain];
    r14 = [[rbx currentEvent] retain];
    rdi = rbx;
    if (r14 == 0x0) goto loc_100029bef;

loc_100029b18:
    [rdi release];
    if (([r14 modifierFlags] & 0x80000) != 0x0) goto loc_100029b6e;

loc_100029b33:
    [r14 retain];
    if ((([r14 modifierFlags] & 0x40000) != 0x0) || ([r14 type] == 0x4)) goto loc_100029b66;

loc_100029bcc:
    rbx = [r14 type];
    [r14 release];
    if (rbx == 0x3) goto loc_100029b6e;

loc_100029bec:
    rdi = r14;
    goto loc_100029bef;

loc_100029bef:
    [rdi release];
    r14 = [[swift_getInitializedObjCClass(@class(NSUserDefaults)) standardUserDefaults] retain];
    if (*qword_1000e7e70 != 0xffffffffffffffff) {
            swift_once(qword_1000e7e70, sub_100069790);
    }
    rbx = *qword_1000ee1f8;
    r15 = *qword_1000ee200;
    swift_bridgeObjectRetain(rbx);
    r15 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, r15);
    swift_bridgeObjectRelease(rbx);
    rbx = [[r14 objectForKey:r15] retain];
    [r15 release];
    [r14 release];
    if (rbx != 0x0) {
            swift_getObjectType(rbx);
            var_50 = rbx;
    }
    else {
            intrinsic_movaps(var_40, 0x0);
            var_50 = intrinsic_movaps(var_50, 0x0);
    }
    rax = sub_10001c9a0(&var_50, &var_78);
    if (var_58 != 0x1) goto loc_100029cd8;

loc_100029ccd:
    sub_10001c2f0(&var_78);
    goto loc_100029d44;

loc_100029d44:
    if (*(int8_t *)(r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded) == 0x1) {
            rax = sub_1000230e0(0x1);
    }
    else {
            *(int8_t *)(r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_performDelayedClicks) = 0x1;
            rax = sub_1000215f0();
            if ((rax & 0x1) == 0x0) {
                    rbx = *objc_ivar_offset__TtC11Bartender_311AppDelegate_performDelayedClicks;
                    rax = *(int8_t *)(r13 + rbx);
                    rax = !rax & 0x1;
                    *(int8_t *)(r13 + rbx) = rax;
            }
    }
    return rax;

loc_100029cd8:
    rcx = *qword_1000e8a98;
    if (rcx == 0x0) {
            rcx = swift_getObjCClassMetadata(swift_getInitializedObjCClass(@class(NSDictionary)));
            *qword_1000e8a98 = rcx;
    }
    rax = swift_dynamicCast(&var_28, &var_78, *type metadata for Any + 0x8);
    if (rax == 0x0) goto loc_100029d44;

loc_100029d24:
    r14 = var_28;
    if ([r14 count] == 0x0) goto loc_100029d8f;

loc_100029d3c:
    [r14 release];
    goto loc_100029d44;

loc_100029d8f:
    r15 = [objc_allocWithZone(@class(NSAlert)) init];
    rbx = sub_1000a7f20("No menu items have been setup", 0x1d, 0x1, rcx, 0x6);
    r12 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, 0x1);
    swift_bridgeObjectRelease(rbx);
    [r15 setMessageText:r12];
    [r12 release];
    rbx = sub_1000a7f20("No menu items have been setup in Bartender Preferences, so Bartender is not doing anything yet. Would you like to open preferences now.", 0x87, 0x1, rcx, 0x6);
    r12 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, 0x1);
    swift_bridgeObjectRelease(rbx);
    [r15 setInformativeText:r12];
    [r12 release];
    [r15 setAlertStyle:0x1];
    rbx = sub_1000a7f20("Open Preferences", 0x10, 0x1, rcx, 0x6);
    r12 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, 0x1);
    swift_bridgeObjectRelease(rbx);
    rbx = [[r15 addButtonWithTitle:r12] retain];
    [r12 release];
    [rbx release];
    rbx = sub_1000a7f20("Dismiss", 0x7, 0x1, rcx, 0x6);
    r12 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, 0x1);
    swift_bridgeObjectRelease(rbx);
    rbx = [[r15 addButtonWithTitle:r12] retain];
    [r12 release];
    [rbx release];
    if ([r15 runModal] == 0x3e8) {
            sub_100029a10();
    }
    [r15 release];
    rax = [r14 release];
    return rax;

loc_100029b6e:
    *(int8_t *)(r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_performDelayedClicks) = 0x0;
    rdi = r14;
    if (([rdi modifierFlags] & 0x40000) == 0x0) {
            sub_100020de0();
    }
    else {
            if (*(int8_t *)(r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded) == 0x1) {
                    sub_1000230e0(0x1);
            }
            else {
                    sub_100020fe0(rdi);
            }
    }
    rax = [r14 release];
    return rax;

loc_100029b66:
    [r14 release];
    goto loc_100029b6e;

loc_100029f44:
    asm { ud2 };
    rax = sub_100029f46();
    return rax;
}
複製程式碼

PS: 為了便於讀者結合後面分析部分的內容快速定位(Command + F),上面的虛擬碼沒有使用截圖形式展示。

其中很醒目的是 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 我們按照之前的方法,將虛擬碼先切回彙編模式,找到對應的彙編程式碼處。

逆向 Mac 應用 Bartender

這是一段明顯的 if 語句彙編程式碼,看下面的 mov edi, 0x1 這一小節就是指 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEndedtrue 之後執行的程式碼,表示要是試用期到期就執行 0x1000230e0 處的方法。我們記下這個地址之後把這兩處的彙編程式碼通過上文插入彙編程式碼的方式修改一下,將這個 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 直接替換為 0x0false

逆向 Mac 應用 Bartender

在逆向工程中,切忌不可以冒進,時值今日幾乎所有應用都會採取措施來增加其逆向難度。這時候千萬不要想著一步到位,應該在適量修改之後嘗試匯出二進位制,用動態分析的方法驗證一下結果。因為我們這時候不是正向開發者,在沒有見到上下文的情況下修改程式碼很可能會把程式改成一個不可用的狀態(比如正常功能損壞或者頻繁 Crash),所以最好步步為營。

這裡我們匯出修改之後的二進位制檔案,按照 Bartender 的原路徑覆蓋之前的二進位制檔案驗證一下結果。我在這個階段執行時發現如果正常開啟 Bartender 還是會有一個 10s 左右的可用時長,之後依然會彈出試用期到期彈窗,並且程式變為不可用狀態;而如果重啟 Bartender 在試用期彈窗彈出之前點選功能按鈕則可以正常切換,但是再次點選按鈕卻切換不回來了,並且程式執行 10s 左右仍會彈出試用期到期彈窗,但是選單欄上面的圖示不會變失效,只是切不回去而已。

功能破解

到目前為止如果不在乎功能僅僅想要隱藏選單欄的圖示已經是可以湊合用了,但是這顯然不是我們想要的最終結果。

通過上面執行程式後觀察到的情況我推測在 -[_TtC11Bartender_311AppDelegate bartenderStatusItemClickWithSender:] 內部切換回來的邏輯中仍然有地方對是否到期做了判斷,我們上面只是成功修改了切換過去的邏輯,那麼切換回來的邏輯在哪呢?

按邏輯推測,正向切換的時候是使用 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 做判斷,反向切換應該同理才對,我們去追蹤 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 的使用,最終發現 sub_10001f870 中使用了 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEndedsub_10001f870sub_100029a10 呼叫,sub_100029a10 又被 sub_100029ac0 呼叫,sub_100029ac0 就是上文在 -[_TtC11Bartender_311AppDelegate bartenderStatusItemClickWithSender:] 中被呼叫的函式,這不僅滿足了被 Bartender 功能按鈕所引用的條件,同時還對 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 有所引用,所以我用插入彙編的方式將 sub_10001f870 中關於 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 的使用改為了 0x0,即 false

嘛~ 匯出二進位制覆蓋,發現這次的 Bartender 已經可以正常使用功能了,不過試用期到期的彈窗問題依然存在,儘管它並不影響使用,但我還是無法接受這樣一個半成品的狀態。

完美破解

還記得上文中得出的 0x1000230e0 嗎,如果試用期到期則會執行 0x1000230e0 地址處的方法,我們通過快捷鍵 G 跳轉到 0x1000230e0 地址,看一下里面的實現邏輯。

void sub_1000230e0(int arg0) {
    r14 = arg0;
    r15 = r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_trialOverWindow;
    rbx = swift_unknownWeakLoadStrong(r15);
    if (rbx != 0x0) {
            [rbx center];
            [rbx release];
            rbx = **_NSApp;
            if (rbx != 0x0) {
                    [rbx retain];
                    [rbx activateIgnoringOtherApps:sign_extend_64($S10ObjectiveC22_convertBoolToObjCBoolyAA0eF0VSbF(r14 & 0xff))];
                    [rbx release];
                    rbx = swift_unknownWeakLoadStrong(r15);
                    if (rbx != 0x0) {
                            [rbx makeKeyAndOrderFront:0x0];
                            [rbx release];
                    }
                    else {
                            asm { ud2 };
                            sub_100023199();
                    }
            }
            else {
                    asm { ud2 };
                    loc_100023195();
            }
    }
    else {
            asm { ud2 };
            loc_100023191();
    }
    return;
}
複製程式碼

通過上面的虛擬碼,我們可以初步判斷這個 0x1000230e0 內部就是彈出試用期到期彈窗的方法。接著我們通過快捷鍵 X 檢視關於 0x1000230e0 的引用,可以發現有三處呼叫,一個一個看下去發現第一個 sub_100022840 中的呼叫最像是延時呼叫,因為其中有 Hopper 反編譯出來的 Dispatch 相關的虛擬碼。

	$Ss10SetAlgebraPyxqd__cs8SequenceRd__7ElementQyd__ADRtzlufCTj(&var_A0, r13);
    swift_release(*__swiftEmptyArrayStorage);
    (extension in Dispatch):__ObjC.OS_dispatch_queueasyncAfterdeadlineqosflags.execute(Dispatch.DispatchTime, Dispatch.DispatchQoS, Dispatch.DispatchWorkItemFlags, @convention(block) () -> ()) -> ()(var_40, var_68, var_B0, var_30);
    (*(var_D0 + 0x8))(var_B0, var_C8);
    (*(var_C0 + 0x8))(var_68, var_B8);
    _Block_release(var_30);
    swift_release(var_D8);
    (var_38)(var_40, var_70, rdx);
    [var_A8 release];
    sub_1000230e0(0x0);
    rbx = var_48;
    goto loc_100022df5;
複製程式碼

切到彙編模式,找到對應的彙編程式碼。

逆向 Mac 應用 Bartender

由於 sub_1000230e0(0x0); 是在 Dispatch 中呼叫的,考慮到修改後程式的穩定性,這裡通過 Hopper 的 Modify 選單中提供的 NOP Region 填平 call sub_1000230e0 彙編程式碼。

逆向 Mac 應用 Bartender

老規矩,匯出二進位制檔案覆蓋 Bartender 中的二進位制後重啟 Bartender 驗收成果。

逆向 Mac 應用 Bartender

清爽~ 這次執行 Bartender 發現不但可以正常使用功能,之前煩人的試用期到期彈窗也被我們成功幹掉了。

總結

  • 文章簡單介紹了本次要破解的目標 Mac 應用 Bartender,如果各位同學還沒有找到合適的頂部選單欄圖示管理工具不妨試著使用 Bartender。
  • 文章介紹了 maxOS 與 iOS 逆向工程中主流的靜態分析工具 Hopper,從文章後面破解 Bartender 的實戰過程中就可以看出 Hopper 對於我們逆向過程的幫助有多麼大。
  • 文章最後詳細講述了我在破解 Bartender 過程中的經歷,從初始常規思路到不起作用思路被截斷再到通過動態分析重拾思路...一直到最後的完美破解中間經歷了許多關鍵節點,希望對大家有所幫助。

每一次逆向的過程都是未知的,有的時候可能會很順利(直接 mov eax, 0x1 + ret 就搞定),有的時候可能會很曲折,有的時候可能還會以失敗收尾。我寫這篇文章主要是想與大家交流在逆向過程中的常規方法以及遇到困難時的一些解決思路,其實不論是 Bartender 還是其他應用,不論是 Mac 應用還是 iOS 應用,逆向的思路都是相通的,願各位同學日後可以舉一反三。

如果有任何問題歡迎在文章下方留言或在我的微博 @Lision 聯絡我,真心希望我的文章可以為你帶來價值~


補充~ 我建了一個技術交流微信群,想在裡面認識更多的朋友!如果各位同學對文章有什麼疑問或者工作之中遇到一些小問題都可以在群裡找到我或者其他群友交流討論,期待你的加入喲~

逆向 Mac 應用 Bartender

Emmmmm..由於微信群人數過百導致不可以掃碼入群,所以請掃描上面的二維碼關注公眾號進群。

相關文章