Skip to content

Commit c967421

Browse files
authored
Introduce FD-based code-signature verifier (#942)
A new subpackage that verifies a Mach-O slice's code signature against expected (cdhash, signing_id, team_id) using only a borrowed file descriptor. Designed for use on the ES_AUTH_EXEC hot path. Streams the file once: full-file SHA-256, page-hash validation against the picked CodeDirectory, and cdhash compute share a single pass — every byte read from disk is observed at most once per Run() call. Public surface in `Source/common/verifyinghasher/VerifyingHasher.h`: ```cpp VerifyingHasher::Run(fd, cputype, cpusubtype, Expected) -> Result Status ::= { kError, kNoMatch, kMatchCDHash, kMatchSidTidDrift } Result ::= { Status, std::optional<std::array<uint8_t, 32>> sha256 } Expected ::= { std::span<const uint8_t> cdhash, std::string_view signing_id, std::string_view team_id } ``` Exact cdhash equality returns `kMatchCDHash`. A (signing_id, team_id) drift fallback returns `kMatchSidTidDrift` only when both `Expected.team_id` and the parsed CD's team_id are non-empty (ad-hoc binaries are not eligible). The strongest available CodeDirectory by hashType is picked (SHA-384 > SHA-256 > SHA-256-truncated > SHA-1), mirroring xnu's `hashPriorities` in `bsd/kern/ubc_subr.c`. ### Internal layers (package-private; visibility encapsulated by BUILD) | Target | Purpose | | --- | --- | | `FileReader` / `FdFileReader` | pread-only positional file access | | `HeaderParser` | fat / mach_header / LC_CODE_SIGNATURE | | `CodeSignatureParser` | SuperBlob / CodeDirectory / cdhash | | `HashTraits` | `Sha{1,256,256Truncated,384}Traits` | | `PageVerifier` | per-page hash check + gap detection | | `VerifyingHasherCore` | phase orchestration + full-file digest | | `UninitBuffer` | uninit-on-allocate byte buffer | Structural caps and validations track xnu where it has its own bound or runtime check (LC_CODE_SIGNATURE.datasize, sizeofcmds, CD pageSize, scatter rejection, the nCodeSlots-vs-codeLimit asymmetry). ### Tests under `Source/common/verifyinghasher/` - Eight unit-test targets, one per layer plus end-to-end. - The `CountingMemoryFileReader` oracle asserts `MaxReadsAnyByte <= 1` across default, small-buffer, and post-malformed-CS paths. - `hw_universal`: ad-hoc multi-CD fat fixture (SHA-1/256/256t/384). - `hw_team_signed`: team-signed counterpart for the drift path. ### Tests under `Testing/` - `//Testing/OneOffs:verifying_hasher_smoke` runs the public facade against `/usr/bin/yes`. - `//Testing/Fuzzing:VerifyingHasherFuzzer` and `HeaderParserFuzzer` with seed corpora; the end-to-end fuzzer additionally aborts on `MaxReadsAnyByte > 1`. libFuzzer + ASan wired via `--config=fuzz` in `.bazelrc` and the `objc_fuzz_test` macro in `Testing/Fuzzing/fuzzing.bzl`. ### Out of scope for this PR (separate follow-ups) - santad integration (`SNTExecutionController`, `SNTPolicyProcessor`) - handling for unsigned binaries (LC_CODE_SIGNATURE absent → `kError`; fallback hashing handled by the consuming caller) - `SNTFileInfo` / `MOLCodesignChecker` edits - configuration keys, telemetry, rollout flags - CS_KILL/CS_HARD page-hash skip optimization
1 parent f38092e commit c967421

46 files changed

Lines changed: 6743 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.bazelrc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,11 @@ build:ubsan --copt="-DUNDEFINED_SANITIZER=1"
6565
build:ubsan --copt="-fno-sanitize=function" --copt="-fno-sanitize=vptr"
6666
build:ubsan --linkopt="-fsanitize=undefined"
6767
build:ubsan --test_env="UBSAN_OPTIONS=log_path=/tmp/san_out"
68+
69+
# libFuzzer + ASan for fuzz targets under //Testing/Fuzzing/...
70+
# Mirrors the prototype's --config=fuzz configuration; layered on top of
71+
# the shared san-common block (also used by --config=asan/tsan/ubsan).
72+
build:fuzz --config=san-common
73+
build:fuzz --@rules_fuzzing//fuzzing:cc_engine=@rules_fuzzing//fuzzing/engines:libfuzzer
74+
build:fuzz --@rules_fuzzing//fuzzing:cc_engine_instrumentation=libfuzzer
75+
build:fuzz --@rules_fuzzing//fuzzing:cc_engine_sanitizer=asan

MODULE.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ bazel_dep(name = "googletest", version = "1.17.0.bcr.2")
77
bazel_dep(name = "protobuf", version = "33.6")
88
bazel_dep(name = "rules_apple", version = "4.3.3")
99
bazel_dep(name = "rules_cc", version = "0.2.17")
10+
bazel_dep(name = "rules_fuzzing", version = "0.6.0")
1011
bazel_dep(name = "rules_shell", version = "0.6.1")
1112
bazel_dep(name = "rules_swift", version = "2.9.0")
1213
bazel_dep(name = "xxhash", version = "0.8.3.bcr.1")

Source/common/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,7 @@ test_suite(
11431143
"//Source/common/cel:CELTest",
11441144
"//Source/common/faa:unit_tests",
11451145
"//Source/common/ne:unit_tests",
1146+
"//Source/common/verifyinghasher:unit_tests",
11461147
],
11471148
visibility = ["//:santa_package_group"],
11481149
)
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
load("@rules_cc//cc:defs.bzl", "objc_library")
2+
load("//:helper.bzl", "santa_unit_test")
3+
4+
# Default visibility scoped to:
5+
# - this subpackage's own tests
6+
# - the CLI in Testing/OneOffs
7+
# - the fuzz harnesses in Testing/Fuzzing
8+
# The public facade target (:VerifyingHasher, defined below) gets
9+
# an explicit per-target visibility list that includes
10+
# //Source/santad:__subpackages__. Internal targets remain
11+
# package-private to santad to enforce that only the facade is consumed
12+
# outside this subpackage.
13+
package(
14+
default_visibility = [
15+
"//Source/common/verifyinghasher:__pkg__",
16+
"//Testing/Fuzzing:__pkg__",
17+
"//Testing/OneOffs:__pkg__",
18+
],
19+
)
20+
21+
licenses(["notice"])
22+
23+
objc_library(
24+
name = "FileReader",
25+
srcs = ["FileReader.mm"],
26+
hdrs = ["FileReader.h"],
27+
)
28+
29+
objc_library(
30+
name = "MemoryFileReader",
31+
hdrs = ["MemoryFileReader.h"],
32+
deps = [":FileReader"],
33+
)
34+
35+
objc_library(
36+
name = "CountingMemoryFileReader",
37+
hdrs = ["CountingMemoryFileReader.h"],
38+
deps = [":FileReader"],
39+
)
40+
41+
santa_unit_test(
42+
name = "FileReaderTest",
43+
srcs = ["FileReaderTest.mm"],
44+
deps = [
45+
":FileReader",
46+
":MemoryFileReader",
47+
"//Source/common:ScopedFile",
48+
],
49+
)
50+
51+
objc_library(
52+
name = "HashTraits",
53+
hdrs = ["HashTraits.h"],
54+
)
55+
56+
santa_unit_test(
57+
name = "HashTraitsTest",
58+
srcs = ["HashTraitsTest.mm"],
59+
deps = [":HashTraits"],
60+
)
61+
62+
objc_library(
63+
name = "UninitBuffer",
64+
hdrs = ["UninitBuffer.h"],
65+
)
66+
67+
santa_unit_test(
68+
name = "UninitBufferTest",
69+
srcs = ["UninitBufferTest.mm"],
70+
deps = [":UninitBuffer"],
71+
)
72+
73+
objc_library(
74+
name = "HeaderParser",
75+
srcs = ["HeaderParser.mm"],
76+
hdrs = ["HeaderParser.h"],
77+
)
78+
79+
santa_unit_test(
80+
name = "HeaderParserTest",
81+
srcs = ["HeaderParserTest.mm"],
82+
structured_resources = [":hw_universal_fixture"],
83+
deps = [
84+
":FileReader",
85+
":HeaderParser",
86+
":MemoryFileReader",
87+
"//Source/common:ScopedFile",
88+
],
89+
)
90+
91+
objc_library(
92+
name = "CodeSignatureParser",
93+
srcs = ["CodeSignatureParser.mm"],
94+
hdrs = ["CodeSignatureParser.h"],
95+
deps = [":HashTraits"],
96+
)
97+
98+
santa_unit_test(
99+
name = "CodeSignatureParserTest",
100+
srcs = ["CodeSignatureParserTest.mm"],
101+
deps = [
102+
":CodeSignatureParser",
103+
"//Source/common:ScopedFile",
104+
],
105+
)
106+
107+
objc_library(
108+
name = "PageVerifier",
109+
hdrs = ["PageVerifier.h"],
110+
deps = [":HashTraits"],
111+
)
112+
113+
santa_unit_test(
114+
name = "PageVerifierTest",
115+
srcs = ["PageVerifierTest.mm"],
116+
deps = [":PageVerifier"],
117+
)
118+
119+
objc_library(
120+
name = "VerifyingHasherCore",
121+
srcs = ["VerifyingHasherCore.mm"],
122+
hdrs = ["VerifyingHasherCore.h"],
123+
deps = [
124+
":CodeSignatureParser",
125+
":FileReader",
126+
":HashTraits",
127+
":HeaderParser",
128+
":PageVerifier",
129+
":UninitBuffer",
130+
],
131+
)
132+
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.
136+
filegroup(
137+
name = "hw_universal_fixture",
138+
srcs = ["testdata/hw_universal"],
139+
visibility = [
140+
"//Source/common/verifyinghasher:__pkg__",
141+
"//Testing/Fuzzing:__pkg__",
142+
],
143+
)
144+
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.
151+
filegroup(
152+
name = "hw_team_signed_fixture",
153+
srcs = ["testdata/hw_team_signed"],
154+
visibility = ["//Source/common/verifyinghasher:__pkg__"],
155+
)
156+
157+
santa_unit_test(
158+
name = "VerifyingHasherCoreTest",
159+
srcs = ["VerifyingHasherCoreTest.mm"],
160+
structured_resources = [":hw_universal_fixture"],
161+
deps = [
162+
":CountingMemoryFileReader",
163+
":MemoryFileReader",
164+
":VerifyingHasherCore",
165+
"//Source/common:ScopedFile",
166+
],
167+
)
168+
169+
objc_library(
170+
name = "VerifyingHasher",
171+
srcs = ["VerifyingHasher.mm"],
172+
hdrs = ["VerifyingHasher.h"],
173+
visibility = [
174+
"//Source/common/verifyinghasher:__pkg__",
175+
"//Source/santad:__subpackages__",
176+
"//Testing/Fuzzing:__pkg__",
177+
"//Testing/OneOffs:__pkg__",
178+
],
179+
deps = [
180+
":CodeSignatureParser",
181+
":FileReader",
182+
":VerifyingHasherCore",
183+
],
184+
)
185+
186+
santa_unit_test(
187+
name = "VerifyingHasherTest",
188+
srcs = ["VerifyingHasherTest.mm"],
189+
structured_resources = [
190+
":hw_team_signed_fixture",
191+
":hw_universal_fixture",
192+
],
193+
deps = [
194+
":VerifyingHasher",
195+
"//Source/common:ScopedFile",
196+
],
197+
)
198+
199+
test_suite(
200+
name = "unit_tests",
201+
tests = [
202+
":CodeSignatureParserTest",
203+
":FileReaderTest",
204+
":HashTraitsTest",
205+
":HeaderParserTest",
206+
":PageVerifierTest",
207+
":UninitBufferTest",
208+
":VerifyingHasherCoreTest",
209+
":VerifyingHasherTest",
210+
],
211+
)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/// Copyright 2026 North Pole Security, Inc.
2+
///
3+
/// Licensed under the Apache License, Version 2.0 (the "License");
4+
/// you may not use this file except in compliance with the License.
5+
/// You may obtain a copy of the License at
6+
///
7+
/// http://www.apache.org/licenses/LICENSE-2.0
8+
///
9+
/// Unless required by applicable law or agreed to in writing, software
10+
/// distributed under the License is distributed on an "AS IS" BASIS,
11+
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
/// See the License for the specific language governing permissions and
13+
/// limitations under the License.
14+
15+
#ifndef SANTA_COMMON_VERIFYINGHASHER_CODESIGNATUREPARSER_H
16+
#define SANTA_COMMON_VERIFYINGHASHER_CODESIGNATUREPARSER_H
17+
18+
#include <sys/cdefs.h>
19+
20+
__BEGIN_DECLS
21+
#include <Kernel/kern/cs_blobs.h>
22+
__END_DECLS
23+
24+
#include <cstddef>
25+
#include <cstdint>
26+
#include <span>
27+
#include <string>
28+
#include <string_view>
29+
30+
namespace santa {
31+
32+
// `slot_hashes` is a non-owning view into the input blob passed to
33+
// ParseCodeSignature(); the caller must keep that blob alive for as long
34+
// as ParsedCodeDirectory is used. `cdhash` is a fixed-size in-line array,
35+
// owned and self-contained. VerifyingHasherCore holds parsed_cd_ alongside the
36+
// cs_blob_buf_ that backs the view, so they share the verifier's lifetime.
37+
struct ParsedCodeDirectory {
38+
uint8_t hash_type = 0; // CS_HASHTYPE_*
39+
uint8_t hash_size = 0; // 20, 32, or 48
40+
uint32_t page_size = 0;
41+
uint64_t code_limit = 0;
42+
uint32_t page_count = 0;
43+
std::span<const uint8_t> slot_hashes; // page_count * hash_size bytes
44+
// 20-byte truncated cdhash of this CodeDirectory blob, computed using
45+
// its own hashType (matches xnu's cs_cd_hash and es_cdhash_t).
46+
uint8_t cdhash[CS_CDHASH_LEN] = {};
47+
std::string
48+
identifier; // signing identifier from CD identOffset (empty if absent)
49+
std::string team_id; // team id from CD teamOffset (empty if absent or
50+
// pre-CS_SUPPORTSTEAMID)
51+
};
52+
53+
// Parse a CS_SuperBlob already pread'd into memory. `slice_size` is used
54+
// for the codeLimit-fits-in-slice validation. Returns true on success;
55+
// false sets `err` to a diagnostic string.
56+
//
57+
// Selects the strongest available CD by hashType:
58+
// SHA-384 > SHA-256 > SHA-256-TRUNCATED > SHA-1
59+
// Skips alternates of unsupported types.
60+
bool ParseCodeSignature(std::span<const uint8_t> blob, uint64_t slice_size,
61+
ParsedCodeDirectory& out, std::string& err);
62+
63+
} // namespace santa
64+
65+
#endif // SANTA_COMMON_VERIFYINGHASHER_CODESIGNATUREPARSER_H

0 commit comments

Comments
 (0)