手動加載DLL(PE文件)

以前學重載內核時學到了手動加載一個PE文件,想在Ring3層也實現一遍,不過在GitHub上看到有現成的源碼了就不自己寫了。本篇文章就分析一下這個mmLoader,看看怎麼實現手動加載PE文件

閱讀前需要了解PE文件結構,還不瞭解的自行惡補

這個庫爲了實現注入ShellCode,自己實現了memset等常用庫函數,還自己定義了要用到的庫函數表。實際使用時,如果不用注入ShellCode使用常規的庫函數就好

雖然這個庫是用來加載DLL的,修改一下也可以用來加載EXE

加載PE文件的主要步驟:

  1. 將PE頭和各個節映射到內存
  2. 重定位
  3. 修復IAT
  4. 調用模塊入口點

映射文件到內存

這一步就是把PE文件的內容讀到內存相應的位置,要注意文件中各節的對齊和內存中的對齊是不一樣的

/// <summary>
/// Maps all the sections.
/// </summary>
/// <param name="pMemModule">The <see cref="MemModule" /> instance.</param>
/// <returns>True if successful.</returns>
BOOL MapMemModuleSections(PMEM_MODULE pMemModule, LPVOID lpPeModuleBuffer)
{
    // Validate
    if (NULL == pMemModule || NULL == pMemModule->pNtFuncptrsTable || NULL == lpPeModuleBuffer)
        return FALSE;

    // Function pointer
    // VirtualAlloc的函數指針,ShellCode用的,實際使用時直接寫VirtualAlloc即可,後面類似的不再贅述
    Type_VirtualAlloc pfnVirtualAlloc = (Type_VirtualAlloc)(pMemModule->pNtFuncptrsTable->pfnVirtualAlloc);
    Type_VirtualFree pfnVirtualFree = (Type_VirtualFree)(pMemModule->pNtFuncptrsTable->pfnVirtualFree);

    // Convert to IMAGE_DOS_HEADER
    // PE文件頭部指針,即DOS頭
    PIMAGE_DOS_HEADER pImageDosHeader = (PIMAGE_DOS_HEADER)(lpPeModuleBuffer);

    // Get the pointer to IMAGE_NT_HEADERS
    // NT頭,MakePointer是個宏,返回某指針+某偏移量後的新指針
    PIMAGE_NT_HEADERS pImageNtHeader = MakePointer(
        PIMAGE_NT_HEADERS, pImageDosHeader, pImageDosHeader->e_lfanew);

    // Get the section count
    int nNumberOfSections = pImageNtHeader->FileHeader.NumberOfSections;

    // Get the section header
    // 節頭
    PIMAGE_SECTION_HEADER pImageSectionHeader = MakePointer(
        PIMAGE_SECTION_HEADER, pImageNtHeader, sizeof(IMAGE_NT_HEADERS));

    // Find the last section limit
    // 計算整個模塊尺寸
    DWORD dwImageSizeLimit = 0;
    for (int i = 0; i < nNumberOfSections; ++i)
    {
        if (0 != pImageSectionHeader[i].VirtualAddress)
        {
            if (dwImageSizeLimit < (pImageSectionHeader[i].VirtualAddress + pImageSectionHeader[i].SizeOfRawData))
                dwImageSizeLimit = pImageSectionHeader[i].VirtualAddress + pImageSectionHeader[i].SizeOfRawData;
        }
    }

    // Align the last image size limit to the page size
    // 按頁對齊
    dwImageSizeLimit = (dwImageSizeLimit + pMemModule->dwPageSize - 1) & ~(pMemModule->dwPageSize - 1);

    // Reserve virtual memory 
    // 優先使用ImageBase作爲模塊基址,分配一塊內存
    LPVOID lpBase = pfnVirtualAlloc(
        (LPVOID)(pImageNtHeader->OptionalHeader.ImageBase), 
        dwImageSizeLimit,
        MEM_RESERVE | MEM_COMMIT, 
        PAGE_READWRITE);

    // Failed to reserve space at ImageBase, then it's up to the system
    // 這個基址不能使用,讓系統隨機選另一個基址(後面需要重定位)
    if (NULL == lpBase)
    {
        // Reserver memory in arbitrary address
        lpBase = pfnVirtualAlloc(
            NULL, 
            dwImageSizeLimit,
            MEM_RESERVE | MEM_COMMIT, 
            PAGE_READWRITE);

        // Failed again, return 
        if (NULL == lpBase)
        {
            pMemModule->dwErrorCode = MMEC_ALLOCATED_MEMORY_FAILED;
            return FALSE;
        }
    }

    // Commit memory for PE header
    LPVOID pDest = pfnVirtualAlloc(lpBase, pImageNtHeader->OptionalHeader.SizeOfHeaders, MEM_COMMIT, PAGE_READWRITE);
    if (!pDest)
    {
        pMemModule->dwErrorCode = MMEC_ALLOCATED_MEMORY_FAILED;
        return FALSE;
    }

    // Copy the data of PE header to the memory allocated
    // 複製PE頭
    mml_memmove(pDest, lpPeModuleBuffer, pImageNtHeader->OptionalHeader.SizeOfHeaders);

    // Store the base address of this module.
    pMemModule->lpBase = pDest;
    pMemModule->dwSizeOfImage = pImageNtHeader->OptionalHeader.SizeOfImage;
    pMemModule->bLoadOk = TRUE;

    // Get the DOS header, NT header and Section header from the new PE header buffer
    pImageDosHeader = (PIMAGE_DOS_HEADER)pDest;
    pImageNtHeader = MakePointer(PIMAGE_NT_HEADERS, pImageDosHeader, pImageDosHeader->e_lfanew);
    pImageSectionHeader = MakePointer(PIMAGE_SECTION_HEADER, pImageNtHeader, sizeof(IMAGE_NT_HEADERS));

    // Map all section data into the memory
    // 複製所有的節
    LPVOID pSectionBase = NULL;
    LPVOID pSectionDataSource = NULL;
    for (int i = 0; i < nNumberOfSections; ++i)
    {
        if (0 != pImageSectionHeader[i].VirtualAddress)
        {
            // Get the section base
            pSectionBase = MakePointer(LPVOID, lpBase, pImageSectionHeader[i].VirtualAddress);

            if (0 == pImageSectionHeader[i].SizeOfRawData)
            {
                if (pImageNtHeader->OptionalHeader.SectionAlignment > 0)
                {
                    // If the size is zero, but the section alignment is not zero then allocate memory with the aligment
                    pDest = pfnVirtualAlloc(pSectionBase, pImageNtHeader->OptionalHeader.SectionAlignment,
                        MEM_COMMIT, PAGE_READWRITE);
                    if (NULL == pDest)
                    {
                        pMemModule->dwErrorCode = MMEC_ALLOCATED_MEMORY_FAILED;
                        return FALSE;
                    }

                    // Always use position from file to support alignments smaller than page size.
                    mml_memset(pSectionBase, 0, pImageNtHeader->OptionalHeader.SectionAlignment);
                }
            }
            else
            {
                // Commit this section to target address
                pDest = pfnVirtualAlloc(pSectionBase, pImageSectionHeader[i].SizeOfRawData, MEM_COMMIT, PAGE_READWRITE);
                if (NULL == pDest)
                {
                    pMemModule->dwErrorCode = MMEC_ALLOCATED_MEMORY_FAILED;
                    return FALSE;
                }

                // Get the section data source and copy the data to the section buffer
                pSectionDataSource = MakePointer(LPVOID, lpPeModuleBuffer, pImageSectionHeader[i].PointerToRawData);
                mml_memmove(pDest, pSectionDataSource, pImageSectionHeader[i].SizeOfRawData);
            }

            // Get next section header
            pImageSectionHeader[i].Misc.PhysicalAddress = (DWORD)(ULONGLONG)pDest;
        }
    }

    return TRUE;
}

重定位

代碼中那些絕對地址是以模塊基址=ImageBase爲前提硬編碼的,如果模塊加載的基址不是ImageBase指定的基址,則需要重定位

新地址=舊地址-ImageBase+實際模塊基址,只要算出實際模塊基址-ImageBase,重定位時加上偏移量就行了

/// <summary>
/// Relocates the module.
/// </summary>
/// <param name="pMemModule">The <see cref="MemModule" /> instance.</param>
/// <returns>True if successful.</returns>
BOOL RelocateModuleBase(PMEM_MODULE pMemModule)
{
    // Validate the parameters
    if (NULL == pMemModule  || NULL == pMemModule->pImageDosHeader)
        return FALSE;

    PIMAGE_NT_HEADERS pImageNtHeader = MakePointer(
        PIMAGE_NT_HEADERS,
        pMemModule->pImageDosHeader, 
        pMemModule->pImageDosHeader->e_lfanew);

    // Get the delta of the real image base with the predefined
    // 計算偏移量
    LONGLONG lBaseDelta = ((PUINT8)pMemModule->iBase - (PUINT8)pImageNtHeader->OptionalHeader.ImageBase);

    // This module has been loaded to the ImageBase, no need to do relocation
    if (0 == lBaseDelta) return TRUE;

    if (0 == pImageNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress
        || 0 == pImageNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size)
        return TRUE;

    // 重定位表
    PIMAGE_BASE_RELOCATION pImageBaseRelocation = MakePointer(PIMAGE_BASE_RELOCATION, pMemModule->lpBase, 
        pImageNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);

    if (NULL == pImageBaseRelocation)
    {
        pMemModule->dwErrorCode = MMEC_INVALID_RELOCATION_BASE;
        return FALSE;
    }

    while (0 != (pImageBaseRelocation->VirtualAddress + pImageBaseRelocation->SizeOfBlock))
    {
        PWORD pRelocationData = MakePointer(PWORD, pImageBaseRelocation, sizeof(IMAGE_BASE_RELOCATION));

        int NumberOfRelocationData = (pImageBaseRelocation->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);

        for (int i = 0; i < NumberOfRelocationData; i++)
        {
            if (IMAGE_REL_BASED_HIGHLOW == (pRelocationData[i] >> 12))
            {
                // 需要重定位的地址
                PDWORD pAddress = (PDWORD)(pMemModule->iBase + pImageBaseRelocation->VirtualAddress + (pRelocationData[i] & 0x0FFF));
                // 重定位
                *pAddress += (DWORD)lBaseDelta;
            }

#ifdef _WIN64
            if (IMAGE_REL_BASED_DIR64 == (pRelocationData[i] >> 12))
            {
                PULONGLONG pAddress = (PULONGLONG)(pMemModule->iBase + pImageBaseRelocation->VirtualAddress + (pRelocationData[i] & 0x0FFF));
                *pAddress += lBaseDelta;
            }
#endif
        }

        pImageBaseRelocation = MakePointer(PIMAGE_BASE_RELOCATION, pImageBaseRelocation, pImageBaseRelocation->SizeOfBlock);
    }

    return TRUE;
}

修復IAT

這一步載入本模塊依賴的模塊,並將本模塊的IAT定位到依賴模塊的相應的函數上

/// <summary>
/// Resolves the import table.
/// </summary>
/// <param name="pMemModule">The <see cref="MemModule" /> instance.</param>
/// <returns>True if successful.</returns>
BOOL ResolveImportTable(PMEM_MODULE pMemModule)
{
    if (NULL == pMemModule  || NULL == pMemModule->pNtFuncptrsTable || NULL == pMemModule->pImageDosHeader)
        return FALSE;

    Type_GetModuleHandleA pfnGetModuleHandleA = (Type_GetModuleHandleA)(pMemModule->pNtFuncptrsTable->pfnGetModuleHandleA);
    Type_LoadLibraryA pfnLoadLibraryA = (Type_LoadLibraryA)(pMemModule->pNtFuncptrsTable->pfnLoadLibraryA);
    Type_GetProcAddress pfnGetProcAddress = (Type_GetProcAddress)(pMemModule->pNtFuncptrsTable->pfnGetProcAddress);

    PIMAGE_NT_HEADERS pImageNtHeader = MakePointer(PIMAGE_NT_HEADERS, pMemModule->pImageDosHeader, pMemModule->pImageDosHeader->e_lfanew);

    if (pImageNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress == 0
        || pImageNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size == 0)
        return TRUE;

    PIMAGE_IMPORT_DESCRIPTOR pImageImportDescriptor = MakePointer(PIMAGE_IMPORT_DESCRIPTOR, pMemModule->lpBase, 
        pImageNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);

    // 遍歷導入的模塊
    for (; pImageImportDescriptor->Name; pImageImportDescriptor++)
    {
        // Get the dependent module name
        // 依賴的模塊名
        PCHAR pDllName = MakePointer(PCHAR, pMemModule->lpBase, pImageImportDescriptor->Name);

        // Get the dependent module handle
        // 取依賴的模塊句柄,其實直接用LoadLibrary就行了,這裏兩個函數都用了而且釋放模塊時也沒有FreeLibrary,會造成內存泄漏
        HMODULE hMod = pfnGetModuleHandleA(pDllName);

        // Load the dependent module
        if (NULL == hMod) hMod = pfnLoadLibraryA(pDllName);

        // Failed
        if (NULL == hMod)
        {
            pMemModule->dwErrorCode = MMEC_IMPORT_MODULE_FAILED;
            return FALSE;
        }
        // Original thunk
        PIMAGE_THUNK_DATA pOriginalThunk = NULL;
        if (pImageImportDescriptor->OriginalFirstThunk)
            pOriginalThunk = MakePointer(PIMAGE_THUNK_DATA, pMemModule->lpBase, pImageImportDescriptor->OriginalFirstThunk);
        else
            pOriginalThunk = MakePointer(PIMAGE_THUNK_DATA, pMemModule->lpBase, pImageImportDescriptor->FirstThunk);

        // IAT thunk
        PIMAGE_THUNK_DATA pIATThunk = MakePointer(PIMAGE_THUNK_DATA, pMemModule->lpBase, 
            pImageImportDescriptor->FirstThunk);

        // 遍歷導入的函數
        for (; pOriginalThunk->u1.AddressOfData; pOriginalThunk++, pIATThunk++)
        {
            // 取函數地址
            FARPROC lpFunction = NULL;
            if (IMAGE_SNAP_BY_ORDINAL(pOriginalThunk->u1.Ordinal))
            {
                lpFunction = pfnGetProcAddress(hMod, (LPCSTR)IMAGE_ORDINAL(pOriginalThunk->u1.Ordinal));
            }
            else
            {
                PIMAGE_IMPORT_BY_NAME pImageImportByName = MakePointer(
                    PIMAGE_IMPORT_BY_NAME, pMemModule->lpBase, pOriginalThunk->u1.AddressOfData);

                lpFunction = pfnGetProcAddress(hMod, (LPCSTR)&(pImageImportByName->Name));
            }

            // Write into IAT
            // 寫到IAT
#ifdef _WIN64
            pIATThunk->u1.Function = (ULONGLONG)lpFunction;
#else
            pIATThunk->u1.Function = (DWORD)lpFunction;
#endif
        }
    }

    return TRUE;
}

設置各節內存保護

沒有這一步也無所謂,爲了安全和嚴謹就加上這一步吧

/// <summary>
/// Sets the memory protected stats of all the sections.
/// </summary>
/// <param name="pMemModule">The <see cref="MemModule" /> instance.</param>
/// <returns>True if successful.</returns>
BOOL SetMemProtectStatus(PMEM_MODULE pMemModule)
{
    if (NULL == pMemModule || NULL == pMemModule->pNtFuncptrsTable)
        return FALSE;

    // 索引含義:可執行、可讀、可寫,根據這3個屬性決定內存保護
    int ProtectionMatrix[2][2][2] = {
        {
            // not executable
            { PAGE_NOACCESS, PAGE_WRITECOPY },
            { PAGE_READONLY, PAGE_READWRITE },
        },
        {
            // executable
            { PAGE_EXECUTE, PAGE_EXECUTE_WRITECOPY },
            { PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE },
        },
    };

    Type_VirtualProtect pfnVirtualProtect = (Type_VirtualProtect)(pMemModule->pNtFuncptrsTable->pfnVirtualProtect);
    Type_VirtualFree pfnVirtualFree = (Type_VirtualFree)(pMemModule->pNtFuncptrsTable->pfnVirtualFree);

    PIMAGE_DOS_HEADER pImageDosHeader = (PIMAGE_DOS_HEADER)(pMemModule->lpBase);

    ULONGLONG ulBaseHigh = 0;
#ifdef _WIN64
    ulBaseHigh = (pMemModule->iBase & 0xffffffff00000000);
#endif

    PIMAGE_NT_HEADERS pImageNtHeader = MakePointer(
        PIMAGE_NT_HEADERS, pImageDosHeader, pImageDosHeader->e_lfanew);

    int nNumberOfSections = pImageNtHeader->FileHeader.NumberOfSections;
    PIMAGE_SECTION_HEADER pImageSectionHeader = MakePointer(
        PIMAGE_SECTION_HEADER, pImageNtHeader, sizeof(IMAGE_NT_HEADERS));

    // 遍歷各節
    for (int idxSection = 0; idxSection < nNumberOfSections; idxSection++)
    {
        DWORD protectFlag = 0;
        DWORD oldProtect = 0;
        BOOL isExecutable = FALSE;
        BOOL isReadable = FALSE;
        BOOL isWritable = FALSE;

        BOOL isNotCache = FALSE;
        ULONGLONG dwSectionBase = (pImageSectionHeader[idxSection].Misc.PhysicalAddress | ulBaseHigh);
        DWORD dwSecionSize = pImageSectionHeader[idxSection].SizeOfRawData;
        if (0 == dwSecionSize) continue;

        // This section is in this page
        // 接下來根據節屬性設置頁面內存保護
        DWORD dwSectionCharacteristics = pImageSectionHeader[idxSection].Characteristics;

        // Discardable
        if (dwSectionCharacteristics & IMAGE_SCN_MEM_DISCARDABLE)
        {
            pfnVirtualFree((LPVOID)dwSectionBase, dwSecionSize, MEM_DECOMMIT);
            continue;
        }

        // Executable
        if (dwSectionCharacteristics & IMAGE_SCN_MEM_EXECUTE)
            isExecutable = TRUE;

        // Readable
        if (dwSectionCharacteristics & IMAGE_SCN_MEM_READ)
            isReadable = TRUE;

        // Writable
        if (dwSectionCharacteristics & IMAGE_SCN_MEM_WRITE)
            isWritable = TRUE;

        if (dwSectionCharacteristics & IMAGE_SCN_MEM_NOT_CACHED)
            isNotCache = TRUE;

        protectFlag = ProtectionMatrix[isExecutable][isReadable][isWritable];
        if (isNotCache) protectFlag |= PAGE_NOCACHE;
        // 設置內存保護
        if (!pfnVirtualProtect((LPVOID)dwSectionBase, dwSecionSize, protectFlag, &oldProtect))
        {
            pMemModule->dwErrorCode = MMEC_PROTECT_SECTION_FAILED;
            return FALSE;
        }
    }

    return TRUE;
}

調用TLS回調和模塊入口點

這一步也是可選的,不過如果不做有些全局變量可能未初始化。做了的話有可能失敗,因爲操作系統不認我們手動加載的模塊,調用某些函數有可能失敗…

/// <summary>
/// Executes the TLS callback function.
/// </summary>
/// <param name="pMemModule">The <see cref="MemModule" /> instance.</param>
/// <returns>True if successful.</returns>
BOOL ExecuteTLSCallback(PMEM_MODULE pMemModule)
{
    if (NULL == pMemModule || NULL == pMemModule->pImageDosHeader)
        return FALSE;

    PIMAGE_NT_HEADERS pImageNtHeader = MakePointer(
        PIMAGE_NT_HEADERS,
        pMemModule->pImageDosHeader,
        pMemModule->pImageDosHeader->e_lfanew);

    IMAGE_DATA_DIRECTORY imageDirectoryEntryTls = pImageNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS];
    if (imageDirectoryEntryTls.VirtualAddress == 0) return TRUE;

    PIMAGE_TLS_DIRECTORY tls = (PIMAGE_TLS_DIRECTORY)(pMemModule->iBase + imageDirectoryEntryTls.VirtualAddress);
    PIMAGE_TLS_CALLBACK* callback = (PIMAGE_TLS_CALLBACK *)tls->AddressOfCallBacks;
    if (callback)
    {
        while (*callback)
        {
            (*callback)((LPVOID)pMemModule->hModule, DLL_PROCESS_ATTACH, NULL);
            callback++;
        }
    }
    return TRUE;
}

/// <summary>
/// Calls the module entry.
/// </summary>
/// <param name="pMemModule">The <see cref="MemModule" /> instance.</param>
/// <param name="dwReason">The reason of the calling.</param>
/// <returns>True if successful.</returns>
BOOL CallModuleEntry(PMEM_MODULE pMemModule, DWORD dwReason)
{
    if (NULL == pMemModule || NULL == pMemModule->pImageDosHeader)
        return FALSE;

    PIMAGE_NT_HEADERS pImageNtHeader = MakePointer(
        PIMAGE_NT_HEADERS,
        pMemModule->pImageDosHeader, 
        pMemModule->pImageDosHeader->e_lfanew);

    Type_DllMain pfnModuleEntry = NULL;

    pfnModuleEntry = MakePointer(
        Type_DllMain, 
        pMemModule->lpBase, 
        pImageNtHeader->OptionalHeader.AddressOfEntryPoint);

    if (NULL == pfnModuleEntry)
    {
        pMemModule->dwErrorCode = MMEC_INVALID_ENTRY_POINT;
        return FALSE;
    }

    return pfnModuleEntry(pMemModule->hModule, dwReason, NULL);
}

總流程

這個函數就是把之前分析過的函數調用一遍實現加載PE文件。有些不重要而且看名字就知道幹什麼的函數就不分析了

BOOL __stdcall LoadMemModule(PMEM_MODULE pMemModule, LPVOID lpPeModuleBuffer, BOOL bCallEntry)
{
    if (NULL == pMemModule || NULL == pMemModule->pNtFuncptrsTable || NULL == lpPeModuleBuffer)
        return FALSE;

    pMemModule->dwErrorCode = ERROR_SUCCESS;

    // Verify file format
    if (FALSE == IsValidPEFormat(pMemModule, lpPeModuleBuffer))
    {
        return FALSE;
    }

    // Map PE header and section table into memory
    if (FALSE == MapMemModuleSections(pMemModule, lpPeModuleBuffer))
        return FALSE;

    // Relocate the module base
    if (FALSE == RelocateModuleBase(pMemModule))
    {
        UnmapMemModule(pMemModule);
        return FALSE;
    }

    // Resolve the import table
    if (FALSE == ResolveImportTable(pMemModule))
    {
        UnmapMemModule(pMemModule);
        return FALSE;
    }

    pMemModule->dwCrc = mml_getcrc32(
        0, pMemModule->lpBase, pMemModule->dwSizeOfImage);

    // Correct the protect flag for all section pages
    if (FALSE == SetMemProtectStatus(pMemModule))
    {
        UnmapMemModule(pMemModule);
        return FALSE;
    }

    if (FALSE == ExecuteTLSCallback(pMemModule))
        return FALSE;

    if (bCallEntry)
    {
        if (FALSE == CallModuleEntry(pMemModule, DLL_PROCESS_ATTACH))
        {
            // failed to call entry point,
            // clean resource, return false
            UnmapMemModule(pMemModule);
            return FALSE;
        }
    }

    return TRUE;
}

應用

  1. 重載某模塊,繞過各種hook
    重載模塊的話會有其他問題,比如全局變量在內存中有兩份副本。其實也很好解決,重定位時定位到原模塊就行了。不過用來繞hook未免小題大做了,針對性地用匯編繞過部分hook就行了
  2. 隱藏注入模塊
    你手動加載的模塊,操作系統根本不知道它的存在,模塊列表裏沒有,也不會引起任何回調
  3. 獲取模塊的原始內容,比如內核中的SSDT
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章