尋找UC瀏覽器的漏洞

尋找UC瀏覽器的漏洞

介紹

三月底我們 報導,他們發現了在 UC 瀏覽器中載入和運行未經驗證的程式碼的隱藏功能。 今天,我們將詳細了解這種下載是如何發生的,以及駭客如何利用它來達到自己的目的。

不久前,UC 瀏覽器的廣告和分發非常激進:它使用惡意軟體安裝在用戶的裝置上,並以視頻檔案的形式從各個網站分發(即,用戶認為他們正在下載色情視頻,但實際上是這樣)相反,該瀏覽器收到了 APK),使用可怕的橫幅,其中包含瀏覽器已過時、易受攻擊等資訊。 在VK的官方UC瀏覽器組裡有 主題,其中用戶可以抱怨不公平的廣告,有很多例子。 2016年甚至還有 影片廣告 俄語(是的,廣告攔截瀏覽器的廣告)。

截至本文撰寫時,UC 瀏覽器在 Google Play 上的安裝量已超過 500 億。 這令人印象深刻 - 只有 Google Chrome 擁有更多。 在評論中,您可以看到很多關於廣告和重定向到 Google Play 上某些應用程式的投訴。 這就是我們進行研究的原因:我們決定看看 UC 瀏覽器是否做了壞事。 事實證明他確實如此!

在應用程式程式碼中,發現了下載和運行可執行程式碼的能力, 這違反了發布應用程式的規則 在 Google Play 上。 除了下載可執行程式碼之外,UC 瀏覽器還以不安全的方式執行此操作,可用於發動 MitM 攻擊。 讓我們看看是否可以進行這樣的攻擊。

以下寫的所有內容都與研究時 Google Play 上提供的 UC 瀏覽器版本相關:

package: com.UCMobile.intl
versionName: 12.10.8.1172
versionCode: 10598
sha1 APK-файла: f5edb2243413c777172f6362876041eb0c3a928c

攻擊向量

在 UC 瀏覽器清單中,您可以找到名稱不言自明的服務 com.uc.deployment.UpgradeDeployService.

    <service android_exported="false" android_name="com.uc.deployment.UpgradeDeployService" android_process=":deploy" />

當該服務啟動時,瀏覽器會向 puds.ucweb.com/upgrade/index.xhtml,可以在開始後一段時間在交通中看到。 作為回應,他可能會收到下載某些更新或新模組的命令。 在分析過程中,伺服器沒有發出此類命令,但我們注意到,當我們嘗試在瀏覽器中開啟 PDF 時,它會向上面指定的位址發出第二次請求,然後下載本機庫。 為了進行攻擊,我們決定使用 UC 瀏覽器的這項功能:能夠使用本機庫開啟 PDF,該程式庫不在 APK 中,需要時可以從網路下載。 值得注意的是,理論上,如果您對瀏覽器啟動後執行的請求提供格式良好的回應,則可以在沒有使用者互動的情況下強制 UC 瀏覽器下載某些內容。 但要做到這一點,我們需要更詳細地研究與伺服器互動的協議,因此我們決定編輯截獲的回應並替換用於處理 PDF 的程式庫會更容易。

因此,當使用者想要直接在瀏覽器中開啟 PDF 時,可以在流量中看到以下請求:

尋找UC瀏覽器的漏洞

首先有一個 POST 請求 puds.ucweb.com/upgrade/index.xhtml, 然後
下載帶有用於查看 PDF 和 Office 格式的庫的存檔。 假設第一個請求傳輸有關係統的資訊(至少是提供所需庫的體系結構)是合乎邏輯的,並且作為回應,瀏覽器會收到有關需要下載的庫的一些資訊:地址以及可能的資訊。 ,其他的東西。 問題是這個請求是加密的。

請求片段

答案片段

尋找UC瀏覽器的漏洞

尋找UC瀏覽器的漏洞

該庫本身打包在 ZIP 中並且未加密。

尋找UC瀏覽器的漏洞

搜尋流量解密代碼

讓我們嘗試破解伺服器回應。 我們來看看類別代碼 com.uc.deployment.UpgradeDeployService:來自方法 啟動命令com.uc.deployment.bx,並從它到 com.uc.browser.core.dcfe:

    public final void e(l arg9) {
int v4_5;
String v3_1;
byte[] v3;
byte[] v1 = null;
if(arg9 == null) {
v3 = v1;
}
else {
v3_1 = arg9.iGX.ipR;
StringBuilder v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]product:");
v4.append(arg9.iGX.ipR);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]version:");
v4.append(arg9.iGX.iEn);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]upgrade_type:");
v4.append(arg9.iGX.mMode);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]force_flag:");
v4.append(arg9.iGX.iEo);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]silent_mode:");
v4.append(arg9.iGX.iDQ);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]silent_type:");
v4.append(arg9.iGX.iEr);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]silent_state:");
v4.append(arg9.iGX.iEp);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]silent_file:");
v4.append(arg9.iGX.iEq);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apk_md5:");
v4.append(arg9.iGX.iEl);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]download_type:");
v4.append(arg9.mDownloadType);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]download_group:");
v4.append(arg9.mDownloadGroup);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]download_path:");
v4.append(arg9.iGH);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_child_version:");
v4.append(arg9.iGX.iEx);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_series:");
v4.append(arg9.iGX.iEw);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_cpu_arch:");
v4.append(arg9.iGX.iEt);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_cpu_vfp3:");
v4.append(arg9.iGX.iEv);
v4 = new StringBuilder("[");
v4.append(v3_1);
v4.append("]apollo_cpu_vfp:");
v4.append(arg9.iGX.iEu);
ArrayList v3_2 = arg9.iGX.iEz;
if(v3_2 != null && v3_2.size() != 0) {
Iterator v3_3 = v3_2.iterator();
while(v3_3.hasNext()) {
Object v4_1 = v3_3.next();
StringBuilder v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]component_name:");
v5.append(((au)v4_1).getName());
v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]component_ver_name:");
v5.append(((au)v4_1).aDA());
v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]component_ver_code:");
v5.append(((au)v4_1).gBl);
v5 = new StringBuilder("[");
v5.append(((au)v4_1).getName());
v5.append("]component_req_type:");
v5.append(((au)v4_1).gBq);
}
}
j v3_4 = new j();
m.b(v3_4);
h v4_2 = new h();
m.b(v4_2);
ay v5_1 = new ay();
v3_4.hS("");
v3_4.setImsi("");
v3_4.hV("");
v5_1.bPQ = v3_4;
v5_1.bPP = v4_2;
v5_1.yr(arg9.iGX.ipR);
v5_1.gBF = arg9.iGX.mMode;
v5_1.gBI = arg9.iGX.iEz;
v3_2 = v5_1.gAr;
c.aBh();
v3_2.add(g.fs("os_ver", c.getRomInfo()));
v3_2.add(g.fs("processor_arch", com.uc.b.a.a.c.getCpuArch()));
v3_2.add(g.fs("cpu_arch", com.uc.b.a.a.c.Pb()));
String v4_3 = com.uc.b.a.a.c.Pd();
v3_2.add(g.fs("cpu_vfp", v4_3));
v3_2.add(g.fs("net_type", String.valueOf(com.uc.base.system.a.Jo())));
v3_2.add(g.fs("fromhost", arg9.iGX.iEm));
v3_2.add(g.fs("plugin_ver", arg9.iGX.iEn));
v3_2.add(g.fs("target_lang", arg9.iGX.iEs));
v3_2.add(g.fs("vitamio_cpu_arch", arg9.iGX.iEt));
v3_2.add(g.fs("vitamio_vfp", arg9.iGX.iEu));
v3_2.add(g.fs("vitamio_vfp3", arg9.iGX.iEv));
v3_2.add(g.fs("plugin_child_ver", arg9.iGX.iEx));
v3_2.add(g.fs("ver_series", arg9.iGX.iEw));
v3_2.add(g.fs("child_ver", r.aVw()));
v3_2.add(g.fs("cur_ver_md5", arg9.iGX.iEl));
v3_2.add(g.fs("cur_ver_signature", SystemHelper.getUCMSignature()));
v3_2.add(g.fs("upgrade_log", i.bjt()));
v3_2.add(g.fs("silent_install", String.valueOf(arg9.iGX.iDQ)));
v3_2.add(g.fs("silent_state", String.valueOf(arg9.iGX.iEp)));
v3_2.add(g.fs("silent_file", arg9.iGX.iEq));
v3_2.add(g.fs("silent_type", String.valueOf(arg9.iGX.iEr)));
v3_2.add(g.fs("cpu_archit", com.uc.b.a.a.c.Pc()));
v3_2.add(g.fs("cpu_set", SystemHelper.getCpuInstruction()));
boolean v4_4 = v4_3 == null || !v4_3.contains("neon") ? false : true;
v3_2.add(g.fs("neon", String.valueOf(v4_4)));
v3_2.add(g.fs("cpu_cores", String.valueOf(com.uc.b.a.a.c.Jl())));
v3_2.add(g.fs("ram_1", String.valueOf(com.uc.b.a.a.h.Po())));
v3_2.add(g.fs("totalram", String.valueOf(com.uc.b.a.a.h.OL())));
c.aBh();
v3_2.add(g.fs("rom_1", c.getRomInfo()));
v4_5 = e.getScreenWidth();
int v6 = e.getScreenHeight();
StringBuilder v7 = new StringBuilder();
v7.append(v4_5);
v7.append("*");
v7.append(v6);
v3_2.add(g.fs("ss", v7.toString()));
v3_2.add(g.fs("api_level", String.valueOf(Build$VERSION.SDK_INT)));
v3_2.add(g.fs("uc_apk_list", SystemHelper.getUCMobileApks()));
Iterator v4_6 = arg9.iGX.iEA.entrySet().iterator();
while(v4_6.hasNext()) {
Object v6_1 = v4_6.next();
v3_2.add(g.fs(((Map$Entry)v6_1).getKey(), ((Map$Entry)v6_1).getValue()));
}
v3 = v5_1.toByteArray();
}
if(v3 == null) {
this.iGY.iGI.a(arg9, "up_encode", "yes", "fail");
return;
}
v4_5 = this.iGY.iGw ? 0x1F : 0;
if(v3 == null) {
}
else {
v3 = g.i(v4_5, v3);
if(v3 == null) {
}
else {
v1 = new byte[v3.length + 16];
byte[] v6_2 = new byte[16];
Arrays.fill(v6_2, 0);
v6_2[0] = 0x5F;
v6_2[1] = 0;
v6_2[2] = ((byte)v4_5);
v6_2[3] = -50;
System.arraycopy(v6_2, 0, v1, 0, 16);
System.arraycopy(v3, 0, v1, 16, v3.length);
}
}
if(v1 == null) {
this.iGY.iGI.a(arg9, "up_encrypt", "yes", "fail");
return;
}
if(TextUtils.isEmpty(this.iGY.mUpgradeUrl)) {
this.iGY.iGI.a(arg9, "up_url", "yes", "fail");
return;
}
StringBuilder v0 = new StringBuilder("[");
v0.append(arg9.iGX.ipR);
v0.append("]url:");
v0.append(this.iGY.mUpgradeUrl);
com.uc.browser.core.d.c.i v0_1 = this.iGY.iGI;
v3_1 = this.iGY.mUpgradeUrl;
com.uc.base.net.e v0_2 = new com.uc.base.net.e(new com.uc.browser.core.d.c.i$a(v0_1, arg9));
v3_1 = v3_1.contains("?") ? v3_1 + "&dataver=pb" : v3_1 + "?dataver=pb";
n v3_5 = v0_2.uc(v3_1);
m.b(v3_5, false);
v3_5.setMethod("POST");
v3_5.setBodyProvider(v1);
v0_2.b(v3_5);
this.iGY.iGI.a(arg9, "up_null", "yes", "success");
this.iGY.iGI.b(arg9);
}

我們在這裡看到 POST 請求的形成。 我們注意16位元組數組的建立及其填充:0x5F、0、0x1F、-50(=0xCE)。 與我們在上面的請求中看到的一致。

在同一個類別中,您可以看到一個嵌套類,它有另一個有趣的方法:

        public final void a(l arg10, byte[] arg11) {
f v0 = this.iGQ;
StringBuilder v1 = new StringBuilder("[");
v1.append(arg10.iGX.ipR);
v1.append("]:UpgradeSuccess");
byte[] v1_1 = null;
if(arg11 == null) {
}
else if(arg11.length < 16) {
}
else {
if(arg11[0] != 0x60 && arg11[3] != 0xFFFFFFD0) {
goto label_57;
}
int v3 = 1;
int v5 = arg11[1] == 1 ? 1 : 0;
if(arg11[2] != 1 && arg11[2] != 11) {
if(arg11[2] == 0x1F) {
}
else {
v3 = 0;
}
}
byte[] v7 = new byte[arg11.length - 16];
System.arraycopy(arg11, 16, v7, 0, v7.length);
if(v3 != 0) {
v7 = g.j(arg11[2], v7);
}
if(v7 == null) {
goto label_57;
}
if(v5 != 0) {
v1_1 = g.P(v7);
goto label_57;
}
v1_1 = v7;
}
label_57:
if(v1_1 == null) {
v0.iGY.iGI.a(arg10, "up_decrypt", "yes", "fail");
return;
}
q v11 = g.b(arg10, v1_1);
if(v11 == null) {
v0.iGY.iGI.a(arg10, "up_decode", "yes", "fail");
return;
}
if(v0.iGY.iGt) {
v0.d(arg10);
}
if(v0.iGY.iGo != null) {
v0.iGY.iGo.a(0, ((o)v11));
}
if(v0.iGY.iGs) {
v0.iGY.a(((o)v11));
v0.iGY.iGI.a(v11, "up_silent", "yes", "success");
v0.iGY.iGI.a(v11);
return;
}
v0.iGY.iGI.a(v11, "up_silent", "no", "success");
}
}

此方法以位元組數組為輸入,並檢查零位元組是否為 0x60 或第三個位元組是否為 0xD0,第二個位元組是否為 1、11 或 0x1F。 我們來看看伺服器的回應:第0個位元組是60x0,第二個是1x0F,第三個是60xXNUMX。 聽起來像我們需要的。 從這些行(例如“up_decrypt”)來看,這裡應該呼叫一個方法來解密伺服器的回應。
讓我們繼續討論方法 吉傑。 請注意,第一個參數是偏移量 2 處的位元組(即本例中的 0x1F),第二個參數是不帶任何內容的伺服器回應
前 16 個位元組。

     public static byte[] j(int arg1, byte[] arg2) {
if(arg1 == 1) {
arg2 = c.c(arg2, c.adu);
}
else if(arg1 == 11) {
arg2 = m.aF(arg2);
}
else if(arg1 != 0x1F) {
}
else {
arg2 = EncryptHelper.decrypt(arg2);
}
return arg2;
}

顯然,這裡我們選擇一種解密演算法,並且與我們的解密演算法中的相同位元組
case 等於 0x1F,表示三個可能選項之一。

我們繼續分析程式碼。 經過幾次跳轉後,我們發現自己處於一個具有不言自明的名稱的方法中 透過金鑰解密位元組.

這裡,我們的回應中又分離出了兩個位元組,並從中取得了一個字串。 顯然,透過這種方式選擇了用於解密訊息的金鑰。

    private static byte[] decryptBytesByKey(byte[] bytes) {
byte[] v0 = null;
if(bytes != null) {
try {
if(bytes.length < EncryptHelper.PREFIX_BYTES_SIZE) {
}
else if(bytes.length == EncryptHelper.PREFIX_BYTES_SIZE) {
return v0;
}
else {
byte[] prefix = new byte[EncryptHelper.PREFIX_BYTES_SIZE];  // 2 байта
System.arraycopy(bytes, 0, prefix, 0, prefix.length);
String keyId = c.ayR().d(ByteBuffer.wrap(prefix).getShort()); // Выбор ключа
if(keyId == null) {
return v0;
}
else {
a v2 = EncryptHelper.ayL();
if(v2 == null) {
return v0;
}
else {
byte[] enrypted = new byte[bytes.length - EncryptHelper.PREFIX_BYTES_SIZE];
System.arraycopy(bytes, EncryptHelper.PREFIX_BYTES_SIZE, enrypted, 0, enrypted.length);
return v2.l(keyId, enrypted);
}
}
}
}
catch(SecException v7_1) {
EncryptHelper.handleDecryptException(((Throwable)v7_1), v7_1.getErrorCode());
return v0;
}
catch(Throwable v7) {
EncryptHelper.handleDecryptException(v7, 2);
return v0;
}
}
return v0;
}

展望未來,我們注意到,現階段我們尚未獲得密鑰,而僅獲得其「標識符」。 取得鑰匙有點複雜。

在下一個方法中,在現有參數的基礎上添加了兩個參數,使其成為四個參數:幻數16、密鑰標識符、加密資料和難以理解的字串(在我們的例子中為空)。

    public final byte[] l(String keyId, byte[] encrypted) throws SecException {
return this.ayJ().staticBinarySafeDecryptNoB64(16, keyId, encrypted, "");
}

經過一系列轉換後,我們得到了該方法 staticBinarySafeDecryptNoB64 介面 com.alibaba.wireless.security.open.staticdataencrypt.IStaticDataEncryptComponent。 主應用程式程式碼中沒有實作此介面的類別。 文件中有這樣一個類別 lib/armeabi-v7a/libsgmain.so,它實際上不是 .so,而是 .jar。 我們感興趣的方法實現如下:

package com.alibaba.wireless.security.a.i;
// ...
public class a implements IStaticDataEncryptComponent {
private ISecurityGuardPlugin a;
// ...
private byte[] a(int mode, int magicInt, int xzInt, String keyId, byte[] encrypted, String magicString) {
return this.a.getRouter().doCommand(10601, new Object[]{Integer.valueOf(mode), Integer.valueOf(magicInt), Integer.valueOf(xzInt), keyId, encrypted, magicString});
}
// ...
private byte[] b(int magicInt, String keyId, byte[] encrypted, String magicString) {
return this.a(2, magicInt, 0, keyId, encrypted, magicString);
}
// ...
public byte[] staticBinarySafeDecryptNoB64(int magicInt, String keyId, byte[] encrypted, String magicString) throws SecException {
if(keyId != null && keyId.length() > 0 && magicInt >= 0 && magicInt < 19 && encrypted != null && encrypted.length > 0) {
return this.b(magicInt, keyId, encrypted, magicString);
}
throw new SecException("", 301);
}
//...
}

這裡我們的參數列表又補充了兩個整數:2 和 0。
一切,2表示解密,如方法所示 最終結果 系統類 javax.crypto.Cipher。 所有這些都傳送到某個編號為 10601 的路由器 - 這顯然是命令編號。

在下一個轉換鏈之後,我們找到一個實作該介面的類 IRouter組件 和方法 執行命令:

package com.alibaba.wireless.security.mainplugin;
import com.alibaba.wireless.security.framework.IRouterComponent;
import com.taobao.wireless.security.adapter.JNICLibrary;
public class a implements IRouterComponent {
public a() {
super();
}
public Object doCommand(int arg2, Object[] arg3) {
return JNICLibrary.doCommandNative(arg2, arg3);
}
}

還有課 JNIC庫,其中聲明了本機方法 doCommandNative:

package com.taobao.wireless.security.adapter;
public class JNICLibrary {
public static native Object doCommandNative(int arg0, Object[] arg1);
}

這意味著我們需要在本機程式碼中找到一個方法 doCommandNative。 這就是樂趣的開始。

機器碼混淆

在文件中 libsgmain.so (實際上是一個 .jar,我們在其中找到了上面一些與加密相關的介面的實作)有一個本機庫: libsgmainso-6.4.36.so。 我們在 IDA 中打開它,看到一堆有錯誤的對話框。 問題是節頭表無效。 這樣做的目的是為了讓分析變得複雜。

尋找UC瀏覽器的漏洞

但這不是必需的:要正確載入 ELF 檔案並對其進行分析,程式頭表就足夠了。 因此,我們只需刪除節表,將標頭中的相應欄位清零即可。

尋找UC瀏覽器的漏洞

再次在 IDA 中開啟該檔案。

有兩種方法可以告訴 Java 虛擬機器在 Java 程式碼中聲明為本機的方法的實作在本機庫中的確切位置。 首先是給它一個物種名稱 Java_package_name_ClassName_MethodName.

第二種是在載入庫時註冊它(在函數中 JNI_載入)
使用函數調用 註冊本地人.

在我們的例子中,如果我們使用第一種方法,名稱應該是這樣的: java_com_taobao_wireless_security_adapter_JNICLibrary_doCommandNative.

導出的函數中沒有這個函數,也就是說需要尋找調用 註冊本地人.
讓我們進入函數 JNI_載入 我們看到這張圖:

尋找UC瀏覽器的漏洞

這裡發生了什麼事? 乍一看,函數的開始和結束是 ARM 架構的典型特徵。 堆疊上的第一條指令儲存函數在其操作中將使用的暫存器的內容(在本例中為 R0、R1 和 R2),以及 LR 暫存器的內容,其中包含函數的回傳位址。 最後一條指令恢復已儲存的暫存器,並且返回位址立即放入 PC 暫存器中 - 從而從函數返回。 但如果仔細觀察,您會注意到倒數第二條指令更改了堆疊中儲存的回傳位址。 我們來計算一下之後會是什麼樣子
代碼執行。 將某個位址1xB0載入到R130中,從中減去5,然後將其傳輸到R0中,並加入0x10。 結果是0xB13B。 因此,IDA認為最後一條指令是正常的函數返回,但實際上它是指向計算出的位址0xB13B。

這裡值得回顧的是,ARM 處理器有兩種模式和兩組指令:ARM 和 Thumb。 位址的最低有效位元告訴處理器正在使用哪個指令集。 即位址實際上是0xB13A,最低有效位元為XNUMX表示Thumb模式。

類似的“適配器”已添加到該庫中每個函數的開頭,並且
垃圾代碼。 我們不會進一步詳細討論它們 - 我們只是記住
幾乎所有功能的真正開始都有點遙遠。

由於程式碼沒有明確跳到 0xB13A,IDA 本身無法辨識代碼位於該位置。 出於同樣的原因,它不會將庫中的大部分程式碼識別為程式碼,這使得分析有些困難。 我們告訴 IDA,這就是程式碼,並且發生的情況如下:

尋找UC瀏覽器的漏洞

該表顯然是從 0xB144 開始的。 sub_494C 裡有什麼?

尋找UC瀏覽器的漏洞

當在LR暫存器中呼叫這個函數時,我們得到了前面提到的表格的位址(0xB144)。 在 R0 中 - 該表中的索引。 即從表中取出數值,與LR相加,結果為
要前往的地址。 讓我們來計算一下:0xB144 + [0xB144 + 8* 4] = 0xB144 + 0x120 = 0xB264。 我們轉到收到的位址,看到一些有用的指令,然後再轉到 0xB140:

尋找UC瀏覽器的漏洞

現在將在表中索引 0x20 的偏移處發生轉換。

從表的大小來看,程式碼中會有很多這樣的轉換。 問題是是否有可能以某種方式更自動地處理這個問題,而無需手動計算地址。 腳本和在 IDA 中修補程式碼的能力可以幫助我們:

def put_unconditional_branch(source, destination):
offset = (destination - source - 4) >> 1
if offset > 2097151 or offset < -2097152:
raise RuntimeError("Invalid offset")
if offset > 1023 or offset < -1024:
instruction1 = 0xf000 | ((offset >> 11) & 0x7ff)
instruction2 = 0xb800 | (offset & 0x7ff)
patch_word(source, instruction1)
patch_word(source + 2, instruction2)
else:
instruction = 0xe000 | (offset & 0x7ff)
patch_word(source, instruction)
ea = here()
if get_wide_word(ea) == 0xb503: #PUSH {R0,R1,LR}
ea1 = ea + 2
if get_wide_word(ea1) == 0xbf00: #NOP
ea1 += 2
if get_operand_type(ea1, 0) == 1 and get_operand_value(ea1, 0) == 0 and get_operand_type(ea1, 1) == 2:
index = get_wide_dword(get_operand_value(ea1, 1))
print "index =", hex(index)
ea1 += 2
if get_operand_type(ea1, 0) == 7:
table = get_operand_value(ea1, 0) + 4
elif get_operand_type(ea1, 1) == 2:
table = get_operand_value(ea1, 1) + 4
else:
print "Wrong operand type on", hex(ea1), "-", get_operand_type(ea1, 0), get_operand_type(ea1, 1)
table = None
if table is None:
print "Unable to find table"
else:
print "table =", hex(table)
offset = get_wide_dword(table + (index << 2))
put_unconditional_branch(ea, table + offset)
else:
print "Unknown code", get_operand_type(ea1, 0), get_operand_value(ea1, 0), get_operand_type(ea1, 1) == 2
else:
print "Unable to detect first instruction"

將遊標放在第 0xB26A 行,執行腳本並查看到 0xB4B0 的轉換:

尋找UC瀏覽器的漏洞

IDA 再次未將該區域識別為代碼。 我們幫助她並在那裡看到了另一種設計:

尋找UC瀏覽器的漏洞

BLX之後的指令看起來沒有太大意義,更像是某種位移。 讓我們看看 sub_4964:

尋找UC瀏覽器的漏洞

事實上,這裡在 LR 中的位址處取出一個雙字,加到該位址,然後取出結果位址處的值並將其放入堆疊中。 此外,LR 中還添加了 4,以便從函數返回後,會跳過相同的偏移量。 之後 POP {R1} 指令從堆疊中取得結果值。 如果您查看位址 0xB4BA + 0xEA = 0xB5A4 處的內容,您將看到類似位址表的內容:

尋找UC瀏覽器的漏洞

要修補此設計,您需要從程式碼中取得兩個參數:偏移量和要在其中放置結果的暫存器編號。 對於每個可能的暫存器,您都必須提前準備一段程式碼。

patches = {}
patches[0] = (0x00, 0xbf, 0x01, 0x48, 0x00, 0x68, 0x02, 0xe0)
patches[1] = (0x00, 0xbf, 0x01, 0x49, 0x09, 0x68, 0x02, 0xe0)
patches[2] = (0x00, 0xbf, 0x01, 0x4a, 0x12, 0x68, 0x02, 0xe0)
patches[3] = (0x00, 0xbf, 0x01, 0x4b, 0x1b, 0x68, 0x02, 0xe0)
patches[4] = (0x00, 0xbf, 0x01, 0x4c, 0x24, 0x68, 0x02, 0xe0)
patches[5] = (0x00, 0xbf, 0x01, 0x4d, 0x2d, 0x68, 0x02, 0xe0)
patches[8] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0x80, 0xd8, 0xf8, 0x00, 0x80, 0x01, 0xe0)
patches[9] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0x90, 0xd9, 0xf8, 0x00, 0x90, 0x01, 0xe0)
patches[10] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0xa0, 0xda, 0xf8, 0x00, 0xa0, 0x01, 0xe0)
patches[11] = (0x00, 0xbf, 0xdf, 0xf8, 0x06, 0xb0, 0xdb, 0xf8, 0x00, 0xb0, 0x01, 0xe0)
ea = here()
if (get_wide_word(ea) == 0xb082 #SUB SP, SP, #8
and get_wide_word(ea + 2) == 0xb503): #PUSH {R0,R1,LR}
if get_operand_type(ea + 4, 0) == 7:
pop = get_bytes(ea + 12, 4, 0)
if pop[1] == 'xbc':
register = -1
r = get_wide_byte(ea + 12)
for i in range(8):
if r == (1 << i):
register = i
break
if register == -1:
print "Unable to detect register"
else:
address = get_wide_dword(ea + 8) + ea + 8
for b in patches[register]:
patch_byte(ea, b)
ea += 1
if ea % 4 != 0:
ea += 2
patch_dword(ea, address)
elif pop[:3] == 'x5dxf8x04':
register = ord(pop[3]) >> 4
if register in patches:
address = get_wide_dword(ea + 8) + ea + 8
for b in patches[register]:
patch_byte(ea, b)
ea += 1
patch_dword(ea, address)
else:
print "POP instruction not found"
else:
print "Wrong operand type on +4:", get_operand_type(ea + 4, 0)
else:
print "Unable to detect first instructions"

我們將遊標放在要替換的結構的開頭 - 0xB4B2 - 並執行腳本:

尋找UC瀏覽器的漏洞

除了已經提到的結構之外,程式碼還包含以下內容:

尋找UC瀏覽器的漏洞

與前面的情況一樣,BLX 指令之後有一個偏移量:

尋找UC瀏覽器的漏洞

我們從 LR 中取出地址的偏移量,將其添加到 LR 中並轉到那裡。 0x72044 + 0xC = 0x72050。 這個設計的腳本非常簡單:

def put_unconditional_branch(source, destination):
offset = (destination - source - 4) >> 1
if offset > 2097151 or offset < -2097152:
raise RuntimeError("Invalid offset")
if offset > 1023 or offset < -1024:
instruction1 = 0xf000 | ((offset >> 11) & 0x7ff)
instruction2 = 0xb800 | (offset & 0x7ff)
patch_word(source, instruction1)
patch_word(source + 2, instruction2)
else:
instruction = 0xe000 | (offset & 0x7ff)
patch_word(source, instruction)
ea = here()
if get_wide_word(ea) == 0xb503: #PUSH {R0,R1,LR}
ea1 = ea + 6
if get_wide_word(ea + 2) == 0xbf00: #NOP
ea1 += 2
offset = get_wide_dword(ea1)
put_unconditional_branch(ea, (ea1 + offset) & 0xffffffff)
else:
print "Unable to detect first instruction"

腳本執行結果:

尋找UC瀏覽器的漏洞

一旦函數中的所有內容都被修補,您就可以將 IDA 指向其真正的開始。 它將所有函數程式碼拼湊在一起,並且可以使用 HexRays 進行反編譯。

解碼字串

我們已經學會如何處理庫中機器碼的混淆 libsgmainso-6.4.36.so 從 UC 瀏覽器收到功能碼 JNI_載入.

int __fastcall real_JNI_OnLoad(JavaVM *vm)
{
int result; // r0
jclass clazz; // r0 MAPDST
int v4; // r0
JNIEnv *env; // r4
int v6; // [sp-40h] [bp-5Ch]
int v7; // [sp+Ch] [bp-10h]
v7 = *(_DWORD *)off_8AC00;
if ( !vm )
goto LABEL_39;
sub_7C4F4();
env = (JNIEnv *)sub_7C5B0(0);
if ( !env )
goto LABEL_39;
v4 = sub_72CCC();
sub_73634(v4);
sub_73E24(&unk_83EA6, &v6, 49);
clazz = (jclass)((int (__fastcall *)(JNIEnv *, int *))(*env)->FindClass)(env, &v6);
if ( clazz
&& (sub_9EE4(),
sub_71D68(env),
sub_E7DC(env) >= 0
&& sub_69D68(env) >= 0
&& sub_197B4(env, clazz) >= 0
&& sub_E240(env, clazz) >= 0
&& sub_B8B0(env, clazz) >= 0
&& sub_5F0F4(env, clazz) >= 0
&& sub_70640(env, clazz) >= 0
&& sub_11F3C(env) >= 0
&& sub_21C3C(env, clazz) >= 0
&& sub_2148C(env, clazz) >= 0
&& sub_210E0(env, clazz) >= 0
&& sub_41B58(env, clazz) >= 0
&& sub_27920(env, clazz) >= 0
&& sub_293E8(env, clazz) >= 0
&& sub_208F4(env, clazz) >= 0) )
{
result = (sub_B7B0(env, clazz) >> 31) | 0x10004;
}
else
{
LABEL_39:
result = -1;
}
return result;
}

讓我們仔細看看以下幾行:

  sub_73E24(&unk_83EA6, &v6, 49);
clazz = (jclass)((int (__fastcall *)(JNIEnv *, int *))(*env)->FindClass)(env, &v6);

功能中 子_73E24 類名顯然正在被解密。 作為此函數的參數,傳遞類似加密資料的資料指標、特定緩衝區和數字。 顯然,在呼叫該函數後,緩衝區中將有一個解密的行,因為它被傳遞給函數 查找類別,它將類別名稱作為第二個參數。 因此,該數字是緩衝區的大小或行的長度。 讓我們試著破解類名,它應該告訴我們我們是否朝著正確的方向前進。 讓我們仔細看看發生了什麼 sub_73E24。

int __fastcall sub_73E56(unsigned __int8 *in, unsigned __int8 *out, size_t size)
{
int v4; // r6
int v7; // r11
int v8; // r9
int v9; // r4
size_t v10; // r5
int v11; // r0
struc_1 v13; // [sp+0h] [bp-30h]
int v14; // [sp+1Ch] [bp-14h]
int v15; // [sp+20h] [bp-10h]
v4 = 0;
v15 = *(_DWORD *)off_8AC00;
v14 = 0;
v7 = sub_7AF78(17);
v8 = sub_7AF78(size);
if ( !v7 )
{
v9 = 0;
goto LABEL_12;
}
(*(void (__fastcall **)(int, const char *, int))(v7 + 12))(v7, "DcO/lcK+h?m3c*q@", 16);
if ( !v8 )
{
LABEL_9:
v4 = 0;
goto LABEL_10;
}
v4 = 0;
if ( !in )
{
LABEL_10:
v9 = 0;
goto LABEL_11;
}
v9 = 0;
if ( out )
{
memset(out, 0, size);
v10 = size - 1;
(*(void (__fastcall **)(int, unsigned __int8 *, size_t))(v8 + 12))(v8, in, v10);
memset(&v13, 0, 0x14u);
v13.field_4 = 3;
v13.field_10 = v7;
v13.field_14 = v8;
v11 = sub_6115C(&v13, &v14);
v9 = v11;
if ( v11 )
{
if ( *(_DWORD *)(v11 + 4) == v10 )
{
qmemcpy(out, *(const void **)v11, v10);
v4 = *(_DWORD *)(v9 + 4);
}
else
{
v4 = 0;
}
goto LABEL_11;
}
goto LABEL_9;
}
LABEL_11:
sub_7B148(v7);
LABEL_12:
if ( v8 )
sub_7B148(v8);
if ( v9 )
sub_7B148(v9);
return v4;
}

功能 子_7AF78 為指定大小的位元組陣列建立一個容器實例(我們不會詳細討論這些容器)。 這裡創建了兩個這樣的容器:一個包含行 “DcO/lcK+h?m3c*q@” (很容易猜到這是密鑰),另一個包含加密資料。 接下來,這兩個物件都被放置在某個結構中,該結構被傳遞給函數 子_6115C。 我們在這個結構體中也標記一個值為3的字段,看看接下來這個結構體會發生什麼。

int __fastcall sub_611B4(struc_1 *a1, _DWORD *a2)
{
int v3; // lr
unsigned int v4; // r1
int v5; // r0
int v6; // r1
int result; // r0
int v8; // r0
*a2 = 820000;
if ( a1 )
{
v3 = a1->field_14;
if ( v3 )
{
v4 = a1->field_4;
if ( v4 < 0x19 )
{
switch ( v4 )
{
case 0u:
v8 = sub_6419C(a1->field_0, a1->field_10, v3);
goto LABEL_17;
case 3u:
v8 = sub_6364C(a1->field_0, a1->field_10, v3);
goto LABEL_17;
case 0x10u:
case 0x11u:
case 0x12u:
v8 = sub_612F4(
a1->field_0,
v4,
*(_QWORD *)&a1->field_8,
*(_QWORD *)&a1->field_8 >> 32,
a1->field_10,
v3,
a2);
goto LABEL_17;
case 0x14u:
v8 = sub_63A28(a1->field_0, v3);
goto LABEL_17;
case 0x15u:
sub_61A60(a1->field_0, v3, a2);
return result;
case 0x16u:
v8 = sub_62440(a1->field_14);
goto LABEL_17;
case 0x17u:
v8 = sub_6226C(a1->field_10, v3);
goto LABEL_17;
case 0x18u:
v8 = sub_63530(a1->field_14);
LABEL_17:
v6 = 0;
if ( v8 )
{
*a2 = 0;
v6 = v8;
}
return v6;
default:
LOWORD(v5) = 28032;
goto LABEL_5;
}
}
}
}
LOWORD(v5) = -27504;
LABEL_5:
HIWORD(v5) = 13;
v6 = 0;
*a2 = v5;
return v6;
}

switch參數是一個結構體字段,之前被賦值為3。看情況3:給函數 子_6364C 參數是從上一個函數中添加的結構傳遞的,即密鑰和加密資料。 如果你仔細觀察 子_6364C,你可以認出裡面的RC4演算法。

我們有一個演算法和一個金鑰。 讓我們嘗試破解類名。 事情是這樣的: com/taobao/wireless/security/adapter/JNICLibrary。 偉大的! 我們走在正確的軌道上。

命令樹

現在我們需要找到一個挑戰 註冊本地人,這將向我們指出該函數 doCommandNative。 讓我們看看呼叫的函數 JNI_OnLoad, 我們發現它在 子_B7B0:

int __fastcall sub_B7F6(JNIEnv *env, jclass clazz)
{
char signature[41]; // [sp+7h] [bp-55h]
char name[16]; // [sp+30h] [bp-2Ch]
JNINativeMethod method; // [sp+40h] [bp-1Ch]
int v8; // [sp+4Ch] [bp-10h]
v8 = *(_DWORD *)off_8AC00;
decryptString((unsigned __int8 *)&unk_83ED9, (unsigned __int8 *)name, 0x10u);// doCommandNative
decryptString((unsigned __int8 *)&unk_83EEA, (unsigned __int8 *)signature, 0x29u);// (I[Ljava/lang/Object;)Ljava/lang/Object;
method.name = name;
method.signature = signature;
method.fnPtr = sub_B69C;
return ((int (__fastcall *)(JNIEnv *, jclass, JNINativeMethod *, int))(*env)->RegisterNatives)(env, clazz, &method, 1) >> 31;
}

事實上,這裡註冊了一個具有該名稱的本機方法 doCommandNative。 現在我們知道他的地址了。 讓我們看看他做了什麼。

int __fastcall doCommandNative(JNIEnv *env, jobject obj, int command, jarray args)
{
int v5; // r5
struc_2 *a5; // r6
int v9; // r1
int v11; // [sp+Ch] [bp-14h]
int v12; // [sp+10h] [bp-10h]
v5 = 0;
v12 = *(_DWORD *)off_8AC00;
v11 = 0;
a5 = (struc_2 *)malloc(0x14u);
if ( a5 )
{
a5->field_0 = 0;
a5->field_4 = 0;
a5->field_8 = 0;
a5->field_C = 0;
v9 = command % 10000 / 100;
a5->field_0 = command / 10000;
a5->field_4 = v9;
a5->field_8 = command % 100;
a5->field_C = env;
a5->field_10 = args;
v5 = sub_9D60(command / 10000, v9, command % 100, 1, (int)a5, &v11);
}
free(a5);
if ( !v5 && v11 )
sub_7CF34(env, v11, &byte_83ED7);
return v5;
}

從名稱你可以猜到,這裡是開發人員決定轉移到本機函式庫的所有函數的入口點。 我們對 10601 號函數感興趣。

從程式碼可以看到指令number產生了三個數字: 命令/10000, 命令% 10000 / 100 и 命令%10,即在我們的例子中為 1、6 和 1。這三個數字以及指向 JNI環境 傳遞給函數的參數被加到結構中並繼續傳遞。 使用獲得的三個數字(讓我們將它們表示為 N1、N2 和 N3),建立命令樹。

像這樣的東西:

尋找UC瀏覽器的漏洞

樹是動態填充的 JNI_載入.
三個數字編碼樹中的路徑。 樹的每個葉子都包含對應函數的 pocked 位址。 關鍵在父節點中。 如果您了解所使用的所有結構,那麼在程式碼中找到將我們需要的函數添加到樹中的位置並不困難(我們不會描述它們,以免使已經相當大的文章變得臃腫)。

更多混淆

我們收到了應該解密流量的函數的位址:0x5F1AC。 但現在高興還為時過早:UC瀏覽器的開發者又為我們準備了另一個驚喜。

從Java程式碼中形成的陣列接收參數後,我們得到
到位址 0x4D070 處的函數。 這裡還有另一種類型的程式碼混淆等著我們。

我們在 R7 和 R4 中放置兩個索引:

尋找UC瀏覽器的漏洞

我們將第一個索引移至 R11:

尋找UC瀏覽器的漏洞

若要從表格中取得位址,請使用索引:

尋找UC瀏覽器的漏洞

轉到第一個位址後,使用第二個索引,該索引位於 R4 中。 表中有230個元素。

怎麼辦? 您可以告訴 IDA 這是一個開關:編輯 -> 其他 -> 指定開關習慣用法。

尋找UC瀏覽器的漏洞

產生的程式碼很可怕。 但是,在穿過叢林時,您會注意到對我們已經熟悉的函數的調用 子_6115C:

尋找UC瀏覽器的漏洞

在案例 3 中存在一個開關,其中使用 RC4 演算法進行解密。 在這種情況下,傳遞給函數的結構由傳遞給的參數填充 doCommandNative。 讓我們記住我們在那裡擁有的一切 魔法整數 值為 16。我們查看相應的情況 - 經過幾次轉換後,我們找到了可以識別演算法的程式碼。

尋找UC瀏覽器的漏洞

這就是AES!

該演算法已經存在,剩下的就是獲取其參數:模式、金鑰以及可能的初始化向量(其存在取決於 AES 演算法的操作模式)。 它們的結構必須在函數呼叫之前的某個地方形成 子_6115C,但這部分程式碼被混淆得特別好,因此產生了修補程式碼的想法,以便將解密函數的所有參數都轉儲到檔案中。

補丁

為了不用手動用彙編語言編寫所有補丁程式碼,您可以啟動Android Studio,在那裡編寫一個函數,該函數接收與我們的解密函數相同的輸入參數並寫入文件,然後複製貼上編譯器將要執行的程式碼產生。

我們 UC 瀏覽器團隊的朋友也很關心添加程式碼的便利性。 讓我們記住,在每個函數的開頭,我們都有垃圾程式碼,可以輕鬆地用任何其他程式碼替換。 非常方便 🙂 但是,在目標函數的開頭,沒有足夠的空間用於將所有參數保存到檔案的程式碼。 我必須將其分成幾個部分並使用相鄰函數中的垃圾塊。 總共有四個部分。

第一部分:

尋找UC瀏覽器的漏洞

在ARM架構中,前四個函數參數透過暫存器R0-R3傳遞,其餘的(如果有的話)則透過堆疊傳遞。 LR暫存器攜帶返回地址。 所有這些都需要保存,以便在我們轉儲其參數後函數可以工作。 我們還需要保存在此過程中將使用的所有暫存器,因此我們執行 PUSH.W {R0-R10,LR}。 在 R7 中,我們取得透過堆疊傳遞給函數的參數清單的位址。

使用功能 打開 讓我們打開文件 /資料/本地/tmp/aes 在“ab”模式下
即用於添加。 在 R0 中我們載入檔案名稱的位址,在 R1 中載入指示模式的行的位址。 垃圾程式碼到這裡就結束了,所以我們繼續下一個函數。 為了讓它繼續工作,我們在一開始就將過渡到函數的實際程式碼,繞過垃圾,並且我們添加了補丁的延續,而不是垃圾。

尋找UC瀏覽器的漏洞

打電話 打開.

函數的前三個參數 AES 有類型 INT。 由於我們一開始就將暫存器保存到堆疊中,因此我們可以簡單地傳遞函數 它們在堆疊上的位址。

尋找UC瀏覽器的漏洞

接下來我們有三個結構體,其中包含資料大小和指向金鑰資料的指標、初始化向量和加密資料。

尋找UC瀏覽器的漏洞

最後,關閉文件,恢復暫存器並將控制權轉移給真正的函數 AES.

我們收集帶有修補庫的 APK,對其進行簽名,將其上傳到設備/模擬器,然後啟動它。 我們看到我們的轉儲正在創建,並且大量資料正在寫入其中。 瀏覽器不僅對流量使用加密,而且所有加密都會通過相關功能。 但由於某種原因,必要的資料不存在,且所需的請求在流量中不可見。 為了不等到 UC 瀏覽器設計發出必要的請求,讓我們從先前收到的伺服器取得加密回應並再次修補應用程式:將解密新增至主活動的 onCreate 中。

    const/16 v1, 0x62
new-array v1, v1, [B
fill-array-data v1, :encrypted_data
const/16 v0, 0x1f
invoke-static {v0, v1}, Lcom/uc/browser/core/d/c/g;->j(I[B)[B
move-result-object v1
array-length v2, v1
invoke-static {v2}, Ljava/lang/String;->valueOf(I)Ljava/lang/String;
move-result-object v2
const-string v0, "ololo"
invoke-static {v0, v2}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I

我們組裝、簽署、安裝、啟動。 我們收到 NullPointerException 因為該方法傳回 null。

在進一步分析程式碼的過程中,發現了一個可以破解有趣行的函數:「META-INF/」和「.RSA」。 看起來應用程式正在驗證其證書。 或甚至從中產生密鑰。 我真的不想處理證書發生的事情,所以我們只需將正確的證書塞給它即可。 讓我們修補加密行,以便我們得到“BLABLINF/”而不是“META-INF/”,在 APK 中建立一個具有該名稱的資料夾並在其中添加 squirrel 瀏覽器憑證。

我們組裝、簽署、安裝、啟動。 答對了! 我們有鑰匙!

米特

我們收到一個金鑰和一個等於該金鑰的初始化向量。 讓我們嘗試以 CBC 模式解密伺服器回應。

尋找UC瀏覽器的漏洞

我們看到存檔 URL,類似 MD5、「extract_unzipsize」和一個數字。 我們檢查:壓縮包的MD5是否相同,解壓縮後的庫大小是否相同。 我們正在嘗試修補這個程式庫並將其提供給瀏覽器。 為了表明我們的修補庫已加載,我們將啟動一個意圖來創建一條帶有文字「PWNED!」的簡訊。 我們將替換來自伺服器的兩個回應: puds.ucweb.com/upgrade/index.xhtml 並下載存檔。 在第一個中,我們替換 MD5(解壓縮後大小不會改變),在第二個中,我們為存檔提供修補後的庫。

瀏覽器嘗試多次下載存檔,然後出現錯誤。 顯然有什麼
他不喜歡。 分析這種模糊格式的結果是,伺服器也傳輸了存檔的大小:

尋找UC瀏覽器的漏洞

它以 LEB128 編碼。 打補丁後,帶有庫的壓縮包的大小發生了一些變化,因此瀏覽器認為壓縮包下載歪了,嘗試了幾次後就拋出了錯誤。

我們調整存檔的大小...然後 – 勝利! 🙂 結果在影片中。

https://www.youtube.com/watch?v=Nfns7uH03J8

後果和開發者反應

同樣,駭客也可以利用UC瀏覽器的不安全特性來傳播和運行惡意函式庫。 這些庫將在瀏覽器的上下文中運作,因此它們將獲得其所有系統權限。 因此,能夠顯示網路釣魚窗口,以及存取橙色中國松鼠的工作文件,包括儲存在資料庫中的登入名稱、密碼和 cookie。

我們聯繫了UC瀏覽器的開發人員,向他們通報了我們發現的問題,試圖指出漏洞及其危險,但他們沒有與我們討論任何內容。 同時,瀏覽器繼續在眾目睽睽之下炫耀其危險功能。 但一旦我們揭露了漏洞的細節,就不能再像以前那樣忽略它了。 27 月 XNUMX 日是
UC瀏覽器12.10.9.1193新版本發布,透過HTTPS存取伺服器: puds.ucweb.com/upgrade/index.xhtml.

此外,在“修復”之後直至撰寫本文時,嘗試在瀏覽器中打開 PDF 會導致出現錯誤訊息,其中包含文字“哎呀,出了點問題!” 嘗試開啟 PDF 時未向伺服器發出請求,但在啟動瀏覽器時向伺服器發出請求,這暗示了違反 Google Play 規則的繼續下載執行程式碼的能力。

來源: www.habr.com

添加評論