ARM 彙編入門指南 2

上文回顧

在上一篇《ARM 彙編入門指南 1》文章裏我們用一個 helloworld 的例子帶着大家對 ARM 彙編有了一個最基本的認知,大致瞭解了一些 ARM 彙編入門的最基礎的知識點:例如 ARM64 架構下的寄存器,內存佈局,僞指令和幾個常用的彙編指令等,在介紹新的知識點之前,我們先來回顧一下這幾個彙編的基礎知識:

  1. 在函數開始的時候使用 SUB 指令操作 sp 寄存器可以申請新的棧內存空間,在函數結束的時候使用 ADD 指令操作 sp 寄存器來釋放之前申請的棧內存空間。

  2. 使用 ST 指令將寄存器的值保存到棧內存上(寄存器 -> 棧),使用 LD 指令將棧內存上的值保存到寄存器中(棧 -> 寄存器)。

  3. 使用 BL 指令來調用函數進行跳轉。

其實上一篇的重點是介紹在 ARM 彙編中如何 調用函數 這個重要的知識點,因爲大部分我們去分析彙編代碼的時候,重點關注的都是函數之間的調用情況,例如一個函數內調用了哪些函數,一個函數被哪些函數調用過等這些關鍵的信息來從彙編的層面來分析和理解程序的邏輯。

類和對象

在這一篇文章裏,我們將接着上文繼續來學習另一個重要的知識點,那就是如何在 ARM 彙編中去做 對象實例化 ,說直白一點就是用 ARM 彙編去實現創建一個 c++ 的類 ( class) 的實例化對象 ( object )。

在上一篇 helloworld 中,我們故弄玄虛的使用了倒敘的手法,先給大概來看彙編代碼,再通過一行行的解釋來引出其對應的 C++ 代碼,這其實就是代碼逆向的過程。在這一篇文章裏面,我們將採用正序的手法,按照正常人的邏輯,先給大家看 C++ 代碼是怎樣寫的,讓大家先能理解代碼邏輯,然後使用編譯器將其編譯後看看它所對應的反彙編代碼。

我們先來看我們這個例子裏面的 C++ 代碼裏面的這個 類的對象 長什麼樣的:

object.h

#ifndef H_Object
#define H_Object

class Object 
{

public:
   Object(long id, int type);

   int GetType();

private:
   int type_;

public:
   long id_;
};

#endif

object.cpp

#include "object.h"

Object::Object(long id, int type) : id_(id), type_(type) {
}

int Object::GetType() {
   return type_;
}

我們的 對象 很簡單,就是一個名爲 Object 的 C++ 類,它有兩個成員變量:idtype,並且有 1 個成員方法:GetType()

棧上實例化對象

我們先來看看在棧上 實例化對象 的 ARM 彙編是怎樣的,我們的 C++ 代碼大概是這樣的:

#include "object.h"

int main() {
   Object object(1, 2);
   return 0;
}

其實我們只是將上一篇 helloworldmain 函數里面修改了一行,將調用 printf 函數修改成了調用 Object 的構造器函數,用來在棧上面實例化了一個新的 Object 對象,它的 id 等於 1, type 等於 2。

接下來我們還是使用 Android NDK 自帶的 clang 編譯將其編譯成二進制,然後使用 objdump 反彙編獲得它的 ARM 彙編代碼,在本例中我們使用的也和上文一樣都是使用 ARM64 的架構來進行學習,這裏我們的 main 函數反彙編後的代碼量明顯比我們上文的 helloworld 的要長很多,不過其實大部分和 helloworld 的彙編代碼差不多,在經過了上一篇一行一行的彙編代碼學習後,我們應該已經有足夠的基礎來一段一段的學習彙編代碼了,下面我們先來看函數開始和和函數結束的這兩段彙編代碼:

// 函數開始
708:   d10103ff       sub     sp, sp, #0x40 // 申請棧內存
70c:   a9037bfd       stp     x29, x30, [sp,#48] // 備份sp和lr寄存器
710:   9100c3fd       add     x29, sp, #0x30 // 新的sp

// ...

// 函數結尾
760:   a9437bfd       ldp     x29, x30, [sp,#48] // 還原sp和lr寄存器
764:   910103ff       add     sp, sp, #0x40 // 退還棧內存
768:   d65f03c0       ret

這兩段代碼和上文 helloworld 的邏輯是一模一樣的,也是在函數一開始的位置先使用 SUB 彙編指令申請一塊新的棧空間,然後使用 STP 彙編指令將 x29(sp)x30(lr)寄存器的值先備份到棧內存上,並且在函數結束的時候使用 LDP 彙編指令將棧上備份的值還原到 x29(sp)x30(lr) 寄存器中,如果看過上一篇文章的同學對於這兩段彙編代碼就很熟悉了,如果有不理解的可以先去讀上一篇文章這一部分的詳細講解再回來繼續閱讀。

棧內存溢出保護

接下來的彙編代碼就和我們之前的 helloworld 完全不一樣的,並且它也是在函數開始和函數結束遙相呼應的:

// ...
714:   d53bd048       mrs     x8, tpidr_el0 // 讀取tpidr_el0作爲canary值
718:   f9401509       ldr     x9, [x8,#40]
71c:   f81f83a9       stur   x9, [x29,#-8] // 放canary金絲雀在棧上面做保護

// ...

744:   f9401509       ldr     x9, [x8,#40]
748:   f85f83a0       ldur   x0, [x29,#-8]
74c:   eb000129       subs   x9, x9, x0 // 檢查棧上面的canary金絲雀是否被修改
750:   f90003e9       str     x9, [sp]
754:   540000c1       b.ne   76c <main+0x64>
758:   14000001       b       75c <main+0x54>
// ...
76c:   97ffffb1       bl     630 <__stack_chk_fail@plt> // 金絲雀死了修改則認爲棧溢出

這兩段彙編代碼其實並不是我們寫的 C++ 代碼,而是 clang 編譯器在 SafeStack 階段在彙編代碼中插入的棧溢出保護的代碼,這是一種常見的 canary 金絲雀的棧保護機制,它通過在棧底保存一個數據,在這裏是 TLS 裏面的一個值 ( tpidr_el0, #40 ),然後在函數退出的時候,檢查這個棧底的數據是否有被修改過,如果被修改了則認爲棧溢出,跳轉到 __stack_chk_fail() 函數中。

在這個棧保護的機制裏面,使用了 MRS 彙編指令,它的作用是從 CPSR程序狀態寄存器中讀取數據,而 MSR彙編指令則是向 CPSR寄存器中寫入數據。這是通用性的解釋,但其實根據 ARM 的文檔,AArch64狀態下其實是沒有等同於 arm32 的 CPSR (Current Program State Register) 寄存器的,而是取而代之使用叫做 Process State 的機制來實現類似的功能,包括 NZCV 條件碼標誌位等。

其中這裏使用的是 MRS 指令去讀取 tpidr_el0 的值,它按照 ARM 文檔的解釋是指:Provide locations to store the IDs of software threads and processes for OS management purposes. 按此處彙編上下文的意思,推測是獲取當前線程的 pthread 結構體內的某個值作爲 canary 的值來保護棧,但未查到相同文檔說明該值代表什麼意思,但具體這個值是什麼並不影響對於棧保護內存機制的理解,從理論來說它可以是一個隨機數,只要確保進函數時和出函數時是一致的即可實現保護效果。

另外還有兩個不認識的彙編指令是 SUBSB.NE ,其中 SUBSSUB 一樣,都是表示減法,但是增加了 S 的後綴,表示則計算後會去更新 NVCV 條件碼標誌位,然後 B.NEB 也類似,也是跳轉指令,只是增加了 .NE 或者 .EQ 後綴,則表示是條件跳轉,如果不相等或者相等才跳轉,類似於我們代碼裏面寫得 if (x != y) goto A; else goto B 類似的邏輯。這裏寫得是指如果 棧上面現在的canary 的值不等於之前保存的 canary 值,則跳轉到 __stack_chk_fail() 函數里面去表示棧溢出了。

調用構造函數

在棧上面實例化一個 class 的對象,其實就是調用一下這個 class 對象的構造函數,和調用一個普通的函數類似,例如在上文的 helloworld 中調用 printf 函數一樣,也是先準備好調用函數的參數,然後使用 BL 彙編指令跳轉到這個函數地址執行,最後通過寄存器接收函數的返回值即可。

// ...
720:   2a1f03ea       mov     w10, wzr
724:   b90017ea       str     w10, [sp,#20]
728:   320003ea       orr     w10, wzr, #0x1
72c:   2a0a03e1       mov     w1, w10         // 參數1:id = 1
730:   910063e0       add     x0, sp, #0x18   // 參數0:this=棧上的內存地址
734:   321f03e2       orr     w2, wzr, #0x2   // 參數2:type = 2
738:   f90007e8       str     x8, [sp,#8]
73c:   97ffffe7       bl     6d8 <_ZN6ObjectC1Eli> // invoke function: Object::Object(id, type)
740:   f94007e8       ldr     x8, [sp,#8]     // 返回值
// ...

這一段彙編對應的就是我們寫得 C++ 裏面的 Object object(1, 2) 這一句調用構造函數,我們首先準備三個寄存器用於調用 Object::Object(long id, int type) 構造函數的傳參:

這裏很奇怪的地方就是,明明在 C++ 的 Object::Object(long id, int type) 構造函數就 2 個參數,但爲什麼在彙編代碼中,我們需要傳遞三個參數進去,這其實是 C++class 語法引起的,在調用每個 class 的成員函數時,其實都隱式的將第 0 個參數 this 指針傳遞到成員函數中進行函數調用的,即真正在彙編看來的函數簽名是這樣的:

Object::Object(void* this, long id, long type);

在類似像 python 這種其他的語言,其實在書寫代碼的時候,都要求顯式的將 this 或者 self 參數寫出來,只是在像 java 或者 c++ 這種語言中它們是通過隱式傳遞的。

構造函數

下面我們來看 Object 類的構造函數里面幹了些什麼:

00000000000006d8 <_ZN6ObjectC1Eli>:
6d8:   d10083ff       sub     sp, sp, #0x20
6dc:   f9000fe0       str     x0, [sp,#24] // 接收並保存this參數到棧內存
6e0:   f9000be1       str     x1, [sp,#16] // 接收並保存id參數到棧內存
6e4:   b9000fe2       str     w2, [sp,#12] // 接收並保存long參數到棧內存
6e8:   f9400fe0       ldr     x0, [sp,#24] // 加載this參數的值到x0寄存器
6ec:   aa0003e1       mov     x1, x0
6f0:   f9400be8       ldr     x8, [sp,#16]
6f4:   f9000028       str     x8, [x1]   // 將id參數寫入內存塊,偏移爲object基地址+0
6f8:   b9400fe2       ldr     w2, [sp,#12]
6fc:   b9000802       str     w2, [x0,#8] // 將type參數寫入內存塊,偏移爲object基地址+8
700:   910083ff       add     sp, sp, #0x20
704:   d65f03c0       ret

同樣的我們先忽略掉進入函數和退出函數對於棧內存的擴大和縮小,直接看核心的邏輯:我們已經得知構造器一共接收了三個參數:this,  idtype ,它們都是通過寄存器傳值的,分別在 x0 ,  x1, x2 寄存器中,它們的值分別是sp,#1812, 因此 Object 的構造器首先把這 3 個參數都先從寄存器中取出來,將它們存到棧內存上,然後將 this 參數的值即一個棧上的地址加載到 x0 寄存器中,這個地址其實就是用於保存當前的 Object對象的內存起始地址,而指向這個地址的指針其實就是這個 Object 對象的指針,在這裏,這個 Object 對象的地址在棧上的 sp,#18 這個的內存地址。

然後做的事情就是把傳進來的參數的值:id, type 再從棧內存中加載到寄存器中,並將其寫入到 Object 對象的內存塊裏面:

對象內存佈局

到此我們就已經成功在棧上面實例化了一個 Object 對象,下面還是按照老規矩,我們掛上調試器來觀察一下現在棧上的內存佈局和寄存器的變化是這樣的,更進一步的加深理解剛纔學習的這些彙編代碼執行完了之後的情況。需要注意的是,下面的表的從上到下的順序是按照內存地址從小到大的順序,但是因爲這裏我們觀察的是棧內存,因爲棧的生長方向是從高地址向低地址,因此在按照這個表從上到下的順序是從棧頂到棧底的順序:

jkpDgd

堆上實例化對象

在我們學會了在棧上實例化一個對象後,我們將進一步學習如何使用 c++new 關鍵字來在堆內存上實例化一個對象,並獲得一個指向這個堆內存的指針,這也是在 c++ 代碼中比較常見的創建對象實例的方法。我們稍微修改一下我們的 c++ 代碼:

#include <stdio.h>
#include "object.h"

int main() {
   Object* obj = new Object(1, 2);
   return 0;
}

其實也只是簡單的修改了一行代碼,將之前的 Object object(1, 2); 一行修改成了 Object* obj = new Object(1, 2)。我們來看使用 new 關鍵字在堆上面實例化一個對象和直接在棧上實例化一個對象在彙編層面的區別是什麼,這裏我們反彙編以後會發現彙編的代碼量又大大增加了,包括了一些 new , delete, 甚至 try-catch,exception相關的彙編代碼,這裏我們將直接忽略這些超綱的彙編代碼,重點關注在堆上面實例化對象和在棧上實例化對象的主要區別,至於那些超綱的那些知識點我們將在後面的章節進行學習。

這裏我們先摘抄出 main 函數彙編代碼的主要區別:

// ...
64e4:b27c03e0 orrx0, xzr, #0x10   // x0 = 0x10,malloc函數的size參數
64e8:b24003e1 orrx1, xzr, #0x1// x1 = 1, 構造函數的id參數
64ec:321f03e2 orrw2, wzr, #0x2   // x2 = 2,構造函數的type參數
64f0:b81fc3bf sturwzr, [x29,#-4]
64f4:b90013e2 strw2, [sp,#16]
64f8:f90007e1 strx1, [sp,#8]
64fc:94000013 bl6548 <_Znwm> // 調用operatornew[](size=0x10)函數分配堆內存
6500:f90003e0 strx0, [sp] // 將malloc返回的地址作爲構造函數的this參數
6504:f94007e1 ldrx1, [sp,#8]
6508:b94013e2 ldrw2, [sp,#16]
650c:97ffffe7 bl64a8 <_ZN6ObjectC1Eli> // 調用Object構造器
// ...

首先在堆上面去實例化一個對象和在棧上面去實例化一個對象基本是類似的,都是去調用它的構造函數:Object::Object(long id, int type) 來實現對象的實例化的,但最主要的區別是在調用構造函數的時候第一個 this 參數的傳參值,當我們在棧上面實例化對象的時候,這個 this 參數直接傳的是一個指向棧上的內存地址,這塊內存因爲在棧上面,因此在函數退出的時候是直接隨着整個棧內存縮小而自動回收的,因此不需要調用內存回收的函數,而當我們在堆上面實例化對象的時候,這個 this 參數的值是先通過調用 operatornew 這個方法(其實它內部是調用 malloc 函數)在堆內存上申請一塊內存(大小爲 0x10 ),然後將這個堆內存的地址作爲 this 參數的值傳入給構造函數的。

其實 Object 對象在堆內存上的內存佈局和之前在堆上面實例化的對象的內存佈局是一模一樣的,只是內存的基地址一個在棧內存上面,一個在堆內存上面,Object 對象在內存上都是一個 0x10 大小的內存塊,其中前面的 8 bytes 用來存放成員變量 id , 後面的 8 bytes 用來保存成員變量 type 的值。

JFzhbX

訪問成員變量

在我們實例化了一個類的對象後,對於一些 public 的成員變量,我們可以在外部直接訪問到它們,例如我們修改一下我們的 c++ 代碼,增加獲取 Objectid 成員變量的打印語句:

#include <stdio.h>
#include "object.h"

int main() {
   Object* obj = new Object(1, 2);
   printf("id=%lu", obj->id_); // 訪問Object的公開成員變量:id
   return 0;
}

涉及到的彙編代碼如下:

// ...   
6548:9400001b bl65b4 <_Znwm> // 調用new函數申請棧內存
654c:f9000be0 strx0, [sp,#16] // 返回值爲內存地址,將其保存在棧上面
// ...
6558:97ffffe4 bl64e8 <_ZN6ObjectC1Eli> // 將該內存地址作爲Object的this參數傳入構造函數
// ...
656c:f85f03a9 ldurx9, [x29,#-16] // 從棧上面讀取Object的內存基地址
6570:f9400521 ldrx1, [x9,#8] // 做一個0x08的偏移計算,讀取Object的成員變量 `id_` 的內存地址上的值
// ...
6580:97ffff48 bl62a0 <printf@plt> // 將 `id_` 的值作爲參數傳遞給 `printf` 函數打印

其實我們從之前的 Object 對象在實例化之後在內存中的佈局就可以得知,要想訪問到 Object 對象的成員變量的值,其實都可以通過 Object 對象的內存基地址增加一個 offset 偏移量計算得到其成員變量的內存地址,然後讀取該內存地址上的值,即得到了這個成員變量的具體值。這樣我們要訪問 Object 對象的 id_ 成員變量的值時,只需要先獲取到 Object 指針指向的內存地址,將其作爲 Object 實例對象在內存裏的基地址,然後我們通過在該基地址上增加一個 0x08 的偏移量,即得到了 id_ 成員變量的內存地址,再使用 LD 彙編指令讀取內存裏面的值到寄存器即可。

調用成員函數

在寫 c++ 代碼創建了一個類的實例對象後,除了訪問它的成員變量,最多的操作其實是調用它的成員函數,例如在我們的 Object 對象中就提供一個 GetType() 成員函數來訪問其私有的成員變量 type ,我們再稍微修改一下我們的 c++ 代碼:

#include <stdio.h>
#include "object.h"

int main() {
   Object* obj = new Object(1, 2);
   printf("type=%d", obj->GetType()); // 調用Object對象的成員函數
   return 0;
}

當看到彙編代碼的時候,其實我們就會發現調用一個對象的成員函數這件事情我們早就已經做過了,代碼是如此的似曾相識:

// ...
6584:f85f03a9 ldurx9, [x29,#-16] // 從棧上面讀取Object的內存基地址
658c:aa0903e0 movx0, x9 // 將Object的內存基地址加載到x0寄存器作爲第0個參數
6590:97ffffe2 bl6518 <_ZN6Object7GetTypeEv> // 調用 Object::GetType() 函數
6598:b9000be0 strw0, [sp,#8] // 獲取函數的返回值
// ...

這段代碼其實和我們之前調用 Object 的構造函數:Object::Object(long id, int type) 的邏輯是一樣的,也是將 Object 的指針(內存地址)作爲第 0 個參數,然後通過 BL 指令跳轉調用這個成員函數即可。也就是說調用一個類的成員函數,和調用一個普通的函數本質上來說是沒有什麼區別的,唯一的區別是調用成員函數的第 0 個參數都是 this 指針,在彙編中都是傳入對象在內存中的基地址。

NOTE: 需要注意的是,這裏所說的的成員函數的調用情況並不包括虛函數,虛函數會稍微有些區別,這個會在今後在講類的繼承這一節中一起提到。

小結

在這一篇文章中我們分別通過在棧內存上和在堆內存上實例化一個 c++ 類(class) 的實例對象 ( object ),並訪問了對象的成員變量,以及調用了對象的成員函數等幾個很實際的例子中,學習到了幾個新的彙編知識點,包括 canary 棧內存保護機制,和mrssubs, b.ne ,orr等彙編指令,對象在內存中的佈局等。在後面的章節裏我們將學習更多相關知識。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UArWnoFgI1z174TvAPM2Jw