一直都想自己动手实现一个简易的安卓inlinehook, 毕竟从原理上来说并不是太难, 当然我是指最简陋的那种, 也是最方便的那种. 最近两天动手实操了一下, 写下这篇笔记记录一下相关内容, 包括收获和疑问.
编译环境
首先是cmake脚本, 在网上搜ndk大部分都会说要android studio写jni调用之类的, 但在这就没必要了, 因为我们只需要最简单的用c编写的hello world作为目标.
对于新手可能会比较困难搜索到相关的信息, 我主要参考了
脱离as交叉编译
cmake教程
cmake_minimum_required(VERSION 3.1)
#include(D:/android-sdks/build/ndk-bundle/cmake/android.toolchain.cmake)
add_compile_options(-fno-elide-constructors)
project(hello)
set(CMAKE_CXX_STANDARD 11)
add_executable(hello hello.cpp)
然后设置一个bat编译脚本
set abi=armeabi-v7a
if not exist %abi% md %abi%
cd %abi%
%ANDROID_SDK_HOME%/cmake/3.10.2.4988404/bin/cmake ^
-DANDROID_ABI=%abi% ^
-DANDROID_NDK=%ANDROID_SDK_HOME%/ndk-bundle ^
-DCMAKE_BUILD_TYPE=Debug ^
-DCMAKE_TOOLCHAIN_FILE=%ANDROID_SDK_HOME%/ndk-bundle/build/cmake/android.toolchain.cmake ^
-DANDROID_NATIVE_API_LEVEL=16 ^
-DANDROID_TOOLCHAIN=clang -DCMAKE_GENERATOR="Ninja" ^
-DCMAKE_MAKE_PROGRAM=%ANDROID_SDK_HOME%/cmake/3.10.2.4988404/bin/ninja ^
..
%ANDROID_SDK_HOME%/cmake/3.10.2.4988404/bin/ninja
cd ..
pause
相关的环境可能还需要配置一下环境变量之类的, 之后双击bat脚本就能编译出安卓二进制了.
调试环境
我用的真机wifi调试, 真机有root, 比较方便一点. ida调试二进制也是一样的, 把android_server打开, 转发端口, 先打开hello的二进制, 不打开的话可能出现问题, 启动程序而不是附加程序.
将编译出来的libinject.so放到system/lib目录下, 给777权限, hello我放在了data/user目录下, 给777权限.
调试入口挂起配置
关于调试入口的配置, 如果需要调试libinject.so, 那就把library load这项勾上, 等模块加载完成后去模块窗口找到对应函数下断点. 如果仅仅是需要调试hello, 那就只要entry point这项勾上.
程序启动配置
程序启动主要需要配置一下前三项路径, 然后启动程序就能够下断点调试了.
Hook对象和Hook代码
在这里, 我们的hook对象是一个简单的hello:
//hook对象 可执行文件hello
#include<stdio.h>
void fun(char *output)
{
printf("Output is : %s\n", output);
}
int main()
{
getchar();
fun("hello");
}
hook目标是替换hello字符串. 加一个getchar()稳妥一点, 方便调试.
//hook代码
#include<string.h>
#include<stdio.h>
#include<stdlib.h>
#include<pthread.h>
#include<dirent.h>
#include<sys/mman.h>
#include<errno.h>
int getPID(char *PackageName) //获得程序pid
{
DIR *dir=NULL;
struct dirent *ptr=NULL;
FILE *fp=NULL;
char filepath[256];
char filetext[128];
dir = opendir("/proc");
if (NULL != dir)
{
while ((ptr = readdir(dir)) != NULL)
{
if ((strcmp(ptr->d_name, ".") == 0) || (strcmp(ptr->d_name, "..") == 0))
continue;
if (ptr->d_type != DT_DIR)
continue;
sprintf(filepath, "/proc/%s/status", ptr->d_name);
fp = fopen(filepath, "r");
if (NULL != fp)
{
fgets(filetext,sizeof(filetext),fp);
if (strstr(filetext, "hello") != NULL)//第一个hello是基址
{
fclose(fp);
break;
}
fclose(fp);
}
}
}
if (readdir(dir) == NULL)
{
return 0;
}
closedir(dir);
return atoi(ptr->d_name);
}
unsigned long GetSoBase() //获得hello模块基址
{
char programName[] = "hello";
int pid = getPID(programName);
char mapsPath[64];
sprintf(mapsPath, "/proc/%d/maps", pid);
FILE *fp = fopen(mapsPath, "r");
char buff[256];
unsigned long base;
unsigned long end;
while (!feof(fp))
{
fgets(buff,sizeof(buff),fp);
if (strstr(buff, "hello") != NULL)
{
sscanf(buff, "%lx-%lx", &base, &end);
return base;
}
}
base = 0;
return base;
}
void HackFun()
{
char s[] = "the string has been replaced."; //被替换的字符串
//申请内存, 内存页属性可读可写可执行, 用来存放跳转后的二进制硬编码
char *codePtr = (char*)mmap(NULL, PAGE_SIZE, PROT_READ |PROT_WRITE|PROT_EXEC, MAP_ANONYMOUS|MAP_PRIVATE, 0, 0);
//获得基址, 目标函数偏移是0x2458
char *patchAddr = (char*)(GetSoBase()+0x2458);
//修改目标函数内存页属性可读可写可执行, 注意第一个参数必须是内存页起始位置
mprotect((void*)((int)patchAddr-(int)patchAddr%PAGE_SIZE), PAGE_SIZE, PROT_READ|PROT_WRITE|PROT_EXEC);
//复制一部分fun的代码过去
memcpy((void*)(codePtr+8), (void*)patchAddr, 128);
//此处几行为之后的二进制硬编码, 注意点为小端排序
*((unsigned int*)(codePtr)) = (unsigned int)s;
*((unsigned int*)(codePtr+4)) = 0x8f85f; //ldr r0,[pc,#-8]
*((unsigned int*)(codePtr+18)) = 0xbf004a01; //ldr r2,[pc,#4]
*((unsigned short*)(codePtr+22)) = 0x4697; //mov pc,r2
*((unsigned int*)(codePtr+24)) = (unsigned int)patchAddr+10;
*((unsigned int*)patchAddr) = 0x2f8df; //ldr r0,[pc,#2]
*((unsigned short*)(patchAddr+4)) = 0x4687; //mov pc,r0
*((unsigned int*)(patchAddr+6)) = (unsigned int)codePtr+4;
printf("patched done.\n");
}
void __attribute__((constructor)) _init() //constructor属性使得so被加载时会执行这个函数
{
printf("init enter.\n"); //输出提示, lief添加依赖无问题
HackFun();
}
原理
将其编译为二进制后查看反汇编代码:
.text:00002458 ; _DWORD __fastcall fun(char *)
.text:00002458 EXPORT _Z3funPc
.text:00002458 _Z3funPc ; CODE XREF: main+12↓p
.text:00002458
.text:00002458 var_C = -0xC
.text:00002458
.text:00002458 ; __unwind {
.text:00002458 PUSH {R7,LR}
.text:0000245A MOV R7, SP
.text:0000245C SUB SP, SP, #8
.text:0000245E STR R0, [SP,#0x10+var_C]
.text:00002460 LDR R1, [SP,#0x10+var_C]
.text:00002462 LDR R0, =(aOutputIsS - 0x2468)
.text:00002464 ADD R0, PC ; "Output is : %s\n"
.text:00002466 BLX printf
.text:0000246A ADD SP, SP, #8
.text:0000246C POP {R7,PC}
.text:0000246C ; End of function fun(char *)
........
.text:00002474 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00002474 EXPORT main
.text:00002474 main ; DATA XREF: .text:00002414↑o
.text:00002474 ; .got:main_ptr↓o
.text:00002474
.text:00002474 var_C = -0xC
.text:00002474
.text:00002474 PUSH {R7,LR}
.text:00002476 MOV R7, SP
.text:00002478 SUB SP, SP, #8
.text:0000247A BLX getchar
.text:0000247E LDR R1, =(aHello - 0x2484)
.text:00002480 ADD R1, PC ; "hello"
.text:00002482 STR R0, [SP,#0x10+var_C]
.text:00002484 MOV R0, R1 ; char *
.text:00002486 BL _Z3funPc ; fun(char *)
.text:0000248A MOVS R0, #0
.text:0000248C ADD SP, SP, #8
.text:0000248E POP {R7,PC}
.text:0000248E ; End of function main
总体思路: arm的参数传递约定前四个是通用寄存器r0-r3, 也就是说想要改变fun的参数, 我们只需要把fun入口劫持, 将r0寄存器改成我们想要的值, 同时不改变其他寄存器状态. 然后返回原函数位置, 做到受害者无感知.
代码的注入时机: 可以参考frida-gadget的实现,使用lief工具把我们自己的libinject.so添加进hello二进制的依赖库。在so中使用constructor属性实现hook函数,这样注入的so和目标程序处于同一进程内存空间下。hook函数先枚举pid,然后找到主模块基址加上目标函数偏移。这样前期准备工作就完成了,之后是处理hook的细节。
hook具体的细节:
在实际逆向过程中还是有很多thumb指令, 所以这里目标也是thumb形式的二进制. 我们要编写尽可能少的汇编指令, 转化成二进制填到相关区域, 因为一旦被我们填掉的区域中含有相对pc寻址的指令, 那就麻烦多了. 比如这里的.text:00002462便是与当前pc寄存器值有关, 所以就要求被修改的指令长度不超过10个字节.
演示流程图
申请一块内存区域, 设置可读可写可执行, 将fun函数整体复制过去. 首先是被hook的fun函数入口, 用mprotect设置fun函数区域可读可写可执行, 众所周知thumb情况下读pc寄存器实际会读出pc+4(这里有疑问, 在后面会遇到). 最精简的代码是直接ldr pc,[pc], 但是这么做貌似会造成指令模式的切换, 具体我没太研究. 通用寄存器方面考虑r0可以使用, 因此有此方案, 总共正好10个字节长度.
ldr r0, [pc,#2] #加载第三行地址到r0 DF F8 02 00
mov pc, r0 #跳转 87 46
xx xx xx xx #目标地址 4字节
#修改完跳转回来
跳转到目标地址后同理修改r0的值, 跳转回去. 跳转回去的时候r0存储了修改后的值, r1被使用了, 所以用r2寄存器
xx xx xx xx #替换的字符串地址
ldr r0,[pc,#-8] #替换r0值 5F F8 08 00
#执行以下5条被污染的指令, 均不涉及pc相关寻址
PUSH {R7,LR}
MOV R7, SP
SUB SP, SP, #8
STR R0, [SP,#0x10+var_C]
LDR R1, [SP,#0x10+var_C]
ldr r2,[pc,#4] #注意是+4, 而不是+2 01 4A
NOP #补齐 00 BF
mov pc,r2 #跳转回去 97 46
xx xx xx xx #回去的地址
第9行位置并不是+2而是+4, 并且因为本身缩短为2字节, 要有nop来补足. 如果沿用fun函数处的修改则会出现问题, 其会把mov pc,r2这条指令和后面两个数据字节加在一起看做一条指令, 表面上以ida的视角是这样的. 单步执行下去ida会提示检测到未被识别为代码的数据, 问是否创建指令, 执行完这步就会使得取得的数据地址比预期中少两个字节. 具体原因未知, 也是比较困惑我的一点(下面图片中r0应该换成r2, 放错图了)
错误情况
将其换成+4用nop补齐则解决了这一问题.
正确情况
最后输出结果为
输出结果
这样几行代码就能实现一个迷你的inlinehook效果了, 并且还是永久的, 实际上我随便找了一个游戏试了试也是有效果的. 原本的打算的直接操作elf的段, 在原有的二进制基础上进行注入, 凭空新增一个段总是会出现莫名其妙的问题, 然后退了一步, 以后有机会再尝试尝试.
虽然这样局限性非常的大, 具体问题要具体分析, 实现的功能复杂点就很难把握, 被污染的指令要是含有相对pc寻址的数据或跳转就需要做复杂的修复, 或者函数体非常小, 不到10个字节. 但实验完成了以后对于底层的把握就更加深刻了, 只要是能够对内存进行操作, 那就可以去随意拦截代码段, 比如gg修改器配合lua脚本能做到的就更多了, 而不仅仅是简单的搜索内存, 修改内存.
挖个坑, 初步打算是有时间再进一步一般化, 实现一个不怎么复杂的hook框架, 能够足以应对大部分情况.
补充一下出现跳转奇怪现象的二进制, 感兴趣的可以去琢磨琢磨
文件链接