#!/usr/bin/env python3 import os import subprocess import argparse import lief import xattr import psutil import plistlib ROOTLESS_CONF = '/System/Library/Sandbox/rootless.conf' ROOTLESS_PLIST = '/System/Library/Sandbox/com.apple.xpc.launchd.rootless.plist' class MachOProcessor: def __init__(self, path): '''This class contains part of the code from the main() for the SnakeI: Mach-O part.''' self.macho_magic_numbers = { 0xfeedface, # 32-bit Mach-O 0xfeedfacf, # 64-bit Mach-O 0xcefaedfe, # 32-bit Mach-O, byte-swapped 0xcffaedfe, # 64-bit Mach-O, byte-swapped 0xcafebabe, # Fat binary 0xbebafeca # Fat binary, byte-swapped } self.path = os.path.abspath(path) self.binary = self.parseFatBinary()[0] # Does not matter which architecture we take here for this tool functionalities. def isFileMachO(self): '''Check if file is Mach-O. ''' try: with open(self.path, 'rb') as f: magic = f.read(4) if len(magic) < 4: return False magic_number = int.from_bytes(magic, byteorder='big') return magic_number in self.macho_magic_numbers except Exception: return False def parseFatBinary(self): '''Return Fat Binary object if file exists.''' if os.path.exists(self.path): if self.isFileMachO(): return lief.MachO.parse(self.path) else: return None def getCodeSignature(self): '''Returns information about the Code Signature.''' result = subprocess.run(["codesign", "-d", "-vvvvvv", self.path], capture_output=True) return result.stderr def hasRestrictSegment(self): '''Check if binary contains __RESTRICT segment. Return True if it does.''' for segment in self.binary.segments: if segment.name.lower().strip() == "__restrict": return True return False def hasRestrictFlag(self): '''Check if Code Signature flag CS_RESTRICT 0x800(restrict) is set for the given binary''' if b'restrict' in self.getCodeSignature(): return True return False def isRestricted(self): '''Check if binary has __RESTRICT segment or CS_RESTRICT flag set.''' if self.hasRestrictSegment() or self.hasRestrictFlag(self.path): return True return False class FileSystemProcessor: def __init__(self): # File System Flags based on: https://github.com/apple-oss-distributions/xnu/blob/94d3b452840153a99b38a3a9659680b2a006908e/bsd/sys/stat.h self.file_system_flags = { 'ACCESSPERMS': 0o777, # 0777 'ALLPERMS': 0o666, # 0666 'DEFFILEMODE': (0o400 | 0o200 | 0o100 | 0o40 | 0o20 | 0o10), # Default file mode # Owner changeable flags 'UF_SETTABLE': 0x0000ffff, # mask of owner changeable flags 'UF_NODUMP': 0x00000001, # do not dump file 'UF_IMMUTABLE': 0x00000002, # file may not be changed 'UF_APPEND': 0x00000004, # writes to file may only append 'UF_OPAQUE': 0x00000008, # directory is opaque wrt. union 'UF_COMPRESSED': 0x00000020, # file is compressed 'UF_TRACKED': 0x00000040, # document ID tracking 'UF_DATAVAULT': 0x00000080, # entitlement required for reading/writing 'UF_HIDDEN': 0x00008000, # hint for GUI display # Super-user changeable flags 'SF_SUPPORTED': 0x009f0000, # mask of superuser supported flags 'SF_SETTABLE': 0x3fff0000, # mask of superuser changeable flags 'SF_SYNTHETIC': 0xc0000000, # mask of system read-only synthetic flags 'SF_ARCHIVED': 0x00010000, # file is archived 'SF_IMMUTABLE': 0x00020000, # file may not be changed 'SF_APPEND': 0x00040000, # writes to file may only append 'SF_RESTRICTED': 0x00080000, # entitlement required for writing 'SF_NOUNLINK': 0x00100000, # item may not be removed, renamed, or mounted on 'SF_FIRMLINK': 0x00800000, # file is a firmlink 'SF_DATALESS': 0x40000000, # file is a dataless object # Extended flags 'EF_MAY_SHARE_BLOCKS': 0x00000001, # file may share blocks with another file 'EF_NO_XATTRS': 0x00000002, # file has no xattrs 'EF_IS_SYNC_ROOT': 0x00000004, # file is a sync root for iCloud 'EF_IS_PURGEABLE': 0x00000008, # file is purgeable 'EF_IS_SPARSE': 0x00000010, # file has at least one sparse region 'EF_IS_SYNTHETIC': 0x00000020, # a synthetic directory/symlink 'EF_SHARES_ALL_BLOCKS': 0x00000040, # file shares all of its blocks with another file } def pathExists(self,path): try: # Use the ls command to check if the path exists output = subprocess.check_output(['ls', path], stderr=subprocess.STDOUT) return True # If ls doesn't raise an error, the path exists except subprocess.CalledProcessError: return False # If ls raises an error, the path doesn't exist # I had to replace it to ls, because os.stat() does not handle symlink properly. def isFile(self, path): '''Check if the path is a file.''' return os.path.isfile(path) def isDirectory(self, path): '''Check if the path is a directory.''' return os.path.isdir(path) def getFileFlags(self, path): '''Return a list of active flags for the given path.''' try: # Get file status stat_info = os.stat(path) # Assuming `st_flags` is available and contains the flags flags = stat_info.st_flags # Adjust this if necessary active_flags = {} for flag_name, flag_value in self.file_system_flags.items(): if flags & flag_value: active_flags[flag_name] = flag_value return active_flags except Exception as e: print(f"Error in FileSystemProcessor.getFileFlags: {e}") return None def getExtendedAttributes(self, path): '''Return extended file attributes names.''' try: return xattr.listxattr(path) except Exception as e: print(f"Error in FileSystemProcessor.getExtendedAttributes: {e}") return None class RootlessProcessor: def __init__(self): self.fs_processor = FileSystemProcessor() self.protected_paths, self.excluded_paths, self.service_exceptions = self.parseRootlessConf() def extract_excluded_paths(self, line): '''Extract the path from a line that starts with '*' and contains a path after spaces. * /Users ''' # Remove the leading '*' and any whitespace before the first letter path = line.lstrip('*').lstrip() return path def extract_protected_paths(self, line): '''Extract the path from a line that starts with ' ' and contains a path after spaces. /System ''' path = line.strip() return path def extract_service_exceptions(self, line): '''Extract service exceptions in the format {key:value}.''' parts = line.split(maxsplit=1) # Split the line into two parts: key and value if len(parts) == 2: key = parts[0].strip() # Pkey (e.g., CoreAnalytics) value = parts[1].strip() # value (e.g., /Library/CoreAnalytics) return {key: value} return None def parseRootlessConf(self, rootless_conf_path=ROOTLESS_CONF): ''' Return a list of paths that are protected and excluded by SIP from rootless.conf. ''' protected_paths = [] excluded_paths = [] service_exceptions = {} with open(rootless_conf_path, 'r') as file: for line in file: if line.startswith('#'): continue # Skip commented lines elif line.startswith('*'): # Excluded paths path = self.extract_excluded_paths(line) excluded_paths.append(path) elif line[0].isalnum(): # Service exceptions key_value = self.extract_service_exceptions(line) key = next(iter(key_value)) path = key_value[key] service_exceptions.update(key_value) else: # Protected paths path = self.extract_protected_paths(line) protected_paths.append(path) protected_paths.remove('/tmp') return protected_paths, excluded_paths, service_exceptions def checkForServiceException(self, path): ''' Check if the given path is a service exception and return the service names. ''' service_exceptions = [] for service_name, service_path in self.service_exceptions.items(): if path == service_path: service_exceptions.append(service_name) if service_exceptions: return service_exceptions return None def makePathsToCheck(self, path): ''' Make a list of paths to check by adding final slash at the end of string if it does not exist and remove it if it does. This is needed because the rootless.conf may contain paths without and with final slash. ''' paths_to_check = [path] if path.endswith('/'): paths_to_check.append(path) path = path[:-1] elif not path.endswith('/'): path = path + '/' paths_to_check.append(path) return paths_to_check def checkIfPathIsProtectedByRootlessConf(self, path): ''' Check if the given path is protected by SIP. In case of services exceptions, it will return the service name.''' protected_paths, excluded_paths, _ = self.parseRootlessConf() paths = self.makePathsToCheck(path) if any(path in protected_paths for path in paths): return 1 elif any(path in excluded_paths for path in paths): return 2 elif any(path in self.service_exceptions.values() for path in paths): service_name = self.checkForServiceException(path) return service_name else: return 3 def checkIfParentDirectoryIsProtectedByRootlessConf(self, path): '''Check if the parent directory of the given path is protected by SIP.''' protected_paths, _, _ = self.parseRootlessConf() # Get protected paths path = os.path.abspath(path) # Get absolute path parent_dir = os.path.dirname(path) # Get parent directory # Check if the parent directory is in the list of protected paths if parent_dir in protected_paths: return True return False def isRestrictedFlagSet(self, path): '''Check if the CS_RESTRICT flag is set for the given path.''' flags = self.fs_processor.getFileFlags(path) if flags and 'SF_RESTRICTED' in flags: return True return False def isRestricedAttributeSet(self, path): '''Check if the com.apple.rootless extended attribute is set for the given path.''' xattr_value = self.fs_processor.getExtendedAttributes(path) if xattr_value and 'com.apple.rootless' in xattr_value: return True return False def isRestrictedByRootlessPlist(self, service_name): '''Check if the given service is protected by the rootless.plist file.''' rootless_plist_path = ROOTLESS_PLIST with open(rootless_plist_path, 'rb') as file: plist_data = plistlib.load(file) # Check if the service_name is in RemovableServices removable_services = plist_data.get('RemovableServices', {}) if service_name in removable_services: return 1 # Check if the service_name is in InstallerRemovableServices installer_removable_services = plist_data.get('InstallerRemovableServices', {}) if service_name in installer_removable_services: return 2 return False class SipTester: def __init__(self): self.rootless_processor = RootlessProcessor() self.fs_processor = FileSystemProcessor() def checkRootlessConf(self, path): result = self.rootless_processor.checkIfPathIsProtectedByRootlessConf(path) if result == 1: print(f"{path}: SIP-protected in rootless.conf") elif result == 2: print(f"{path} is not SIP-protected (excluded by rootless.conf)") elif result == 3: pass # print(f"{path}: does not exists in rootless.conf") else: print(f"{path} is SIP-protected, but {result} service is exception and has access to it") def checkParentDirectory(self, path): if self.rootless_processor.checkIfParentDirectoryIsProtectedByRootlessConf(path): print(f"{path}: parent directory is protected by rootless.conf") def checkFileSystemRestrictFlag(self, path): if self.rootless_processor.isRestrictedFlagSet(path): print(f"{path}: SF_RESTRICTED flag set") def checkRestrictedAttribute(self, path): if self.rootless_processor.isRestricedAttributeSet(path): print(f"{path}: com.apple.rootless extended attribute is set") def pathTester(self, path): path = os.path.abspath(path) self.checkRootlessConf(path) self.checkParentDirectory(path) self.checkFileSystemRestrictFlag(path) self.checkRestrictedAttribute(path) def checkCodeSignatureRestrictedFlag(self, path): if MachOProcessor(path).hasRestrictFlag(): print(f"{path}: CS_RESTRICT flag set on binary") def checkRestrictSegment(self, path): if MachOProcessor(path).hasRestrictSegment(): print(f"{path}: __restrict segment set on binary") def pidTester(self, pid): try: process = psutil.Process(pid) path = process.exe() self.checkRootlessConf(path) self.checkParentDirectory(path) self.checkFileSystemRestrictFlag(path) self.checkRestrictedAttribute(path) self.checkCodeSignatureRestrictedFlag(path) self.checkRestrictSegment(path) except psutil.NoSuchProcess: print(f"Process with PID {pid} does not exist") def checkRootlessPlist(self, service): if self.rootless_processor.isRestrictedByRootlessPlist(service) == 1: print(f"{service} is restricted by rootless.plist in RemovableServices.") elif self.rootless_processor.isRestrictedByRootlessPlist(service) == 2: print(f"{service} is restricted by rootless.plist in InstallerRemovableServices.") def serviceTester(self, service): self.checkRootlessPlist(service) def missingPathsTester(self): all_paths = self.rootless_processor.protected_paths + list(self.rootless_processor.service_exceptions.values()) missing_paths = [] for path in all_paths: if path.endswith('*'): path = path[:-1] if not self.fs_processor.pathExists(path): missing_paths.append(path) if missing_paths: print("Paths from rootless.conf that are missing:") for path in missing_paths: print(f"{path}") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Check SIP protection") parser.add_argument('--path', help='Path to file or directory') parser.add_argument('--pid', help='PID of the process') parser.add_argument('--service', help='Launchd service name') parser.add_argument('--missing_paths', action='store_true', help='Show paths from rootless.conf that does not exists on the filesystem') args = parser.parse_args() sip_tester = SipTester() if args.path: sip_tester.pathTester(args.path) if args.pid: sip_tester.pidTester(int(args.pid)) if args.service: sip_tester.serviceTester(args.service) if args.missing_paths: sip_tester.missingPathsTester()