#!/usr/bin/env python ''' Performs a phony spyware scan as seen in MacScan version 2.7 by SecureMac. Copyright (c) 2010 Ryan Govostes Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ''' import urllib2 import os from calendar import timegm from datetime import datetime from optparse import OptionParser from urlparse import urlparse from sys import byteorder # These modules are Macintosh-specific import plistlib from Carbon import File, Files from MacOS import GetCreatorAndType, SetCreatorAndType, Error def datetime_from_Carbon(u): ''' The File Manager APIs use a struct called a UTC Date Time to represent times. Python is unfortunately oblivious to these, so we convert manually. ''' base = (u[0] << 32) | u[1] base -= 2082844800 # Seconds from Mac epoch to Unix epoch out = datetime.utcfromtimestamp(base) return out def Carbon_from_datetime(dt): ''' The inverse of datetime_from_Carbon(). ''' base = int(timegm(dt.timetuple())) + 2082844800 high = base >> 32 low = base & 0xFFFFFFFF return (high, low, 0) def load_definitions(defpath): ''' Loads definitions from the given path or URL. ''' # Download or read the spyware definitions if defpath.startswith('http:') or defpath.startswith('https:'): data = urllib2.urlopen(defpath).read() urlbits = urlparse(defpath) print '[+] Fetched spyware definitions from %s' % (urlbits.hostname) else: data = open(defpath, 'r').read() print '[+] Read spyware definitions from %s' % (os.path.basename(defpath)) # Load the data as a property list plist = plistlib.readPlistFromString(data) del data # Convert the property list format into something sensible definitions = list() timeformat = '%a, %b %d, %Y %I:%M:%S %p' badcount = racount = 0 for entry in plist[3:]: newdef = { 'dateCreated': datetime.strptime(entry['attr2'].data, timeformat), 'dateModified': datetime.strptime(entry['attr3'].data, timeformat), 'spywareName': entry['attr4'].data, 'isRAProg': entry['attr5'] } # Handle creator codes correctly. There are a few things to be mindful of # here. The NSFoundation API used in MacScan, NSHFSTypeCodeFromFileType, # requires creator codes to be (i) 4 characters long and (ii) enclosed in # single quotes for a total of 6 characters. For all other inputs it # returns 0. Amusingly, some of MacScan's definitions don't even have # valid creator codes at all! creator = entry['attr1'].data if len(creator) != 6 or creator[0] != creator[-1] != '\'': creator = '\x00\x00\x00\x00' #print '[-] Entry for \'%s\' has invalid creator code' \ # % newdef['spywareName'] else: creator = creator[1:-1] newdef['creatorCode'] = creator # Another MacScan WTF: Some of the days of the week in the timestamps are # incorrect. Since it compares dates as strings, this means that they'll # never match a file. createdDOTW = newdef['dateCreated'].strftime('%a') if createdDOTW != entry['attr2'].data.partition(', ')[0]: print '[-] Entry for \'%s\' has invalid creation day of the week' \ % newdef['spywareName'] newdef['dateCreated'] = None modifiedDOTW = newdef['dateModified'].strftime('%a') if modifiedDOTW != entry['attr3'].data.partition(', ')[0]: print '[-] Entry for \'%s\' has invalid modification day of the week' \ % newdef['spywareName'] newdef['dateModified'] = None if newdef['dateCreated'] is None and newdef['dateModified'] is None: badcount += 1 if newdef['isRAProg']: racount += 1 definitions.append(newdef) print '[+] Loaded %i spyware definitions (%i valid, %i remote admin)' \ % (len(definitions), len(definitions) - badcount, racount) return definitions def scan_file(definitions, path, remoteadmin=False): ''' Scans the file at a given path. ''' # Get the creation and modification dates of the file try: flags = Files.kFSCatInfoCreateDate | Files.kFSCatInfoContentMod fref, _ = File.FSPathMakeRef(path) info, _, _, _ = fref.FSGetCatalogInfo(flags) except Error: return False createdate = datetime_from_Carbon(info.createDate) modifydate = datetime_from_Carbon(info.contentModDate) # Get the file's creator code. Due to a bug in Python's MacOS module, the # code is accidentally reversed, so we have to correct for it. creator, _ = GetCreatorAndType(path) if byteorder == 'little': creator = creator[::-1] # Compare the file against "spyware" for spyware in definitions: # If the day of the week was invalid, MacScan will never match it if spyware['dateCreated'] is None or spyware['dateModified'] is None: continue if spyware['isRAProg'] and not remoteadmin: continue if spyware['creatorCode'] != creator: continue # MacScan will permit a bad creation date as long as the modification date # is on their blacklist. if spyware['dateCreated'] != createdate: if spyware['dateModified'] != modifydate: continue print '[!] Found \'%s\' in file %s' % (spyware['spywareName'], path) return True return False def generate_cases(definitions, dir): ''' Generates a test case that will yield a false positive for each spyware definition. ''' for i, spyware in enumerate(definitions): # Create an empty file at the path path = os.path.join(dir, str(i)) open(path, 'w').close() # Set the creator code SetCreatorAndType(path, spyware['creatorCode'], 'rgov') # Set modification and creation times flags = Files.kFSCatInfoCreateDate | Files.kFSCatInfoContentMod fref, _ = File.FSPathMakeRef(path) info, _, _, _ = fref.FSGetCatalogInfo(flags) if spyware['dateCreated'] is not None: info.createDate = Carbon_from_datetime(spyware['dateCreated']) if spyware['dateModified'] is not None: info.contentModDate = Carbon_from_datetime(spyware['dateModified']) fref.FSSetCatalogInfo(flags, info) print '[+] Generated false positive test cases in %s/' % dir if __name__ == '__main__': parser = OptionParser() parser.add_option('-d', '--definitions', dest='defpath', default='http://macscan.securemac.com/version/' 'SpywareDefinitions.plist', help='the file or URL containing spyware definitions') parser.add_option('-r', '--remoteadmin', dest='remadmin', action='store_true', default=False, help='detect remote administration programs') parser.add_option('-g', '--gen', dest='generate', action='store_true', default=False, help='generates empty files to trigger false positives') (options, args) = parser.parse_args() # Which directory does the user want to scan? if len(args) == 0: scandir = os.getcwd() elif len(args) == 1: scandir = args[0] else: parser.error('you may only specify one directory to scan') scandir = os.path.normpath(scandir) if options.generate and len(os.listdir(scandir)) > 0: parser.error('use an empty directory to put test cases into') # Load the spyware definitions definitions = load_definitions(options.defpath) # Generate false positive cases if options.generate: generate_cases(definitions, scandir) # "Scan" files for malware print '[*] Starting scan of %s/' % scandir count = 0 for dirpath, dirnames, filenames in os.walk(scandir): for f in sorted(filenames, key=lambda a:int(a)): if scan_file(definitions, os.path.join(dirpath, f), options.remadmin): count += 1 print '[*] Scan complete --', if count == 0: print 'no malware found' elif count == 1: print '1 threat found' else: print '%i threats found' % count