• 标 题:DLL输出类使用研究手记 
  • 作 者:softworm
  • 时 间:2003/08/04 09:54pm
  • 链 接:http://bbs.pediy.com

贴一篇我以前写的文章,改头换面在杂志上登过,感觉还有点意思。

在写一个程序时,我想使用一个共享软件中的C++类。该类名为Crypt,封装在一个DLL中,文件名为Crypt.dll。通过SoftIce和IDA Pro,我已基本弄清了其成员函数的用法。现在的问题是,没有相应的.H文件及.LIB文件(当然更没有源码)。另外,其成员函数显然不能以GetProcAddress取得地址后直接调用。

该软件是用Borland C++写的。

先用Borland C++提供的工具获取必要的文件

C:\bc5\bin\impdef  Crypt.def  Crypt.dll//得到Crypt.def文件
C:\bc5\bin\implib  Crypt.lib  Crypt.dll//得到Crypt.lib文件

Crypt.def中的相关内容如下:

@Crypt@$bctr$qpxc@1; Crypt::Crypt(const char*)
@Crypt@$bctr$qpxuci@2; Crypt::Crypt(const unsigned char*,int)
@Crypt@DecodeFrom$qpuct1l @3; Crypt::DecodeFrom(unsigned char*,unsigned
                          char*,long)
@Crypt@EncodeTo$qpuct1l @4; Crypt::EncodeTo(unsigned char*,unsigned
                         char*,long)

从分号后的注释,可以得到Demangled后的成员函数原型,但是没有类的定义,我们不知道这个类包含什么数据成员(以及别的未exported的成员函数,这一点不重要,因为原来的编程者在使用这个封装在DLL中的类时,也只能使用exported的函数)。如何构造一个正确的头文件?先来看看原来的代码是如何使用这个类的。以下为IDA Pro的输出:

00453E3E 0F0push   8
00453E40 0F4lea    eax, [ebp+var_8]
00453E43 0F4push   eax
00453E44 0F8lea    ecx, [ebp+var_E0]
00453E4A 0F8push   ecx
00453E4B 0FCcall   Crypt::Crypt(uchar *,int)
00453E50 0FCadd    esp, 0Ch

这段代码调用Crypt::Crypt(uchar *,int)成员函数,使用__cdecl调用规则,由调用者维护堆栈。函数有2个参数,向堆栈中压入了3个值,最后一个push入栈的是指向当前Crypt对象的this指针,即变量var_E0就是在栈上分配的Crypt类对象。从IDA Pro中可看到,var_E0覆盖了从FFFFFF20到FFFFFF80共96字节的空间。我们知道,C++类的成员函数、静态数据成员是不放在对象内的,对象只含有数据成员(若类中或其基类中定义有虚函数,还包含vptr)。也就是说,Crypt类的所有数据成员共占据96字节。具体细节请参照Stanley Lippman的《深度探索C++对象模型》。

由此,我们可以自己定义Crypt类的数据成员(使用字节数组),使其占据同样的内存空间,与原来的类在内存布局上一致即可。实际上,只要我们给出的类定义保证能分配足够的内存空间,原来的构造函数就可以在分配的内存中创建出正确的对象。这种方法与COM的思想有相似之处,都是在二进制的级别上保证内存布局的兼容。我写的头文件如下:

class _import Crypt
{
public:
  Crypt(const char* lpszPassword);
  Crypt(const unsigned char* lpszPassword,int cbBuffer);
  EncodeTo(unsigned char* lpSource,unsigned char* lpDestination,int nSize);
  DecodeFrom(unsigned char* lpSource,unsigned char* lpDestination,int nSize);

public:
  char dummy[96];   //Bingo!:-)
};

将此头文件及前面的Crypt.lib文件加入项目,证明此方法是可行的。测试代码如下:

 Crypt obj("123456");
 obj.EncodeTo(lpData,lpData,nSize);

以上的尝试都是在Borland C++下做的,与原来的程序具有同样的环境,如果想在Visual C++下使用该类又如何实现?从DLL中输出类的技术细节是因编译器厂商而异的,显然不能再如法炮制(VC++甚至不能识别用implib生成的Crypt.lib文件)。我们可以变通一下,自己用VC++写一个Crypt.dll,包裹在原来的DLL外面,输出与原有DLL相同名字和序号的函数,用VC++写的客户程序使用这个Wrapper DLL,由其再去调用原来的DLL。这种编写一个包在原有DLL外面的动态链接库的方法,相关资料很多,这里不再详细解释。

将原来的DLL改名为OldDll.dll。源代码如下:

头文件Crypt.h:

#ifdef CRYPT_EXPORTS
#define CRYPT_API __declspec(dllexport)
#else
#define CRYPT_API __declspec(dllimport)
#endif

class CRYPT_API Crypt {

public:
 Crypt& operator=(const Encrypt& rhs);//赋值运算符,禁止
 Crypt(const Encrypt& rhs);//拷贝构造函数,禁止

public:
 Crypt(const char* lpszPassword);
 Crypt(const unsigned char* lpszPassword,int cbBuffer);
 void __cdecl EncodeTo(unsigned char* lpSource,unsigned char*
            lpDestination,int nSize);
 void __cdecl DecodeFrom(unsigned char* lpSource,unsigned char*  
             lpDestination,int nSize);

public:
char dummy[96];   //Bingo!:-)
};

实现文件Crypt.cpp:

#include "stdafx.h"
#include "Crypt.h"

static HINSTANCE hOldDll=NULL;

static DWORD dwRet;
static DWORD dwRetAddr;

static FARPROC lpCrypt1;//带1个参数的构造函数
static FARPROC lpCrypt2;//带2个参数的构造函数
static FARPROC lpEncodeTo;
static FARPROC lpDecodeFrom;

BOOL APIENTRY DllMain( HANDLE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved )
{
 BOOL bRet=false;

 switch (ul_reason_for_call)
 {
  case DLL_PROCESS_ATTACH:

    //加载原来的DLL,获取原函数地址

    hOldDll=LoadLibrary("c:\\test\\OldDll.dll");
    if(hOldDll)
    {
      lpCrypt1=::GetProcAddress(hOldDll,MAKEINTRESOURCE(0x1));
      lpCrypt2=::GetProcAddress(hOldDll,MAKEINTRESOURCE(0x2));
      lpEncodetTo=::GetProcAddress(hOldDll,MAKEINTRESOURCE(0x3));
      lpDecodeFrom=::GetProcAddress(hOldDll,MAKEINTRESOURCE(0x4));

      bRet=true;
    }

    break;

   case DLL_THREAD_ATTACH:
    break;

   case DLL_THREAD_DETACH:
    break;

   case DLL_PROCESS_DETACH:
    if(hOldDll)
    {
     ::FreeLibrary(hOldDll);
    }
    break;
  }
 
  return bRet;
}

__declspec(naked) Crypt::Crypt(const char *lpszPassword)
{
  //手工模仿__cdel调用规则

  _asm
  {
    pop eax//弹出并保存返回地址
    mov dwRetAddr,eax

    push ecx//压this指针入栈

    call lpCrypt1 //调用原函数

    mov dwRet,eax //保存调用返回值

    mov eax,dwRetAddr
    push eax//重新压返回地址入栈

    mov eax,dwRet //恢复调用返回值

    ret 8 //返回,丢弃2个dword(参数和this指针)
  }
}

__declspec(naked) Crypt::Crypt(const unsigned char* lpszPassword,int cbBuffer)
{
  //手工模仿__cdel调用规则

  _asm
  {
    pop eax
    mov dwRetAddr,eax

    push ecx

    call lpCrypt2

    mov dwRet,eax

    mov eax,dwRetAddr
    push eax
    mov eax,dwRet
    ret 0xC    //丢弃3个dword
  }
}

void __declspec(naked) __cdecl Crypt::EncodeTo(unsigned char* lpSource,
unsigned char* lpDestination,int nSize)
{
  //直接跳转到原函数

  _asm jmp far dword ptr lpEncodeTo
}

void __declspec(naked) __cdecl Crypt::DecodeFrom(unsigned char* lpSource,
unsigned char* lpDestination,int nSize)
{
  //直接跳转到原函数

  _asm jmp far dword ptr lpDecodeFrom
}

有几处需要注意:首先,这里使用了naked调用规则(Borland C++不支持),以便于直接操作堆栈及用内嵌的汇编语言编程。另外,虽然我们的类中并没有包含虚函数或对象成员,VC++编译器却仍生成了member-wise的拷贝构造函数和bit-wise赋值运算符,并导致原来的DLL中的构造函数不能正确创建对象。为了禁止编译器自动生成不必要的代码,在头文件中定义了赋值运算符和拷贝构造函数,但并未提供实现。两个构造函数有点特殊,我发现无论指定何种调用规则,生成的代码总是使用thiscall调用规则,即在ecx寄存器中传递this指针,为此构造函数需要特殊处理,用汇编代码手工模仿__cdecl调用规则去调用原来DLL中的函数,包括维护栈指针。

其余的代码已作了注释,易于理解,不再赘述。