zoukankan      html  css  js  c++  java
  • 将DLL挂接到远程进程之中(远程注入)

    线程的远程注入

    要实现线程的远程注入必须使用Windows提供的CreateRemoteThread函数来创建一个远程线程
    该函数的原型如下:
    HANDLE CreateRemoteThread(
        HANDLE hProcess,
        LPSECURITY_ATTRIBUTES lpThreadAttributes,
        SIZE_T dwStackSize,
        LPTHREAD_START_ROUTINE lpStartAddress,
        LPVOID lpParameter,
        DWORD dwCreationFlags,
        LPDWORD lpThreadId
    );

    参数说明:
    hProcess:目标进程的句柄
    lpThreadAttributes:指向线程的安全描述结构体的指针,一般设置为NULL,表示使用默认的安全级别
    dwStackSize:线程堆栈大小,一般设置为0,表示使用默认的大小,一般为1M
    lpStartAddress:线程函数的地址
    lpParameter:线程参数
    dwCreationFlags:线程的创建方式
                     CREATE_SUSPENDED 线程以挂起方式创建
    lpThreadId:输出参数,记录创建的远程线程的ID

    CreateRemoteThread函数介绍完毕,其他详细信息参考MSDN中关于该函数的详细说明!
    既然知道了使用这个函数来创建一个远程线程,接下来我们就来定义线程函数体,和普通的线程函数的
    定义相同,远程线程的线程函数必须定义程类的静态成员函数或者全局函数
    例如:
    DWORD __stdcall threadProc(LPVOID lParam)
    {
       //我们在这里先将该线程函数定义为空函数
       return 0;
    }
    在这里我们先将线程函数体定义为空,因为要作为远程注入的线程,线程函数体的编写方式和普通线程函数
    稍有不同。

    然后将线程代码拷贝到目标进程地址空间中(该地址必须是页面属性为PAGE_EXECUTE_READWRITE的页面)或者
    其他宿主进程能执行地方(如:共享内存映射区)。在这里我们选择宿主进程。在拷贝线程体的时候我们需要
    使用VirtualAllocEx函数在宿主进程中申请一块存储区域,然后再通过WriteProcessMemory函数将线程代码写
    入宿主进程中。

    要取得宿主进程的ID可以有很多种方法可以使用Psapi.h中的函数,也可以使用Toolhelp函数,在这里提供一种
    使用Toolhelp实现的函数,函数如下

    //根据进程名称得到进程的ID,如果有多个实例在同时运行的话,只返回第一个枚举到的进程ID
    DWORD processNameToId(LPCTSTR lpszProcessName)
    {
       HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
       PROCESSENTRY32 pe;
       pe.dwSize = sizeof(PROCESSENTRY32);

       if (!Process32First(hSnapshot, &pe)) {
           MessageBox(NULL, 
               "The frist entry of the process list has not been copyied to the buffer", 
               "Notice", MB_ICONINFORMATION | MB_OK);
           return 0;
       }

        while (Process32Next(hSnapshot, &pe)) {
            if (!strcmp(lpszProcessName, pe.szExeFile)) {
                return pe.th32ProcessID;
            }
        }
     
        return 0;
    }

    以上步骤完成之后就可以使用CreateRemoteThread创建远程线程了!示例代码如下

    #include <windows.h>
    #include <TlHelp32.h>
    #include <iostream>

    //要插入宿主进程中的线程函数
    DWORD __stdcall threadProc(LPVOID lParam)
    {
        return 0;
    }

    int main(int argc, char* argv[])
    {
        const DWORD dwThreadSize = 4096;
        DWORD dwWriteBytes;

        std::cout << "Please input the name of target process" << std::endl;
        char szExeName[MAX_PATH] = { 0 };
        //等待输入宿主进程名称
        std::cin >> szExeName;
     
        //得到指定名称进程的进程ID,如果有多个进程实例,则得到第一个进程ID
        DWORD dwProcessId = processNameToId(szExeName);
        HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId)
        void* pRemoteThread = VirtualAllocEx(hTargetProcess, 0, 
        dwThreadSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
        //把线程体写入宿主进程中
        if (!WriteProcessMemory(hTargetProcess, 
            pRemoteThread, &threadProc, dwThreadSize, 0)) {
            MessageBox(NULL, "Write data to target process failed !", 
                "Notice", MB_ICONINFORMATION | MB_OK);
            return 0;
        }
        //在宿主进程中创建线程
        HANDLE hRemoteThread = CreateRemoteThread(
            hTargetProcess, NULL, 0, (DWORD (__stdcall *)(void *))pRemoteThread, 
            NULL, 0, &dwWriteBytes);
        if (!hRemoteThread) {
            MessageBox(NULL, "Create remote thread failed !", "Notice", MB_ICONSTOP);
            return -1;
        }
        return 0;
    }

    当上面的代码运行的时候会在宿主进程中创建一条由程序员定义的线程,只不过现在这个线程函数体为空
    什么都不做。
    下面我们来编写具体的线程函数体的内容,在这里我们只是简单的显示一个消息对话框MessageBox
    修改之后的线程函数体如下:
    DWORD __stdcall threadProc(LPVOID lParam)
    {
        MessageBox(NULL, "hello", "hello", MB_OK);
        return 0;
    }

    线程体修改完毕之后我们运行程序,将线程注入到宿主进程之中。不过此时会产生一个非法访问的错误。原
    因就是线程体中的MessageBox(NULL, "hello", "hello", MB_OK);函数的第二和第三个参数所指向的字符串
    是存在于当前进程的地址空间中,宿主进程中的线程访问该字符串"hello"就会出现访问内存非法的错误。
    解决的方法就是将该字符串的内容也拷贝到宿主进程的地址空间中,而且连同MessageBox函数在User32.dll
    中的地址也拷贝到宿主进程之中。
    要将字符串和MessageBox函数的入口地址拷贝到宿主进程中我们首先定义下面这个RemoteParam结构体,用来
    存放MessageBox函数的入口地址和MessageBox显示的字符串的内容,该结构的定义如下:
    //线程参数
    typedef struct _RemoteParam {
        char szMsg[12];    //MessageBox函数显示的字符串
        DWORD dwMessageBox;//MessageBox函数的入口地址
    } RemoteParam, * PRemoteParam;

    RemoteParam remoteData;
    ZeroMemory(&remoteData, sizeof(RemoteParam));
     
    HINSTANCE hUser32 = LoadLibrary("User32.dll");
    remoteData.dwMessageBox = (DWORD)GetProcAddress(hUser32, "MessageBoxA");
    strcat(remoteData.szMsg, "Hello/0");

    //在宿主进程中分配存储空间
    RemoteParam* pRemoteParam = (RemoteParam*)VirtualAllocEx(
        hTargetProcess , 0, sizeof(RemoteParam), MEM_COMMIT, PAGE_READWRITE);
     
    if (!pRemoteParam) {
        MessageBox(NULL, "Alloc memory failed !", 
            "Notice", MB_ICONINFORMATION | MB_OK);
        return 0;
    }

    //将字符串和MessageBox函数的入口地址写入宿主进程
    if (!WriteProcessMemory(hTargetProcess ,
            pRemoteParam, &remoteData, sizeof(remoteData), 0)) {
        MessageBox(NULL, "Write data to target process failed !", 
            "Notice", MB_ICONINFORMATION | MB_OK);
        return 0;
    }
     
    //创建远程线程
    HANDLE hRemoteThread = CreateRemoteThread(
        hTargetProcess, NULL, 0, (DWORD (__stdcall *)(void *))pRemoteThread, 
        pRemoteParam, 0, &dwWriteBytes);

    另外还需要注意的一点是,在打开进程的时候有些系统进程是无法用OpenProcess函数
    打开的,这个时候就需要提升进程的访问权限,进而来达到访问系统进程的目的,在这里
    我提供了一个提升进程访问权限的函数enableDebugPriv(),该函数的定义如下:

    //提升进程访问权限
    bool enableDebugPriv()
    {
        HANDLE hToken;
        LUID sedebugnameValue;
        TOKEN_PRIVILEGES tkp;
      
        if (!OpenProcessToken(GetCurrentProcess(), 
            TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken)) {
            return false;
        }

        if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &sedebugnameValue)) {
            CloseHandle(hToken);
            return false;
        }

        tkp.PrivilegeCount = 1;
        tkp.Privileges[0].Luid = sedebugnameValue;
        tkp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

        if (!AdjustTokenPrivileges(hToken, FALSE, &tkp, sizeof(tkp), NULL, NULL)) {
            CloseHandle(hToken);
            return false;
        }

        return true;
    }

    至此创建远程线程的工作全部结束,下面就给出完整的代码:

    #pragma once
    #include "stdafx.h"
    #include <windows.h>
    #include <TlHelp32.h>
    #include <iostream>

    //线程参数结构体定义
    typedef struct _RemoteParam {
        char szMsg[12];    //MessageBox函数中显示的字符提示
        DWORD dwMessageBox;//MessageBox函数的入口地址
    } RemoteParam, * PRemoteParam;

    //定义MessageBox类型的函数指针
    typedef int (__stdcall * PFN_MESSAGEBOX)(HWND, LPCTSTR, LPCTSTR, DWORD);


    //线程函数定义
    DWORD __stdcall threadProc(LPVOID lParam)
    {
        RemoteParam* pRP = (RemoteParam*)lParam;
     
        PFN_MESSAGEBOX pfnMessageBox;
        pfnMessageBox = (PFN_MESSAGEBOX)pRP->dwMessageBox;
        pfnMessageBox(NULL, pRP->szMsg, pRP->szMsg, 0);

        return 0;
    }

    //提升进程访问权限
    bool enableDebugPriv()
    {
        HANDLE hToken;
        LUID sedebugnameValue;
        TOKEN_PRIVILEGES tkp;
      
        if (!OpenProcessToken(GetCurrentProcess(), 
            TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken)) {
            return false;
        }

        if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &sedebugnameValue)) {
            CloseHandle(hToken);
            return false;
        }

        tkp.PrivilegeCount = 1;
        tkp.Privileges[0].Luid = sedebugnameValue;
        tkp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

        if (!AdjustTokenPrivileges(hToken, FALSE, &tkp, sizeof(tkp), NULL, NULL)) {
            CloseHandle(hToken);
            return false;
        }

        return true;
    }


    //根据进程名称得到进程ID,如果有多个运行实例的话,返回第一个枚举到的进程的ID
    DWORD processNameToId(LPCTSTR lpszProcessName)
    {
        HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
        PROCESSENTRY32 pe;
        pe.dwSize = sizeof(PROCESSENTRY32);

        if (!Process32First(hSnapshot, &pe)) {
            MessageBox(NULL, 
                "The frist entry of the process list has not been copyied to the buffer", 
               "Notice", MB_ICONINFORMATION | MB_OK);
            return 0;
        }

        while (Process32Next(hSnapshot, &pe)) {
            if (!strcmp(lpszProcessName, pe.szExeFile)) {
                return pe.th32ProcessID;
            }
        }
     
        return 0;
    }

    int main(int argc, char* argv[])
    {
        //定义线程体的大小
        const DWORD dwThreadSize = 4096;
        DWORD dwWriteBytes;
        //提升进程访问权限
        enableDebugPriv();

        //等待输入进程名称,注意大小写匹配
        std::cout << "Please input the name of target process !" << std::endl;
        char szExeName[MAX_PATH] = { 0 };
        std::cin >> szExeName;

        DWORD dwProcessId = processNameToId(szExeName);

        if (dwProcessId == 0) {
            MessageBox(NULL, "The target process have not been found !",
                "Notice", MB_ICONINFORMATION | MB_OK);
            return -1;
        }

        //根据进程ID得到进程句柄
        HANDLE hTargetProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId);
     
        if (!hTargetProcess) {
            MessageBox(NULL, "Open target process failed !", 
                "Notice", MB_ICONINFORMATION | MB_OK);
            return 0;
        }
     
        //在宿主进程中为线程体开辟一块存储区域
        //在这里需要注意MEM_COMMIT | MEM_RESERVE内存非配类型以及PAGE_EXECUTE_READWRITE内存保护类型
        //其具体含义请参考MSDN中关于VirtualAllocEx函数的说明。
        void* pRemoteThread = VirtualAllocEx(hTargetProcess, 0, 
            dwThreadSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

        if (!pRemoteThread) {
            MessageBox(NULL, "Alloc memory in target process failed !", 
                "notice", MB_ICONINFORMATION | MB_OK);
            return 0;
        }
     
        //将线程体拷贝到宿主进程中
        if (!WriteProcessMemory(hTargetProcess, 
                pRemoteThread, &threadProc, dwThreadSize, 0)) {
            MessageBox(NULL, "Write data to target process failed !", 
                "Notice", MB_ICONINFORMATION | MB_OK);
            return 0;
        }
        //定义线程参数结构体变量
        RemoteParam remoteData;
        ZeroMemory(&remoteData, sizeof(RemoteParam));
     
        //填充结构体变量中的成员
        HINSTANCE hUser32 = LoadLibrary("User32.dll");
        remoteData.dwMessageBox = (DWORD)GetProcAddress(hUser32, "MessageBoxA");
        strcat(remoteData.szMsg, "Hello/0");
     
        //为线程参数在宿主进程中开辟存储区域
        RemoteParam* pRemoteParam = (RemoteParam*)VirtualAllocEx(
        hTargetProcess , 0, sizeof(RemoteParam), MEM_COMMIT, PAGE_READWRITE);
     
        if (!pRemoteParam) {
            MessageBox(NULL, "Alloc memory failed !", 
                "Notice", MB_ICONINFORMATION | MB_OK);
            return 0;
        }

        //将线程参数拷贝到宿主进程地址空间中
        if (!WriteProcessMemory(hTargetProcess ,
                pRemoteParam, &remoteData, sizeof(remoteData), 0)) {
            MessageBox(NULL, "Write data to target process failed !", 
                "Notice", MB_ICONINFORMATION | MB_OK);
            return 0;
        }
     
        //在宿主进程中创建线程
        HANDLE hRemoteThread = CreateRemoteThread(
            hTargetProcess, NULL, 0, (DWORD (__stdcall *)(void *))pRemoteThread, 
            pRemoteParam, 0, &dwWriteBytes);

        if (!hRemoteThread) {
            MessageBox(NULL, "Create remote thread failed !", "Notice",  MB_ICONINFORMATION | MB_OK);
            return 0;
        }

        CloseHandle(hRemoteThread);

        return 0;
    }

    上面是远程线程注入的一段简单的代码,以及一些简要的说明,放在这里和大家共享!
    希望各位不要见笑! :)

    将DLL挂接到远程进程之中(远程注入)

    在上一篇文章《线程的远程注入》中介绍了如何让其他的进程中执行自己的代码的一种方法 
    及自己定义一个线程,在线程体中编写执行代码,然后使用VirtualAllocEx函数为线程体以 
    及线程中用到的字符常量和调用的MessageBox入口函数地址,在目标进程中开辟存储区,然 
    后再通过WriteProcessMemory函数,将这些数据写入目标进程的地址空间中。最后通过 
    CreateRemoteThread函数,让自己的线程运行在目标进程中。 

    上面的方法需要为线程和常量在目标进程中开辟足够的存储空间,而且这个空间的大小很难 
    确定,稍不注意就会发生访问违规的错误。 

    下面我来介绍另外一种在其他进程中执行自己的代码的方法,让目标进程加载我们自己编写的 
    DLL模块。 

    首先我们来创建一个DLL模块(稍后我们将要把这个DLL加载到目标进程中,让目标进程来运行 
    DLL中的代码)。 
    我们创建的这个DLL模块非常简单,只是得到加载该DLL的进程的ID,然后通过MessageBox函数 
    显示出来,当然也可以写一些复杂的代码。不过我们这里只是介绍一下方法,有个显示结果足 
    以:)。下面就是DLL模块的代码: 

    //Test.dll源代码 
    // 
    #include <windows.h> 

    BOOL APIENTRY DllMain(HANDLE hMoudle, DWORD dwReason, LPVOID lpReserved) 

        char* pszProcessId = (char*)malloc(10*sizeof(char)); 

        switch(dwReason) { 
            case DLL_PROCESS_ATTACH: 
            _itoa(GetCurrentProcessId(), pszProcessId, 10); 
            MessageBox(NULL, pszProcessId, "Notice", MB_ICONINFORMATION | MB_OK); 
            case DLL_PROCESS_DETACH: 
            case DLL_THREAD_ATTACH: 
            case DLL_THREAD_DETACH: 
            break; 
        } 
        return TRUE; 


    下一步我们就可以专注于如何把这个DLL挂接到目标进程中。 

    首先我们通过OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId)来打开我们试图加载DLL的进程(注意 
    进程的打开权限一定要设置为PROCESS_ALL_ACCESS,因为我们要在目标进程中创建线程)。 

    然后我们可以使用CreateRemoteThread来创建LoadLibraryA线程来启动我们的这个DLL。 
    LoadLibraryA存在于系统的kernel32.dll中用来加载DLL模块,该函数只有一个参数就是DLL文件的名称(该
    名称包括路径)。由于加载DLL的操作是在其他进程中进行,所以我们必须把DLL的文件名称拷贝到目标进程 
    的地址空间中。 

    我们必须计算出该文件名所占的内存空间 

    int nLength = (strlen(pszFileName)+1)*sizeof(char); 
    接下来使用VirtualAllocEx函数为DLL文件名在目标进程中分配地址空间,在使用WriteProcessMemory将 
    DLL的文件名拷贝到刚刚分配的地址中。 

    下一步就是取得LoadLibraryA函数的入口地址 
    PTHREAD_START_ROUTINE pfnLoadLibraryA = 
    (PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle("Kernel32.dll"), "LoadLibraryA"); 
    因为kernel32.dll模块是系统的核心模块,所以该模块中的函数地址在所有进程中全都相同。 
    我们在本进程中得到的LoadLibraryA函数的入口地址同样适用于其他进程。 

    所有条件已经具备,最后我们使用CreateRemoteThread函数,将LoadLibraryA函数的入口地址以及DLL的文件 
    名作为参数,即可让目标进程加载我们自己的DLL,至于你要让你的DLL中运行什么代码,就自己看着办吧! 

    让远程进程挂接自己的DLL的思路就是这样,下面给出完整代码: 

    #include <windows.h> 
    #include <psapi.h> 
    #include <string> 
    #include <iostream> 

    #pragma comment(lib, "Psapi.lib") 

    //提升进程访问权限 
    void enableDebugPriv() 

        HANDLE hToken; 
        LUID sedebugnameValue; 
        TOKEN_PRIVILEGES tkp; 

        if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken)) 
            return; 

        if (!LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &sedebugnameValue)) { 
            CloseHandle(hToken); 
            return; 
        } 

        tkp.PrivilegeCount = 1; 
        tkp.Privileges[0].Luid = sedebugnameValue; 
        tkp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; 

        if (!AdjustTokenPrivileges(hToken, FALSE, &tkp, sizeof(tkp), NULL, NULL)) 
            CloseHandle(hToken); 
        } 

        //根据进程名称取得进程ID,如果有多个运行实例则返回第一个枚举出来的进程ID 
    DWORD getSpecifiedProcessId(const char* pszProcessName) 

        DWORD processId[1024], cbNeeded, dwProcessesCount; 
        HANDLE hProcess; 
        HMODULE hMod; 

        char szProcessName[MAX_PATH] = "UnknownProcess"; 
        DWORD dwArrayInBytes = sizeof(processId)*sizeof(DWORD); 

        if (!EnumProcesses(processId, dwArrayInBytes, &cbNeeded)) 
            return 0; 
        //计算数组中的元素个数
        dwProcessesCount = cbNeeded / sizeof(DWORD); 
        enableDebugPriv(); 

        for (UINT i = 0; i < dwProcessesCount; i++) { 
            hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processId[i]); 
            if (!hProcess) { 
                continue; 
            } else { 
                if (EnumProcessModules(hProcess, &hMod, sizeof(hMod), &cbNeeded)) { 
                    GetModuleBaseName(hProcess, hMod, szProcessName, sizeof(szProcessName)); 
                    if (!_stricmp(szProcessName, pszProcessName)) { 
                        CloseHandle(hProcess); 
                        return processId[i]; 
                    } 
                } 
            } 
        } 

        CloseHandle(hProcess); 
        return 0; 



    int main(int argc, char* argv[]) 

        std::cout << "please input the name of target process !" << std::endl; 
        //等待输入进程名称 
        std::string strProcessName; 
        std::cin >> strProcessName; 

        //在这里为了简单起见,使用了绝对路径 
        char szDllPath[MAX_PATH] = "D://test.dll"; 
        char szFileName[MAX_PATH] = "D://test.dll"; 
        //提升进程访问权限 
        enableDebugPriv(); 

        if (strProcessName.empty()) { 
            MessageBox(NULL, "The target process name is invalid !", "Notice", MB_ICONSTOP); 
            return -1; 
        } 
        //根据进程名称得到进程ID 
        DWORD dwTargetProcessId = getSpecifiedProcessId(strProcessName.c_str()); 

        HANDLE hTargetProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwTargetProcessId); 

        if (!hTargetProcess) { 
            MessageBox(NULL, "Open target process failed !", "Notice", MB_ICONSTOP); 
            return -1; 
        } 
        //计算DLL文件名称所占的存储空间 
        int memorySize = (strlen(szDllPath) + 1) * sizeof(char); 
        //在目标进程中开辟存储空间,用来存放DLL的文件名称 
        char* pszFileNameRemote = (char*)VirtualAllocEx(hTargetProcess, 
            0, memorySize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); 

        if (!pszFileNameRemote) { 
            MessageBox(NULL, "Alloc dll name string in target process failed !", "Notice", MB_ICONSTOP); 
            return -1; 
        } 
        //将DLL的文件名写入目标进程地址空间 
        if (!WriteProcessMemory(hTargetProcess, pszFileNameRemote, 
            (LPVOID)szFileName, memorySize, NULL)) { 
            MessageBox(NULL, "Write dll name string to target process failed !", 
                "Notice", MB_ICONSTOP); 
            return -1; 
        } 

        PTHREAD_START_ROUTINE pfnStartAddr = 
            (PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryA"); 

        HANDLE hRemoteThread = CreateRemoteThread(hTargetProcess, 
            NULL, 0, pfnStartAddr, pszFileNameRemote, 0, NULL); 

        WaitForSingleObject(hRemoteThread, INFINITE); 
        VirtualFreeEx(hTargetProcess, 0, memorySize, NULL); 

        if (hRemoteThread) 
            CloseHandle(hTargetProcess); 

        return 0; 


    本文参考了西祠高手shotgun的《揭开木马的神秘面纱》,代码为自己所写,放在这里供大家参考。 
    大家可以参考上面的步骤自己实现一遍! 

  • 相关阅读:
    仿jquery 选择器功能
    多个div拖拽功能
    js 模拟jquery onready 事件
    随着鼠标移动的图片百叶窗效果
    计算体重引发的思考
    js 模拟事件
    表单验证功能(利用冒泡功能)
    视频播放滚动条(最终完善版)
    仿制视频播放滚动条效果(加左右控制按钮)
    无极树(待整理)
  • 原文地址:https://www.cnblogs.com/shangdawei/p/4077758.html
Copyright © 2011-2022 走看看