首页
社区
课程
招聘
[原创] 某 iOS 遊戲抓包、修改
发表于: 2022-7-29 09:22 51673

[原创] 某 iOS 遊戲抓包、修改

2022-7-29 09:22
51673

這是一款音樂節奏型的遊戲,遊戲玩法爲本地和聯網混合。最近它更新了 v4.0.255 版本,應該是本遊戲的結局作品。借此機會我們來研究一下。

預備條件:已越獄的 iOS 設備。

我們先簡單地試着修改殘片值,看看對程序的修改流程是否能正常運作。

先用 IDA x64 加載主程序 Arc-mobile 等待自動分析完成。

Shift-F7 打開 Segments 界面,把名字中帶 const, string, data 的區段 Segment permissions 全部改成只讀的,這樣 IDA 反編譯時才好識別出字符串。

通過一些簡單的搜索可以瞭解到,殘片值信息存在本地的 AppData Container/Library/Preferences/moe.low.arc.plist 文件的一個 fr_v 鍵值中,並且有一個相對的 fr_k 鍵值校驗 fr_v 值的可信性,大概是某種哈希算法。但是這些都不重要,但存地修改本地緩存的值只能一次性生效,遊戲中的數值修改仍然會複寫掉你改的值。我們可以試試更加有效的更改殘片值的方法。

在 IDA 的 Python 命令行輸入 'fr_v'.encode().hex(' ') 得到這個字符串的 hex 值,然後在 IDA 的 Hex View 中 Alt-B 打開 Binary search 界面,搜索這串 hex 值。(有可能是字符串長度太短 IDA 沒有把它認作字符串,故而使用二進制搜索而不是字符串搜索)

可以看到在 __cstring 區段找到了這個字符串。

通過 XREF 我們可以定位到這個函數,簡單看一下就能發現它讀取 fr_vfr_k 的鍵值,做了一些看似驗證的操作,最後返回了 fr_v 的鍵值或者 0,大概驗證失敗的話就返回 0 了。

在確認一下這個函數是如何被調用的:

可以看到返回值直接被賦值到一個結構體上了,沒有進一步的檢驗。那麼想要修改殘片值就太簡單了,甚至完全不需要去理會 fr_k 是怎麼校驗的。

如果有安裝 keypatch 之類的插件可以直接使用插件修改程序,不過爲了照顧更多人,這裏我們從頭生成機器碼。

打開 Compiler Explorer,隨便編譯一個返回 999999999 數值的函數,記得選擇 armv8-a 的編譯目標:

每一行匯編上面一小行 hex 字符是對應的機器碼,不過是用 Little-endian 的 DWORD 表示的,如果寫入二進制文件需要 swap bytes。

回到 IDA 的匯編界面,把光標移到目標函數的開頭一行,然後切換到 Hex View,按 F2 進入編輯模式,然後直接覆寫 Hex 值機器碼,完後再按一次 F2 確認編輯:

回到匯編界面我們可以看到該函數被改爲返回 999999999 了。 可以按 P 在此處重新建立並分析函數,就能 F5 看反編譯代碼了。

接下來我們要把改動的部分寫入到 Arc-mobile 文件中,這個操作位於 Edit - Patch program - Apply patches to input file...,注意你必須位於匯編界面或者 Hex 界面才能使用這個操作。

接下來我們需要 Sideload 我們修改後的 App,這一步的選擇有很多,而且不一定需要越獄,比如 AltStore, AltServerPatcher, Sideloadly, Cydia Impactor, 甚至手動。因此就不詳細展開了。

不過如果一切順利,你安裝好的遊戲每次啓動都會恢復到 999999999 個殘片。做到這一點說明你已經成功掌握了修改 IPA 並安裝測試的整個流程了。接下來我們來做些更有趣的改動。

網上有很多關於該遊戲資源文件的格式介紹,我就不詳細說明了,其中主要的曲目文件都在 songs 目錄下,其中 songlist 文件列舉了所有的曲目,是遊戲主程序加載曲目的索引文件。爲了防止修改 songlist 文件,遊戲對其進行了哈希校驗,其哈希值是寫死在了 Arc-mobile 二進制文件中的。我們這就來看看。

首先 Shift-F12 打開字符串界面,Ctrl-F 搜索 songlist,找到 songs/songlist 字符串,然後通過 XREF 找到這樣一個函數:

更值得注意的是這個函數後半部分有這樣一段哈希值的對比代碼:

毫無疑問這個函數做的就是加載 songs 目錄下的索引文件並進行校驗,且一共有三個文件需要被校驗:songlist, packlist, 以及 unlocks。我們現在就來讓修改程序直接繞過校驗。先來看一下這個哈希值字符串對比的匯編代碼:

看到在調用完 std::string::compare 之後馬上使用 CBNZ 即如果返回值 W0 != 0,也就是說字符串匹配不相等,則跳轉到後面的地址。反過來說,如果要繞過這個校驗,我們需要讓這個代碼執行字符串匹配時,也就是 W0 == 0 時的行爲,也就是不進行跳轉。那麼一個很簡單的 MOD 就是把這些 CBNZ 全部替換成 NOP 空語句。根本不需要去研究那些哈希值是怎麼計算出來的。

再次打開 Compiler Explorer,這回把 NOP 翻譯成機器碼,即 1F 20 03 D5,然後替換掉 CBNZ 語句的機器碼:

然後我們可以看到反編譯界面中的 std::string::compare 全都成了無作用的代碼了。

現在我們就可以對 songs 目錄下的文件隨意更改了。比如把 songlist 中一首需要下載的歌曲的 remote_dl 字段刪掉,再把下載好的 oggaff 等文件放到 song id 的文件夾中,即可把歌曲轉變爲本地歌曲,無需下載。 又比如修改 unlocks 文件可以改變歌曲的解鎖方法,甚至讓其變成無需解鎖的歌曲。這方面的改動就交給讀者自由探索了。

接下來我們做點稍有難度的,抓取 HTTPS API 數據包。首先我們需要給 iOS 設備配置中間人代理,並且能替換 HTTPS 證書解密加密流量內容。這需要一個中間人代理軟件,最好還能記錄數據包。這一步有很多選擇,比如 Burp SuitemitmproxyCharles Proxy 等等,我這裏使用 Burp Suite 來演示。

首先需要配置 Burp Suite 的代理服務器,以及 iOS 上的網絡代理設置,請參考這裏的指示。 然後需要把 Burp Suite 的 HTTPS 代理證書加入到 iOS 的信任證書中,請參考這裏的指示操作。做完這些後你應該能在 Burp Suite 的數據包記錄界面看到一些抓取到的數據包了。

但是,當你打開遊戲,進行一番操作後,在 Burp Suite 中卻沒有看到任何 API 相關的數據包。按經驗來說,這是遇上 Cert Pinning 了,需要進行 Unpin。

通過搜索 Arc-mobile 中的一些字符串,可以確認遊戲使用了 TrustKit 這一開源項目實現 Cert Pinning。簡單地閱讀一下 TrustKit 的代碼即可找到 [TSKPinningValidator evaluateTrust:forHostname:] 這個函數,對每個請求的目標進行證書驗證,如果成功則返回 True。知道了這點,要繞過這個 Cert Pinning 簡直輕而易舉。

再次打開 Compiler Explorer, 寫一個返回 true 值的函數:

在 IDA 的 Function 界面 Ctrl-F 搜索 evaluateTrust 即可找到目標函數,然後照舊覆寫機器碼:

如果你現在保存修改,安裝 IPA 嘗試抓包,會發現依然沒有遊戲的 API 數據出現在 Burp Suite 軟件中,這是怎麼回事呢?實際上 Cert Pinning 我們已經繞過了的,但是本遊戲對 API 的保護還有一層,那就是客戶端的 HTTPS 證書校驗。

通常的 HTTPS 請求,只是客戶端校驗服務端的證書,然而這種特殊的配置下,服務端也會同時校驗客戶端的證書,唯有雙向都驗證成功,鏈接才會創立。而 Burp Suite 位於 MITM,阻斷了客戶端向服務端發送證書進行校驗。如果 MITM 任由客戶端和服務端相互校驗各自的證書建立鏈接,則 MITM 將無法解密其消息內容。因此,我們必須將遊戲的客戶端證書以及對應的私鑰都提取出來。

通過一些搜索我們可以知道,要在 iOS 上做這種客戶端的證書驗證,一定會使用到 NSURLAuthenticationMethodClientCertificate 這個引用,我們就以此爲關鍵字在 IDA 中檢索,很容易就能找到這條函數:

可以看到這個函數在處理客戶端證書請求的時候,先調用 getClientIdentity 再把返回值提供給 [NSURLCredential credentialWithIdentity:certificates:persistence:] 創建 NSURLCredential 對象。 通過 Apple 的一些文檔可以理解出來,Identity 包括了證書和私鑰,因此我們跟進 getClientIdentity 函數:

很明顯了,這個函數讀取了一個加密的 PKCS12 證書+密鑰的結合數據,其中 self->sslCert 就是 PKCS12 的二進制數據,而 self->sslCertPassword 就是解密這個數據的密碼。 現在我們要做的就是把這兩項內容給提取出來。

因爲不想費太多時間去分析代碼找到原始數據,我決定直接用 LLDB 動態從內存中提取這些數據。

只要在 Sideload 過程中使用的是自己的 Apple 開發者帳號的證書,你就可以用 Xcode 調試你 Sideload 的 App。如果設備有越獄,還可以運行 debugserver 然後直接連接 IDA 遠程調試。

打開 Xcode,使用 Debug - Attach to Process by PID or Name... 然後使用 Arc-mobile 作爲調試對象,然後再啓動 Sideload 的遊戲程序,Xcode 就能 attach 到遊戲進程上了。

接下來我們要計算下斷點的地址,使用 target modules list LLDB 指令可以查看到主程序 Arc-mobile 的起始地址,在此基礎上加上函數地址的偏移值就是我們要下斷點的地址了。 使用 LLDB 的 breakpoint 指令佈置斷點,然後執行任何可以觸發 API 請求的操作,比如隨便登錄一下,即可觸發斷點。

調試器跟進到讀取了 self->sslCert 的位置,然後在 LLDB 執行 po $x8 即可打印出 PKCS12 的二進制數據:

同理在讀取了 self->sslCertPassword 的位置我們可以得出 PKCS12 的密碼爲 HelloWorld

爲了能夠模擬成真實的客戶端,同時對 API 進行抓包,我們需要寫一個 API 代理轉發程序。這裏我就用 Golang 寫了:

把前面提取出來的 PKCS12 文件以二進制形式保存到 v4.0.255_key.p12 然後執行上面的 Golang 程序就能在本地 127.0.0.1:5151 端口開啓 API 代理轉發服務器了。

接下來我們要配置 MITM 代理把所有的 API 請求轉發到 API 代理轉發服務器上,這裏的話需要根據你的 MITM 軟件來設置, 如果用的是 Charles 可以直接用 Map Remote 功能,如果用的是 Burp Suite,則需要使用 Extension 腳本,這裏給出 Python 版的:

現在我們就能成功抓包了,你可以根據抓到的包去寫私服,不過網上有已經寫好的服務端可以直接用,方法也很簡單,就是把上面轉發 API 請求的目的地變成你搭建的私服的地址即可。如果你想把私服架在公網,然後隨時隨地無需設置 MITM 代理也能使用私服遊玩,那你還需要把遊戲的 API Endpoint 地址給改成自己的私服 Endpoint 地址。

這一步相對繁瑣,但是並不難。通過抓包我們知道 API Endpoint Prefix 是 https://arcapi-v2.lowiro.com/join/21/ ,在 IDA 中搜索一些 API method 比如 auth/login 即可找到構建請求 URL 的地方。

不難看出,API Endpoint Prefix 是經過加密處理的,稍加閱讀代碼可以看出加密的模式是 CFB,也就是說上一個 Block 的密文會被用來計算下一個 Block 的 XOR Key:

但是我們也可以看得出 Block Encryption 不像是直接調用 AES 那麼簡單,是不是 AES 也沒能一眼確認出來(個人猜測是把 AES Key Expand 了一下之類的或者做了些 Input Output Encoding)。那麼像我這種懶人是不會花那個時間去逆向他的加密算法的,我們要做的只不過是改掉 API Endpoint Prefix 而已。那麼當我們知道他用的是 CFB Mode 的時候,我們已經無需關心他底層的 Block Encryption 用的是什麼,甚至不需要知道 Encryption Key 是什麼了,我們可以直接用動態調試的手法直接修改密文,使其解密出來的內容是我們想要的明文。

首先我們知道,第一個 Block 的 XOR Key (記作 XK1)是固定的,跟密文上下文無關,而且我們已經知道第一個 Block 的明文是 https://arcapi-v (記作 P1),則密文的第一個 Block (記作 C1)爲 C1=P1 ⊕ XK1。如果我們想要把明文改成 P1',則對應的修改後的密文 C1' 需要爲 C1'=P1' ⊕ XK1=P1' ⊕ P1 ⊕ P1 ⊕ XK1=P1' ⊕ P1 ⊕ C1。也就是說直接把我們已知的明文,和想要的明文一起 XOR 到現有的密文上,即可讓密文變成解密出我們想要的明文。

但是,修改後的當前 Block 的密文,會影響下一個 Block 的 XOR Key,但是我們可以通過動態調試直接拿到每一次 Block 處理時,當前的 XOR Key,因此這個加密對於我們想做的事情形同虛設。

回到 IDA 的代碼中,可以看到 v214 做的就是每個 Block 解密最後的 C1 ⊕ XK1 計算,也就是說 v212v213 就是 C1 和 XK1 了。另外我們可以看出 byte_100BEAA78 指向的是完整的密文,一共 48 bytes,按照 16 bytes 一個 Block 的大小算,就是三個 Block 的長度。

我們用 LLDB 在那個解密的 do ... while 前面下斷點 ,然後去查看一下 v212v213 的指向內存區域,即可找到 C1 和 XK1 了。

可以看到 x10 寄存器是 C1,x9 寄存器是 XK1,然後根據上述方法計算出 C1',然後用 LLDB 命令 memory write $x10 0x11 0x22 0x33... 複寫 C1。如果想要確認解密出來的內容和想要的明文一致,可以在 do ... while 後面下斷點然後查看 x/16b $x11-16 。重複這個操作直至 3 個 Block 都改掉。然後在 IDA 把修改後的密文整個寫入 byte_100BEAA78 位置即可。

做完這些,你就可以使用自己的私服,隨時隨地的遊玩了。

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
package main
 
import (
    "crypto"
    "crypto/tls"
    "crypto/x509"
    _ "embed"
    "io"
    "log"
    "net/http"
 
    "golang.org/x/crypto/pkcs12"
)
 
//go:embed v4.0.255_key.p12
var v4_0_255_key []byte
var password = "HelloWorld"
 
func init() {
    var (
        err       error
        clientKey crypto.PrivateKey
        clientCert *x509.Certificate
    )
    if clientKey, clientCert, err = pkcs12.Decode(v4_0_255_key, password); err != nil {
        panic(err)
    }
    http.DefaultClient.Transport = &http.Transport{TLSClientConfig: &tls.Config{
        Certificates: []tls.Certificate{{
            Certificate: [][]byte{clientCert.Raw},
            PrivateKey: clientKey,
        }},
    }}
}
 
func main() {
    addr := "0.0.0.0:5151"
    log.Println("Server Started at", addr)
    http.ListenAndServe(addr, http.HandlerFunc(handler))
}
 
func handler(w http.ResponseWriter, r *http.Request) {
    var (
        err error
        resp *http.Response
    )
 
    check := func(err error) bool {
        if err != nil {
            w.WriteHeader(500)
            log.Printf("%s %s", "FAIL", err.Error())
            return true
        }
        return false
    }
 
    log.Printf("%s %s", r.Method, r.URL.String())
 
    if resp, err = forwardHTTPRequest(r); check(err) {
        return
    }
 
    defer func() {
        resp.Body.Close()
    }()
 
    for key, vals := range resp.Header {
        for _, val := range vals {
            w.Header().Add(key, val)
        }
    }
 
    w.WriteHeader(resp.StatusCode)
    io.Copy(w, resp.Body)
}
 
func forwardHTTPRequest(r *http.Request) (resp *http.Response, err error) {
    r.URL.Scheme = "https"
    r.URL.Host = "arcapi-v2.lowiro.com"
    r.Header.Del("Host")
 
    req, err := http.NewRequest(r.Method, r.URL.String(), r.Body)
    if err != nil {
        return
    }
 
    req.Header = r.Header
    if resp, err = http.DefaultClient.Do(req); err != nil {
        return
    }
    return
}
package main
 
import (
    "crypto"

[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 4
支持
分享
最新回复 (3)
雪    币: 31
活跃值: (3254)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
看雪公号上看过,精华上榜文章,希望用心向作者学习, 目前 C , Objective-C 在学。 配合飘云的书,先入门。 成为一个安全工程师
2023-1-1 16:42
0
雪    币: 192
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
楼主,你好,有个hook的需求,可以加你了解一下吗
2023-5-18 16:39
0
游客
登录 | 注册 方可回帖
返回
//