diff --git a/mobsf/MobSF/init.py b/mobsf/MobSF/init.py index 154da407..9dc54a46 100644 --- a/mobsf/MobSF/init.py +++ b/mobsf/MobSF/init.py @@ -18,7 +18,7 @@ from mobsf.install.windows.setup import windows_config_local logger = logging.getLogger(__name__) -VERSION = '4.5.3' +VERSION = '4.5.4' BANNER = r""" __ __ _ ____ _____ _ _ ____ | \/ | ___ | |__/ ___|| ___|_ _| || | | ___| diff --git a/mobsf/StaticAnalyzer/views/android/xapk.py b/mobsf/StaticAnalyzer/views/android/xapk.py index 50426dcb..c7e58265 100644 --- a/mobsf/StaticAnalyzer/views/android/xapk.py +++ b/mobsf/StaticAnalyzer/views/android/xapk.py @@ -9,7 +9,12 @@ from pathlib import Path from django.conf import settings from mobsf.StaticAnalyzer.views.common.shared_func import unzip -from mobsf.MobSF.security import is_safe_path +from mobsf.MobSF.security import ( + is_path_traversal, + is_pipe_or_link, + is_safe_path, + sanitize_for_logging, +) from mobsf.MobSF.utils import ( append_scan_status, find_java_binary, @@ -51,26 +56,105 @@ def handle_xapk(app_dic): return None +def _regular_file(path): + """Return True for a regular file that is not a symlink or FIFO.""" + try: + if is_pipe_or_link(path): + return False + except OSError: + return False + # codeql[py/path-injection]: path already contained by is_safe_path + return Path(path).is_file() + + +def _collect_split_apks(app_dir, members): + """Return the primary APK and sibling APKs that were actually written.""" + base_apk = None + fallback_apk = None + splits = [] + app_dir = Path(app_dir) + for apk in members: + if not isinstance(apk, str) or not apk.endswith('.apk'): + continue + full_path = app_dir / apk + if not is_safe_path(app_dir, full_path, apk): + continue + if not _regular_file(full_path): + continue + splits.append(full_path) + if apk.endswith('base.apk'): + base_apk = full_path + elif 'config.' not in apk.lower() and fallback_apk is None: + fallback_apk = full_path + return base_apk or fallback_apk, splits + + +def _split_extract_dir(app_dir, split_apk): + """Return an extract directory inside the upload root, or None.""" + dest_name = split_apk.stem + if (not dest_name or dest_name in {'.', '..'} + or is_path_traversal(dest_name)): + return None + dest = Path(app_dir) / 'split_apks' / dest_name + if not is_safe_path(app_dir, dest, dest_name): + return None + return dest + + +def _extract_sibling_splits(checksum, app_dir, primary, splits, size_budget): + """Extract non-primary splits under one uncompressed-size budget.""" + limit = settings.ZIP_MAX_UNCOMPRESSED_TOTAL_SIZE + for split_apk in splits: + if split_apk == primary: + continue + if size_budget['used'] >= limit: + logger.error('Split APK uncompressed size budget exhausted') + break + if not _regular_file(split_apk): + logger.warning( + 'Skipping unextracted split APK %s', + sanitize_for_logging(split_apk.name)) + continue + dest = _split_extract_dir(app_dir, split_apk) + if dest is None: + logger.warning( + 'Skipping unsafe split APK %s', + sanitize_for_logging(split_apk.name)) + continue + unzip( + checksum, + split_apk.as_posix(), + dest.as_posix(), + size_budget) + + def handle_split_apk(app_dic): - """Unzip and Extract Split APKs.""" + """Unzip split APKs and extract sibling splits for native libraries.""" checksum = app_dic['md5'] - apks = app_dic['app_dir'] / f'{checksum}.apk' + app_dir = app_dic['app_dir'] + apks = app_dir / f'{checksum}.apk' # Check if previously extracted - manifest = app_dic['app_dir'] / 'AndroidManifest.xml' + manifest = app_dir / 'AndroidManifest.xml' if manifest.exists(): return True - for apk in unzip(checksum, apks.as_posix(), app_dic['app_dir']): - full_path = app_dic['app_dir'] / apk - safe_path = is_safe_path(app_dic['app_dir'], full_path, apk) - if apk.endswith('base.apk') and safe_path: - move(full_path, apks) - return True - if ('config.' not in apk.lower() - and apk.endswith('.apk') - and safe_path): - move(full_path, apks) - return True - return None + primary, splits = _collect_split_apks( + app_dir, + unzip(checksum, apks.as_posix(), app_dir), + ) + if primary is None: + return None + # codeql[py/path-injection]: primary is contained by is_safe_path + move(primary, apks) + # Sibling contents share one cap. The container unzip above keeps its + # own cap, matching the previous single-archive limit. + _extract_sibling_splits( + checksum, + app_dir, + primary, + splits, + {'used': 0}, + ) + return True def handle_aab(app_dic): diff --git a/mobsf/StaticAnalyzer/views/common/shared_func.py b/mobsf/StaticAnalyzer/views/common/shared_func.py index 5f75cf29..d6baf2ca 100644 --- a/mobsf/StaticAnalyzer/views/common/shared_func.py +++ b/mobsf/StaticAnalyzer/views/common/shared_func.py @@ -100,7 +100,7 @@ def is_reserved_file_conflict(file_path): return False -def unzip(checksum, app_path, ext_path): +def unzip(checksum, app_path, ext_path, size_budget=None): """Unzip APK. Unzip a APK archive while handling encrypted files, reserved file conflicts, @@ -111,6 +111,10 @@ def unzip(checksum, app_path, ext_path): checksum (str): The checksum of the file. app_path (str): Path to the ZIP archive. ext_path (str): Path to extract the files. + size_budget (dict): Optional shared counter, ``{'used': int}``. + When set, this call continues that uncompressed-byte total and + stops at ZIP_MAX_UNCOMPRESSED_TOTAL_SIZE. The OS unzip fallback + is not used, so a failed open cannot bypass the cap. Returns: list: A list of files extracted or an empty list if an error occurs. @@ -120,11 +124,20 @@ def unzip(checksum, app_path, ext_path): append_scan_status(checksum, msg) files = [] original_ext_path = ext_path + budgeted = size_budget is not None + # A shared budget must not fall through to OS unzip: that path has no + # byte cap. Leave the flag unset on the default path so its fallback + # behavior stays the same. + if budgeted: + stop_fallback_extraction = True try: with zipfile.ZipFile(app_path, 'r') as zipptr: files = zipptr.namelist() - total_size = 0 - stop_fallback_extraction = False + if budgeted: + total_size = size_budget.get('used', 0) + else: + total_size = 0 + stop_fallback_extraction = False for fileinfo in zipptr.infolist(): ext_path = original_ext_path @@ -159,6 +172,8 @@ def unzip(checksum, app_path, ext_path): # Check uncompressed size if not fileinfo.is_dir(): total_size += fileinfo.file_size + if budgeted: + size_budget['used'] = total_size if fileinfo.file_size > settings.ZIP_MAX_UNCOMPRESSED_FILE_SIZE: size_mb = fileinfo.file_size / (1024 * 1024) msg = (f'File too large ({size_mb:.2f} MB). Skipping ' diff --git a/pyproject.toml b/pyproject.toml index 8f477868..0456d078 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mobsf" -version = "4.5.3" +version = "4.5.4" description = "Mobile Security Framework (MobSF) is an automated, all-in-one mobile application (Android/iOS/Windows) pen-testing, malware analysis and security assessment framework capable of performing static and dynamic analysis." keywords = ["mobsf", "mobile security framework", "mobile security", "security tool", "static analysis", "dynamic analysis", "malware analysis"] authors = ["Ajin Abraham "]