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.
315 lines
12 KiB
Python
315 lines
12 KiB
Python
# -*- coding: utf_8 -*-
|
|
"""Dynamic Analyzer Reporting for iOS devices."""
|
|
import logging
|
|
import shlex
|
|
import shutil
|
|
from pathlib import Path
|
|
import json
|
|
|
|
from django.conf import settings
|
|
from django.shortcuts import render
|
|
from django.template.defaulttags import register
|
|
from django.views.decorators.http import require_http_methods
|
|
from django.http import HttpResponse
|
|
|
|
import mobsf.MalwareAnalyzer.views.Trackers as Trackers
|
|
from mobsf.DynamicAnalyzer.views.ios.analysis import (
|
|
get_screenshots,
|
|
ios_api_analysis,
|
|
run_analysis,
|
|
)
|
|
from mobsf.DynamicAnalyzer.views.ios.device.dynamic_analyzer import (
|
|
FAILED,
|
|
OK,
|
|
validate_and_connect_device,
|
|
)
|
|
from mobsf.DynamicAnalyzer.tools.webproxy import (
|
|
get_http_tools_url,
|
|
stop_httptools,
|
|
)
|
|
from mobsf.MobSF.utils import (
|
|
IOS_DEVICE_ID_REGEX,
|
|
SSH_DEVICE_ID_REGEX,
|
|
base64_decode,
|
|
get_md5,
|
|
is_md5,
|
|
key,
|
|
pretty_json,
|
|
print_n_send_error_response,
|
|
replace,
|
|
strict_package_check,
|
|
)
|
|
from mobsf.MobSF.views.authentication import (
|
|
login_required,
|
|
)
|
|
from mobsf.DynamicAnalyzer.views.common.shared import (
|
|
send_response,
|
|
)
|
|
from mobsf.MobSF.views.authorization import (
|
|
Permissions,
|
|
permission_required,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
register.filter('key', key)
|
|
register.filter('replace', replace)
|
|
register.filter('pretty_json', pretty_json)
|
|
register.filter('base64_decode', base64_decode)
|
|
|
|
|
|
def download_app_data_device(ios_device, checksum):
|
|
"""Download App data from device."""
|
|
app_dir = Path(settings.UPLD_DIR) / checksum
|
|
container_file = app_dir / 'mobsf_app_container_path.txt'
|
|
if container_file.exists():
|
|
app_container = container_file.read_text(
|
|
'utf-8').splitlines()[0].strip()
|
|
tarfile = f'/tmp/{checksum}-app-container.tar'
|
|
localtar = app_dir / f'{checksum}-app-container.tar'
|
|
ios_device.execute_command(
|
|
f'tar -C {shlex.quote(app_container)} -cvf {shlex.quote(tarfile)} .')
|
|
ios_device.download_file(tarfile, localtar)
|
|
if localtar.exists():
|
|
dst = Path(settings.DWD_DIR) / f'{checksum}-app_data.tar'
|
|
shutil.copyfile(localtar, dst)
|
|
|
|
# JSON API
|
|
|
|
|
|
@login_required
|
|
@permission_required(Permissions.SCAN)
|
|
@require_http_methods(['POST'])
|
|
def download_data_device(request, bundle_id, api=False):
|
|
"""Download Application Data from Device."""
|
|
logger.info('Downloading application data')
|
|
data = {
|
|
'status': FAILED,
|
|
'message': 'Failed to Download application data'}
|
|
try:
|
|
device_id = request.POST['device_id']
|
|
ios_device, error = validate_and_connect_device(device_id)
|
|
if error:
|
|
data['message'] = error
|
|
return send_response(data, api)
|
|
if not strict_package_check(bundle_id):
|
|
data['message'] = 'Invalid iOS Bundle id'
|
|
return send_response(data, api)
|
|
checksum = get_md5(bundle_id.encode('utf-8'))
|
|
# App Container download
|
|
logger.info('Downloading app container data')
|
|
download_app_data_device(ios_device, checksum)
|
|
# Stop HTTPS Proxy
|
|
stop_httptools(get_http_tools_url(request))
|
|
# Move HTTP raw logs to download directory
|
|
flows = Path.home() / '.httptools' / 'flows'
|
|
webf = flows / f'{bundle_id}.flow.txt'
|
|
dwd = Path(settings.DWD_DIR)
|
|
dweb = dwd / f'{checksum}-web_traffic.txt'
|
|
if webf.exists():
|
|
shutil.copyfile(webf, dweb)
|
|
data = {
|
|
'status': OK,
|
|
'message': 'Downloaded application data',
|
|
}
|
|
except Exception as exp:
|
|
logger.exception('Downloading application data')
|
|
data['message'] = str(exp)
|
|
return send_response(data, api)
|
|
|
|
|
|
@login_required
|
|
def view_report_device(request, bundle_id, api=False):
|
|
"""Dynamic Analysis Report Generation."""
|
|
logger.info('iOS Dynamic Analysis Report Generation')
|
|
try:
|
|
if api:
|
|
device_id = request.POST.get('device_id')
|
|
else:
|
|
device_id = request.GET.get('device_id')
|
|
if device_id:
|
|
usb = IOS_DEVICE_ID_REGEX.match(device_id)
|
|
wifi = SSH_DEVICE_ID_REGEX.match(device_id)
|
|
if not (usb or wifi):
|
|
return print_n_send_error_response(
|
|
request,
|
|
'Invalid device id or SSH connection string format',
|
|
api)
|
|
if not strict_package_check(bundle_id):
|
|
# bundle_id is not validated in REST API.
|
|
# Also bundleid is not strictly validated
|
|
# in URL path.
|
|
return print_n_send_error_response(
|
|
request,
|
|
'Invalid iOS Bundle id',
|
|
api)
|
|
checksum = get_md5(bundle_id.encode('utf-8'))
|
|
app_dir = Path(settings.UPLD_DIR) / checksum
|
|
download_dir = settings.DWD_DIR
|
|
tools_dir = settings.TOOLS_DIR
|
|
frida_log = app_dir / 'mobsf_frida_out.txt'
|
|
data_dir = app_dir / 'DYNAMIC_DeviceData'
|
|
if not (frida_log.exists() or data_dir.exists()):
|
|
msg = ('Dynamic Analysis report is not available '
|
|
'for this app. Perform Dynamic Analysis '
|
|
'and generate the report.')
|
|
return print_n_send_error_response(request, msg, api)
|
|
api_analysis = ios_api_analysis(app_dir)
|
|
dump_analysis = run_analysis(app_dir, bundle_id, checksum)
|
|
trk = Trackers.Trackers(checksum, app_dir, tools_dir)
|
|
trackers = trk.get_trackers_domains_or_deps(
|
|
dump_analysis['domains'], None)
|
|
screenshots = get_screenshots(checksum, download_dir)
|
|
logger.info('Report generation completed')
|
|
context = {
|
|
'hash': checksum,
|
|
'device_id': device_id,
|
|
'version': settings.MOBSF_VER,
|
|
'title': 'iOS Dynamic Analysis Report',
|
|
'bundleid': bundle_id,
|
|
'trackers': trackers,
|
|
'screenshots': screenshots,
|
|
'frida_logs': frida_log.exists(),
|
|
}
|
|
context.update(api_analysis)
|
|
context.update(dump_analysis)
|
|
template = 'dynamic_analysis/ios/device/dynamic_report.html'
|
|
if api:
|
|
return context
|
|
return render(request, template, context)
|
|
except Exception as exp:
|
|
logger.exception('Dynamic Analysis Report Generation')
|
|
err = f'Error Generating Dynamic Analysis Report. {str(exp)}'
|
|
return print_n_send_error_response(request, err, api)
|
|
|
|
|
|
# JSON API + File Download
|
|
@login_required
|
|
@permission_required(Permissions.SCAN)
|
|
@require_http_methods(['POST'])
|
|
def download_file_device(request, api=False):
|
|
"""Download file from device."""
|
|
err_msg = 'Failed to download file'
|
|
data = {
|
|
'status': FAILED,
|
|
'message': err_msg,
|
|
}
|
|
try:
|
|
remote_path = request.POST['file']
|
|
device_id = request.POST['device_id']
|
|
ios_device, error = validate_and_connect_device(device_id)
|
|
if error:
|
|
data['message'] = error
|
|
return send_response(data, api)
|
|
file_obj = ios_device.download_file_object(remote_path)
|
|
if file_obj:
|
|
response = HttpResponse(file_obj, content_type='application/octet-stream')
|
|
response['Content-Disposition'] = (
|
|
f'inline; filename={Path(remote_path).name}')
|
|
return response
|
|
else:
|
|
data['message'] = 'Failed to download file'
|
|
except Exception as exp:
|
|
logger.exception(err_msg)
|
|
data['message'] = str(exp)
|
|
return send_response(data, api)
|
|
|
|
|
|
# JSON API
|
|
@login_required
|
|
def ios_api_monitor(request, api=False):
|
|
try:
|
|
data = {
|
|
'status': FAILED,
|
|
'message': 'Failed to get API monitor data',
|
|
}
|
|
if api:
|
|
checksum = request.POST['checksum']
|
|
stream = True
|
|
else:
|
|
checksum = request.GET.get('checksum', '')
|
|
stream = request.GET.get('stream', '')
|
|
bundle_id = request.GET.get('bundle_id', '')
|
|
if bundle_id and not strict_package_check(bundle_id):
|
|
data['message'] = 'Invalid iOS Bundle id'
|
|
return send_response(data, api)
|
|
if not is_md5(checksum):
|
|
data['message'] = 'Invalid checksum format'
|
|
return send_response(data, api)
|
|
if stream:
|
|
app_dir = Path(settings.UPLD_DIR) / checksum
|
|
apimon_file = app_dir / 'mobsf_dump_file.txt'
|
|
data = {}
|
|
if not apimon_file.exists():
|
|
data['message'] = 'Data does not exist.'
|
|
return send_response(data, api)
|
|
# Read the file and parse each line as JSON
|
|
lines = apimon_file.read_text(encoding='utf-8').splitlines()
|
|
# remove all duplicate lines
|
|
lines = list(set(lines))
|
|
api_data = []
|
|
|
|
for line in lines:
|
|
if line.strip():
|
|
try:
|
|
# Parse each line as JSON
|
|
json_obj = json.loads(line)
|
|
|
|
# Determine the API type based on the keys in the JSON object
|
|
api_type = None
|
|
if 'cookies' in json_obj:
|
|
api_type = 'Cookies'
|
|
elif 'keychain' in json_obj:
|
|
api_type = 'Keychain'
|
|
elif 'json' in json_obj:
|
|
api_type = 'JSON Data'
|
|
elif 'network' in json_obj:
|
|
api_type = 'Network Request'
|
|
elif 'sql' in json_obj:
|
|
api_type = 'SQL Query'
|
|
elif 'filename' in json_obj:
|
|
api_type = 'File Access'
|
|
elif 'crypto' in json_obj:
|
|
api_type = 'Crypto'
|
|
elif 'files' in json_obj:
|
|
api_type = 'Files'
|
|
elif 'nslog' in json_obj:
|
|
api_type = 'Logs'
|
|
elif 'credentialstorage' in json_obj:
|
|
api_type = 'Credentials'
|
|
elif 'nsuserdefaults' in json_obj:
|
|
api_type = 'UserDefaults'
|
|
elif 'pasteboard' in json_obj:
|
|
api_type = 'Pasteboard'
|
|
elif 'textinput' in json_obj:
|
|
api_type = 'Text Inputs'
|
|
elif 'datadir' in json_obj:
|
|
api_type = 'Data Directory'
|
|
else:
|
|
# If no known key, use the first key as API type
|
|
api_type = list(json_obj.keys())[
|
|
0] if json_obj else 'Unknown'
|
|
|
|
# Create the data object for DataTable
|
|
api_data.append({
|
|
'api': api_type,
|
|
'data': json_obj,
|
|
})
|
|
except json.JSONDecodeError:
|
|
# Skip invalid JSON lines
|
|
continue
|
|
|
|
data['data'] = api_data
|
|
return send_response(data, api)
|
|
template = 'dynamic_analysis/ios/api_monitor.html'
|
|
return render(request,
|
|
template,
|
|
{'checksum': checksum,
|
|
'bundle_id': bundle_id,
|
|
'version': settings.MOBSF_VER,
|
|
'title': 'API Monitor'})
|
|
except Exception:
|
|
logger.exception('API monitor streaming')
|
|
err = 'Error in API monitor streaming'
|
|
return print_n_send_error_response(request, err, api)
|