本文为看雪论坛优秀文章
看雪论坛作者ID:Invert
這是一款音樂節奏型的遊戲,遊戲玩法爲本地和聯網混合。最近它更新了 v4.0.255 版本,應該是本遊戲的結局作品。借此機會我們來研究一下。
預備條件:已越獄的 iOS 設備。
我們先簡單地試着修改殘片值,看看對程序的修改流程是否能正常運作。
先用 IDA x64 加載主程序 Arc-mobile 等待自動分析完成。
Shift-F7 打開 Segments 界面,把名字中帶 const, string, data 的區段 Segment permissions 全部改成只讀的,這樣 IDA 反編譯時才好識別出字符串。
通過一些簡單的搜索(https://www.bilibili.com/read/cv11838855)可以瞭解到,殘片值信息存在本地的 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_v 和 fr_k 的鍵值,做了一些看似驗證的操作,最後返回了 fr_v 的鍵值或者 0,大概驗證失敗的話就返回 0 了。
在確認一下這個函數是如何被調用的:
可以看到返回值直接被賦值到一個結構體上了,沒有進一步的檢驗。那麼想要修改殘片值就太簡單了,甚至完全不需要去理會 fr_k 是怎麼校驗的。
如果有安裝 keypatch(https://github.com/keystone-engine/keypatch) 之類的插件可以直接使用插件修改程序,不過爲了照顧更多人,這裏我們從頭生成機器碼。
打開 Compiler Explorer(https://gcc.godbolt.org/z/576T9Koes),隨便編譯一個返回 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, 甚至手動(https://www.reddit.com/r/jailbreak/comments/ejbi1w/release_install_ipa_files_using_windows/)。因此就不詳細展開了。
不過如果一切順利,你安裝好的遊戲每次啓動都會恢復到 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(https://gcc.godbolt.org/z/Y5Po5MK9f),這回把 NOP 翻譯成機器碼,即 1F 20 03 D5,然後替換掉 CBNZ 語句的機器碼:
然後我們可以看到反編譯界面中的 std::string::compare 全都成了無作用的代碼了。
現在我們就可以對 songs 目錄下的文件隨意更改了。比如把 songlist 中一首需要下載的歌曲的 remote_dl 字段刪掉,再把下載好的 ogg、aff 等文件放到 song id 的文件夾中,即可把歌曲轉變爲本地歌曲,無需下載。又比如修改 unlocks 文件可以改變歌曲的解鎖方法,甚至讓其變成無需解鎖的歌曲。這方面的改動就交給讀者自由探索了。
接下來我們做點稍有難度的,抓取 HTTPS API 數據包。首先我們需要給 iOS 設備配置中間人代理,並且能替換 HTTPS 證書解密加密流量內容。這需要一個中間人代理軟件,最好還能記錄數據包。這一步有很多選擇,比如 Burp Suite(https://portswigger.net/burp),mitmproxy(https://mitmproxy.org/),Charles Proxy(https://www.charlesproxy.com/) 等等,我這裏使用 Burp Suite 來演示。
首先需要配置 Burp Suite 的代理服務器,以及 iOS 上的網絡代理設置,請參考這裏(https://portswigger.net/support/configuring-an-ios-device-to-work-with-burp)的指示。然後需要把 Burp Suite 的 HTTPS 代理證書加入到 iOS 的信任證書中,請參考這裏(https://portswigger.net/support/installing-burp-suites-ca-certificate-in-an-ios-device)的指示操作。做完這些後你應該能在 Burp Suite 的數據包記錄界面看到一些抓取到的數據包了。
但是,當你打開遊戲,進行一番操作後,在 Burp Suite 中卻沒有看到任何 API 相關的數據包。按經驗來說,這是遇上 Cert Pinning 了,需要進行 Unpin。
通過搜索 Arc-mobile 中的一些字符串,可以確認遊戲使用了 TrustKit(https://github.com/datatheorem/TrustKit) 這一開源項目實現 Cert Pinning。簡單地閱讀一下 TrustKit 的代碼即可找到 [TSKPinningValidator evaluateTrust:forHostname:](https://github.com/datatheorem/TrustKit/blob/0617d99b534de6adb632ada2475ca8c20c644736/TrustKit/TSKPinningValidator.m#L84) 這個函數,對每個請求的目標進行證書驗證,如果成功則返回 True。知道了這點,要繞過這個 Cert Pinning 簡直輕而易舉。
再次打開 Compiler Explorer(https://gcc.godbolt.org/z/x691dqYKx), 寫一個返回 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 寫了:
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
}
把前面提取出來的 PKCS12 文件以二進制形式保存到 v4.0.255_key.p12 然後執行上面的 Golang 程序就能在本地 127.0.0.1:5151 端口開啓 API 代理轉發服務器了。
接下來我們要配置 MITM 代理把所有的 API 請求轉發到 API 代理轉發服務器上,這裏的話需要根據你的 MITM 軟件來設置, 如果用的是 Charles 可以直接用 Map Remote 功能,如果用的是 Burp Suite,則需要使用 Extension 腳本,這裏給出 Python 版的:
from burp import IBurpExtender
from burp import IHttpListener
HOST_FROM = "arcapi-v2.lowiro.com"
HOST_TO = "127.0.0.1"
PORT_TO = 5151
PROTO_TO= "http"
class BurpExtender(IBurpExtender, IHttpListener):
#
# implement IBurpExtender
#
def registerExtenderCallbacks(self, callbacks):
# obtain an extension helpers object
self._helpers = callbacks.getHelpers()
# set our extension name
callbacks.setExtensionName("Traffic redirector")
# register ourselves as an HTTP listener
callbacks.registerHttpListener(self)
#
# implement IHttpListener
#
def processHttpMessage(self, toolFlag, messageIsRequest, messageInfo):
# get the HTTP service for the request
httpService = messageInfo.getHttpService()
host = httpService.getHost()
# if the host is HOST_FROM, change it to HOST_TO
if messageIsRequest and host == HOST_FROM:
messageInfo.setHttpService(self._helpers.buildHttpService(HOST_TO,
PORT_TO, PROTO_TO))
現在我們就能成功抓包了,你可以根據抓到的包去寫私服,不過網上有已經寫好的服務端可以直接用,方法也很簡單,就是把上面轉發 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 計算,也就是說 v212 和 v213 就是 C1 和 XK1 了。另外我們可以看出 byte_100BEAA78 指向的是完整的密文,一共 48 bytes,按照 16 bytes 一個 Block 的大小算,就是三個 Block 的長度。
我們用 LLDB 在那個解密的 do ... while 前面下斷點 ,然後去查看一下 v212 和 v213 的指向內存區域,即可找到 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 位置即可。
做完這些,你就可以使用自己的私服,隨時隨地的遊玩了。
看雪ID:Invert
https://bbs.pediy.com/user-home-898032.htm
# 往期推荐
球分享
球点赞
球在看
点击“阅读原文”,了解更多!