关于虚函数的实现、虚函数表的含义,已经有很多资料介绍。这里分析一个实际问题:虚函数寻址的编译实现,或者说如何找到虚函数的地址。弄清楚这一问题,有助于理解分析通过野指针调用虚函数时的行为表现。
- 注意:本文分析虚函数具体实现,具体实现与编译器有关。测试环境包括Android + armv8 + clang, macOS + x64 + clang.
p->call();
p
是对象指针,call()
是它的虚函数。看下armv8的汇编代码:
0x5555555c44 <+64>: ldr x9, [x8]
0x5555555c48 <+68>: ldr x9, [x9, #0x10]
0x5555555c4c <+72>: mov x0, x8
0x5555555c50 <+76>: blr x9
过程解释如下:
-
x8寄存器保存的是p的值,即对象的地址
-
ldr x9, [x8]
:把虚函数表的地址保存到x9. 虚函数表的地址保存在对象的开头8个字节(armv8架构). -
ldr x9, [x9, #0x10]
:从虚函数表的第2个索引处(0x10 / 0x8 = 2),加载call()
函数的实际地址,保存到x9。虚函数表的第0个、第1个索引处,是对象的析构函数的地址。是的,有两个析构函数地址,一个只执行析构,一个执行析构 + free -
mov x0, x8
:把p对象指针作为隐含参数,传给call()
-
blr x9
: 跳转执行call()
由此可知,调用虚函数至少有两次解引用的过程。对于p为野指针的情况,两次ldr可能crash,也可能不会,blr可能跳转到非法地址处,也可能跳转到非text段执行非法指令。注意都只是可能,所以野指针带来的问题比空指针更棘手。
现在来考虑如何打印出虚函数的地址。根据前面的汇编代码可知,还有一个遗留问题是如何拿到虚函数表的索引值。测试发现,虚函数的指针实际是虚函数表中的偏移量,实函数的指针是真实地址。
核心代码如下:
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string>
class Base {
public:
virtual ~Base() = default;
template <typename T>
union FunPtr {
T fun_ptr;
ptrdiff_t offset;
};
virtual void call() {
FunPtr<void (Base::*)()> u = {};
u.fun_ptr = &Base::call;
dump(__PRETTY_FUNCTION__, u.offset);
}
void dumpConcreteFun() {
FunPtr<void(Base::*)()> u = {};
u.fun_ptr = &Base::dumpConcreteFun;
printf("addr of %s 0x%zx\n", __PRETTY_FUNCTION__, u.offset);
}
protected:
void dump(const char *fun_name, ptrdiff_t offset) {
void *vptr = *(void **)this;
size_t index = offset / sizeof(void *);
void *fun_real_addr = ((void **)vptr)[index];
printf("%s, virtual function table index %zu, real addr %p\n", fun_name,
index, fun_real_addr);
Dl_info info = {};
if (!dladdr(fun_real_addr, &info)) {
printf("dladdr failed\n");
return;
}
printf(
"dladdr symbol name %s, exact addr %p, base addr %p, pc offset "
"from real addr 0x%lx\n",
info.dli_sname, info.dli_saddr, info.dli_fbase,
(long)((char *)fun_real_addr - (char *)info.dli_fbase));
}
};
class Base2 {
public:
virtual ~Base2() = default;
virtual void call2() {}
};
class Child : public Base, public Base2 {
public:
~Child() override { printf("%s\n", __PRETTY_FUNCTION__); }
void call() override {
printf("this addr in %s is %p\n", __PRETTY_FUNCTION__, this);
FunPtr<void (Child::*)()> u = {};
u.fun_ptr = &Child::call;
dump(__PRETTY_FUNCTION__, u.offset);
call2();
}
void call2() override {
FunPtr<void (Child::*)()> u = {};
u.fun_ptr = &Child::call2;
dump(__PRETTY_FUNCTION__, u.offset);
}
};
int main(int argc, char *argv[]) {
auto p = new Base();
p->dumpConcreteFun();
p->call();
delete p;
p = new Child();
p->call();
delete p;
return 0;
}
测试结果:
addr of void Base::dumpConcreteFun() 0x1022e6910
virtual void Base::call(), virtual function table index 2, real addr 0x1022e69f0
dladdr symbol name _ZN4Base4callEv, exact addr 0x1022e69f0, base addr 0x1022e3000, pc offset from real addr 0x39f0
this addr in virtual void Child::call() is 0x7ff457c05b40
virtual void Child::call(), virtual function table index 2, real addr 0x1022e6be0
dladdr symbol name _ZN5Child4callEv, exact addr 0x1022e6be0, base addr 0x1022e3000, pc offset from real addr 0x3be0
virtual void Child::call2(), virtual function table index 3, real addr 0x1022e6c60
dladdr symbol name _ZN5Child5call2Ev, exact addr 0x1022e6c60, base addr 0x1022e3000, pc offset from real addr 0x3c60
virtual Child::~Child()
可以看到:
-
实函数指针是真实地址
-
虚函数的指针是虚函数表中的偏移量,
virtual function table index 2
-
虚函数真实地址减去加载地址base address,可以得到函数符号的相对偏移量。拿到的结果可以与nm输出的结果相对照:
0000000100003910 T Base::dumpConcreteFun() 00000001000039f0 t Base::call() /////////// 0000000100003a40 T Base::dump(char const*, long) 00000001000038f0 t Base::Base() 0000000100003980 t Base::Base() 00000001000039c0 t Base::~Base() 00000001000039a0 t Base::~Base() 0000000100003a30 t Base::~Base() 0000000100003a30 t Base2::call2() 0000000100003b70 t Base2::Base2() 00000001000039c0 t Base2::~Base2() 00000001000039a0 t Base2::~Base2() 0000000100003a30 t Base2::~Base2() 0000000100003be0 t Child::call() //////////// 0000000100003c60 t Child::call2() //////////// 0000000100003960 t Child::Child() 0000000100003b10 t Child::Child() 0000000100003bb0 t Child::~Child() 0000000100003b90 t Child::~Child() 0000000100003d00 t Child::~Child()
也可以与dladdr拿到的实际函数地址相对照。但在Android armv8环境,dladdr返回了base address,但没返回符号地址。
-
多重继承的情况,结果也是正确的
-
用调试器查看虚函数表
(lldb) p *(void**)p (void *) $0 = 0x0000000103845078 (lldb) x/3a 0x0000000103845078 0x103845078: 0x0000000103844b90 vtbl`Child::~Child() at vtbl.cpp:59 0x103845080: 0x0000000103844bb0 vtbl`Child::~Child() at vtbl.cpp:59 0x103845088: 0x0000000103844be0 vtbl`Child::call() at vtbl.cpp:61
虚函数在虚函数表中的索引与虚函数声明的顺序有关。调整虚函数声明的顺序,会导致ABI不兼容。如果更新依赖的动态库后,发现函数调用错乱,可以猜想引用的头文件可能与实际动态库不符,需要更新头文件重新编译。