https://www.freebuf.com/articles/system/190565.html
前言
前陣子受《Xposed模塊編寫的那些事》這篇文章的幫助很大,感覺有必要寫一篇文章來回饋freebuf社區。現在最火爆的又是frida,該框架從Java層hook到Native層hook無所不能,雖然持久化還是要依靠Xposed和hookzz等開發框架,但是frida的動態和靈活對逆向以及自動化逆向的幫助非常巨大。
frida是啥?
首先,frida
是啥,github目錄Awesome Frida這樣介紹frida
的:
Frida is Greasemonkey for native apps, or, put in more technical terms, it’s a dynamic code instrumentation toolkit. It lets you inject snippets of JavaScript into native apps that run on Windows, Mac, Linux, iOS and Android. Frida is an open source software.
frida
是平臺原生app
的Greasemonkey
,說的專業一點,就是一種動態插樁工具,可以插入一些代碼到原生app
的內存空間去,(動態地監視和修改其行爲),這些原生平臺可以是Win
、Mac
、Linux
、Android
或者iOS
。而且frida
還是開源的。
Greasemonkey
可能大家不明白,它其實就是firefox
的一套插件體系,使用它編寫的腳本可以直接改變firefox
對網頁的編排方式,實現想要的任何功能。而且這套插件還是外掛的,非常靈活機動。
frida
也是一樣的道理。
frida爲什麼這麼火?
動靜態修改內存實現作弊一直是剛需,比如金山遊俠,本質上frida
做的跟它是一件事情。原則上是可以用frida
把金山遊俠,包括CheatEngine
等“外掛”做出來的。
當然,現在已經不是直接修改內存就可以高枕無憂的年代了。大家也不要這樣做,做外掛可是違法行爲。
在逆向的工作上也是一樣的道理,使用frida
可以“看到”平時看不到的東西。出於編譯型語言的特性,機器碼在CPU和內存上執行的過程中,其內部數據的交互和跳轉,對用戶來講是看不見的。當然如果手上有源碼,甚至哪怕有帶調試符號的可執行文件包,也可以使用gbd
、lldb
等調試器連上去看。
那如果沒有呢?如果是純黑盒呢?又要對app
進行逆向和動態調試、甚至自動化分析以及規模化收集信息的話,我們需要的是細粒度的流程控制和代碼級的可定製體系,以及不斷對調試進行動態糾正和可編程調試的框架,這就是frida
。
frida
使用的是python
、JavaScript
等“膠水語言”也是它火爆的一個原因,可以迅速將逆向過程自動化,以及整合到現有的架構和體系中去,爲你們發佈“威脅情報”、“數據平臺”甚至“AI風控”等產品打好基礎。
官宣屁屁踢甚至將其敏捷開發
和迅速適配到現有架構
的能力作爲其核心賣點。
frida實操環境
主機:
Host:Macbook Air CPU: i5 Memory:8GSystem:Kali Linux 2018.4 (Native,非虛擬機)
客戶端:
client:Nexus 6 shamu CPU:Snapdragon 805 Mem:3GSystem:lineage-15.1-20181123-NIGHTLY-shamu,android 8.1
用kali linux
的原因是工具很全面,權限很單一,只有一個root
,作爲原型開發很好用,否則python
和node
的各種權限、環境和依賴實在是煩。用lineage
因爲它有便利的網絡ADB調試
,可以省掉一個usb
數據線連接的過程。(雖然真實的原因是沒錢買新設備,Nexus 6
官方只支持到7.1.1
,想上8.1
只有lineage
一個選擇。)記得需要刷進去一個lineage
的 su
包,獲取root
權限,frida
是需要在root
權限下運行的。
首先到官網下載一個platform-tools
的linux版本——SDK Platform-Tools for Linux
,下載解壓之後可以直接運行裏面的二進制文件,當然也可以把路徑加到環境裏去。這樣adb
和fastboot
命令就有了。
然後再將frida-server
下載下來,拷貝到安卓機器裏去,使用root
用戶跑起來,保持adb
的連接不要斷開。
$ ./adb root # might be required
$ ./adb push frida-server /data/local/tmp/
$ ./adb shell "chmod 755 /data/local/tmp/frida-server"
$ ./adb shell "/data/local/tmp/frida-server &"
最後在kali linux
裏安裝好frida
即可,在kali
裏安裝frida
真是太簡單了,一句話命令即可,保證不出錯。(可能會需要先安裝pip
,也是一句話命令:curl [https://bootstrap.pypa.io/get-pip.py](https://bootstrap.pypa.io/get-pip.py) -o get-pip.py
)
pip install frida-tools
然後用frida-ps -U
命令連上去,就可以看到正在運行的進程了。
root@kali:~# frida-ps -U
Waiting for USB device to appear...
PID Name
---- -----------------------------------------------
431 ATFWD-daemon
3148 adbd
391 adspd
2448 android.ext.services
358 [email protected]
265 [email protected]
359 [email protected]
360 [email protected]
361 [email protected]
266 [email protected]
357 [email protected]
...
...
基本能力Ⅰ:hook參數、修改結果
先自己寫個app
:
package com.roysue.demo02;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
fun(50,30);
}
}
void fun(int x , int y ){
Log.d("Sum" , String.valueOf(x+y));
}
}
原理上很簡單,就是間隔一秒在控制檯輸出一下fun(50,30)
函數的結果,fun()
這個函數的作用是求和。那最終結果在控制檯如下所示。
$ adb logcat |grep Sum
11-26 21:26:23.234 3245 3245 D Sum : 80
11-26 21:26:24.234 3245 3245 D Sum : 80
11-26 21:26:25.235 3245 3245 D Sum : 80
11-26 21:26:26.235 3245 3245 D Sum : 80
11-26 21:26:27.236 3245 3245 D Sum : 80
11-26 21:26:28.237 3245 3245 D Sum : 80
11-26 21:26:29.237 3245 3245 D Sum : 80
現在我們來寫一段js
代碼,並用frida-server
將這段代碼加載到com.roysue.demo02
中去,執行其中的hook
函數。
$ nano s1.js
console.log("Script loaded successfully ");
Java.perform(function x() {
console.log("Inside java perform function");
//定位類
var my_class = Java.use("com.roysue.demo02.MainActivity");
console.log("Java.Use.Successfully!");//定位類成功!
//在這裏更改類的方法的實現(implementation)
my_class.fun.implementation = function(x,y){
//打印替換前的參數
console.log( "original call: fun("+ x + ", " + y + ")");
//把參數替換成2和5,依舊調用原函數
var ret_value = this.fun(2, 5);
return ret_value;
}
});
然後我們在kali
主機上使用一段python
腳本,將這段js
腳本“傳遞”給安卓系統里正在運行的frida-server
。
$ nano loader.py
import time
import frida
# 連接安卓機上的frida-server
device = frida.get_usb_device()
# 啓動`demo02`這個app
pid = device.spawn(["com.roysue.demo02"])
device.resume(pid)
time.sleep(1)
session = device.attach(pid)
# 加載s1.js腳本
with open("s1.js") as f:
script = session.create_script(f.read())
script.load()
# 腳本會持續運行等待輸入
raw_input()
然後得保證frida-server
正在運行,方法可以是在kali
主機輸入frida-ps -U
命令,如果安卓機上的進程出現了,則frida-server
運行良好。
還需要保證selinux
是關閉的狀態,可以在adb shell
裏,su -
獲得root
權限之後,輸入setenforce 0
命令來獲得,在Settings→About Phone→SELinux status
裏看到Permissive
,說明selinux
關閉成功。
然後在kali
主機上輸入python loader.js
,可以觀察到安卓機上com.roysue.demo02
這個app
馬上重啓了。然後$ adb logcat|grep Sum
裏的內容也變了。
11-26 21:44:47.875 2420 2420 D Sum : 80
11-26 21:44:48.375 2420 2420 D Sum : 80
11-26 21:44:48.875 2420 2420 D Sum : 80
11-26 21:44:49.375 2420 2420 D Sum : 80
11-26 21:44:49.878 2420 2420 D Sum : 7
11-26 21:44:50.390 2420 2420 D Sum : 7
11-26 21:44:50.904 2420 2420 D Sum : 7
11-26 21:44:51.408 2420 2420 D Sum : 7
11-26 21:44:51.921 2420 2420 D Sum : 7
11-26 21:44:52.435 2420 2420 D Sum : 7
11-26 21:44:52.945 2420 2420 D Sum : 7
11-26 21:44:53.459 2420 2420 D Sum : 7
11-26 21:44:53.970 2420 2420 D Sum : 7
11-26 21:44:54.480 2420 2420 D Sum : 7
在kali
主機上可以觀察到:
$ python loader.py
Script loaded successfully
Inside java perform function
Java.Use.Successfully!
original call: fun(50, 30)
original call: fun(50, 30)
original call: fun(50, 30)
original call: fun(50, 30)
original call: fun(50, 30)
original call: fun(50, 30)
original call: fun(50, 30)
original call: fun(50, 30)
original call: fun(50, 30)
說明腳本執行成功了,代碼也插到com.roysue.demo02
這個包裏去,並且成功執行了,s1.js
裏的代碼成功執行了,並且把交互結果傳回了kali
主機上。
基本能力Ⅱ:參數構造、方法重載、隱藏函數的處理
我們現在把app
的代碼稍微寫複雜一點點:
package com.roysue.demo02;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
public class MainActivity extends AppCompatActivity {
private String total = "@@@###@@@";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
while (true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
fun(50,30);
Log.d("ROYSUE.string" , fun("LoWeRcAsE Me!!!!!!!!!"));
}
}
void fun(int x , int y ){
Log.d("ROYSUE.Sum" , String.valueOf(x+y));
}
String fun(String x){
total +=x;
return x.toLowerCase();
}
String secret(){
return total;
}
}
app
運行起來後在使用logcat
打印出來的日誌如下:
$ adb logcat |grep ROYSUE
11-26 22:22:35.689 3051 3051 D ROYSUE.Sum: 80
11-26 22:22:35.689 3051 3051 D ROYSUE.string: lowercase me!!!!!!!!!
11-26 22:22:36.695 3051 3051 D ROYSUE.Sum: 80
11-26 22:22:36.696 3051 3051 D ROYSUE.string: lowercase me!!!!!!!!!
11-26 22:22:37.696 3051 3051 D ROYSUE.Sum: 80
11-26 22:22:37.696 3051 3051 D ROYSUE.string: lowercase me!!!!!!!!!
11-26 22:22:38.697 3051 3051 D ROYSUE.Sum: 80
11-26 22:22:38.697 3051 3051 D ROYSUE.string: lowercase me!!!!!!!!!
11-26 22:22:39.697 3051 3051 D ROYSUE.Sum: 80
11-26 22:22:39.698 3051 3051 D ROYSUE.string: lowercase me!!!!!!!!!
可以看到fun()
方法有了重載,在參數是兩個int
的情況下,返回兩個int
之和。在參數爲String
類型之下,則返回字符串的小寫形式。
另外,secret()
函數爲隱藏方法,在app
裏沒有被直接調用。
這時候如果我們直接使用上一節裏面的js
腳本和loader.js
來加載的話,肯定會崩潰。爲了看到崩潰的信息,我們對loader.js
做一些處理。
def my_message_handler(message , payload): #定義錯誤處理
print message
print payload
...
script.on("message" , my_message_handler) #調用錯誤處理
script.load()
再運行$ python loader.py
的話,就會看到如下的錯誤信息返回:
$ python loader.py
Script loaded successfully
Inside java perform function
Java.Use.Successfully!
{u'columnNumber': 1, u'description': u"Error: fun(): has more than one overload, use .overload(<signature>) to choose from:\n\t.overload('java.lang.String')\n\t.overload('int', 'int')", u'fileName': u'frida/node_modules/frida-java/lib/class-factory.js', u'lineNumber': 2233, u'type': u'error', u'stack': u"Error: fun(): has more than one overload, use .overload(<signature>) to choose from:\n\t.overload('java.lang.String')\n\t.overload('int', 'int')\n at throwOverloadError (frida/node_modules/frida-java/lib/class-factory.js:2233)\n at frida/node_modules/frida-java/lib/class-factory.js:1468\n at x (/script1.js:14)\n at frida/node_modules/frida-java/lib/vm.js:43\n at M (frida/node_modules/frida-java/index.js:347)\n at frida/node_modules/frida-java/index.js:299\n at frida/node_modules/frida-java/lib/vm.js:43\n at frida/node_modules/frida-java/index.js:279\n at /script1.js:15"}
None
可以看出是一個throwOverloadError
,這時候就是因爲我們沒有處理重載,造成的重載處理錯誤。這個時候就需要我們來處理重載了,在js
腳本中處理重載是這樣寫的:
my_class.fun.overload("int" , "int").implementation = function(x,y){
...
my_class.fun.overload("java.lang.String").implementation = function(x){
其中參數均爲兩個int
的情況下,上一節已經講過了。參數爲String
類的時候,由於String
類不是Java基本數據類型,而是java.lang.String
類型,所以在替換參數的構造上,需要花點心思。
var string_class = Java.use("java.lang.String"); //獲取String類型
my_class.fun.overload("java.lang.String").implementation = function(x){
console.log("*************************************");
var my_string = string_class.$new("My TeSt String#####"); //new一個新字符串
console.log("Original arg: " +x );
var ret = this.fun(my_string); // 用新的參數替換舊的參數,然後調用原函數獲取結果
console.log("Return value: "+ret);
console.log("*************************************");
return ret;
};
這樣我們對於重載函數的處理就算是ok了。我們到實驗裏來看下:
$ python loader.py
Script loaded successfully
Inside java perform function
original call: fun(50, 30)
*************************************
Original arg: LoWeRcAsE Me!!!!!!!!!
Return value: my test string#####
*************************************
original call: fun(50, 30)
*************************************
Original arg: LoWeRcAsE Me!!!!!!!!!
Return value: my test string#####
*************************************
original call: fun(50, 30)
*************************************
Original arg: LoWeRcAsE Me!!!!!!!!!
Return value: my test string#####
*************************************
然後logcat
打出來的結果也變了。
$ adb logcat |grep ROYSUE
11-26 22:23:29.597 3244 3244 D ROYSUE.Sum: 7
11-26 22:23:29.673 3244 3244 D ROYSUE.string: my test string#####
11-26 22:23:30.689 3244 3244 D ROYSUE.Sum: 7
11-26 22:23:30.730 3244 3244 D ROYSUE.string: my test string#####
11-26 22:23:31.740 3244 3244 D ROYSUE.Sum: 7
11-26 22:23:31.789 3244 3244 D ROYSUE.string: my test string#####
11-26 22:23:32.797 3244 3244 D ROYSUE.Sum: 7
11-26 22:23:32.833 3244 3244 D ROYSUE.string: my test string#####
最後再說一下隱藏方法的調用,frida
對其的處理辦法跟Xposed
是非常像的,Xposed
使用的是XposedHelpers.findClass("com.example.inner_class_demo.demo",lpparam.classLoader);
方法,直接findClass
,其實frida
也非常類似,也是使用的直接到內存裏去尋找的方法,也就是Java.choose(className, callbacks)
函數,通過類名觸發回掉函數。
Java.choose("com.roysue.demo02.MainActivity" , {
onMatch : function(instance){ //該類有多少個實例,該回調就會被觸發多少次
console.log("Found instance: "+instance);
console.log("Result of secret func: " + instance.secret());
},
onComplete:function(){}
});
最終運行效果如下:
$ python loader.py
Script loaded successfully
Inside java perform function
Found instance: com.roysue.demo02.MainActivity@92d5deb
Result of secret func: @@@###@@@
original call: fun(50, 30)
*************************************
Original arg: LoWeRcAsE Me!!!!!!!!!
Return value: my test string#####
*************************************
original call: fun(50, 30)
*************************************
Original arg: LoWeRcAsE Me!!!!!!!!!
Return value: my test string#####
*************************************
original call: fun(50, 30)
這樣隱藏方法也被調用起來了。
中級能力:遠程調用
上一小節中我們在安卓機器上使用js
腳本調用了隱藏函數secret()
,它在app
內雖然沒有被任何地方調用,但是仍然被我們的腳本“找到”並且“調用”了起來
這一小節我們要實現的是,不僅要在跑在安卓機上的js
腳本里調用這個函數,還要可以在kali
主機上的py
腳本里,直接調用這個函數。
也就是使用frida
提供的RPC
功能(Remote Procedure Call)。
安卓app
不需要有任何修改,這次我們要修改的是js
腳本和py
腳本。
$ nano s3.js
console.log("Script loaded successfully ");
function callSecretFun() { //定義導出函數
Java.perform(function () { //找到隱藏函數並且調用
Java.choose("com.roysue.demo02.MainActivity", {
onMatch: function (instance) {
console.log("Found instance: " + instance);
console.log("Result of secret func: " + instance.secret());
},
onComplete: function () { }
});
});
}
rpc.exports = {
callsecretfunction: callSecretFun //把callSecretFun函數導出爲callsecretfunction符號,導出名不可以有大寫字母或者下劃線
};
然後我們可以在kali
主機的py
腳本里直接調用該函數:
$ nano loader3.py
import time
import frida
def my_message_handler(message, payload):
print message
print payload
device = frida.get_usb_device()
pid = device.spawn(["com.roysue.demo02"])
device.resume(pid)
time.sleep(1)
session = device.attach(pid)
with open("s3.js") as f:
script = session.create_script(f.read())
script.on("message", my_message_handler)
script.load()
command = ""
while 1 == 1:
command = raw_input("Enter command:\n1: Exit\n2: Call secret function\nchoice:")
if command == "1":
break
elif command == "2": #在這裏調用
script.exports.callsecretfunction()
然後在kali
主機上我們就可以看到以下的輸出:
$ python loader3.py
Script loaded successfully
Enter command:
1: Exit
2: Call secret function
choice:2
Found instance: com.roysue.demo02.MainActivity@2eacd80
Result of secret func: @@@###@@@LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!
Enter command:
1: Exit
2: Call secret function
choice:2
Found instance: com.roysue.demo02.MainActivity@2eacd80
Result of secret func: @@@###@@@LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!
Enter command:
1: Exit
2: Call secret function
choice:2
Found instance: com.roysue.demo02.MainActivity@2eacd80
Result of secret func: @@@###@@@LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!LoWeRcAsE Me!!!!!!!!!
Enter command:
1: Exit
2: Call secret function
choice:1
這樣我們就實現了在kali
主機上直接調用安卓app
內部的函數的能力。
高級能力:互聯互通、動態修改
最後我們要實現的功能是,我們不僅僅可以在kali
主機上調用安卓app
裏的函數。我們還可以把數據從安卓app
裏傳遞到kali
主機上,在主機上進行修改,再傳遞迴安卓app
裏面去。
我們編寫這樣一個app
,其中最核心的地方在於判斷用戶是否爲admin
,如果是,則直接返回錯誤,禁止登陸。如果不是,則把用戶和密碼上傳到服務器上進行驗證。
package com.roysue.demo04;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Base64;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;
public class MainActivity extends AppCompatActivity {
EditText username_et;
EditText password_et;
TextView message_tv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
password_et = (EditText) this.findViewById(R.id.editText2);
username_et = (EditText) this.findViewById(R.id.editText);
message_tv = ((TextView) findViewById(R.id.textView));
this.findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (username_et.getText().toString().compareTo("admin") == 0) {
message_tv.setText("You cannot login as admin");
return;
}
//hook target
message_tv.setText("Sending to the server :" + Base64.encodeToString((username_et.getText().toString() + ":" + password_et.getText().toString()).getBytes(), Base64.DEFAULT));
}
});
}
}
最終跑起來之後,效果就是這樣。
我們的目標就是在kali
主機上“得到”輸入框輸入的內容,並且修改其輸入的內容,並且“傳輸”給安卓機器,使其通過驗證。也就是說,我們哪怕輸入admin
的賬戶和密碼,也可以繞過本地校驗,進行登陸的操作。
所以最終安卓端的js
代碼的邏輯就是,截取輸入,傳輸給kali
主機,暫停執行,得到kali
主機傳回的數據之後,繼續執行。形成代碼如下:
Java.perform(function () {
var tv_class = Java.use("android.widget.TextView");
tv_class.setText.overload("java.lang.CharSequence").implementation = function (x) {
var string_to_send = x.toString();
var string_to_recv;
send(string_to_send); // 將數據發送給kali主機的python代碼
recv(function (received_json_object) {
string_to_recv = received_json_object.my_data
console.log("string_to_recv: " + string_to_recv);
}).wait(); //收到數據之後,再執行下去
return this.setText(string_to_recv);
}
});
kali
主機端的流程就是,將接受到的JSON
數據解析,提取出其中的密碼部分,然後將用戶名替換成admin
,這樣就實現了將admin
和pw
發送給“服務器”的結果。
import time
import frida
def my_message_handler(message, payload):
print message
print payload
if message["type"] == "send":
print message["payload"]
data = message["payload"].split(":")[1].strip()
print 'message:', message
data = data.decode("base64")
user, pw = data.split(":")
data = ("admin" + ":" + pw).encode("base64")
print "encoded data:", data
script.post({"my_data": data}) # 將JSON對象發送回去
print "Modified data sent"
device = frida.get_usb_device()
pid = device.spawn(["com.roysue.demo04"])
device.resume(pid)
time.sleep(1)
session = device.attach(pid)
with open("s4.js") as f:
script = session.create_script(f.read())
script.on("message", my_message_handler) # 註冊消息處理函數
script.load()
raw_input()
我們只要輸入任意用戶名(非admin)+密碼,非admin的用戶名可以繞過compareTo
校驗,然後frida
會幫助我們將用戶名改成admin
,最終就是admin:pw
的組合發送到服務器。
$ python loader4.py
Script loaded successfully
{u'type': u'send', u'payload': u'Sending to the server :YWFhYTpiYmJi\n'}
None
Sending to the server :YWFhYTpiYmJi
message: {u'type': u'send', u'payload': u'Sending to the server :YWFhYTpiYmJi\n'}
data: aaaa:bbbb
pw: bbbb
encoded data: YWRtaW46YmJiYg==
Modified data sent
string_to_recv: YWRtaW46YmJiYg==
動態修改輸入內容就這樣實現了。
打算做個成套的教程、目錄已經想好了
frida『葵花寶典』
第一章.各種環境安裝(包括Win、Mac、Ubuntu、ARM機器下的各種環境安裝)第二章.基本案例上手(安卓、iOS、Win、Mac爲對象的各種插樁方法)第三章.frida-tools(frida原生提供的各種工具的使用)第四章.frida-scripts(各種frida腳本的介紹、使用和總結)第五章.frida高級應用(安卓hook參數模型的總結、SSL-unpinning模型、iOS應用重打包動態修改等等)第六章.二次開發基礎(frida-API基本使用方法、基於frida的二次開發模型)第七章.二次開發案例(Fridump、r2frida、brida、Appmon等源碼解析和解讀)
當然還在醞釀中,大家有想法可以跟我溝通,想要源碼的也可以留言。
謝謝大家。