#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-pefile. Emits `key=value' lines, one per line, in file order; the suite reads them back with grep. A python3 start on this arm is tens of seconds, so taking one start for every measurement here is the difference between a one-minute run and a half-hour one. WHAT THIS PACKAGE DOES, AND THEREFORE WHAT IS MEASURED. `pefile' PARSES AND WRITES Portable Executable images. It is pure structure work over a byte buffer: `PE(data=...)' walks the DOS stub, the `PE\\0\\0' signature, the COFF header, the optional header, the data directories and the section table, and `write()' serialises the parsed object back. There is no I/O beyond an optional file read, no network, no subprocess and no terminal -- which is what makes this port close on its suite. THE FIXTURE IS BUILT HERE, NOT SHIPPED. `_build()' below assembles a PE32 i386 image from `struct.pack' -- 64-byte DOS header, `PE\\0\\0', a 20-byte COFF header, a 224-byte optional header with its 16 data directories, a two-entry section table and the sections' raw bytes. Every expectation the suite states is therefore a LITERAL THIS FILE WROTE, not a property of some binary that happened to be lying around. It is also what makes the malformed cases possible: each one is the same builder with exactly one field corrupted, so a `PEFormatError' can be attributed to the corruption rather than to the fixture. THE ROUND TRIP IS THE STRONGEST CASE. Parse, change ONE field, `write()', re-parse, and assert both halves: the changed field changed, and every other field this suite names did not. A serialiser that quietly rewrites a neighbouring field passes a "did my edit take" test and fails this one. TWO RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline -- every value below is a single line, and bytes are emitted as hex. And no value may depend on the encoding stdout happens to be in: every one is ASCII. """ import hashlib import os import shutil import struct import sys import tempfile emitted = [] def emit(key, value): emitted.append(key) print("%s=%s" % (key, value)) def call(fn, *a, **kw): """Return fn(*a) as a string, or `!ExceptionName' if it raised. Asserted by exception NAME rather than by message: pefile's messages have moved between releases, while the name is the contract a caller codes against.""" try: got = fn(*a, **kw) except Exception as exc: return "!" + type(exc).__name__ # NEVER return the object. `PE.__str__' is `dump_info()' -- several hundred # lines of formatted header dump -- and the runner reads this stream one # line at a time, so returning a PE here would not merely be noisy, it would # desynchronise every key after it. if isinstance(got, pefile.PE): got.close() return "no-raise" return got try: import pefile except Exception as exc: print("import_error=%s: %s" % (type(exc).__name__, exc)) sys.exit(1) # ================================================================ fixture ==== I386 = 0x014C PE32 = 0x010B LFANEW = 0x80 FILE_ALIGN = 0x200 SECT_ALIGN = 0x1000 SIZE_OPT = 224 # 96-byte PE32 optional header + 16 dirs # 512 bytes of x86 with NO NUL in it. The NUL ratio matters: pefile warns # when a file is mostly zero, and a fixture that tripped that warning would put # a permanent entry in `warnings_joined' and hide a real one behind it. TEXT = bytes(bytearray((0x40 + (i % 0x50)) for i in range(512))) def _rdata(): """.rdata, carrying a REAL export directory at RVA 0x2000. One exported name at ordinal base 1, pointing into .text. It is what makes `fast_load' observable: with fast_load=True there is no DIRECTORY_ENTRY_EXPORT attribute at all, and `full_load()' puts one there. A fixture with sixteen EMPTY directories cannot tell those two apart.""" b = bytearray(512) struct.pack_into(" "FIXTURE.DLL" 1, # Base (ordinal base) 1, 1, # NumberOfFunctions, NumberOfNames 0x2050, 0x2058, 0x2060) # Functions, Names, NameOrdinals b[0x38:0x44] = b"FIXTURE.DLL\x00" struct.pack_into(" .text struct.pack_into(" section, and RVA -> file offset, over the fixture's own map. emit("section_for_rva_text", pe.get_section_by_rva(0x1000).Name.rstrip(b"\x00").decode("ascii")) emit("section_for_rva_rdata", pe.get_section_by_rva(0x2000).Name.rstrip(b"\x00").decode("ascii")) emit("section_for_rva_gap", "None" if pe.get_section_by_rva(0x8000) is None else "not-none") emit("offset_from_rva_text", "0x%x" % pe.get_offset_from_rva(0x1000)) emit("offset_from_rva_rdata", "0x%x" % pe.get_offset_from_rva(0x2000)) emit("rva_from_offset_rdata", "0x%x" % pe.get_rva_from_offset(0x400)) emit("string_at_rva_dllname", pe.get_string_at_rva(0x2038, 16).decode("ascii")) emit("string_at_rva_export", pe.get_string_at_rva(0x2070, 32).decode("ascii")) # AN RVA PAST EVERY SECTION IS NOT AN EXCEPTION from get_offset_from_rva; it is # a PEFormatError, which is the one place a lookup raises rather than returning # None. Named because the two neighbouring lookups above return None instead. emit("offset_from_rva_bad", call(pe.get_offset_from_rva, 0x800000)) # THE DATA DIRECTORIES. Sixteen are declared; the fixture fills the first. emit("ndirs_parsed", len(pe.OPTIONAL_HEADER.DATA_DIRECTORY)) emit("dir0_name", pe.OPTIONAL_HEADER.DATA_DIRECTORY[0].name) emit("dir0_rva", "0x%x" % pe.OPTIONAL_HEADER.DATA_DIRECTORY[0].VirtualAddress) emit("dir0_size", "0x%x" % pe.OPTIONAL_HEADER.DATA_DIRECTORY[0].Size) emit("dir1_name", pe.OPTIONAL_HEADER.DATA_DIRECTORY[1].name) emit("dir1_rva", pe.OPTIONAL_HEADER.DATA_DIRECTORY[1].VirtualAddress) emit("dir2_name", pe.OPTIONAL_HEADER.DATA_DIRECTORY[2].name) emit("dir5_name", pe.OPTIONAL_HEADER.DATA_DIRECTORY[5].name) # THE EXPORT DIRECTORY, PARSED. Every value here is a literal `_rdata()' # wrote, which is what lets the whole directory be asserted rather than just # its presence. emit("has_export_attr", "yes" if hasattr(pe, "DIRECTORY_ENTRY_EXPORT") else "no") emit("export_dll_name", pe.DIRECTORY_ENTRY_EXPORT.name.decode("ascii")) emit("export_base", pe.DIRECTORY_ENTRY_EXPORT.struct.Base) emit("export_nfunctions", pe.DIRECTORY_ENTRY_EXPORT.struct.NumberOfFunctions) emit("export_nnames", pe.DIRECTORY_ENTRY_EXPORT.struct.NumberOfNames) emit("export_symbol_count", len(pe.DIRECTORY_ENTRY_EXPORT.symbols)) emit("export_symbol_name", pe.DIRECTORY_ENTRY_EXPORT.symbols[0].name.decode("ascii")) emit("export_symbol_rva", "0x%x" % pe.DIRECTORY_ENTRY_EXPORT.symbols[0].address) emit("export_symbol_ordinal", pe.DIRECTORY_ENTRY_EXPORT.symbols[0].ordinal) # NO IMPORTS AND NO RESOURCES, because those directories are zero -- so the # attributes are ABSENT rather than empty, which is the distinction a caller # has to code for. emit("has_import_attr", "yes" if hasattr(pe, "DIRECTORY_ENTRY_IMPORT") else "no") emit("has_resource_attr", "yes" if hasattr(pe, "DIRECTORY_ENTRY_RESOURCE") else "no") emit("has_reloc_attr", "yes" if hasattr(pe, "DIRECTORY_ENTRY_BASERELOC") else "no") # THE IMPORT HASH OF A BINARY WITH NO IMPORTS IS THE EMPTY STRING, not the # md5 of nothing. A caller keying a corpus on imphash gets a collision across # every import-free file rather than a distinct value each. emit("imphash", pe.get_imphash()) # THE WARNINGS LIST IS A FIRST-CLASS RESULT, not a log. A file pefile # considers suspicious still parses; the caller is expected to read this. The # fixture is built to produce NONE of them, which is what makes every red in # this suite a statement about the parser rather than about the fixture. emit("warnings_count", len(pe.get_warnings())) emit("warnings_joined", " | ".join(pe.get_warnings()).replace("\n", " ")) emit("verify_checksum_zero", "yes" if pe.verify_checksum() is False else "no") # ================================================== checksum, independently == def _checksum(data, csum_off): """The PE checksum, computed here rather than asked of the module. Sum the image as little-endian dwords with the CheckSum field itself skipped, fold the carries, fold to 16 bits, add the file length.""" total = 0 rem = len(data) % 4 padded = data + b"\x00" * ((4 - rem) if rem else 0) for i in range(len(padded) // 4): if i == csum_off // 4: continue total += struct.unpack("= 2 ** 32: total = (total & 0xFFFFFFFF) + (total >> 32) total = (total & 0xFFFF) + (total >> 16) total = total + (total >> 16) return (total & 0xFFFF) + len(data) _csum_off = pe.OPTIONAL_HEADER.get_file_offset() + 0x40 emit("checksum_offset", "0x%x" % _csum_off) emit("checksum_generated", "0x%x" % pe.generate_checksum()) emit("checksum_independent", "0x%x" % _checksum(GOOD, _csum_off)) emit("checksum_agrees", "yes" if pe.generate_checksum() == _checksum(GOOD, _csum_off) else "no") # WRITING THE CHECKSUM BACK MAKES verify_checksum() TRUE, and that is the whole # contract: generate, assign, and the file validates. _pe_c = pefile.PE(data=GOOD) _pe_c.OPTIONAL_HEADER.CheckSum = _pe_c.generate_checksum() _fixed = _pe_c.write() _pe_c2 = pefile.PE(data=bytes(_fixed)) emit("checksum_written", "0x%x" % _pe_c2.OPTIONAL_HEADER.CheckSum) emit("checksum_verifies", "yes" if _pe_c2.verify_checksum() else "no") _pe_c.close() _pe_c2.close() # ============================================================= round trip ==== # PARSE, CHANGE ONE FIELD, write(), RE-PARSE. Both halves are asserted: the # field moved, and nothing else this suite names did. `write()' returns a # bytearray, not bytes -- a caller who type-checks the result will be surprised. _rt = pefile.PE(data=GOOD) _before = { "machine": _rt.FILE_HEADER.Machine, "nsections": _rt.FILE_HEADER.NumberOfSections, "magic": _rt.OPTIONAL_HEADER.Magic, "imagebase": _rt.OPTIONAL_HEADER.ImageBase, "filealign": _rt.OPTIONAL_HEADER.FileAlignment, "sizeofimage": _rt.OPTIONAL_HEADER.SizeOfImage, "subsystem": _rt.OPTIONAL_HEADER.Subsystem, "sec0_va": _rt.sections[0].VirtualAddress, "sec1_va": _rt.sections[1].VirtualAddress, "sec0_rawsize": _rt.sections[0].SizeOfRawData, "sec0_name": _rt.sections[0].Name, } _rt.OPTIONAL_HEADER.AddressOfEntryPoint = 0x1234 _out = _rt.write() emit("write_type", type(_out).__name__) emit("write_len_same", "yes" if len(_out) == len(GOOD) else "no") emit("write_bytes_changed", "yes" if bytes(_out) != GOOD else "no") _rt2 = pefile.PE(data=bytes(_out)) emit("rt_entrypoint", "0x%x" % _rt2.OPTIONAL_HEADER.AddressOfEntryPoint) _same = [] _diff = [] for k, v in _before.items(): got = { "machine": _rt2.FILE_HEADER.Machine, "nsections": _rt2.FILE_HEADER.NumberOfSections, "magic": _rt2.OPTIONAL_HEADER.Magic, "imagebase": _rt2.OPTIONAL_HEADER.ImageBase, "filealign": _rt2.OPTIONAL_HEADER.FileAlignment, "sizeofimage": _rt2.OPTIONAL_HEADER.SizeOfImage, "subsystem": _rt2.OPTIONAL_HEADER.Subsystem, "sec0_va": _rt2.sections[0].VirtualAddress, "sec1_va": _rt2.sections[1].VirtualAddress, "sec0_rawsize": _rt2.sections[0].SizeOfRawData, "sec0_name": _rt2.sections[0].Name, }[k] (_same if got == v else _diff).append(k) emit("rt_unchanged_count", len(_same)) emit("rt_changed_others", ",".join(sorted(_diff))) emit("rt_sec_data_intact", "yes" if _rt2.sections[1].get_data()[:len(RDATA)] == RDATA else "no") # TWO BYTES MOVE, AND THEY ARE THE TWO THAT HAD TO. 0x1000 -> 0x1234 differs # in the low two bytes of one dword and nowhere else, so this count is the # tightest statement the round trip can make: a serialiser that normalised a # neighbouring field, or rewrote padding, would show a larger number here while # every named field above still compared equal. emit("rt_bytes_differing", sum(1 for a, b in zip(bytes(_out), GOOD) if a != b)) # A SECOND ROUND TRIP WITH NO EDIT IS THE IDENTITY. This is the case that says # write() is a serialiser rather than a normaliser: re-emitting an untouched # parse reproduces the input byte for byte. _id = pefile.PE(data=GOOD) emit("rt_identity", "yes" if bytes(_id.write()) == GOOD else "no") _id.close() # EDITING A SECTION HEADER ROUND-TRIPS -- AND THE RE-PARSE RE-SORTS. `.text' # is renamed to `.code' and moved above `.rdata', so on the way back it is # sections[1] and `.rdata' is sections[0]. The section TABLE order in the file # is unchanged; only the list pefile hands the caller moved. A caller holding # an index across a write() is holding the wrong section. _sr = pefile.PE(data=GOOD) _sr.sections[0].Name = b".code\x00\x00\x00" _sr.sections[0].VirtualAddress = 0x3000 _sr2 = pefile.PE(data=bytes(_sr.write())) emit("rt_sec_index0_name", _sr2.sections[0].Name.rstrip(b"\x00").decode("ascii")) emit("rt_sec_index1_name", _sr2.sections[1].Name.rstrip(b"\x00").decode("ascii")) emit("rt_sec_renamed_va", "0x%x" % [x for x in _sr2.sections if x.Name.startswith(b".code")][0].VirtualAddress) emit("rt_sec_rdata_untouched", "0x%x" % [x for x in _sr2.sections if x.Name.startswith(b".rdata")][0].VirtualAddress) # ...and the TABLE itself still has `.code' first, which is the other half of # the claim: the sort is presentational and write() did not reorder the file. emit("rt_table_first_name", bytes(_sr.write())[0x80 + 4 + 20 + SIZE_OPT: 0x80 + 4 + 20 + SIZE_OPT + 8] .rstrip(b"\x00").decode("ascii")) _sr.close() _sr2.close() # write(filename=...) IS THE FILE-BACKED FORM, and it writes the same bytes the # in-memory form returns. _wf = os.path.join(W, "written.exe") _rt2.write(filename=_wf) with open(_wf, "rb") as fh: _wf_bytes = fh.read() emit("write_file_matches", "yes" if _wf_bytes == bytes(_out) else "no") emit("write_file_len", len(_wf_bytes)) _rt.close() _rt2.close() # ============================================================ from a file ==== # PE(name=...) AND PE(data=...) AGREE. The file form mmaps; the data form does # not. Nothing above depends on which was used, and this is why. _pf = pefile.PE(GOODFILE) emit("file_form_machine", "0x%04x" % _pf.FILE_HEADER.Machine) emit("file_form_nsections", _pf.FILE_HEADER.NumberOfSections) emit("file_form_sec0", _pf.sections[0].Name.rstrip(b"\x00").decode("ascii")) emit("file_form_write_matches", "yes" if bytes(_pf.write()) == GOOD else "no") _pf.close() emit("missing_file", call(pefile.PE, os.path.join(W, "nope.exe"))) emit("empty_data", call(pefile.PE, None, b"")) # ========================================================= fast_load path ==== # fast_load=True PARSES THE HEADERS AND STOPS -- no data directories. On this # fixture every directory is empty, so the observable difference is the # `full_load' bookkeeping rather than a populated attribute; both are asserted # so the pair reads as a measurement. _fl = pefile.PE(data=GOOD, fast_load=True) emit("fast_machine", "0x%04x" % _fl.FILE_HEADER.Machine) emit("fast_nsections", len(_fl.sections)) emit("fast_has_export", "yes" if hasattr(_fl, "DIRECTORY_ENTRY_EXPORT") else "no") emit("full_load_returns", "None" if _fl.full_load() is None else "not-none") emit("full_has_export", "yes" if hasattr(_fl, "DIRECTORY_ENTRY_EXPORT") else "no") emit("full_export_dll_name", _fl.DIRECTORY_ENTRY_EXPORT.name.decode("ascii")) emit("full_export_symbol_name", _fl.DIRECTORY_ENTRY_EXPORT.symbols[0].name.decode("ascii")) # full_load() IS IDEMPOTENT: a second call neither raises nor duplicates. _fl.full_load() emit("full_load_twice_symbols", len(_fl.DIRECTORY_ENTRY_EXPORT.symbols)) emit("fast_write_identity", "yes" if bytes(_fl.write()) == GOOD else "no") _fl.close() # A DIRECTORY WITH AN OUT-OF-RANGE RVA DOES NOT RAISE -- it is recorded as a # WARNING and the object comes back usable. This is the ticket's third error # path and it is the one that does NOT behave like the other two: pefile is # deliberately tolerant of a bad directory because real packed binaries carry # them. fast_load=True skips the parse entirely and produces no warning at all, # which is the cleanest statement of what fast_load buys. _bad_dir = _build(dirs={1: (0x900000, 0x28)}) _bd = pefile.PE(data=_bad_dir) emit("baddir_parses", "yes") emit("baddir_has_warnings", "yes" if _bd.get_warnings() else "no") emit("baddir_import_attr", "yes" if hasattr(_bd, "DIRECTORY_ENTRY_IMPORT") else "no") emit("baddir_dir1_rva", "0x%x" % _bd.OPTIONAL_HEADER.DATA_DIRECTORY[1].VirtualAddress) emit("baddir_export_attr", "yes" if hasattr(_bd, "DIRECTORY_ENTRY_EXPORT") else "no") _bd.close() _bdf = pefile.PE(data=_bad_dir, fast_load=True) emit("baddir_fast_warnings", len(_bdf.get_warnings())) _bdf.close() # ============================================================== malformed ==== # ONE FIELD CORRUPTED PER CASE, each from the same builder, so a PEFormatError # is attributable to the corruption named in the key. emit("no_mz", call(pefile.PE, None, _build(dos_magic=b"ZM"))) try: pefile.PE(data=_build(dos_magic=b"ZM")) emit("no_mz_raised", "no") emit("no_mz_text", "") except pefile.PEFormatError as exc: emit("no_mz_raised", "yes") emit("no_mz_text", str(exc).replace("\n", " ")) # TRUNCATED BEFORE THE DOS HEADER IS EVEN COMPLETE. emit("truncated_dos", call(pefile.PE, None, GOOD[:32])) emit("truncated_at_sig", call(pefile.PE, None, GOOD[:0x82])) # A SECTION TABLE THAT RUNS PAST THE END OF THE FILE. THE TICKET EXPECTED A # PEFormatError AND THERE IS NONE. NumberOfSections claims 4096 entries in an # image that holds two; pefile parses the entries that are actually there, # records a WARNING, and hands back a usable object with a short section list. # That is the shape of every structural failure past the DOS stub -- see the # three cases here and `bad_opt_magic' below. A caller who relies on the # constructor to reject a malformed file gets nothing; get_warnings() is the # only signal, and it is advisory. _spe = _build(nsections=4096) emit("sections_past_eof", call(pefile.PE, None, _spe)) _sp = pefile.PE(data=_spe) emit("sections_past_eof_declared", _sp.FILE_HEADER.NumberOfSections) emit("sections_past_eof_parsed", len(_sp.sections)) emit("sections_past_eof_warned", "yes" if _sp.get_warnings() else "no") emit("sections_past_eof_warning", " | ".join(_sp.get_warnings())[:200].replace("\n", " ")) _sp.close() # e_lfanew POINTING PAST THE END OF THE FILE *IS* REFUSED. Not a rebuild: the # good image with that ONE dword overwritten, so nothing else about it moved. # This is the boundary the two halves of this section sit either side of -- # pefile refuses what it cannot READ (a header off the end of the buffer) and # tolerates what it can read but cannot BELIEVE (a section count, an optional # header magic, a directory RVA). _lpe = bytearray(GOOD) struct.pack_into("