主頁 > 後端開發 > 對app的反爬測驗之apk逆向分析-frida繞過ssl pinning檢測

對app的反爬測驗之apk逆向分析-frida繞過ssl pinning檢測

2020-10-01 08:17:03 後端開發

前言:

 

受人所托,需要對他們的產品進行反爬測驗,所以就有了以下內容,

 

不過,我知道,針對這方面的文章太多了,是真的多,而且好早就有了,但是目前為止,很多app的防護基本也還是用的ssl pinning檢測證書,

因為,目前的app要嘛不用ssl,要嘛用就是一般的ssl,基本就是在手機上裝個相關軟體 的代理即可,而且這個代理基本就是fiddler,charlels,burpsuite,mitmproxy(Python環境下的)四個抓包軟體自帶的ssl證書,然后即可抓到ssl(https)的請求

以上這些,基本可以解決大部分的app(其實很多使用ssl的網站也是這樣處理)

 

但是因為很多app為了防止資料被分析爬取,會做ssl pinning驗證

 

ssl painning

 

SSL Pinning是一種防止中間人攻擊(MITM)的技術,主要機制是在客戶端發起請求–>收到服務器發來的證書進行校驗,如果收到的證書不被客戶端信任,就直接斷開連接不繼續求情,

所以在遇到對關鍵請求開啟SSL Pinning的APP時,我們抓包就只能看到APP上提示無法連接網路或者請求失敗之類的提示;而在抓包工具上面,要么就只能看到一排 CONNECT 請求,獲取到證書卻沒有后續了,要么就是一些無關的請求,找不到想要的介面

 

比如如下圖:

 

 

 

針對這種,如果是web網站,我們都知道,在本地裝下抓包軟體自帶的ssl證書就行了,但是app的話,如此操作之后還是不行,而且app還會提示沒網(比如:網路連接失敗,網路有問題等等的),反正意思就是沒網的意思,這種就是因為app自身做了ssl pinning驗證處理,驗證下當前的ssl證書是否是合法允許的,如果不是就會驗證失敗

 

其實使用ssl pinning目前已經成為了趨勢,那么我們的目前物件剛好就有這個怎么辦呢?

 

目前根據我的經驗,最有效的有三個方法:

 

  • 1.使用低版本的安卓機抓包
  • 2.使用ios端手機抓包
  • 3.使用frida繞過證書處理

 

使用低版本的安卓機抓包

 

因為app的話,目前主流的都是用的前后端分離開發,所以越到后期,app更新新版后,越會有不同版本的后端介面存在,而且新版介面和老版介面其實回傳的資料差異性很小,并且有個關鍵點就是,為了兼容性,會依舊留下舊版介面,因為每個用戶使用的手機不一樣,安卓或者ios版本不同,系統版本也就會不同,且老款手機因為記憶體太小,不會更新新版的app包,種種情況下來,結果就是會留下舊版介面,而且這個舊版介面安全性比新版低很多,所以可以用低版本的餓安卓機來抓包,就正常的抓包流程即可,不出意外的話,可能還用的普通的http請求,

為什么高版本的安卓就抓不到包呢,因為高版本的(安卓7以上)開始,安卓自帶了一些安全機制,本質上就是只信任系統證書,不再信任用戶自己安裝的證書了,我們裝的ssl代理證書就是自己裝的,所以就會驗證不通過了

 

使用ios端手機抓包

 

這個情況真的很多,因為,蘋果端的appstore管理得很嚴,不能加些自己獨特的東西,但是加ssl是可以的,但是很多app并沒有加我就不知道了,這個情況就很簡單,需要一臺iphone,其他都是正常抓包操作,然后安裝證書,把證書信任下就行了,詳細的操作就不說了,網上很多教程

 

 

使用frida繞過證書處理

 

 

這個方法就是本篇文章的重點了,這個放到后面再說

 

 

其他方法

其實也有其他的方法,這些方法并不是通用的,可能運氣好可以用,運氣不好就沒用:

 

安卓模擬器

 

用安卓模擬器,模擬低版本安卓然后抓包

 

對證書信任,修改APP設定

 

看這個app是否是自有app,如果是自有的,谷歌有debug模式,該模式下讓app默認可以信任用戶域的證書(trust-anchors),如果是非自有,用xposed+JustTrustMe即可,但是使用Xposed框架需要root,網上那些微信魔改小功能,什么自動搶紅包,防訊息撤回之類的就是用的xposed框架改的,用JustTrustMe來信任用戶安裝的證書

目前市面上有VitualXposed、太極等虛擬的框架,不用root也可以操作,太極這個軟體挺好的,有太極-陰(免root)和太極-陽(需要root),兩個版本都可以用,但是針對有些app的話,太極-陰沒戲,只能太極-陽,但是既然我都已經root了,我就沒必要整這些了,

 

 

如果是 App 的開發者或者把 apk 逆向出來了,那么可以直接通過修改 AndroidManifest.xml 檔案,在 apk 里面添加證書的信任規則即可,詳情可以參考 https://crifan.github.io/app_capture_package_tool_charles/website/how_capture_app/complex_https/https_ssl_pinning/,這種思路屬于第一種信任證書的解決方案,

 

 

強制信任證書

 

其實就是將證書設定為系統證書,只需要將抓包軟體的證書設定為系統區域即可,但這個前提是手機必須要 ROOT,而且需要計算證書 Hash Code 并對證書進行重命名,具體可以參考 https://crifan.github.io/app_capture_package_tool_charles/website/how_capture_app/complex_https/https_ssl_pinning, 把ssl代理證書強制的放到安卓機的/system/etc/security/cacerts/目錄下,這個目錄就是安卓機系統信任的目錄,

 

httpcannary

這個是安卓端的抓包工具,網上吹得很火,根據我(我手機是安卓10)親自操作,發現其實沒有用,也不知道是不是我的姿勢錯誤,或者我手機安卓系統版本太高了失效

 

 

VirtualApp

 

用這個可以免root操作,然后正常抓包,但是這個方法我沒有實際操作過,網上的資料不多,自行查找

 

 

 

Xposed + JustTrustMe

 

 

 

Xposed 是一款 Android 端的 Hook 工具,利用它我們可以 Hook App 里面的關鍵方法的執行邏輯,繞過 HTTPS 的證書校驗程序,JustTrustMe 是基于 Xposed 一個插件,它可以將 HTTPS 證書校驗的部分進行 Hook,改寫其中的證書校驗邏輯,這種思路是屬于第二種繞過 HTTPS 證書校驗的解決方案,

 

 

 

當然基于 Xposed 的類似插件也有很多,如 SSLKiller、sslunpining 等等,可以自行搜索, 

 

不過 Xposed 的安裝必須要 ROOT,如果不想 ROOT 的話,可以使用后文介紹的 VirtualXposed,

 

具體可以參考 https://codeshare.frida.re/@pcipolloni/universal-android-ssl-pinning-bypass-with-frida/,

 

 

VirtualXposed

 

Xposed 的使用需要 ROOT,如果不想 ROOT 的話,可以直接使用一款基于 VirtualApp 開發的 VirtualXposed 工具,它提供了一個虛擬環境,內置了 Xposed,我們只需要將想要的軟體安裝到 VirtualXposed 里面就能使用 Xposed 的功能了,然后配合 JustTrustMe 插件也能解決 SSL Pining 的問題,這種思路是屬于第二種繞過 HTTPS 證書校驗的解決方案,

 

特殊改寫

  

其實本質上是對一些關鍵的校驗方法進行了 Hook 和改寫,去除了一些校驗邏輯,但是對于一些代碼混淆后的 App 來說,其校驗 HTTPS 證書的方法名直接變了,那么 JustTrustMe 這樣的插件就無法 Hook 這些方法,因此也就無效了,

 

 

強制全域代理

 

手機root后,使用proxy Droid 實作強制全域代理,讓ssl代理證書生效,proxy Droid可以在UpToDown,ApkHere等的地方下載

 

VPN抓包

免root,在安卓機上安裝packet capture,然后抓包,我試了下,我的手機(我手機是安卓10)沒用

 

魔改JustTrustMe

在JustTrustMe插件上增加一個可以運行時根據實際情況調整ssl檢測的功能,對hook增加動態適配,這個方法我沒試過,我在看雪論壇里找到一個 JustTrustMePlus,點我下載

 

反編譯app包

用apktools修改組態檔里的ssl證書檢測部分,可利用jadx等工具分析原始碼,然后重新打包,再抓包分析,這個方法是可行的,詳細的步驟自行百度吧,后續有時間的話,我單獨發一篇對app的脫殼重新打包

 

 

AndServer處理

這個工具的原理就是把一個安卓機在本地作為一臺服務器,然后找到資料介面,這個方法沒有親測過,更多的適用于獲取app的sign/token時去獲取介面

  

以上的方法就是我所知道的方法,各位朋友自行操作

 

 

接下來進入正題,frida hook

 

什么是frida

 

官網:https://frida.re/

Frida是個輕量級別的hook框架, 是Python API,用JavaScript除錯來邏輯

Frida的核心是用C撰寫的,并將Google的V8引擎注入到目標行程中,在這些行程中,JS可以完全訪問記憶體,掛鉤函式甚至呼叫行程內的本機函式來執行,

使用Python和JS可以使用無風險的API進行快速開發,Frida可以幫助您輕松捕獲JS中的錯誤并為您提供例外而不是崩潰,

 

frida是平臺原生app的Greasemonkey,說的專業一點,就是一種動態插樁工具,可以插入一些代碼到原生app的記憶體空間去,(動態地監視和修改其行為),這些原生平臺可以是Win、Mac、Linux、Android或者iOS,而且frida還是開源的,

Greasemonkey可能大家不明白,它其實就是firefox的一套插件體系,使用它撰寫的腳本可以直接改變firefox對網頁的編排方式,實作想要的任何功能,而且這套插件還是外掛的,非常靈活機動,

 

 

frida框架主要分為兩部分:
1)一部分是運行在系統上的互動工具frida CLI,
2)另一部分是運行在目標機器上的代碼注入工具 frida-server

 

 

注:以下相關操作,終端里凡是 C:\Users\Administrator 開頭的都是在pc機上操作的,需要在安卓機目錄里操作的我都有說明,不要搞混了

 

環境準備

 

安裝frida

沒有python的安裝python,然后安裝frida:

pip  install frida

pip install frida-tools

 

安裝程序很慢,這個只能耐心等待,然后如果你是macbook的話,如果你遇到安裝出錯,可以看看我這篇文章的解決方法 macos 安裝frida的坑

然后frida是mac,linux,windows都可以安裝使用的,這個根據你自己的條件選擇了

安裝adb

這個就很簡單,去 安卓開發網  然后下載這個工具:

 

 

 

  

 

 如果你下載太慢可以在我這里下載:點我

下載完畢后,解壓,然后放到你想放的路徑,然后配置下環境變數即可,此電腦(我的電腦)- 屬性-高級系統設定-環境變數-系統變數的path,新增即可

 

 

 

然后,打開終端:

 

 敲adb,回車,如果有以下提示,說明你adb安裝成功

 

 

 

 

 

以上配置是windows平臺,如果是其他平臺的話,自行查找,這里就不展示了

 

找一個安卓機(已root)

根據現在的行情,要找到一個已root的手機,問題不大也不小,但是很多時候沒有必要,所以我這里就選擇用安卓模擬器來輔助操作了

安裝夜神模擬器,夜神默認是安卓5,你可以自行選擇安卓版本,在夜神里設定已root即可

 

 

 

打開開發者選項里的USB除錯

 

設定里面,關于本機,然后狂點系統版本號,開啟開發者模式:

 

 

 

回傳,會多一個開發者選項:

 

 

打開除錯

 

 

 

adb連接安卓機(模擬器)

 

在安裝了frida和adb的真機作業系統下,打開終端,用 adb connect IP 連接安卓機:

 

夜神的ip是127.0.0.1:62001,這里注意,如果你創建了多個安卓系統的話,那么你待連接的安卓機不一定是62001,可能是其他的,這個就你自己去找了,我就因為這個,我查了很久才找到

 

 

 

我這里已經連接上了,所以提示已連接

 

連接之后可以用 adb devices查看已連接的機器:

 

 

安裝frida-server

frida-server這個需要安裝在安卓機上,但是安卓機我們都知道有很多個版本,對應架構才行,要查看當前安卓機的架構:

adb shell getprop ro.product.cpu.abi

 

 

 

 

 

 

然后去這里下載對應架構的frida-server :  點我

 

我這里是x86,安卓,所以選下面我選中那個下載,你的安卓機是什么你就選哪個就行了

 

 

 

然后下載很慢,我這里也提供了,點我下載  

 

解壓,然后用adb 傳到安卓機上

adb push (本機的frida-sever檔案所在目錄) (安卓機目錄)

  

 

 

 

這里提示太長了,看不出來,可以用adb shell 去那個目錄下看下是否有frida-server即可:

 

 

 

 

修改frida-server的權限:

chmod 700 frida-server

  

 

 

 

 

下載一個frida hook 的js檔案

 

這個檔案,有好幾個版本,我選用了兩個版本,放到下面,你們自己選擇吧

 

版本1:

 

setTimeout(function(){
    Java.perform(function (){
    	console.log("");
	    console.log("[.] Cert Pinning Bypass/Re-Pinning");

	    var CertificateFactory = Java.use("java.security.cert.CertificateFactory");
	    var FileInputStream = Java.use("java.io.FileInputStream");
	    var BufferedInputStream = Java.use("java.io.BufferedInputStream");
	    var X509Certificate = Java.use("java.security.cert.X509Certificate");
	    var KeyStore = Java.use("java.security.KeyStore");
	    var TrustManagerFactory = Java.use("javax.net.ssl.TrustManagerFactory");
	    var SSLContext = Java.use("javax.net.ssl.SSLContext");

	    // Load CAs from an InputStream
	    console.log("[+] Loading our CA...")
	    cf = CertificateFactory.getInstance("X.509");
	    
	    try {
	    	var fileInputStream = FileInputStream.$new("/data/local/tmp/cert-der.crt");
	    }
	    catch(err) {
	    	console.log("[o] " + err);
	    }
	    var bufferedInputStream = BufferedInputStream.$new(fileInputStream);
	  	var ca = cf.generateCertificate(bufferedInputStream);
	    bufferedInputStream.close();

		var certInfo = Java.cast(ca, X509Certificate);
	    console.log("[o] Our CA Info: " + certInfo.getSubjectDN());

	    // Create a KeyStore containing our trusted CAs
	    console.log("[+] Creating a KeyStore for our CA...");
	    var keyStoreType = KeyStore.getDefaultType();
	    var keyStore = KeyStore.getInstance(keyStoreType);
	    keyStore.load(null, null);
	    keyStore.setCertificateEntry("ca", ca);
	    
	    // Create a TrustManager that trusts the CAs in our KeyStore
	    console.log("[+] Creating a TrustManager that trusts the CA in our KeyStore...");
	    var tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm();
	    var tmf = TrustManagerFactory.getInstance(tmfAlgorithm);
	    tmf.init(keyStore);
	    console.log("[+] Our TrustManager is ready...");

	    console.log("[+] Hijacking SSLContext methods now...")
	    console.log("[-] Waiting for the app to invoke SSLContext.init()...")

	   	SSLContext.init.overload("[Ljavax.net.ssl.KeyManager;", "[Ljavax.net.ssl.TrustManager;", "java.security.SecureRandom").implementation = function(a,b,c) {
	   		console.log("[o] App invoked javax.net.ssl.SSLContext.init...");
	   		SSLContext.init.overload("[Ljavax.net.ssl.KeyManager;", "[Ljavax.net.ssl.TrustManager;", "java.security.SecureRandom").call(this, a, tmf.getTrustManagers(), c);
	   		console.log("[+] SSLContext initialized with our custom TrustManager!");
	   	}
    });
},0);

  

版本2:

Java.perform(function() {
    var array_list = Java.use("java.util.ArrayList");
    var ApiClient = Java.use('com.android.org.conscrypt.TrustManagerImpl');

    ApiClient.checkTrustedRecursive.implementation = function(a1, a2, a3, a4, a5, a6) {
        // console.log('Bypassing SSL Pinning');
        var k = array_list.$new();
        return k; 
        }
}, 0);

  

然后你自己復制以上的任何一個版本的代碼,然后在本地新建一個js檔案,粘貼進去就行了

 

 

 

 

 

 

或者這個:

 

這里補充一個完全版:

 

Java.perform(function() {

/*
hook list:
1.SSLcontext
2.okhttp
3.webview
4.XUtils
5.httpclientandroidlib
6.JSSE
7.network\_security\_config (android 7.0+)
8.Apache Http client (support partly)
9.OpenSSLSocketImpl
10.TrustKit
11.Cronet
*/

	// Attempts to bypass SSL pinning implementations in a number of
	// ways. These include implementing a new TrustManager that will
	// accept any SSL certificate, overriding OkHTTP v3 check()
	// method etc.
	var X509TrustManager = Java.use('javax.net.ssl.X509TrustManager');
	var HostnameVerifier = Java.use('javax.net.ssl.HostnameVerifier');
	var SSLContext = Java.use('javax.net.ssl.SSLContext');
	var quiet_output = false;

	// Helper method to honor the quiet flag.

	function quiet_send(data) {

		if (quiet_output) {

			return;
		}

		send(data)
	}


	// Implement a new TrustManager
	// ref: https://gist.github.com/oleavr/3ca67a173ff7d207c6b8c3b0ca65a9d8
	// Java.registerClass() is only supported on ART for now(201803). 所以android 4.4以下不兼容,4.4要切換成ART使用.
	/*
06-07 16:15:38.541 27021-27073/mi.sslpinningdemo W/System.err: java.lang.IllegalArgumentException: Required method checkServerTrusted(X509Certificate[], String, String, String) missing
06-07 16:15:38.542 27021-27073/mi.sslpinningdemo W/System.err:     at android.net.http.X509TrustManagerExtensions.<init>(X509TrustManagerExtensions.java:73)
        at mi.ssl.MiPinningTrustManger.<init>(MiPinningTrustManger.java:61)
06-07 16:15:38.543 27021-27073/mi.sslpinningdemo W/System.err:     at mi.sslpinningdemo.OkHttpUtil.getSecPinningClient(OkHttpUtil.java:112)
        at mi.sslpinningdemo.OkHttpUtil.get(OkHttpUtil.java:62)
        at mi.sslpinningdemo.MainActivity$1$1.run(MainActivity.java:36)
*/
	var X509Certificate = Java.use("java.security.cert.X509Certificate");
	var TrustManager;
	try {
		TrustManager = Java.registerClass({
			name: 'org.wooyun.TrustManager',
			implements: [X509TrustManager],
			methods: {
				checkClientTrusted: function(chain, authType) {},
				checkServerTrusted: function(chain, authType) {},
				getAcceptedIssuers: function() {
					// var certs = [X509Certificate.$new()];
					// return certs;
					return [];
				}
			}
		});
	} catch (e) {
		quiet_send("registerClass from X509TrustManager >>>>>>>> " + e.message);
	}





	// Prepare the TrustManagers array to pass to SSLContext.init()
	var TrustManagers = [TrustManager.$new()];

	try {
		// Prepare a Empty SSLFactory
		var TLS_SSLContext = SSLContext.getInstance("TLS");
		TLS_SSLContext.init(null, TrustManagers, null);
		var EmptySSLFactory = TLS_SSLContext.getSocketFactory();
	} catch (e) {
		quiet_send(e.message);
	}

	send('Custom, Empty TrustManager ready');

	// Get a handle on the init() on the SSLContext class
	var SSLContext_init = SSLContext.init.overload(
		'[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom');

	// Override the init method, specifying our new TrustManager
	SSLContext_init.implementation = function(keyManager, trustManager, secureRandom) {

		quiet_send('Overriding SSLContext.init() with the custom TrustManager');

		SSLContext_init.call(this, null, TrustManagers, null);
	};

	/*** okhttp3.x unpinning ***/


	// Wrap the logic in a try/catch as not all applications will have
	// okhttp as part of the app.
	try {

		var CertificatePinner = Java.use('okhttp3.CertificatePinner');

		quiet_send('OkHTTP 3.x Found');

		CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function() {

			quiet_send('OkHTTP 3.x check() called. Not throwing an exception.');
		}

	} catch (err) {

		// If we dont have a ClassNotFoundException exception, raise the
		// problem encountered.
		if (err.message.indexOf('ClassNotFoundException') === 0) {

			throw new Error(err);
		}
	}

	// Appcelerator Titanium PinningTrustManager

	// Wrap the logic in a try/catch as not all applications will have
	// appcelerator as part of the app.
	try {

		var PinningTrustManager = Java.use('appcelerator.https.PinningTrustManager');

		send('Appcelerator Titanium Found');

		PinningTrustManager.checkServerTrusted.implementation = function() {

			quiet_send('Appcelerator checkServerTrusted() called. Not throwing an exception.');
		}

	} catch (err) {

		// If we dont have a ClassNotFoundException exception, raise the
		// problem encountered.
		if (err.message.indexOf('ClassNotFoundException') === 0) {

			throw new Error(err);
		}
	}

	/*** okhttp unpinning ***/


	try {
		var OkHttpClient = Java.use("com.squareup.okhttp.OkHttpClient");
		OkHttpClient.setCertificatePinner.implementation = function(certificatePinner) {
			// do nothing
			quiet_send("OkHttpClient.setCertificatePinner Called!");
			return this;
		};

		// Invalidate the certificate pinnet checks (if "setCertificatePinner" was called before the previous invalidation)
		var CertificatePinner = Java.use("com.squareup.okhttp.CertificatePinner");
		CertificatePinner.check.overload('java.lang.String', '[Ljava.security.cert.Certificate;').implementation = function(p0, p1) {
			// do nothing
			quiet_send("okhttp Called! [Certificate]");
			return;
		};
		CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function(p0, p1) {
			// do nothing
			quiet_send("okhttp Called! [List]");
			return;
		};
	} catch (e) {
		quiet_send("com.squareup.okhttp not found");
	}

	/*** WebView Hooks ***/

	/* frameworks/base/core/java/android/webkit/WebViewClient.java */
	/* public void onReceivedSslError(Webview, SslErrorHandler, SslError) */
	var WebViewClient = Java.use("android.webkit.WebViewClient");

	WebViewClient.onReceivedSslError.implementation = function(webView, sslErrorHandler, sslError) {
		quiet_send("WebViewClient onReceivedSslError invoke");
		//執行proceed方法
		sslErrorHandler.proceed();
		return;
	};

	WebViewClient.onReceivedError.overload('android.webkit.WebView', 'int', 'java.lang.String', 'java.lang.String').implementation = function(a, b, c, d) {
		quiet_send("WebViewClient onReceivedError invoked");
		return;
	};

	WebViewClient.onReceivedError.overload('android.webkit.WebView', 'android.webkit.WebResourceRequest', 'android.webkit.WebResourceError').implementation = function() {
		quiet_send("WebViewClient onReceivedError invoked");
		return;
	};

	/*** JSSE Hooks ***/

	/* libcore/luni/src/main/java/javax/net/ssl/TrustManagerFactory.java */
	/* public final TrustManager[] getTrustManager() */
	/* TrustManagerFactory.getTrustManagers maybe cause X509TrustManagerExtensions error  */
	// var TrustManagerFactory = Java.use("javax.net.ssl.TrustManagerFactory");
	// TrustManagerFactory.getTrustManagers.implementation = function(){
	//     quiet_send("TrustManagerFactory getTrustManagers invoked");
	//     return TrustManagers;
	// }

	var HttpsURLConnection = Java.use("javax.net.ssl.HttpsURLConnection");
	/* libcore/luni/src/main/java/javax/net/ssl/HttpsURLConnection.java */
	/* public void setDefaultHostnameVerifier(HostnameVerifier) */
	HttpsURLConnection.setDefaultHostnameVerifier.implementation = function(hostnameVerifier) {
		quiet_send("HttpsURLConnection.setDefaultHostnameVerifier invoked");
		return null;
	};
	/* libcore/luni/src/main/java/javax/net/ssl/HttpsURLConnection.java */
	/* public void setSSLSocketFactory(SSLSocketFactory) */
	HttpsURLConnection.setSSLSocketFactory.implementation = function(SSLSocketFactory) {
		quiet_send("HttpsURLConnection.setSSLSocketFactory invoked");
		return null;
	};
	/* libcore/luni/src/main/java/javax/net/ssl/HttpsURLConnection.java */
	/* public void setHostnameVerifier(HostnameVerifier) */
	HttpsURLConnection.setHostnameVerifier.implementation = function(hostnameVerifier) {
		quiet_send("HttpsURLConnection.setHostnameVerifier invoked");
		return null;
	};

	/*** Xutils3.x hooks ***/
	//Implement a new HostnameVerifier
	var TrustHostnameVerifier;
	try {
		TrustHostnameVerifier = Java.registerClass({
			name: 'org.wooyun.TrustHostnameVerifier',
			implements: [HostnameVerifier],
			method: {
				verify: function(hostname, session) {
					return true;
				}
			}
		});

	} catch (e) {
		//java.lang.ClassNotFoundException: Didn't find class "org.wooyun.TrustHostnameVerifier"
		quiet_send("registerClass from hostnameVerifier >>>>>>>> " + e.message);
	}

	try {
		var RequestParams = Java.use('org.xutils.http.RequestParams');
		RequestParams.setSslSocketFactory.implementation = function(sslSocketFactory) {
			sslSocketFactory = EmptySSLFactory;
			return null;
		}

		RequestParams.setHostnameVerifier.implementation = function(hostnameVerifier) {
			hostnameVerifier = TrustHostnameVerifier.$new();
			return null;
		}

	} catch (e) {
		quiet_send("Xutils hooks not Found");
	}

	/*** httpclientandroidlib Hooks ***/
	try {
		var AbstractVerifier = Java.use("ch.boye.httpclientandroidlib.conn.ssl.AbstractVerifier");
		AbstractVerifier.verify.overload('java.lang.String', '[Ljava.lang.String', '[Ljava.lang.String', 'boolean').implementation = function() {
			quiet_send("httpclientandroidlib Hooks");
			return null;
		}
	} catch (e) {
		quiet_send("httpclientandroidlib Hooks not found");
	}

	/***
android 7.0+ network_security_config TrustManagerImpl hook
apache httpclient partly
***/
	var TrustManagerImpl = Java.use("com.android.org.conscrypt.TrustManagerImpl");
	// try {
	//     var Arrays = Java.use("java.util.Arrays");
	//     //apache http client pinning maybe baypass
	//     //https://github.com/google/conscrypt/blob/c88f9f55a523f128f0e4dace76a34724bfa1e88c/platform/src/main/java/org/conscrypt/TrustManagerImpl.java#471
	//     TrustManagerImpl.checkTrusted.implementation = function (chain, authType, session, parameters, authType) {
	//         quiet_send("TrustManagerImpl checkTrusted called");
	//         //Generics currently result in java.lang.Object
	//         return Arrays.asList(chain);
	//     }
	//
	// } catch (e) {
	//     quiet_send("TrustManagerImpl checkTrusted nout found");
	// }

	try {
		// Android 7+ TrustManagerImpl
		TrustManagerImpl.verifyChain.implementation = function(untrustedChain, trustAnchorChain, host, clientAuth, ocspData, tlsSctData) {
			quiet_send("TrustManagerImpl verifyChain called");
			// Skip all the logic and just return the chain again :P
			//https://www.nccgroup.trust/uk/about-us/newsroom-and-events/blogs/2017/november/bypassing-androids-network-security-configuration/
			// https://github.com/google/conscrypt/blob/c88f9f55a523f128f0e4dace76a34724bfa1e88c/platform/src/main/java/org/conscrypt/TrustManagerImpl.java#L650
			return untrustedChain;
		}
	} catch (e) {
		quiet_send("TrustManagerImpl verifyChain nout found below 7.0");
	}
	// OpenSSLSocketImpl
	try {
		var OpenSSLSocketImpl = Java.use('com.android.org.conscrypt.OpenSSLSocketImpl');
		OpenSSLSocketImpl.verifyCertificateChain.implementation = function(certRefs, authMethod) {
			quiet_send('OpenSSLSocketImpl.verifyCertificateChain');
		}

		quiet_send('OpenSSLSocketImpl pinning')
	} catch (err) {
		quiet_send('OpenSSLSocketImpl pinner not found');
	}
	// Trustkit
	try {
		var Activity = Java.use("com.datatheorem.android.trustkit.pinning.OkHostnameVerifier");
		Activity.verify.overload('java.lang.String', 'javax.net.ssl.SSLSession').implementation = function(str) {
			quiet_send('Trustkit.verify1: ' + str);
			return true;
		};
		Activity.verify.overload('java.lang.String', 'java.security.cert.X509Certificate').implementation = function(str) {
			quiet_send('Trustkit.verify2: ' + str);
			return true;
		};

		quiet_send('Trustkit pinning')
	} catch (err) {
		quiet_send('Trustkit pinner not found')
	}

	try {
		//cronet pinner hook
		//weibo don't invoke

		var netBuilder = Java.use("org.chromium.net.CronetEngine$Builder");

		//https://developer.android.com/guide/topics/connectivity/cronet/reference/org/chromium/net/CronetEngine.Builder.html#enablePublicKeyPinningBypassForLocalTrustAnchors(boolean)
		netBuilder.enablePublicKeyPinningBypassForLocalTrustAnchors.implementation = function(arg) {

			//weibo not invoke
			console.log("Enables or disables public key pinning bypass for local trust anchors = " + arg);

			//true to enable the bypass, false to disable.
			var ret = netBuilder.enablePublicKeyPinningBypassForLocalTrustAnchors.call(this, true);
			return ret;
		};

		netBuilder.addPublicKeyPins.implementation = function(hostName, pinsSha256, includeSubdomains, expirationDate) {
			console.log("cronet addPublicKeyPins hostName = " + hostName);

			//var ret = netBuilder.addPublicKeyPins.call(this,hostName, pinsSha256,includeSubdomains, expirationDate);
			//this 是呼叫 addPublicKeyPins 前的物件嗎? Yes,CronetEngine.Builder
			return this;
		};

	} catch (err) {
		console.log('[-] Cronet pinner not found')
	}
});

 

上面這個完全版本包含了如下功能,如果你想一步到位的話,就可以用這個完全版

 

  • SSLcontext(ART only)
  • okhttp
  • webview
  • XUtils(ART only)
  • httpclientandroidlib
  • JSSE
  • network_security_config (android 7.0+)
  • Apache Http client (support partly)
  • OpenSSLSocketImpl
  • TrustKit
  • Cronet 

 

任意一個都可以,不要三個都用,都用也沒用,根據實際情況選用

 

安卓機配置代理

 

配置代理到開啟了抓包工具的IP上:

 

 

 

長按wiredssid

 

 

 

 

 

 

 

 

 

 

 

 補充一句,當配置完代理后,pc端電腦上一定要打開對應的抓包軟體,不然安卓機會沒網

 

 

 

安卓機上安裝ssl證書

 

 

根據你選用的抓包工具,fiddler,charles,burpsuite,安裝證書即可,你可以訪問局域網下帶的ip來下載,然后安裝:

 

配置了代理再執行此步驟,不然打不開下載證書的局域網址

 

 

 

 

也可以用adb 像傳frida-server一樣,用adb push把證書push到安卓機上,然后在安卓機的設定-安全里本地匯入證書:

 

 

 

 

 用adb push 之后,還是把代理配置上,不然后面操作也無法繼續,不管怎么操作,反正必須要ssl證書安裝上即可

 

 

 

開始hook

 

hook的本質意思就是鉤子,在開發里面通俗的說就是可以在任意流程里插一手,然后做些手腳,比如打開一個app,在啟動到完全打開app,顯示app的首頁,這個程序就可以hook一下,比如把本來要打開首頁的,改成打開第二頁資料,當然這只是舉個例子

 

 

啟動frida-server:

/data/local/tmp/frida-server

 

補充下,有的高級點的app會檢測本地是否啟動了frida-server的程式,以及監聽是否開啟了27042埠,所以,如果有反除錯的話,建議將frida-server改個自定義的名字,比如fsx86之類的,反正就是別讓app檢測到,然后啟動:

 

/data/local/tmp/fsx86 -l 0.0.0.0:6666  (6666就是自定義埠)

  

 

這里要用絕對路徑來啟動,我也不知道為啥,啟動,如下,warning是個警告,無所謂,說明啟動成功了,只要沒報錯就行了

 

 

映射埠

 

在pc端電腦,裝adb的機器上使用如下命令映射埠

 

 

adb forward tcp:27042 tcp:27042
adb forward tcp:27043 tcp:27043

 

  

 

 

找到需要hook的app包名

 

 

這個包名不是app的名字,是安裝之后存在目錄里的檔案夾名,一般是com.xxxx.xxx之類的,但是有少部分奇葩的報名并不是com開頭

查看當前所有的包名:

frida-ps -U

 

 

 

 注意要安卓機里先啟動了firda-server,然后adb連上了安卓機,才可以呼叫frida命令, 如果不啟動的話,運行frida這樣,Failed,失敗的意思

 

 

 

 

 

 

 

以上查看app包,顯示出來太多了,你根本不知道哪個才是我們需要的包名,可以使用下面的命令查看

adb shell pm list packages:列印設備上的所有軟體包

adb shell pm list packages -f:輸出包和包相關聯的檔案

adb shell pm list packages -d:只輸出禁用的包由于本機禁用沒有,輸出為空

adb shell pm list packages -e:只輸出啟用的包

adb shell pm list packages -s:只輸出系統的包

adb shell pm list packages -3:只輸出第三方的包

adb shell pm list packages -i:只輸出包和安裝資訊(安裝來源)

adb shell pm list packages -u:只輸出包和未安裝包資訊(安裝來源)

adb shell pm list packages --user <USER_ID>:根據用戶id查詢用戶的空間的所有包,USER_ID代表當前連接設備的順序,從零開始

  

如果還找不到,可以先在安卓機上啟動了目標app后,再用命令查看:

adb shell "dumpsys window | grep mCurrentFocus"

  

 

 

 

hook操作

frida -U -f (app包名) -l  (js目錄)  --no-pause

  

 

 

 

 

 

注意了,這段js是放在安裝了frida和adb的電腦上,不是放在安卓機上

 

運行完這條命令,安卓機會自動打開目標app,

 

app打開界面我就不展示了

 

如果打開的就是我們預期的那個app,那就是對的,如果打開錯了,請重新獲取app包名,打開之后就可以用抓包工具進行抓包了,ssl的一樣的可以抓:

 

 

 

 

上面看到的https的還是會隧道,但是緊接著就有資料出現,說明還是抓到了資料包了

 

 

ok,繞過ssl  pinning成功!!!

 

其實如果你覺得需要做些改動的話,可以寫個python腳本來呼叫,js代碼就作為檔案內容讀取就行了,然后進行hook操作 

 

 

最后得出的結論就是,我朋友他們的產品,其實反爬做得挺好,上面的截圖也可以看到,其實還是有些資料拿不到的

 

 

補充:

如果你用的模擬器在安裝了app之后打不開,說明app有檢測是否是模擬器或者對安卓版本做了檢測,版本太低直接不給使用,那么你就只能用真機操作了,adb連接真機操作區別不大,詳細的自行百度

 

檢測模擬器的辦法:

 

  • 1.檢測模擬器上特有的檔案
  • 2.檢測qemu pipes驅動程式
  • 3.檢測手機號是否是155552155開頭的
  • 4.檢測設備ID是否是15個0
  • 5.檢測IMSI ID是否是31026+10個0
  • 6.檢測運營商是否是“Android”
  • 7.代碼里用getInstance()方法呼叫任意一個方法,回傳true就是模擬器
  • 8.檢測IMEI或者入網許可證

 

以上都是我以前搜集的資料,但是,根據現在的時代發展,可能模擬器也早就更新迭代了,把一些特征給抹除或者改的跟真機一樣了,所以有些方法并不是有用了,這個就只有自行選擇了

 

免root使用frida

 

 

其實不root也可以使用frida,這里我就不展開了,給一個大神寫的鏈接,里面還有其他方法的hook,感興趣自己看吧,點我

 

 

 

針對很安全性很強的app——逆向

 

 

 

JEB 

JEB 是一款適用于 Android 應用程式和本機機器代碼的反匯編器和反編譯器軟體,利用它我們可以直接將安卓的 apk 反編譯得到 Smali 代碼、jar 檔案,獲取到 Java 代碼,有了 Java 代碼,我們就能分析其中的加密邏輯了,

 

 

JEB:https://www.pnfsoftware.com/

 

 

JADX

 

與 JEB 類似,JADX 也是一款安卓反編譯軟體,可以將 apk 反編譯得到 jar 檔案,得到 Java 代碼,從而進一步分析邏輯,
JADX:https://github.com/skylot/jadx

  

dex2jar、jd-gui

  

這兩者通常會配合使用來進行反編譯,同樣也可以實作 apk 檔案的反編譯,但其用起來個人感覺不如 JEB、JADX 方便,

 

脫殼

 

一些 apk 可能進行了加固處理,所以在反編譯之前需要進行脫殼處理,一般來說可以先借助于一些查殼工具查殼,如果有殼的話可以借助于 Dumpdex、FRIDA-DEXDump 等工具進行脫殼,

 

 

FRIDA-DEXDump:https://github.com/hluwa/FRIDA-DEXDump
Dumpdex:https://github.com/WrBug/dumpDex

 

 

反匯編

 

一些 apk 里面的加密可能直接寫入 so 格式的元件里面,要想破解其中的邏輯,就需要用到反匯編的一些知識了,這里可以借助于 IDA 這個軟體來進行分析,
IDA:https://www.hex-rays.com/

 

 

以上的一些逆向操作需要較深的功底和安全知識,在很多情況下,如果逆向成功了,一些加密演算法還是能夠被找出來的,找出來了加密邏輯之后,我們用程式模擬就方便了,

 

模擬

 

逆向對于多數有保護 App 是有一定作用的,但有的時候 App 還增加了風控檢測,一旦 App 檢測到運行環境或訪問頻率等資訊出現例外,那么 App 或服務器就可能產生防護,直接停止執行或者服務器回傳假資料等都是有可能的,

 

對于這種情形,有時候我們就需要回歸本源,真實模擬一些 App 的手工操作了,

 

adb

 

最常規的 adb 命令可以實作一些手機自動化操作,但功能有限,

 

觸動精靈、按鍵精靈

 

有很多商家提供了手機 App 的一些自動化腳本和驅動,如觸動精靈、按鍵精靈等,利用它們的一些服務我們可以自動化地完成一些 App 的操作,

 

觸動精靈:https://www.touchsprite.com/

 

Appium

 

類似 Selenium,Appium 是手機上的一款移動端的自動化測驗工具,也能做到可見即可爬的操作,

 Appium:http://appium.io/

 

AirTest

 

同樣是一款移動端的自動化測驗工具,是網易公司開發的,相比 Appium 來說使用更方便,

 

AirTest:http://airtest.netease.com/

 

Appium/AirTest + mitmdump

 

mitmdump 其實是一款抓包軟體,與 mitmproxy 是一套工具,這款軟體配合自動化的一些操作就可以用 Python 實作實時抓包處理了,

 

mitmdump:https://mitmproxy.readthedocs.io/

 

 

 

 

 

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/144569.html

標籤:Python

上一篇:如何在outlook里增加一個按鈕

下一篇:2分鐘用Python實作自動化水軍評論,值得學習

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 【C++】Microsoft C++、C 和匯編程式檔案

    ......

    uj5u.com 2020-09-10 00:57:23 more
  • 例外宣告

    相比于斷言適用于排除邏輯上不可能存在的狀態,例外通常是用于邏輯上可能發生的錯誤。 例外宣告 Item 1:當函式不可能拋出例外或不能接受拋出例外時,使用noexcept 理由 如果不打算拋出例外的話,程式就會認為無法處理這種錯誤,并且應當盡早終止,如此可以有效地阻止例外的傳播與擴散。 示例 //不可 ......

    uj5u.com 2020-09-10 00:57:27 more
  • Codeforces 1400E Clear the Multiset(貪心 + 分治)

    鏈接:https://codeforces.com/problemset/problem/1400/E 來源:Codeforces 思路:給你一個陣列,現在你可以進行兩種操作,操作1:將一段沒有 0 的區間進行減一的操作,操作2:將 i 位置上的元素歸零。最終問:將這個陣列的全部元素歸零后操作的最少 ......

    uj5u.com 2020-09-10 00:57:30 more
  • UVA11610 【Reverse Prime】

    本人看到此題沒有翻譯,就附帶了一個自己的翻譯版本 思考 這一題,它的第一個要求是找出所有 $7$ 位反向質數及其質因數的個數。 我們應該需要質數篩篩選1~$10^{7}$的所有數,這里就不慢慢介紹了。但是,重讀題,我們突然發現反向質數都是 $7$ 位,而將它反過來后的數字卻是 $6$ 位數,這就說明 ......

    uj5u.com 2020-09-10 00:57:36 more
  • 統計區間素數數量

    1 #pragma GCC optimize(2) 2 #include <bits/stdc++.h> 3 using namespace std; 4 bool isprime[1000000010]; 5 vector<int> prime; 6 inline int getlist(int ......

    uj5u.com 2020-09-10 00:57:47 more
  • C/C++編程筆記:C++中的 const 變數詳解,教你正確認識const用法

    1、C中的const 1、區域const變數存放在堆疊區中,會分配記憶體(也就是說可以通過地址間接修改變數的值)。測驗代碼如下: 運行結果: 2、全域const變數存放在只讀資料段(不能通過地址修改,會發生寫入錯誤), 默認為外部聯編,可以給其他源檔案使用(需要用extern關鍵字修飾) 運行結果: ......

    uj5u.com 2020-09-10 00:58:04 more
  • 【C++犯錯記錄】VS2019 MFC添加資源不懂如何修改資源宏ID

    1. 首先在資源視圖中,添加資源 2. 點擊新添加的資源,復制自動生成的ID 3. 在解決方案資源管理器中找到Resource.h檔案,編輯,使用整個專案搜索和替換的方式快速替換 宏宣告 4. Ctrl+Shift+F 全域搜索,點擊查找全部,然后逐個替換 5. 為什么使用搜索替換而不使用屬性視窗直 ......

    uj5u.com 2020-09-10 00:59:11 more
  • 【C++犯錯記錄】VS2019 MFC不懂的批量添加資源

    1. 打開資源頭檔案Resource.h,在其中預先定義好宏 ID(不清楚其實ID值應該設定多少,可以先新建一個相同的資源項,再在這個資源的ID值的基礎上遞增即可) 2. 在資源視圖中選中專案資源,按F7編輯資源檔案,按 ID 型別 相對路徑的形式添加 資源。(別忘了先把檔案拷貝到專案中的res檔案 ......

    uj5u.com 2020-09-10 01:00:19 more
  • C/C++編程筆記:關于C++的參考型別,專供新手入門使用

    今天要講的是C++中我最喜歡的一個用法——參考,也叫別名。 參考就是給一個變數名取一個變數名,方便我們間接地使用這個變數。我們可以給一個變數創建N個參考,這N + 1個變數共享了同一塊記憶體區域。(參考型別的變數會占用記憶體空間,占用的記憶體空間的大小和指標型別的大小是相同的。雖然參考是一個物件的別名,但 ......

    uj5u.com 2020-09-10 01:00:22 more
  • 【C/C++編程筆記】從頭開始學習C ++:初學者完整指南

    眾所周知,C ++的學習曲線陡峭,但是花時間學習這種語言將為您的職業帶來奇跡,并使您與其他開發人員區分開。您會更輕松地學習新語言,形成真正的解決問題的技能,并在編程的基礎上打下堅實的基礎。 C ++將幫助您養成良好的編程習慣(即清晰一致的編碼風格,在撰寫代碼時注釋代碼,并限制類內部的可見性),并且由 ......

    uj5u.com 2020-09-10 01:00:41 more
最新发布
  • Rust中的智能指標:Box<T> Rc<T> Arc<T> Cell<T> RefCell<T> Weak

    Rust中的智能指標是什么 智能指標(smart pointers)是一類資料結構,是擁有資料所有權和額外功能的指標。是指標的進一步發展 指標(pointer)是一個包含記憶體地址的變數的通用概念。這個地址參考,或 ” 指向”(points at)一些其 他資料 。參考以 & 符號為標志并借用了他們所 ......

    uj5u.com 2023-04-20 07:24:10 more
  • Java的值傳遞和參考傳遞

    值傳遞不會改變本身,參考傳遞(如果傳遞的值需要實體化到堆里)如果發生修改了會改變本身。 1.基本資料型別都是值傳遞 package com.example.basic; public class Test { public static void main(String[] args) { int ......

    uj5u.com 2023-04-20 07:24:04 more
  • [2]SpinalHDL教程——Scala簡單入門

    第一個 Scala 程式 shell里面輸入 $ scala scala> 1 + 1 res0: Int = 2 scala> println("Hello World!") Hello World! 檔案形式 object HelloWorld { /* 這是我的第一個 Scala 程式 * 以 ......

    uj5u.com 2023-04-20 07:23:58 more
  • 理解函式指標和回呼函式

    理解 函式指標 指向函式的指標。比如: 理解函式指標的偽代碼 void (*p)(int type, char *data); // 定義一個函式指標p void func(int type, char *data); // 宣告一個函式func p = func; // 將指標p指向函式func ......

    uj5u.com 2023-04-20 07:23:52 more
  • Django筆記二十五之資料庫函式之日期函式

    本文首發于公眾號:Hunter后端 原文鏈接:Django筆記二十五之資料庫函式之日期函式 日期函式主要介紹兩個大類,Extract() 和 Trunc() Extract() 函式作用是提取日期,比如我們可以提取一個日期欄位的年份,月份,日等資料 Trunc() 的作用則是截取,比如 2022-0 ......

    uj5u.com 2023-04-20 07:23:45 more
  • 一天吃透JVM面試八股文

    什么是JVM? JVM,全稱Java Virtual Machine(Java虛擬機),是通過在實際的計算機上仿真模擬各種計算機功能來實作的。由一套位元組碼指令集、一組暫存器、一個堆疊、一個垃圾回收堆和一個存盤方法域等組成。JVM屏蔽了與作業系統平臺相關的資訊,使得Java程式只需要生成在Java虛擬機 ......

    uj5u.com 2023-04-20 07:23:31 more
  • 使用Java接入小程式訂閱訊息!

    更新完微信服務號的模板訊息之后,我又趕緊把微信小程式的訂閱訊息給實作了!之前我一直以為微信小程式也是要企業才能申請,沒想到小程式個人就能申請。 訊息推送平臺🔥推送下發【郵件】【短信】【微信服務號】【微信小程式】【企業微信】【釘釘】等訊息型別。 https://gitee.com/zhongfuch ......

    uj5u.com 2023-04-20 07:22:59 more
  • java -- 緩沖流、轉換流、序列化流

    緩沖流 緩沖流, 也叫高效流, 按照資料型別分類: 位元組緩沖流:BufferedInputStream,BufferedOutputStream 字符緩沖流:BufferedReader,BufferedWriter 緩沖流的基本原理,是在創建流物件時,會創建一個內置的默認大小的緩沖區陣列,通過緩沖 ......

    uj5u.com 2023-04-20 07:22:49 more
  • Java-SpringBoot-Range請求頭設定實作視頻分段傳輸

    老實說,人太懶了,現在基本都不喜歡寫筆記了,但是網上有關Range請求頭的文章都太水了 下面是抄的一段StackOverflow的代碼...自己大修改過的,寫的注釋挺全的,應該直接看得懂,就不解釋了 寫的不好...只是希望能給視頻網站開發的新手一點點幫助吧. 業務場景:視頻分段傳輸、視頻多段傳輸(理 ......

    uj5u.com 2023-04-20 07:22:42 more
  • Windows 10開發教程_編程入門自學教程_菜鳥教程-免費教程分享

    教程簡介 Windows 10開發入門教程 - 從簡單的步驟了解Windows 10開發,從基本到高級概念,包括簡介,UWP,第一個應用程式,商店,XAML控制元件,資料系結,XAML性能,自適應設計,自適應UI,自適應代碼,檔案管理,SQLite資料庫,應用程式到應用程式通信,應用程式本地化,應用程式 ......

    uj5u.com 2023-04-20 07:22:35 more