Skip to content

Commit d3cd14f

Browse files
authored
Add delegated signal handling support (#932)
Fixes SNT-404
1 parent 77745f4 commit d3cd14f

9 files changed

Lines changed: 208 additions & 11 deletions

Source/common/Platform.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,11 @@
3434
#undef HAVE_MACOS_15_4
3535
#endif
3636

37+
#if defined(MAC_OS_VERSION_15_5) && \
38+
__MAC_OS_X_VERSION_MAX_ALLOWED >= MAC_OS_VERSION_15_5
39+
#define HAVE_MACOS_15_5 1
40+
#else
41+
#undef HAVE_MACOS_15_5
42+
#endif
43+
3744
#endif // SANTA_COMMON_PLATFORM_H

Source/common/SNTConfigurator.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,15 @@
196196
///
197197
@property(readonly, nonatomic, nullable) NSArray<NSString*>* antiSuspendSigningIDs;
198198

199+
///
200+
/// When YES, signals delegated by launchd on behalf of any Apple platform
201+
/// binary (instigator->is_platform_binary == true) targeting santad are
202+
/// allowed, in addition to the hardcoded allowlist baked into the tamper
203+
/// resistance client. Defaults to NO. Available on macOS 15.5+ where the ES
204+
/// SIGNAL event exposes the instigator field; ignored on older OSes.
205+
///
206+
@property(readonly, nonatomic) BOOL allowDelegatedSignals;
207+
199208
///
200209
/// Defines how event logs are stored. Options are:
201210
/// SNTEventLogTypeSyslog "syslog": Sent to ASL or ULS (if built with the 10.12 SDK or later).

Source/common/SNTConfigurator.mm

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ @implementation SNTConfigurator
175175
static NSString* const kEnableAntiTamperProcessSuspendResumeKey =
176176
@"EnableAntiTamperProcessSuspendResume";
177177
static NSString* const kAntiSuspendSigningIDsKey = @"AntiSuspendSigningIDs";
178+
static NSString* const kAllowDelegatedSignalsKey = @"AllowDelegatedSignals";
178179
static NSString* const kFailClosedKey = @"FailClosed";
179180
static NSString* const kDisableUnknownEventUploadKey = @"DisableUnknownEventUpload";
180181

@@ -355,6 +356,7 @@ - (instancetype)initWithSyncStateFile:(NSString*)syncStateFilePath
355356
kEnableBadSignatureProtectionKey : number,
356357
kEnableAntiTamperProcessSuspendResumeKey : number,
357358
kAntiSuspendSigningIDsKey : array,
359+
kAllowDelegatedSignalsKey : number,
358360
kEnableStandalonePasswordFallbackKey : number,
359361
kEnableSilentModeKey : number,
360362
kEnableSilentTTYModeKey : number,
@@ -818,6 +820,10 @@ + (NSSet*)keyPathsForValuesAffectingAntiSuspendSigningIDs {
818820
return [self configStateSet];
819821
}
820822

823+
+ (NSSet*)keyPathsForValuesAffectingAllowDelegatedSignals {
824+
return [self configStateSet];
825+
}
826+
821827
+ (NSSet*)keyPathsForValuesAffectingRemovableMediaAction {
822828
return [self syncAndConfigStateSet];
823829
}
@@ -1181,6 +1187,11 @@ - (BOOL)enableAntiTamperProcessSuspendResume {
11811187
return EnsureArrayOfStrings(self.configState[kAntiSuspendSigningIDsKey]);
11821188
}
11831189

1190+
- (BOOL)allowDelegatedSignals {
1191+
NSNumber* number = self.configState[kAllowDelegatedSignalsKey];
1192+
return number ? [number boolValue] : NO;
1193+
}
1194+
11841195
- (BOOL)enableStandalonePasswordFallback {
11851196
NSNumber* number = self.configState[kEnableStandalonePasswordFallbackKey];
11861197
return number ? [number boolValue] : YES;

Source/common/SNTConfiguratorTest.mm

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,4 +269,20 @@ - (void)testTelemetryFilterExpressions {
269269
}
270270
}
271271

272+
- (void)testAllowDelegatedSignalsDefault {
273+
SNTConfigurator* sut = [[SNTConfigurator alloc] init];
274+
// Default must be NO
275+
XCTAssertFalse(sut.allowDelegatedSignals);
276+
}
277+
278+
- (void)testAllowDelegatedSignalsOverride {
279+
SNTConfigurator* sut = [[SNTConfigurator alloc] init];
280+
281+
sut.configState[@"AllowDelegatedSignals"] = @YES;
282+
XCTAssertTrue(sut.allowDelegatedSignals);
283+
284+
sut.configState[@"AllowDelegatedSignals"] = @NO;
285+
XCTAssertFalse(sut.allowDelegatedSignals);
286+
}
287+
272288
@end

Source/santad/EventProviders/SNTEndpointSecurityTamperResistance.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,16 @@ NS_ASSUME_NONNULL_BEGIN
3333
/// Accepts an NSArray of NSStrings but stores internally as a hash set for O(1) lookup.
3434
- (void)setAntiSuspendSigningIDs:(nullable NSArray<NSString*>*)antiSuspendSigningIDs;
3535

36+
/// When YES, signals delegated by launchd on behalf of any Apple platform
37+
/// binary may target santad, in addition to the hardcoded allowlist.
38+
/// Synchronized for safe concurrent access; live-updated from KVO.
39+
@property(atomic) BOOL allowDelegatedSignals;
40+
3641
- (instancetype)initWithESAPI:(std::shared_ptr<santa::EndpointSecurityAPI>)esApi
3742
metrics:(std::shared_ptr<santa::ESMetricsObserver>)metrics
3843
logger:(std::shared_ptr<santa::Logger>)logger
39-
antiSuspendSigningIDs:(nullable NSArray<NSString*>*)antiSuspendSigningIDs;
44+
antiSuspendSigningIDs:(nullable NSArray<NSString*>*)antiSuspendSigningIDs
45+
allowDelegatedSignals:(BOOL)allowDelegatedSignals;
4046

4147
@end
4248

Source/santad/EventProviders/SNTEndpointSecurityTamperResistance.mm

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
#include "absl/container/flat_hash_set.h"
3131
#include "absl/synchronization/mutex.h"
3232

33+
#import "Source/common/Platform.h"
3334
#import "Source/common/SNTConfigurator.h"
3435
#import "Source/common/SNTLogging.h"
3536
#import "Source/common/SigningIDHelpers.h"
@@ -58,6 +59,20 @@
5859
{"/Library/LaunchDaemons/com.northpolesec.santa.", WatchItemPathType::kPrefix},
5960
};
6061

62+
#if HAVE_MACOS_15_5
63+
// Platform-binary signing-IDs that Santa trusts to ask launchd to deliver
64+
// a signal to santad. Extended at runtime via the `AllowDelegatedSignals`
65+
// config (any platform binary is trusted when YES).
66+
static const absl::flat_hash_set<std::string> kTrustedSignalInstigators = {
67+
// launchd's own signing-id; covers internal launchd flows that surface
68+
// launchd as the instigator (e.g., bootout teardown).
69+
"platform:com.apple.xpc.launchd",
70+
// /usr/libexec/smd, the Service Management daemon, drives
71+
// SMAppService / SMJobBless flows that may signal santad.
72+
"platform:com.apple.xpc.smd",
73+
};
74+
#endif // HAVE_MACOS_15_5
75+
6176
void RemoveLegacyLaunchdPlists() {
6277
constexpr std::string_view legacyPlists[] = {
6378
"/Library/LaunchDaemons/com.google.santad.plist",
@@ -156,12 +171,14 @@ @implementation SNTEndpointSecurityTamperResistance {
156171
- (instancetype)initWithESAPI:(std::shared_ptr<EndpointSecurityAPI>)esApi
157172
metrics:(std::shared_ptr<santa::ESMetricsObserver>)metrics
158173
logger:(std::shared_ptr<Logger>)logger
159-
antiSuspendSigningIDs:(NSArray<NSString*>*)antiSuspendSigningIDs {
174+
antiSuspendSigningIDs:(NSArray<NSString*>*)antiSuspendSigningIDs
175+
allowDelegatedSignals:(BOOL)allowDelegatedSignals {
160176
self = [super initWithESAPI:std::move(esApi)
161177
metrics:std::move(metrics)
162178
processor:santa::Processor::kTamperResistance];
163179
if (self) {
164180
_logger = logger;
181+
self.allowDelegatedSignals = allowDelegatedSignals;
165182
[self setAntiSuspendSigningIDs:antiSuspendSigningIDs];
166183

167184
[self establishClientOrDie];
@@ -269,22 +286,56 @@ - (void)handleMessage:(Message&&)esMsg
269286
}
270287

271288
case ES_EVENT_TYPE_AUTH_SIGNAL: {
289+
// signal event type in ES does not support caching
290+
cacheable = false;
272291
if (esMsg->event.signal.sig == 0) {
273292
// Signal 0 doesn't actually get sent to the process, it is only used to
274293
// check if the process exists. Because of this, we don't need to block it.
275294
break;
276295
}
277296

278-
// Only block signals sent to us and not from launchd.
279297
pid_t sourcePid = audit_token_to_pid(esMsg->process->audit_token);
280298
pid_t targetPid = audit_token_to_pid(esMsg->event.signal.target->audit_token);
281-
if (targetPid == getpid() && sourcePid != 1) {
299+
300+
if (targetPid != getpid()) {
301+
break;
302+
}
303+
304+
// Only launchd may deliver signals to santad.
305+
if (sourcePid != 1) {
282306
LOGW(@"Preventing attempt to signal Santa daemon: signal %d, sending pid: %d, sending "
283307
@"process: %@",
284308
esMsg->event.signal.sig, sourcePid,
285309
santa::StringTokenToNSString(esMsg->process->executable->path));
286310
result = ES_AUTH_RESULT_DENY;
311+
break;
312+
}
313+
314+
// When launchd delivers a signal on behalf of another process (msg
315+
// version >= 9 with non-NULL instigator), the originator must match
316+
// kTrustedSignalInstigators or be a platform binary while
317+
// AllowDelegatedSignals is YES.
318+
#if HAVE_MACOS_15_5
319+
if (esMsg->version >= 9) {
320+
const es_process_t* instigator = esMsg->event.signal.instigator;
321+
if (instigator != NULL) {
322+
NSString* signingId = FormatSigningID(
323+
santa::StringTokenToNSString(instigator->signing_id),
324+
santa::StringTokenToNSString(instigator->team_id), instigator->is_platform_binary);
325+
bool isAllowlisted = signingId && kTrustedSignalInstigators.contains(
326+
santa::NSStringToUTF8String(signingId));
327+
bool isPermittedPlatformBinary =
328+
self.allowDelegatedSignals && instigator->is_platform_binary;
329+
if (!isAllowlisted && !isPermittedPlatformBinary) {
330+
LOGW(@"Preventing delegated signal to Santa daemon: signal %d, "
331+
@"instigator pid: %d, instigator process: %@",
332+
esMsg->event.signal.sig, audit_token_to_pid(instigator->audit_token),
333+
santa::StringTokenToNSString(instigator->executable->path));
334+
result = ES_AUTH_RESULT_DENY;
335+
}
336+
}
287337
}
338+
#endif // HAVE_MACOS_15_5
288339
break;
289340
}
290341

Source/santad/EventProviders/SNTEndpointSecurityTamperResistanceTest.mm

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
#include <memory>
2626
#include <set>
2727

28+
#import "Source/common/Platform.h"
2829
#import "Source/common/SNTConfigurator.h"
2930
#include "Source/common/TestUtils.h"
3031
#include "Source/common/es/Client.h"
@@ -90,7 +91,8 @@ - (void)testEnable {
9091
[[SNTEndpointSecurityTamperResistance alloc] initWithESAPI:mockESApi
9192
metrics:nullptr
9293
logger:nullptr
93-
antiSuspendSigningIDs:nil];
94+
antiSuspendSigningIDs:nil
95+
allowDelegatedSignals:NO];
9496
id mockTamperClient = OCMPartialMock(tamperClient);
9597

9698
[mockTamperClient enable];
@@ -136,7 +138,8 @@ - (void)testEnableWithAntiSuspendSigningIDs {
136138
initWithESAPI:mockESApi
137139
metrics:nullptr
138140
logger:nullptr
139-
antiSuspendSigningIDs:@[ @"ABCDE12345:com.example.protected" ]];
141+
antiSuspendSigningIDs:@[ @"ABCDE12345:com.example.protected" ]
142+
allowDelegatedSignals:NO];
140143
id mockTamperClient = OCMPartialMock(tamperClient);
141144

142145
[mockTamperClient enable];
@@ -178,7 +181,8 @@ - (void)testSetAntiSuspendSigningIDsAfterEnable {
178181
[[SNTEndpointSecurityTamperResistance alloc] initWithESAPI:mockESApi
179182
metrics:nullptr
180183
logger:nullptr
181-
antiSuspendSigningIDs:nil];
184+
antiSuspendSigningIDs:nil
185+
allowDelegatedSignals:NO];
182186
id mockTamperClient = OCMPartialMock(tamperClient);
183187

184188
[mockTamperClient enable];
@@ -237,7 +241,8 @@ - (void)testHandleMessage {
237241
[[SNTEndpointSecurityTamperResistance alloc] initWithESAPI:mockESApi
238242
metrics:nullptr
239243
logger:nullptr
240-
antiSuspendSigningIDs:nil];
244+
antiSuspendSigningIDs:nil
245+
allowDelegatedSignals:NO];
241246

242247
id mockTamperClient = OCMPartialMock(tamperClient);
243248

@@ -528,9 +533,80 @@ - (void)testHandleMessage {
528533

529534
XCTAssertSemaTrue(semaMetrics, 5, "Metrics not recorded within expected window");
530535
XCTAssertEqual(gotAuthResult, kv.second);
531-
XCTAssertEqual(gotCachable, kv.second == ES_AUTH_RESULT_ALLOW);
536+
XCTAssertFalse(gotCachable);
537+
}
538+
}
539+
540+
// Check SIGNAL tamper events with v9 instigator field
541+
#if HAVE_MACOS_15_5
542+
{
543+
esMsg.event_type = ES_EVENT_TYPE_AUTH_SIGNAL;
544+
esMsg.version = 9;
545+
546+
struct InstigatorCase {
547+
const char* desc;
548+
bool allowDelegatedSignals;
549+
bool hasInstigator;
550+
bool instigatorIsPlatform;
551+
const char* instigatorTeamID;
552+
const char* instigatorSigningID;
553+
es_auth_result_t expected;
554+
};
555+
556+
static const InstigatorCase cases[] = {
557+
// Direct signal from launchd (no instigator) -> ALLOW (shutdown path).
558+
{"direct from launchd", /*allow=*/false, /*hasInst=*/false,
559+
/*plat=*/false, "", "", ES_AUTH_RESULT_ALLOW},
560+
// Delegated by launchd itself -> ALLOW (baseline).
561+
{"baseline launchd", false, true, true, "", "com.apple.xpc.launchd", ES_AUTH_RESULT_ALLOW},
562+
// Delegated by smd -> ALLOW (baseline).
563+
{"baseline smd", false, true, true, "", "com.apple.xpc.smd", ES_AUTH_RESULT_ALLOW},
564+
// Unlisted platform binary with AllowDelegatedSignals=NO -> DENY.
565+
{"unlisted platform, AllowDelegatedSignals=NO", false, true, true, "", "com.apple.unknown",
566+
ES_AUTH_RESULT_DENY},
567+
// Same instigator with AllowDelegatedSignals=YES -> ALLOW.
568+
{"unlisted platform, AllowDelegatedSignals=YES", true, true, true, "", "com.apple.unknown",
569+
ES_AUTH_RESULT_ALLOW},
570+
// Third-party-signed instigator with AllowDelegatedSignals=YES -> DENY
571+
// (the config only relaxes when is_platform_binary == true).
572+
{"third party, AllowDelegatedSignals=YES", true, true, false, "ABCDE12345", "com.evil.app",
573+
ES_AUTH_RESULT_DENY},
574+
};
575+
576+
for (const auto& c : cases) {
577+
[tamperClient setAllowDelegatedSignals:c.allowDelegatedSignals];
578+
Message msg(mockESApi, &esMsg);
579+
580+
es_process_t target_proc = MakeESProcess(&file);
581+
target_proc.audit_token = MakeAuditToken(getpid(), 42);
582+
esMsg.event.signal.target = &target_proc;
583+
esMsg.process->audit_token = MakeAuditToken(1, 42); // sender = launchd
584+
585+
es_file_t instigatorFile = MakeESFile("/usr/libexec/instigator");
586+
es_process_t instigator_proc = MakeESProcess(&instigatorFile);
587+
instigator_proc.team_id = MakeESStringToken(c.instigatorTeamID);
588+
instigator_proc.signing_id = MakeESStringToken(c.instigatorSigningID);
589+
instigator_proc.is_platform_binary = c.instigatorIsPlatform;
590+
esMsg.event.signal.instigator = c.hasInstigator ? &instigator_proc : NULL;
591+
592+
[mockTamperClient handleMessage:std::move(msg)
593+
recordEventMetrics:^(EventDisposition d) {
594+
XCTAssertEqual(d,
595+
c.expected == ES_AUTH_RESULT_DENY ? EventDisposition::kProcessed
596+
: EventDisposition::kDropped,
597+
@"%s", c.desc);
598+
dispatch_semaphore_signal(semaMetrics);
599+
}];
600+
601+
XCTAssertSemaTrue(semaMetrics, 5, "Metrics not recorded within expected window");
602+
XCTAssertEqual(gotAuthResult, c.expected, @"%s", c.desc);
532603
}
604+
605+
// Reset shared state for downstream test blocks.
606+
esMsg.event.signal.instigator = NULL;
607+
esMsg.version = MaxSupportedESMessageVersionForCurrentOS();
533608
}
609+
#endif // HAVE_MACOS_15_5
534610

535611
// Check PROC_SUSPEND_RESUME tamper events - EnableAntiTamperProcessSuspendResume = NO
536612
{
@@ -742,7 +818,8 @@ - (void)testHandleMessageTruncatedPath {
742818
[[SNTEndpointSecurityTamperResistance alloc] initWithESAPI:mockESApi
743819
metrics:nullptr
744820
logger:nullptr
745-
antiSuspendSigningIDs:nil];
821+
antiSuspendSigningIDs:nil
822+
allowDelegatedSignals:NO];
746823

747824
id mockTamperClient = OCMPartialMock(tamperClient);
748825

Source/santad/Santad.mm

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ void SantadMain(std::shared_ptr<EndpointSecurityAPI> esapi, std::shared_ptr<Logg
168168
initWithESAPI:esapi
169169
metrics:metrics
170170
logger:logger
171-
antiSuspendSigningIDs:[configurator antiSuspendSigningIDs]];
171+
antiSuspendSigningIDs:[configurator antiSuspendSigningIDs]
172+
allowDelegatedSignals:[configurator allowDelegatedSignals]];
172173

173174
auto faaPolicyProcessor = std::make_shared<santa::FAAPolicyProcessor>(
174175
[SNTDecisionCache sharedCache], enricher, logger, tty_writer, metrics,
@@ -476,6 +477,15 @@ void SantadMain(std::shared_ptr<EndpointSecurityAPI> esapi, std::shared_ptr<Logg
476477
LOGI(@"AntiSuspendSigningIDs changed");
477478
[tamper_client setAntiSuspendSigningIDs:newValue];
478479
}],
480+
[[SNTKVOManager alloc] initWithObject:configurator
481+
selector:@selector(allowDelegatedSignals)
482+
type:[NSNumber class]
483+
callback:^(NSNumber* oldValue, NSNumber* newValue) {
484+
if ([oldValue isEqual:newValue]) return;
485+
486+
LOGI(@"AllowDelegatedSignals changed: %@", newValue);
487+
[tamper_client setAllowDelegatedSignals:[newValue boolValue]];
488+
}],
479489
[[SNTKVOManager alloc] initWithObject:configurator
480490
selector:@selector(staticRules)
481491
type:[NSArray class]

docs/src/lib/santaconfig.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ export const SantaConfigKeyGroups: SantaConfigGroups = {
155155
],
156156
versionAdded: "2026.3",
157157
},
158+
{
159+
key: "AllowDelegatedSignals",
160+
description:
161+
`If true, signals delegated by \'launchd\' on behalf of any Apple platform binary targeting \'santad\'
162+
are allowed.`,
163+
type: "bool",
164+
defaultValue: false,
165+
syncConfigurable: false,
166+
versionAdded: "2026.4",
167+
},
158168
],
159169
gui: [
160170
{

0 commit comments

Comments
 (0)