三年三班三井寿 发表于 2020-5-7 12:51

安卓so劫持原理及实现

劫持原理
之前用漏洞进行临时提权,但手机重启后就没有了root权限。system分区由于dm-verity挂载不了,然后就去实现开机自启。不过这需要用户赋予我们权限。
之后在看雪看到so注入的帖子,很老的一种技术,想着可以通过劫持那些开机会自动加载的so来实现该功能。
原帖中步骤比较详细(https://bbs.pediy.com/thread-220783.htm),实现了armeabi-v7a架构的so劫持,本贴主要讲arm-v8的so的实现方式。


原理其实都一样,不过在armv8中多了些许限制,先来看看再v7中的实现原理:
核心就是伪造一个so,其导出函数需要与被劫持的so一样,在伪造的接口中在跳转到原来真正的接口中去。当然也可以不跳过去,自己实现这个接口完成hook,与windows下的IAT hook差不多。
hook的话需要知道原函数的参数类型,返回值。如果跳转的话只需要函数名就可以。
首先 通过nm -D获取过滤出需要被劫持的so的导出函数,比如劫持libyoga.so

并将该so重命名,比如重命名成libyoga1.so。


对于原来so的处理就是这些,接下来伪造出一个libyoga.so替换进去即可。
定义一个结构体,用于存放跳转的指令,以及原函数的地址:
typedef struct JmpStruct{
      unsigned char code;
      uint32_t address;
} JmpStruct;


通过__attribute__((visibility("default")))伪造出与原来相同的导出函数,伪造的导出函数可当作一个JmpStruct结构体:
#define DLL_PUBLIC __attribute__((section(".text"))) __attribute__((visibility("default"))) JmpStruct
DLL_PUBLICxxx;


so在加载时最早会执行.init_array段里的函数进行初始化,所以我们选择在这里进行加载:
在伪造的so的.init_array段中添加一个初始化函数my_init:
typedef void (*myown_call)(void);
myown_call testinit __attribute__((section(".init_array")))=my_init;


在my_init中加载原来的so:
这里写相对路径,需要在同一路径下,其他路径可能也没权限:
handle = dlopen("libmgrcore1.so", RTLD_NOW);


并填充各个伪造的导出函数:
GetProcessAddress(xxxxxx);


填充方式也很简单,通过mprotect修改页属性,直接填入构造好的跳转指令以及获取到的原函数地址:
#define GetProcessAddress( name ) \
      while(1)\
      {\
                void* pForTest=dlsym(handle, #name );\
                myMemProtect((void*)& name ,sizeof(JmpStruct),PROT_READ |PROT_WRITE |PROT_EXEC);\
                void* destin=memcpy( name .code,JmpCode,32);\
                name .address=(uint32_t)pForTest;\
      LOGD("%s-->destin:%p:%x, %p:%p,%p" , #name , destin,*(uint32_t*)(destin),&(name .address),(void*)name .address,pForTest);\
                break;\
      };


接下来看看构造的跳转指令:
static unsigned charJmpCode[] ={0x00,0x80,0x2d,0xe9, //push {pc}      
                                                      0x01,0x00,0x2d,0xe9,//push {r0}
                                                      0x00,0x80,0x2d,0xe9,//push {pc}
                                                      0x01,0x00,0xbd,0xe8,//pop {r0}
                                                      0x10,0x00,0x90,0xe5,//ldr r0,{r0,#16}
                                                      0x04,0x00,0x8d,0xe5,//str r0,{sp,#4}
                                                      0x01,0x00,0xbd,0xe8,//pop {r0}
                                                      0x00,0x80,0xbd,0xe8};//pop {pc}
将pc与r0先压进栈,再通过push和pop的操作获取到当前PC的值,这里获取到的PC是第三条指令push进的值。由于arm三级/五级流水线的设计,此时的r0也就是第五条指令处的地址。
接下来ldr r0,{r0,#16}获取到的则是JmpCode下面的值,即原来真正的函数地址。
str r0,{sp,#4}则把真正的函数地址,写入到sp+4的位置,即替换了第一次push进来的PC值。
最后再pop {r0}恢复r0寄存器,pop {pc}跳转到真正的导出函数里。


arm-v8下的尝试
至此也就完成了,接下来需要完成对arm-v8 so的劫持。
按照v7的方式方法,不过在arm64中不允许直接操作PC寄存器了。但和win下一样,可以通过很多方式拿到当前PC的值。
然后跳转的方式,好像也只能通过函数调用的方式进行pc的跳转。调用方式如果通过br x0寄存器跳转,则会破环一个寄存器环境。最后选择计算偏移直接b过去。
先抬高堆栈,保存寄存器环境。这里为了测试,也没想怎么去节省寄存器,因为下面需要计算偏移,所以就用了四个:
stp         x0,x30,!
stp          x1,x2,!


然后获取当前pc值,在x86下直接通过call,pop可以拿到PC,arm下通过bl也一样能达到.
跳转到下一条指令处,并将返回地址(下一条指令的地址)存到x30中:
bl         4


此时,x30即为当前指令地址。通过偏移跳过JmpStruct.code,获取到原函数地址JmpStruct.address:
ldr         x0,


接下来计算上一条指令地址与真实地址之间的偏移,以及之间的指令数(/4):
sub         x2,x0,x30
lsr          x2,x2,#2


判断是向前跳转还是向后跳转:
cmp         x0,x30
ble          36;


由于刚刚算偏移的PC值并不是最终跳转的指令处的PC值,所以还得修正:
sub         x2,x2,12


向后跳转指令为xx xx xx 14:
mov      x1,0x14000000


计算得到跳转指令,并将指令写入最终跳转处
add         w2,w2,w1
str          w2,


恢复寄存器,堆栈:
ldp         x1,x2,
ldp         x0,x30,
add         sp,sp,0x20


向后跳转(此时,偏移已经算出并修改完成):
00 00 00 14


还有一种向前跳转的情况,过程类似:
sub      x2,x2,21
mov      x1,0x17000000
and      x2,x2,0xffffff
add      w2,w2,w1
str      w2,
ldp      x1,x2,
ldp      x0,x30,
add      sp,sp,0x20


向前跳转:
00 00 00 17


测试与调试
修改完成后,再将code大小,指针大小调整为64位下的。
JmpStruct会存在结构体对齐,为了使address紧接着code后面,使对齐粒度变为1:
#pragma pack(1)


其跳转原理与v7类似,仅在跳转指令中加入了动态偏移的计算。
当原程序加载so时,会进入到伪造的init_arrary中的函数。
在其中会加载原来的so,并把跳转指令以及真实的地址填入到伪造的导出函数中。
当程序调用so中接口时,伪造的函数会计算出当前指令与真实接口的偏移,自修改跳转,跳转到原来的函数中。


接下来进行测试,随便写一个arm-v8的程序。
先获取到导出函数:



在伪造的so中进行导出:
DLL_PUBLIC Java_com_samsung_hijackso_CoreMgr_print;
DLL_PUBLIC _Z7versionv;


在init_arrary中进行填充:
handle = dlopen("libmgrcore1.so", RTLD_NOW);
GetProcessAddress( Java_com_samsung_hijackso_CoreMgr_print);
GetProcessAddress( _Z7versionv);


将原来的libmgrcore.so重命名为libmgrcore1.so
并将编译伪造的libmgrcore.so放入该路径下:



最好属性,用户组都保持一致:



然后重新运行所写的demo,当它加载so时,很尴尬地崩溃了:



日志定位的carsh在so中0x97C的偏移处,也就是最终跳转的语句:



so未加载时,导出函数Java_com_samsung_hijackso_CoreMgr_print为空。
加载后,会将一个JmpStruct的结构体填入,前100个字节(0x64)为跳转码code
0x980处会填为真实的函数地址,0x97C则是在code运行计算后的最终跳转指令。



调试一下,在init_arrary最后下断,此时伪造的导出函数已经填充完毕



0x980偏移处的0x70CA94A62C 也是原先正确的函数地址:



填充正确后,我们在填充完成的导出函数开头下断,然后单步跟踪。
但在原先的崩溃点0x97C的偏移处,却没有崩溃,。F9程序正常运行。。。
不过当我们不单步,直接F9则有仍然崩溃。
崩溃时PC指向0x70C688897C:


但崩溃点已经计算修改成功,正常执行应该跳转到70CA84762C正确的导出函数里


实际上,我们把偏移patch成0,可以发现,若偏移未经修改,则跳转的点就是0x70C688897C


也就是说,虽然代码中看到修改成功了,若单步执行,确实会跳转到正确的地址,trace跟踪也没有问题。
但实际上若直接运行,跳转的偏移仍然为0进而崩溃,dump出此时的内存,跳转指令却也已正确修改了。


怀疑了一阵人生,最终还是稍改变了一下想法,将计算跳转偏移以及接口调用逻辑分开。
可以在伪造的init_arrary中注入跳转代码,然后紧接着调用该代码计算偏移(这里计算第一条code与原函数的偏移)。并将之前伪造的第一条代码修改为跳转代码,再直接返回。这样,相当于伪造的接口第一次调用则是计算偏移,非第一次调用则是直接跳转到原来的函数中(当然,也可以分开不关联)。在so加载时,就完成各个接口偏移的计算。
同样,这里调用也不需要知道导出函数的类型,直接定义一个类型全为空的函数指针即可:
typedef void (*pfun)(void);
pfun f=NULL;
#define CallFun( name ) \
      f=& name ;\
      f();
CallFun(Java_com_samsung_hijackso_CoreMgr_print);
CallFun(_Z7versionv);

这样做与之前的区别就是,加载的时候完成所有导出函数偏移的计算,而调用的时候直接跳转过去。
测试可行之后,将计算与调用分开。
实现起来也比之前容易,不用考虑指令间的偏移,也不需构造填充跳转结构体,直接将伪造的函数第一条指令改成跳转即可。另外定义一个函数用作计算偏移:
#define fakefun( name ) \
      while(1)\
      {\
                void* old=dlsym(handle , #name );\
                void* fake=(void*)& name;\
                myMemProtect(fake,4,PROT_READ |PROT_WRITE |PROT_EXEC);\
                if((uint64_t)old>(uint64_t)& name){\
                        *(uint32_t *)fake=0x14000000+(uint32_t)(((uint64_t)old-(uint64_t)fake)>>2);\
                         /*LOGD("%s-->destin:%p, fake:%p:%x" , #name , old,fake,*(uint32_t *)fake);*/\
                }\
                else{\      
                        *(uint32_t *)fake=0x17000000+(uint32_t)((((uint64_t)old-(uint64_t)fake)>>2)&0xffffff);\
                         /*LOGD("%s-->destin:%p, fake:%p:%x" , #name , old,fake,*(uint32_t *)fake);*/\
                }\
                break;\
      }
当然还可以进一步优化,比如当导出函数很多的时候,可以将if拿出来,将两个模块基址先进行比较用作区分前跳后跳。
最后在init_arrary中加入我们想要的代码,比如启动我们的activity:
测试的时候需要添加--user参数才能启动,不知道是不是所有手机都需要:
system("am start --user 0 -n com.samsung.hijackso/com.samsung.hijackso.MainActivity");



不论v7还是v8,实现起来还是比较容易的,比较麻烦的是劫持so的寻找。
由于dm的存在,动不了system分区,但通过临时root权限,可以对data分区下的so进行劫持。
较为通用的so可能在于 厂家自带的app中的so,或是一些大众软件中的so。
比如开机后,微信会加载许多so。在其中寻找那些没验证不重要,尽可能小一些的尝试,查看不同版本间该so导出函数是否变化。


本贴仅为学习交流,各位大佬轻喷。
也不知道有啥好的漏洞提权的方案,希望大佬们能点拨点拨{:1_932:}

赤座灯里 发表于 2020-5-7 15:40

三年三班三井寿 发表于 2020-5-7 15:35
感谢,有时间再去看看去,之前也觉得是类似的问题,但只是在中间加了一串nop,后来也没管了

;www我也因为这个问题折腾过好久,最后没办法,在中间sleep了10ms就算解决了

赤座灯里 发表于 2020-5-7 15:09

填充正确后,我们在填充完成的导出函数开头下断,然后单步跟踪。
但在原先的崩溃点0x97C的偏移处,却没有崩溃,。F9程序正常运行。。。
不过当我们不单步,直接F9则有仍然崩溃。
这个问题我也遇到过,应该是没有清除缓存,也可以试试sleep一会等它刷新

cptw 发表于 2020-5-7 13:17

感谢楼主分享,但有点深,看不太懂,谢谢!

icaomi 发表于 2020-5-7 13:33

有个app没有源码,想汉化,和加弹窗与更新,可以做吗,付费

lyjsolo 发表于 2020-5-7 14:17

这也太深奥了....

a59322 发表于 2020-5-7 14:57

没有基础的人根本看不懂{:1_937:}

赤座灯里 发表于 2020-5-7 14:57

v8不是兼容v7?{:1_904:}如果单是为了楼主需要而劫持,看雪那篇帖子已经够了,把v8目录的so删掉就行

三年三班三井寿 发表于 2020-5-7 15:09

赤座灯里 发表于 2020-5-7 14:57
v8不是兼容v7?如果单是为了楼主需要而劫持,看雪那篇帖子已经够了,把v8目录的so删掉就行

我这微信更新714之后就只有v8的so了,不知道是不是所有手机更新后都这样

赤座灯里 发表于 2020-5-7 15:17

赤座灯里 发表于 2020-5-7 15:09
这个问题我也遇到过,应该是没有清除缓存,也可以试试sleep一会等它刷新


调用号是0xf0002

赤座灯里 发表于 2020-5-7 15:20

本帖最后由 赤座灯里 于 2020-5-7 15:33 编辑

三年三班三井寿 发表于 2020-5-7 15:09
我这微信更新714之后就只有v8的so了,不知道是不是所有手机更新后都这样
我去微信官网看了下,v7和v8是分开下载的https://weixin.qq.com/
页: [1] 2
查看完整版本: 安卓so劫持原理及实现