C++中的函数名称粉碎机制和它的逆向应用

1.名称粉碎机制的由来

在C语言的语法中,函数名称是一个函数的唯一标识,如果一个文件内含有两个名称相同的函数,编译器就会报“函数已有主体”的错误;在多个文件链接时,如果发现有两个名称相同的函数,链接器就会报“符号重定义”的错误。

具有多态特性的C++支持函数的重载,函数不再以函数名称作为唯一标识。只要满足构成重载的条件,两个(或多个)功能不同的函数可以有相同的函数名称。这样一来,函数的调用者会获得多态性带来的极大方便(虽然函数的编写者的工作量没有改变,所有的同名函数仍需要一个一个地去编写)。构成函数重载的条件是:
1.作用域相同
2.函数名称相同
3.参数不同(类型,个数,顺序)
(另外:返回值类型、调用约定类型并不作为参考)

为了支持函数重载这一新特性,编译器的开发者们大多选择使用名称粉碎机制,即把函数的原有名称和参数类型、个数、顺序等信息融合成一个新的函数名称。这个新的名称就是此函数的唯一标识。有了它,之后的工作就可以继续沿用C语言的套路(在编译、连接过程中,若发现新名称存在重复现象,仍会发出“函数已有主体”或“符号重定义”信息)。

值得一提的是,C++标准中只是说明了函数重载的定义,但并没有提出“名称粉碎机制”这种概念。由于名称粉碎机制的直观、高效、易于兼容以前的C版本的特点,所以各编译器作者不约而同地选择用名称粉碎机制来实现函数重载。虽然各编译器的思路是一致的,但是由于没有统一的标准,所以各编译器的名称粉碎结果也自然是五花八门。下面我们来观察微软VC编译器的名称粉碎细节。

2.微软VC编译器的名称粉碎细节

在一般情况下,我们是看不到名称粉碎机制的细节的(因为我们没有必要知道编译器内部的操作)。为了看到这些细节,我们必须进入编译生成的obj文件中探索。例如这次定义两个名为test_add的函数,分别用于计算整形数据相加之和和双精度浮点型数据相加之和:

//文件 test.cpp
#include "stdafx.h"

int test_add(int n1, int n2)
{
  return n1 + n2;
}

double test_add(double d1, double d2)
{
  return d1 + d2;
}

在头文件中声明这两个函数:

//文件test.h
#pragma once

int test_add(int n1, int n2);
double test_add(double d1, double d2);

在main函数中调用test_add函数分别求整形和浮点数的和:

//文件main.cpp
#include "stdafx.h"
#include "test.h"

int _tmain(int argc, _TCHAR* argv[])
{
  int nNum = test_add(8, 9);
  double dNum = test_add(8.8, 9.9);
  
  return 0;
}

此时程序可以正常编译编译运行。来到工程目录下,使用WinHex打开test.cpp所生成的obj文件(名称为test.obj)。使用文本搜索功能搜索“test_add”,可以在文件快结束的地方发现被粉碎后的新函数名称,如图所示:

除了在obj文件中探索,其实还可以通过人为制造编译错误的方法,快速地让编译器告诉我们粉碎后的新的函数名称。在原有代码的基础上,注释掉test.cpp文件中关于两个test_add函数的定义部分:

#include "stdafx.h"

/*
int test_add(int n1, int n2)
{
  return n1 + n2;
}

double test_add(double d1, double d2)
{
  return d1 + d2;
}*/

这样一来,虽然编译过程不会报错,但是在链接的时候,因为main函数中需要执行test_add函数的代码却无法找到它的定义,就会发生错误:

1>main.obj : error LNK2019: 无法解析的外部符号 "int __cdecl test_add(int,int)" (?test_add@@YAHHH@Z),该符号在函数 _wmain 中被引用
1>main.obj : error LNK2019: 无法解析的外部符号 "double __cdecl test_add(double,double)" (?test_add@@YANNN@Z),该符号在函数 _wmain 中被引用

错误信息向我们揭示了:两个add_test函数因为参数的不同,被名称粉碎机制赋予了新的函数名称,分别是?test_add@@YAHHH@Z?test_add@@YANNN@Z。通过这个人为制造错误的方法,我们可以继续测试不同类型的参数会对名称粉碎造成什么样的影响。在微软的名称粉碎机制中,除了函数的参数类型之外,函数的调用约定、返回值类型和作用域都被整合到了粉碎后的新名称之中。

此外,微软还为我们提供了一个“反名称粉碎”工具undname,用于快速地把粉碎后的函数名称还原成本来的样子。打开VS2012工具命令提示(位于 开始菜单->Microsoft Visual Studio 2012->Visual Studio Tools),输入undname即可打开这个工具。我们可以利用它来直接翻译粉碎后的名称,如图所示,函数的返回值类型,调用约定,参数的类型、个数、顺序都被翻译出来了。

3.文件粉碎机制的逆向应用

举个具体例子,一个正在合作同一个项目的程序员,在完成了自己负责的那一部分功能后,因为想保护自己的源码,所以只共享了编译后生成的obj文件和一份配套的文档,文档里说明了怎么去调用此obj里的函数。

现在有了undname这个工具,配合之前对obj文件的文本搜索经验,我们就可以尝试探索并调用obj文件里的所有可用函数,而不是被局限于文档的说明。

假设一个程序员写了一个cpp文件,并且只在文档里说明了test_open函数,却隐藏了test_hiden函数的说明:

注:代码中英语“隐藏”拼写错误,应该是hidden,而非hiden。

//文件:test.cpp (作者:编程合作者)
//在文档内提供次函数说明
int test_open(int n)
{
  return n + 10;
}

//文档里没有提到此函数
int test_hiden(int n)
{
  return n * 10;
}

编译之后,obj文件和test_open函数的调用说明(test_open函数的调用说明也可以是头文件的形式)被共享出去。主函数的编写者拿到obj文件之后,虽然他不知道test_open函数的源码,但是按照调用说明还是可以调用这个函数:

//文件:main.cpp (作者:主函数编写者)
#include <stdio.h>

int test_open(int n);    //obj文件内的函数的声明;也可以是包含一个头文件的形式。

int main(int argc, char* argv[])
{
  int nNum = test_open(5);

  printf("%d", nNum);

  return 0;
}

现在只要编译此main.cpp文件,就可以拿到main.obj文件。然后把main.obj文件和编程合作者提供的test.obj文件链接,我们就会得到有功能的main.exe文件了。main.exe的执行结果为15,符合test_open函数的功能,整个过程如下图所示:

现在我们用WinHex来打开编程合作者发来的test.obj文件,尝试探索一些他在文档里没有说明的信息(即test_hiden函数)。如图所示,按照之前的经验,可以在文件末尾发现被名称粉碎的函数信息。

对于我们感兴趣的test_hiden函数,复制它的信息,然后用undname工具还原一下本来的面貌,如图所示:

原来,这是一个C调用约定的函数,它的返回值是int,需要1个int型的参数。知道了这些信息,我们就可以尝试在main函数里去调用它。修改main函数代码(test_hiden的函数声明里:形参名字可以任意写,甚至可以不写,编译器在乎的只是形参的类型;C调用约定是默认调用约定,所以不用写):

#include <stdio.h>

int test_open(int n);
int test_hiden(int nSecret);

int main(int argc, char* argv[])
{
  int nNum = test_open(5);

  printf("Function that was documented: %d\r\n", nNum);

  nNum = test_hiden(5);
  printf("Function that was hiden: %d\r\n", nNum);

  return 0;
}

这样,我们就调用了这个没有被文档说明的函数:

对于C语法下的带有static修饰符的静态函数,这种方法还是无能为力的,因为static函数的信息不会出现在obj文件中。

对于c++语法下类(Class)里面的private/protected函数(包括带有static修饰符的静态函数),虽然我们能通过obj文件和undname工具还原它们的函数信息,但由于它们的private/protected属性,还是无法从外部对它们进行调用。如果函数是public属性,那么无论他是普通函数,还是static修饰的静态函数,都可以用本文的方法还原函数信息然后调用。

关于static修饰符的题外话:
同样是用static修饰函数,C语法下(文件中的静态函数)和C++语法下(类中的静态函数)有很大的区别。C语法下(文件中的静态函数)目的是把函数封装在它所在的文件中,让其他文件看不到这个函数,相当于给它添加了C++中的private属性。C++语法下(类中的静态函数)目的是让类的使用者在无需实例化类为对象的情况下,可以顺利使用类中的函数。
C++语法中,类中的静态属性和静态函数,分别是为了替代C语法中的全局变量和全局函数。在C语法中,如果不用static修饰一个函数,那么它的作用域就是整个工程,它就是全局函数。C++为什么要替代C语法中的全局变量和全局函数?一是因为他们作用域为全局,难以管理。二是因为在程序员合作编写项目时,随着软件代码量的增加,出现了全局变量、全局函数的重名问题。
在一些其他流行的高级编程语言中,全局变量和全局函数是直接被禁止使用的。C++为了兼容C的语法,所以一直保留着全局变量和全局函数的用法。

posted @ 2016-10-14 10:21  zhugehq  阅读(3910)  评论(4编辑  收藏  举报