Skip to content

Commit 66e39ec

Browse files
committed
fix(pack): a garbage e_lfanew threw instead of answering "not a PE"
`identify()` is documented as never throwing and, for one input, terminated the process. `std::string_view::substr` throws `std::out_of_range` when `pos > size()`, and the position came straight out of the file: if (b.substr(*lfanew, 4) == "PE\0\0") A file that starts with "MZ" and has garbage at 0x3C is ordinary malformed input — a truncated download, a DOS stub, a text file named `.exe` — and `mcpp pack` calls this on every artifact it packages. Measured rather than argued: `std::string_view{256 bytes}.substr(0xFFFFFFFF, 4)` throws `string_view::substr` under libc++. Every other read in the module goes through a bounds-checked accessor; these two comparisons were the exceptions, which is exactly how a module whose stated contract is "total over nonsense" stopped being one. `has_at()` now does the same job with the same check as the rest. Found by re-reading the file after it was already merged and green — the existing truncation test never produced an `e_lfanew` PAST the end, only one exactly AT it, where `substr` is well-defined and returns empty.
1 parent add6317 commit 66e39ec

2 files changed

Lines changed: 36 additions & 2 deletions

File tree

src/pack/binfmt.cppm

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,19 @@ std::optional<std::uint64_t> le64(std::string_view b, std::size_t off) {
160160
return v;
161161
}
162162

163+
// Do the bytes at `off` equal `lit`?
164+
//
165+
// NOT `b.substr(off, n) == lit`, and the difference is a crash.
166+
// `std::string_view::substr` THROWS `std::out_of_range` when `pos > size()`,
167+
// and `off` here comes from a field READ OUT OF THE FILE — a file starting
168+
// with "MZ" whose `e_lfanew` is garbage is ordinary malformed input, not a
169+
// reason to terminate. This module's contract is that it is total over
170+
// nonsense; one unchecked `substr` was enough to break that promise.
171+
bool has_at(std::string_view b, std::size_t off, std::string_view lit) {
172+
if (off > b.size() || b.size() - off < lit.size()) return false;
173+
return b.compare(off, lit.size(), lit) == 0;
174+
}
175+
163176
// NUL-terminated string at `off`, bounded by the file end.
164177
std::optional<std::string> cstr(std::string_view b, std::size_t off) {
165178
if (off >= b.size()) return std::nullopt;
@@ -309,7 +322,7 @@ pe_needed(std::string_view b) {
309322
auto lfanew = le32(b, 0x3C);
310323
if (!lfanew) return std::unexpected("PE: no e_lfanew");
311324
const std::size_t nt = *lfanew;
312-
if (b.substr(nt, 4) != std::string_view("PE\0\0", 4))
325+
if (!has_at(b, nt, std::string_view("PE\0\0", 4)))
313326
return std::unexpected("PE: no PE\\0\\0 signature at e_lfanew");
314327

315328
auto numSections = le16(b, nt + 6);
@@ -445,7 +458,7 @@ Ident identify(const std::filesystem::path& binary) {
445458
// Saying "PE" for a file that has none would send the caller into a
446459
// parser that cannot succeed.
447460
if (auto lfanew = detail::le32(b, 0x3C)) {
448-
if (b.substr(*lfanew, 4) == std::string_view("PE\0\0", 4)) {
461+
if (detail::has_at(b, *lfanew, std::string_view("PE\0\0", 4))) {
449462
id.format = Format::Pe;
450463
if (auto m = detail::le16(b, *lfanew + 4))
451464
id.arch = detail::pe_arch(*m);

tests/unit/test_pack_binfmt.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,27 @@ TEST(PackBinfmt, ADosStubWithoutAPeSignatureIsNotAPe) {
324324
EXPECT_FALSE(bf::needed_names(f.path).has_value());
325325
}
326326

327+
TEST(PackBinfmt, AGarbageELfanewIsRejectedAndDoesNotThrow) {
328+
// An "MZ" file whose `e_lfanew` points past the end is ordinary malformed
329+
// input: a truncated download, a DOS stub, a text file named `.exe`.
330+
//
331+
// This crashed. `std::string_view::substr` THROWS `std::out_of_range` when
332+
// `pos > size()`, and the offset comes straight out of the file — so
333+
// `identify()`, which is documented as never throwing, terminated the
334+
// process instead of answering Unknown. Bounds-checked comparison now.
335+
for (std::uint32_t lfanew : {0xFFFFFFFFu, 0x7FFFFFFFu, 0x10000u, 0x101u}) {
336+
std::string b(0x100, '\0');
337+
b[0] = 'M'; b[1] = 'Z';
338+
put(b, 0x3C, lfanew, 4);
339+
TempFile f{"mzjunk", b};
340+
EXPECT_NO_THROW({
341+
EXPECT_EQ(bf::identify(f.path).format, bf::Format::Unknown)
342+
<< "e_lfanew=" << lfanew;
343+
EXPECT_FALSE(bf::needed_names(f.path).has_value());
344+
}) << "e_lfanew=" << lfanew;
345+
}
346+
}
347+
327348
TEST(PackBinfmt, TruncatedInputIsRejectedRatherThanRead) {
328349
// Malformed input is ordinary: a half-downloaded file, a text file named
329350
// `.exe`. Every read is bounds-checked, so the parser is total over it.

0 commit comments

Comments
 (0)