From 0de9f4743cc961847a9867b4ea903e2578bab83e Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 23 Sep 2026 17:49:20 +0200 Subject: [PATCH 1/2] feat(report): recognise .NET executables that leave no runtime file beside them The .NET timing caution (runtime.dotnet_stopwatch_qpc) fired only when coreclr.dll or a .deps.json sat beside the target, or when the executable exported the runtime's own symbols. A framework-dependent single file, a .NET Framework executable and an application started as `dotnet app.dll` got no caution at all. pe.rs now answers is_dotnet_executable from one open of the file, by any of three marks: the runtime's exports (NativeAOT, self-contained single file) as before, the 32-byte bundle signature every apphost carries in .data (bundle_marker.c in dotnet/runtime, which dotnet publish never rewrites), and a CLR header behind data directory 14, which only a .NET Framework executable carries. The report also recognises dotnet.exe by name. The caution holds for all of them: the .NET Framework Stopwatch calls QueryPerformanceCounter as well, and the harness measures it real on x86. A NativeAOT build with debugger support turned off carries none of the marks and is still not recognised. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++- crates/cli/src/pe.rs | 280 ++++++++++++++++++++++++++++++++------- crates/cli/src/report.rs | 13 +- 3 files changed, 249 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4cdd60..fe84081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,15 @@ Notable changes to Chrono Mock, newest first. The format follows ### Added -- **The .NET timing caution now reaches applications that carry the runtime inside the - executable.** An application published as NativeAOT or as a self-contained single file has no - .NET runtime files beside it, and those files were how the session recognised .NET, so it never - got the caution that a `Stopwatch` timer does not follow the session speed. The session now also - looks for the marker the .NET runtime leaves inside the executable itself. Two builds carry no - such marker and are still not recognised: a framework-dependent single file, and a NativeAOT - application published with debugger support turned off. +- **The .NET timing caution now reaches .NET applications that leave no runtime files beside + them.** An application published as NativeAOT or as a single file (self-contained or not), a + .NET Framework application, whose runtime lives in the Windows directory, and one started as + `dotnet app.dll` have no .NET runtime files beside the executable. Those files were how the + session recognised .NET, so none of these got the caution that a `Stopwatch` timer does not follow + the session speed. The session now also reads the marks .NET leaves inside the executable itself - + the runtime's own exports, the signature every .NET launcher carries, and the header of a .NET + Framework program - and recognises the `dotnet` host by name. One build carries none of them and + is still not recognised: a NativeAOT application published with debugger support turned off. - **A caution when the application was built with Go.** The Go runtime reads the date straight from shared system memory rather than asking Windows for it, so a Go application sees the real date and nothing this tool can do will change that. The session time zone does reach it, which diff --git a/crates/cli/src/pe.rs b/crates/cli/src/pe.rs index fab3e92..7d8a3da 100644 --- a/crates/cli/src/pe.rs +++ b/crates/cli/src/pe.rs @@ -1,6 +1,6 @@ //! A bounded, read-only look inside the target's own executable, for the facts the file names around -//! it cannot give: whether the Go toolchain linked it, and whether it carries the .NET runtime inside -//! itself rather than in DLLs beside it. +//! it cannot give: whether the Go toolchain linked it, and whether it is a .NET executable that leaves +//! no runtime file beside it to say so. //! //! Every read is capped and every offset goes through `get`, so a truncated or hostile file answers //! "no" rather than panicking, and a half-gigabyte target costs a few kilobytes to ask. The file is @@ -70,13 +70,46 @@ const GO_DATA_WINDOW: usize = 64 * 1024; /// kept, because a list that only knew today's would go quiet on the next release. /// /// What this does NOT reach: a framework-dependent single-file executable exports nothing at all -/// (measured), and a .NET Framework executable carries no runtime of its own. +/// (measured), and a .NET Framework executable carries no runtime of its own. The apphost signature +/// and the CLR header below answer for those two. const DOTNET_RUNTIME_EXPORTS: &[&[u8]] = &[ b"DotNetRuntimeDebugHeader", b"DotNetRuntimeContractDescriptor", b"DotNetRuntimeInfo", ]; +/// The signature every .NET apphost carries in its bundle locator, the SHA-256 of ".net core bundle", +/// copied from the host's own source (`src/native/corehost/apphost/bundle_marker.c` in dotnet/runtime). +/// The locator is the eight-byte offset of a single-file bundle followed by these 32 bytes, and +/// `dotnet publish` only ever rewrites the offset, zero for a plain apphost. So the signature is there +/// in every apphost, single-file or not, and never in a binary no .NET host produced. +/// +/// Measured: at `.data+0x140` in a 3 072-byte section on three plain apphosts and on a +/// framework-dependent single-file one, at `.data+0x3D78` in a 20 992-byte section on a +/// self-contained single-file one, and absent from C, Go, Java, Python, Node, the command interpreter, +/// `dotnet.exe` itself and our own `chrono.exe`. +const APPHOST_BUNDLE_SIGNATURE: [u8; 32] = [ + 0x8b, 0x12, 0x02, 0xb9, 0x6a, 0x61, 0x20, 0x38, 0x72, 0x7b, 0x93, 0x02, 0x14, 0xd7, 0xa0, 0x32, 0x13, 0xf5, + 0xb9, 0xe6, 0xef, 0xae, 0x33, 0x18, 0xee, 0x3b, 0x2d, 0xce, 0x24, 0xb3, 0x6a, 0xae, +]; + +/// The bundle offset in front of the signature. Required, like Go's complete header: a signature with +/// no room for its locator in front of it is not one the host wrote. +const APPHOST_BUNDLE_OFFSET: usize = 8; + +/// How far into `.data` to look for the signature. The largest offset measured is 0x3D78, so sixty-four +/// kilobytes reads the whole section on everything measured, with four times that to spare. +const APPHOST_DATA_WINDOW: usize = 64 * 1024; + +/// Data directory entry 14, which the PE format calls the COM descriptor: where a managed image keeps +/// its CLR header. An executable carrying one is started by the .NET Framework runtime, because .NET +/// (Core) never builds its managed code into an `.exe` - it builds a `.dll` and a native apphost. +const CLR_DIRECTORY: usize = 14; + +/// The size of that header (`IMAGE_COR20_HEADER`), which its own first field, `cb`, repeats. Measured: +/// 72 on every .NET Framework executable we have, x64 and x86. +const CLR_HEADER: usize = 72; + /// Whether this executable was produced by the Go toolchain. /// /// 🔴 Why it is worth knowing: Go does not call a time export we can detour. Measured against the @@ -92,22 +125,29 @@ pub(crate) fn is_go_binary(target_path: &Path) -> bool { go_buildinfo_in_data(target_path).unwrap_or(false) } -/// Whether this executable carries the .NET runtime inside itself: NativeAOT, or a self-contained -/// single-file build. Neither leaves `coreclr.dll` or a `.deps.json` beside the executable, which is -/// all the file-name fingerprint can see, so without this such a target got no .NET caution at all. -pub(crate) fn embeds_dotnet_runtime(target_path: &Path) -> bool { - PeFile::open(target_path) - .and_then(|mut pe| pe.exports_any(DOTNET_RUNTIME_EXPORTS)) - .unwrap_or(false) +/// Whether this is a .NET executable that the files beside it may not give away, by any of the three +/// marks a .NET build leaves inside the file itself: +/// +/// - the runtime's own exports: NativeAOT, and a self-contained single-file build, +/// - the apphost's bundle signature: every apphost, including a framework-dependent single-file one, +/// which carries nothing else, +/// - a CLR header: a .NET Framework executable, whose runtime lives in the Windows directory. +/// +/// None of those leaves `coreclr.dll` or a `.deps.json` beside the executable, which is all the +/// file-name fingerprint can see, so without this such a target got no .NET caution at all. The file +/// is opened once and every doubt answers "no", like the Go fingerprint. +pub(crate) fn is_dotnet_executable(target_path: &Path) -> bool { + let Some(mut pe) = PeFile::open(target_path) else { + return false; + }; + pe.exports_any(DOTNET_RUNTIME_EXPORTS) == Some(true) + || pe.carries_apphost_signature() == Some(true) + || pe.has_clr_header() == Some(true) } -/// The walk `is_go_binary` wraps: the `.data` section header, then a window at its start. +/// The walk `is_go_binary` wraps: a window at the start of `.data`. fn go_buildinfo_in_data(target_path: &Path) -> Option { - let mut pe = PeFile::open(target_path)?; - let Some(data) = pe.section(b".data\0\0\0") else { - return Some(false); - }; - let bytes = pe.read_at(u64::from(data.raw_offset), (data.raw_size as usize).min(GO_DATA_WINDOW))?; + let bytes = PeFile::open(target_path)?.section_start(b".data\0\0\0", GO_DATA_WINDOW)?; let at = bytes.windows(GO_BUILDINFO_MAGIC.len()).position(|w| w == GO_BUILDINFO_MAGIC); Some(matches!(at, Some(start) if bytes.len() - start >= GO_BUILDINFO_HEADER)) } @@ -218,6 +258,34 @@ impl PeFile { Some(bytes) } + /// Up to `window` bytes from the start of the named section, or `None` when there is no such + /// section or it cannot be read. + fn section_start(&mut self, name: &[u8; 8], window: usize) -> Option> { + let section = self.section(name)?; + self.read_at(u64::from(section.raw_offset), (section.raw_size as usize).min(window)) + } + + /// Whether `.data` holds the apphost's bundle locator: the signature with room for the offset in + /// front of it. Searched only past that room, so a signature sitting at the very start of the + /// section, where no locator could be, does not count. + fn carries_apphost_signature(&mut self) -> Option { + let bytes = self.section_start(b".data\0\0\0", APPHOST_DATA_WINDOW)?; + let searched = bytes.get(APPHOST_BUNDLE_OFFSET..)?; + Some(searched.windows(APPHOST_BUNDLE_SIGNATURE.len()).any(|w| w == APPHOST_BUNDLE_SIGNATURE)) + } + + /// Whether data directory 14 points at a CLR header the file really holds: a whole one, inside a + /// section, whose own size field says it is at least as large as the format defines it. A stray + /// directory entry pointing nowhere, or at something shorter, is not a managed image. + fn has_clr_header(&mut self) -> Option { + let Some((rva, _)) = self.directory(CLR_DIRECTORY).filter(|&(rva, size)| rva != 0 && size != 0) else { + return Some(false); + }; + let at = self.offset_of(rva)?; + let header = self.read_at(at, CLR_HEADER)?; + Some(header.len() == CLR_HEADER && u32_at(&header, 0)? as usize >= CLR_HEADER) + } + /// Whether the export name table holds any of `wanted`, compared whole and case-sensitively, the /// way the loader compares them. One read of the export block, then everything is resolved inside /// it: a name pointing outside the block does not match, and a pointer table running out of it @@ -267,12 +335,13 @@ mod tests { /// A PE just real enough to be walked: DOS stub with the offset at 0x3C, the signature, a COFF /// header naming one section, an optional header of the requested kind, and a section whose raw - /// bytes are ours to fill. `export` points data directory zero at a range of that section. + /// bytes are ours to fill. `directory` points one data directory entry, (index, RVA, size), at a + /// range of that section. /// /// Built here rather than pointing at a Go or .NET binary on this machine, and that is the whole /// point: the probe binaries live outside the repository, so a test that read one would pass here /// and fail on every clean runner - the exact shape that kept CI red for four pushes once already. - fn synthetic_pe(magic: u16, section_name: &[u8; 8], data: &[u8], export: Option<(u32, u32)>) -> Vec { + fn synthetic_pe(magic: u16, section_name: &[u8; 8], data: &[u8], directory: Option<(usize, u32, u32)>) -> Vec { let pe_at: usize = 0x80; let optional_size: usize = if magic == 0x20B { 240 } else { 224 }; let (count_at, table_at) = if magic == 0x20B { (108, 112) } else { (92, 96) }; @@ -286,9 +355,10 @@ mod tests { bytes[pe_at + 20..pe_at + 22].copy_from_slice(&(optional_size as u16).to_le_bytes()); bytes[optional..optional + 2].copy_from_slice(&magic.to_le_bytes()); bytes[optional + count_at..optional + count_at + 4].copy_from_slice(&16u32.to_le_bytes()); - if let Some((rva, size)) = export { - bytes[optional + table_at..optional + table_at + 4].copy_from_slice(&rva.to_le_bytes()); - bytes[optional + table_at + 4..optional + table_at + 8].copy_from_slice(&size.to_le_bytes()); + if let Some((index, rva, size)) = directory { + let entry = optional + table_at + index * DATA_DIRECTORY_ENTRY; + bytes[entry..entry + 4].copy_from_slice(&rva.to_le_bytes()); + bytes[entry + 4..entry + 8].copy_from_slice(&size.to_le_bytes()); } bytes[table..table + 8].copy_from_slice(section_name); bytes[table + 8..table + 12].copy_from_slice(&(data.len() as u32).to_le_bytes()); @@ -322,7 +392,24 @@ mod tests { /// telling the truth about the block. fn exporting(magic: u16, names: &[&[u8]]) -> Vec { let block = export_block(names); - synthetic_pe(magic, b".rdata\0\0", &block, Some((RVA, block.len() as u32))) + synthetic_pe(magic, b".rdata\0\0", &block, Some((EXPORT_DIRECTORY, RVA, block.len() as u32))) + } + + /// The `.data` bytes of an apphost: some ordinary data, the zeroed bundle offset, the signature, + /// more data, the way the linker lays the locator out among the host's other globals. + fn apphost_data(signature: &[u8]) -> Vec { + let mut data = vec![0x5Au8; 0x138]; + data.extend_from_slice(&[0u8; APPHOST_BUNDLE_OFFSET]); + data.extend_from_slice(signature); + data.extend_from_slice(&[0x5Au8; 64]); + data + } + + /// A managed executable: a CLR header whose size field is `cb`, pointed at by directory 14. + fn managed(magic: u16, cb: u32) -> Vec { + let mut header = vec![0u8; CLR_HEADER]; + header[..4].copy_from_slice(&cb.to_le_bytes()); + synthetic_pe(magic, b".text\0\0\0", &header, Some((CLR_DIRECTORY, RVA, CLR_HEADER as u32))) } /// A fixture file under a name no other test process can share. A fixed name would let two @@ -397,7 +484,7 @@ mod tests { for (flavour, magic) in [("pe32plus", 0x20Bu16), ("pe32", 0x10B)] { for &name in DOTNET_RUNTIME_EXPORTS { let alone = write_probe(&format!("{flavour}-alone"), &exporting(magic, &[name])); - assert!(embeds_dotnet_runtime(&alone), "{flavour}: {} alone", String::from_utf8_lossy(name)); + assert!(is_dotnet_executable(&alone), "{flavour}: {} alone", String::from_utf8_lossy(name)); let _ = std::fs::remove_file(alone); } @@ -405,18 +492,19 @@ mod tests { // is neither first nor last. let crowd = exporting(magic, &[b"BrotliDecoderCreateInstance", b"DotNetRuntimeInfo", b"g_dacTable"]); let crowd = write_probe(&format!("{flavour}-crowd"), &crowd); - assert!(embeds_dotnet_runtime(&crowd), "{flavour}: found in the middle of the table"); + assert!(is_dotnet_executable(&crowd), "{flavour}: found in the middle of the table"); // A different export, a prefix of ours, and ours with different case: none is the runtime. let other = exporting(magic, &[b"DotNetRuntime", b"dotnetruntimeinfo", b"DotNetRuntimeInfoX"]); let other = write_probe(&format!("{flavour}-other"), &other); - assert!(!embeds_dotnet_runtime(&other), "{flavour}: a near miss is not a match"); + assert!(!is_dotnet_executable(&other), "{flavour}: a near miss is not a match"); - // No export directory at all, which is what a plain native executable and a - // framework-dependent single-file one both look like. + // No export directory at all, which is what a plain native executable looks like. A + // framework-dependent single-file one looks the same here and is recognised by its apphost + // signature instead, which this fixture does not carry. let none = synthetic_pe(magic, b".rdata\0\0", &export_block(&[b"DotNetRuntimeInfo"]), None); let none = write_probe(&format!("{flavour}-none"), &none); - assert!(!embeds_dotnet_runtime(&none), "{flavour}: names without a directory pointing at them"); + assert!(!is_dotnet_executable(&none), "{flavour}: names without a directory pointing at them"); for p in [crowd, other, none] { let _ = std::fs::remove_file(p); @@ -434,57 +522,153 @@ mod tests { let table_at = 0x80 + 24 + 112; nowhere[table_at..table_at + 4].copy_from_slice(&0x9000_0000u32.to_le_bytes()); let nowhere = write_probe("nowhere", &nowhere); - assert!(!embeds_dotnet_runtime(&nowhere), "a directory no section holds"); + assert!(!is_dotnet_executable(&nowhere), "a directory no section holds"); let mut short = good.clone(); short[table_at + 4..table_at + 8].copy_from_slice(&20u32.to_le_bytes()); let short = write_probe("short", &short); - assert!(!embeds_dotnet_runtime(&short), "a size that does not even cover the directory"); + assert!(!is_dotnet_executable(&short), "a size that does not even cover the directory"); let cut = write_probe("cut", &good[..good.len() - 1]); - assert!(!embeds_dotnet_runtime(&cut), "the terminator of the only name is missing"); + assert!(!is_dotnet_executable(&cut), "the terminator of the only name is missing"); let headers = write_probe("headers", &good[..0x200]); - assert!(!embeds_dotnet_runtime(&headers), "headers without the section they describe"); + assert!(!is_dotnet_executable(&headers), "headers without the section they describe"); let mut unknown = good.clone(); unknown[0x80 + 24..0x80 + 26].copy_from_slice(&0x107u16.to_le_bytes()); let unknown = write_probe("unknown", &unknown); - assert!(!embeds_dotnet_runtime(&unknown), "a ROM image magic has no directory table we know"); + assert!(!is_dotnet_executable(&unknown), "a ROM image magic has no directory table we know"); - assert!(!embeds_dotnet_runtime(Path::new("no such file anywhere.exe"))); + assert!(!is_dotnet_executable(Path::new("no such file anywhere.exe"))); // And the untouched original still answers yes, so the five above failed for their damage // and not because the fixture never worked. let good = write_probe("good", &good); - assert!(embeds_dotnet_runtime(&good)); + assert!(is_dotnet_executable(&good)); for p in [nowhere, short, cut, headers, unknown, good] { let _ = std::fs::remove_file(p); } } - /// The fingerprint is only worth something if it reaches the report. This goes through the same - /// entry the session uses, so removing either line in `fingerprint_target` turns it red. + /// The reversal probe for the apphost fingerprint, in both directions and both PE flavours: the + /// signature counts in `.data` with its locator room in front of it, and nowhere else. #[test] - fn both_fingerprints_reach_the_runtime_warnings() { - let dir = crate::testutil::unique_temp_dir("chrono-pe-wiring"); - std::fs::create_dir_all(&dir).expect("probe dir"); - - let aot = dir.join("Aot.exe"); - std::fs::write(&aot, exporting(0x20B, &[b"DotNetRuntimeDebugHeader"])).expect("probe file"); - let keys = crate::report::detect_runtime_warnings(&aot, false); - assert_eq!(keys, vec!["runtime.dotnet_stopwatch_qpc".to_string()]); + fn an_apphost_is_recognised_by_its_bundle_signature_in_data_and_only_there() { + for (flavour, magic) in [("pe32plus", 0x20Bu16), ("pe32", 0x10B)] { + let with = |section: &[u8; 8], data: &[u8]| synthetic_pe(magic, section, data, None); + + let host = write_probe(&format!("{flavour}-apphost"), &with(b".data\0\0\0", &apphost_data(&APPHOST_BUNDLE_SIGNATURE))); + assert!(is_dotnet_executable(&host), "{flavour}: the signature where every apphost carries it"); + + // The same bytes in another section. The host keeps the locator in writable data, and a + // match anywhere else would be reading the whole file for a coincidence. + let elsewhere = write_probe(&format!("{flavour}-rdata"), &with(b".rdata\0\0", &apphost_data(&APPHOST_BUNDLE_SIGNATURE))); + assert!(!is_dotnet_executable(&elsewhere), "{flavour}: read .data, not whatever section holds the bytes"); + + // One byte off, the way a near miss looks. + let mut near = APPHOST_BUNDLE_SIGNATURE; + near[31] ^= 0x01; + let near = write_probe(&format!("{flavour}-near"), &with(b".data\0\0\0", &apphost_data(&near))); + assert!(!is_dotnet_executable(&near), "{flavour}: a signature with one byte changed is not the signature"); + + // The signature at the very start of the section, where no offset could sit in front of it. + let bare = write_probe(&format!("{flavour}-bare"), &with(b".data\0\0\0", &APPHOST_BUNDLE_SIGNATURE)); + assert!(!is_dotnet_executable(&bare), "{flavour}: no room for the locator, so not a locator"); + + for p in [host, elsewhere, near, bare] { + let _ = std::fs::remove_file(p); + } + } + } + + /// The reversal probe for the CLR header, in both directions and both PE flavours: directory 14 has + /// to point at a whole header the file holds, and nothing less counts as a managed executable. + #[test] + fn a_clr_header_is_recognised_only_when_the_file_really_holds_one() { + for (flavour, magic) in [("pe32plus", 0x20Bu16), ("pe32", 0x10B)] { + let managed_exe = write_probe(&format!("{flavour}-managed"), &managed(magic, CLR_HEADER as u32)); + assert!(is_dotnet_executable(&managed_exe), "{flavour}: a whole CLR header behind directory 14"); + + // The same header bytes with directory 14 left empty: a native executable that happens to + // carry them. + let header = managed(magic, CLR_HEADER as u32)[RAW..].to_vec(); + let native = write_probe(&format!("{flavour}-native"), &synthetic_pe(magic, b".text\0\0\0", &header, None)); + assert!(!is_dotnet_executable(&native), "{flavour}: no directory entry, no managed image"); + + // A size field smaller than the format defines. + let small = write_probe(&format!("{flavour}-small"), &managed(magic, 40)); + assert!(!is_dotnet_executable(&small), "{flavour}: a header claiming 40 bytes is not a CLR header"); + + // The directory pointing past every section. + let mut lost = managed(magic, CLR_HEADER as u32); + let table_at = 0x80 + 24 + (if magic == 0x20B { 112 } else { 96 }) + CLR_DIRECTORY * DATA_DIRECTORY_ENTRY; + lost[table_at..table_at + 4].copy_from_slice(&0x9000_0000u32.to_le_bytes()); + let lost = write_probe(&format!("{flavour}-lost"), &lost); + assert!(!is_dotnet_executable(&lost), "{flavour}: a directory no section holds"); + + // A header cut short by the end of the file. + let whole = managed(magic, CLR_HEADER as u32); + let cut = write_probe(&format!("{flavour}-cut"), &whole[..whole.len() - 8]); + assert!(!is_dotnet_executable(&cut), "{flavour}: a header the file does not hold in full"); + + // A table that declares fewer directories than fifteen has no entry 14 at all. + let mut few = managed(magic, CLR_HEADER as u32); + let count_at = 0x80 + 24 + (if magic == 0x20B { 108 } else { 92 }); + few[count_at..count_at + 4].copy_from_slice(&14u32.to_le_bytes()); + let few = write_probe(&format!("{flavour}-few"), &few); + assert!(!is_dotnet_executable(&few), "{flavour}: entry 14 past the declared table"); + + for p in [managed_exe, native, small, lost, cut, few] { + let _ = std::fs::remove_file(p); + } + } + } + + /// The fingerprints are only worth something if they reach the report. This goes through the same + /// entry the session uses, so removing any branch in `fingerprint_target` or `is_dotnet_executable` + /// turns it red. Each target sits in its own directory, because the report also reads the file + /// names beside a target and one fixture must not answer for another. + #[test] + fn every_fingerprint_reaches_the_runtime_warnings() { + let root = crate::testutil::unique_temp_dir("chrono-pe-wiring"); + let probe = |name: &str, file: &str, bytes: &[u8]| { + let dir = root.join(name); + std::fs::create_dir_all(&dir).expect("probe dir"); + let path = dir.join(file); + std::fs::write(&path, bytes).expect("probe file"); + path + }; + let dotnet = vec!["runtime.dotnet_stopwatch_qpc".to_string()]; + + let aot = probe("aot", "Aot.exe", &exporting(0x20B, &[b"DotNetRuntimeDebugHeader"])); + assert_eq!(crate::report::detect_runtime_warnings(&aot, false), dotnet, "NativeAOT"); // Under --scale-qpc the Stopwatch axis DOES scale, so the caution must give way like every // other member of its family. let keys = crate::report::detect_runtime_warnings(&aot, true); assert_eq!(keys, vec!["qpc.scaled_render_may_distort".to_string()]); - let go = dir.join("Go.exe"); - std::fs::write(&go, synthetic_pe(0, b".data\0\0\0", &go_blob(), None)).expect("probe file"); + let single = synthetic_pe(0x20B, b".data\0\0\0", &apphost_data(&APPHOST_BUNDLE_SIGNATURE), None); + let host = probe("apphost", "Single.exe", &single); + assert_eq!(crate::report::detect_runtime_warnings(&host, false), dotnet, "an apphost with nothing beside it"); + + let framework = probe("framework", "Framework.exe", &managed(0x10B, CLR_HEADER as u32)); + assert_eq!(crate::report::detect_runtime_warnings(&framework, false), dotnet, ".NET Framework"); + + // The .NET host by name alone: `dotnet app.dll` names the application only in its arguments. + let muxer = probe("muxer", "dotnet.exe", b"not a PE, the name is the evidence"); + assert_eq!(crate::report::detect_runtime_warnings(&muxer, false), dotnet, "dotnet.exe"); + + let go = probe("go", "Go.exe", &synthetic_pe(0, b".data\0\0\0", &go_blob(), None)); let keys = crate::report::detect_runtime_warnings(&go, false); assert_eq!(keys, vec!["runtime.go_wall_clock_unreachable".to_string()]); - let _ = std::fs::remove_dir_all(dir); + // And a plain executable next to nothing gets nothing, so the five above are not the report + // saying the same thing about every file. + let plain = probe("plain", "Plain.exe", &synthetic_pe(0x20B, b".data\0\0\0", b"ordinary data", None)); + assert!(crate::report::detect_runtime_warnings(&plain, false).is_empty(), "a plain executable"); + + let _ = std::fs::remove_dir_all(root); } } diff --git a/crates/cli/src/report.rs b/crates/cli/src/report.rs index 8eb2746..55cfc32 100644 --- a/crates/cli/src/report.rs +++ b/crates/cli/src/report.rs @@ -454,18 +454,21 @@ fn fingerprint_target(target_path: &std::path::Path) -> Vec { add(&mut keys, "runtime.go_wall_clock_unreachable"); } - // A .NET application that carries its runtime inside the executable (NativeAOT, self-contained - // single-file) leaves no DLL beside it for the loop below to find, yet its Stopwatch reads QPC - // exactly like the one in `coreclr.dll` - the class lives in the shared part of CoreLib. - if crate::pe::embeds_dotnet_runtime(target_path) { + // A .NET executable that leaves no runtime file beside it for the loop below to find: NativeAOT, + // any apphost including a framework-dependent single file, and .NET Framework, whose runtime lives + // in the Windows directory. Its Stopwatch reads QPC all the same - in .NET the class lives in the + // shared part of CoreLib, and in .NET Framework it calls QueryPerformanceCounter directly. + if crate::pe::is_dotnet_executable(target_path) { add(&mut keys, "runtime.dotnet_stopwatch_qpc"); } - // The target executable's own name is a strong signal - a plain interpreter launcher. + // The target executable's own name is a strong signal - a plain interpreter launcher, or the .NET + // host that runs `dotnet app.dll` with the shared runtime, which sits nowhere near it on disk. if let Some(name) = target_path.file_name().and_then(|n| n.to_str()) { match name.to_ascii_lowercase().as_str() { "python.exe" | "pythonw.exe" => add(&mut keys, "runtime.python_perfcounter_qpc"), "java.exe" | "javaw.exe" => add(&mut keys, "runtime.java_nanotime_qpc"), + "dotnet.exe" => add(&mut keys, "runtime.dotnet_stopwatch_qpc"), _ => {} } } From 26ddb9a05c1d44cb6b06f8dd570107027a9161f2 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 23 Sep 2026 18:00:44 +0200 Subject: [PATCH 2/2] fix(report): accept a CLR header only when its whole range is in the file has_clr_header took any non-zero size in data directory 14 and read 72 bytes from an address only its first byte of which was checked against the section. A directory declaring one byte, or a header running past the end of its section into bytes the section does not own, could still be read as a .NET Framework executable. The directory now has to declare at least a whole header, and offset_of takes a length and answers only when every byte of the range lies inside one section on disk. The export reader keeps its old bound (the first byte), because its block is limited by its own size field and the window. Both cases are new assertions, measured red before the fix, one each. Co-Authored-By: Claude Opus 5.5 --- crates/cli/src/pe.rs | 49 +++++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/crates/cli/src/pe.rs b/crates/cli/src/pe.rs index 7d8a3da..befdbc1 100644 --- a/crates/cli/src/pe.rs +++ b/crates/cli/src/pe.rs @@ -241,13 +241,15 @@ impl PeFile { Some((u32_at(&self.head, entry)?, u32_at(&self.head, entry.checked_add(4)?)?)) } - /// The file offset behind a relative virtual address, through the section that holds it. Only - /// bytes that are really in the file count: the zero-filled tail a section has in memory has no - /// place on disk to read. - fn offset_of(&self, rva: u32) -> Option { + /// The file offset behind `len` bytes starting at a relative virtual address, through the section + /// that holds them ALL on disk. Only bytes that are really in the file count: the zero-filled tail + /// a section has in memory has no place on disk to read, and bytes past the section's raw size + /// belong to whatever the file keeps next, not to this section. + fn offset_of(&self, rva: u32, len: u32) -> Option { self.sections().find_map(|section| { let delta = rva.checked_sub(section.virtual_address)?; - (delta < section.raw_size).then(|| u64::from(section.raw_offset) + u64::from(delta)) + let end = delta.checked_add(len)?; + (len > 0 && end <= section.raw_size).then(|| u64::from(section.raw_offset) + u64::from(delta)) }) } @@ -274,14 +276,15 @@ impl PeFile { Some(searched.windows(APPHOST_BUNDLE_SIGNATURE.len()).any(|w| w == APPHOST_BUNDLE_SIGNATURE)) } - /// Whether data directory 14 points at a CLR header the file really holds: a whole one, inside a - /// section, whose own size field says it is at least as large as the format defines it. A stray - /// directory entry pointing nowhere, or at something shorter, is not a managed image. + /// Whether data directory 14 points at a CLR header the file really holds: a directory declaring a + /// whole one, all of it inside one section on disk, and the header's own size field saying it is + /// at least as large as the format defines it. A stray entry pointing nowhere, declaring less, or + /// at bytes running out of their section, is not a managed image. fn has_clr_header(&mut self) -> Option { - let Some((rva, _)) = self.directory(CLR_DIRECTORY).filter(|&(rva, size)| rva != 0 && size != 0) else { + let Some((rva, _)) = self.directory(CLR_DIRECTORY).filter(|&(rva, size)| rva != 0 && size as usize >= CLR_HEADER) else { return Some(false); }; - let at = self.offset_of(rva)?; + let at = self.offset_of(rva, CLR_HEADER as u32)?; let header = self.read_at(at, CLR_HEADER)?; Some(header.len() == CLR_HEADER && u32_at(&header, 0)? as usize >= CLR_HEADER) } @@ -294,7 +297,9 @@ impl PeFile { let Some((rva, size)) = self.directory(EXPORT_DIRECTORY).filter(|&(rva, _)| rva != 0) else { return Some(false); }; - let at = self.offset_of(rva)?; + // Only the first byte has to be in a section here: the block is bounded by its own size field + // and the window, and every name is resolved inside what was read. + let at = self.offset_of(rva, 1)?; let block = self.read_at(at, (size as usize).min(EXPORT_WINDOW))?; let count = u32_at(&block, 24)? as usize; let names = u32_at(&block, 32)?.checked_sub(rva)? as usize; @@ -603,11 +608,27 @@ mod tests { // The directory pointing past every section. let mut lost = managed(magic, CLR_HEADER as u32); - let table_at = 0x80 + 24 + (if magic == 0x20B { 112 } else { 96 }) + CLR_DIRECTORY * DATA_DIRECTORY_ENTRY; - lost[table_at..table_at + 4].copy_from_slice(&0x9000_0000u32.to_le_bytes()); + let entry = 0x80 + 24 + (if magic == 0x20B { 112 } else { 96 }) + CLR_DIRECTORY * DATA_DIRECTORY_ENTRY; + lost[entry..entry + 4].copy_from_slice(&0x9000_0000u32.to_le_bytes()); let lost = write_probe(&format!("{flavour}-lost"), &lost); assert!(!is_dotnet_executable(&lost), "{flavour}: a directory no section holds"); + // A directory that declares less than a whole header, pointing at one that is whole. + let mut tiny = managed(magic, CLR_HEADER as u32); + tiny[entry + 4..entry + 8].copy_from_slice(&1u32.to_le_bytes()); + let tiny = write_probe(&format!("{flavour}-tiny"), &tiny); + assert!(!is_dotnet_executable(&tiny), "{flavour}: a directory declaring one byte"); + + // A header that starts inside the section and runs past its end on disk, into bytes the + // section does not own. The file holds all 72, so only the section's bound refuses it. + let mut data = vec![0u8; 8]; + data.extend_from_slice(&(CLR_HEADER as u32).to_le_bytes()); + data.resize(CLR_HEADER, 0); + let mut straddle = synthetic_pe(magic, b".text\0\0\0", &data, Some((CLR_DIRECTORY, RVA + 8, CLR_HEADER as u32))); + straddle.extend_from_slice(&[0u8; 16]); + let straddle = write_probe(&format!("{flavour}-straddle"), &straddle); + assert!(!is_dotnet_executable(&straddle), "{flavour}: a header running past the end of its section"); + // A header cut short by the end of the file. let whole = managed(magic, CLR_HEADER as u32); let cut = write_probe(&format!("{flavour}-cut"), &whole[..whole.len() - 8]); @@ -620,7 +641,7 @@ mod tests { let few = write_probe(&format!("{flavour}-few"), &few); assert!(!is_dotnet_executable(&few), "{flavour}: entry 14 past the declared table"); - for p in [managed_exe, native, small, lost, cut, few] { + for p in [managed_exe, native, small, lost, tiny, straddle, cut, few] { let _ = std::fs::remove_file(p); } }