Android逆向工程
在Root前提下,我們可以使用Hooker方式繫結so庫,通過逆向方式篡改數值,從而達到所謂破解目的。然而,目前無論是軟體加固方式,或是資料處理能力後臺化,還是客戶端資料真實性驗證,都有了一定積累和發展,讓此“懶技術”不再是破解修改的萬金油。再者,閱讀彙編指令,函式指標替換,壓棧出棧等技術需要一定技術沉澱,不利於開發同學上手。
兩年前,也是因為懶,很懶,非常懶,堆積了足夠的動力,寫了一個基於人工模擬方式,對一個特定規則的遊戲進行暴力破解。我們都知道,人工模擬方式,繞過了大量防破解技術,只要還是人機互動模式,並且滿足一定的遊戲規則,基本是無法防禦的。
技術實現原理
因涉及到安全方面的考量,本文主要圍繞技術實現原理和關鍵技術點進行闡述。
技術要求:
- 支援多解析度
- 支援多點觸控
- 支援輸入速率動態變更
- 處理能力峰值需要達到30fps
實現方式分三步:
- 劫持螢幕
- 分析資料
- 模擬輸出
1.劫持螢幕
先說說劫持螢幕,做過截圖功能的同學應該清楚,Root了之後能訪問裝置“dev/graphic”資料夾,裡面有fb0, fb1, fb2三個screen buffer檔案。這裡用到的是fb0檔案。
丟擲一個問題,當前主流螢幕解析度都在1920*1080區間,一張圖片的快取能去到2M左右,要達到30fps的效能指標,光是螢幕資料的讀寫耗時,就滿足不了要求。怎麼做呢?
一般在做影象處理的時候都會想到parallel programming。然而,這裡的圖片是時間相關的,不適宜採用多執行緒任務派發。
懶人一番思量後,發現一條捷徑,共享記憶體讀取,請看以下程式碼。
mapbase = mmap(0, **mapsize, PROT_READ, MAP_SHARED, fd, offset);
這行程式碼廣泛存在於各個截圖程式碼片段中,精髓在於PROT_READ 和 MAP_SHARED上。先科普一下mmap引數中這兩個引數吧。
prot : 對映區域的保護方式。可以為以下幾種方式的組合:
- PROT_EXEC 對映區域可被執行
- PROT_READ 對映區域可被讀取
- PROT_WRITE 對映區域可被寫入
- PROT_NONE 對映區域不能存取
flags : 影響對映區域的各種特性。在呼叫mmap()時必須要指定MAP_SHARED 或MAP_PRIVATE。
- MAP_FIXED 如果引數start所指的地址無法成功建立對映時,則放棄對映,不對地址做修正。通常不鼓勵用此旗標。
- MAP_SHARED 對對映區域的寫入資料會複製迴文件內,而且允許其他對映該檔案的程序共享。
- MAP_PRIVATE 對對映區域的寫入操作會產生一個對映檔案的複製,即私人的“寫入時複製”(copy on write)對此區域作的任何修改都不會寫回原來的檔案內容。
- MAP_ANONYMOUS建立匿名對映。此時會忽略引數fd,不涉及檔案,而且對映區域無法和其他程序共享。
- MAP_DENYWRITE只允許對對映區域的寫入操作,其他對檔案直接寫入的操作將會被拒絕。
- MAP_LOCKED 將對映區域鎖定住,這表示該區域不會被置換(swap)。
因為我們不需要寫屏,所以prot只需要採用PORT_READ;而我們期望避免螢幕資料的多次建立,flags就需要用到MAP_SHARED,這樣檔案控制代碼fd指向的記憶體塊資料就會實時變更,無需多次建立,拷貝,釋放資料。
2.分析資料
擷取到螢幕資料就好辦了,對每一幀進行資料處理,這裡完全就是演算法問題了。懶人都用搓演算法,大概的思路就是:7*7宮格,對於所有相連的兩個同色item做了橫向對映表和縱向對映表,然後輪尋處理5連,4連和3連。裡面還有一些涉及到實現細節的對映表重置與預判,因為不是本文重點,就帶過了。
void Handle_X_Combination() {
LOGE("Handle_X_Combination");
gen_Horizontal_Matrix(6);
get_Horizontal_X_Match();
gen_Vertical_Matrix(0, 6);
get_Vertical_X_Match();
}
下面是程式執行時的Log資訊片段,以供大家參考。
3. 模擬輸出
演算法會輸出當前螢幕的一個模擬手勢操作佇列,最精彩的當然放到最後,也是此工程的技術點,怎麼模擬輸出手勢的問題。
Android所給予的截圖和模擬操作分別為 adb screenshot 和 adb shell sendevent (根據android版本,有些機型用的是input event,記得沒錯的話~)
所有需要adb處理的指令,都不能採用高併發方式呼叫,要不然要麼機器重啟,要麼指令堵塞。所以adb這條路不通。
怎麼辦呢?
懶人又一番思量後,linux系統大都採用檔案buffer,直接將指令寫檔案吧。其實adb也是寫檔案,不過adb做了一層轉譯,這裡涉及到裝置層指令程式碼,不同機型定義的指令程式碼不盡相同。
要完成此任務,首先要弄清楚幾件事情:
- 一個點選事件的構成是怎樣的
- 一個滑動事件的構成多了什麼
- 事件的指令程式碼分別代表什麼
萬能的adb給了我一些思路,adb shell getevent,會打印出當前event的指令。再科普一下,event有很多,包括compass_sensor,light_sensor,pressure_sensor,accelerometer_sensor等等。
我們這裡監聽的是,touchscreen_sensor。
有了上面的指導資訊,要構建一個模擬操作函式就很容易了。操作螢幕打印出想要的模擬的手勢,然後寫下來就好了。一共會有這麼幾個模擬操作函式需要建立:
void simulate_long_press_start_event(int touch, int fromX, int fromY);
void simulate_long_press_hold_event(int touch, int fromX, int fromY);
void simulate_long_press_end_event(int touch);
void simulate_press_event(int touch, int fromX, int fromY);
void simulate_move_event(int touch, int fromX, int fromY, int toX, int toY);
下面給出一個我寫好的範例出來,大家可以依葫蘆畫瓢,把剩下的寫好。
void simulate_press_event(int touch, int fromX, int fromY) {
pthread_mutex_lock(&global.writeEventLock);
LOGE("simulate_press_event");
INPUT_EVENT event;
// 0. Multi-Touch
// 此專案非必要,因為沒有用到多點觸控,是另一個專案使用到了
event.type = 0x3;
event.code = 0x2f;
event.value = touch;
write(global.fd_event, &event, sizeof(event));
// 1. ABS_MT_TRACKING_ID:
// 理論上必要,因為Android事件輸入是批量處理的,需要用到輸入id,
// 但是這裡偷懶使用了同步鎖,並且沒有多點觸控需求,所以不會有Tracking_ID串擾問題,也就不需要記數了
event.type = 0x3;
event.code = 0x39;
event.value = global.event_id > 60000 ? 10 : global.event_id++;
write(global.fd_event, &event, sizeof(event));
// 2. At screen coordinates:
// 觸控點x,y座標
event.type = 0x3;
event.code = 0x35;
event.value = fromX;
write(global.fd_event, &event, sizeof(event));
event.type = 0x3;
event.code = 0x36;
event.value = fromY;
write(global.fd_event, &event, sizeof(event));
// 4. Sync
// 資料同步到裝置
event.type = 0x0;
event.code = 0x0;
event.value = 0x0;
write(global.fd_event, &event, sizeof(event));
event.type = 0x3;
event.code = 0x39;
event.value = 0xffffffff;
write(global.fd_event, &event, sizeof(event));
// 4. Pure event separator:
// 結束符
event.type = 0x0;
event.code = 0x0;
event.value = 0x0;
write(global.fd_event, &event, sizeof(event));
pthread_mutex_unlock(&global.writeEventLock);
}
為了大家對Android逆向有一個簡單的理解,我們看下面幾個問題。
首先,請大家查閱原始碼:
frameworks/base/services/surfaceflinger/DisplayHardware/DisplayHardware.cpp
擷取其中關鍵的兩段:
渲染方式宣告:
#ifdef EGL_ANDROID_swap_rectangle
if (extensions.hasExtension("EGL_ANDROID_swap_rectangle")) {
if (eglSetSwapRectangleANDROID(display, surface,
0, 0, mWidth, mHeight) == EGL_TRUE) {
// This could fail if this extension is not supported by this
// specific surface (of config)
mFlags |= SWAP_RECTANGLE;
}
}
// when we have the choice between PARTIAL_UPDATES and SWAP_RECTANGLE
// choose PARTIAL_UPDATES, which should be more efficient
if (mFlags & PARTIAL_UPDATES)
mFlags &= ~SWAP_RECTANGLE;
#endif
具體渲染操作:
void DisplayHardware::flip(const Region& dirty) const
{
checkGLErrors();
EGLDisplay dpy = mDisplay;
EGLSurface surface = mSurface;
#ifdef EGL_ANDROID_swap_rectangle
if (mFlags & SWAP_RECTANGLE) {
const Region newDirty(dirty.intersect(bounds()));
const Rect b(newDirty.getBounds());
eglSetSwapRectangleANDROID(dpy, surface,
b.left, b.top, b.width(), b.height());
}
#endif
if (mFlags & PARTIAL_UPDATES) {
mNativeWindow->setUpdateRectangle(dirty.getBounds());
}
mPageFlipCount++;
eglSwapBuffers(dpy, surface);
checkEGLErrors("eglSwapBuffers");
// for debugging
//glClearColor(1,0,0,0);
//glClear(GL_COLOR_BUFFER_BIT);
}
這段程式碼主要用來檢查系統的主繪圖表面是否支援EGL_ANDROID_swap_rectangle擴充套件屬性。如果支援的話,那麼每次在呼叫函式eglSwapBuffers來渲染UI時,都會使用軟體的方式來支援部分更新區域功能,即:先得到不在新髒區域裡面的那部分舊髒區域的內容,然後再將得到的這部分舊髒區域的內容拷貝回到要渲染的新圖形緩衝區中去,這要求每次在渲染UI時,都要將被渲染的圖形緩衝區以及對應的髒區域儲存下來。注意,如果系統的主繪圖表面同時支援EGL_ANDROID_swap_rectangle擴充套件屬性以及部分更新屬性,那麼將會優先使用部分更新屬性,因為後者是直接在硬體上支援部分更新,因而效能會更好。
在Android原始碼中有以下對framebuffer的結構定義:
hardware/libhardware/include/hardware/gralloc.h
typedef struct framebuffer_device_t {
struct hw_device_t common;
/* flags describing some attributes of the framebuffer */
const uint32_t flags;
/* dimensions of the framebuffer in pixels */
const uint32_t width;
const uint32_t height;
/* frambuffer stride in pixels */
const int stride;
/* framebuffer pixel format */
const int format;
/* resolution of the framebuffer's display panel in pixel per inch*/
const float xdpi;
const float ydpi;
/* framebuffer's display panel refresh rate in frames per second */
const float fps;
/* min swap interval supported by this framebuffer */
const int minSwapInterval;
/* max swap interval supported by this framebuffer */
const int maxSwapInterval;
int reserved[8];
int (*setSwapInterval)(struct framebuffer_device_t* window,
int interval);
int (*setUpdateRect)(struct framebuffer_device_t* window,
int left, int top, int width, int height);
int (*post)(struct framebuffer_device_t* dev, buffer_handle_t buffer);
int (*compositionComplete)(struct framebuffer_device_t* dev);
void* reserved_proc[8];
} framebuffer_device_t;
以上宣告中,成員函式compositionComplete用來通知fb裝置device,圖形緩衝區的組合工作已經完成。引用參考[2]的文章說明,此函式指標並沒有被使用到。那麼,我們就要找到在哪裡能夠獲取得到螢幕渲染完成的訊號量了。
這個問題建議大家先行閱讀所有引用參考文章。然後因為懶,這裡就直接給出大家結論,過程需參考surfaceflinger的所有原始碼。
我們都知道Android在渲染螢幕的時候,一開始用到了double buffer技術,而後的4.0以上版本升級到triple buffer。buffer的快取是以檔案記憶體對映的方式儲存在dev\graphics\fb0路徑。每塊buffer置換的時候,會有唯一的,一個,訊號量(注意修飾語)拋給應用層,接收方是我們經常用到的SurfaceView控制元件。SurfaceView內的OnSurfaceChanged() API 即是當前螢幕更新的訊號量,除此之外,程式無從通過任何其他官方API形式獲取螢幕切換的時間點。這也是Android應用商場為何沒有顯示當前任意螢幕的FPS數值的軟體(補充一下,有,需要Root,用到的就是本文後續介紹的技術。準確來說,是本文實現了一遍他們的技術)。
本文將在稍後的獨立章節說明如何實現強行暴力獲取埋在系統底層surfaceflinger service內的訊號量。
Hooker 程式碼注入
系統螢幕切換所用到的函式是在surfaceflinger內的elfswapbuffer()函式,要獲取得系統螢幕切換的訊號量,需要劫持surfaceflinger service內的elfswapbuffer()函式,替換成我們自己的newelfswapbuffer()函式,並在系統每次呼叫newelfswapbuffer()函式時,此向JNI層丟擲一個訊號量,這樣就能強行獲得螢幕切換狀態量。
而,這樣做,需要用到hooker技能,向系統服務注入一段程式碼,勾住elfswapbuffer()函式的ELF表地址,然後把自己的newelfswapbuffer()函式地址替換入ELF表內。在程式結束後,需要逆向實現一遍以上操作,還原ELF表。
程式用到了以下兩個核心檔案:
一個檔案負責注入系統服務,另一個負責感染系統程式。
Inject surfaceflinger
int main(int argc, char** argv) {
pid_t target_pid;
target_pid = find_pid_of("/system/bin/surfaceflinger");
if (-1 == target_pid) {
printf("Can't find the process\n");
return -1;
}
//target_pid = find_pid_of("/data/test");
inject_remote_process(target_pid, argv[1], "hook_entry", argv[2], strlen(argv[2]));
return 0;
}
Infect surfaceflinger
int hook_entry(char * argv) {
LOGD("Hook success\n");
LOGD("pipe path:%s", argv);
if(mkfifo(argv, 0777) != 0 && errno != EEXIST) {
LOGD("pipe create failed:%d",errno);
return -1;
} else {
LOGD("pipe create successfully");
}
LOGD("Start injecting\n");
elfHook(LIB_PATH, "eglSwapBuffers", (void *)new_eglSwapBuffers, (void **)&old_eglSwapBuffers);
while(1){
int fPipe = open(argv, O_TRUNC, O_RDWR);
if (fPipe == -1) {
LOGD("pipe open failed");
break;
} else {
LOGD("pipe open successfully");
}
char command[10];
memset(command, 0x0, 10);
int ret = read(fPipe, &command, 10);
if(ret > 0 && strcmp(command, "done") == 0) {
LOGD("ptrace detach successfully with %s", command);
break;
} else {
LOGD("ret:%d received command: %s", ret, command);
}
// close the pipe
close(fPipe);
usleep(100);
}
elfHook(LIB_PATH, "eglSwapBuffers", (void *)old_eglSwapBuffers, (void **)&new_eglSwapBuffers);
}
我們能看到以上程式碼還用到了pipe管道通訊,那是因為注入的是一段二進位制可執行程式碼,而我們在退出程式時需要與此二進位制程式碼通訊,以便正常退出。
關於程式碼的hook,大家可以參考之前的文章:點選開啟連結