Android漏洞之戰——整體加殼原理和脫殼技巧詳解

一、前言

為了幫助更加方便的進行漏洞挖掘工作,前面我們通過了幾篇文章詳解的給大家介紹了動態調試技術、過反調試技術、Hook技術、過反Hook技術、抓包技術等,掌握了這些可以很方便的開展App漏洞挖掘工作,而最后我們還需要掌握一定的脫殼技巧,進行進一步助力我們漏洞挖掘的效率,本文主要介紹Android App加殼中的整體dex加殼,幫助大家掌握加殼的原理和脫殼的各種技能。

本文第二節主要講述Android啟動流程和加殼原理

本文第三節主要介紹整體加殼的實現

本文第四節主要講當下脫殼點的概念

本文第五節講述現有的脫殼技巧

二、相關介紹

1.Android App啟動流程

(1)Android系統啟動流程

我們要徹底的了解App加殼原理,首先我們從了解App的啟動流程出發,先于App啟動之前,Android系統是啟動最早,下面我們來詳細查看一下Android系統的啟動過程:

我在Xposed源碼定制一文中詳細的講解了Android的啟動流程,簡單來說就是:

1

加載BootLoader --> 初始化內核 --> 啟動init進程 --> init進程fork出Zygote進程 --> Zygote進程fork出SystemServer進程

我們就了解了最后Zygote進程fork出第一個進程:SystemServer進程,SystemServer主要完成了以下工作:

?

android app安裝

首先這里我們先介紹一下PackageManagerService,其主要是完成Android中應用程序安裝的服務,我們了解的Android應用程序安裝的方式:

1

2

3

4

· 系統啟動時安裝,沒有安裝界面

· 第三方應用安裝,有安裝界面,也是我們最熟悉的方式

· ADB命令安裝,沒有安裝界面

· 通過各類應用市場安裝,沒有安裝界面

image-20220612154820955

雖然安裝方式不同,但是最后四種方式都是通過PackageManagerService服務來完成應用程序的安裝。而PackageManagerService服務則通過與Installd服務通信,發送具體的指令來執行應用程序的安裝、卸載等工作

1

2

3

4

5

6

public static final IPackageManager main(Context context, Installer installer,

????boolean factoryTest, boolean onlyCore) {

????????PackageManagerService m = new PackageManagerService(context, installer, factoryTest, onlyCore);

????????ServiceManager.addService("package", m);

????return m;

}

應用程序在安裝時涉及到如下幾個重要目錄:

image-20220612154820955

我們了解完App的安裝流程是由PackageManagerService,同理SystemServer啟動了一個更加重要的服務ActivityManagerService, 而AMS其中很重要的一個作用就是啟動Launcher進程,具體是怎么啟動的,大家可以參考文章:Android系統啟動流程(四)Launcher啟動過程與系統啟動流程,這里就不再詳細講解,而進入Launcher進程,我們就進入了App啟動的流程。

(2)App啟動流程

Android系統啟動的最后一步是啟動一個Home應用程序,這個應用程序用來顯示系統中已經安裝的應用程序,這個Home應用程序就叫做Launcher。應用程序Launcher在啟動過程中會請求PackageManagerService返回系統中已經安裝的應用程序的信息,并將這些信息封裝成一個快捷圖標列表顯示在系統屏幕上,這樣用戶可以通過點擊這些快捷圖標來啟動相應的應用程序

前面我們描述了AMS將Launcher啟動,然后進入App啟動流程,這里參考文章:ActivityThread的理解和APP的啟動過程

image-20220612154820955

1

2

3

4

5

6

7

8

(1)點擊桌面APP圖標時,Launcher的startActivity()方法,通過Binder通信,調用system_server進程中AMS服務的startActivity方法,發起啟動請求

(2)system_server進程接收到請求后,向Zygote進程發送創建進程的請求

(3)Zygote進程fork出App進程,并執行ActivityThread的main方法,創建ActivityThread線程,初始化MainLooper,主線程Handler,同時初始化ApplicationThread用于和AMS通信交互

(4)App進程,通過Binder向sytem_server進程發起attachApplication請求,這里實際上就是APP進程通過Binder調用sytem_server進程中AMS的attachApplication方法,AMS的attachApplication方法的作用是將ApplicationThread對象與AMS綁定

(5)system_server進程在收到attachApplication的請求,進行一些準備工作后,再通過binder IPC向App進程發送handleBindApplication請求(初始化Application并調用onCreate方法)和scheduleLaunchActivity請求(創建啟動Activity)

(6)App進程的binder線程(ApplicationThread)在收到請求后,通過handler向主線程發送BIND_APPLICATION和LAUNCH_ACTIVITY消息,這里注意的是AMS和主線程并不直接通信,而是AMS和主線程的內部類ApplicationThread通過Binder通信,ApplicationThread再和主線程通過Handler消息交互。

(7)主線程在收到Message后,創建Application并調用onCreate方法,再通過反射機制創建目標Activity,并回調Activity.onCreate()等方法

(8)到此,App便正式啟動,開始進入Activity生命周期,執行完onCreate/onStart/onResume方法,UI渲染后顯示APP主界面

到這里,我們的大致弄清了APP的啟動流程,而這里我們就進入了加殼中十分重要的地方ActivityTread

(3)ActivityThread啟動流程

寒冰大佬在FART:ART環境下基于主動調用的自動化脫殼方案?一文中講述了ActivityThread.main()是進入App世界的大門,并由此展開了對加殼原理的講述

同理接下來,我們開始進行源碼分析,了解ActivityThread的具體操作:

xref/frameworks/base/core/java/android/app/ActivityThread.java

image-20220612164337749

根據寒冰大佬描述,在ActivityThread完成實例化操作,調用thread.attach(false)完成一系列初始化準備工作,最后主線程進入消息循環,等待接收來自系統的消息。當收到系統發送來的bindapplication的進程間調用時,調用函數handlebindapplication來處理該請求

1

2

3

4

5

6

7

8

9

10

public void handleMessage(Message msg) {

****

????case BIND_APPLICATION:

????????Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "bindApplication");

????????AppBindData data = (AppBindData)msg.obj;

????????handleBindApplication(data);

????????Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);

????????break;

****

}

在處理消息過程,很很明顯進入了handlebindapplication函數

這里我再用寒冰大佬文章的內容:

image-20220612164337749

我們定位第四步,Application進行實例化,然后進入makeApplication

image-20220612165753498

然后我們進入newApplication

image-20220612170020202

這里我們可以看見完成了兩件事:

1

2

1)完成了Application的實例化

2)并調用Application.attach()函數

然后我們繼續進入Application.attach()函數

image-20220612170305879

這里我們就進一步調用了attachBaseContext()方法

最后回到handlebindapplication中執行第6步,進入callApplicationOnCreate()函數

image-20220612170604374

就執行了Application.onCreate()方法

總結:

1

2

3

4

從上可知, App的運行流程是

????初始化————>Application的構造函數————>Application.attachBaseContext()————>Application.onCreate()函數

最后才會進入MainActivity中的attachBaseContext函數、onCreate函數

所以加殼廠商要在程序正式執行前,也就是上面的流程中進行動態加載和類加載器的修正,這樣才能對加密的dex進行釋放,而一般的1廠商往往選擇在Application中的attachBaseContext或onCreate函數進行

這里我附上網上一個大佬的詳細執行流程圖:

image-20220612170604374

2.整體加殼原理詳解

(1)整體加殼原理

Dex整體加殼可以理解為在加密的源Apk程序外面有套上了一層外殼,簡單過程為:

image-20220424141415510

image-20220424141415510

如何對App進行加一層外殼呢,這里就需要應用動態加載的原理,關于動態加載和類加載器,我在上篇文章中有詳細講解:Android加殼脫殼學習(1)——動態加載和類加載機制詳解

這里我們可以用一個案例來進一步講述,我們打開一個整體加殼的樣本

image-20220612172943793

我們很明顯看見,除了一個代理類Application,其他相關的代碼信息都無法發現

image-20220612173124912

在代理類中反射調用了一些方法,很顯然我們解析出的結果都無法查找,很明顯就說明在Application.attchBaseContext()和Application.onCreate()中必須要完成對源加密的dex的動態加載和解密

結合上面的描述,App加載應用解析時就是這個流程:

1

2

3

4

5

6

1)BootClassLoader加載系統核心庫

2)PathClassLoader加載APP自身dex

3)進入APP自身組件,解析AndroidManifest.xml,然后查找Application代理

4)調用聲明Application的attachBaseContext()對源程序進行動態加載或解密

5)調用聲明Application的onCreate()對源程序進行動態加載或解密

6)進入MainActivity中的attachBaseContext(),然后進入onCreate()函數,執行源程序代碼

(2)類加載器的修正

上面我們已經很清晰的了解了殼加載的流程,我們很明顯的意識到一個問題,我們從頭到尾都是用PathClassLoader來加載dex,而上篇文章我在講類加載器的過程中說過

image-20220612185103615

1

2

3

4

5

6

7

8

Android中的ClassLoader類型分為系統ClassLoader和自定義ClassLoader。其中系統ClassLoader包括3種是BootClassLoader、DexClassLoader、PathClassLoader

(1)BootClassLoader:Android平臺上所有Android系統啟動時會使用BootClassLoader來預加載常用的類

(2)BaseDexClassLoader:實際應用層類文件的加載,而真正的加載委托給pathList來完成

(3)DexClassLoader:可以加載dex文件以及包含dex的壓縮文件(apk,dex,jar,zip),可以安裝一個未安裝的apk文件,一般為自定義類加載器

(4)PathClassLoader:可以加載系統類和應用程序的類,通常用來加載已安裝的apk的dex文件

補充:

Android 提供的原生加載器叫做基礎類加載器,包括:BootClassLoader,PathClassLoader,DexClassLoader,InMemoryDexClassLoader(Android 8.0 引入),DelegateLastClassLoader(Android 8.1 引入)

我們要想動態加載dex文件必須使用自定義的DexClassLoader,那我們直接使用DexClassLoader進行加載就可以么,很顯然不行,還是會報異常

1

DexClassLoader加載的類是沒有組件生命周期的,即DexClassLoader即使通過對APK的動態加載完成了對組件類的加載,當系統啟動該組件時,依然會出現加載類失敗的異常

所以我們要想使用DexClassLoader進行動態加載dex,我們需要進行類加載器的修正

當前實現類加載器的修正,主要有兩種方案:

1

2

1)替換系統組件類加載器為我們的DexClassLoader,同時設置DexClassLoader的parent為系統組件加載器

2)打破原有的雙親委派關系,在系統組件類加載器PathClassLoader和BootClassLoader的中間插入我們自己的DexClassLoader

<1>類加載器替換

怎么去替換系統的類加載器了,這就和我們上面分析的ActivityThread中LoadedApk有關了,LoadedApk主要負責加載一個Apk程序,我們進一步分析源碼

image-20220612190524422

很明顯,我們可以想到我們通過反射獲取mclassLoader,然后使用我們的DexClassLoader進行替換,不就可以成功的讓DexClassLoader擁有生命周期了么

源碼實現:

1

2

3

4

5

6

總結:

????1)獲取ActivityThread實例

????2)通過反射獲取類加載器

????3)獲取LoadedApk

????4)獲取mClassLoader系統類加載器

????5)替換自定義類加載器為系統類加載器

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

public static void replaceClassLoader(Context context,ClassLoader dexClassLoader){

???????ClassLoader pathClassLoader = MainActivity.class.getClassLoader();

???????try {

???????????//1.獲取ActivityThread實例

???????????Class ActivityThread = pathClassLoader.loadClass("android.app.ActivityThread");

???????????Method currentActivityThread = ActivityThread.getDeclaredMethod("currentActivityThread");

???????????Object activityThreadObj = currentActivityThread.invoke(null);

???????????//2.通過反射獲得類加載器

???????????//final ArrayMap<String, WeakReference<LoadedApk>> mPackages = new ArrayMap<>();

???????????Field mPackagesField = ActivityThread.getDeclaredField("mPackages");

???????????mPackagesField.setAccessible(true);

???????????//3.拿到LoadedApk

???????????ArrayMap mPackagesObj = (ArrayMap) mPackagesField.get(activityThreadObj);

???????????String packagename = context.getPackageName();

???????????WeakReference wr = (WeakReference) mPackagesObj.get(packagename);

???????????Object LoadApkObj = wr.get();

???????????//4.拿到mclassLoader

???????????Class LoadedApkClass = pathClassLoader.loadClass("android.app.LoadedApk");

???????????Field mClassLoaderField = LoadedApkClass.getDeclaredField("mClassLoader");

???????????mClassLoaderField.setAccessible(true);

???????????Object mClassLoader =mClassLoaderField.get(LoadApkObj);

???????????Log.e("mClassLoader",mClassLoader.toString());

???????????//5.將系統組件ClassLoader給替換

???????????mClassLoaderField.set(LoadApkObj,dexClassLoader);

???????}

???????catch (ClassNotFoundException e) {

???????????e.printStackTrace();

???????} catch (NoSuchMethodException e) {

???????????e.printStackTrace();

???????} catch (IllegalAccessException e) {

???????????e.printStackTrace();

???????} catch (InvocationTargetException e) {

???????????e.printStackTrace();

???????} catch (NoSuchFieldException e) {

???????????e.printStackTrace();

???????}

???}

<2>類加載器插入

還有一種方案,動態加載中我們講述了類加載器的雙親委派機制,就是說我們的類加載器剛拿到類,并不會直接進行加載,而是先判斷自己是否加載,如果沒有加載則給自己的父類,父類再給父類,所以我們讓DexClassLoader成為PathClassLoader的父類,這樣就可以解決DexClassLoader生命周期的問題

1

2

3

總結:

????1)將DexClassloader父節點設置為BootClassLoader

????2)將PathClassLoader父節點設置為DexClassloader

代碼實現:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

public static void replaceClassLoader(Context context, ClassLoader dexClassLoader){

????????//將pathClassLoader父節點設置為DexClassLoader

????????ClassLoader pathClassLoaderobj = context.getClassLoader();

????????Class<ClassLoader> ClassLoaderClass = ClassLoader.class;

????????try {

????????????Field parent = ClassLoaderClass.getDeclaredField("parent");

????????????parent.setAccessible(true);

????????????parent.set(pathClassLoaderobj,dexClassLoader);

????????} catch (NoSuchFieldException e) {

????????????e.printStackTrace();

????????} catch (IllegalAccessException e) {

????????????e.printStackTrace();

????????}

????}

完成殼加載器的修正后,我們就可以正常的加載dex了

三、整體加殼案例實現

前面我們詳細講述了App運行機制和整體加殼的實現機制,下面我們就按照前面的講述,來實現一個簡單的整體加殼案例

實驗準備:

1

2

源程序

加殼程序

1.編寫源程序

image-20220612193114397

這就是我們的源程序,源程序運行,我們會在日志中看見我們打印的信息,然后我們生成dex文件

2.編寫殼程序

(1)準備工作

將dex文件上傳sdcard,并給應用設置存儲權限

image-20220612195812601

image-20220612200126917

(2)編寫代理類

我們首先編寫代理類,模仿上面的加殼應用

image-20220612193735398

然后我們設置AndroidManifest.xml中的代理類別

image-20220612193921062

然后我們選擇在attachBaseContext或onCreate中對我們的dex進行動態加載和類加載器修正即可,因為這里我們源dex并未進行加密,所以也無需解密的過程

然后加入導入類的Activity

image-20220612215843088

(3)動態加載

我們進行動態加載classes.dex

image-20220612200319851

然后使用上面的一種方法進行類加載器修正

image-20220612215713138

然后運行

image-20220612215745595

運行成功,說明我們的整體加殼成功

四、脫殼點相關概念詳解

上面我們已經理解了APP加殼的基本原理,下面我們進一步來學習如何進行脫殼,Android APP脫殼繞不開DexFileArtMethod兩個概念,這兩個在脫殼中扮演的至關重要的地位,無數的脫殼點都是從其演變而來。

1.Dex加載流程

我們在分析脫殼點過程中,首先就需要明白Dex加載的基本流程

image-20220612215745595

1

2

3

DexPathList:該類主要用來查找Dex、SO庫的路徑,并這些路徑整體呈一個數組

Element:根據多路徑的分隔符“;”將dexPath轉換成File列表,記錄所有的dexFile

DexFile:用來描述Dex文件,Dex的加載以及Class的查找都是由該類調用它的native方法完成的

我們依次來分析這個過程中的源碼

DexPathList

1

2

3

4

5

6

7

8

/libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

public DexPathList(ClassLoader definingContext, String dexPath,

????????????String librarySearchPath, File optimizedDirectory) {

**********************?????

???this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,

?????????????????????????????????????????suppressedExceptions, definingContext);???

**********************?

????????????}

makeDexElements

1

2

3

4

5

6

private static Element[] makeDexElements(List<File> files, File optimizedDirectory,

??????????List<IOException> suppressedExceptions, ClassLoader loader) {

**********************???????????

???????DexFile dex = loadDexFile(file, optimizedDirectory, loader, elements);???

**********************????????

??????????}

loadDexFile

1

2

3

4

5

6

7

8

9

10

private static DexFile loadDexFile(File file, File optimizedDirectory, ClassLoader loader,

???????????????????????????????????????Element[] elements)

????????????throws IOException {

????????if (optimizedDirectory == null) {

????????????return new DexFile(file, loader, elements);

????????} else {

???????????String optimizedPath = optimizedPathFor(file, optimizedDirectory);

????????????return DexFile.loadDex(file.getPath(), optimizedPath, 0, loader, elements);

????????}

????}

loadDex

1

2

3

4

static DexFile loadDex(String sourcePathName, String outputPathName,

??????int flags, ClassLoader loader, DexPathList.Element[] elements) throws IOException {

??????return new DexFile(sourcePathName, outputPathName, flags, loader, elements);

??}

DexFile

1

2

3

4

5

6

7

/libcore/dalvik/src/main/java/dalvik/system/DexFile.java

DexFile(String fileName, ClassLoader loader, DexPathList.Element[] elements) throws IOException {

????????mCookie = openDexFile(fileName, null, 0, loader, elements);

????????mInternalCookie = mCookie;

????????mFileName = fileName;

????????//System.out.println("DEX FILE cookie is " + mCookie + " fileName=" + fileName);

????}

這里出現的mCookie,mCookie在C/C++層中是DexFile的指針,我們在下面詳細講解

openDexFile

1

2

3

4

5

6

7

8

9

10

11

private static Object openDexFile(String sourceName, String outputName, int flags,

????????ClassLoader loader, DexPathList.Element[] elements) throws IOException {

???????// Use absolute paths to enable the use of relative paths when testing on host.

????????return openDexFileNative(new File(sourceName).getAbsolutePath(),

?????????????????????????????????(outputName == null)

????????????????????????????????????? null

???????????????????????????????????: new File(outputName).getAbsolutePath(),

??????????????????????????????????????flags,

???????????????????????????????????loader,

???????????????????????????????????elements);

????}

這里就進入了C/C++層

openDexFileNative

image-20220613134340460

為了節約篇幅,我們快速分析,中間再經過一些函數

1

2

3

4

OpenDexFilesFromOat()

MakeUpToDate()

GenerateOatFileNoChecks()

Dex2Oat()

最后進進入了Dex2Oat,這就進入了Dex2Oat的編譯流程

反之如果我們在下面Dex2Oat的流程中通過Hook相關方法或execv或execve導致dex2oat失敗,我們就會返回到OpenDexFilesFromOat

OpenDexFilesFromOat

image-20220613145156590

會先在HasOriginalDexFiles里嘗試加載我們的Dex,也就是說,倘若我們的殼阻斷了dex2oat的編譯流程,然后又調用了DexFile的Open函數。

DexFile::Open

image-20220613145606897

校驗dex的魔術字字段,然后調用DexFile::OpenFile

DexFile::OpenFile

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

/art/runtime/dex_file.cc

std::unique_ptr<const DexFile> DexFile::OpenFile(int fd,

????????????????????????????????????????????????const std::string& location,

????????????????????????????????????????????????bool verify,

????????????????????????????????????????????????bool verify_checksum,

????????????????????????????????????????????????std::string* error_msg) {

?**************************************

?std::unique_ptr<DexFile> dex_file = OpenCommon(map->Begin(),

????????????????????????????????????????????????map->Size(),

????????????????????????????????????????????????location,

????????????????????????????????????????????????dex_header->checksum_,

????????????????????????????????????????????????kNoOatDexFile,

????????????????????????????????????????????????verify,

????????????????????????????????????????????????verify_checksum,

????????????????????????????????????????????????error_msg);??

??**************************************

????????????????????????????????????????????????}

OpenCommon

image-20220613145950224

最后又再次回到DexFile類,這里我們的dex文件加載基本流程分析完畢

2.Dex2Oat編譯流程

Dex2oat是google公司為了提高編譯效率的一種機制,從Android8.0開始實施,一些加殼廠商實現抽取殼往往會禁用Dex2oat,而針對整體加殼沒有禁用的Dex2Oat也成為了脫殼點

image-20220613134904994

Exec

1

2

3

4

5

6

7

8

9

10

11

/art/runtime/exec_utils.cc

bool Exec(std::vector<std::string>& arg_vector, std::string* error_msg) {

??int status = ExecAndReturnCode(arg_vector, error_msg);

??if (status != 0) {

????const std::string command_line(android::base::Join(arg_vector, ' '));

????*error_msg = StringPrintf("Failed execv(%s) because non-0 exit status",

??????????????????????????????command_line.c_str());

????return false;

??}

??return true;

}

ExecAndReturnCode

image-20220613143206138

而我們就可以通過Hook execv或execve來禁用Dex2Oat,而如果我們不禁用dex2oat,execve函數是用來調用dex2oat的二進制程序實現對dex文件的加載,我們這時候找到dex2oat.cc這個文件,找到main函數

1

2

3

4

5

6

7

/art/dex2oat/dex2oat.cc

?int main(int argc, char** argv) {

??int result = static_cast<int>(art::Dex2oat(argc, argv));

??if (!art::kIsDebugBuild && (RUNNING_ON_MEMORY_TOOL == 0)) {

????_exit(result);

??}

??return result;

這里我們調用了Dex2oat

Dex2Oat

1

2

3

4

5

6

7

8

9

10

11

12

/art/dex2oat/dex2oat.cc

static dex2oat::ReturnCode Dex2oat(int argc, char** argv) {

???**************************************

???dex2oat::ReturnCode setup_code = dex2oat->Setup();

????dex2oat::ReturnCode result;

??if (dex2oat->IsImage()) {

????result = CompileImage(*dex2oat);

??} else {

????result = CompileApp(*dex2oat);

?}

???**************************************

}

Dex2oat中會對dex文件進行逐個類逐個函數的編譯,setup()函數完成對dex的加載

然后順序執行,就會進入CompileApp

編譯過程中會按照逐個函數進行編譯,就會進入CompileMethod

image-20220613151229524

到這里Dex2oat的基本流程就分析完畢

3.類加載流程

要理解DexFile為什么如此重要,首先我們要清除Android APP的類加載流程。Android的類加載一般分為兩類隱式加載顯式加載

1

2

3

4

5

6

7

8

9

1.隱式加載:

????(1)創建類的實例,也就是new一個對象

????(2)訪問某個類或接口的靜態變量,或者對該靜態變量賦值

????(3)調用類的靜態方法

????(4)反射Class.forName("android.app.ActivityThread")

????(5)初始化一個類的子類(會首先初始化子類的父類)

2.顯示加載:

????(1)使用LoadClass()加載

????(2)使用forName()加載

我們詳細看一下顯示加載:

1

2

3

Class.forName 和 ClassLoader.loadClass加載有何不同:

1)ClassLoader.loadClass也能加載一個類,但是不會觸發類的初始化(也就是說不會對類的靜態變量,靜態代碼塊進行初始化操作)

2)Class.forName這種方式,不但會加載一個類,還會觸發類的初始化階段,也能夠為這個類的靜態變量,靜態代碼塊進行初始化操作

我們在詳細來看一下在類加載過程中的流程:

java層

image-20220612215745595

我們可以發現類加載中關鍵的DexFile,該類用來描述Dex文件,所以我們的脫殼對象就是DexFile

這里從DexFile進入Native層中,還有一個關鍵的字段就是mCookie

image-20220613102141423

后面我們詳細的介紹mCookie的作用

我們進一步分析,進入Native層

Native層

/art/runtime/native/[dalvik_system_DexFile.cc

image-20220613124716608

1

ConvertJavaArrayToDexFiles對cookie進行了處理

image-20220613125016884

通過這里的分析,我們可以知道mCooike轉換為C/C++層指針后,就是dexfile的索引

我們繼續分析DefineClass

1

2

3

4

5

6

7

8

9

10

11

art/runtime/class_linker.cc

mirror::Class* ClassLinker::DefineClass(Thread* self,

??????????????????????????????????????const char* descriptor,

????????????????????????????????????????size_t hash,

???????????????????????????????????????Handle<mirror::ClassLoader> class_loader,

????????????????????????????????????????const DexFile& dex_file,

????????????????????????????????????????const DexFile::ClassDef& dex_class_def) {

***************

LoadClass(self, *new_dex_file, *new_class_def, klass);

***************

}

LoadClass

1

2

3

4

5

6

7

8

9

10

11

art/runtime/class_linker.cc

void ClassLinker::LoadClass(Thread* self,

3120??????????????????????????? const DexFile& dex_file,

3121??????????????????????????? const DexFile::ClassDef& dex_class_def,

3122??????????????????????????? Handle<mirror::Class> klass) {

3123? const uint8_t* class_data = dex_file.GetClassData(dex_class_def);

3124? if (class_data == nullptr) {

3125??? return;? // no fields or methods - for example a marker interface

3126? }

3127? LoadClassMembers(self, dex_file, class_data, klass);

3128}

LoadClassMembers

1

2

3

4

5

6

7

8

9

10

art/runtime/class_linker.cc

void ClassLinker::LoadClassMembers(Thread* self,

???????????????????????????????????const DexFile& dex_file,

???????????????????????????????????const uint8_t* class_data,

???????????????????????????????????Handle<mirror::Class> klass) {

***************

??????LoadMethod(dex_file, it, klass, method);

??????LinkCode(this, method, oat_class_ptr, class_def_method_index);

***************

}

LoadMethod

1

2

3

4

5

6

art/runtime/class_linker.cc

void ClassLinker::LoadMethod(const DexFile& dex_file,

???????????????????????????const ClassDataItemIterator& it,

????????????????????????????Handle<mirror::Class> klass,

?????????????????????????????ArtMethod* dst) {

}

LinkCode

image-20220613130149629

我們可以發現這里就進入了從linkcode后就進入了解釋器中,并對是否進行dex2oat進行了判斷,我們直接進入解釋器中繼續分析

我們知道Art解釋器分為兩種:解釋模式下quick模式下,而我們又知道Android8.0開始進行dex2oat

1

2

3

如果殼沒有禁用dex2oat,那類中的初始化函數運行在解釋器模式下

如果殼禁用dex2oat,dex文件中的所有函數都運行在解釋器模式下

則類的初始化函數運行在解釋器模式下

所以一般的加殼廠商會禁用掉dex2oat,這樣可以是所有的函數都運行在解釋模式下,所以一些脫殼點選在dex2oat流程中,可能針對禁用dex2oat的情況并不使用,我們這里主要針對整體加殼,就不展開講述,最后我們得知解釋器中會運行在Execute

Execute

1

2

3

4

5

6

7

8

9

10

11

12

13

art/runtime/interpreter/interpreter.cc

static inline JValue Execute(

????Thread* self,

????const DexFile::CodeItem* code_item,

????ShadowFrame& shadow_frame,

????JValue result_register,

????bool stay_in_interpreter = false) REQUIRES_SHARED(Locks::mutator_lock_){

***************

??????ArtMethod *method = shadow_frame.GetMethod();

***************

????}

這里我們大致分析完成了類加載的思路

4.DexFile詳解

前面我們分析了很多,對dex加載、類加載等都已經有了一個很詳細的了解,而最終一切的核心就是DexFile,DexFile就是我們脫殼所關注的重點,寒冰大佬在撥云見日:安卓APP脫殼的本質以及如何快速發現ART下的脫殼點中提到,在ART下只要獲得了DexFile對象,那么我們就可以得到該dex文件在內存中的起始地址和大小,進而完成脫殼。

我們先查看一些DexFile的結構體

image-20220613152305983

只要我們能獲得起始地址begin和大小size,就可以成功的將dex文件脫取下來,這里我們記得DexFile含有虛函數表,所以根據C++布局,要偏移一個指針

image-20220613152517629

而DexFile類還給我們提供了方便的API

image-20220613152724888

這樣只要我們找到函數中有DexFile對象,就可以通過調用API來進一步dump dex文件,由此按照寒冰大佬的思想,大量的脫殼點由此產生

(1)直接查找法

我們通過直接在Android源碼中搜索DexFile,就可以獲得海量的脫殼點

image-20220613153036103

我們通過在IDA中搜索libart.so導出的DexFile,同樣可以獲得大量的脫殼點

image-20220613153220786

(2)間接查找法

這里就是寒冰大佬在文章中提到的通過ArtMethod對象的getDexFile()獲取到ArtMethod所屬的DexFile對象的這種一級間接法,通過Thread的getCurrentMethod()函數首先獲取到ArtMethod或者通過ShadowFrame的getMethod獲取到ArtMethod對象,然后再通過getDexFile獲取到ArtMethod對象所屬的DexFile的二級間接法。

1

2

getDexFile()

getMethod()

5.ArtMethod詳解

上面我們已經詳細分析了DexFile的文件結構,我們知道通過ArtMethod可以獲得DexFile,那么為啥又要單獨提ArtMethod呢,因為ArtMethod在抽取殼和VMP等殼中扮演了重要的角色

ArtMethod結構體

image-20220613154044296

我們通過ArtMethod可以獲得codeitem的偏移和方法索引,熟悉dex結構的朋友知道codeitem就是代碼實際的值,而codeitem則再后續加殼技術扮演了至關重要的地址,而且ArtMethod還有非常豐富的方法,可以幫助大家實現很多功能,所以在脫殼工作中也是十分重要的

五、脫殼技術歸納

前面分析了很多,最后無非整體加殼的脫殼方案落腳在DexFile的關鍵對象上,由此產生了一些常用的方法

image-20220613154906679

1.現有工具脫殼法

工欲善其事必先利其器,整體加殼已經很多年,不少的大佬們都開發了很多非常好用的工具,我們在自己掌握原理過程時,平時工作中也可以使用很多大佬的開發工具,這里隨便舉幾個自己經常用的工具,這里我對各個大佬的脫殼工具進行了一個梳理

image-20220613154906679

(1)FRIDA-DEXDump

這是葫蘆娃大佬開發的針對整體加殼的工具,主要通過frida技術,文章參考:深入 FRIDA-DEXDump 中的矛與盾,該工具的特點是一般的hook方案通過直接搜索DEX的頭文件dex.035來定位dex的起始地址,但是后來不少公司對頭文件的魔術字段進行了抹除,這樣針對沒有文件頭的 DEX 文件,該工具通過map_off 找到 DEX 的 map_list, 通過解析它,并得到類型為 TYPE_MAP_LIST 的條目計算出文件的大小和起始地址,也很好的提供了一種解決思路。

使用方法:

FRIDA-DEXDump使用十分的簡單,詳細參考github:FRIDA-DEXDump

這里引用一張大佬星球的使用流程圖,非常詳細,快速進行脫殼

image-20220613161015961

我們簡單演示一下,這里結合objection一起使用

image-20220613224143108

然后再次打開脫下來的dex,即可

(2)FDex2

Fdex2主要是利用Android7.0及版本以下的特殊API getDex()來進行脫殼,原本是基于Xposed的模塊,不過掌握原理后,大家可以使用各種Hook框架去實現,參考鏈接:安卓xposed脫殼工具FDex2?

(3)其他工具

針對整體殼的脫殼工具有很多,無非是針對各種脫殼點再采用不同的方法,其原理是殊途同歸,而基于源碼定制的Fart、youpk等等針對整體加殼殼都可以基本實現完全的脫殼,而且抽取殼也有著很好的效果,下面我們就依次來講述具體的脫殼方法原理,各種脫殼工具如下圖所示:

image-20220613162509955

2.Hook脫殼法

我們前面知道了,只要函數中包含DexFile對象,我們就可以通過Hook技術拿到對象,然后取到begin和size,從而進行脫殼,市面上使用較多的無非是Xposed和frida,我平時使用frida較為方便,這里也用frida和大家演示:

首先我們使用GDA識別加殼程序

image-20220613164418413

很明顯是進行了整體加殼,有沒其他加殼暫時不知道,我們先進行脫殼

找到脫殼點

通過IDA打開libart.so,搜索DexFile,我們可以找到海量的脫殼點

image-20220613164747966

我們就隨便找一個包含DexFile的脫殼函數,然后記錄符號值

image-20220613164841377

然后我們編寫hook腳本

image-20220613172003659

1

這里之所以獲取begin加上一個指針,是因為我們前面講了dexfile含有一個虛函數地址,所以加上一個指針偏移

然后啟動frida_server

image-20220613170608657

附加進程進行dump,這里我們存在sdcard下面,所以需要提前賦予sdcard權限

image-20220613172109177

這里就脫殼成功

image-20220613172222202

然后我們打開相應的dex

image-20220613172222202

此時說明我們整體脫殼成功,不過應用還有抽取殼,這個不是本文解決的內容

3.插樁脫殼法

插樁脫殼法,就是在Android源碼里面定位到相應的脫殼點,然后插入相應的代碼,重新編譯源碼生成系統鏡像,最后就可以使用定制的系統進行脫殼

我們在源碼編譯(1)——Android6.0源碼編譯詳解中已經講述了如何編譯源碼,接下來我們進行插樁脫殼

同理、還是定位脫殼點,我們還是隨便定位一個脫殼點LoadMethod 然后進行插樁

image-20220613220518548

1

2

3

4

5

6

7

8

9

10

11

12

13

14

//add

char dexfilepath[100]=0;

memset(dexfilepath,0,100);

sprintf(dexfilepath,"%d_%zu_LoadMethod.dex",getpid(),dex_file.Size());

int dexfd = open(dexfilepathm,O_CREAT|O_RDWR,666);

if(dexfd>0){

????int result = write(dexfd,dex_file.Begin(),dex_file.Size());

????if(result>0){

????????close(dexfd);

????????LOG(WARNING)<<"LoadMethod"<<dexfilepath;

????}

}

//add

同理我們在execute同樣插樁此段代碼,最后進行編譯,編譯成功

image-20220613172222202

然后給程序授權sdcard權限,再次啟動應用,就可以看見脫取的dex文件就保存在sdcard目錄下

image-20220613215956911

再次將sdcard下dex文件打開,這里我們已經看見了8732435這個文件,再次打開脫取成功

image-20220613172222202

4.反射脫殼法

反射脫殼法的核心思想就是利用前面我們提到的mCooike值

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

核心思路:反射 + mCookie

步驟:

1、找到加固apk的任一class,一般選擇主Application或Activity

2、通過該類找到對應的Classloader

3、通過該Classloader找到BaseDexClassLoader

4、通過BaseDexClassLoader找到其字段DexPathList

5、通過DexPathList找到其變量Element數組dexElements

6、迭代該數組,該數組內部包含DexFile結構

7、通過DexFile獲取其變量mCookie和mFileName

至此我們已經獲取了mCookie

對該mCookie的解釋:

#1、4.4以下好像,mCookie對應的是一個int值,該值是指向native層內存中的dexfile的指針

#2、5.0是一個long值,該值指向native層std::vector<const DexFile*>* 指針,注意這里有多個dex,你需要找到你要的

#3、8.0,該值也是一個long型的值,指向底層vector,但是vector下標0是oat文件,從1開始是dex文件

// 至于你手機是那個版本,如果沒有落入我上面描述的,你需要自己看看代碼

8、根據mCookie對應的值做轉換,最終你能找到dexfile內存指針

9、把該指針轉換為dexfile結構,通過findClassDef來匹配你所尋找的dex是你要的dex

10、dump寫文件

綜述mCookie是在native層就是dexfile的指針,我們利用反射原理來獲取mCookie,從而就可以進行脫殼了,這里我們同樣使用frida演示:

編寫hook代碼

image-20220613190231102

image-20220613190303491

我們看見了和上面同樣大小的8841876_mCookie.dex

image-20220613190401266

使用工具打開,發現同樣脫殼成功

image-20220613190438224

5.動態調試脫殼法

所謂動態調試法,核心原理和上面一樣,就是我們在動態調試的過程中找到DexFile的起始地址和大小,然后執行腳本進行dump

首先選取脫殼點,我們還是選擇DexFile::DexFile

image-20220613210130186

動態調試的步驟我在前面的文章中已經做了詳細的講解,不會的朋友去看前面的文章

首先我們啟動android_server

image-20220613194111711

然后我們附加上進程

image-20220613194326114

image-20220613195349008

image-20220613203024990

然后我們打開libart.so,并定位到DexFile::DexFile

image-20220613210859021

然后在該函數下斷點,然后F9過來

image-20220613211145356

此處我們就可以很明顯看到X1就是我們的起始地址,X4是我們的偏移值

編寫腳本進行hook

1

2

3

4

5

6

7

8

9

10

11

static main(void){???

????auto fp, begin, end, dexbyte;?????

????fp = fopen("d:\\dump.dex", "wb+");?????

????begin =? 0x76FCD93020;???

????end = begin + 0x7EEC5600;

????for ( dexbyte = begin; dexbyte<end;dexbyte++)

????{

????fputc(Byte(dexbyte), fp);???????

????}??

}

image-20220613214647627

直接運行run

然后我們查看dump.dex文件

image-20220613223330340

image-20220613215148251

我們可以發現這里是代理類,還沒有到我們想要的dex,我們再次F9,再次到這里,地址再次改變,再次結合長度來計算,我們每次計算可以取小點值,先試一下

image-20220613215343895

發現還是不是,我們需要不停測試直到dump出dex為此

這里大家可以下去按照此方法嘗試,或者換一個脫殼點來嘗試

6.特殊API脫殼法

所謂特殊的API脫殼法就是通過Android自身提供的API來獲得Dex,這主要是參考Fdex2,前面我們講了Fdex2主要是利用Android7.0及以下提供了getDex()和getBytes()兩個API,我們可以直接可以獲得class對象,然后直接調用這兩個API

image-20220613191047342

image-20220613191158377

編寫hook代碼:

image-20220613192251923

1

2

3

4

1.使用frida枚舉所有Classloader

2.確定正確的ClassLoader并獲取目標類的Class對象

3.通過Class對象獲取得到dex對象

4.通過dex對象獲取內存字節流并保存

然后我們查看程序的類對象,隨便dump一個類對象

image-20220613191722932

image-20220613192447143

然后我們再次用工具打開

image-20220613192548342

image-20220613192659791

發現就可以成功的dump

通過這種方式,我們發現神奇的事我們還可以抽取殼的情況,比如我們之前為空類

image-20220613192926853

我們明顯可以發現這里是采用了函數抽取的技術,一般的一代殼dump方案是無法解決抽取殼的,我們使用特殊API方法

image-20220613193028308

再次打開,成功dump

image-20220613193102209

這其實主要是抽取殼的一個回填時機的問題,這個詳細放在以后抽取殼中講解

六、實驗總結

本文總結了當下dex整體加殼的基本原理,和常用的一些脫殼方案,并一一進行復現,還有一些文件監控法等,由于我平時用的很少就沒列舉了,復現實驗過程中由于涉及到不同的實驗,所以我用了Android 6.0 Android 7.0 Android 8.0三臺機器進行實驗,所以大家可以注意下對應的方法和其Android版本,這里徹底解決了整體加殼的脫殼方案,到這里可以掌握脫殼、抓包、Hook、反Hook、反調、反簽等基本手段,這樣在進行Android App漏洞挖掘過程中將事半功倍。后面我將繼續講解Android App漏洞中的XSS漏洞、Sql注入漏洞、文件上傳漏洞、端口掃描漏洞、WebView漏洞等。

脫殼腳本相關樣本會放在github,所有的脫殼腳本和工具和上傳知識星球

github:github

七、參考文獻

1

2

3

4

https://bbs.pediy.com/thread-252630.htm#msg_header_h2_4

https://bbs.pediy.com/thread-254555.htm#msg_header_h2_4

https://www.anquanke.com/post/id/221905?display=mobile

https://www.qj301.com/news/317.html

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/41444.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/41444.shtml
英文地址,請注明出處:http://en.pswp.cn/news/41444.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

opencv基礎:幾個常用窗口方法

開始說了一些opencv中的一些常用方法。 namedWindow方法 在OpenCV中&#xff0c;namedWindow函數用于創建一個窗口&#xff0c;并給它指定一個名字。這個函數的基本語法如下&#xff1a; import cv2cv2.namedWindow(窗口名稱, 標識 )窗口名稱&#xff1a;其實窗口名稱&…

Azure創建自定義VM鏡像

創建一個虛擬機&#xff0c;參考 https://blog.csdn.net/m0_48468018/article/details/132267096&#xff0c;入站端口開啟80&#xff0c;22 進行遠程遠程連接 使用CLI命令部署NGINX,輸入如下命令 sudo su apt-get update -y apt-get install nginx git -y最后的效果 4. 關閉…

非結構化數據庫-MinIO基本集成

是什么 MinIO 是一個高性能的分布式對象存儲服務&#xff0c;適合存儲非結構化數據&#xff0c;如圖片&#xff0c;音頻&#xff0c;視頻&#xff0c;日志等。對象文件最大可以達到5TB。 安裝啟動 mkdir -p /usr/local/minio cd /usr/local/minio# 下載安裝包 wget https:/…

pandas.errors.ParserError: Error tokenizing data. C error: out of memory

目錄 用pandas讀入數據的時候發現數據讀入時出錯了&#xff0c;數據量感覺也不是很大 十萬多條數據。電腦內存是16個G。報錯信息為&#xff1a;“ pandas.errors.ParserError: Error tokenizing data. C error: out of memory” 想想不對啊 昨天都可以順利的讀入&#xff0c;現…

你真的掌握了 Python 的七種參數了嗎?

不知道為什么網上總有人說 Python 的參數類型有 4 種啊&#xff0c;5 種啊&#xff0c;殊不知其實有 7 種。Python 的 7 種參數分別是 默認參數、位置參數、關鍵字參數、可變長位置參數、可變長關鍵字參數、僅位置參數 和 僅關鍵字參數。小白可能沒見過“可變長參數”&#xff…

lvs-dr模式

一&#xff0c;數據包流向&#xff1a; 1&#xff0c;cilent向目標vip發出請求&#xff0c;dir接收&#xff0c;此時ip報頭數據幀頭信息。 2&#xff0c;dir根據負載均衡算法給rs&#xff08;rip&#xff09;&#xff0c;將rip所在網卡的mac地址作為目標的mac地址&#xff0c;發…

深入解析Spring基本概念和核心思想

文章目錄 基本概念IoCIoc容器IoC理解IoC的步驟Spring中使用ioc的步驟 AopAop的理解Aop的步驟 控制反轉誰控制誰? 控制什么?為何叫反轉(對應于正向)?哪些方面反轉了?為何需要反轉? 依賴什么是依賴(按名稱理解、按動詞理解)? 誰依賴于誰? 為什么需要依賴? 依賴什么東西?…

vscode如何漢化

首先我們到vscode官網下載 鏈接如下&#xff1a; Visual Studio Code - Code Editing. Redefined 根據自己需要的版本下載就好 下載并且安裝完畢之后 運行vscode 然后按快捷鍵 CTRLSHIFTX 打開安裝擴展界面 搜索簡體中文 安裝就可以了 謝謝大家觀看

npm ERR!Cannot read properties of null(reading ‘pickAlgorithm’)報錯問題解決

當在使用npm包管理器或執行npm命令時&#xff0c;有時候會遇到“npm ERR!Cannot read properties of null(reading ‘pickAlgorithm’)”這個錯誤提示&#xff0c;這是一個常見的npm錯誤。 這個錯誤提示通常說明在使用npm包管理器時&#xff0c;執行了某個npm命令&#xff0c;…

學習筆記整理-正則表達式-01-認識正則

一、基本認識 1. 什么是正則表達式 正則表達式(regular expression)描述了字符串"構成模式"&#xff0c;經常被用于檢查字符串是否符合預定的格式要求。 用一個例子快速演示正則表達式基本使用方法&#xff1a;檢查某個字符串是否是6位數字 // 要檢查的字符串va…

第五課:聯合體或共用體 Union

功能描述&#xff1a;聯合體或共用體 Union 的介紹及使用方法 一、Union 簡介 共同體&#xff08;union&#xff09;也稱為聯合體&#xff0c;它能在同一個內存空間中存儲不同的數據類型&#xff08;不是同時存儲&#xff09;&#xff0c;其典型的用法是&#xff0c;設計一種表…

Vue3 —— watchEffect 高級偵聽器

該文章是在學習 小滿vue3 課程的隨堂記錄示例均采用 <script setup>&#xff0c;且包含 typescript 的基礎用法 前言 Vue3 中新增了一種特殊的監聽器 watchEffect&#xff0c;它的類型是&#xff1a; function watchEffect(effect: (onCleanup: OnCleanup) > void,o…

整理mongodb文檔:find方法查詢數據

個人博客 整理mongodb文檔:find方法查詢數據 求關注&#xff0c;求批評&#xff0c;求指出&#xff0c;如果哪兒不清晰&#xff0c;請指出來&#xff0c;謝謝 文章概敘 如題&#xff0c;本文講的是如何用find查詢數據&#xff0c;如何在數組、字段、對象中查詢&#xff0c;以…

自然語言處理技術:NLP句法解析樹與可視化方法

自然語言處理(Natural Language Processing,NLP)句法解析樹是一種表示自然語言句子結構的圖形化方式。它幫助將句子中的每個詞匯和短語按照語法規則連接起來,形成一個樹狀結構,以便更好地理解句子的語法結構和含義。句法解析樹對于理解句子的句法關系、依存關系以及語義角…

Python實現輕量級WEB服務器接收HTTP提交的RFID刷卡信息并回應驅動讀卡器顯示播報語音

本示例使用的設備&#xff1a;RFID網絡WIFI無線TCP/UDP/HTTP可編程二次開發讀卡器POE供電語音-淘寶網 (taobao.com) # -*- coding: utf-8 -*- import time import datetime import socket import threading#將中文信息轉換編碼&#xff0c;顯示文字、TTS語音都需要轉換-------…

從入門到精通Python隧道代理的使用與優化

哈嘍&#xff0c;Python爬蟲小伙伴們&#xff01;今天我們來聊聊如何從入門到精通地使用和優化Python隧道代理&#xff0c;讓我們的爬蟲程序更加穩定、高效&#xff01;今天我們將對使用和優化進行一個簡單的梳理&#xff0c;并且會提供相應的代碼示例。 1. 什么是隧道代理&…

SpringCloud Gateway:status: 503 error: Service Unavailable

使用SpringCloud Gateway路由請求時&#xff0c;出現如下錯誤 yml配置如下&#xff1a; 可能的一種原因是&#xff1a;yml配置了gateway.discovery.locator.enabledtrue&#xff0c;此時gateway會使用負載均衡模式路由請求&#xff0c;但是SpringCloud Alibaba刪除了Ribbon的…

無涯教程-Perl - setpwent函數

描述 此功能將枚舉設置(或重置)到密碼條目集的開頭。應該在第一次調用getpwent之前調用此函數。 語法 以下是此函數的簡單語法- setpwent返回值 此函數不返回任何值。 例 以下是顯示其基本用法的示例代碼- #!/usr/bin/perlwhile(($name, $passwd, $uid, $gid, $quota, …

C++寫文件,直接寫入結構體

C寫文件&#xff0c;直接寫入結構體 以前寫文件都是寫入字符串或者二進制再或者就是一些配置文件&#xff0c;今天介紹一下直接寫入結構體&#xff0c;可以在軟件參數較多的時候直接進行讀寫&#xff0c;直接將整個結構體寫入和讀取&#xff0c;看代碼&#xff1a; #include&…

tomcat中的BIO與NIO發展

tomcat中的NIO發展 前言 Tomcat目前支持BIO&#xff08;阻塞 I/O&#xff09;、NIO&#xff08;非阻塞 I/O&#xff09;、AIO&#xff08;異步非阻塞式IO&#xff0c;NIO的升級版&#xff09;、APR&#xff08;Apache可移植運行庫&#xff09;模型&#xff0c;本文主要介紹NI…