security_test.m   [plain text]


/*
 *  security_test.m
 *  kext_tools
 *
 *  Copyright 2017 Apple Inc. All rights reserved.
 *
 */
#import <Foundation/Foundation.h>
#import <copyfile.h>

#import "unit_test.h"
#import "security.h"
#import "staging.h"

#pragma mark External Function Declarations
extern Boolean pathIsSecure(NSString *path);
extern Boolean bundleValidates(NSURL *bundleURL, BOOL isGPUBundle);
extern Boolean stageBundle(NSURL *sourceURL, NSURL *destinationURL, BOOL isGPUBundle);
extern NSData *copyIdentifierFromBundle(NSURL *url);
typedef BOOL (^BundleURLHandler)(NSURL *, NSURL *);
extern void forEachInsecureBundleHelper(NSArray *bundles, BundleURLHandler callbackHandler, NSURL *sourceBaseURL, NSURL *targetBaseURL);
extern NSURL *createStagingURL(NSURL *originalURL);
extern BOOL bundleNeedsStaging(NSURL *sourceURL, NSURL *destinationURL);
extern NSURL *createURLWithoutPrefix(NSURL *url, NSString *prefix);
extern Boolean pruneStagingDirectoryHelper(NSString *stagingRoot);
extern Boolean clearStagingDirectoryHelper(NSString *stagingRoot);

/* It's unfortunate that this is required, but --remove-signature is flaky and its error output
 * isn't reflective of the failures, so the simplest thing to do is call it a few times.
 * For more information, see <rdar://problem/36603724>.
 */
static void
remove_signature(const char *path)
{
    const static int CALL_COUNT = 5;
    NSString *command = [NSString stringWithFormat:@"codesign --remove-signature %s", path];
    int calls = 0;

    while (calls < CALL_COUNT) {
        system(command.UTF8String);
        calls += 1;
    }
}

#pragma mark Test Functions
static void
test_path_secure()
{
    TEST_START("path security");

    TEST_CASE("/S/L/E apple driver is secure", pathIsSecure(@"/System/Library/Extensions/AppleHV.kext")== true);
    TEST_CASE("/L/E third party kext is not secure", pathIsSecure(@"/Library/Extensions/PromiseSTEX.kext") == false);
    TEST_CASE("staged extension directory is secure", pathIsSecure(@"/Library/StagedExtensions") == true);
    TEST_CASE("staged gpu bundle directory is secure", pathIsSecure(@"/Library/GPUBundles") == true);
}

static void
test_bundle_validation()
{
    NSURL *bundleURL = nil;

    TEST_START("bundle validation");

    bundleURL = [NSURL fileURLWithPath:@"/System/Library/Extensions/AppleHV.kext"];
    TEST_CASE("apple kext validates as bundle", bundleValidates(bundleURL, YES) == true);
    TEST_CASE("apple kext validates as kext", bundleValidates(bundleURL, NO) == true);

    bundleURL = [NSURL fileURLWithPath:@"/Library/Extensions/PromiseSTEX.kext"];
    TEST_CASE("third party kext does not validate as gpu bundle", bundleValidates(bundleURL, YES) == false);
    TEST_CASE("third party kext validates as kext", bundleValidates(bundleURL, NO) == true);
}

static void
test_staging_function()
{
    Boolean success = false;
    NSURL *sourceURL = nil;
    NSURL *targetURL = nil;
    NSArray<NSURL *> *contents = nil;
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSURL *tmpDir = [NSURL fileURLWithPath:@"/tmp/kext_security_test"];

    TEST_START("staging functionality");

    // Setup temporary directory.
    [fileManager removeItemAtURL:tmpDir error:nil];
    [fileManager createDirectoryAtURL:tmpDir withIntermediateDirectories:NO attributes:nil error:nil];

    // Stage an apple kext, which will validate properly and end up in the destination.
    // Copy the kext into an insecure location first so we strip the SIP xattrs, which would fail
    // to stage since the staging copy can't put the extended attributes on in the staged location.
    sourceURL = [NSURL fileURLWithPath:@"/System/Library/Extensions/AppleHV.kext"];
    targetURL = [NSURL fileURLWithPath:@"/tmp/AppleHV.kext"];
    [fileManager copyItemAtURL:sourceURL
                         toURL:targetURL
                         error:nil];
    sourceURL = targetURL;
    targetURL = [tmpDir URLByAppendingPathComponent:@"SecureCopy.kext"];
    success = stageBundle(sourceURL, targetURL, YES);
    contents = [fileManager contentsOfDirectoryAtURL:tmpDir includingPropertiesForKeys:nil options:0 error:nil];

    TEST_CASE("SETUP: apple kext staging", [fileManager fileExistsAtPath:sourceURL.path]);
    TEST_CASE("apple kext stages", success == true);
    TEST_CASE("apple kext staging ends up in right location", [fileManager fileExistsAtPath:targetURL.path]);
    TEST_CASE("apple kext staging removes temporary artifacts", contents.count == 1);

    [fileManager removeItemAtURL:sourceURL error:nil];
    [fileManager removeItemAtURL:targetURL error:nil];

    // Try staging a third party kext, which should fail and leave no trace in the output directory.
    sourceURL = [NSURL fileURLWithPath:@"/Library/Extensions/PromiseSTEX.kext"];
    targetURL = [tmpDir URLByAppendingPathComponent:@"DoesntExist.kext"];
    success = stageBundle(sourceURL, targetURL, YES);
    contents = [fileManager contentsOfDirectoryAtURL:tmpDir includingPropertiesForKeys:nil options:0 error:nil];

    TEST_CASE("third party kext fails staging as a bundle", success == false);
    TEST_CASE("third party kext staging as a bundle removes all artifacts", contents.count == 0);

    // Try staging a third party kext as a kext, which should succeed.
    sourceURL = [NSURL fileURLWithPath:@"/Library/Extensions/PromiseSTEX.kext"];
    targetURL = [tmpDir URLByAppendingPathComponent:@"PromiseCopy.kext"];
    success = stageBundle(sourceURL, targetURL, NO);
    contents = [fileManager contentsOfDirectoryAtURL:tmpDir includingPropertiesForKeys:nil options:0 error:nil];

    TEST_CASE("third party kext stages", success == true);
    TEST_CASE("third party kext staging ends up in right location", [fileManager fileExistsAtPath:targetURL.path]);
    TEST_CASE("third party kext staging removes temporary artifacts", contents.count == 1);

    // Cleanup temporary directory.
    [fileManager removeItemAtURL:tmpDir error:nil];
}

static void
test_system_doesnt_need_staging()
{
    NSArray<NSURL *> *contents = nil;
    NSFileManager *fileManager = [NSFileManager defaultManager];

    TEST_START("/S/L/E doesn't contain kexts that need any staging");

    // Validate nothing in /S/L/E actually needs any staging.
    contents = [fileManager contentsOfDirectoryAtURL:[NSURL fileURLWithPath:@"/System/Library/Extensions"]
                          includingPropertiesForKeys:nil
                                             options:0
                                               error:nil];
    for (NSURL *kextURL in contents) {
        OSKextRef stagedKext = NULL;
        NSString *testName = nil;
        OSKextRef kext = OSKextCreate(NULL, (__bridge CFURLRef)kextURL);
        if (!kext) {
            // Skip non-kext objects.
            continue;
        }

        testName = [NSString stringWithFormat:@"%@ doesn't need GPU bundles staged", kextURL.path];
        TEST_CASE(testName.UTF8String, needsGPUBundlesStaged(kext) == false);

        testName = [NSString stringWithFormat:@"%@ doesn't need kext staging", kextURL.path];
        stagedKext = createStagedKext(kext);
        TEST_CASE(testName.UTF8String, stagedKext && (stagedKext == kext));
    }
}

static void
test_insecure_identifier()
{
    NSURL *testRootURL = [NSURL fileURLWithPath:@"/private/tmp/kexttest"];
    NSURL *sourceURL = nil;
    NSData *cdhash = nil;
    NSFileManager *fileManager = [NSFileManager defaultManager];

    TEST_START("insecure identifier generation");

    // Cleanup in case a previous test has failed to cleanup.
    [fileManager removeItemAtURL:testRootURL error:nil];
    [fileManager createDirectoryAtPath:testRootURL.path
           withIntermediateDirectories:YES
                            attributes:nil
                                 error:nil];

    sourceURL = [NSURL fileURLWithPath:@"/Library/Extensions/SoftRAID.kext"];
    cdhash = copyIdentifierFromBundle(sourceURL);
    TEST_CASE("valid signature looks like cdhash", cdhash && cdhash.length == 20);

    // Make an unsigned bundle by copying and removing the signature from a signed bundle.
    NSURL *testKextURL = [testRootURL URLByAppendingPathComponent:@"test.kext"];
    [fileManager copyItemAtURL:sourceURL
                         toURL:testKextURL
                         error:nil];
    remove_signature(testKextURL.path.UTF8String);
    cdhash = copyIdentifierFromBundle(testKextURL);
    TEST_CASE("unsigned bundle returns adhoc cdhash as string", cdhash.length == 40);

    sourceURL = [NSURL fileURLWithPath:@"/System/Library/LaunchDaemons/com.apple.kextd.plist"];
    cdhash = copyIdentifierFromBundle(sourceURL);
    TEST_CASE("flat file returns adhoc cdhash as string", cdhash.length == 40);

    [fileManager removeItemAtURL:testRootURL error:nil];
}

static void
test_for_each_insecure_bundle()
{
    __block int callCount = 0;
    BundleURLHandler handler = NULL;
    NSArray<NSString *> *bundles = nil;
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSURL *sleURL = [NSURL fileURLWithPath:@"/System/Library/Extensions"];
    NSURL *tempSourceURL = [NSURL fileURLWithPath:@"/tmp/kext_source"];
    NSURL *tempTargetURL = [NSURL fileURLWithPath:@"/tmp/kext_target"];
    NSURL *url = nil;

    handler = ^ BOOL (NSURL *sourceURL, NSURL *targetURL) {
        callCount += 1;
        return YES;
    };

    TEST_START("foreach insecure bundle");

    [fileManager removeItemAtURL:tempSourceURL error:nil];
    [fileManager removeItemAtURL:tempTargetURL error:nil];
    [fileManager createDirectoryAtURL:tempSourceURL withIntermediateDirectories:NO attributes:nil error:nil];
    [fileManager createDirectoryAtURL:tempTargetURL withIntermediateDirectories:NO attributes:nil error:nil];

    callCount = 0;
    bundles = @[@"../"];
    forEachInsecureBundleHelper(bundles, handler, sleURL, tempTargetURL);
    TEST_CASE("../ in bundle name results in no callbacks", callCount == 0);

    callCount = 0;
    bundles = @[@"symlink"];
    url = [tempSourceURL URLByAppendingPathComponent:@"symlink"];
    [fileManager createSymbolicLinkAtURL:url withDestinationURL:sleURL error:nil];
    forEachInsecureBundleHelper(bundles, handler, tempSourceURL, tempTargetURL);
    TEST_CASE("symlink source results in no callbacks", callCount == 0);

    callCount = 0;
    bundles = @[@"AppleHV.kext"];
    forEachInsecureBundleHelper(bundles, handler, sleURL, tempTargetURL);
    TEST_CASE("secure source results in no callbacks", callCount == 0);

    callCount = 0;
    bundles = @[@"AppleHV.kext"];
    url = [tempSourceURL URLByAppendingPathComponent:@"AppleHV.kext"];
    copyfile("/System/Library/Extensions/AppleHV.kext", url.path.UTF8String, NULL, COPYFILE_STAT | COPYFILE_DATA | COPYFILE_RECURSIVE);
    forEachInsecureBundleHelper(bundles, handler, tempSourceURL, tempTargetURL);
    TEST_CASE("insecure bundle results in callback", callCount == 1);

    callCount = 0;
    url = [tempTargetURL URLByAppendingPathComponent:@"AppleHV.kext"];
    copyfile("/System/Library/Extensions/AppleHV.kext", url.path.UTF8String, NULL, COPYFILE_STAT | COPYFILE_DATA | COPYFILE_RECURSIVE);
    forEachInsecureBundleHelper(bundles, handler, tempSourceURL, tempTargetURL);
    TEST_CASE("target exists and has no differences results in no callbacks", callCount == 0);

    callCount = 0;
    [fileManager removeItemAtURL:url error:nil];
    copyfile("/System/Library/Extensions/apfs.kext", url.path.UTF8String, NULL, COPYFILE_STAT | COPYFILE_DATA | COPYFILE_RECURSIVE);
    forEachInsecureBundleHelper(bundles, handler, tempSourceURL, tempTargetURL);
    TEST_CASE("target exists and is different results in a callback", callCount == 1);

    [fileManager removeItemAtURL:tempSourceURL error:nil];
    [fileManager removeItemAtURL:tempTargetURL error:nil];
}

static void
test_custom_authentication(void)
{
    OSKextRef kextRef = NULL;
    int result = 0;
    NSFileManager *fm = [NSFileManager defaultManager];
    AuthOptions_t testOptions = {0};
    NSURL *kextURL = nil;

    testOptions.allowNetwork = true;
    testOptions.isCacheLoad = true;
    testOptions.performFilesystemValidation = true;
    testOptions.performSignatureValidation = true;
    testOptions.requireSecureLocation = true;
    testOptions.respectSystemPolicy = true;

    TEST_START("custom authentication method testing");

    kextURL = [NSURL fileURLWithPath:@"/System/Library/Extensions/AppleHV.kext"];
    kextRef = OSKextCreate(NULL, (__bridge CFURLRef)kextURL);
    TEST_CASE("system kext authenticates as cache load", authenticateKext(kextRef, &testOptions));

    testOptions.isCacheLoad = false;
    testOptions.allowNetwork = false;
    TEST_CASE("system kext authenticates without network as runtime load", authenticateKext(kextRef, &testOptions));

    result = copyfile("/System/Library/Extensions/AppleHV.kext", "/tmp/AppleHV.kext", NULL, COPYFILE_STAT | COPYFILE_DATA | COPYFILE_RECURSIVE);
    kextURL = [NSURL fileURLWithPath:@"/tmp/AppleHV.kext"];
    kextRef = OSKextCreate(NULL, (__bridge CFURLRef)kextURL);
    TEST_CASE("SETUP: copied system kext properly", result == 0 && kextRef != NULL);
    TEST_CASE("copied system kext doesn't authenticate", authenticateKext(kextRef, &testOptions) == false);

    testOptions.performFilesystemValidation = false;
    testOptions.requireSecureLocation = false;
    TEST_CASE("copied system kext authenticates without filesystem / location checks", authenticateKext(kextRef, &testOptions));

    system("touch /tmp/AppleHV.kext/Contents/BadFile");
    TEST_CASE("SETUP: succesfully modified kext", [fm fileExistsAtPath:@"/tmp/AppleHV.kext/Contents/BadFile"]);
    TEST_CASE("modified kext authenticates due to Apple Internal policy", authenticateKext(kextRef, &testOptions));

    testOptions.performSignatureValidation = false;
    TEST_CASE("modified kext authenticates without signature validation", authenticateKext(kextRef, &testOptions));

    system("rm -rf /tmp/AppleHV.kext");
}

static void
test_kext_staging_helpers()
{
    int result = 0;
    OSKextRef kext = NULL;
    NSURL *sourceURL = nil;
    NSURL *destinationURL = nil;

    TEST_START("staging helpers");

    // createStagingURL tests
    sourceURL = [NSURL fileURLWithPath:@"/Library/Extensions/ArcMSR.kext"];
    destinationURL = createStagingURL(sourceURL);
    TEST_CASE("creates proper staging url location", [destinationURL.path isEqualToString:@"/Library/StagedExtensions/Library/Extensions/ArcMSR.kext"]);
    TEST_CASE("staging url has a trailing /", [destinationURL.absoluteString hasSuffix:@"/"]);

    // bundleNeedsStaging tests
    sourceURL = [NSURL fileURLWithPath:@"/Library/Extensions/ArcMSR.kext"];
    destinationURL = [NSURL fileURLWithPath:@"/tmp/ArcMSR.kext"];
    TEST_CASE("non-SIP protected URL needs staging", bundleNeedsStaging(sourceURL, destinationURL) == YES);

    sourceURL = [NSURL fileURLWithPath:@"/Library/Extensions/ArcMSR.kext"];
    destinationURL = [NSURL fileURLWithPath:@"/tmp/ArcMSR.kext"];
    result = copyfile(sourceURL.path.UTF8String, destinationURL.path.UTF8String, NULL, COPYFILE_STAT | COPYFILE_DATA | COPYFILE_RECURSIVE);
    TEST_CASE("SETUP: copied ArcMSR kext properly", result == 0);
    TEST_CASE("bundle doesn't need staging if it was already staged", bundleNeedsStaging(sourceURL, destinationURL) == NO);
    system("rm -rf /tmp/ArcMSR.kext");

    sourceURL = [NSURL fileURLWithPath:@"/System/Library/Extensions/AppleHV.kext"];
    destinationURL = [NSURL fileURLWithPath:@"/Library/StagedExtensions/AppleHV.kext"];
    TEST_CASE("SIP protected URL doesn't need staging", bundleNeedsStaging(sourceURL, destinationURL) == NO);

    // kextRequiresStaging tests
    sourceURL = [NSURL fileURLWithPath:@"/System/Library/Extensions/AppleHV.kext"];
    kext = OSKextCreate(NULL, (__bridge CFURLRef)sourceURL);
    TEST_CASE("SIP protected kext doesn't need staging", kextRequiresStaging(kext) == false);

    sourceURL = [NSURL fileURLWithPath:@"/Library/Extensions/ArcMSR.kext"];
    kext = OSKextCreate(NULL, (__bridge CFURLRef)sourceURL);
    TEST_CASE("non-SIP protected kext needs staging", kextRequiresStaging(kext) == true);

    // createURLWithoutPrefix helper function tests.
    sourceURL = [NSURL fileURLWithPath:@"/Library/Extensions/ArcMSR.kext"];
    destinationURL = createURLWithoutPrefix(sourceURL, @"/Library/StagedExtensions");
    TEST_CASE("createURLWithoutPrefix returns original url if not prefixed", destinationURL == sourceURL);

    destinationURL = createURLWithoutPrefix(sourceURL, @"/Library");
    TEST_CASE("createURLWithoutPrefix can remove 1 component prefix", [destinationURL.path isEqualToString:@"/Extensions/ArcMSR.kext"]);

    destinationURL = createURLWithoutPrefix(sourceURL, @"/Library/Extensions");
    TEST_CASE("createURLWithoutPrefix can remove 2 component prefix", [destinationURL.path isEqualToString:@"/ArcMSR.kext"]);
}

static void
test_staging_management_helpers(void)
{
    NSFileManager *fm = [NSFileManager defaultManager];
    NSURL *testRootURL = [NSURL fileURLWithPath:@"/private/tmp/stagingtest"];
    NSArray *dirContents = nil;
    NSError *error = nil;

    TEST_START("staging management functions");

    // First, ensure clear can delete multiple items in the top level directory.
    [fm removeItemAtURL:testRootURL error:nil];
    [fm createDirectoryAtURL:testRootURL withIntermediateDirectories:YES attributes:nil error:nil];
    [fm createDirectoryAtURL:[testRootURL URLByAppendingPathComponent:@"One"] withIntermediateDirectories:YES attributes:nil error:nil];
    [fm createDirectoryAtURL:[testRootURL URLByAppendingPathComponent:@"Two"] withIntermediateDirectories:YES attributes:nil error:nil];

    clearStagingDirectoryHelper(testRootURL.path);
    dirContents = [fm contentsOfDirectoryAtURL:testRootURL includingPropertiesForKeys:nil options:0 error:&error];
    TEST_CASE("staging directory exists after clear", error == nil);
    TEST_CASE("staging directory is empty after clear", dirContents.count == 0);

    // Second, create a basic pruning scenario:
    //   1. a kext that exists outside staging, which should persist
    //   2. a kext in the same directory as 1 that doesn't exist, to test cleanup and ensure the parent isn't removed
    //   3. a kext in a completely different directory that requires parent traversal for full path cleanup
    [fm createDirectoryAtURL:[testRootURL URLByAppendingPathComponent:@"Library/Extensions/ArcMSR.kext"] withIntermediateDirectories:YES attributes:nil error:nil];
    [fm createDirectoryAtURL:[testRootURL URLByAppendingPathComponent:@"Library/Extensions/Pineapple.kext"] withIntermediateDirectories:YES attributes:nil error:nil];
    [fm createDirectoryAtURL:[testRootURL URLByAppendingPathComponent:@"Applications/Company/Mango.kext"] withIntermediateDirectories:YES attributes:nil error:nil];

    pruneStagingDirectoryHelper(testRootURL.path);
    dirContents = [fm contentsOfDirectoryAtPath:testRootURL.path error:&error];
    TEST_CASE("staging directory exists after pruning", [fm fileExistsAtPath:testRootURL.path]);
    TEST_CASE("staging directory has only one directory after pruning", error == nil && dirContents.count == 1);
    TEST_CASE("existing kext directory still exists in staging area", [fm fileExistsAtPath:[testRootURL URLByAppendingPathComponent:@"Library/Extensions/ArcMSR.kext"].path]);
    TEST_CASE("non-existent kexts were deleted",
              ![fm fileExistsAtPath:[testRootURL URLByAppendingPathComponent:@"Library/Extensions/Pineapple.kext"].path] &&
              ![fm fileExistsAtPath:[testRootURL URLByAppendingPathComponent:@"Applications/Company/Mango.kext"].path]);

    [fm removeItemAtURL:testRootURL error:nil];
}

int main(int argc, char *argv[])
{
    test_path_secure();
    test_bundle_validation();
    test_staging_function();
    test_insecure_identifier();
    test_system_doesnt_need_staging();
    test_for_each_insecure_bundle();
    test_custom_authentication();
    test_kext_staging_helpers();
    test_staging_management_helpers();
    exit(0);
}