mirror of
https://github.com/MobSF/Mobile-Security-Framework-MobSF.git
synced 2026-10-12 02:06:59 +00:00
Security Fixes [GHSA-8j49-mmcx-4mp5] Arbitrary File Read via Path Traversal in ZIP/APK Icon Extraction The android:icon attribute from an APK manifest was interpolated into file paths without validation, allowing a crafted APK to read arbitrary files from the server. Fixed by adding is_path_traversal() and is_safe_path() guards in find_icon_path_zip. Dependency bump included. [GHSA-3p54-567p-2wpr] CSRF Checks Not Enforced After Django Middleware Migration The migration from the deprecated MIDDLEWARE_CLASSES to MIDDLEWARE omitted CsrfViewMiddleware, SecurityMiddleware, and XFrameOptionsMiddleware, leaving CSRF, HSTS, and clickjacking protections silently disabled. All three have been restored to the active MIDDLEWARE tuple. The now-dead MIDDLEWARE_CLASSES block has been removed to prevent future confusion. [GHSA-x768-8642-mmq9] Zip Bomb Denial of Service via Per-File Size Limit Bypass The per-file size check in ZIP extraction logged a warning on oversized members but was missing a continue, allowing files exceeding ZIP_MAX_UNCOMPRESSED_FILE_SIZE to be extracted anyway as long as the aggregate limit was not reached. Fixed by adding the missing continue. [GHSA-95px-34x5-p37h] SSRF Port Restriction Bypass in assetlinks_check valid_host() was called with only the bare hostname, allowing android:port to be appended afterward without going through port validation, bypassing the HTTP/HTTPS-only restriction. Fixed with a two-layer approach: primary port allowlist check before URL assembly in get_browsable_activities, and a defence-in-depth re-check in _check_url. Hardening The hand-rolled safe_extract used os.path.abspath (symlink-blind) instead of os.path.realpath, creating a TOCTOU window where a symlink could redirect extraction outside the destination directory. Replaced with Python 3.12's tarfile.extractall(filter='data') (PEP 706), which rejects symlinks, hardlinks, absolute paths, and traversal members per-member before extraction. A robust realpath-based fallback is included for older Python versions. Both download_app_data call sites in the iOS dynamic analyser now wrap app_container and tarfile with shlex.quote when building the SSH tar command, as defensive coding hygiene. Refactoring is_path_traversal, is_safe_path, clean_filename, cmd_injection_check, is_pipe_or_link, and is_attack_pattern were scattered across utils.py and shared.py. All have been moved to mobsf/MobSF/security.py as the single authoritative location for security primitives. Developer Tooling AGENTS.md / CLAUDE.md - AI agent guidelines Added a coding-standards document for AI coding agents (Cursor, Claude, Codex) covering MobSF-specific secure-by-default patterns: path traversal guards, archive extraction safety, Django middleware active-tuple hygiene, split-validation anti-patterns (SSRF port bypass), guard completeness (continue/return/raise after every security check), and Django-specific security features (form validators, decorators, middleware). CLAUDE.md is a symlink to AGENTS.md.
185 lines
6.2 KiB
Python
185 lines
6.2 KiB
Python
# -*- coding: utf_8 -*-
|
|
"""Common helpers for Android and iOS Dynamic Analysis."""
|
|
import logging
|
|
import os
|
|
import re
|
|
import errno
|
|
import json
|
|
import tarfile
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from django.http import HttpResponse
|
|
|
|
from mobsf.MalwareAnalyzer.views.MalwareDomainCheck import (
|
|
MalwareDomainCheck,
|
|
)
|
|
from mobsf.MobSF.exceptions import PathTraversalError
|
|
from mobsf.MobSF.security import clean_filename, is_pipe_or_link
|
|
from mobsf.MobSF.utils import (
|
|
EMAIL_REGEX,
|
|
URL_REGEX,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def extract_urls_domains_emails(checksum, data):
|
|
"""Extract URLs, Domains and Emails."""
|
|
# URL Extraction
|
|
urls = re.findall(URL_REGEX, data)
|
|
if urls:
|
|
urls = list(set(urls))
|
|
else:
|
|
urls = []
|
|
# Domain Extraction and Malware Check
|
|
logger.info('Performing Malware check on extracted domains')
|
|
# For domain extraction, use lowercased URLs
|
|
domains = MalwareDomainCheck().scan(
|
|
checksum,
|
|
urls)
|
|
# Email Extraction Regex
|
|
emails = set()
|
|
for email in EMAIL_REGEX.findall(data.lower()):
|
|
if email.startswith('//'):
|
|
continue
|
|
if email.endswith('.png'):
|
|
continue
|
|
emails.add(email)
|
|
return urls, domains, emails
|
|
|
|
|
|
def safe_paths(tar_meta):
|
|
"""Safe filenames in windows."""
|
|
for fh in tar_meta:
|
|
fh.name = clean_filename(fh.name)
|
|
yield fh
|
|
|
|
|
|
def onexc(func, path, exc_info):
|
|
_, exc_value, _ = exc_info
|
|
if exc_value.errno == errno.EACCES: # Permission error
|
|
try:
|
|
os.chmod(path, 0o755)
|
|
func(path)
|
|
except Exception:
|
|
pass
|
|
elif exc_value.errno == errno.ENOTEMPTY: # Directory not empty
|
|
try:
|
|
func(path)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
raise
|
|
|
|
|
|
def untar_files(tar_loc, untar_dir):
|
|
"""Untar files."""
|
|
logger.info('Extracting Tar files')
|
|
try:
|
|
# Extract Device Data
|
|
if not tar_loc.exists():
|
|
return False
|
|
if untar_dir.exists():
|
|
# fix for permission errors
|
|
shutil.rmtree(untar_dir, onexc=onexc)
|
|
else:
|
|
os.makedirs(untar_dir)
|
|
with tarfile.open(tar_loc.as_posix(), errorlevel=1) as tar:
|
|
if hasattr(tarfile, 'data_filter'):
|
|
# Python 3.12+ (PEP 706) / backported to 3.11.4, 3.10.12, 3.9.17.
|
|
# Rejects symlinks outside destination, hardlinks, device files,
|
|
# and absolute/traversal paths per-member before extraction.
|
|
tar.extractall(
|
|
untar_dir,
|
|
members=safe_paths(tar),
|
|
filter='data')
|
|
else:
|
|
# Fallback for Python < 3.9.17 without the PEP 706 backport.
|
|
# Manually reject symlinks and hardlinks, then use realpath
|
|
# (not abspath) to verify the resolved path stays inside the
|
|
# destination before extracting each member.
|
|
safe_root = os.path.realpath(untar_dir)
|
|
safe_members = []
|
|
for member in safe_paths(tar):
|
|
if member.issym() or member.islnk():
|
|
logger.warning(
|
|
'Skipping link member in tar: %s', member.name)
|
|
continue
|
|
member_path = os.path.realpath(
|
|
os.path.join(safe_root, member.name))
|
|
if not (member_path.startswith(safe_root + os.sep)
|
|
or member_path == safe_root):
|
|
raise PathTraversalError(
|
|
'Attempted Path Traversal in Tar File')
|
|
safe_members.append(member)
|
|
tar.extractall(untar_dir, members=iter(safe_members))
|
|
except (FileExistsError, tarfile.ReadError):
|
|
logger.warning('Failed to extract tar file')
|
|
except Exception:
|
|
logger.exception('Tar extraction failed')
|
|
return True
|
|
|
|
|
|
def get_app_files(app_dir, tarname):
|
|
"""Get files from device."""
|
|
logger.info('Getting app files')
|
|
all_files = {'xml': [], 'sqlite': [], 'others': [], 'plist': []}
|
|
appdir = Path(app_dir)
|
|
tar_loc = appdir / f'{tarname}.tar'
|
|
untar_dir = appdir / 'DYNAMIC_DeviceData'
|
|
success = untar_files(tar_loc, untar_dir)
|
|
if not success:
|
|
return all_files
|
|
# Do Static Analysis on Data from Device
|
|
try:
|
|
untar_dir = untar_dir.as_posix()
|
|
for dir_name, _, files in os.walk(untar_dir):
|
|
for jfile in files:
|
|
file_path = os.path.join(untar_dir, dir_name, jfile)
|
|
fileparam = file_path.replace(f'{untar_dir}/', '')
|
|
if is_pipe_or_link(file_path):
|
|
continue
|
|
if jfile == 'lib':
|
|
pass
|
|
else:
|
|
if jfile.endswith('.xml'):
|
|
all_files['xml'].append(
|
|
{'type': 'xml', 'file': fileparam})
|
|
elif jfile.endswith('.plist'):
|
|
all_files['plist'].append(
|
|
{'type': 'plist', 'file': fileparam})
|
|
else:
|
|
with open(file_path,
|
|
'r',
|
|
encoding='ISO-8859-1') as flip:
|
|
file_cnt_sig = flip.read(6)
|
|
if file_cnt_sig == 'SQLite':
|
|
all_files['sqlite'].append(
|
|
{'type': 'db', 'file': fileparam})
|
|
elif not jfile.endswith('.DS_Store'):
|
|
all_files['others'].append(
|
|
{'type': 'others', 'file': fileparam})
|
|
except Exception:
|
|
logger.exception('Getting app files')
|
|
return all_files
|
|
|
|
|
|
def send_response(data, api=False):
|
|
"""Return JSON Response."""
|
|
if api:
|
|
return data
|
|
return HttpResponse(
|
|
json.dumps(data),
|
|
content_type='application/json; charset=utf-8')
|
|
|
|
|
|
def invalid_params(api=False):
|
|
"""Standard response for invalid params."""
|
|
msg = 'Invalid Parameters'
|
|
logger.error(msg)
|
|
data = {'status': 'failed', 'message': msg}
|
|
if api:
|
|
return data
|
|
return send_response(data)
|