-
Notifications
You must be signed in to change notification settings - Fork 5
/
sip_tester
executable file
·405 lines (333 loc) · 16.3 KB
/
sip_tester
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
#!/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()