CVE-2023-39969 (published 2023-08-09T16:15Z)

uthenticode is a small cross-platform library for partially verifying Authenticode digital signatures. Version 1.0.9 of uthenticode hashed the entire file rather than hashing sections by virtual address, in violation of the Authenticode specification. As a result, an attacker could modify code within a binary without changing its Authenticode hash, making it appear valid from uthenticode's perspective. Versions of uthenticode prior to 1.0.9 are not vulnerable to this attack, nor are versions in the 2.x series. By design, uthenticode does not perform full-chain validation. However, the malleability of signature verification introduced in 1.0.9 was an unintended oversight. The 2.x series addresses the vulnerability. Versions prior to 1.0.9 are also not vulnerable, but users are encouraged to upgrade rather than downgrade. There are no workarounds to this vulnerability.

CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-347
cwe_name: Improper Verification of Cryptographic Signature
description: The product does not verify, or incorrectly verifies, the cryptographic signature for data.
extended_description: 
            
            
         
         

Fix information:
repo_url: https://github.com/trailofbits/uthenticode
commit_details: repo_url: https://github.com/trailofbits/uthenticode
author: William Woodruff
author_date: 2023-08-02 15:46:44-04:00
msg: Fix hashing (#84)

* Revert "Fix hash calculation (#62)"

This reverts commit d16e9ec2b4d54c1333137ae34f011e9eb3e0b962.

* test: stuffed PE tests

Signed-off-by: William Woodruff <william@trailofbits.com>

* uthenticode-test: re-add failing hashes

These hashes are correct; this can't be merged until these tests pass.

Signed-off-by: William Woodruff <william@trailofbits.com>

* src, test: don't support checksums on unsigned exes

Signed-off-by: William Woodruff <william@trailofbits.com>

* test/assets: stuffing example

Signed-off-by: William Woodruff <william@trailofbits.com>

* uthenticode: fix hashing on trailing data

Signed-off-by: William Woodruff <william@trailofbits.com>

* uthenticode: safer buffer operations

Signed-off-by: William Woodruff <william@trailofbits.com>

* uthenticode: free trailer_buf once we're done with it

Signed-off-by: William Woodruff <william@trailofbits.com>

---------

Signed-off-by: William Woodruff <william@trailofbits.com>
repository: repo_url: https://github.com/trailofbits/uthenticode
repo_name: trailofbits/uthenticode
description: A cross-platform library for verifying Authenticode signatures
repo_language: C++
owner: trailofbits
file_changes: filename: uthenticode.h
change_type: ModificationType.MODIFY
diff: @@ -286,11 +286,14 @@ std::vector<Checksum> get_checksums(peparse::parsed_pe *pe);
 /**
  * Calculates the requested message digest for the given `parsed_pe`.
  *
+ * `parsed_pe` must contain a security directory; calculating the checksum
+ * of a "bare" PE is not supported.
+ *
  * @param  pe   the `peparse::parsed_pe` to hash
  * @param  kind the kind of message digest to calculate
- * @return      the resulting digest, or an empty string on failure
+ * @return      the resulting digest, or `std::nullopt` on failure
  */
-std::string calculate_checksum(peparse::parsed_pe *pe, checksum_kind kind);
+std::optional<std::string> calculate_checksum(peparse::parsed_pe *pe, checksum_kind kind);
 
 /**
  * Verifies the given `parsed_pe`.

programming_language: C++
filename: svcli.cpp
change_type: ModificationType.MODIFY
diff: @@ -53,7 +53,12 @@ int main(int argc, char const *argv[]) {
   std::array<checksum_kind, 3> kinds = {
       checksum_kind::MD5, checksum_kind::SHA1, checksum_kind::SHA256};
   for (const auto &kind : kinds) {
-    std::cout << std::setw(6) << kind << ": " << uthenticode::calculate_checksum(pe, kind) << '\n';
+    auto cksum = uthenticode::calculate_checksum(pe, kind);
+    if (cksum.has_value()) {
+      std::cout << std::setw(6) << kind << ": " << cksum.value() << '\n';
+    } else {
+      std::cout << std::setw(6) << kind << ": NONE (not signed)\n";
+    }
   }
   std::cout << '\n';
 

programming_language: C++
filename: uthenticode.cpp
change_type: ModificationType.MODIFY
diff: @@ -470,12 +470,19 @@ std::vector<Checksum> get_checksums(peparse::parsed_pe *pe) {
   return checksums;
 }
 
-std::string calculate_checksum(peparse::parsed_pe *pe, checksum_kind kind) {
+std::optional<std::string> calculate_checksum(peparse::parsed_pe *pe, checksum_kind kind) {
   auto nid = checksum_type_to_nid(kind);
   if (nid == NID_undef) {
     return {};
   }
 
+  /* We'll stash the bits of the PE that we need to hash in this buffer.
+   * Reserve the original PE's size upfront, since we expect the hashed data
+   * to be only slightly smaller.
+   */
+  std::vector<std::uint8_t> pe_bits;
+  pe_bits.reserve(pe->fileBuffer->bufLen);
+
   /* In both PEs and PE32+s, the PE checksum is 64 bytes into the optional header,
    * which itself is 24 bytes after the PE magic and COFF header from the offset
    * specified in the DOS header.
@@ -502,6 +509,14 @@ std::string calculate_checksum(peparse::parsed_pe *pe, checksum_kind kind) {
     return {};
   }
 
+  /* We explicitly don't support hashing files that don't contain a security
+   * directory; not because we *can't*, but because doing so isn't well defined
+   * in the Authenticode specification.
+   */
+  if (security_dir.VirtualAddress == 0) {
+    return {};
+  }
+
   /* "VirtualAddress" here is really an offset; an invalid one indicates a tampered input.
    * Similarly, a cert_table_offset beyond size_of_headers indicates a tampered input
    * (we get the pe_checksum_offset check for free, since it's always smaller).
@@ -519,11 +534,6 @@ std::string calculate_checksum(peparse::parsed_pe *pe, checksum_kind kind) {
     return {};
   }
 
-  /* We'll stash the bits of the PE that we need to hash in this buffer.
-   */
-  std::vector<std::uint8_t> pe_bits;
-  pe_bits.reserve(size_of_headers);
-
   pe_bits.insert(pe_bits.begin(), header_buf->buf, header_buf->buf + header_buf->bufLen);
   delete header_buf;
 
@@ -541,36 +551,68 @@ std::string calculate_checksum(peparse::parsed_pe *pe, checksum_kind kind) {
   pe_bits.erase(pe_bits.begin() + cert_table_offset, pe_bits.begin() + cert_table_offset + 8);
   pe_bits.erase(pe_bits.begin() + pe_checksum_offset, pe_bits.begin() + pe_checksum_offset + 4);
 
-  /* Hash pe_bits, which contains the rest of the header.
-   */
-  const auto *md = EVP_get_digestbynid(nid);
-  auto *md_ctx = EVP_MD_CTX_new();
-  EVP_DigestInit(md_ctx, md);
-  EVP_DigestUpdate(md_ctx, pe_bits.data(), pe_bits.size());
+  struct iter_sec_ctx {
+    impl::SectionList sections;
+    uint32_t total_bytes_hashed;
+  };
 
-  if (security_dir.VirtualAddress > 0) {
-    /* If a certificate table exists, hash everything before and after it.
-     */
-    EVP_DigestUpdate(md_ctx,
-                     pe->fileBuffer->buf + size_of_headers,
-                     security_dir.VirtualAddress - size_of_headers);
+  impl::SectionList sections;
+  uint32_t total_bytes_hashed = size_of_headers;
+  iter_sec_ctx ctx = {sections, total_bytes_hashed};
 
-    /* Most PEs won't have any trailing data but the Authenticode specification is explicit about
-     * hashing any if it exists.
-     */
-    EVP_DigestUpdate(md_ctx,
-                     pe->fileBuffer->buf + security_dir.VirtualAddress + security_dir.Size,
-                     pe->fileBuffer->bufLen - (security_dir.VirtualAddress + security_dir.Size));
-  } else {
-    /* If there's no certificate table, just hash the rest of the file.
-     */
-    EVP_DigestUpdate(
-        md_ctx, pe->fileBuffer->buf + size_of_headers, pe->fileBuffer->bufLen - size_of_headers);
+  /* Build up the list of sections in the PE, in ascending order by PointerToRawData
+   * (i.e., by file offset).
+   *
+   * NOTE(ww): Ideally we'd use a capture with the C++ lambda here, but C++ lambdas can't be
+   * used within C callbacks unless they're captureless.
+   */
+  peparse::IterSec(
+      pe,
+      [](void *cbd,
+         [[maybe_unused]] const peparse::VA &secBase,
+         [[maybe_unused]] const std::string &sectionName,
+         [[maybe_unused]] const peparse::image_section_header &sec,
+         const peparse::bounded_buffer *b) -> int {
+        auto &ctx = *static_cast<iter_sec_ctx *>(cbd);
+        ctx.sections.emplace_back(b);
+        ctx.total_bytes_hashed += sec.SizeOfRawData;
+        return 0;
+      },
+      &ctx);
+
+  /* Copy each section's data into pe_bits, in ascending order.
+   */
+  for (const auto &section : sections) {
+    pe_bits.insert(pe_bits.end(), section->buf, section->buf + section->bufLen);
   }
 
-  /* Finally, finish hashing the damn thing.
+  /* Also copy any data that happens to be trailing the certificate table into pe_bits.
+   * Most PEs won't have any trailing data but the Authenticode specification is explicit about
+   * hashing any if it exists.
+   */
+  auto file_size = pe->fileBuffer->bufLen;
+  auto extra_data_size = file_size - (security_dir.Size + total_bytes_hashed);
+  auto *trailer_buf = peparse::splitBuffer(
+      pe->fileBuffer, total_bytes_hashed, total_bytes_hashed + extra_data_size);
+  if (trailer_buf == nullptr) {
+    return {};
+  }
+  pe_bits.insert(pe_bits.end(), trailer_buf->buf, trailer_buf->buf + trailer_buf->bufLen);
+  delete trailer_buf;
+
+  /* Finally, hash the damn thing.
+   *
+   * NOTE(ww): Instead of building up pe_bits and hashing it in one pass, we
+   * could hash it incrementally with each section. This would also solve
+   * the capture problem with the C++ callback above and would reduce
+   * the number of needed allocations.
    */
   std::array<std::uint8_t, EVP_MAX_MD_SIZE> md_buf;
+  const auto *md = EVP_get_digestbynid(nid);
+  auto *md_ctx = EVP_MD_CTX_new();
+
+  EVP_DigestInit(md_ctx, md);
+  EVP_DigestUpdate(md_ctx, pe_bits.data(), pe_bits.size());
   EVP_DigestFinal(md_ctx, md_buf.data(), nullptr);
   EVP_MD_CTX_free(md_ctx);
 

programming_language: C++
filename: helpers.h
change_type: ModificationType.MODIFY
diff: @@ -111,3 +111,19 @@ class MissingEKUTest : public ::testing::Test {
 
   peparse::parsed_pe *pe{nullptr};
 };
+
+class StuffingTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    auto *file = UTHENTICODE_TEST_ASSETS "/YourPhone.exe";
+
+    pe = peparse::ParsePEFromFile(file);
+    ASSERT_TRUE(pe != nullptr);
+  }
+
+  void TearDown() override {
+    peparse::DestructParsedPE(pe);
+  }
+
+  peparse::parsed_pe *pe{nullptr};
+};

programming_language: C++
filename: signeddata-test.cpp
change_type: ModificationType.MODIFY
diff: @@ -123,3 +123,13 @@ TEST_F(MissingEKUTest, SignedData_missing_codesigning_EKU) {
 
   ASSERT_FALSE(signed_data->verify_signature());
 }
+
+TEST_F(StuffingTest, SignedData_missing_codesigning_EKU) {
+  auto certs = uthenticode::read_certs(pe);
+  auto signed_data = certs[0].as_signed_data();
+
+  // The signature in this PE is valid, but it doesn't actually
+  // match the PE's calculated checksum. See `StuffingTest.verify`
+  // for the corresponding test.
+  ASSERT_TRUE(signed_data->verify_signature());
+}

programming_language: C++
filename: uthenticode-test.cpp
change_type: ModificationType.MODIFY
diff: @@ -75,73 +75,69 @@ TEST_F(Auth32PlusTest, get_checksums) {
 
 TEST_F(NoAuthTest, calculate_checksum) {
   auto unk = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::UNKNOWN);
-  EXPECT_TRUE(unk.empty());
+  EXPECT_FALSE(unk.has_value());
 
   auto md5 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::MD5);
-  EXPECT_EQ(md5.size(), 32);
-  EXPECT_STRCASEEQ(md5.c_str(), "A31557B1E39554C88C69AAE1DFAAF314");
+  EXPECT_FALSE(md5.has_value());
 
   auto sha1 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::SHA1);
-  EXPECT_EQ(sha1.size(), 40);
-  EXPECT_STRCASEEQ(sha1.c_str(), "2B316F0552972605D509321F31F4274533C93161");
+  EXPECT_FALSE(sha1.has_value());
 
   auto sha256 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::SHA256);
-  EXPECT_EQ(sha256.size(), 64);
-  EXPECT_STRCASEEQ(sha256.c_str(),
-                   "6B7FA3E8298F33BC47F4ABB9C845930B1EACC0DAD96503CFA52D4EA18DDC89F0");
+  EXPECT_FALSE(sha256.has_value());
 }
 
 TEST_F(Auth32Test, calculate_checksum) {
   auto unk = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::UNKNOWN);
-  EXPECT_TRUE(unk.empty());
+  EXPECT_FALSE(unk.has_value());
 
   auto md5 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::MD5);
-  EXPECT_EQ(md5.size(), 32);
-  EXPECT_STRCASEEQ(md5.c_str(), "64c29391b57679b2973ac562cf64685d");
+  EXPECT_EQ(md5.value().size(), 32);
+  EXPECT_STRCASEEQ(md5.value().c_str(), "64c29391b57679b2973ac562cf64685d");
 
   auto sha1 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::SHA1);
-  EXPECT_EQ(sha1.size(), 40);
-  EXPECT_STRCASEEQ(sha1.c_str(), "6663dd7c24fa84fce7f16e0b02689952c06cfa22");
+  EXPECT_EQ(sha1.value().size(), 40);
+  EXPECT_STRCASEEQ(sha1.value().c_str(), "6663dd7c24fa84fce7f16e0b02689952c06cfa22");
 
   auto sha256 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::SHA256);
-  EXPECT_EQ(sha256.size(), 64);
-  EXPECT_STRCASEEQ(sha256.c_str(),
+  EXPECT_EQ(sha256.value().size(), 64);
+  EXPECT_STRCASEEQ(sha256.value().c_str(),
                    "ea013992f99840f76dcac225dd1262edcec3254511b250a6d1e98d99fc48f815");
 }
 
 TEST_F(Auth32DupeTest, calculate_checksum) {
   auto unk = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::UNKNOWN);
-  EXPECT_TRUE(unk.empty());
+  EXPECT_FALSE(unk.has_value());
 
   auto md5 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::MD5);
-  EXPECT_EQ(md5.size(), 32);
-  EXPECT_STRCASEEQ(md5.c_str(), "64c29391b57679b2973ac562cf64685d");
+  EXPECT_EQ(md5.value().size(), 32);
+  EXPECT_STRCASEEQ(md5.value().c_str(), "64c29391b57679b2973ac562cf64685d");
 
   auto sha1 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::SHA1);
-  EXPECT_EQ(sha1.size(), 40);
-  EXPECT_STRCASEEQ(sha1.c_str(), "6663dd7c24fa84fce7f16e0b02689952c06cfa22");
+  EXPECT_EQ(sha1.value().size(), 40);
+  EXPECT_STRCASEEQ(sha1.value().c_str(), "6663dd7c24fa84fce7f16e0b02689952c06cfa22");
 
   auto sha256 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::SHA256);
-  EXPECT_EQ(sha256.size(), 64);
-  EXPECT_STRCASEEQ(sha256.c_str(),
+  EXPECT_EQ(sha256.value().size(), 64);
+  EXPECT_STRCASEEQ(sha256.value().c_str(),
                    "ea013992f99840f76dcac225dd1262edcec3254511b250a6d1e98d99fc48f815");
 }
 
 TEST_F(Auth32PlusTest, calculate_checksum) {
   auto unk = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::UNKNOWN);
-  EXPECT_TRUE(unk.empty());
+  EXPECT_FALSE(unk.has_value());
 
   auto md5 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::MD5);
-  EXPECT_EQ(md5.size(), 32);
-  EXPECT_STRCASEEQ(md5.c_str(), "ea0235b77d552633c5a38974cef0e2b5");
+  EXPECT_EQ(md5.value().size(), 32);
+  EXPECT_STRCASEEQ(md5.value().c_str(), "ea0235b77d552633c5a38974cef0e2b5");
 
   auto sha1 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::SHA1);
-  EXPECT_EQ(sha1.size(), 40);
-  EXPECT_STRCASEEQ(sha1.c_str(), "2559e91a60953a5e16f9650f5f88953a2cca5425");
+  EXPECT_EQ(sha1.value().size(), 40);
+  EXPECT_STRCASEEQ(sha1.value().c_str(), "2559e91a60953a5e16f9650f5f88953a2cca5425");
 
   auto sha256 = uthenticode::calculate_checksum(pe, uthenticode::checksum_kind::SHA256);
-  EXPECT_EQ(sha256.size(), 64);
-  EXPECT_STRCASEEQ(sha256.c_str(),
+  EXPECT_EQ(sha256.value().size(), 64);
+  EXPECT_STRCASEEQ(sha256.value().c_str(),
                    "5c823491c5991914aec971d9456d93d6cf2b8ee7e0ed7abc0b77031d8ec073c0");
 }
 
@@ -167,3 +163,11 @@ TEST_F(Auth32DupeTest, verify) {
 TEST_F(Auth32PlusTest, verify) {
   EXPECT_TRUE(uthenticode::verify(pe));
 }
+
+TEST_F(MissingEKUTest, verify) {
+  EXPECT_FALSE(uthenticode::verify(pe));
+}
+
+TEST_F(StuffingTest, verify) {
+  EXPECT_FALSE(uthenticode::verify(pe));
+}

programming_language: C++