mirror of
https://github.com/MobSF/Mobile-Security-Framework-MobSF.git
synced 2026-10-11 22:50:09 +00:00
fix: analyze native libraries from split APKs (#2685)
* fix: extract native libraries from split APKs * docs: add APKS native library PoC * fix: share one unzip budget across split APKs Sibling splits are still extracted so native libraries are analyzed. Those nested unzips now share one uncompressed-size cap, skip symlinks and members that were never written, and do not fall back to OS unzip. Co-authored-by: Ajin Abraham <[email protected]> * chore: bump version to 4.5.4 Split APK native-library extraction now shares one unzip budget, so this release can move past 4.5.3. Co-authored-by: Ajin Abraham <[email protected]> * docs: remove APKS native library PoC image Co-authored-by: Ajin Abraham <[email protected]> * chore: suppress false positive CodeQL path-injection alerts Mark the split APK file check and primary move as contained by is_safe_path so py/path-injection does not report them. Co-authored-by: Ajin Abraham <[email protected]> * test: remove split APK extraction tests Co-authored-by: Ajin Abraham <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Ajin Abraham <[email protected]>
This commit is contained in:
co-authored by
Ajin Abraham
Cursor Agent
parent
d3869adc46
commit
c78bf022fd
+1
-1
@@ -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"""
|
||||
__ __ _ ____ _____ _ _ ____
|
||||
| \/ | ___ | |__/ ___|| ___|_ _| || | | ___|
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 '
|
||||
|
||||
+1
-1
@@ -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 <[email protected]>"]
|
||||
|
||||
Reference in New Issue
Block a user