這是一款音樂節奏型的遊戲,遊戲玩法爲本地和聯網混合。最近它更新了 v4.0.255 版本,應該是本遊戲的結局作品。借此機會我們來研究一下。
IPA 提取拆包
預備條件:已越獄的 iOS 設備。
- 從 App Store 下載安裝遊戲
- 使用 將遊戲解密導出爲 IPA
- 將導出的 IPA 傳到 PC 上
- 用 7zip 或者別的 unzip 工具解壓 IPA
簡單的殘片數值修改
我們先簡單地試着修改殘片值,看看對程序的修改流程是否能正常運作。
先用 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_v
和 fr_k
的鍵值,做了一些看似驗證的操作,最後返回了 fr_v
的鍵值或者 0
,大概驗證失敗的話就返回 0
了。
在確認一下這個函數是如何被調用的:
可以看到返回值直接被賦值到一個結構體上了,沒有進一步的檢驗。那麼想要修改殘片值就太簡單了,甚至完全不需要去理會 fr_k
是怎麼校驗的。
如果有安裝 之類的插件可以直接使用插件修改程序,不過爲了照顧更多人,這裏我們從頭生成機器碼。
打開 ,隨便編譯一個返回 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,這一步的選擇有很多,而且不一定需要越獄,比如 , , , , 。因此就不詳細展開了。
不過如果一切順利,你安裝好的遊戲每次啓動都會恢復到 999999999
個殘片。做到這一點說明你已經成功掌握了修改 IPA 並安裝測試的整個流程了。接下來我們來做些更有趣的改動。
更改 songlist 文件
網上有很多關於該遊戲資源文件的,我就不詳細說明了,其中主要的曲目文件都在 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
空語句。根本不需要去研究那些哈希值是怎麼計算出來的。
再次打開 ,這回把 NOP
翻譯成機器碼,即 1F 20 03 D5
,然後替換掉 CBNZ
語句的機器碼:
然後我們可以看到反編譯界面中的 std::string::compare
全都成了無作用的代碼了。
HTTPS API 抓包
接下來我們做點稍有難度的,抓取 HTTPS API 數據包。首先我們需要給 iOS 設備配置中間人代理,並且能替換 HTTPS 證書解密加密流量內容。這需要一個中間人代理軟件,最好還能記錄數據包。這一步有很多選擇,比如 ,, 等等,我這裏使用 Burp Suite 來演示。
首先需要配置 Burp Suite 的代理服務器,以及 iOS 上的網絡代理設置,請參考的指示。 然後需要把 Burp Suite 的 HTTPS 代理證書加入到 iOS 的信任證書中,請參考的指示操作。做完這些後你應該能在 Burp Suite 的數據包記錄界面看到一些抓取到的數據包了。
但是,當你打開遊戲,進行一番操作後,在 Burp Suite 中卻沒有看到任何 API 相關的數據包。按經驗來說,這是遇上 Cert Pinning 了,需要進行 Unpin。
通過搜索 Arc-mobile
中的一些字符串,可以確認遊戲使用了 這一開源項目實現 Cert Pinning。簡單地閱讀一下 TrustKit 的代碼即可找到 這個函數,對每個請求的目標進行證書驗證,如果成功則返回 True。知道了這點,要繞過這個 Cert Pinning 簡直輕而易舉。
再次打開 , 寫一個返回 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 寫了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
|
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 版的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
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):
def
registerExtenderCallbacks(
self
, callbacks):
self
._helpers
=
callbacks.getHelpers()
callbacks.setExtensionName(
"Traffic redirector"
)
callbacks.registerHttpListener(
self
)
def
processHttpMessage(
self
, toolFlag, messageIsRequest, messageInfo):
httpService
=
messageInfo.getHttpService()
host
=
httpService.getHost()
if
messageIsRequest
and
host
=
=
HOST_FROM:
messageInfo.setHttpService(
self
._helpers.buildHttpService(HOST_TO,
PORT_TO, PROTO_TO))
|
現在我們就能成功抓包了,你可以根據抓到的包去寫私服,不過網上有已經寫好的服務端可以直接用,方法也很簡單,就是把上面轉發 API 請求的目的地變成你搭建的私服的地址即可。如果你想把私服架在公網,然後隨時隨地無需設置 MITM 代理也能使用私服遊玩,那你還需要把遊戲的 API Endpoint 地址給改成自己的私服 Endpoint 地址。
修改 API 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
位置即可。
做完這些,你就可以使用自己的私服,隨時隨地的遊玩了。