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:
Zen1th53
2026-09-21 21:48:28 -04:00
committed by GitHub
co-authored by Ajin Abraham Cursor Agent
parent d3869adc46
commit c78bf022fd
4 changed files with 120 additions and 21 deletions
+1 -1
View File
@@ -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"""
__ __ _ ____ _____ _ _ ____
| \/ | ___ | |__/ ___|| ___|_ _| || | | ___|
+100 -16
View File
@@ -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
View File
@@ -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]>"]