Audit token provided by NEFilterDataProvider sometimes fails to provide code object with SecCodeCopyGuestWithAttributes

I'm using this code to get the path of an executable from the audit token provided in NEFilterDataProvider.handleNewFlow(_:), forwarded from the Network Extension to the main app via IPC:

private func securePathFromAuditToken(_ auditToken: Data) throws -> String {
    let secFlags = SecCSFlags()
    var secCode: SecCode?
    var status = SecCodeCopyGuestWithAttributes(nil, [kSecGuestAttributeAudit: auditToken] as CFDictionary, secFlags, &secCode)
    guard let secCode = secCode else {
        throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
    }
    var secStaticCode: SecStaticCode?
    status = SecCodeCopyStaticCode(secCode, secFlags, &secStaticCode)
    guard let secStaticCode = secStaticCode else {
        throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
    }
    var url: CFURL?
    status = SecCodeCopyPath(secStaticCode, secFlags, &url)
    guard let url = url as URL? else {
        throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
    }
    return url.path
}

This code sometimes returns paths like /System/Library/PrivateFrameworks/HelpData.framework/Versions/A/Resources/helpd or /Library/Developer/CoreSimulator/Volumes/iOS_21A328/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.0.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd.

But sometimes the SecCodeCopyGuestWithAttributes fails with status 100001 which is defined in MacErrors.h as kPOSIXErrorEPERM = 100001, /* Operation not permitted */. In these cases I resort to this code, which I have read is not as secure:

private func insecurePathFromAuditToken(_ auditToken: Data) throws -> String? {
    if auditToken.count == MemoryLayout<audit_token_t>.size {
        let pid = auditToken.withUnsafeBytes { buffer in
            audit_token_to_pid(buffer.baseAddress!.assumingMemoryBound(to: audit_token_t.self).pointee)
        }
        let pathbuf = UnsafeMutablePointer<Int8>.allocate(capacity: Int(PROC_PIDPATHINFO_SIZE))
        defer {
            pathbuf.deallocate()
        }
        let ret = proc_pidpath(pid, pathbuf, UInt32(PROC_PIDPATHINFO_SIZE))
        if ret <= 0 {
            throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
        }
        return String(cString: pathbuf)
    }
    return nil
}

This insecure code then returns paths like /usr/libexec/trustd, /usr/libexec/rapportd, /usr/libexec/nsurlsessiond and /usr/libexec/timed.

From what I can see, SecCodeCopyGuestWithAttributes fails for all processes in /usr/libexec. Some of these processes have executables with the same name placed in another directory, like /Library/Developer/CoreSimulator/Volumes/iOS_21A328/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.0.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/mobileassetd for which it succeeds, while for /usr/libexec/mobileassetd it fails.

Occasionally, both the secure and the insecure methods fail and in these cases the secure one returns status code 100003, which is defined as kPOSIXErrorESRCH = 100003, /* No such process */. When can this happen?

This seems to happen with both NEFilterFlow.sourceAppAuditToken and sourceProcessAuditToken. What is the problem?

Occasionally, both the secure and the insecure methods fail and in these cases the secure one returns status code 100003

ESRCH typically means that the process wasn’t found, probably because it terminated between the point after it triggered the network request that invoked your provider but before your provider actually ran.

This code sometimes returns paths like …

Yep. Those are the simulated versions of various system processes.

But sometimes the SecCodeCopyGuestWithAttributes fails with status 100001

EPERM typically means that something was blocked by the sandbox, but I’m surprised you’re seeing that from SecCodeCopyGuestWithAttributes.

I tweaked your code to run as a standalone tool. This seems to work with both system and simulator processes. For example:

% ./pid-to-path `pgrep trustd`
267: /usr/libexec/trustdFileHelper
14727: /usr/libexec/trustd
14781: /usr/libexec/trustd
14782: /usr/libexec/trustd
14783: /usr/libexec/trustd
14784: /usr/libexec/trustd
14786: /usr/libexec/trustd
89025: /usr/libexec/trustd
89028: /usr/libexec/trustd
89029: /usr/libexec/trustd
89172: /Library/Developer/CoreSimulator/Volumes/iOS_21A328/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.0.simruntime/Contents/Resources/RuntimeRoot/usr/libexec/trustd
90565: /usr/libexec/trustd
92067: /usr/libexec/trustd

If you’re able to reproduce this problem, try running this tool against the process to see what comes back.

Note I had two switch to using kSecGuestAttributePid because accepting audit tokens from the command line is quite tricky.

Share and Enjoy

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


import Foundation

private func securePathFromProcessID(_ pid: pid_t) throws -> String {
    let secFlags = SecCSFlags()
    var secCode: SecCode?
    var status = SecCodeCopyGuestWithAttributes(nil, [kSecGuestAttributePid: pid] as CFDictionary, secFlags, &secCode)
    guard let secCode = secCode else {
        throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
    }
    var secStaticCode: SecStaticCode?
    status = SecCodeCopyStaticCode(secCode, secFlags, &secStaticCode)
    guard let secStaticCode = secStaticCode else {
        throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
    }
    var url: CFURL?
    status = SecCodeCopyPath(secStaticCode, secFlags, &url)
    guard let url = url as URL? else {
        throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
    }
    return url.path
}

func main() {
    let pids = CommandLine.arguments
        .dropFirst()
        .compactMap { s in
            pid_t(s)
        }
    guard !pids.isEmpty else {
        print("usage: pid-to-path pid...")
        exit(1)
    }
    for pid in pids {
        do {
            let path = try securePathFromProcessID(pid)
            print("\(pid): \(path)")
        } catch {
            print("\(pid): \(error)")
        }
    }
}

main()

Hi Quinn, thanks a lot for your help. I created an empty Xcode project with the "Command Line Tool" template, pasted your code, and ran the built executable in the Terminal. When passing the pids for all the trustd processes I see in Activity Monitor it always returns the expected path, /usr/libexec/trustd.

But then I created an empty Xcode project with the "App" template and created a main.swift file again with your code, and when running the built App/Contents/MacOS/app in the Terminal, I get the same error that I mentioned before: Error Domain=NSOSStatusErrorDomain Code=100001 "EPERM: Operation not permitted". Do I need to add special entitlements in order to make it work?

Do I need to add special entitlements in order to make it work?

The default app template is sandboxed.

Of course, your NE sysex is also sandboxed O-:

This is tricky because the NE sysex runs in an unusual environment because it’s both running as root and sandboxed. I’m not 100% sure what the expected behaviour is here.

I get the same error that I mentioned before

For all processes? Or just some subset?

What happens if you run App/Contents/MacOS/App using sudo? [1]

Do you see any sandbox violation reports?

Share and Enjoy

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

[1] sudo is a poor substitute for the launchd daemon environment that the NE sysex runs in, but it’s an easy place to start.

Yes, they are both sandboxed. Sorry if that wasn't clear.

Even with sudo I still get the same error Error Domain=NSOSStatusErrorDomain Code=100001 "EPERM: Operation not permitted". It's only the processes in /usr/libexec, all others seem to work and return some path.

I also don't see any sandbox violation reports in the Console. Even just searching for com.apple.sandbox.reporting doesn't show any results. The instructions given in the link you posted say that I should post 3 different lines in the Console search field, but in the screenshot it looks like pasting the string type:error should appear as a token with the text error, while I still see type:error. So I searched for error and manually changed the search scope to "Message type", but again no results.

Thanks for the update.

I don’t have a ready answer for you here. While it’s pretty clear that this is some sort of trusted execution / sandbox limition, I’m not sure exactly what that is. My suggestions:

  • If you’re prepared to live with your current workaround, simply file a bug about this. Given that an NE sysex has to be sandboxed and a content filter obviously needs a secure way to identify the caller, something need to change on our side to allow that.

    If you do file a bug, please post your bug number, just for the record.

  • Alternatively, you could open a DTS tech support incident, which would allow me to spend more time looking into this. However, if I had to guess, I’d say that it’s unlikely that this will change the ultimate resolution.

Share and Enjoy

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

Thank you. I already filed FB12057582 in March 2023 but got no response for now. It would be great if you have any power over speeding that up.

Since I'm planning on releasing this in an App Store app, I want to be sure that only process names that can be securely determined are displayed as such, while all the other ones, which currently include the mentioned ones (trustd etc.) will be shown with red text and a warning. It would be great if all legit processes could be displayed as such sooner or later (but preferably sooner).

And thank you for predicting that a TSI probably won't change things here. It's always frustrating discarding a TSI that simply results in a mere confirmation that it's a bug and one will have to wait for it to be solved.

I recently noticed that NEFilterFlow already seems to have a notion of the process that generated it. In handleNewFlow(_:), printing flow.description outputs something like this:

identifier = 976999BB-C169-42BE-9CAF-41D22E332848
hostname = www.imdb.com
sourceAppIdentifier = .com.apple.Safari
sourceAppVersion = 17.0
sourceAppUniqueIdentifier = {length = 20, bytes = 0x94d650dbd26d77127b24d6c80c1faf8109368246}
procPID = 744
eprocPID = 600
direction = outbound
inBytes = 0
outBytes = 0
signature = {length = 32, bytes = 0x3f4e07a6 6077bb66 05cd661f 522a515e ... fa2751eb ac6bb41a }
localEndpoint = 192.168.1.6:52304
remoteEndpoint = 18.165.186.203:443
remoteHostname = www.imdb.com
protocol = 6
family = 2
type = 1
procUUID = F470D161-B607-3458-ADDE-F35CC5857E19
eprocUUID = A8B1FDA3-50E6-3A8A-AFCF-47D86E86B87F

In particular, notice sourceAppIdentifier = .com.apple.Safari and procPID = 744. Is this information reliable? Why does it appear in the description but cannot be accessed via the API?

Is this information reliable?

On iOS, yes.

Why does it appear in the description but cannot be accessed via the API?

Different info is available on iOS.

iOS requires that all third-party code be authorisation by a provisioning profile. The code and the profile are tied together by an App ID that’s registered on the Developer website. Thus, iOS can offer easier options to identify code.

macOS allows you to run a code without a profile and thus it has a harder time reliably identifying code. See the ‘pretend to be the Finder’ example in this post. In short, on macOS:

  • You can’t trust the bundle ID

  • You can’t trust the code signing identifier

  • Not every app has an App ID

  • Not every program is an app!

  • Not every program is signed

  • A signed program might be ad hoc signed, meaning it has no stable identifiers

This is the price we all collectively pay for Apple’s commitment to keep the Mac a Mac (-:

If you’re want to learn more about the backstory to all of this, I recommend reading:

Share and Enjoy

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

Different info is available on iOS.

I've been running this code on macOS, never on iOS, so the app and pid information seems to also available on macOS. Or do you mean that the information that's printed in the description is not reliable on macOS?

Or do you mean that the information that's printed in the description is not reliable on macOS?

To answer that I’d have to look at exactly how that description method is implemented, which is not something I’m inclined to do because… well… time, but also because description results are not intended to be used as API. However, it certainly looks like:

  • The sourceAppIdentifier = .com.apple.Safari field is rendering an App ID [1], which would match the behaviour of the iOS-only sourceAppIdentifier property. This isn’t helpful on macOS, as I’ve explained above.

  • The procPID = 744 field is rendering a process ID, which isn’t great on any platform because of PID reuse attacks.

    If you want to get a pid_t from an audit token you can do that trivially: Just call audit_token_to_pid. However, that exposes you to the PID reuse attacks that audit tokens are designed to protect you from.

Share and Enjoy

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

[1] Note the leading ., which would normally separate the App ID prefix from the bundle ID, but there is no App ID prefix because this is built-in app.

Audit token provided by NEFilterDataProvider sometimes fails to provide code object with SecCodeCopyGuestWithAttributes
 
 
Q