diff --git a/BitsParser.py b/BitsParser.py index be70619..d813cae 100644 --- a/BitsParser.py +++ b/BitsParser.py @@ -50,12 +50,13 @@ class BitsParser: - def __init__(self, queue_dir, carve_db, carve_all, out_file): + def __init__(self, queue_dir, carve_db, carve_all, out_file, sid_lookup=True): self.queue_dir = queue_dir self.carve_db_files = carve_db self.carve_all_files = carve_all self.out_file = out_file + self.sid_lookup = sid_lookup self.sid_user_cache = {} self.visited_jobs = set() @@ -66,6 +67,10 @@ def __init__(self, queue_dir, carve_db, carve_all, out_file): def get_username_from_sid(self, sid): """ Returns the username associated with the given SID by calling LookupAccountSid """ + # The lookup runs against the accounts of this machine, not the one the database came from + if not self.sid_lookup: + return None + # Cache usernames to improve efficiency with repeated lookups if sid in self.sid_user_cache: return self.sid_user_cache[sid] @@ -350,6 +355,35 @@ def output_jobs(self, file_path, jobs): sys.stdout = orig_stdout + def output_jobs_properjson(self, file_path, jobs): + """Cleans up and outputs the parsed jobs from the qmgr database files""" + + # If an output file is specified, open it and use it instead of stdout + if self.out_file: + orig_stdout = sys.stdout + sys.stdout = open(self.out_file, "w") + + try: + uniquejobs = [] + for job in jobs: + # Skip incomplete carved jobs as they do not contain useful info + if job.is_carved() and not job.is_useful_for_analysis(): + continue + + # Output unique jobs + if job.hash not in self.visited_jobs: + uniquejobs.append(job) + self.visited_jobs.add(job.hash) + + results = [j.job_dict for j in uniquejobs] + jobsJson = json.dumps({"jobs" : results}, indent=4) + print(jobsJson) + finally: + if self.out_file: + sys.stdout.close() + sys.stdout = orig_stdout + + def process_file(self, file_path): """ Processes the given BITS file. Attempts to find/parse jobs. """ @@ -374,7 +408,7 @@ def process_file(self, file_path): else: jobs = self.load_non_qmgr_jobs(file_data) - self.output_jobs(file_path, jobs) + self.output_jobs_properjson(file_path, jobs) except Exception: print(f'Exception occurred processing file {file_path}: ' + traceback.format_exc(), file=sys.stderr) @@ -564,8 +598,10 @@ def parse(self): parser.add_argument('--output', '-o', help='Optionally specify a file for JSON output. If not specified the output will be printed to stdout.') parser.add_argument('--carvedb', action='store_true', help='Carve deleted records from database files') parser.add_argument('--carveall', action='store_true', help='Carve deleted records from all other files') + parser.add_argument('--no-sid-lookup', action='store_true', help='Do not resolve owner SIDs to account names on this machine (no "Owner" field)') parsed_args = parser.parse_args() queue_dir = os.path.expandvars(parsed_args.input) - bits_parser = BitsParser(queue_dir, parsed_args.carvedb, parsed_args.carveall, parsed_args.output) + bits_parser = BitsParser(queue_dir, parsed_args.carvedb, parsed_args.carveall, parsed_args.output, + sid_lookup=not parsed_args.no_sid_lookup) bits_parser.run() diff --git a/ese/ese.py b/ese/ese.py index a389fdd..4e12790 100644 --- a/ese/ese.py +++ b/ese/ese.py @@ -397,6 +397,9 @@ def __init__(self, db, data=None): self.record = None if data is not None: self.record = ESENT_PAGE_HEADER(self.__DBHeader['Version'], self.__DBHeader['FileFormatRevision'], self.__DBHeader['PageSize'], data) + # From format 0x620 revision 0x122 the upper 4 bits of the tag count are reserved + if self.__DBHeader['Version'] == 0x620 and self.__DBHeader['FileFormatRevision'] >= 0x122: + self.record['FirstAvailablePageTag'] &= 0x0fff def getTag(self, tagNum): """Gets the next tag from this page""" @@ -640,8 +643,12 @@ def openTable(self, tableName): cursor['CurrentPageData'] = page cursor['CurrentTag'] = 0 - # Create a mapping of the long values tree - cursor['LongValues'] = self.__getLongValues(cursor['TableData']['LongValues']['FatherDataPageNumber']) + # Create a mapping of the long values tree (a table that never stored a long value has none) + longValues = cursor['TableData']['LongValues'] + if isinstance(longValues, OrderedDict): + cursor['LongValues'] = {} + else: + cursor['LongValues'] = self.__getLongValues(longValues['FatherDataPageNumber']) return cursor diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..d7e913f --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,8 @@ +# Tools to build the one-file exe with PyInstaller (Python 3.11.9), on top of the runtime requirements. +-r requirements.txt +pyinstaller==6.22.3 +altgraph==0.17.5 +packaging==26.3 +pefile==2024.8.26 +pyinstaller-hooks-contrib==2026.8 +pywin32-ctypes==0.2.3 diff --git a/requirements.txt b/requirements.txt index 8f82def..c4f9a0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -bits_parser +bits_parser @ git+https://github.com/ANSSI-FR/bits_parser@5755dff9d69fafa2defbf0dd3f81ae43826bad2d