Timestomping a PE compile timestamp - adversary tradecraft and detection
How adversaries weaponise PE compile timestamps to deceive analysts and hide in plain sight.
Intro
If you’ve wondered how advanced adversaries (red teams or real threat actors) try blend into a target system or deceive CTI/IR analysts, this post shows how COFF timestomping (overwriting a PE’s compile timestamp) is used and why it matters, as well as offering advice for spotting this.
We will also discuss why this technique is of value to adversaries of higher sophistication, and why understanding these internals can enhance the accuracy and depth of SOC or DFIR assessments. What I want to demonstrate is - don’t always take forensic data at face value, because adversaries can and will deceive you…
In this blog post we look specifically at timestomping the compile timestamp that is imprinted in a Windows Portable Executable when it is compiled. This is a feature coming in v0.4 of the Wyrm C2 that I am building, it is already built on the dev branch and should be merged in the near future (early November 2025). Depending upon when you read this article, my Wyrm C2 may already have it on the stable branch!
Timestomping
To start, timestomping is the act of modifying timestamps of a file, artifact or metadata which is often done to hide changes to the system, or outright cause deniability. Mitre ATT&CK tracks this TTP as T1070.006 and it comes in a great variety of flavours - for example you can timestomp the time a file is written to disk or what time it was last modified.
Here however, we are talking about a far more subtle method of timestomping, and in fact, I myself was somewhat blinded by this during some research I did many years ago that I will come onto later.
Defensive actions
If you want a few methods to try detect what we discuss here:
- Cross-check COFF TimeDateStamp vs NTFS timestamps (MFT creation / modified times).
- Cross-check COFF TimeDateStamp vs IMAGE_DEBUG_DIRECTORY.TimeDateStamp and other in-PE timestamps (if present).
- Find improbable timestamps (e.g. epoch 0, year far in the past / future, or many unrelated samples with same timestamp).
- Detect signature invalidation - modifying the compile time of a signed binary will invalidate the signature.
- Detect repeated identical timestamps across many unrelated samples (bulk stomping artifacts).
- Correlate with VirusTotal / first seen / download timestamps - if COFF time is earlier than earliest observed submission by a long margin this may be suspicious.
- Detect impossible combinations - e.g., a binary compiled before the creation date of the toolkit / version that produced it, or compiled before the compiler itself was released.
PE Headers
In Windows Portable Executable (PE) files (exe, dll, sys etc.) you may be familiar with the header containing the ‘MZ’ magic bytes, aka 4D 5A.
Microsoft have defined the structure and layout of a PE file, such that when it is executed, it can be loaded by the kernel correctly, and equally, when modules are imported, the kernel / ntdll know how to map the image into memory. At a high level, it looks like so (image courtesy of https://onlyf8.com/pe-format):

The first few sections in the binary can be thought of a mix of contents pages and settings for the binary, most notably including:
- IMAGE_DOS_HEADER (now mostly legacy) ‘MZ’ signature, as well as the famous ‘This program cannot be run in DOS mode’. Whilst this structure has a fair number of fields, the most relevant to us is the e_lfanew, which is the address (offset) of the NT Headers.
- The NT Headers include the IMAGE_FILE_HEADER structure which contains some important fields:
- Machine: The architecture of the machine the image can run on (e.g. AMD64).
- NumberOfSections: How many sections the image contains (see a later bullet point for sections).
- TimeDateStamp: A 32 bit little-endian integer representing the time the image was created by the linker, the time is calculated based off of the number of seconds elapsed since 1st Jan 1970 - it is this field we will stomp to hinder defensive teams.
- Sections: There are a number of sections included in the binary - a few of which are:
- .text: This section represents the executable code.
- .data: This sections contains initialised data which the programmer produced in the code.
- .rdata: Read only data, including things such as metadata.
- .rsrc: Resource section containing information such as embedded certificates, other binaries, etc.
Ultimately, to achieve this method of timestomping, we need to parse these headers and edit the TimeDateStamp field, such that we can trick analysis teams.
Why
There are a few reasons why we may wish to do this; one such instance is from a cyber threat intelligence perspective. To tell somewhat of an anecdote, several years ago I was doing some research into the prevalence of leaked cobalt strike implants with seemingly legitimate serial numbers (embedded in the binary) vs ‘cracked’ versions which had a bad serial number. One metric I was trying to track was any correlations to the compilation date / time of the binaries, and try plot them as a function of time to derive any trends.
Eventually, I concluded that this was not a good approach as through the malleable profile for Cobalt Strike, the adversary has full control over being able to timestomp this field - making it difficult to draw any real conclusions without other data such as telemetry from VirusTotal.
By changing this field; the adversary can appear to be a legitimate system binary to the unsuspecting analyst, it is somewhat common to timestomp things such as date created / modified fields of the file, but if those pre-date the compilation date, a diligent analyst might spot the inconsistency. By changing this; an advanced threat actor anticipating a capable blue team can sow doubt in the assessment as to whether that file is malicious / linked to a known timeline for an incident.
Implementing COFF timestomping
Okay - enough background. Here we will look at my approach to implementing this for the Wyrm C2, which is my (fully) Rust based post-exploitation toolkit.
The binary gets built on the C2 based on configurable profile settings, and one such setting the operator can define is the timestamp for the
TimeDateStamp field. Starting at the very beginning of that - we want to ingest that field from the .toml profile. Thanks to the
serde and toml crates, this is really easy. I won’t show that here, I would urge you to have a look at the project if you are interested on
parsing data structures or toml.
Without turning this into a Rust simp session; one reason I love Rust for offensive security development is we get the low level control of C, with the high level ergonomics of modern languages (such as Go, JavaScript, Python - albeit with a harder learning curve). We can control raw memory and access the hardware on bare metal, work in the kernel, and also write fully functioning memory safe web servers.
So, on the C2, we check whether the user in their selected profile has added the timestomp field; I have decided this must be in british format, and must appear as so (the operator may of course alter the date and time - this is for examples sake):
timestomp = "08/04/2022 19:53:15"
The C2 has built the implant binary at this point as per the profile, and now the timestomping takes place. We have a function which takes a path
to the PE to stomp, as well as the datetime as a &str (a std::string_view if you like in C++).
I decided to bring in the thiserror crate to help with making this function ergonomic to write, which makes returning errors easier in an idiomatic way, allowing us to return multiple different error types from the function. I’ll put the error enum at the bottom of this section.
First, we try open the file and read it into a buffer that is of 2 kb, this should be plenty to ensure we capture the IMAGE_FILE_HEADER. We try read exactly 2kb into a buffer, if that fails - we return an error.
pub async fn timestomp_binary_compile_date(
dt_str: &str,
build_path: &Path,
) -> Result<(), TimestompError> {
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(build_path)
.await
.map_err(|e| TimestompError::FileOpen(e.to_string()))?;
//
// Read the first 2 kb of the binary into our buffer and grab the e_lfanew so we can offset to the
// TimeDateStamp field
//
const INITIAL_LEN: usize = 2000;
let mut buf = Vec::with_capacity(INITIAL_LEN);
unsafe { buf.set_len(INITIAL_LEN) };
if let Err(e) = file.read_exact(&mut buf).await {
return Err(TimestompError::FileRead(e.to_string()));
}
// ..
}
Now we have the 2kb in a vector (a heap allocated buffer). We need to get a pointer to the start of the buffer, and cast it as an IMAGE_DOS_HEADER so that we may start inspecting that memory. We also check at this point we start with the MZ magic bytes. If we do not start with that, we have not read a valid PE.
let p_dos_header = buf.as_ptr() as *const IMAGE_DOS_HEADER;
// SAFETY: We know this is not null
let dos_header = unsafe { &*(p_dos_header) };
if dos_header.e_magic != 0x5a4d {
return Err(TimestompError::MagicBytesMZ(dos_header.e_magic));
}
Next - we need to grab the e_lfanew which is the offset in the PE to the start of the NT header. We check that our buffer contains this
offset, plus the size of the IMAGE_NT_HEADERS64 structure in bytes.
if dos_header.e_lfanew as usize + size_of::<IMAGE_NT_HEADERS64>() > buf.len() {
return Err(TimestompError::BuffTooSmall);
}
And now we can run a helper function to convert the input &str to epoch time (time since 1st Jan 1970). Again, thanks to Rusts amazing ergonomics and community library support, this is easy using the chrono crate:
let timestamp = str_to_epoch(dt_str)?;
fn str_to_epoch(dt_str: &str) -> Result<u32, TimestompError> {
let datetime = match NaiveDateTime::parse_from_str(dt_str, "%d/%m/%Y %H:%M:%S") {
Ok(d) => d,
Err(_) => return Err(TimestompError::DTMismatch),
};
Ok(datetime.and_utc().timestamp() as u32)
}
Now, with the correctly formatted 32 bit timestamp, we can simply overwrite the timestamp in the file. We define a constant for the offset to this field calculated by doing some simple math over the below structure for the IMAGE_NT_HEADERS64 and IMAGE_FILE_HEADER structures, I show you how to count this in comments below:
#[repr(C)]
#[allow(non_snake_case, non_camel_case_types)]
pub struct IMAGE_NT_HEADERS64 {
pub Signature: u32, // 4 bytes
pub FileHeader: IMAGE_FILE_HEADER, // this is not a pointer, so the fields are embedded into IMAGE_NT_HEADERS64
// OptionalHeader omitted...
}
#[repr(C)]
#[allow(non_snake_case, non_camel_case_types)]
pub struct IMAGE_FILE_HEADER {
pub Machine: u16, // 2 bytes
pub NumberOfSections: u16, // 2 bytes
pub TimeDateStamp: u32, // This field therefore is at an offset of 8 bytes
pub PointerToSymbolTable: u32,
pub NumberOfSymbols: u32,
pub SizeOfOptionalHeader: u16,
pub Characteristics: IMAGE_FILE_CHARACTERISTICS,
}
// Now for overwriting the TimeDateStamp in the binary:
const OFFSET_TIMESTAMP: u64 = 8;
file.seek(SeekFrom::Start(
dos_header.e_lfanew as u64 + OFFSET_TIMESTAMP,
))
.await
.map_err(|e| TimestompError::FileWriteError(e.to_string()))?;
file.write_all(×tamp.to_le_bytes())
.await
.map_err(|e| TimestompError::FileWriteError(e.to_string()))?;
file.flush()
.await
.map_err(|e| TimestompError::FileWriteError(e.to_string()))?;
The error enum as mentioned:
#[derive(Error, Debug)]
pub enum TimestompError {
#[error("unable to open file, {0}")]
FileOpen(String),
#[error("unable to read buffer from file object, {0}")]
FileRead(String),
#[error("did not match on magic bytes, got: {0}")]
MagicBytesMZ(u16),
#[error("could not read file content, but not a file read error..")]
NoRead,
#[error("datetime was not formatted correctly, must be british formatting - %d/%m/%Y %H:%M:%S")]
DTMismatch,
#[error("the buffer was too small")]
BuffTooSmall,
#[error("could not write to file, {0}")]
FileWriteError(String),
}
Result
And now, as mentioned, if we open up the binary in a tool such as pe-bear, you can see the compiled datetime on the binary is that we added into our profile, 08/04/2022 19:53:15!

As a final comment, it is worth noting that building in debug mode will add some additional metadata in surrounding the time of compilation / linking, so, as ever - never build in debug and always ensure symbols are stripped!