Skip to content

Commit 2941d15

Browse files
committed
verifyinghasher: extend ParsedCodeDirectory and VerifyingHasher::Result
- ParsedCodeDirectory: add `cd_bytes` span over the picked CD's blob bytes; populated on every successful parse. - VerifyingHasher::Result: add optional cdhash, signing_id, team_id, cd_bytes (owning copy), and cs_blob_size fields. Populated only on kMatchCDHash / kMatchSidTidDrift. - New hw_entitled fixture (Mach-O with XML + DER entitlement slots and a real CMS signature). New testdata/README documents regeneration recipes for the package's four fixtures. - ParseCodeSignature refactored to accumulate fields into a local and assign to `out` via a single move at the end on success. Adds tests for the new fields, the drift-path population, and the parser's output contract.
1 parent 402d5ab commit 2941d15

9 files changed

Lines changed: 415 additions & 43 deletions

File tree

Source/common/verifyinghasher/BUILD

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,8 @@ objc_library(
130130
],
131131
)
132132

133-
# Single source of truth for the multi-CD ad-hoc-signed test fixture.
134-
# Testing/Fuzzing references this via cross-package filegroup rather than
135-
# holding its own copy.
133+
# Mach-O test fixtures. Regeneration procedures: testdata/README.md.
134+
136135
filegroup(
137136
name = "hw_universal_fixture",
138137
srcs = ["testdata/hw_universal"],
@@ -142,35 +141,24 @@ filegroup(
142141
],
143142
)
144143

145-
# Team-signed counterpart used to exercise positive drift detection
146-
# (kMatchSidTidDrift requires a non-empty team_id). Generated via:
147-
# cp testdata/hw_universal testdata/hw_team_signed
148-
# codesign --force --sign <ApplicationDevelopmentIdentity> testdata/hw_team_signed
149-
# Update kHwTeamSignedSigningID + kHwTeamSignedTeamID in VerifyingHasherTest.mm
150-
# if regenerated with a different identity.
151144
filegroup(
152145
name = "hw_team_signed_fixture",
153146
srcs = ["testdata/hw_team_signed"],
154147
visibility = ["//Source/common/verifyinghasher:__pkg__"],
155148
)
156149

157-
# Single source of truth for the unsigned Mach-O test fixture used to
158-
# exercise Expected::Unsigned semantics. Generated via:
159-
# cat > /tmp/hw_unsigned_build/main.c <<'EOF'
160-
# int main(void) { return 0; }
161-
# EOF
162-
# clang -arch arm64 -Wl,-no_adhoc_codesign -o hw_unsigned /tmp/hw_unsigned_build/main.c
163-
# `-Wl,-no_adhoc_codesign` suppresses the linker's default ad-hoc signing
164-
# so the produced Mach-O has no LC_CODE_SIGNATURE / no CS blob.
165-
# DO NOT regenerate without also updating kHwUnsignedSha256 in
166-
# VerifyingHasherTest.mm — the hash changes whenever the toolchain or
167-
# the source byte sequence changes.
168150
filegroup(
169151
name = "hw_unsigned_fixture",
170152
srcs = ["testdata/hw_unsigned"],
171153
visibility = ["//Source/common/verifyinghasher:__pkg__"],
172154
)
173155

156+
filegroup(
157+
name = "hw_entitled_fixture",
158+
srcs = ["testdata/hw_entitled"],
159+
visibility = ["//Source/common/verifyinghasher:__pkg__"],
160+
)
161+
174162
santa_unit_test(
175163
name = "VerifyingHasherCoreTest",
176164
srcs = ["VerifyingHasherCoreTest.mm"],

Source/common/verifyinghasher/CodeSignatureParser.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ struct ParsedCodeDirectory {
4141
uint64_t code_limit = 0;
4242
uint32_t page_count = 0;
4343
std::span<const uint8_t> slot_hashes; // page_count * hash_size bytes
44+
// Full CodeDirectory blob bytes (header + identifier + team_id + slot
45+
// hash table). H_hashType(cd_bytes) truncated to CS_CDHASH_LEN equals
46+
// cdhash. Non-owning view into the blob passed to ParseCodeSignature();
47+
// shares its lifetime. Populated on every successful parse.
48+
std::span<const uint8_t> cd_bytes;
4449
// 20-byte truncated cdhash of this CodeDirectory blob, computed using
4550
// its own hashType (matches xnu's cs_cd_hash and es_cdhash_t).
4651
uint8_t cdhash[CS_CDHASH_LEN] = {};

Source/common/verifyinghasher/CodeSignatureParser.mm

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,14 @@ uint8_t HashSizeFor(uint8_t hash_type) {
8888

8989
bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
9090
ParsedCodeDirectory& out, std::string& err) {
91-
// Reset output up front so callers reusing a ParsedCodeDirectory across
92-
// parses don't leak stale strings/spans/hashes from a prior successful
93-
// call through the conditionally-overwritten fields (identifier,
94-
// team_id) on the next parse, and so every early-return path leaves
95-
// a clean output.
91+
// All-or-nothing semantics: accumulate every field into `tmp`, commit
92+
// to `out` only when every check has passed. `out` is reset on entry
93+
// so failure paths leave the caller's struct in a clean default state
94+
// — never a partially-populated one — regardless of where validation
95+
// bailed.
9696
out = ParsedCodeDirectory{};
9797
err.clear();
98+
ParsedCodeDirectory tmp;
9899

99100
if (blob.size() < sizeof(CS_SuperBlob)) {
100101
err = "code signature blob too small for SuperBlob";
@@ -228,13 +229,14 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
228229
// picked CD's bytes.
229230

230231
const CS_CodeDirectory* cd = picked->cd;
231-
out.hash_type = cd->hashType;
232-
out.hash_size = HashSizeFor(cd->hashType);
233-
if (out.hash_size == 0) {
232+
tmp.hash_type = cd->hashType;
233+
tmp.cd_bytes = std::span<const uint8_t>(picked->blob_base, picked->blob_len);
234+
tmp.hash_size = HashSizeFor(cd->hashType);
235+
if (tmp.hash_size == 0) {
234236
err = "CodeDirectory unsupported hashType slipped through";
235237
return false;
236238
}
237-
if (cd->hashSize != out.hash_size) {
239+
if (cd->hashSize != tmp.hash_size) {
238240
err = "CodeDirectory hashSize mismatch";
239241
return false;
240242
}
@@ -259,7 +261,7 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
259261
err = "CodeDirectory pageSize unsupported (must be 12..18)";
260262
return false;
261263
}
262-
out.page_size = 1u << cd->pageSize;
264+
tmp.page_size = 1u << cd->pageSize;
263265

264266
const uint32_t version = OSSwapBigToHostInt32(cd->version);
265267

@@ -282,11 +284,11 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
282284

283285
const uint32_t cl32 = OSSwapBigToHostInt32(cd->codeLimit);
284286
if (version >= CS_SUPPORTSCODELIMIT64 && cd->codeLimit64 != 0) {
285-
out.code_limit = OSSwapBigToHostInt64(cd->codeLimit64);
287+
tmp.code_limit = OSSwapBigToHostInt64(cd->codeLimit64);
286288
} else {
287-
out.code_limit = cl32;
289+
tmp.code_limit = cl32;
288290
}
289-
if (out.code_limit > slice_size) {
291+
if (tmp.code_limit > slice_size) {
290292
err = "CodeDirectory codeLimit exceeds slice size";
291293
return false;
292294
}
@@ -309,11 +311,11 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
309311
// (large codeLimit, small page_size) lets nCodeSlots match a truncated
310312
// value and PageVerifier walks past the slot table at runtime.
311313
uint64_t numerator;
312-
if (os_add_overflow(out.code_limit, static_cast<uint64_t>(out.page_size) - 1, &numerator)) {
314+
if (os_add_overflow(tmp.code_limit, static_cast<uint64_t>(tmp.page_size) - 1, &numerator)) {
313315
err = "CodeDirectory codeLimit + page_size overflows";
314316
return false;
315317
}
316-
const uint64_t expected_pages_u64 = numerator / out.page_size;
318+
const uint64_t expected_pages_u64 = numerator / tmp.page_size;
317319
if (expected_pages_u64 > UINT32_MAX) {
318320
err = "CodeDirectory page count exceeds UINT32_MAX";
319321
return false;
@@ -325,7 +327,7 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
325327
// the xnu invariant on the full claim so a CD that overstates nCodeSlots
326328
// beyond what fits gets rejected with the same verdict xnu would give.
327329
if (hash_offset > picked->blob_len ||
328-
(picked->blob_len - hash_offset) / out.hash_size < n_code_slots) {
330+
(picked->blob_len - hash_offset) / tmp.hash_size < n_code_slots) {
329331
err = "CodeDirectory nCodeSlots does not fit in blob";
330332
return false;
331333
}
@@ -353,9 +355,9 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
353355
err = "CodeDirectory has fewer slot hashes than codeLimit/pageSize requires";
354356
return false;
355357
}
356-
out.page_count = expected_pages;
358+
tmp.page_count = expected_pages;
357359

358-
const size_t slots_bytes = static_cast<size_t>(out.page_count) * out.hash_size;
360+
const size_t slots_bytes = static_cast<size_t>(tmp.page_count) * tmp.hash_size;
359361
// Note: hashOffset < sizeof(CS_CodeDirectory) is intentionally accepted.
360362
// xnu's cs_validate_codedirectory only checks `length < hashOffset`,
361363
// not against the CD header size:
@@ -370,13 +372,13 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
370372
err = "CodeDirectory slot hashes out of bounds";
371373
return false;
372374
}
373-
out.slot_hashes = std::span<const uint8_t>(picked->blob_base + hash_offset, slots_bytes);
375+
tmp.slot_hashes = std::span<const uint8_t>(picked->blob_base + hash_offset, slots_bytes);
374376

375377
// Compute the cdhash of the picked CD: H_picked(cd_blob[0, blob_len)),
376378
// truncated to CS_CDHASH_LEN. Matches xnu's cs_cd_hash.
377379
{
378380
uint8_t full[CC_SHA384_DIGEST_LENGTH]; // largest supported
379-
switch (out.hash_type) {
381+
switch (tmp.hash_type) {
380382
case CS_HASHTYPE_SHA1: {
381383
Sha1Traits::Ctx c;
382384
Sha1Traits::Init(&c);
@@ -408,7 +410,7 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
408410
}
409411
static_assert(CS_CDHASH_LEN <= CC_SHA1_DIGEST_LENGTH,
410412
"all supported hash digests must be at least CS_CDHASH_LEN");
411-
std::memcpy(out.cdhash, full, CS_CDHASH_LEN);
413+
std::memcpy(tmp.cdhash, full, CS_CDHASH_LEN);
412414
}
413415

414416
// Read the null-terminated identifier string at cd_blob_base + identOffset.
@@ -419,7 +421,7 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
419421
const uint8_t* p = picked->blob_base + ident_off;
420422
size_t max_len = picked->blob_len - ident_off;
421423
size_t actual_len = strnlen(reinterpret_cast<const char*>(p), max_len);
422-
out.identifier.assign(reinterpret_cast<const char*>(p), actual_len);
424+
tmp.identifier.assign(reinterpret_cast<const char*>(p), actual_len);
423425
}
424426
}
425427

@@ -431,10 +433,12 @@ bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
431433
const uint8_t* p = picked->blob_base + team_off;
432434
size_t max_len = picked->blob_len - team_off;
433435
size_t actual_len = strnlen(reinterpret_cast<const char*>(p), max_len);
434-
out.team_id.assign(reinterpret_cast<const char*>(p), actual_len);
436+
tmp.team_id.assign(reinterpret_cast<const char*>(p), actual_len);
435437
}
436438
}
437439

440+
// All checks passed. Commit the fully-populated tmp to out in one move.
441+
out = std::move(tmp);
438442
return true;
439443
}
440444

Source/common/verifyinghasher/CodeSignatureParserTest.mm

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,71 @@ - (void)testCdHashZeroedOnMalformed {
398398
XCTAssertEqual(0, std::memcmp(parsed.cdhash, zero, CS_CDHASH_LEN));
399399
}
400400

401+
// All-or-nothing contract: on `false` return, every parsed-out field
402+
// must be in its default-initialized state — never a mix of stale
403+
// caller data and partial parser writes. Builds a CD that passes the
404+
// early candidate-selection structural checks but fails a later gate
405+
// (insufficient nCodeSlots). Pre-populates `parsed` with sentinel
406+
// values to prove both halves of the contract: reset-on-entry wipes
407+
// caller state, and no field gets re-written before the all-checks-pass
408+
// commit at the bottom of the parser.
409+
- (void)testAllOrNothingOutputOnLateFailure {
410+
constexpr uint32_t kCodeLimit = 8192; // 2 pages at 4 KiB
411+
constexpr uint32_t kHashSize = 32;
412+
constexpr uint32_t kClaimed = 1; // underclaim — fails fewer-slot-hashes gate
413+
const size_t cd_off = sizeof(CS_SuperBlob) + sizeof(CS_BlobIndex);
414+
const size_t cd_sz = sizeof(CS_CodeDirectory) + kClaimed * kHashSize;
415+
const size_t total = cd_off + cd_sz;
416+
std::vector<uint8_t> blob(total, 0);
417+
418+
auto* sb = reinterpret_cast<CS_SuperBlob*>(blob.data());
419+
sb->magic = OSSwapHostToBigInt32(CSMAGIC_EMBEDDED_SIGNATURE);
420+
sb->length = OSSwapHostToBigInt32(static_cast<uint32_t>(total));
421+
sb->count = OSSwapHostToBigInt32(1);
422+
423+
auto* idx = reinterpret_cast<CS_BlobIndex*>(blob.data() + sizeof(CS_SuperBlob));
424+
idx->type = OSSwapHostToBigInt32(CSSLOT_CODEDIRECTORY);
425+
idx->offset = OSSwapHostToBigInt32(static_cast<uint32_t>(cd_off));
426+
427+
auto* cd = reinterpret_cast<CS_CodeDirectory*>(blob.data() + cd_off);
428+
cd->magic = OSSwapHostToBigInt32(CSMAGIC_CODEDIRECTORY);
429+
cd->length = OSSwapHostToBigInt32(static_cast<uint32_t>(cd_sz));
430+
cd->version = 0;
431+
cd->hashOffset = OSSwapHostToBigInt32(static_cast<uint32_t>(sizeof(CS_CodeDirectory)));
432+
cd->codeLimit = OSSwapHostToBigInt32(kCodeLimit);
433+
cd->nCodeSlots = OSSwapHostToBigInt32(kClaimed);
434+
cd->hashSize = kHashSize;
435+
cd->hashType = CS_HASHTYPE_SHA256;
436+
cd->pageSize = 12;
437+
438+
// Pre-populate with sentinels so a regression that skips the reset
439+
// or leaks partial writes is observable.
440+
ParsedCodeDirectory parsed;
441+
parsed.hash_type = 0xAB;
442+
parsed.hash_size = 0xCD;
443+
parsed.page_size = 0xEF;
444+
parsed.code_limit = 0x1234;
445+
parsed.page_count = 0x5678;
446+
parsed.identifier = "stale";
447+
parsed.team_id = "stale";
448+
std::memset(parsed.cdhash, 0xFF, CS_CDHASH_LEN);
449+
450+
std::string err;
451+
XCTAssertFalse(ParseCodeSignature(blob, /*slice_size=*/kCodeLimit, parsed, err));
452+
453+
ParsedCodeDirectory fresh;
454+
XCTAssertEqual(parsed.hash_type, fresh.hash_type);
455+
XCTAssertEqual(parsed.hash_size, fresh.hash_size);
456+
XCTAssertEqual(parsed.page_size, fresh.page_size);
457+
XCTAssertEqual(parsed.code_limit, fresh.code_limit);
458+
XCTAssertEqual(parsed.page_count, fresh.page_count);
459+
XCTAssertTrue(parsed.slot_hashes.empty());
460+
XCTAssertTrue(parsed.cd_bytes.empty());
461+
XCTAssertEqual(0, std::memcmp(parsed.cdhash, fresh.cdhash, CS_CDHASH_LEN));
462+
XCTAssertTrue(parsed.identifier.empty());
463+
XCTAssertTrue(parsed.team_id.empty());
464+
}
465+
401466
- (void)testParsesRealCsBlob {
402467
uint64_t slice_size = 0;
403468
auto blob = ExtractCsBlob("/usr/bin/yes", &slice_size);
@@ -1249,4 +1314,39 @@ - (void)testRejectsMalformedAlternateWithValidCanonical {
12491314
@"expected 'wrong magic' error, got: %s", err.c_str());
12501315
}
12511316

1317+
// cd_bytes contract: must be a non-empty view inside the input blob, and
1318+
// H_hashType(cd_bytes) truncated to CS_CDHASH_LEN must equal parsed.cdhash.
1319+
// Exercised on a real signed binary to cover the integration path.
1320+
- (void)testParseCodeSignaturePopulatesCdBytes {
1321+
uint64_t slice_size = 0;
1322+
auto blob = ExtractCsBlob("/usr/bin/yes", &slice_size);
1323+
XCTAssertFalse(blob.empty());
1324+
1325+
ParsedCodeDirectory parsed;
1326+
std::string err;
1327+
XCTAssertTrue(ParseCodeSignature(blob, slice_size, parsed, err), @"ParseCodeSignature: %s",
1328+
err.c_str());
1329+
1330+
XCTAssertFalse(parsed.cd_bytes.empty());
1331+
XCTAssertGreaterThanOrEqual(parsed.cd_bytes.data(), blob.data());
1332+
XCTAssertLessThanOrEqual(parsed.cd_bytes.data() + parsed.cd_bytes.size(),
1333+
blob.data() + blob.size());
1334+
1335+
uint8_t digest[CC_SHA384_DIGEST_LENGTH];
1336+
switch (parsed.hash_type) {
1337+
case CS_HASHTYPE_SHA256:
1338+
case CS_HASHTYPE_SHA256_TRUNCATED:
1339+
CC_SHA256(parsed.cd_bytes.data(), static_cast<CC_LONG>(parsed.cd_bytes.size()), digest);
1340+
break;
1341+
case CS_HASHTYPE_SHA1:
1342+
CC_SHA1(parsed.cd_bytes.data(), static_cast<CC_LONG>(parsed.cd_bytes.size()), digest);
1343+
break;
1344+
case CS_HASHTYPE_SHA384:
1345+
CC_SHA384(parsed.cd_bytes.data(), static_cast<CC_LONG>(parsed.cd_bytes.size()), digest);
1346+
break;
1347+
default: XCTFail(@"unexpected hash_type %u", parsed.hash_type); return;
1348+
}
1349+
XCTAssertEqual(0, std::memcmp(digest, parsed.cdhash, CS_CDHASH_LEN));
1350+
}
1351+
12521352
@end

Source/common/verifyinghasher/VerifyingHasher.h

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,22 @@
1717

1818
#include <CommonCrypto/CommonDigest.h>
1919
#include <mach/machine.h>
20+
#include <sys/cdefs.h>
2021
#include <sys/types.h>
2122

23+
__BEGIN_DECLS
24+
#include <Kernel/kern/cs_blobs.h> // CS_CDHASH_LEN
25+
__END_DECLS
26+
2227
#include <array>
28+
#include <cstddef>
2329
#include <cstdint>
2430
#include <ctime>
2531
#include <optional>
2632
#include <span>
33+
#include <string>
2734
#include <string_view>
35+
#include <vector>
2836

2937
namespace santa {
3038

@@ -91,6 +99,24 @@ class VerifyingHasher {
9199
// (no .value()) instead of silently consuming an unverified or
92100
// unfinalized digest.
93101
std::optional<std::array<uint8_t, CC_SHA256_DIGEST_LENGTH>> sha256;
102+
103+
// CD-resident fields surfaced from the parsed CodeDirectory for
104+
// BinaryAttestation consumption. Populated only when Run() reaches
105+
// a verified signed-path outcome — i.e., kMatchCDHash or
106+
// kMatchSidTidDrift. nullopt on every other status, including
107+
// kNoMatch (we parsed a CD but the caller asked for one we didn't
108+
// ultimately accept).
109+
std::optional<std::array<uint8_t, CS_CDHASH_LEN>> cdhash;
110+
std::optional<std::string> signing_id;
111+
std::optional<std::string> team_id;
112+
// Owning copy of the picked CodeDirectory blob bytes (header + ids +
113+
// slot hash table). Used by BinaryAttestation as CMSDecoder's
114+
// detached content. Owning rather than view because Core's
115+
// cs_blob_buf_ does not outlive Run().
116+
std::optional<std::vector<uint8_t>> cd_bytes;
117+
// Total embedded code-signature blob size (LC_CODE_SIGNATURE.datasize).
118+
// Used by KernelCsBlob::Fetch as a one-syscall sizing hint.
119+
std::optional<size_t> cs_blob_size;
94120
};
95121

96122
struct RunOptions {

Source/common/verifyinghasher/VerifyingHasher.mm

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,22 @@ bool StatMatches(const struct stat& st, const VerifyingHasher::StatTuple& exp) {
111111
} else {
112112
r.status = Status::kNoMatch;
113113
}
114+
115+
// Surface VH-side fields for downstream BinaryAttestation consumption.
116+
// Populated only when we actually accepted a CD as a verified match.
117+
if (r.status == Status::kMatchCDHash || r.status == Status::kMatchSidTidDrift) {
118+
if (computed_cdhash.size() == CS_CDHASH_LEN) {
119+
std::array<uint8_t, CS_CDHASH_LEN> arr;
120+
std::copy(computed_cdhash.begin(), computed_cdhash.end(), arr.begin());
121+
r.cdhash = arr;
122+
}
123+
if (!parsed.identifier.empty()) r.signing_id = parsed.identifier;
124+
if (!parsed.team_id.empty()) r.team_id = parsed.team_id;
125+
if (!parsed.cd_bytes.empty()) {
126+
r.cd_bytes = std::vector<uint8_t>(parsed.cd_bytes.begin(), parsed.cd_bytes.end());
127+
}
128+
r.cs_blob_size = static_cast<size_t>(core.Slice().cs_blob_size);
129+
}
114130
return r;
115131
}
116132

0 commit comments

Comments
 (0)