diff --git a/apps/uab/header/src/light_elf.h b/apps/uab/header/src/light_elf.h index 517a8d5a2..bb870843b 100644 --- a/apps/uab/header/src/light_elf.h +++ b/apps/uab/header/src/light_elf.h @@ -9,12 +9,15 @@ #include #include +#include #include #include #include +#include #include #include +#include #include namespace lightElf { @@ -160,22 +163,50 @@ class Elf [[nodiscard]] std::optional getSectionHeader(const std::string &name) const { + // Everything used below comes from the file being inspected: for a .uab the + // caller is the untrusted bundle itself, so e_shentsize/e_shnum/e_shoff and + // every sh_name must be treated as attacker controlled input. + // + // The section header table is only well formed when each entry is exactly as + // large as SectionHeader. A tampered e_shentsize used to be handed straight + // to pread, so a value larger than sizeof(SectionHeader) wrote past the end of + // the on-stack `shdr` below and a smaller value left it only partially filled. + // Reject anything else instead of reading, which keeps the stack object safe. + if (header.e_shentsize != section_size) { + return std::nullopt; + } + + // The table referenced by e_shoff/e_shnum has to fit inside the file, otherwise + // the entries the loop walks do not exist and reads would return short data. + if (!sectionTableInsideFile()) { + return std::nullopt; + } + SectionHeader shdr; auto offset = header.e_shoff; - const auto *data = rawSectionNames.data(); for (auto index = 0; index < header.e_shnum; ++index) { - if (::pread(fd, &shdr, header.e_shentsize, offset) == -1) { + // Read exactly one SectionHeader, never header.e_shentsize bytes: the two + // are equal at this point, but pinning the read to section_size keeps the + // stack buffer safe even if the validation above is ever reordered. + const auto bytesRead = ::pread(fd, &shdr, section_size, offset); + if (bytesRead == -1) { throw std::runtime_error("failed to read section header of" + name + ": " + ::strerror(errno)); } + if (bytesRead != static_cast(section_size)) { + return std::nullopt; + } - auto curName = std::string_view(data + shdr.sh_name); - if (!curName.empty() && curName == name) { + // sh_name is an offset into the section-name string table. A malformed + // entry is skipped rather than matched, so a single corrupt offset cannot + // make the string_view scan past the end of rawSectionNames. + const auto curName = sectionName(shdr); + if (curName && !curName->empty() && *curName == name) { return shdr; } - offset += header.e_shentsize; + offset += section_size; } return std::nullopt; @@ -183,6 +214,59 @@ class Elf private: constexpr static auto section_size = sizeof(SectionHeader); + + // Returns the size of the file that fd refers to, or std::nullopt when it cannot be + // determined. Needed to prove the section header table lies inside the file before + // any of it is read. + [[nodiscard]] std::optional fileSize() const noexcept + { + struct stat st{}; + if (::fstat(fd, &st) == -1) { + return std::nullopt; + } + + return static_cast(st.st_size); + } + + // Checks e_shoff + e_shnum * sizeof(SectionHeader) <= file size without letting the + // untrusted e_shoff/e_shnum overflow the computation. + [[nodiscard]] bool sectionTableInsideFile() const noexcept + { + const auto size = fileSize(); + if (!size) { + return false; + } + + const auto tableOffset = static_cast(header.e_shoff); + if (tableOffset > *size) { + return false; + } + + const auto tableSize = static_cast(header.e_shnum) * section_size; + return tableSize <= *size - tableOffset; + } + + // Resolves sh_name into a bounded view of the section-name string table. The view + // must not be built from a raw pointer into the table: an out-of-range sh_name or a + // table that is not NUL terminated at sh_name would make the length scan read past + // rawSectionNames. Returns std::nullopt for such malformed entries. + [[nodiscard]] std::optional + sectionName(const SectionHeader &shdr) const noexcept + { + if (shdr.sh_name >= rawSectionNames.size()) { + return std::nullopt; + } + + const auto *begin = rawSectionNames.data() + shdr.sh_name; + const auto remaining = rawSectionNames.size() - shdr.sh_name; + const auto *terminator = static_cast(std::memchr(begin, '\0', remaining)); + if (terminator == nullptr) { + return std::nullopt; + } + + return std::string_view{ begin, static_cast(terminator - begin) }; + } + constexpr static auto header_size = sizeof(ElfHeader); std::vector rawSectionNames; diff --git a/libs/linglong/tests/ll-tests/CMakeLists.txt b/libs/linglong/tests/ll-tests/CMakeLists.txt index 58e11583b..5821726ab 100644 --- a/libs/linglong/tests/ll-tests/CMakeLists.txt +++ b/libs/linglong/tests/ll-tests/CMakeLists.txt @@ -39,6 +39,7 @@ pfl_add_executable( src/linglong/package/fallback_version_test.cpp src/linglong/package/layer_dir_test.cpp src/linglong/package/layer_packager_test.cpp + src/linglong/package/light_elf_test.cpp src/linglong/package_manager/action_test.cpp src/linglong/package_manager/data_monitor_test.cpp src/linglong/package_manager/task_test.cpp diff --git a/libs/linglong/tests/ll-tests/src/linglong/package/light_elf_test.cpp b/libs/linglong/tests/ll-tests/src/linglong/package/light_elf_test.cpp new file mode 100644 index 000000000..ae77d9c6c --- /dev/null +++ b/libs/linglong/tests/ll-tests/src/linglong/package/light_elf_test.cpp @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include + +#include "light_elf.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using NativeElf = lightElf::native_elf; +using ElfHeader = NativeElf::ElfHeader; +using SecHeader = NativeElf::SectionHeader; + +// Fixed layout of the synthetic ELF image built by makeSyntheticElf(). The offsets are +// deliberately simple so that a test can patch one field of the image without having to +// reimplement the whole builder. +constexpr std::size_t kStrTabOffset = 0x100; +constexpr std::size_t kSectionTableOffset = 0x200; +constexpr std::uint16_t kTextIndex = 1; +constexpr std::uint16_t kDataIndex = 2; +constexpr std::uint16_t kStrTabIndex = 3; +constexpr std::uint16_t kSectionCount = 4; + +std::vector readWholeFile(const std::filesystem::path &path) +{ + std::ifstream in(path, std::ios::binary); + return std::vector(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +void writeWholeFile(const std::filesystem::path &path, const std::vector &bytes) +{ + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(bytes.data(), static_cast(bytes.size())); +} + +template +void storeAt(std::vector &bytes, std::size_t offset, const T &value) +{ + std::memcpy(bytes.data() + offset, &value, sizeof(T)); +} + +template +T loadAt(const std::vector &bytes, std::size_t offset) +{ + T value; + std::memcpy(&value, bytes.data() + offset, sizeof(T)); + return value; +} + +// A tiny, fully controlled ELF image: a null section, a .text and a .data section plus a +// section-name string table. Because every byte is produced here, a test can model the +// malformed headers an attacker would put into a .uab, which a real linker would never +// emit. `entrySize` is written to e_shentsize and also used as the stride of the table +// so that the reader can still locate the string table before the entry-size check runs. +struct SyntheticElf +{ + std::vector bytes; + std::size_t entrySize{ sizeof(SecHeader) }; + std::size_t textNameOffset{}; + std::size_t dataNameOffset{}; + std::size_t strTabNameOffset{}; + std::size_t strTabSize{}; + + std::size_t sectionOffset(std::uint16_t index) const + { + return kSectionTableOffset + static_cast(index) * entrySize; + } + + ElfHeader header() const { return loadAt(bytes, 0); } + + void setHeader(const ElfHeader &value) { storeAt(bytes, 0, value); } + + SecHeader section(std::uint16_t index) const + { + return loadAt(bytes, sectionOffset(index)); + } + + void setSection(std::uint16_t index, const SecHeader &value) + { + storeAt(bytes, sectionOffset(index), value); + } + + void writeTo(const std::filesystem::path &path) const { writeWholeFile(path, bytes); } +}; + +SyntheticElf makeSyntheticElf(std::size_t entrySize = sizeof(SecHeader), + std::uint16_t sectionCount = kSectionCount) +{ + SyntheticElf image; + image.entrySize = entrySize; + + // The table starts with a NUL so that sh_name == 0 is the empty name of the null + // section; the names used by the tests are appended afterwards. + std::string strTab(1, '\0'); + image.textNameOffset = strTab.size(); + strTab.append(".text").push_back('\0'); + image.dataNameOffset = strTab.size(); + strTab.append(".data").push_back('\0'); + image.strTabNameOffset = strTab.size(); + strTab.append(".shstrtab").push_back('\0'); + image.strTabSize = strTab.size(); + + // The table has to be large enough for the last entry as it is written below, and + // for the string table regardless of the stride used by a tampered e_shentsize. + const auto lastEntryEnd = kSectionTableOffset + + static_cast(sectionCount - 1) * entrySize + sizeof(SecHeader); + const auto minSize = std::max(lastEntryEnd, kStrTabOffset + strTab.size()); + image.bytes.assign(minSize, '\0'); + std::memcpy(image.bytes.data() + kStrTabOffset, strTab.data(), strTab.size()); + + ElfHeader header{}; + header.e_ident[EI_MAG0] = ELFMAG0; + header.e_ident[EI_MAG1] = ELFMAG1; + header.e_ident[EI_MAG2] = ELFMAG2; + header.e_ident[EI_MAG3] = ELFMAG3; + header.e_ident[EI_CLASS] = lightElf::machine_type == 64 ? ELFCLASS64 : ELFCLASS32; + header.e_shoff = kSectionTableOffset; + header.e_shentsize = static_cast(entrySize); + header.e_shnum = sectionCount; + header.e_shstrndx = kStrTabIndex; + image.setHeader(header); + + SecHeader text{}; + text.sh_name = static_cast(image.textNameOffset); + text.sh_type = SHT_PROGBITS; + text.sh_offset = kStrTabOffset; + text.sh_size = 4; + image.setSection(kTextIndex, text); + + SecHeader data = text; + data.sh_name = static_cast(image.dataNameOffset); + image.setSection(kDataIndex, data); + + SecHeader strTabSection{}; + strTabSection.sh_name = static_cast(image.strTabNameOffset); + strTabSection.sh_type = SHT_STRTAB; + strTabSection.sh_offset = kStrTabOffset; + strTabSection.sh_size = strTab.size(); + image.setSection(kStrTabIndex, strTabSection); + + return image; +} + +// A well-formed image must still be parsed completely: the constructor accepts it and +// getSectionHeader() finds every section by the name stored in the string table. +TEST(LightElfTest, EnumeratesEverySectionOfTheRunningExecutable) +{ + TempDir dir; + const auto path = dir.path() / "self.elf"; + + // /proc/self/exe is a real linker-produced ELF, so the section count, the entry size + // and every name come from an actual toolchain instead of from this test. + std::error_code ec; + std::filesystem::copy_file("/proc/self/exe", path, ec); + ASSERT_FALSE(ec); + + lightElf::native_elf elf(path); + + const auto raw = readWholeFile(path); + const auto header = loadAt(raw, 0); + ASSERT_EQ(header.e_shentsize, sizeof(SecHeader)); + ASSERT_GT(header.e_shnum, 1); + + const auto strTabSection = loadAt( + raw, + header.e_shoff + static_cast(header.e_shstrndx) * header.e_shentsize); + const std::string strTab(raw.data() + strTabSection.sh_offset, strTabSection.sh_size); + + std::size_t matched = 0; + for (decltype(header.e_shnum) index = 1; index < header.e_shnum; ++index) { + const auto shdr = + loadAt(raw, + header.e_shoff + static_cast(index) * header.e_shentsize); + if (shdr.sh_name == 0 || shdr.sh_name >= strTab.size()) { + continue; + } + + const std::string_view name(strTab.data() + shdr.sh_name); + if (name.empty()) { + continue; + } + + const auto found = elf.getSectionHeader(std::string{ name }); + ASSERT_TRUE(found.has_value()); + EXPECT_EQ(found->sh_offset, shdr.sh_offset); + EXPECT_EQ(found->sh_size, shdr.sh_size); + EXPECT_EQ(found->sh_type, shdr.sh_type); + ++matched; + } + + EXPECT_GT(matched, 0u); +} + +TEST(LightElfTest, FindsSectionInSyntheticImage) +{ + TempDir dir; + const auto path = dir.path() / "synthetic.elf"; + makeSyntheticElf().writeTo(path); + + lightElf::native_elf elf(path); + + ASSERT_TRUE(elf.getSectionHeader(".text").has_value()); + ASSERT_TRUE(elf.getSectionHeader(".data").has_value()); + ASSERT_TRUE(elf.getSectionHeader(".shstrtab").has_value()); + EXPECT_FALSE(elf.getSectionHeader(".does-not-exist").has_value()); +} + +// e_shentsize is a 16 bit header field taken from the file. A value larger than +// sizeof(SectionHeader) used to make getSectionHeader() read that many bytes into the +// fixed on-stack SectionHeader, i.e. a stack buffer overflow. Such a file must now be +// rejected with an empty result instead of being read. +TEST(LightElfTest, RejectsSectionHeaderEntrySizeLargerThanStruct) +{ + TempDir dir; + const auto path = dir.path() / "oversized-entry.elf"; + makeSyntheticElf(2 * sizeof(SecHeader)).writeTo(path); + + lightElf::native_elf elf(path); + ASSERT_NE(elf.fileHeader().e_shentsize, sizeof(SecHeader)); + + EXPECT_FALSE(elf.getSectionHeader(".text").has_value()); + EXPECT_FALSE(elf.getSectionHeader(".shstrtab").has_value()); +} + +// A stride smaller than sizeof(SectionHeader) leaves the on-stack header only partially +// filled, so every field that is not covered by the read stays uninitialised and feeds +// attacker-controlled offsets into the rest of the loop. It must be rejected as well. +TEST(LightElfTest, RejectsSectionHeaderEntrySizeSmallerThanStruct) +{ + TempDir dir; + const auto path = dir.path() / "undersized-entry.elf"; + makeSyntheticElf(sizeof(SecHeader) / 2).writeTo(path); + + lightElf::native_elf elf(path); + ASSERT_NE(elf.fileHeader().e_shentsize, sizeof(SecHeader)); + + EXPECT_FALSE(elf.getSectionHeader(".text").has_value()); + EXPECT_FALSE(elf.getSectionHeader(".shstrtab").has_value()); +} + +// e_shnum is also read from the file. Claiming far more entries than the file can hold +// must not make the loop walk (and read) past the end of the section header table. +TEST(LightElfTest, RejectsSectionTablePastEndOfFile) +{ + TempDir dir; + const auto path = dir.path() / "truncated-table.elf"; + + auto image = makeSyntheticElf(); + auto header = image.header(); + header.e_shnum = 0xFFFF; + image.setHeader(header); + image.writeTo(path); + + lightElf::native_elf elf(path); + EXPECT_FALSE(elf.getSectionHeader(".text").has_value()); + EXPECT_FALSE(elf.getSectionHeader(".shstrtab").has_value()); +} + +// sh_name is an offset into the string table. An offset outside the table used to build +// a string_view that scanned memory far beyond rawSectionNames, so the check below has +// to happen before the view is created. The selected offset is well beyond any heap +// mapping, which makes the missing check crash the old code even without a sanitizer. +TEST(LightElfTest, RejectsOutOfRangeSectionNameOffset) +{ + TempDir dir; + const auto path = dir.path() / "bad-name-offset.elf"; + + auto image = makeSyntheticElf(); + auto corrupt = image.section(kDataIndex); + corrupt.sh_name = static_cast(0xFFFFFFF0u); + image.setSection(kDataIndex, corrupt); + image.writeTo(path); + + lightElf::native_elf elf(path); + + // The corrupt entry can never be matched ... + EXPECT_FALSE(elf.getSectionHeader(".data").has_value()); + // ... but it must not hide the entries that follow it in the table. + EXPECT_TRUE(elf.getSectionHeader(".text").has_value()); + EXPECT_TRUE(elf.getSectionHeader(".shstrtab").has_value()); +} + +// A section name whose terminator is missing (a truncated string table) must be skipped +// rather than turned into a view that reads past the end of the buffer. +TEST(LightElfTest, RejectsUnterminatedSectionName) +{ + TempDir dir; + const auto path = dir.path() / "unterminated-name.elf"; + + auto image = makeSyntheticElf(); + ASSERT_EQ(image.bytes[kStrTabOffset + image.strTabSize - 1], '\0'); + + // Replace the final NUL with a regular byte: the string table still has exactly the + // declared size and stays inside the file, but the last name is now unterminated. + image.bytes[kStrTabOffset + image.strTabSize - 1] = 'x'; + image.writeTo(path); + + lightElf::native_elf elf(path); + + EXPECT_FALSE(elf.getSectionHeader(".shstrtab").has_value()); + // Names that are properly terminated earlier in the table keep working. + EXPECT_TRUE(elf.getSectionHeader(".text").has_value()); + EXPECT_TRUE(elf.getSectionHeader(".data").has_value()); +} + +// Repeated lookups on the same object must stay safe and stable: the validation above +// must not depend on, or mutate, state left behind by a previous call. +TEST(LightElfTest, RepeatedLookupsAreStable) +{ + TempDir dir; + const auto path = dir.path() / "repeated.elf"; + + auto image = makeSyntheticElf(); + auto corrupt = image.section(kDataIndex); + corrupt.sh_name = static_cast(0xFFFFFFF0u); + image.setSection(kDataIndex, corrupt); + image.writeTo(path); + + lightElf::native_elf elf(path); + + for (auto i = 0; i < 2; ++i) { + EXPECT_FALSE(elf.getSectionHeader(".data").has_value()); + EXPECT_TRUE(elf.getSectionHeader(".text").has_value()); + } +} + +} // namespace