SMJobBless fails when called from an XPC Service.

Hey there,

I'm trying to employ the same pattern as demonstrated in the EvenBetterAuthorizationSample: an unsandboxed XPC service calls SMJobBless to install a privileged helper service on behalf a sandboxed main app (which isn't allowed to call SMJobBless). It then starts an XPC connection to the Mach service hosted by the privileged service, and hands over the connection back to the main app, along with the XPC service's connection to the security server (AuthorizationRef).

When I try to do call SMJobBless from my XPC service, I get these messages:

info    authd   Process /usr/libexec/smd (PID 28881) evaluates 1 rights with flags 00000003 (engine 629): (
    "com.apple.ServiceManagement.blesshelper"
)

error   authd   Fatal: interaction not allowed (session has no ui access) (engine 629)

default authd   Failed to authorize right 'com.apple.ServiceManagement.blesshelper' by client '/usr/libexec/smd' [28881] for authorization created by '/MyApp.app/Contents/XPCServices/IntermediatorXPCService.xpc' [29325] (3,0) (-60007) (engine 629)

error   authd   copy_rights: authorization failed

This seems reasonable to me, because I wouldn't expect an XPC service to be capable of running graphics. However, this works just fine in the "App-Sandboxed" app in the EvenBetterAuthorizationSample project.

I poked around the available open source code, and found out that this message is logged when the processes' audit session doesn't have AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS set.

if (!(session_get_attributes(auth_token_get_session(engine->auth)) & AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS)) {
    os_log_error(AUTHD_LOG, "Fatal: interaction not allowed (session has no ui access) (engine %lld)", engine->engine_index);
    return errAuthorizationInteractionNotAllowed;
}

Out of curiosity, I compared the audit sessions of my XPC service to the one in EBAS using this code:

auditinfo_addr_t auditInfo;
int result = getaudit_addr(&auditInfo, sizeof(auditInfo));

assert(result == 0 );

if (auditInfo.ai_flags & AU_SESSION_FLAG_IS_INITIAL) {
    NSLog(@"AU_SESSION_FLAG_IS_INITIAL");
}
if (auditInfo.ai_flags & AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS) {
    NSLog(@"AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS");
}
if (auditInfo.ai_flags & AU_SESSION_FLAG_HAS_TTY) {
    NSLog(@"AU_SESSION_FLAG_HAS_TTY");
}
if (auditInfo.ai_flags & AU_SESSION_FLAG_IS_REMOTE) {
    NSLog(@"AU_SESSION_FLAG_IS_REMOTE");
}
if (auditInfo.ai_flags & AU_SESSION_FLAG_HAS_CONSOLE_ACCESS) {
    NSLog(@"AU_SESSION_FLAG_HAS_CONSOLE_ACCESS");
}
if (auditInfo.ai_flags & AU_SESSION_FLAG_HAS_AUTHENTICATED) {
    NSLog(@"AU_SESSION_FLAG_HAS_AUTHENTICATED");
}

Sure enough, I got different results.

EBAS:

2021-11-20 18:45:52.792512-0500 com.example.apple-samplecode.EBAS.XPCService[25296:592874] result: 0
2021-11-20 18:45:52.792527-0500 com.example.apple-samplecode.EBAS.XPCService[25296:592874] AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS
2021-11-20 18:45:52.792539-0500 com.example.apple-samplecode.EBAS.XPCService[25296:592874] AU_SESSION_FLAG_HAS_TTY
2021-11-20 18:45:52.792549-0500 com.example.apple-samplecode.EBAS.XPCService[25296:592874] AU_SESSION_FLAG_HAS_CONSOLE_ACCESS
(lldb) p auditInfo
(auditinfo_addr_t) $0 = {
  ai_auid = 501
  ai_mask = (am_success = 4294967295, am_failure = 4294967295)
  ai_termid = {
    at_port = 50331650
    at_type = 4
    at_addr = ([0] = 0, [1] = 0, [2] = 0, [3] = 0)
  }
  ai_asid = 100019
  ai_flags = 8240
}

My XPC service:

2021-11-20 21:33:44.355007-0500 IntermediatorXPCService[29325:698278] result: 0
(lldb) p auditInfo
▿ __C.auditinfo_addr
  - ai_auid: 4294967295
  ▿ ai_mask: __C.au_mask
    - am_success: 4294967295
    - am_failure: 4294967295
  ▿ ai_termid: __C.au_tid_addr
    - at_port: 0
    - at_type: 4
    ▿ at_addr: (4 elements)
      - .0: 0
      - .1: 0
      - .2: 0
      - .3: 0
  - ai_asid: 102293
  - ai_flags: 0

It looks like ai_flags is all 0. Any ideas why that might be? What is making EBAS special?

And also, how can AU_SESSION_FLAG_HAS_TTY and AU_SESSION_FLAG_HAS_CONSOLE_ACCESS be false? I'm reading these logs from the console?!

(Another curious observation: audit_session_flags is imported into Swift as RawRepresentable, but not as an OptionSet)

Answers

Have you compared your XPC Service’s Info.plist to XPCService-Info.plist from the sample? In EBAS I set JoinExistingSession, which I think is the relevant magic (but it’s been a while :-).

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

  • Hi Quinn. This made some progress! I posted my response an answer, inititally by accident, but now keeping it because it formats much more nicely than a comment :)

Add a Comment

Spot on! That was exactly it.

Thanks Quinn!

Hmmmm hold the phone, spoke to soon!

This worked, in that the XPC service is now able to present an interactive prompt for the password, and SMJobBless succeeds, but it still breaks when the main app is made to be sandboxed (which was the whole goal of this EBAS sample).

Here's what the logs have to say:

info	authd	Process /usr/libexec/smd (PID 788) evaluates 1 rights with flags 00000003 (engine 213): (
        "com.apple.ServiceManagement.blesshelper"
    )

debug	authd	engine 213: user not used password

debug	authd	engine 213: checking if rule com.apple.ServiceManagement.blesshelper contains password-only item

debug	authd	engine 213: _preevaluate_rule com.apple.ServiceManagement.blesshelper

error	authd	Sandbox denied authorizing right 'com.apple.ServiceManagement.blesshelper' for authorization created by '/MyApp.app/Contents/XPCServices/IntermediatorXPCService.xpc' [13471] (engine 213)

debug	authd	engine 213: authorize result: -60005

(-60005 is errAuthorizationDenied)

SMJobBless itself fails with The operation couldn’t be completed. (CFErrorDomainLaunchd error 4.)

This is another case where intuitively, this makes sense. The docs for JoinExistingSession say:

Boolean. Indicates that your service runs in the same security session as the caller. The default value is False, which indicates that the service is run in a new security session. Set the value to True if the service needs to access to the user’s keychain, the pasteboard, or other per-session resources and services.

If we're sharing the same security session, and the parent is made sandboxed, then how would it be possible for the XPC service to use the authorization services APIs, when the main app can't? I'm left stumped with how the EBAS makes this work.

If we're sharing the same security session, and the parent is made sandboxed, then how would it be possible for the XPC service to use the authorization services APIs, when the main app can't?

Security sessions are not directly related to the sandbox. A security session is set up when the user logs in, but you don’t get a new session when you launch a sandboxed app. You can confirm this with SessionGetInfo.

I'm left stumped with how the EBAS makes this work.

So, to be clear, you’ve built EBAS and it works? ’cause it’s possible that the EBAS code used to work back in the day but is failing on modern systems.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

  • Yep, I can confirm EBAS still works (macOS 12.0.1 (21A559)). Looking into SessionGetInfo now.

  • Indeed, the security session (and audit session, for that matter) appear to be consistent across all apps (sandboxed or not), and even my XPC service, when I set JoinExistingSession to true. So there's some other secret sauce that's allowing EBAS to use SMJobBless despite its main app being sandboxed, and my app must be lacking it. I don't have any ideas for how to narrow down and isolate the difference. Do you have any suggestions?

Add a Comment

A security session is set up when the user logs in, but you don’t get a new session when you launch a sandboxed app. You can confirm this with SessionGetInfo.

Indeed, it appears that every "regular app" is sharing the same security and audit sessions.

Here are some details about the security and audit sessions:

Without JoinExistingSession:

Main app:

My security session (id 100019) has attributes: [.sessionHasGraphicAccess, .sessionHasTTY])
My audit session (id 100019) and has flags: [AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS, AU_SESSION_FLAG_HAS_TTY, AU_SESSION_FLAG_HAS_CONSOLE_ACCESS, AU_SESSION_FLAG_HAS_AUTHENTICATED]

XPC Service:

My security session (id 103826) has attributes: [])
My audit session (id 103826) and has flags: []

With JoinExistingSession:

EBAS has these same flags, as well.

Main app:

My security session (id 100019) has attributes: [.sessionHasGraphicAccess, .sessionHasTTY])
My audit session (id 100019) and has flags: [AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS, AU_SESSION_FLAG_HAS_TTY, AU_SESSION_FLAG_HAS_CONSOLE_ACCESS]

XPC Service:

My security session (id 100019) has attributes: [.sessionHasGraphicAccess, .sessionHasTTY])
My audit session (id 100019) and has flags: [AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS, AU_SESSION_FLAG_HAS_TTY, AU_SESSION_FLAG_HAS_CONSOLE_ACCESS, AU_SESSION_FLAG_HAS_AUTHENTICATED]

Security and Audit session logging code

Here's the code I used to generate those messages, in case you're curious.


extension SessionAttributeBits: CaseIterable {
	public static var allCases: [SessionAttributeBits] {
		[
			.sessionIsRoot,
			.sessionHasGraphicAccess,
			.sessionHasTTY,
			.sessionIsRemote,
		]
	}
}

extension SessionAttributeBits: CustomStringConvertible {
	public var description: String {
		switch self {
			case .sessionIsRoot:			return ".sessionIsRoot"
			case .sessionHasGraphicAccess:	return ".sessionHasGraphicAccess"
			case .sessionHasTTY:			return ".sessionHasTTY"
			case .sessionIsRemote:			return ".sessionIsRemote"
			default: return Self.allCases.filter(self.contains).description
		}
	}
}

// Why isn't `audit_session_flags` imported as an OptionSet to begin with?
extension audit_session_flags: OptionSet {}

extension audit_session_flags: CaseIterable {
	public static var allCases: [audit_session_flags] {
		[
			AU_SESSION_FLAG_IS_INITIAL,
			AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS,
			AU_SESSION_FLAG_HAS_TTY,
			AU_SESSION_FLAG_IS_REMOTE,
			AU_SESSION_FLAG_HAS_CONSOLE_ACCESS,
			AU_SESSION_FLAG_HAS_AUTHENTICATED,
		]
	}
}

extension audit_session_flags: CustomStringConvertible {
	public var description: String {
		switch self {
			case AU_SESSION_FLAG_IS_INITIAL:			return "AU_SESSION_FLAG_IS_INITIAL"
			case AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS:	return "AU_SESSION_FLAG_HAS_GRAPHIC_ACCESS"
			case AU_SESSION_FLAG_HAS_TTY:				return "AU_SESSION_FLAG_HAS_TTY"
			case AU_SESSION_FLAG_IS_REMOTE:				return "AU_SESSION_FLAG_IS_REMOTE"
			case AU_SESSION_FLAG_HAS_CONSOLE_ACCESS:	return "AU_SESSION_FLAG_HAS_CONSOLE_ACCESS"
			case AU_SESSION_FLAG_HAS_AUTHENTICATED:		return "AU_SESSION_FLAG_HAS_AUTHENTICATED"
			default: return Self.allCases.filter(self.contains).description
		}
	}
}

extension auditinfo_addr_t {
	static func forCurrentProcess() -> Self {
		var auditInfo = auditinfo_addr_t();
		let errorCode = getaudit_addr(&auditInfo, Int32(MemoryLayout.size(ofValue: auditInfo)))
		assert(errorCode == 0, "getaudit_addr returned a non-zero error code: \(errorCode)")
		return auditInfo
	}

	var auditSessionFlags: audit_session_flags {
		audit_session_flags(rawValue: UInt32(self.ai_flags))
	}
}

public func printSecuritySessionInfo() {
	var mySessionID = SecuritySessionId()
	var sessionAttributes = SessionAttributeBits()
	let errorCode = SessionGetInfo(callerSecuritySession, &mySessionID, &sessionAttributes)
	assert(errorCode == errSecSuccess)

	print("My security session (id \(mySessionID)) has attributes: \(sessionAttributes))")
}

public func printAuditSessionInfo() {
	let auditSession = auditinfo_addr_t.forCurrentProcess()
	print("My audit session (id \(auditSession.ai_asid)) and has flags: \(auditSession.auditSessionFlags)")
}