Portex is a parser, editor and builder for PE executable images. It works with no_std.
Portex keeps raw files and loader-mapped images apart, because each layout translates an RVA to
a different source offset.
- Raw PE files, loader-mapped images, and mapped images that are relocated to a runtime base.
- PE32 and PE32+ headers, checked RVA/VA conversion, and section layout.
- Zero-filled virtual tails of sections, overlays, Authenticode certificate tables, and checksums.
- Imports, delay imports, bound imports, and exports (also sparse and aliased exports).
- Resources, base relocations, TLS, debug records, and the load configuration.
- Exception tables for x64, ARM and ARM64.
- Raw views for patch tools: import, export, relocation, exception, resource and load configuration records with the buffer offset of each field, and strict header reads.
- A load configuration that is limited by its declared size, also with legacy prefixes. A partial field is an error. A field that is fully absent stays zero.
- Serialization of x64 unwind data with borrowed handler data for a different language, through
UnwindInfo::try_to_bytes_with_handler_data. - Rich headers, the CLR/CLI header of the PE image, and the raw metadata blob.
- Lossless round trips of raw files through
PeFile. When the layout does not change,PeFilekeeps noncanonical gaps, certificate bytes and overlays. - Parsing of malformed input for PE tooling: headers that end early, overlapping header
structures and garbage fields still parse, and
validate()reports the problems. Fallible builders, with no panics on file data. - Normalization of malformed files through
PeFile::normalize, with no change to how the loader maps the image. - Sequential I/O and positional I/O.
no_std + allocsupport. Sequential I/O usesnostdio. PositionalReadAtaccess is also available for mapped images and remote images.
Portex targets PE executable images, for example executables, DLLs, drivers and UEFI-style images. Portex does not support standalone COFF object files or COFF symbol tables.
Portex does not parse CLR metadata streams or tables. To parse them, give
PeImage::clr_metadata() to a metadata crate, for example clrmeta.
[dependencies]
portex = "0.5"For no_std + alloc:
[dependencies]
portex = { version = "0.5", default-features = false }Portex 0.5 requires Rust 1.88 or newer.
The default std feature adds filesystem helpers and FileReader. It also enables the std
feature of nostdio. Then the nostdio traits and nostdio::IoError are the std::io items,
so std files and streams work with the stream APIs directly.
Without std, parsing, building, mapped images, validation, in-memory readers and stream APIs
are still available. nostdio then supplies its own traits and error type. An I/O error is an
ErrorKind::Io in both builds.
The std feature is not fully additive. It changes the types that portex::io re-exports and
the type in ErrorKind::Io. Cargo unifies features, so a different crate in the dependency
graph can enable std for your crate too. Thus no_std code must not match on the variants
of the no_std IoError. Use portex::io::is_unexpected_eof and
portex::io::is_interrupted to examine an I/O error.
| Type | Use it for |
|---|---|
PeFile |
A full raw file. It keeps the certificates, gaps and overlay |
PeImage |
A normalized executable image, parsed from raw bytes or loader-mapped bytes |
PeHeaders |
The headers only. It does not load section data |
PeBuilder |
Building a new executable image |
use portex::PeFile;
let mut file = PeFile::from_file("example.exe")?;
let image = file.image();
println!("64-bit: {}", image.is_64bit());
println!("entry point RVA: {:#x}", image.entry_point());
for dll in &image.imports()?.dlls {
println!("imports from {}", dll.name);
}
// Edits go through the normalized image while PeFile retains raw-only state. The second
// argument selects the section (by index) for a table that does not fit in place. `None`
// adds a new section for it.
let imports = file.image().imports()?;
file.image_mut().update_imports(&imports, None)?;
file.write_to_file("modified.exe")?;
# Ok::<(), portex::Error>(())Use PeImage::parse(raw_bytes) if the rebuilt file does not need the overlays and
certificates. Use PeFile::parse(raw_bytes) if it needs them.
use portex::{ParseOptions, PeImage, SourceLayout};
# let raw_file_bytes: &[u8] = &[];
# let mapped_image: &[u8] = &[];
// Raw file: section payloads are addressed by PointerToRawData.
let raw = PeImage::parse(raw_file_bytes)?;
// Loader-mapped image: section payloads are addressed by RVA.
let mapped = PeImage::parse_mapped(mapped_image)?;
// Preserve the actual load base for VA-bearing directories such as TLS.
let relocated = PeImage::parse_mapped_at(mapped_image, 0x0000_7ff6_0000_0000)?;
// Explicit options also support missing/discarded mapped section pages.
let partial = PeImage::parse_with_options(
mapped_image,
ParseOptions::mapped().allow_missing_section_data(),
)?;
assert_eq!(raw.source_layout(), SourceLayout::File);
assert_eq!(mapped.source_layout(), SourceLayout::Mapped);
assert_eq!(relocated.runtime_image_base(), 0x0000_7ff6_0000_0000);
# Ok::<(), portex::Error>(())PeImage::to_mapped_image() makes loader-style bytes at the runtime image base of the image
(runtime_image_base()). This is the base that you gave to the parser, for example to
parse_mapped_at, or to set_runtime_image_base. If you gave no base, it is the preferred
image base. to_mapped_image_at(base) makes the bytes at a different runtime base. It also
applies the supported base relocations.
For a remote process or a different random-access source, implement ReadAt. Then use one of
these functions:
PeImage::read_from(&reader, source_offset, SourceLayout::Mapped)
PeImage::read_mapped_from(&reader, source_offset, runtime_image_base)
PeHeaders::rva_to_source_offset does the same layout-aware translation. It does not read all
sections.
PeFile::normalize sets malformed header fields and the file layout to normal values:
- It moves NT headers that overlap the DOS header, and a section table that overlaps the optional header.
- It sets an unknown
Magic, invalid alignments,NumberOfRvaAndSizesand the reserved data directories to valid values. - It gives each section its own aligned raw range, and calculates
SizeOfHeadersand the size fields again. It raises aSizeOfImagethat is too small, but does not lower a larger one. It corrects a wrong nonzero checksum.
The loader view stays the same. The loader view is:
- the section RVAs, virtual sizes and characteristics;
- the section bytes that the loader maps, from the loader raw offset up to the
FileAlignment-aligned raw end, limited to the aligned virtual size; - the aligned header size, which is
SizeOfHeadersaligned to a validSectionAlignment; - the header bytes at the entry point and at each data directory. A data directory range starts at its RVA and ends at the first section, whatever its size;
- the header bytes that the followed structures refer to;
- the nonzero header bytes after the section table.
The followed structures are the structures that the loader reads through RVAs:
- the import descriptor array, up to and including the null descriptor;
- for each import descriptor, the DLL name with its NUL, and the
OriginalFirstThunkandFirstThunkarrays, up to and including the null thunk; - each hint/name entry: 2 bytes, the name and the NUL;
- the same items for the delay-import table;
- the TLS callback array, up to and including the null entry.
The method checks only these indirect references. If the import or delay-import table does not parse, the bytes from the RVA of that directory to the end of the address space are protected. The same occurs if the tables have more than 131,072 thunks in name tables at different RVAs.
The loader maps SizeOfHeaders header bytes, or the whole file with a low SectionAlignment.
The method builds each candidate with PeFile::try_build, parses the output again, and
compares it with the built original. It keeps the first candidate that keeps the loader view.
If no candidate does, the fields and the layout of the file stay. The checksum fix uses the
same check. The method does not use a candidate that would make a valid checksum wrong.
calculate_checksum matches the Windows CheckSumMappedFile function, also when the checksum
field is at an odd file offset. For each rejected fix, the method names the reason in
Normalization::kept:
HeaderBytesInUse: the candidate changes header bytes that the image refers to.SectionData: the candidate changes or drops section bytes.SectionAddresses: the candidate changes a section RVA, virtual size or characteristics, or the aligned header size.LowAlignment:SectionAlignmentis smaller than a page, so the file layout stays.Alignment:SectionAlignmentis invalid, and no valid value keeps all section RVAs.Layout(String): the method cannot make a new layout.Build(String): the candidate does not build or parse.Checksum: the checksum fix changes the loader view or does not build. A wrong checksum stays, or the method does not use the candidate that needs the fix.
In each candidate, the method sets the PointerToRawData file offset of each
IMAGE_DEBUG_DIRECTORY entry to the file offset of its AddressOfRawData. This also corrects
a stale field. changes reports this as NormalizeChange::DebugPointers. The method uses such
a candidate only if each changed field is in a section without IMAGE_SCN_CNT_CODE or
IMAGE_SCN_MEM_EXECUTE. Each field must also be outside the other data directories and
outside the followed structures. Otherwise, the debug fields and the layout stay, and kept
names SectionData.
An unedited build does not correct a stale debug field. A layout update moves each nonzero
PointerToRawData field by the distance that the file offset of its AddressOfRawData moves.
Thus a build that keeps the layout keeps the bytes of each field.
NormalizeChange::HeaderTail means that the section table grows into the header bytes after
it, and the build drops these bytes. The method allows this only for a bound import table. The
BoundImport data directory entry then becomes zero.
use portex::PeFile;
# let damaged: &[u8] = &[];
let mut file = PeFile::parse(damaged)?;
let result = file.normalize()?;
println!("changed: {:?}", result.changes);
println!("kept: {:?}", result.kept);
let normalized = file.try_build()?;
# Ok::<(), portex::Error>(())A change invalidates an Authenticode signature. The overlay and the certificate table stay.
PeFile::validate, PeFile::calculate_checksum, PeFile::update_checksum and
PeFile::finalize cover the complete file, with the overlay and the certificate table. The
PeImage methods with the same names cover only the image that PeImage::try_build makes.
nostdio supplies the Read, Write and Seek traits and the Cursor type. portex::io
re-exports them:
use portex::PeFile;
use portex::io::Cursor;
# let raw_file_bytes: &[u8] = &[];
let mut input = Cursor::new(raw_file_bytes);
let file = PeFile::from_stream(&mut input)?;
let mut output = Cursor::new(Vec::new());
file.write_to(&mut output)?;
# Ok::<(), portex::Error>(())Portex also has the positional ReadAt abstraction. ReadAt is better for these sources:
- process memory
- sparse address spaces
- sources with concurrent callers
- sources that must not share cursor state
SeekReader adapts a nostdio::Read + Seek source to ReadAt.
A patch tool rewrites the fields of a table in the file buffer. The raw module reads each
structure at a buffer offset and keeps the offset of each field. A cursor reads one record for
each call, so you can change the buffer between two calls:
use portex::raw::{ImportTableEnd, RawCursor, RawImportDescriptors};
use portex::{DataDirectoryType, PeHeaders};
# let mut file: Vec<u8> = Vec::new();
let headers = PeHeaders::from_slice_strict(&file)?;
// `NumberOfRvaAndSizes` can be too small to hold the entry. Then use an empty table.
let import = headers
.data_directory(DataDirectoryType::Import)
.copied()
.unwrap_or_default();
let offset = headers
.rva_to_offset(import.virtual_address)
.and_then(|offset| usize::try_from(offset).ok())
.unwrap_or(0);
let size = usize::try_from(import.size).unwrap_or(0);
let mut descriptors = RawImportDescriptors::new(offset, size, ImportTableEnd::NullDescriptor);
while let Some(descriptor) = descriptors.read_next(&file)? {
// Clear the time stamp of a bound import.
let field = descriptor.time_date_stamp.offset;
file[field..field + 4].fill(0);
}
# Ok::<(), portex::Error>(())The views take buffer offsets, not RVAs. Use your own rule to convert an RVA. The caller also
selects the end of each table, for example ImportTableEnd or the size of a table.
use portex::section::characteristics;
use portex::{MachineType, PeBuilder, Subsystem};
let image = PeBuilder::new()
.machine(MachineType::Amd64)
.subsystem(Subsystem::WindowsCui)
.entry_point(0x1000)
.add_section(
".text",
vec![0xcc; 256],
characteristics::CODE | characteristics::EXECUTE | characteristics::READ,
)
.try_build()?;
let raw_file = image.try_build()?;
assert!(!raw_file.is_empty());
# Ok::<(), portex::Error>(())Directory builders and image update methods are fallible. If the input or the edits are not
fully trusted, use the try_* variants.
The facade modules contain the usual entry points:
| Module | Contents |
|---|---|
image |
PeImage, PeFile, PeHeaders, parsing options, and PeBuilder |
headers |
DOS, COFF image, optional, section, and data-directory header types |
directories |
Imports, exports, resources, relocations, TLS, exceptions, debug, security, CLR hand-off, and related builders |
io |
nostdio traits/types plus positional ReadAt readers |
Specialized modules, for example import, resource, reloc, security and validation,
are also public. Use them to import a specific area.
Nested modules use the module.rs and module/child.rs layout. Portex does not use the older
module/mod.rs form.
mise manages the toolchain and the pre-commit hooks:
mise install
mise run precommit-install
mise run precommitThe hooks run rustfmt and Clippy on all targets of the crate. They do this with all features
and with no_std + alloc. They also lint the separate fuzz package.
CI runs the same checks. CI also runs the tests and rustdoc with both feature sets, builds for
the thumbv7em-none-eabi target without std, builds the fuzz targets, and runs
cargo-semver-checks.
Each package manifest denies warnings, the Clippy pedantic group, missing docs and unsafe code.
The only unsafe code is BaseAddressReader. Direct Cargo commands thus apply the same policy.
The Rust examples in this README compile as doctests.
The fuzz targets are in fuzz/. Run them with cargo-fuzz and a nightly toolchain. This works
on Linux, macOS and Windows (MSVC). There are four targets:
fuzz-pe-headersparses the input withPeHeaders::from_slice.fuzz-pe-parseparses the input as a rawPeImage, a mappedPeImageand aPeFile.fuzz-pe-imageruns the directory accessors, the validation, the builds, the mapping at the image base and at a new base,rebuild_supported_directoriesandnormalize. It also checks that an uneditedPeFileround trip returns the input byte for byte whenpreserves_source_gapsistrue.fuzz-raw-viewsdrives each view and cursor ofportex::raw. The last 64 bytes of the input select the offsets, sizes, end rules and entry widths. The target also comparesPeHeaders::from_slice_strictwithPeHeaders::from_slice, and reads each field at the offsets ofPeHeaders::header_offsets.
cargo +nightly fuzz run fuzz-pe-headers
cargo +nightly fuzz run fuzz-pe-image
cargo +nightly fuzz run fuzz-pe-parse
cargo +nightly fuzz run fuzz-raw-viewsOn Windows, put the directory of clang_rt.asan_dynamic-x86_64.dll from the Visual Studio
installation on PATH before you run a target. A Rust panic on Windows stops the process with
__fastfail, so libFuzzer does not write a crash file for it. The timeout and out-of-memory
files are not affected.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT License (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in Portex by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.