[記錄] 遊戲橘子讓你的 beanfun! 帳號隱私無所遁形,人人皆可自由存取

有鑑於遊戲橘子旗下越來越多遊戲會推出導向 Beanfun APP 才能進行的活動,所以就養成了會稍微追蹤一下每個活動網頁運作方式的習慣,

跑跑卡丁車在 10/16 推出了萬聖節系列的網頁活動,一個是典型的、直接移植韓服開發的萬聖節輪盤活動,因為運作方式一樣就沒特別去研究,

另一個是橘子自己開發,名為「萬聖節抽鬼牌」的全新活動,這個活動就官方設定,只能透過掃描 QRCode 後在 Beanfun(樂豆)官方手機 APP 上進行,

但其實實際上就只是透過一連串的網址轉向到活動網頁中,只要知道最終的目標網址,就算不透過 Beanfun APP 開啟也能訪問,

而偉哉營運團隊似乎是貪圖方便或是沒有資安觀念,竟然完全信任由客戶端發送的任何請求,沒有針對參數或身分進行任何驗證查核的程序,

導致所有樂豆用戶的帳號隱私可能通通落入有心人士手中,同時也可能影響到所有跑跑玩家的活動參與權益。

先來找出活動入口

活動網頁必須從同樣是萬聖節活動的「萬聖節蜂蜜罐」活動網頁中點擊「前往抽鬼牌」後,掃描出現的 QRCode 進入,

在掃描或解析圖片後得到的網址會是:

https://beanfunstor.blob.core.windows.net/redirect/appCheck.html?url=beanfunapp://Q/h5/w_id/a52659c027334c719685dbc88fb2f158_widget%3Furl%3Dhttps%253a%252f%252fbfweb.beanfun.com%252fAutoRedirect%253fActivityType%253d0%2526RedirectUrl%253dhttps%25253a%25252f%25252fevent.beanfun.com%25252fkartrider%25252fE20201016_H5%25252findex.aspx

這個網址進入後包含了三層的網址轉向:

  1. Landing Page 判斷客戶端裝置是否為手機,若是就轉向以 beanfunapp:// 開頭的 URL,如果已安裝 Beanfun APP 將會自動開啟;若沒安裝 APP 或在電腦版上在 3 秒後會彈出提示框詢問是否進入 APP 下載頁
  2. 在 Beanfun APP 中開啟 WebView,載入 Loading 網頁
  3. 在 Loading 網頁中判斷 User Agent 有沒有包含 BeanGo,有的話就前往目標網頁,沒有則導向 Beanfun 首頁

最終可以得到目標活動網頁為:
https://event.beanfun.com/kartrider/E20201016_H5/index.aspx

此時就能直接在瀏覽器中訪問此網址,就算不使用 Beanfun APP、不用在 APP 內綁定樂豆帳號也能存取活動網頁了。

 

活動運作方式解析

現在就能直接在電腦 Chrome 上訪問活動網頁,雖然一進入就會得到「請使用beanfun!應用程式(P02)」的錯誤訊息,

但這段訊息可謂佛系阻擋,在 Console 裡執行 $.gbox.close(); 就能把訊息關掉,並且能接著使用其他功能。

這時候似乎已經可以開始進行「抽鬼牌」這個動作,任選網頁中兩張牌就會出現抽牌結果,但實際看一下 API 請求:

其實是完全沒有帶上和帳號相關的參數的,也就是這個結果也只能拿來測試用,重新整理再選一次也會拿到一樣的結果。

 

那麼到底有哪些參數是這個活動內使用到的 API 所需的呢?

看一下網頁原始碼就能發現,在最下方有一個區塊是專門讓後端吐資料的區塊:

這塊的內容只有在網頁是從 Beanfun APP 內開啟時,後端會根據 APP 內目前所選的綁定帳號 Session 來抓帳號資訊並填入。

而其中 ErrMsg 如果有值,網頁一載入就會彈框顯示並且不會執行後續判斷帳號的部分;IsJoin 則是判斷目前的帳號是否已報名活動。

接著下面引入了兩個 Script 檔  default.js 和 e20201016H5.js 是這次活動的核心檔案,為了避免線上檔案被更動我已經將原版檔案備份到 [Gist] 中,可以對照參考。

 

接著來就要研究這次的主角,也就是活動會使用到的 API,相關的函式都放在 e20201016H5.js 這支 JS 裡,

可以看到進到網頁後會做三件事,首先判斷上面提到的 ErrMsg,接著會去檢查 Beango 這個和 APP 內 WebView 相關的環境變數,

最後再去檢查主帳號是否已綁定過遊戲帳號,有的話就檢查今天是否已有抽牌紀錄,沒有的話就彈出帳號選擇視窗。

插播一段吐槽時間,BGO.check_app_exist 的 callback 裡面寫了根據 APP 環境檢查結果決定是否彈窗阻擋後續操作的邏輯,

但這裡面的的 return 是寫在 callback 的函式裡,對整個流程阻擋毫無效果,所以不管判斷結果如何一定會繼續執行後面的 IsJoin 判斷,是在寫爽的?

 

只需要 Beanfun 主帳號就能拉出帳號資料

回到正題,關鍵的地方來了,getGameAccountList 這個函式在做的事就是這次讓你的帳號資訊被攤在陽光下的開端,

它會去打這個請求取得遊戲帳號列表:

POST /kartrider/E20201016_H5/index.aspx/GetGameAccountList HTTP/1.1
Host: event.beanfun.com
Content-Type: application/json; charset=UTF-8

{ "MainAccountID": BEANFUN_USERNAME }

其中 JSON 裡的 BEANFUN_USERNAME 就是後端會吐到原始碼裡面的 MainAccountID,也就是登入 Beanfun 時使用的主帳號,

如果在非 Beanfun APP 的環境中瀏覽,MainAccountID 只會收到空字串,不過只要填入任意帳號都能正常被後端接受,整個過程是沒有任何驗證流程的,

換句話說,只要你手上有任何玩家的 Beanfun 主帳號,不需要密碼或其他資訊,你都可以輕易取得這個人的帳號資料,

而 Beanfun APP 過去主打、說得天花亂墜的帳號「最高防護」功能在此刻也完全發揮不了作用。

更好笑的是,橘子旗下遊戲過去還常常主動請玩家將自己的 Beanfun 帳號「詔告天下」,

隨便抓兩個跑跑卡丁車的粉絲團活動來看看,第一個是要得獎者公開留言自己的帳號(Source):

上面這篇只釣(?)出了三個帳號,不甚理想,那接下來看看第二個活動(Source):

這個活動就成功釣出了上百位玩家的 Beanfun 帳號。

 

而這個這個 GetGameAccountList 的 API 打出去之後會收到什麼呢?

這裡就應景從上面的留言列表中挑一個帳號來測試,得到以下結果:

ResultData 內會包含這隻樂豆帳號內已經建立的跑跑卡丁車帳號列表(每個帳號最多有五隻),

並能看到個帳號的以下資訊:

  • ServiceAccountID:遊戲登入帳號(整合 Beanfun 登入前在跑跑登入窗用的,整合後才創的帳號為自動產生的 TE 開頭帳號)
  • ServiceAccountSN:遊戲帳號編號(有遞增連續性)
  • ServiceAccountDisplayName:遊戲顯示名稱(啟動遊戲列表顯示的自訂名稱)
  • ExpirationTime:遊戲最後登出時間(2000/01/01 代表沒有登出記錄)
  • UsedPoints:累積使用過的樂豆點(單一遊戲帳號累積)
  • UsedServicePoints:累積使用過的專用點數(跑跑過去活動贈送過遊戲專用點數)
  • CreateTime:遊戲帳號創立時間(非遊戲角色)
  • LastUsedTime:上次啟動遊戲時間(其實就等於取得 OTP 的時間,不管最後有沒有啟動遊戲)
CreateTimeLastUsedTime 是以 Timestamp 的型態回傳(+0 時區),可以使用 Epoch Converter 轉換成當地日期格式。

 

雖然可以看到累積消費點數的資訊還滿酷的,就算在官網登入帳號也查不到,

拿來查自己的是無所謂,但誰會希望這些隱私被莫名其妙的路人看見呢?

 

免驗證程序幫其他玩家報名參加活動

更扯的事情來了,除了能查詢帳號資料外,這次活動還能免驗證直接幫其他玩家報名參加,

等於可以以任意玩家的身分來存取活動,這個真的相當恐怖,這次的活動是選定帳號後就無法更換,

而一個樂豆主帳號只能綁定一個遊戲帳號,活動獲得的獎勵只會發送至該遊戲帳號中,

只能慶幸跑跑卡丁車的玩家本身就不多,如果這些漏洞是發生在大型遊戲,可能已經天下大亂了。

 

根據 default.js 以及 e20201016H5.js 兩支檔案裡的函式,可以知道整個活動報名的流程是:

  1. 如果此帳號尚未報名,一進頁面時呼叫 GetGameAccountList 取得帳號列表,並渲染出選項顯示在畫面上
  2. 選擇要報名的帳號後,點擊 [確定] 會呼叫 GetGameAccountData 取得該帳號的報名所需資訊,畫面顯示確認視窗
  3. 點擊 [確定] 確認選擇後,呼叫 InsertJoinLog 完成報名程序

 

第一步驟可以直接按照上一個區塊的流程進行來獲取遊戲帳號列表,由於安全問題這裡會改用測試帳號來示範,

最終從中挑選一隻遊戲帳號,這裡拿「周子瑜玩跑跑」這隻來測試,記下稍後會使用到的參數 ServiceAccountID

{
    "__type": "E20201016_H5_GameAccountModel",
    "ServiceAccountID": "TE875d88831472763645",
    "ServiceAccountSN": "2474212",
    "ServiceAccountDisplayName": "周子瑜玩跑跑",
    "Type": "N",
    "ChargeRule": "0W",
    "ExpirationTime": "2000/1/1 上午 12:00:00",
    "UsedPoints": 0,
    "NewAccountFlag": true,
    "UsedServicePoints": 0,
    "CreateTime": "/Date(1478184482013)/",
    "LastUsedTime": "/Date(1478184482013)/",
    "Visible": "1"
}

接著來取得這隻帳號的報名所需資訊,等同於在網頁上操作時確認報名帳號的步驟,先進行以下請求:

POST /kartrider/E20201016_H5/index.aspx/GetGameAccountData HTTP/1.1
Host: event.beanfun.com
Content-Type: application/json; charset=UTF-8

{ "ServiceAccount": ServiceAccountID }

在這裡的 ServiceAccountID 就是上面的遊戲登入帳號 TE875d88831472763645

送出請求後,如果帳號是存在的,就會得到以下回應內容:

其中 ResultData 除了包含 nid 遊戲登入帳號外,還多了 rid 這個是遊戲內的角色名稱(這個也滿扯的可以直接拉到遊戲 ID,這裡是剛好和顯示名稱一樣),

以及 oid 可能是遊戲角色創立順序編號、DBLocation 似乎是帳號資料存放的資料庫,但實際上到底是什麼並不重要,因為只需要原封不動地把這塊資料再丟回去後端就行了,

接著要進行最後的「報名」動作,執行以下請求:

POST /kartrider/E20201016_H5/index.aspx/InsertJoinLog HTTP/1.1
Host: event.beanfun.com
Content-Type: application/json; charset=UTF-8

{
  "StarAccount": "",
  "MainAccountID": BEANFUN_USERNAME,
  "ServiceAccount": ServiceAccountID,
  "AccountData": {
    "__type": "E20201016_H5_GameAccountDataModel",
    "oid": 134263107,
    "nid": "TE875d88831472763645",
    "rid": "周子瑜玩跑跑",
    "DBLocation": "00"
  }
}

MainAccountID 帶入樂豆主帳號、ServiceAccount 帶入遊戲登入帳號,而 AccountData 就是帶入剛剛拿到的 ResultData 整個物件,

至於 StarAccount 我到最後還是沒找出它的內容和用途,且經測試 StarAccountServiceAccount 兩個欄位直接傳入空字串也能報名成功(??),

成功報名之後就會收到以下格式的回傳內容,其中 ResultData 欄位回傳的 3660 就是此帳號的報名排序,

如果報名失敗會直接在 ResultMessage 帶入原因,通常是該樂豆主帳號已經選擇過遊戲帳號了。

後來測試把 AccountData 固定使用同一組遊戲帳號的資料,只修改樂豆主帳號竟然也能報名成功,

這代表可以把不同的樂豆主帳號都綁定非主帳號底下的遊戲帳號,一個你得獎我領獎的概念。

 

報名成功後就能接著參加活動,也就是抽撲克牌,每天都能進行一次,是呼叫 PlayPoker 這隻 API,

比較麻煩的是同樣的 API 要呼叫兩次,分別是第一張和第二張牌:

POST /kartrider/E20201016_H5/index.aspx/PlayPoker HTTP/1.1
Host: event.beanfun.com
Content-Type: application/json; charset=UTF-8

{
  "PlayCount": 1,
  "StockID": "",
  "StarAccount": "",
  "MainAccountID": BEANFUN_USERNAME,
  "ServiceAccount": ServiceAccountID
}

上面的 PlayCount 就是抽取的張數,分別要填入 12 進行兩次請求才能完成當日抽牌活動,

StockID 是遊戲內的道具商品編號,官方是寫抽第二張牌時才要帶入,但經測試後端不會吃到這個值,所以不需填寫,

其餘參數和報名時使用的一模一樣,但 MainAccountID 和 ServiceAccount 兩個欄位的值必須和報名使用的一樣(兩兩配對)才能請求成功,成功抽牌後就會獲得以下結果:

第一張牌一定是遊戲道具或點數,第二張牌大部分都是鬼牌,就是槓龜,Rate 應該是出現機率,但只有第一次請求會顯示實際機率,重複呼叫後機率都會變成 100%,

或是只要看 ResultData.IsWin 就知道有沒有贏,但有沒有贏不是這次的重點,這次的重點是為什麼可以這麼輕易的就使用別人的身分來進行這個活動...

 

總結一下

所以透過這次抽鬼牌活動的漏洞,所有人都可以做到以下這些事:

  • 查詢樂豆主帳號已開通的跑跑卡丁車遊戲帳號列表、遊戲帳號資訊
  • 查詢玩家遊戲帳號在遊戲內對應的角色名稱
  • 幫其他玩家報名活動,並綁定進行活動的遊戲帳號
  • 將其他玩家的樂豆主帳號綁定自己的遊戲帳號
  • 以其他玩家的身分來進行每日抽牌活動
  • 不受裝置、數量限制的無限報名和參加活動

本文到此為止的操作都只要透過一個 Beanfun 主帳號就能達成,完全沒有經過其他認證或授權的程序,你能相信這個產品來自一個營運了 20 幾年的大公司嗎?

查了一下遊戲橘子內部光是和資安相關的工程師就有資安顧問、資安監控、資安管理、資安滲透測試四種職位,但似乎都沒有在這次的專案中發揮到專業,

橘子旗下遊戲的帳號數量少說也有上千萬個,如果每款遊戲的活動都用同樣的品質來開發,等於將這些玩家的隱私暴露在風險中,甚至有可能被大量外洩,帳號安全毫無保障,

另外也要提醒大家這種涉及到重要資料或金錢交易的帳號,為了安全起見千萬不要隨意公開在網路上,橘子自己就做了最大的錯誤示範。

 

後記 I

本文以上提到的安全問題於 10/16 發現,經過確認及驗證後,已經於 10/19 下午 2 點經由遊戲橘子客服中心回報,

同日晚上 9 點左右橘子非常迅速地來電表示上述提到的問題都已經修復完成,營運團隊很重視玩家的帳號安全 blablabla...,

然後不斷感謝我回報了這個漏洞,接著詢問能不能不要在網路上公佈相關細節(因為我在回報信件裡有提到在官方修復完成後會公開),

但基於玩家的立場我覺得還是有必要讓大家知道這件事,這多少也能拋磚引玉讓更多玩家能一起監督後續的活動內容,

所以還是選擇將整個事件的原委給記錄下來,讓大家能知道活動開始後的這四天自己的帳號曾經遭受什麼樣的風險。

順帶一提,經過測試目前以上提到的幾隻 API 的確已經會正常限制非透過 Beanfun APP 授權發送的請求了:

 

後記 II

10/20 早上又收到橘子來電,還有兩通,但我沒接到,也沒打算回撥,而且後續也沒再收到其他來電了,事情應該落幕。

想隨時追蹤最新資訊?歡迎使用 RSS 訂閱最新文章 »

您或許會感興趣的文章

隨機推薦

共有 4 則迴響

  1. 布丁布丁吃布丁

    #1 @

    太專業

  2. 路人999

    #2 @

    網站設計師表示,寫在JS上,都會被人看+漏洞 :thinking:

    • Lay

      # @

      要寫是能寫,只是後端還是要驗證,他們可能以為網頁是內嵌在 APP 裡面就沒人看的到。

  3. 被盜光溜溜路人

    #3 @

    大大工程師編碼英明,請讓我膜拜!!
    難怪玩橘子遊戲必被盜阿。
    智冠 MY card我也被盜過,嚇的我叫客服鎖帳號了。台灣的遊戲實在是不適合儲值當韭菜,直接轉去steam 卡安全

Lay 發表迴響

點擊這裡以取消回覆
*