21.1 Python 使用PEfile分析PE檔案

lyshark發表於2023-10-19

PeFile模組是Python中一個強大的行動式第三方PE格式分析工具,用於解析和處理Windows可執行檔案。該模組提供了一系列的API介面,使得使用者可以透過Python指令碼來讀取和分析PE檔案的結構,包括檔案頭、節表、匯入表、匯出表、資源表、重定位表等等。此外,PEfile模組還可以幫助使用者進行一些惡意程式碼分析,比如提取樣本中的字串、獲取函式列表、重構匯入表、反混淆等等。PEfile模組是Python中處理PE檔案的重要工具之一,廣泛應用於二進位制分析、安全研究和軟體逆向工程等領域。

由於該模組為第三方模組,在使用之前讀者需要在命令列下執行pip install pefile命令安裝第三方庫,當安裝成功後即可正常使用,如下所示則是該模組的基本使用方法,讀者可自行學習理解。

21.1.1 開啟並載入PE檔案

如下這段程式碼封裝並實現了OpenPeFile函式,可用於開啟一個PE檔案,在其內部首先判斷了可執行檔案是否被壓縮如果被壓縮則會透過zipfile模組將壓縮包讀入記憶體並呼叫C2BIP3函式將資料集轉換為2位元組,接著再執行pefile.PE()函式,該函式可用於將可執行檔案載入,至此讀者可在主函式內透過pe.dump_dict()的方式輸出該PE檔案的所有引數,由於輸出的是字典,讀者可以使用字典與列表的方式靈活的提取出該程式的所有引數資訊。

import sys
import zipfile
import pefile

# 如果是Python3則轉換為2位元組
def C2BIP3(string):
    if sys.version_info[0] > 2:
        return bytes([ord(x) for x in string])
    else:
        return string

# 開啟檔案
def OpenPeFile(filename):

    # 判斷是否是ZIP壓縮包
    if filename.lower().endswith('.zip'):
        try:
            oZipfile = zipfile.ZipFile(filename, 'r')
            file = oZipfile.open(oZipfile.infolist()[0], 'r', C2BIP3('infected'))
        except Exception:
            print(sys.exc_info()[1])
            sys.exit()
        oPE = pefile.PE(data=file.read())
        file.close()
        oZipfile.close()

    # 如果是空則
    elif filename == '':
        oPE = False
        return oPE
    # 否則直接開啟檔案
    else:
        oPE = pefile.PE(filename)
    return oPE

if __name__ == "__main__":
    pe = OpenPeFile("d://lyshark.exe")
    print(pe.FILE_HEADER.dump())
    print(pe.dump_dict())

21.1.2 解析PE頭部資料

如下程式碼實現瞭解析PE結構中頭部基本資料,在GetHeader函式內,我們首先透過pe.FILE_HEADER.Machine成員判斷當前讀入的檔案的位數資訊,透過pe.FILE_HEADER.Characteristics可判斷PE檔案的型別,通常為EXE可執行檔案或DLL動態連結庫檔案,透過AddressOfEntryPoint加上ImageBase則可獲取到程式的實際裝載地址,壓縮資料的計算可透過hashlib模組對PE檔案位元組資料進行計算摘要獲取,最後是附加資料,透過get_overlay_data_start_offset則可獲取到,並依次迴圈即可輸出所有附加資料。

import hashlib
import pefile

# 計算得到資料長度,自動使用推薦大小
def NumberOfBytesHumanRepresentation(value):
    if value <= 1024:
        return '%s bytes' % value
    elif value < 1024 * 1024:
        return '%.1f KB' % (float(value) / 1024.0)
    elif value < 1024 * 1024 * 1024:
        return '%.1f MB' % (float(value) / 1024.0 / 1024.0)
    else:
        return '%.1f GB' % (float(value) / 1024.0 / 1024.0 / 1024.0)

# 獲取PE頭部基本資訊
def GetHeader(pe):
    raw = pe.write()

    # 掃描基本資訊
    print("-" * 50)
    print("程式基本資訊")
    print("-" * 50)
    if (hex(pe.FILE_HEADER.Machine) == "0x14c"):
        print("程式位數: {}".format("x86"))
    if (hex(pe.FILE_HEADER.Machine) == "0x8664"):
        print("程式位數: {}".format("x64"))

    if (hex(pe.FILE_HEADER.Characteristics) == "0x102"):
        print("程式型別: Executable")
    elif (hex(pe.FILE_HEADER.Characteristics) == "0x2102"):
        print("程式型別: Dynamic link library")

    if pe.OPTIONAL_HEADER.AddressOfEntryPoint:
        oep = pe.OPTIONAL_HEADER.AddressOfEntryPoint + pe.OPTIONAL_HEADER.ImageBase
        print("實際入口: {}".format(hex(oep)))

    print("映像基址: {}".format(hex(pe.OPTIONAL_HEADER.ImageBase)))
    print("虛擬入口: {}".format(hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint)))
    print("映像大小: {}".format(hex(pe.OPTIONAL_HEADER.SizeOfImage)))
    print("區段對齊: {}".format(hex(pe.OPTIONAL_HEADER.SectionAlignment)))
    print("檔案對齊: {}".format(hex(pe.OPTIONAL_HEADER.FileAlignment)))
    print("區塊數量: {}".format(int(pe.FILE_HEADER.NumberOfSections + 1)))
    print('熵值比例: %f (Min=0.0, Max=8.0)' % pe.sections[0].entropy_H(raw))

    # 計算壓縮資料
    print("-" * 50)
    print("計算壓縮資料")
    print("-" * 50)
    print('MD5     : %s' % hashlib.md5(raw).hexdigest())
    print('SHA-1   : %s' % hashlib.sha1(raw).hexdigest())
    print('SHA-256 : %s' % hashlib.sha256(raw).hexdigest())
    print('SHA-512 : %s' % hashlib.sha512(raw).hexdigest())

    # 掃描檔案末尾是否存在附加資料
    print("-" * 50)
    print("掃描附加資料")
    print("-" * 50)
    overlayOffset = pe.get_overlay_data_start_offset()
    if overlayOffset != None:
        print("起始檔案位置: 0x%08x"%overlayOffset)
        overlaySize = len(raw[overlayOffset:])
        print("長度: 0x%08x %s %.2f%%"%(overlaySize, NumberOfBytesHumanRepresentation(overlaySize), float(overlaySize) / float(len(raw)) * 100.0))
        print("MD5: %s" %hashlib.md5(raw[overlayOffset:]).hexdigest())
        print("SHA-256: %s" %hashlib.sha256(raw[overlayOffset:]).hexdigest())

if __name__ == "__main__":
    pe = pefile.PE("d://lyshark.exe")
    GetHeader(pe)

21.1.3 解析節表資料

運用PEFile模組解析節表也很容易,如下程式碼中分別實現了兩個功能函式,函式ScanSection()用於輸出當前檔案的所有節表資料,其中透過pe.FILE_HEADER.NumberOfSections得到節表數量,並透過迴圈的方式依次解析pe.sections中的每一個節中元素,函式CheckSection()則可用於計算PE檔案節大小以及節MD5值,完整程式碼如下所示;

import hashlib
import pefile

# 計算得到資料長度,自動使用推薦大小
def NumberOfBytesHumanRepresentation(value):
    if value <= 1024:
        return '%s bytes' % value
    elif value < 1024 * 1024:
        return '%.1f KB' % (float(value) / 1024.0)
    elif value < 1024 * 1024 * 1024:
        return '%.1f MB' % (float(value) / 1024.0 / 1024.0)
    else:
        return '%.1f GB' % (float(value) / 1024.0 / 1024.0 / 1024.0)

# 輸出所有的節
def ScanSection(pe):
    print("-" * 100)
    print("{:10s}{:10s}{:10s}{:10s}{:10s}{:10s}{:10s}{:10s}".
          format("序號","節區名稱","虛擬偏移","虛擬大小","實際偏移","實際大小","節區屬性","熵值"))
    print("-" * 100)
    section_count = int(pe.FILE_HEADER.NumberOfSections + 1)

    for count,item in zip(range(1,section_count),pe.sections):
        print("%d\t\t\t%-10s\t0x%.8X\t0x%.8X\t0x%.8X\t0x%.8X\t0x%.8X\t%f"
              %(count,(item.Name).decode("utf-8"),item.VirtualAddress,item.Misc_VirtualSize,item.PointerToRawData,item.SizeOfRawData,item.Characteristics,item.get_entropy()))
    print("-" * 100)

# 計算所有節的MD5
def CheckSection(pe):
    print("-" * 100)
    print("序號\t\t節名稱\t\t檔案偏移\t\t大小\t\tMD5\t\t\t\t\t\t\t\t\t\t節大小")
    print("-" * 100)

    # 讀取PE檔案到記憶體
    image_data = pe.get_memory_mapped_image()

    section_count = int(pe.FILE_HEADER.NumberOfSections + 1)
    for count,item in zip(range(1,section_count),pe.sections):

        section_data = image_data[item.PointerToRawData: item.PointerToRawData + item.SizeOfRawData - 1]
        data_size = NumberOfBytesHumanRepresentation(len(section_data))
        hash_value = hashlib.md5(section_data).hexdigest()
        print("{}\t{:10s}\t{:10X}\t{:10X}\t{:30s}\t{}".format(count,(item.Name).decode("utf-8"),item.PointerToRawData,item.SizeOfRawData,hash_value,data_size))
    print("-" * 100)

if __name__ == "__main__":
    pe = pefile.PE("d://lyshark.exe")
    ScanSection(pe)
    CheckSection(pe)

21.1.4 節區RVA與FOA互轉

此處計算節偏移地址,相信讀者能理解,在之前的文章中我們詳細的介紹了PE檔案如何進行RVAFOA以及VA之間的轉換的,如果是在平時的惡意程式碼分析中需要快速實現轉換那麼使用Python將是一個不錯的選擇,如下程式碼中RVAToFOA可將一個RVA相對地址轉換為FOA檔案偏移,FOAToRVA則可實現將一個FOA檔案偏移轉換為RVA先對地址,當然PeFile模組內也提供了get_rva_from_offset實現從FOA轉RVA,get_offset_from_rva則是從RVA到FOA,讀者可自行選擇不同的轉換方式。

import pefile

# 將RVA轉換為FOA的函式
def RVAToFOA(pe,rva):
    for item in pe.sections:
        Section_Start = item.VirtualAddress
        Section_Ends = item.VirtualAddress + item.SizeOfRawData
        if rva >= Section_Start and rva < Section_Ends:
            return rva - item.VirtualAddress + item.PointerToRawData
    return -1

# 將FOA檔案偏移轉換為RVA相對地址
def FOAToRVA(pe,foa):
    ImageBase = pe.OPTIONAL_HEADER.ImageBase
    NumberOfSectionsCount = pe.FILE_HEADER.NumberOfSections

    for index in range(0,NumberOfSectionsCount):
        PointerRawStart = pe.sections[index].PointerToRawData
        PointerRawEnds = pe.sections[index].PointerToRawData + pe.sections[index].SizeOfRawData

        if foa >= PointerRawStart and foa <= PointerRawEnds:
            rva = pe.sections[index].VirtualAddress + (foa - pe.sections[index].PointerToRawData)
            return rva
    return -1

# 內部功能實現FOA->RVA互轉
def inside(pe):
    # 從FOA獲取RVA 傳入十進位制
    rva = pe.get_rva_from_offset(3952)
    print("對應記憶體RVA: {}".format(hex(rva)))

    # 從RVA獲取FOA 傳入十進位制
    foa = pe.get_offset_from_rva(rva)
    print("對應檔案FOA: {}".format(foa))

if __name__ == "__main__":
    pe = pefile.PE("d://lyshark.exe")
    ref = RVAToFOA(pe,4128)
    print("RVA轉FOA => 輸出十進位制: {}".format(ref))

    ref = FOAToRVA(pe,1056)
    print("FOA轉RVA => 輸出十進位制: {}".format(ref))

21.1.5 解析資料為Hex格式

如下程式碼片段實現了對PE檔案的各種十六進位制操作功能,封裝cDump()類,該類內由多個類函式可以使用,其中HexDump()可用於將讀入的PE檔案以16進位制方式輸出,HexAsciiDump()則可用於輸出十六進位制以及所對應的ASCII格式,GetSectionHex()用於找到PE檔案的.text節,並將此節內的資料讀入到記憶體中,這段程式碼可以很好的實現對PE檔案的十六進位制輸出與解析,讀者可在實際開發中使用。

import pefile
from io import StringIO
import sys
import re

dumplinelength = 16

def CIC(expression):
    if callable(expression):
        return expression()
    else:
        return expression

def IFF(expression, valueTrue, valueFalse):
    if expression:
        return CIC(valueTrue)
    else:
        return CIC(valueFalse)

class cDump():
    def __init__(self, data, prefix='', offset=0, dumplinelength=16):
        self.data = data
        self.prefix = prefix
        self.offset = offset
        self.dumplinelength = dumplinelength

    # 輸出指定位置的十六進位制格式
    def HexDump(self):
        oDumpStream = self.cDumpStream(self.prefix)
        hexDump = ''
        for i, b in enumerate(self.data):
            if i % self.dumplinelength == 0 and hexDump != '':
                oDumpStream.Addline(hexDump)
                hexDump = ''
            hexDump += IFF(hexDump == '', '', ' ') + '%02X' % self.C2IIP2(b)
        oDumpStream.Addline(hexDump)
        return oDumpStream.Content()

    def CombineHexAscii(self, hexDump, asciiDump):
        if hexDump == '':
            return ''
        countSpaces = 3 * (self.dumplinelength - len(asciiDump))
        if len(asciiDump) <= self.dumplinelength / 2:
            countSpaces += 1
        return hexDump + '  ' + (' ' * countSpaces) + asciiDump

    # 輸出指定位置的十六進位制格式以及ASCII字串
    def HexAsciiDump(self):
        oDumpStream = self.cDumpStream(self.prefix)
        hexDump = ''
        asciiDump = ''
        for i, b in enumerate(self.data):
            b = self.C2IIP2(b)
            if i % self.dumplinelength == 0:
                if hexDump != '':
                    oDumpStream.Addline(self.CombineHexAscii(hexDump, asciiDump))
                hexDump = '%08X:' % (i + self.offset)
                asciiDump = ''
            if i % self.dumplinelength == self.dumplinelength / 2:
                hexDump += ' '
            hexDump += ' %02X' % b
            asciiDump += IFF(b >= 32 and b <= 128, chr(b), '.')
        oDumpStream.Addline(self.CombineHexAscii(hexDump, asciiDump))
        return oDumpStream.Content()

    class cDumpStream():
        def __init__(self, prefix=''):
            self.oStringIO = StringIO()
            self.prefix = prefix

        def Addline(self, line):
            if line != '':
                self.oStringIO.write(self.prefix + line + '\n')

        def Content(self):
            return self.oStringIO.getvalue()

    @staticmethod
    def C2IIP2(data):
        if sys.version_info[0] > 2:
            return data
        else:
            return ord(data)

# 只輸出十六進位制資料
def HexDump(data):
    return cDump(data, dumplinelength=dumplinelength).HexDump()

# 輸出十六進位制與ASCII字串
def HexAsciiDump(data):
    return cDump(data, dumplinelength=dumplinelength).HexAsciiDump()

# 找到指定節並讀取hex資料
def GetSectionHex(pe):
    ImageBase = pe.OPTIONAL_HEADER.ImageBase
    for item in pe.sections:
        # 判斷是否是.text節
        if str(item.Name.decode('UTF-8').strip(b'\x00'.decode())) == ".text":
            # print("虛擬地址: 0x%.8X 虛擬大小: 0x%.8X" %(item.VirtualAddress,item.Misc_VirtualSize))
            VirtualAddress = item.VirtualAddress
            VirtualSize = item.Misc_VirtualSize
            ActualOffset = item.PointerToRawData

            StartVA = hex(ImageBase + VirtualAddress)
            StopVA = hex(ImageBase + VirtualAddress + VirtualSize)
            print("[+] 程式碼段起始地址: {} 結束: {} 實際偏移:{} 長度: {}".format(StartVA, StopVA, ActualOffset, VirtualSize))

            # 獲取到.text節區間內的資料
            hex_code = pe.write()[ActualOffset: VirtualSize]
            return hex_code
        else:
            print("程式中不存在.text節")
            return 0
    return 0

REGEX_STANDARD = '[\x09\x20-\x7E]'

def ExtractStringsASCII(data):
    regex = REGEX_STANDARD + '{%d,}'
    return re.findall(regex % 4, data)

def ExtractStringsUNICODE(data):
    regex = '((' + REGEX_STANDARD + '\x00){%d,})'
    return [foundunicodestring.replace('\x00', '') for foundunicodestring, dummy in re.findall(regex % 4, data)]

# 將傳入Hex字串以每16字元分割在一個列表內
def ExtractStrings(data):
    return ExtractStringsASCII(data) + ExtractStringsUNICODE(data)

if __name__ == "__main__":
    pe = pefile.PE("d://lyshark.exe")

    # 得到.text節內資料
    ref = GetSectionHex(pe)

    # 轉為十六進位制格式
    dump_hex = HexDump(ref)
    print(dump_hex)

    # 打包為每16字元一個列表
    dump_list = ExtractStrings(dump_hex)

    print(dump_list)

21.1.6 解析資料目錄表

資料目錄表用於記錄可執行檔案的資料目錄項在檔案中的位置和大小。資料目錄表共有16個條目,每個條目都對應著一個資料目錄項,每個資料目錄項都描述了可執行檔案中某一部分的位置和大小。

資料目錄表的解析可以使用pe.OPTIONAL_HEADER.NumberOfRvaAndSizes首先獲取到資料目錄表的個數,接著二透過迴圈個數依次解包OPTIONAL_HEADER.DATA_DIRECTORY裡面的每一個列表,在迴圈列表時依次解包輸出即可。

import pefile

# 將RVA轉換為FOA的函式
def RVAToFOA(pe,rva):
    for item in pe.sections:
        Section_Start = item.VirtualAddress
        Section_Ends = item.VirtualAddress + item.SizeOfRawData
        if rva >= Section_Start and rva < Section_Ends:
            return rva - item.VirtualAddress + item.PointerToRawData
    return -1

# 掃描資料目錄表
def ScanOptional(pe):
    optional_size = pe.OPTIONAL_HEADER.NumberOfRvaAndSizes
    print("資料目錄表個數: {}".format(optional_size))

    print("-" * 100)
    print("編號 \t\t\t 目錄RVA\t\t 目錄FOA\t\t\t 長度\t\t 描述資訊")
    print("-" * 100)

    for index in range(0,optional_size):
        va = int(pe.OPTIONAL_HEADER.DATA_DIRECTORY[index].VirtualAddress)
        print("%03d \t\t 0x%08X\t\t 0x%08X\t\t %08d \t\t"
              %(index,
                pe.OPTIONAL_HEADER.DATA_DIRECTORY[index].VirtualAddress,
                RVAToFOA(pe,va),
                pe.OPTIONAL_HEADER.DATA_DIRECTORY[index].Size
                ),end="")

        if index == 0:
            print("Export symbols")
        if index == 1:
            print("Import symbols")
        if index == 2:
            print("Resources")
        if index == 3:
            print("Exception")
        if index == 4:
            print("Security")
        if index == 5:
            print("Base relocation")
        if index == 6:
            print("Debug")
        if index == 7:
            print("Copyright string")
        if index == 8:
            print("Globalptr")
        if index == 9:
            print("Thread local storage (TLS)")
        if index == 10:
            print("Load configuration")
        if index == 11:
            print("Bound Import")
        if index == 12:
            print("Import Address Table")
        if index == 13:
            print("Delay Import")
        if index == 14:
            print("COM descriptor")
        if index == 15:
            print("NoUse")

if __name__ == "__main__":
    pe = pefile.PE("d://lyshark.exe")
    ScanOptional(pe)

21.1.7 解析匯入匯出表

匯入表和匯出表都是PE檔案中的重要資料結構,分別記錄著一個模組所匯入和匯出的函式和資料,如下所示則是使用PeFile模組實現對匯入表與匯出表的解析工作,對於匯入表ScanImport的解析需要透過pe.DIRECTORY_ENTRY_IMPORT獲取到完整的匯入目錄,並透過迴圈的方式輸出x.imports中的資料即可,而對於匯出表ScanExport則需要在pe.DIRECTORY_ENTRY_EXPORT.symbols匯出符號中解析獲取。

import pefile

# 輸出所有匯入表模組
def ScanImport(pe):
    print("-" * 100)
    try:
        for x in pe.DIRECTORY_ENTRY_IMPORT:
            for y in x.imports:
                print("[*] 模組名稱: %-20s 匯入函式: %-14s" %((x.dll).decode("utf-8"),(y.name).decode("utf-8")))
    except Exception:
        pass
    print("-" * 100)

# 輸出所有匯出表模組
def ScanExport(pe):
    print("-" * 100)
    try:
        for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
            print("[*] 匯出序號: %-5s 模組地址: %-20s 模組名稱: %-15s"
            %(exp.ordinal,hex(pe.OPTIONAL_HEADER.ImageBase + exp.address),(exp.name).decode("utf-8")))
    except:
        pass
    print("-" * 100)

if __name__ == "__main__":
    pe = pefile.PE("d://lyshark.exe")
    ScanImport(pe)
    ScanExport(pe)

本文作者: 王瑞
本文連結: https://www.lyshark.com/post/92a3370c.html
版權宣告: 本部落格所有文章除特別宣告外,均採用 BY-NC-SA 許可協議。轉載請註明出處!

相關文章