"""Small, read-only PE32+ AMD64 inspector, not a Windows loader. ``PEImage(data: bytes)`` validates DOS/NT/section headers and exposes ``machine``, ``timestamp``, ``image_base``, ``size_of_image`` and a tuple of immutable ``Section`` records. Invalid or unsupported images/ranges raise ``ValueError``. No file access, subprocesses, optional dependencies or runtime state are used. ``read_rva(rva, size)`` returns bytes only when the entire nonempty range lies in one file-backed region (headers or one section). Zero-fill, gaps, ambiguous overlaps and raw padding past a nonzero VirtualSize are not readable. A zero VirtualSize uses SizeOfRawData. Section ``backed_size`` exposes this same rule. ``imports()`` validates the ordinary import directory on demand and returns ``dict[str, list[str | int]]``: lowercase DLL names, case-preserved symbol names, and integer ordinals in 0..65535 (distinct from a symbol named e.g. "#123"). Repeated DLL descriptors are combined, preserving import order and duplicates. An absent directory returns {}. Delay imports, exports and other directories are intentionally not inspected. Bound IAT addresses are not symbol names; OriginalFirstThunk is preferred, with FirstThunk used only when it is absent. ``find("48 8B ?? 90", executable_only=True)`` returns all matching RVAs, sorted, including overlapping matches. Tokens must be two hex digits or ``??``. Matches never cross sections or include virtual zero-fill/raw alignment padding. Only sections with IMAGE_SCN_MEM_EXECUTE are searched by default, never headers. Use the section records for optional named-region checks such as .text. Resource limits intentionally reject oversized/unusual images: 1 GiB file, 4 GiB virtual image, 96 sections, 16 data directories, 4096 import descriptors, 65536 total import slots (including terminators), 4096 bytes per import name, and 4096 signature tokens. This is for exact-build compatibility inspection, not permissive parsing of every image accepted by Windows. """ from dataclasses import dataclass import re import struct _MAX_FILE_SIZE = 1 << 30 _MAX_IMAGE_SIZE = 1 << 32 _MAX_SECTIONS = 96 _MAX_DESCRIPTORS = 4096 _MAX_THUNKS = 65536 _MAX_NAME = 4096 _MAX_PATTERN = 4096 _EXECUTE = 0x20000000 _ORDINAL_FLAG = 1 << 63 @dataclass(frozen=True) class Section: name: str rva: int virtual_size: int raw_offset: int raw_size: int characteristics: int @property def backed_size(self) -> int: """Bytes present both in the file and the section's virtual extent.""" return min(self.raw_size, self.virtual_size or self.raw_size) class PEImage: """Inspect immutable PE file bytes; import validation is deferred to imports.""" def __init__(self, data: bytes): if not isinstance(data, bytes): raise TypeError("PEImage requires immutable bytes") if not 64 <= len(data) <= _MAX_FILE_SIZE: raise ValueError("invalid PE file size") self._data = data if data[:2] != b"MZ": raise ValueError("invalid DOS signature") nt = self._unpack(" 16 or 112 + directories * 8 > optional_size: raise ValueError("invalid data directory count") section_table = optional + optional_size table_end = section_table + count * 40 if not table_end <= self._headers_size <= len(data): raise ValueError("invalid or truncated header range") if not self._headers_size <= self.size_of_image < _MAX_IMAGE_SIZE: raise ValueError("invalid image size") if self.image_base + self.size_of_image > 1 << 64: raise ValueError("image address range overflows") self._imports = ( self._unpack("= 2 else (0, 0) ) sections = [] virtual_ranges = [(0, self._headers_size)] raw_ranges = [(0, self._headers_size)] for index in range(count): offset = section_table + index * 40 name = self._bytes(offset, 8).split(b"\0", 1)[0] try: name = name.decode("ascii") except UnicodeDecodeError as exc: raise ValueError("non-ASCII section name") from exc virtual_size, rva, raw_size, raw_offset = self._unpack(" self.size_of_image: raise ValueError("section exceeds image range") if virtual_extent: virtual_ranges.append((rva, rva + virtual_extent)) if raw_size: self._check_range(raw_offset, raw_size) raw_ranges.append((raw_offset, raw_offset + raw_size)) sections.append(Section( name, rva, virtual_size, raw_offset, raw_size, characteristics, )) for ranges in (virtual_ranges, raw_ranges): ordered = sorted(ranges) if any(left[1] > right[0] for left, right in zip(ordered, ordered[1:])): raise ValueError("overlapping PE regions are ambiguous") self.sections = tuple(sections) def _check_range(self, offset: int, size: int) -> None: if offset < 0 or size < 0 or offset + size > len(self._data): raise ValueError("truncated PE file range") def _bytes(self, offset: int, size: int) -> bytes: self._check_range(offset, size) return self._data[offset:offset + size] def _unpack(self, fmt: str, offset: int) -> tuple: return struct.unpack(fmt, self._bytes(offset, struct.calcsize(fmt))) def _region(self, rva: int) -> tuple[int, int]: """Return file offset and remaining backed bytes for an RVA.""" if type(rva) is not int or not 0 <= rva < self.size_of_image: raise ValueError("invalid RVA") if rva < self._headers_size: return rva, self._headers_size - rva for section in self.sections: delta = rva - section.rva if 0 <= delta < section.backed_size: return section.raw_offset + delta, section.backed_size - delta raise ValueError("RVA is not file-backed") def _file_range(self, rva: int, size: int) -> int: if type(size) is not int or size <= 0: raise ValueError("RVA size must be a positive integer") offset, available = self._region(rva) if size > available: raise ValueError("RVA range is not completely file-backed") return offset def read_rva(self, rva: int, size: int) -> bytes: """Read a complete positive-sized range from one file-backed region.""" offset = self._file_range(rva, size) return self._data[offset:offset + size] def _cstring(self, rva: int) -> str: offset, available = self._region(rva) end = self._data.find(b"\0", offset, offset + min(available, _MAX_NAME + 1)) if end <= offset: raise ValueError("empty, unterminated or oversized import name") try: return self._data[offset:end].decode("ascii") except UnicodeDecodeError as exc: raise ValueError("non-ASCII import name") from exc def imports(self) -> dict[str, list[str | int]]: """Read ordinary imports; int entries are checked 16-bit ordinals.""" rva, size = self._imports if not rva and not size: return {} if not rva or size < 20: raise ValueError("invalid import directory range") directory = self._file_range(rva, size) result: dict[str, list[str | int]] = {} remaining = _MAX_THUNKS for index in range(_MAX_DESCRIPTORS + 1): if (index + 1) * 20 > size: raise ValueError("unterminated import descriptor table") descriptor = self._unpack(" 0xFFFFFFFF: raise ValueError("invalid import name RVA") name = self._cstring(thunk + 2) self._file_range(thunk, 2 + len(name) + 1) names.append(name) else: raise ValueError("unterminated or oversized import thunk table") raise ValueError("unterminated import descriptor table") def find(self, pattern: str, executable_only: bool = True) -> list[int]: """Return every matching section RVA for fixed hex bytes and ?? tokens.""" if not isinstance(pattern, str) or len(pattern) > _MAX_PATTERN * 4: raise ValueError("invalid or oversized signature") tokens = pattern.split() if not 1 <= len(tokens) <= _MAX_PATTERN: raise ValueError("invalid signature length") parts = [] for token in tokens: if token == "??": parts.append(b".") elif re.fullmatch(r"[0-9a-fA-F]{2}", token): parts.append(re.escape(bytes.fromhex(token))) else: raise ValueError("signature tokens must be hex bytes or ??") matcher = re.compile(b"(?=" + b"".join(parts) + b")", re.DOTALL) matches = [] for section in self.sections: if executable_only and not section.characteristics & _EXECUTE: continue # Slice to enforce both lower and upper bounds for lookahead. data = self._data[section.raw_offset:section.raw_offset + section.backed_size] matches.extend(section.rva + match.start() for match in matcher.finditer(data)) return sorted(matches)