====================
== Alert Overload ==
====================
Tales from a SOC analyst

PE Files and How to Create a PowerShell PE File Parser

github powershell

This is taken from my project Invoke-PEAnalysis.

PE File Types

To begin with, Portable Executable (PE) is the name given to executable images developed for the Windows operating system. Most commonly, you will see these as EXE files. These files are made up of various information sections called headers that describe the functionality and behavior of the executable file. These headers also contain the location of various data in the file. By parsing these headers and data, it is possible to extract pertinent information about the executable without ever running it. This is called Static Analysis. Of course, you can’t see everything an executable does through static analysis. However, you can see what it’s importing from the system, what sort of API calls it’s making, what it’s exporting, and other key details.

Aside from PE images, there are also Object files called Common Object File Format (COFF) files that utilize the same structure. Dynamic Link Libraries (DLLs) also use the PE file structure. It is important to remember that the file extension (.exe, .dll, .coff, etc.) does not matter. An extension-less file can still execute if launched correctly. This is due to the file structure.

PE File Structures

The PE file structure fluctuates based on the type of file provided, either 32-bit or 64-bit. The layout stays largely the same, but the offset and size of certain data changes. Outside of this fluctuation, there are also some differences between images and object files. Many headers are specific to the image files, PE32, PE32+, and DLL. File headers, for example, are mostly unique to image files. Both object and image files contain file headers. However, object files only contain a COFF header within the file header section. Image files, on the other hand, contain optional headers, signature, and the MS-DOS stub bundled within the file header.

To differentiate between headers and sections for image files and object files, each header or section will be marked with the applicable file.

The following data is from learn.microsoft.com

* A side note on offsets for those unfamiliar. A File Offset is the number of bytes from the beginning of a file. A relative address is the address of an item after it has been loaded into memory, with the base of the file subtracted from it. This is essentially the offset from the address the file was loaded at. In PE files, these are usually differentiated as a Virtual Address or a Relative Virtual Address. This answer on Stackoverflow is a good explanation of VAs and RVAs, as well as how offsets work in PE files. In this documentation, file offsets are specifically designated. VAs and RVAs are also labeled.

MS-DOS Stub (Image)

The MS-DOS stub is an MS-DOS application that displays the following text when a PE file is executed via MS-DOS.

This program cannot be run in DOS mode

Within the MS-DOS Stub is the file offset to the PE signature (Image). This offset is placed at 0x3C.

Signature (Image)

The signature is a 4-byte segment at file offset 0x3C that identifies the file as a PE format image file. This signature is as follows.

P E 0 0

The letters P and E followed by 2 null bytes

COFF File Header (Object and Image)

Immediately after the PE signature, or at the beginning of the file, is the COFF File Header.

Offset Size Field Description
0 2 Machine The number that identifies the type of target machine.
2 2 NumberOfSections The number of sections. This indicates the size of the section table, which immediately follows the headers.
4 4 TimeDateStamp The low 32 bits of the number of seconds since 00:00 January 1, 1970 (C run-time time_t value, aka EPOCH time), which indicates when the file was created.
8 4 PointerToSymbolTable The file offset of the COFF symbol table, or zero if no COFF symbol table is present. This value should be 0 for an image because COFF debugging information is deprecated.
12 4 NumberOfSymbols The number of symbols in the symbol table. This data can be used to locate the string table, which immediately follows the symbol table.
16 2 SizeOfOptionalHeader The size of the optional header, which is required for executable files but not for object files. This value should be 0 for an object file.
18 2 Characteristics The flags that indicate the attributes of the file.

Optional Header (Image)

Every image file has an optional header that provides information to the loader. This header is optional in the sense that some files (object) do not have it. This header is required for image files. The size of this header is variable and the COFF header must be used to determine the size. Some data in this header is variable size dependent on PE32 vs PE32+ files. These differences are denoted by a (PE32/PE32+) differentiator.

The optional header is made up of three major parts.

Offset (PE32/PE32+) Size (PE32/PE32+) Header Part Description
0 28/24 Standard Fields Fields that are defined for all implementations of COFF, including UNIX.
28/24 68/88 Windows-specific fields Additional fields to support specific features of Windows.
96/112 Variable Data directories Address/size pairs for special tables that are found in the image file and are used by the operating system.

Optional Header Standard Fields

Offset Size Field Description
0 2 Magic The size of the initialized data section, or the sum of all such sections if there are multiple.
2 1 MajorLinkerVersion The linker major version number.
3 1 MinorLinkerVersion The liker minor version number.
4 4 SizeOfCode The size of the code (text) section, or the sum of all code sections if there are multiple sections.
8 4 SizeOfInitializedData The size of the intialized data section, or the sum of all such sections if there are multiple.
12 4 SizeOfUnitializedData The size of the uninitialized data section (BSS), or the sum of all such sections if there are multiple.
16 4 AddressOfEntryPoint The address of the entry point relative to the image base when the executable file is loaded into memory. For program images, this is the starting address.
20 4 BaseOfCode The address that is relative to the image base of the beginning-of-code section when it is loaded into memory.

PE32 files contain the following additional field, which is not present in PE32+ files.

Offset Size Field Description
24 4 BaseOfData The address that is relative to the image base of the beginning-of-data section when it is loaded into memory.

Optional Headers Windows-Specific Fields

Offset (PE32/PE32+) Size (PE32/PE32+) Field Description
28/24 4/8 ImageBase The preferred address of the first byte of the image when loaded into memory. This must be a multiple of 64 K. The default for DLLs is 0x10000000. The default for Windows CE EXEs is 0x00010000. The default for Windows NT, Windows 2000, Windows XP, Windows 95, Windows 98, and Windows ME is 0x00400000.
32/32 4 SectionAlignment The alignment, in bytes, of sections when they are loaded into memory. It must be greater than or equal to FileAlignment.
36/36 4 FileAlignment The alignment factor, in bytes, that is used to align the raw data of sections in the image file. The value should be a power of 2 between 512 and 64 K. The default is 512.
40/40 2 MajorOperatingSystemVersion The major version number of the required operating system.
42/42 2 MinoroperatingSystemVersion The minor version number of the required operating system.
44/44 2 MajorImageVersion The major version of the image.
46/46 2 MinorImageVersion The minor version of the image.
48/48 2 MajorSubsystemVersion The major version of the subsystem.
50/50 2 MinorSubsystemVersion The minor version of the subsystem.
52/52 4 Win32VersionValue Reserved, must be 0.
56/56 4 SizeOfImage The size, in bytes, of the image, including all the headers, as the image is loaded in memory. It must be a multiple of SectionAlignment.
60/60 4 SizeOfHeaders The combined size of an MS-DOS stub, PE header, and section headers rounded up to a multiple of FileAlignment.
64/64 4 Checksum The image file checksum. The algorithm for computing the checksum is incorporated into IMAGHELP.DLL.
68/68 2 Subsystem The subsystem that is required to run this image.
70/70 2 DllCharacteristics See learn.microsoft.com
72/72 4/8 SizeOfStackReserve The size of the stack to reserve. Only SizeOfStackCommit is committed. The rest is available one page at a time until the reserve is reached.
76/80 4/8 SizeOfStackCommit The size of the stack to commit.
80/88 4/8 SizeOfHeapReserve The size of the local heap space to reserve. Only SizeOfHeapCommit is committed. The rest is available one page at a time until the reserve is reached.
84/96 4/8 SizeOfHeapCommit The size of the local heap space to commit.
88/104 4 LoaderFlags Reserved, must be 0.
92/108 4 NumberOfRvaAndSizes The numbe of the data-directory entries in the remainder of the optional header. Each describes a location and size.

Optional Headers Data Directories

Each data directory gives the address and size of a table or string that Windows uses. These entries are loaded into memory so that the system can use them at run time. A data directory is an 8-byte field that has the following definition:

typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;
    DWORD   Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

C#

The first field is the RVA of the table. The RVA is the address relative to the base address of the image when it is loaded. The second file is the size, in bytes, of the table. The data directories are the last part of the optional headers.

The number of data directories is not fixed. The number of data directories is defined in the NumberOfRvaAndSizes field of the optional header.

Offset (PE32/PE32+) Size (PE32/PE32+) Field Description
96/112 8 Export Table The export table address and size.
104/120 8 Import Table The import table address and size.
112 128 8 Resource Table
120/136 8 Exception Table The exception table address and size.
128/144 8 Certificate Table The attribute certificate table address and size.
136/152 8 Base Relocation Table The base relocation table address and size.
144/160 8 Debug The debug data starting address and size.
152/168 8 Architecture Reserved, must be 0.
160/172 8 Global Ptr The RVA of the value to be stored in the global pointer register. The size member of this structure must be 0.
168/184 8 TLS Table The Thread Local Storage (TLS) table address and size.
176/192 8 Load Config Table The load configuration table address and size.
184/200 8 Bound Import The bound import table address and size.
192/208 8 IAT The Import Address Table (IAT) address and size.
200/216 8 Delay Import Descriptor The delay import descriptor address and size.
208/224 8 CLR Runtime Header The CLR runtime header address and size.
216/232 8 Reserved, must be set to 0.

Section Table (Image)

Each row of the section table is a section header. This table immediately follows the optional header. This positioning is required because the file header does not contain a direct pointer to the section table. The location of the section table is determined by calculating the location of the first byte after the headers.

The number of entries in the section table is determined by the NumberOfSections field in the file header. Entries in the section table are numbered starting from one. The code and data memory section entries are in the order chosen by the linker.

Each section header (section table entry) is 40 bytes.

Offset Size Field Description
0 8 Name An 8-byte, null-padded UTF-8 encoded string. If the string is exactly 8 characters long, there is no terminating null. For longer names, this field contains a slash (/) that is followed by an ASCII representation of a decimal number that is an offset into the string table. Executable images do not use a string table and do not support section names longer than 8 characters. Long names in object files are truncated if they are emitted to an executable file.
8 4 VirtualSize The total size of the section when loaded into memory. If this value is greater than SizeOfRawData, the section is zero-padded. This field is valid only for executable images and should be set to zero for object files.
12 4 VirtualAddress For executable images, the address of the first byte of the section relative to the image base when the section is loaded into memory. For object files, this field is the address of the first byte before relocation is applied; for simplicity, compilers should set this to zero. Otherwise, it is an arbitrary value that is subtracted from offsets during relocation.
16 4 SizeOfRawData The size of the section (for object files) or the size of the initialized data on disk (for image files). For executable images, this must be a multiple of FileAlignment from the optional header. If this is less than VirtualSize, the remainder of the section is zero-filled. Because the SizeOfRawData field is rounded but the VirtualSize field is not, it is possible for SizeOfRawData to be greater than VirtualSize as well. When a section contains only uninitialized data, this field should be zero.
20 4 PointerToRawData The file pointer to the first page of the section within the COFF file. For executable images, this must be a multiple of FileAlignment from the optional header. For object files, the value should be aligned on a 4-byte boundary for best performance. When a section contains only uninitialized data, this field should be zero.
24 4 PointerToRelocations The file pointer to the beginning of relocation entries for the section. This is set to zero for executable images or if there are no relocations.
28 4 PointerToLinenumbers The file pointer to the beginning of line-number entries for the section. This is set to zero if there are no COFF line numbers. This value should be zero for an image because COFF debugging information is deprecated.
32 2 NumberOfRelocations The number of relocation entries for the section. This is set to zero for executable images.
34 2 NumberOfLinenumbers The number of line-number entries for the section. This value should be zero for an image because COFF debugging information is deprecated.
36 4 Characteristics The flags that describe the characteristics of the section. For more information, see Section Flags.

.idata Section - Import Directory and Import Lookup Tables (Image)

The Import Table field in the optional headers contains the address of the Import Directory Table (IDT). This table describes the idata section. Each DLL imported by the image has an entry in the IDT. The IDT is terminated by an empty entry (filled with null values).

Import Directory Table

Offset Size Field Description
0 4 Import Lookup Table RVA The RVA of the import lookup table. This table contains the name or ordinal for each import.
4 4 Time/Date Stamp The stamp that is set to 0 until the image is bound.
8 4 Forwarder Chain The index of the first forwarder reference.
12 4 Name RVA The address of the ASCII string that contains the name of the DLL import. The address is relative to the image base.
16 4 Import Address Table RVA (Thunk Table) The RVA of the import address table. The contents of this table are identical to the contents of the import lookup table until the image is bound.

Import Lookup Table

An import lookup table is an array of 32-bit numbers for PE32 or an array of 64-bit numbers for PE32+. Each entry uses the bit-field format that is described in the following table. In this format, bit 31 is the most significant bit for PE32 and bit 63 is the most significant bit for PE32+. The collection of these entries describes all imports from a given DLL. The last entry is set to zero (NULL) to indicate the end of the table.

Bits Size Bit Field Description
31/63 1 Ordinal/Name Flag If this bit is set, import by ordinal. Otherwise, import by name. Bit is masked as 0x80000000 for PE32, 0x8000000000000000 for PE32+.
15-0 16 Ordinal Number A 16-bit ordinal number. This field is used only if the Ordinal/Name Flag bit field is 1 (import by ordinal). Bits 30-15 or 62-15 must be 0.
30-0 31 Hint/Name Table RVA A 31-bit RVA of a hint/name table entry. This field is used only if the Ordinal/Name Flag bit field is 0 (import by name). For PE32+ bits 62-31 must be zero.

Hint/Name Table

In most cases, it’s easier to go directly to the name table from the IDT. The lookup table is not necessary for getting the names of the imports as the name RVA field in the IDT contains the RVA to the name table ASCII string value.

Offset Size Field Description
0 2 Hint An index into the export name pointer table.
2 Variable Name ASCII string that contains the name to import. This is the string that must be matched to the public name in the DLL. This string is case sensitive and terminated by a null byte.
* 0 or 1 Pad A trailing zero-pad byte that appears after the trailing null byte if necessary.

The following segment is taken from codeproject.com - Daniel Pistelli and trial and error.

CLR Runtime Header / Meta Data Header (Image)

Officially titled the CLR runtime header, this header is a dynamic header that contains information about the various streams in the image. A stream is a section of the image that contains a specific type of data. Each stream header is made of two DWORDs, offset and size, and an ASCII string aligned to the next 4-byte boundary.

The Meta Data section structure is as follows. The offsets start at the beginning of the Meta Data table. This address can be found in the CLR Runtime Header field of the Optional Header. Due to the many variable length fields, this table must be parsed dynamically.

Offset Size Field Description
0 4 Signature
4 2 MajorVersion
6 2 MinorVersion
8 4 Reserved DWORD Must be 0
12 4 SizeOfVersionString Variable length.
16 SizeOfVersionString Version
16+SizeOfVersionString 2 Flags
18+SizeOfVersionString 2 NumberOfStreams
20+SizeOfVersionString SizeOfStreams StreamHeaders Terminated by null bytes rounded to the next 4-byte boundary.

The stream headers themselves immediately follow the Meta Data table.

Offset Size Field Description
0 4 StreamRvaFromMetaData The RVA of the Stream from the offset of the Meta Data section.
4 4 SizeOfStream The size of the stream.
8 Variable Length StreamName The name of the stream in ASCII. The name is variable size and is terminated by null bytes rounded to the next 4-byte boundary.

This image shows the stream headers, in hex, of a PE32 file.

The most important stream is the #~ stream. This contains the information on the included meta data tables. This stream is broken down as such:

Offset Size Field Description
0 4 Reserved Must be 0.
4 1 MajorVersion
5 1 MinorVersion
6 1 HeapOffsetSizes Size that indexes into the #String, #GUID, and #Blob streams.
7 1 Reserved Must be 1
8 8 ValidMask Bitmask-QWORD that marks which Meta Data tables are present in the image.
16 8 SortedMask Bitmask-QWORD that marks which Meta Data tables are sorted.

Note on Valid and Sorted fields: The hex value of these fields should be converted to binary and counted to identify which tables are present.
E.g., 00000E093DB7BF57 = 0000000000000000000011100000100100111101101101111011111101010111

Immediately following this stream are the actual Meta Data tables themselves. There are many possible tables, each with their own structure. This document will not provide all table structures. However, Daniel Pistelli has done excellent work documenting the tables on Code Project.

The possible tables with their numbering follow.

The tables we are primarily interested in are:

Table Number Table Name
01 TypeRef
06 Method
08 Param
10 MemberRef
20 Event
26 ModuleRef
28 ImplMap
35 AssemblyRef

Following are the table structures for each table in the above list. Not all fields are fully documented here. See Daniel Pistelli’s post for more information. Code Project.

TypeRef

Offset Size Field Description
q 2 ResolutionScope
2 2 Name Offset from #string heap.
4 2 Namespace Offset from #string heap.

Method

Offset Size Field Description
0 4 RVA
4 2 ImplFlags bitmask of type MethodImplAttributes
6 2 Flags bitmask of type MethodAttribute
8 2 Name Offset from String heap.
10 2 Signature Offset from Blob heap.
12 2 ParamList Offset from Param table.

Param

Offset Size Field Description
0 2 Flags
2 4 Sequence
4 2 Name Offset from String heap.

MemberRef

Offset Size Field Description
0 2 Class
2 2 Name Offset from String heap.
4 2 Signature

Event

Offset Size Field Description
0 2 EventFlags
2 2 Name Offset from String heap.
4 2 EventType

ModuleRef

Offset Size Field Description
0 2 Name Offset from String heap.

ImplMap

Offset Size Field Description
0 2 MappingFlags
2 2 MemberForwarded
4 2 ImportName Offset from String heap.
6 2 ImportScope Index into the ModuleRef table.

AssemblyRef

Offset Size Field Description
0 2 MajorVersion
2 4 MinorVersion
4 2 BuildNumber
6 2 RevisionNumber
8 4 Flags
12 2 PublicKeyOrToken
14 2 Name Offset from String heap.
16 2 Culture
18 2 Hashvalue

Parsing PE Files in PowerShell

Using PowerShell to parse PE files is surprisingly easy to do. By utilizing the underlying .Net framework, PowerShell has no problem performing advanced actions that are outside the constraints of default cmdlets and modules.

For this project, files are read as bytes in dynamically and statically assigned segments. These segments correlate to the various sections, headers, and tables in the PE structure. After reading a segment, the resulting byte array must first be reversed. This is because PE header values are little-endian and must be reversed to convert correctly.

The following code opens a file and reads the offset of the COFF header located at 0x3C and uses a file seek action to go to the specified offset. To properly convert the offset to an int, the endianness must be flipped.

# Open File
$File = New-Object System.IO.FileStream($Path, "Open")

# Create COFF header array
$CoffHeader = [System.Byte[]]::CreateInstance([System.Byte], 24)

# Create COFF Offset holding array
$CFFOFFSET = [System.Byte[]]::CreateInstance([System.Byte], 2)

# Read 60 bytes of MS-DOS STUB to find offset of COFF header - 0x3C
$File.Seek(60,0) | Out-Null
$file.Read($CFFOFFSET,0,2) | Out-Null

# Reverse array to convert little-endian value to big-endian for type conversion to int
[Array]::Reverse($CFFOFFSET)

# File seek operation on $CFFOFSSET bytes 
$file.Seek(([System.Convert]::ToInt64([System.Convert]::ToHexString($CFFOFFSET),16) + 4),0) | Out-Null

Similarly, when reading the COFF header, the values of various fields must also be flipped.

# Read COFF header 
$File.Read($CoffHeader, 0, 20) | Out-Null

# Save the Optional Header size
$SizeOfOptionalHeader = $COFFHeader[16..17]

# Reverse endianness
[Array]::Reverse($SizeOfOptionalHeader)

# Get INT value of optional header size
$SizeOfOptionalHeader = [System.Convert]::ToInt64([System.Convert]::ToHexString($SizeOfOptionalHeader),16)

# Get the optional headers based on the size of the optional headers
if($SizeOfOptionalHeader -gt 0){     
    # Build optional header array based on size
    $OptionalHeaders = [System.Byte[]]::CreateInstance([System.Byte], $SizeOfOptionalHeader)
}else{
    Write-Log "Optional Headers could not be found: $SizeOfOptionalHeader" 1
    Write-Log "Cancelling analysis" 1
    # Close file
    $File.Dispose() | out-Null
    $File.Close() | Out-Null
    return 
}

# Read optional header [Sequential X bytes after COFF header]
$File.Read($OptionalHeaders, 0, $SizeOfOptionalHeader) | Out-Null

PowerShell

You will likely notice that many values are being converted to hex at various points in the code. This is largely remnants from testing and double-checking offsets and addresses. However, it also helps when doing the conversions to various types.

Due to the behavior of the System.IO.FileStream object, $File.Read(Byte[],Int32,Int32) reads sequentially. Therefore, it is easy to read headers and tables that immediately follow each other. Because the optional headers immediately follow the COFF headers, the seek method does not need to be called. However, for jumping between locations in the file, $File.Seek(Int64, SeekOrigin) must be used.

For items like section names and data in the sections table, where the data is sequential but not statically sized ($x number of sections), it’s easier to loop over the data by reading the field values that specify the size. In the case of the sections table, this is the NumberOfSections field in the COFF header.

# Get Sections - This should read a 2-byte value, but 99% of PE files do not have 0xF+ sections. 
    $Sections = $COFFHeader[2]

    # Section Tables
    $SectionsTableStrings = @{}
    for($i=0;$i -lt $Sections;$i++){

        # Read the sections table for each $i section - Sections can later be accessed by getting the size of $SectionTableStrings with $SectionTableStrings[$i]
        $SectionsTable = [System.Byte[]]::CreateInstance([System.Byte], 40) 
        $File.Read($SectionsTable, 0, 40) | Out-Null
        $SectionsTableStrings[$i] = @{}
        $SectionsTableStrings[$i]["Name"] = $SectionsTable[0..7]
        $SectionsTableStrings[$i]["VirtualSize"] = $SectionsTable[8..11]
        $SectionsTableStrings[$i]["VirtualAddress"] = $SectionsTable[12..15]
        $SectionsTableStrings[$i]["SizeOfRawData"] = $SectionsTable[16..19]
        $SectionsTableStrings[$i]["PointerToRawData"] = $SectionsTable[20..23]
        $SectionsTableStrings[$i]["PointerToRelocations"] = $SectionsTable[24..27]
        $SectionsTableStrings[$i]["PointerToLinenumbers"] = $SectionsTable[28..31]
        $SectionsTableStrings[$i]["NumberOfRelocations"] = $SectionsTable[32..33]
        $SectionsTableStrings[$i]["NumberOfLinenumbers"] = $SectionsTable[34..35]
        $SectionsTableStrings[$i]["Characteristics"] = $SectionsTable[36..39]

        # Reverse arrays for values that need to be converted
        [Array]::Reverse($SectionsTableStrings[$i]["VirtualSize"])
        [Array]::Reverse($SectionsTableStrings[$i]["VirtualAddress"])
        $SectionsTableStrings[$i]["VirtualAddress"] = [System.Convert]::ToHexString($SectionsTableStrings[$i]["VirtualAddress"])
        [Array]::Reverse($SectionsTableStrings[$i]["PointerToRawData"])
        $SectionsTableStrings[$i]["PointerToRawData"] = [System.Convert]::ToHexString($SectionsTableStrings[$i]["PointerToRawData"])


        # Get section names
        $SectionNames = $null
        foreach($x in $SectionsTableStrings[$i]['Name']){
            $SectionNames += [char]$x
        }
        $SectionsTableStrings[$i]["Name"] = $SectionNames -join ''

        # Detect UPX packing by section name
        if($SectionsTableStrings[$i]["Name"] -match "UPX"){
            Write-Log "UPX PACKING DETECTED: $($SectionsTableStrings[$i]["Name"])" 1
            $Packed = $true
        }

    }

PowerShell

Differentiating between the various PE types can be done by simply checking the Magic number field in the optional header.

# Optional Header section
# Determine PE type from Optional Header Magic number
$OptionalMagic = $OptionalHeaders[0..1]
[array]::Reverse($OptionalMagic)
$OptionalMagic = [System.Convert]::ToHexString($OptionalMagic)
switch ($OptionalMagic) {
    '010B' { $PEType = "PE32";break }
    '020B' { $PEType = "PE32+";break}
    '0107' { $PEType = "ROM";break}
    Default {
        Write-Log "Unable to determine PE type. Optional Header Magic: $OptionalMagic" 1
    }
}
Write-Log "PE Type: $PEType" 1

# Get size of .text section
$TextSize = $OptionalHeaders[4..7]
[Array]::Reverse($TextSize)
$TextSize = [System.Convert]::ToInt64([System.Convert]::ToHexString($TextSize),16)

# Get entry points
$AddressOfEntryPoint = $OptionalHeaders[16..19]
[array]::Reverse($AddressOfEntryPoint)
$AddressOfEntryPoint = [System.Convert]::ToHexString($AddressOfEntryPoint)
$AddressOfEntryPoint = $AddressOfEntryPoint -replace '..(?!$)','$0 '
$BaseOfCode = $OptionalHeaders[20..23]

PowerShell

Depending on $PEType, the parser will grab all values in the remaining optional header fields.

 # Break between PE32 and PE32+ formats
    if($PEType -eq "PE32"){
        Write-Log "Beginning PE32 analysis" 1

        # Base of Data is specific to PE32 files - Not present in PE32+ (64-bit)
        $BaseOfData = $OptionalHeaders[24..27]

        # Get entry point of first byte
        $ImageBase = $OptionalHeaders[28..31]
        [Array]::Reverse($ImageBase)
        $ImageBase = [System.Convert]::ToHexString($ImageBase)
        $ImageBase = $ImageBase -replace '..(?!$)','$0 '

        # Section and File alignment
        $SectionAlignment = $OptionalHeaders[32..25]
        $FileAlignment = $OptionalHeaders[36..39]

        # Size of Image
        $SizeOfImage = $OptionalHeaders[56..59]
        [array]::Reverse($SizeOfImage)
        $SizeOfImage = [System.Convert]::ToInt64([System.Convert]::ToHexString($SizeOfImage),16)

        # Size of Headers
        $SizeOfHeaders = $OptionalHeaders[60..63]
        [array]::Reverse($SizeOfHeaders)
        $SizeOfHeaders = [System.Convert]::ToInt64([System.Convert]::ToHexString($SizeOfHeaders),16)

        # CheckSum
        $CheckSum = $OptionalHeaders[64..67]
        [Array]::Reverse($CheckSum)
        $CheckSum = [System.Convert]::ToHexString($CheckSum)
        $CheckSum = $CheckSum -replace '..(?!$)','$0 '

        # Export Table
        $ExportTable = $OptionalHeaders[96..103]

        # Import Table
        $ImportTable = $OptionalHeaders[104..111]
        [array]::Reverse($ImportTable)

        # Resource Table
        $ResourceTable = $OptionalHeaders[112..119]

        # Exception Table
        $ExceptionTable = $OptionalHeaders[120..127]

        # IAT
        $IAT = $OPtionalHeaders[192..199]
        [array]::Reverse($IAT)
        $IAT = [System.Convert]::ToHexString($IAT)
        $IAT = $IAT -replace '..(?!$)','$0 '

        $CLRRuntimeHeader = $OptionalHeaders[208..215]
        [Array]::Reverse($CLRRuntimeHeader)

    }elseif($PEType -eq "PE32+"){
        Write-Log "Beginning PE32+ Analysis" 1

        # Get entry point of first byte
        $ImageBase = $OptionalHeaders[24..31]
        [Array]::Reverse($ImageBase)
        $ImageBase = [System.Convert]::ToHexString($ImageBase)
        $ImageBase = $ImageBase -replace '..(?!$)','$0 '

        # Section and File alignment
        $SectionAlignment = $OptionalHeaders[32..25]
        $FileAlignment = $OptionalHeaders[36..39]

        # Size of Image
        $SizeOfImage = $OptionalHeaders[56..59]
        [array]::Reverse($SizeOfImage)
        $SizeOfImage = [System.Convert]::ToInt64([System.Convert]::ToHexString($SizeOfImage),16)

        # Size of Headers
        $SizeOfHeaders = $OptionalHeaders[60..63]
        [array]::Reverse($SizeOfHeaders)
        $SizeOfHeaders = [System.Convert]::ToInt64([System.Convert]::ToHexString($SizeOfHeaders),16)

        # CheckSum
        $CheckSum = $OptionalHeaders[64..67]
        [Array]::Reverse($CheckSum)
        $CheckSum = [System.Convert]::ToHexString($CheckSum)
        $CheckSum = $CheckSum -replace '..(?!$)','$0 '

        # Export Table
        $ExportTable = $OptionalHeaders[112..119]

        # Import Table
        $ImportTable = $OptionalHeaders[120..127]
        [array]::Reverse($ImportTable)

        # Resource Table
        $ResourceTable = $OptionalHeaders[128..135]

        # Exception Table
        $ExceptionTable = $OptionalHeaders[136..143]

        # IAT
        $IAT = $OptionalHeaders[208..215]
        [array]::Reverse($IAT)
        $IAT = [System.Convert]::ToHexString($IAT)
        $IAT = $IAT -replace '..(?!$)','$0 '

        $CLRRuntimeHeader = $OptionalHeaders[224..231]
        [Array]::Reverse($CLRRuntimeHeader)

    }

PowerShell

Following the parsing of the optional headers, the IDT is parsed for import DLLs. This utilizes the $SectionTableStrings and $sections variables that were dynamically created based on the value of the NumberOfSections field. Because the sections contain virtual addresses relative to the first byte of the image base when it is loaded into memory, there is some math that needs to be done to calculate the correct offset to seek for each section. The import table may also be transient and must be located before it can be parsed. To get the section that contains the import table, we can take the import table field of the optional header and calculate which section contains the address in the field. We must also perform a check to ensure that the section is large enough to hold the import table. This ends up looking like this SectionVirtualAddress < ImportTableAddress AND SectionVirtualAddress + SectionVirtualSize > ImportTableAddress. This ensures that the import table falls within the beginning and end of a section. It prevents issues that arise in scenarios such as

Section1Start - 0x0000E000
Section1End - 0x0000F000
Section2Start - 0x0001A000
Section2End - 0x0001F000

ImportAddress - 0x0001B000

Without verifying that the import address is within the bounds of Section2, it is possible to mistakenly attempt to read Section1. A mistake I may or may not have made when intially putting this together... 

Plaintext

The actual parsing of the import table is relatively simple once the math is done.

# Get Import Directory Table

# Find what section IDT is in
for($i=0;$i -lt $Sections;$i++){

    # Virtual Address -lt Import Table address && Virtual Address + Virtual Size -gt Import Table Address
    # Section Start <----> Import Table <----> End of Section

    # If the section starts with the import table
    if([System.Convert]::ToInt64($SectionsTableStrings[$i]["VirtualAddress"], 16) -lt [System.Convert]::ToInt64([System.Convert]::ToHexString($ImportTable[4..7]), 16) -And ([System.Convert]::ToInt64($SectionsTableStrings[$i]["VirtualAddress"], 16) + [System.Convert]::ToInt64([System.Convert]::ToHexString($SectionsTableStrings[$i]["VirtualSize"]), 16)) -gt [System.Convert]::ToInt64([System.Convert]::ToHexString($ImportTable[4..7]), 16)){

        # Get the offset of the IDT
        $IDTOffset = [Math]::ABS([System.Convert]::ToInt64([System.Convert]::ToHexString($ImportTable[4..7]), 16) - [System.Convert]::ToInt64($SectionsTableStrings[$i]["VirtualAddress"], 16))
        $IDTSeek = $IDTOffset + [System.Convert]::ToInt64($SectionsTableStrings[$i]["PointerToRawData"], 16)

        # Set the found section for use with IDT imports
        $IDTSection = $i

    # I actually believe that this is a redundant statement left in from testing phases!
    }elseif((([System.Convert]::ToInt64($SectionsTableStrings[$i]["VirtualAddress"], 16))-eq([System.Convert]::ToInt64([System.Convert]::ToHexString($ImportTable[4..7]), 16)))-And ([System.Convert]::ToInt64($SectionsTableStrings[$i]["VirtualAddress"], 16) + [System.Convert]::ToInt64([System.Convert]::ToHexString($SectionsTableStrings[$i]["VirtualSize"]), 16)) -gt [System.Convert]::ToInt64([System.Convert]::ToHexString($ImportTable[4..7]), 16)){


        $IDTOffset = [Math]::ABS([System.Convert]::ToInt64([System.Convert]::ToHexString($ImportTable[4..7]), 16) - [System.Convert]::ToInt64($SectionsTableStrings[$i]["VirtualAddress"], 16))
        $IDTSeek = $IDTOffset + [System.Convert]::ToInt64($SectionsTableStrings[$i]["PointerToRawData"], 16)

        # Set the found section for use with IDT imports
        $IDTSection = $i
    }
}

PowerShell

Once we have the $IDTOffset and calculate the $IDTSeek, we can begin to parse the IDT directly and get all of the imported DLL names. This process largely mirrors the sections table process. However, as we do not know the number of imported DLLs beforehand, we need to continuously parse the file until we hit the null terminating entry.

$ImportDirectoryTable = @{}
    $ImportDLLNameArray= @()
        for($i=0;;$i++){
            # Seek offset
            $File.Seek($IDTSeek, 0) | Out-Null
            $IDTArray = [System.Byte[]]::CreateInstance([System.Byte], 20) 
            $File.Read($IDTArray, 0, 20) | Out-Null

            # If the NameRVA is empty, exit loop
            if([System.Convert]::ToInt64([System.Convert]::ToHexString($IDTArray[12..15]), 16) -eq 0){
                break
            }

            # Initialize $i as a hashtable
            $ImportDirectoryTable[$i] = @{}

            # Read DLL Name RVA
            $ImportDirectoryTable[$i]["NameRVA"] = $IDTArray[12..15]
            [Array]::Reverse($ImportDirectoryTable[$i]["NameRVA"])

            # Read DLL ILT RVA CORRECT
            $ImportDirectoryTable[$i]["ILTRVA"] = $IDTArray[0..3]
            [Array]::Reverse($ImportDirectoryTable[$i]["ILTRVA"])

            # Use ILT RVA to get location of import name 
            $ImportLookupTableOffset = [Math]::ABS([System.Convert]::ToInt64([System.Convert]::ToHexString($ImportDirectoryTable[$i]["ILTRVA"]), 16) - [System.Convert]::ToInt64($SectionsTableStrings[$IDTSection]["VirtualAddress"], 16))
            $ImportLookupTableSeek = $ImportLookupTableOffset + [System.Convert]::ToInt64($SectionsTableStrings[$IDTSection]["PointerToRawData"], 16)

            # Use Name RVA to get location of Name 
            $ImportDirectoryOffset = [Math]::ABS([System.Convert]::ToInt64([System.Convert]::ToHexString($ImportDirectoryTable[$i]["NameRVA"]), 16) - [System.Convert]::ToInt64($SectionsTableStrings[$IDTSection]["VirtualAddress"], 16))
            $ImportNameSeek = $ImportDirectoryOffset + [System.Convert]::ToInt64($SectionsTableStrings[$IDTSection]["PointerToRawData"], 16)

            # Seek name
            $File.Seek($ImportNameSeek, 0) | Out-Null
            $ImportDLLArray = [System.Byte[]]::CreateInstance([System.Byte], 20)  
            $File.Read($ImportDLLArray, 0, 20) | Out-Null

            # Seek ILT name 
            $File.Seek($ImportLookupTableSeek, 0) | Out-Null
            $ImportLookupArray = [System.Byte[]]::CreateInstance([System.Byte], 25) 
            $File.Read($ImportLookupArray, 0, 25) | Out-Null

            # Parse ILT Name 
            $ImportLookupName = $null
            foreach($x in $ImportLookupArray[10..25]){
                if($x -ne 0){
                    $ImportLookupName += [char]$x
                }else{
                    break
                }
            }

            # Parse DLL name
            $ImportDLLName = $null
            foreach($x in $ImportDLLArray){
                if($x -ne 0){
                    $ImportDLLName += [char]$x
                }else{
                    break
                }
            }

            $ImportDLLNameArray += $ImportDLLName

            # Read the 20-byte tables sequentially
            $IDTSeek += 20 
        }

PowerShell

Because we’re not concerned with all of the details of the IDT and ILT, we can parse out the names and store them in an array for output. The other fields are not necessary to parse.

After reading the IDT and getting DLL imports, we must begin to parse the meta data tables. To do this, we use the CLR Runtime header field from the optional headers.

# If there's no CLRRuntime Header, skip all processing and finish analysis
if([System.Convert]::ToInt64([System.Convert]::ToHexString($CLRRuntimeHeader),16) -ne 0){

    # Parse CLR Runtime Header to get EntryPoint of metadata streams
    $NETheaderRVA = $CLRRuntimeHeader[4..7]
    $NETHeaderOffset = [Math]::ABS([System.Convert]::ToInt64([System.Convert]::ToHexString($NETheaderRVA), 16) - [System.Convert]::ToInt64($SectionsTableStrings[0]["VirtualAddress"], 16))
    $NETHeaderSeek = $NETHeaderOffset + [System.Convert]::ToInt64($SectionsTableStrings[0]["PointerToRawData"], 16)
    $File.Seek($NETHeaderSeek, 0) | Out-Null
    $NETHeaderArray = [System.Byte[]]::CreateInstance([System.Byte], [System.Convert]::ToInt64([System.Convert]::ToHexString($CLRRuntimeHeader[0..3]), 16))
    $File.Read($NETHeaderArray, 0, [System.Convert]::ToInt64([System.Convert]::ToHexString($CLRRuntimeHeader[0..3]), 16)) | Out-Null
    # ---snipped---

PowerShell

After reading the meta data header / CLR runtime header, we can begin to parse it and identify the streams for meta data table parsing.

 # ---continued---
    # Get MetaData RVA & size
    $NETMetaDataRVA = $NETHeaderArray[8..11]
    [Array]::Reverse($NETMetaDataRVA)
    $NETMetaDataSize = $NETHeaderArray[12..15]
    [array]::Reverse($NETMetaDataSize)            

    # Get EntryPointToken
    $NETEntryPointToken = $NETHeaderArray[20..23]
    [array]::Reverse($NETEntryPointToken)

    # Get EntryPoint RVA / Resources RVA
    $NETEntryPointRVA = $NETHeaderArray[24..27]
    [Array]::Reverse($NETEntryPointRVA)

    # Get MetaData header from RVA
    $MetaDataOffset = [System.Convert]::ToInt64([System.Convert]::ToHexString($NETMetaDataRVA), 16) - [System.Convert]::ToInt64($SectionsTableStrings[0]["VirtualAddress"], 16)
    $MetaDataSeek = $MetaDataOffset + [System.Convert]::ToInt64($SectionsTableStrings[0]["PointerToRawData"], 16)
    $File.Seek($MetaDataSeek, 0) | Out-Null
    $MetaDataArray = [System.Byte[]]::CreateInstance([System.Byte], [System.Convert]::ToInt64([System.Convert]::ToHexString($NETMetaDataSize[0..3]), 16))
    $File.Read($MetaDataArray, 0, [System.Convert]::ToInt64([System.Convert]::ToHexString($NETMetaDataSize[0..3]), 16)) | Out-Null

    # Get the StreamHeader from the MetaData Header
    $MetaDataHeaderStringSize = $MetaDataArray[12..15]
    [array]::Reverse($MetaDataHeaderStringSize)
    $MetaDataHeaderStringSize = [System.Convert]::ToInt64([System.Convert]::ToHexString($MetaDataHeaderStringSize), 16)

    # Get number of streams
    $MetaDataStreams = $MetaDataArray[($MetaDataHeaderStringSize + 18)..($MetaDataHeaderStringSize + 19)]
    [Array]::Reverse($MetaDataStreams)
    $MetaDataStreams = [System.Convert]::ToInt64([System.Convert]::ToHexString($MetaDataStreams), 16)

    # Loop through sections
    $StreamIterations = $MetaDataHeaderStringSize + 28
    $MetaDataStreamHeaders = $MetaDataArray[($MetaDataHeaderStringSize + 20)..($MetaDataHeaderStringSize + 27)]

    # ---snipped---

PowerShell

It is important to note that there are a variable number of streams. Typically, these are #~, #Blob, and #Strings. However, the number and names of strings is dynamic to the file. Because of this, we must parse the streams based on the $MetaDataStreams value.

  # ---continued---
    $MetaDataStream = @{}
    for($i=0; $i -lt $MetaDataStreams; $i++){

        # Store current streamheaders in hashtable 
        [Array]::Reverse($MetaDataStreamHeaders)
        $MetaDataStream[$i] = @{}
        $MetaDataStream[$i]["Offset"] = $MetaDataStreamHeaders[4..7]
        $MetaDataStream[$i]["Size"] = $MetaDataStreamHeaders[0..3]

        try {
            # Loop to get the name and find the first non-null byte after 
            $StreamName = $null

            # For loop based on stream iterations (offset for each byte based on start of stream headers)
            for(;;$StreamIterations++){

                # If streamname has not been written, make sure first char is 23 / #
                if($null -eq $StreamName -And [char]$MetaDataArray[$StreamIterations] -eq "#"){

                    # Write the name of the stream to the StreamName 
                    $StreamName += [char]$MetaDataArray[$StreamIterations]

                # if StreamName is not null and byte is not null and no null bytes have been seen
                }elseif(($null -ne $StreamName) -And ($MetaDataArray[$StreamIterations] -ne 0)){

                    # Write the name of the stream to the StreamName 
                    $StreamName += [char]$MetaDataArray[$StreamIterations]

                # If a null byte is seen and the StreamName has been written, terminate
                }elseif(($StreamName.Length -ge 2) -And $MetaDataArray[$StreamIterations] -eq 0){
                    $MetaDataStream[$i]["Name"] = $StreamName
                    throw 
                }
            }                    
        }
        catch {
        }

        # Get the Offset and Size of each section
        $MetaDataStreamHeaders = $MetaDataArray[$StreamIterations..($StreamIterations+9)]
    }
    # ---snipped---

PowerShell

After parsing the streams, we need to identify the #~ and #Strings streams. These are the streams that hold the data for the meta data tables. Because we have identified the names of each stream in $MetaDataStream[$i]["Name"], we can loop through $i streams and select the streams we need.

 # ---continued---
    # Get the #~ stream RVA 
    # Get the #strings RVA to find the start of the #strings stream heap
    for($i=0;$i -lt $MetaDataStreams;$i++){
        if($MetaDataStream[$i]["Name"] -eq "#~"){

            # Get the offset of the #~ table
            $MetaDataTableOffset = [System.Convert]::ToInt64([System.Convert]::ToHexString($MetaDataStream[$i]["Offset"]), 16) + $MetaDataSeek
        }
        if($MetaDataStream[$i]["Name"] -eq "#Strings"){

            # Get the offset of the strings table
            $StringsDataTableOffset = [System.Convert]::ToInt64([System.Convert]::ToHexString($MetaDataStream[$i]["Offset"]), 16) + $MetaDataSeek
            $StringsHeapSize = [System.Convert]::ToInt64([System.Convert]::ToHexString($MetaDataStream[$i]["Size"]), 16)
        }
    }
    # Get String Heap 
    # Read entire heap and apply offset as numeric starting point as in Stream parsing
    $StringHeap = [System.Byte[]]::CreateInstance([System.Byte], $StringsHeapSize)
    $File.Seek($StringsDataTableOffset, 0) | Out-Null
    $File.Read($StringHeap, 0, $StringsHeapSize) | Out-Null


    # Parse the #~ MetaData Table 
    $MetaDataTable = [System.Byte[]]::CreateInstance([System.Byte], 24)
    $File.Seek($MetaDataTableOffset, 0) | Out-Null
    $File.Read($MetaDataTable, 0, 24) | Out-Null

    # ---snipped---

PowerShell

All that is needed now is to identify which meta data tables are present in the file using the ValidBitmask field. The ValidBitmask must be converted to binary, where each bit in the resulting binary number represents the presence of x table.

ValidBitmask = 00000E093DB7BF57 
Binary = 0000000000000000000011100000100100111101101101111011111101010111
Table present = 1

Plaintext

This is easily done by converting the binary number to an array and looping through each value, keeping count of the current position.

   # ---continued---

    # Calculate tables present from Valid bitmask
    $ValidBitmask = $MetaDataTable[8..15]
    [Array]::Reverse($ValidBitmask)
    $BinaryTables = [System.Convert]::ToString([System.Convert]::ToInt64([System.Convert]::ToHexString($ValidBitmask), 16), 2)

    # Determine slots occupied with 1
    $occupiedSlots = @()
    for ($i = 0; $i -lt $BinaryTables.Length; $i++) {
        if ($BinaryTables[$i] -eq '1') {
            $occupiedSlots += $BinaryTables.Length - $i
        }
    }

    # ---snipped---

PowerShell

Once the ValidBitmask is confirmed, we can start processing the tables. I will only show a small example of the table processing. It’s not particularly interesting and it spans several hundred lines. Because the tables are sequential, we still seek the tables we are not parsing. This will ensure that everything in the loop maintains the correct offset.

  # ---continued---

    # Get the size of each table
    $TableSizeArray = @{}
    [Array]::Reverse($occupiedSlots)
    foreach($i in $occupiedSlots){
        $TableSizeArray[$i] = @{}
        $TableSizeArray[$i]["Size"] = [System.Byte[]]::CreateInstance([System.Byte],4)
        $File.Read($TableSizeArray[$i]["Size"], 0, 4)  | Out-Null
        [Array]::Reverse($TableSizeArray[$i]["Size"])
        $TableSizeArray[$i]["Size"] = [System.Convert]::ToInt64([System.Convert]::ToHexString($TableSizeArray[$i]["Size"]), 16)
    }

    # Each table needs to be defined to skip as they are variable lengths
        foreach($i in $occupiedSlots){
            switch($i){

                # Module
                1 {$File.Seek(($TableSizeArray[$i]["Size"] * 10), 1) | Out-Null}

                # TypeRef
                2 {
                    $TypeRefMembers = @{}
                    $TypeRefNames = @()
                    $TypeRefNamespace = @()
                    for($j=0;$j -lt $TableSizeArray[2]["Size"];$j++){
                        $TypeRef = [System.Byte[]]::CreateInstance([System.Byte], 6)
                        $TypeRefMembers[$j] = @{}
                        $File.Read($TypeRef, 0, 6) | Out-Null

                        # Set offset to name
                        $TypeRefMembers[$j]["Name"] = $TypeRef[2..3]
                        [Array]::Reverse($TypeRefMembers[$j]["Name"])

                        # Set offset to namespace
                        $TypeRefMembers[$j]["Namespace"] = $TypeRef[4..5]
                        [Array]::Reverse($TypeRefMembers[$j]["Namespace"])

                        # Get Name
                        $ObjName = $null
                        foreach($x in $StringHeap[([System.Convert]::ToInt64([SYstem.Convert]::ToHexString($TypeRefMembers[$j]["Name"]), 16))..([System.Convert]::ToInt64([SYstem.Convert]::ToHexString($TypeRefMembers[$j]["Name"]), 16) + 30)]){
                            if($x -ne 0){
                                $ObjName += [char]$x
                            }else{
                                break
                            }
                        }
                        $TypeRefNames += $ObjName

                        # Get Namespace
                        $ObjNamespace = $null
                        foreach($x in $StringHeap[([System.Convert]::ToInt64([SYstem.Convert]::ToHexString($TypeRefMembers[$j]["Namespace"]), 16))..([System.Convert]::ToInt64([SYstem.Convert]::ToHexString($TypeRefMembers[$j]["Namespace"]), 16) + 30)]){
                            if($x -ne 0){
                                $ObjNamespace += [char]$x
                            }else{
                                break
                            }
                        }
                        $TypeRefNamespace += $ObjNamespace                         

                    }
                    $TypeRefMembers = @{$TypeRefNames = $TypeRefNamespace}
                }
            # ---snippped---
            }
        }
}

PowerShell

Following the meta data table parsing, all desired output objects are placed into a [PSCustomObject] and sent to the output function for conversion to various display types. This object is built using a somewhat outdated method, I would typically use $Object = [PSCustomObject]{ Key = Value } nowadays, but this project originated a while back and this is how I first learned to do this.

$Results = New-Object PSObject -Property $([ordered]@{
    FileName = $Path
    Hash = (Get-FileHash -Path $Path).Hash
    Type = "$Bit" + " $PEType"
    TimeStamp = $TimeStamp
    Checksum = $CheckSum
    Packed = if($Packed){$true}else{$false}
    Entropy = if(!$SkipEntropyCheck){$entropy}else{"Entropy check skipped"}
    SectionNames = $SectionsNames
    SectionOffsets = $SectionsOffsets
    MetadataOffset = if($MetaDataSeek){$MetaDataSeek.ToString("X8") -replace '..(?!$)','$0 '}
    Streams = $StreamNames
    StreamsOffset = $StreamOffsets
    ImportTable = $ImportDLLNameArray
    TypeRefNames = $TypeRefNames
    TypeRefNamespace = $TypeRefNamespace
    Methods = $MethodArray
    Params = $ParamArray
    MemberRef = $MemberRefArray
    Events = $EventArray
    ModuleRef = $ModuleArray
    Imports = $ImplArray
    AssemblyRef = $AssemblyArray
})

PowerShell

While this section doesn’t cover all the various bits of code, and the project itself does not interact with all aspects of the PE file structure (notably exports), I hope it’s still helpful for anyone else trying to dig into PE analysis and learning the structure behind windows executables.

As an ending note, I’ll say that I am definitely not a programmer in any aspect. This code is messy as heck, and even with the several rewrites and improvements I’ve made, there are still many issues and bad programming practices. However, as a basic “It Works!” type of thing, this works great. It’s definitely been a huge learning opportunity and resource for me. I was even inspired enough to type up all of this.