mirror of
https://github.com/MobSF/Mobile-Security-Framework-MobSF.git
synced 2026-10-11 22:50:09 +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.
403 lines
13 KiB
Python
Executable File
403 lines
13 KiB
Python
Executable File
# -*- coding: utf_8 -*-
|
|
"""Module holding the functions for icon analysis."""
|
|
|
|
import fnmatch
|
|
import logging
|
|
import os
|
|
from shutil import copy2, copytree
|
|
from pathlib import Path
|
|
import subprocess
|
|
|
|
from defusedxml.minidom import parseString
|
|
|
|
from lxml import etree
|
|
|
|
from django.conf import settings
|
|
|
|
from mobsf.MobSF.security import (
|
|
is_path_traversal,
|
|
is_safe_path,
|
|
)
|
|
from mobsf.MobSF.utils import (
|
|
append_scan_status,
|
|
find_java_binary,
|
|
is_file_exists,
|
|
)
|
|
from mobsf.StaticAnalyzer.tools.androguard4 import (
|
|
axml,
|
|
)
|
|
from mobsf.StaticAnalyzer.views.common.shared_func import (
|
|
RESERVED_FILE_NAMES,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# relative to res folder
|
|
KNOWN_PATHS = [
|
|
'mipmap-hdpi',
|
|
'mipmap-xhdpi',
|
|
'drawable-hdpi',
|
|
'drawable-xhdpi',
|
|
'mipmap-mdpi',
|
|
'drawable-mdpi',
|
|
'mipmap-hdpi-v4',
|
|
]
|
|
|
|
KNOWN_MIPMAP_SIZES = [
|
|
'-hdpi',
|
|
'-hdpi-v4',
|
|
'-xhdpi',
|
|
'-xhdpi-v4',
|
|
'-mdpi',
|
|
'-mdpi-v4',
|
|
]
|
|
|
|
|
|
def _search_folder(src, file_pattern):
|
|
matches = []
|
|
for root, _, filenames in os.walk(src):
|
|
for filename in fnmatch.filter(filenames, file_pattern):
|
|
matches.append(os.path.join(root, filename))
|
|
return matches
|
|
|
|
|
|
def guess_icon_path(res_dir):
|
|
icon_folders = [
|
|
'mipmap-hdpi',
|
|
'mipmap-hdpi-v4',
|
|
'drawable',
|
|
]
|
|
for icon_path in icon_folders:
|
|
guessed_icon_path = os.path.join(res_dir, icon_path, 'ic_launcher.png')
|
|
if os.path.exists(guessed_icon_path):
|
|
return guessed_icon_path
|
|
|
|
for guess in _search_folder(res_dir, 'ic_launcher.*'):
|
|
return guess
|
|
|
|
for guess in _search_folder(res_dir, 'ic_launcher*'):
|
|
return guess
|
|
|
|
return ''
|
|
|
|
|
|
def get_icon_from_src(app_dic, icon_from_mfst):
|
|
res_path = ''
|
|
eclipse = Path(app_dic['app_dir']) / 'res'
|
|
studio = Path(app_dic['app_dir']) / 'app' / 'src' / 'main' / 'res'
|
|
if eclipse.exists():
|
|
res_path = eclipse.as_posix()
|
|
elif studio.exists():
|
|
res_path = studio.as_posix()
|
|
if not res_path:
|
|
return
|
|
|
|
icon_file = find_icon_path_zip(
|
|
app_dic['md5'],
|
|
res_path,
|
|
icon_from_mfst)
|
|
if icon_file and Path(icon_file).exists():
|
|
dwd = Path(settings.DWD_DIR)
|
|
out = dwd / (app_dic['md5'] + '-icon' + Path(icon_file).suffix)
|
|
copy2(icon_file, out)
|
|
app_dic['icon_path'] = out.name
|
|
|
|
|
|
def find_icon_path_zip(checksum, res_dir, icon_paths_from_manifest):
|
|
"""
|
|
Find icon.
|
|
|
|
Tries to find an icon, based on paths
|
|
fetched from the manifest and by global search
|
|
returns an empty string on fail or a full path
|
|
"""
|
|
try:
|
|
msg = 'Guessing icon path'
|
|
logger.info(msg)
|
|
append_scan_status(checksum, msg)
|
|
for icon_path in icon_paths_from_manifest:
|
|
if is_path_traversal(icon_path):
|
|
logger.warning('Path traversal detected in icon path: %s', icon_path)
|
|
continue
|
|
if icon_path.startswith('@'):
|
|
path_array = icon_path.strip('@').split(os.sep)
|
|
rel_path = os.sep.join(path_array[1:])
|
|
for size_str in KNOWN_MIPMAP_SIZES:
|
|
tmp_path = os.path.join(
|
|
res_dir, path_array[0] + size_str, rel_path + '.png')
|
|
if not is_safe_path(res_dir, tmp_path, icon_path):
|
|
continue
|
|
if os.path.exists(tmp_path):
|
|
return tmp_path
|
|
elif icon_path.startswith(('res/', '/res/')):
|
|
# Use removeprefix-style stripping to remove only the res/ prefix,
|
|
# not a character set (str.strip('/res') strips chars {/,r,e,s}).
|
|
rel = icon_path.lstrip('/')
|
|
if rel.startswith('res/'):
|
|
rel = rel[len('res/'):]
|
|
full_path = os.path.join(res_dir, rel)
|
|
if not is_safe_path(res_dir, full_path, icon_path):
|
|
continue
|
|
if os.path.exists(full_path):
|
|
return full_path
|
|
full_path += '.png'
|
|
if not is_safe_path(res_dir, full_path, icon_path):
|
|
continue
|
|
if os.path.exists(full_path):
|
|
return full_path
|
|
|
|
file_name = icon_path.split(os.sep)[-1]
|
|
if file_name.endswith('.png'):
|
|
file_name += '.png'
|
|
|
|
for guess in _search_folder(res_dir, file_name):
|
|
if os.path.exists(guess):
|
|
return guess
|
|
|
|
# If didn't find, try the default name.. returns empty if not find
|
|
return guess_icon_path(res_dir)
|
|
|
|
except Exception as exp:
|
|
msg = 'Failed to find icon path'
|
|
logger.exception(msg)
|
|
append_scan_status(checksum, msg, repr(exp))
|
|
|
|
|
|
def get_icon_apk_res(app_dic):
|
|
"""Get icon path from APK resource."""
|
|
icon_src = ''
|
|
checksum = app_dic['md5']
|
|
try:
|
|
msg = 'Fetching icon path'
|
|
logger.info(msg)
|
|
append_scan_status(checksum, msg)
|
|
app_dir = Path(app_dic['app_dir'])
|
|
res_path = app_dir / 'res'
|
|
apktool_res_path = app_dir / 'apktool_out' / 'res'
|
|
icon_name = None
|
|
|
|
# If icon is found in androguard or aapt2
|
|
icon_name = app_dic.get('androguard_apk_icon') or app_dic.get(
|
|
'apk_features', {}).get('application_icon')
|
|
if is_path_traversal(icon_name):
|
|
logger.warning('Path traversal detected in icon path')
|
|
icon_name = None
|
|
# Handle reserved file names case
|
|
if icon_name and any(
|
|
icon_name.startswith(x) for x in RESERVED_FILE_NAMES):
|
|
icon_name = str(Path('_conflict_') / icon_name)
|
|
|
|
# androguard/aapt2 cannot find icon file, fallback to res or apktool.
|
|
if not res_path.exists() and apktool_res_path.exists() and not icon_name:
|
|
logger.warning('Cannot find res directory,'
|
|
' fallback to apktool res directory')
|
|
try:
|
|
copytree(apktool_res_path, res_path, dirs_exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
if res_path.exists() and not icon_name:
|
|
icon_name = ''
|
|
logger.warning('androguard/aapt2 cannot find icon resource')
|
|
icon_name = guess_icon_path(str(res_path))
|
|
icon_src = icon_name
|
|
|
|
if icon_name and icon_name.endswith('.xml'):
|
|
# Handle XML icon case
|
|
apktool_res = False
|
|
# Can be vector XML/XML pointing to vector files
|
|
# Convert AXML to XML for vector
|
|
if not convert_axml_to_xml(app_dir, icon_name):
|
|
# not vector, but adaptive icon
|
|
# need parsing, let's use apktool res
|
|
apktool_res = True
|
|
# Attempt to generate svg(s) from avg(s)
|
|
convert_vector_to_svg(
|
|
app_dir,
|
|
app_dic['tools_dir'],
|
|
icon_name,
|
|
apktool_res)
|
|
xpath = app_dir / icon_name
|
|
ipath = xpath.parent / (xpath.stem + '.svg')
|
|
if ipath.exists():
|
|
# When icon xml is a vector
|
|
icon_path = ipath.as_posix()
|
|
else:
|
|
# When icon xml point to other vector files
|
|
icon_path = get_icon_svg_from_xml(app_dir, icon_name)
|
|
if icon_path:
|
|
icon_src = icon_path
|
|
else:
|
|
# if we cannot find from xml
|
|
icon_src = guess_icon_path(str(res_path))
|
|
elif icon_name:
|
|
# We found png icon, the easy path
|
|
icon_src = (app_dir / icon_name).as_posix()
|
|
|
|
# Log warning if correct icon is not found
|
|
if icon_src.endswith('.xml'):
|
|
logger.warning('Cannot find icon file from xml')
|
|
icon_src = ''
|
|
elif not icon_src.endswith(('.png', '.svg', '.webp')):
|
|
logger.warning('Cannot find a valid icon file')
|
|
icon_src = ''
|
|
if not icon_name:
|
|
logger.warning('Cannot find icon file')
|
|
icon_src = ''
|
|
return icon_src
|
|
except Exception as exp:
|
|
msg = 'Failed to fetch icon path'
|
|
logger.exception(msg)
|
|
append_scan_status(checksum, msg, repr(exp))
|
|
return icon_src
|
|
|
|
|
|
def get_icon_apk(app_dic):
|
|
"""Get/Guess icon from APK binary."""
|
|
app_dic['icon_path'] = ''
|
|
try:
|
|
# Icon lookup in res directory
|
|
icon_file = get_icon_apk_res(app_dic)
|
|
if icon_file:
|
|
src = Path(icon_file)
|
|
# Copy PNG/SVG to Downloads
|
|
icon = app_dic['md5'] + '-icon' + src.suffix.lower()
|
|
out = Path(settings.DWD_DIR) / icon
|
|
if src and src.exists() and src.is_file():
|
|
copy2(src.as_posix(), out.as_posix())
|
|
app_dic['icon_path'] = out.name
|
|
except Exception:
|
|
logger.exception('Failed to get icon from APK')
|
|
|
|
|
|
# PNG icon lookup functions above ^
|
|
# SVG/XML icon lookup functions below
|
|
|
|
|
|
def transform_svg(fpath, bpath, output):
|
|
"""Transform SVG from foreground and background."""
|
|
try:
|
|
import svgutils.transform as sg
|
|
background = sg.fromfile(bpath)
|
|
logo = sg.fromfile(fpath)
|
|
root = logo.getroot()
|
|
root.moveto(1, 1)
|
|
background.append([root])
|
|
background.save(output)
|
|
return output.as_posix()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def get_icon_svg_from_xml(app_dir, icon_xml_file):
|
|
"""
|
|
Parse XML file for icon path.
|
|
|
|
Get icon path from XML.
|
|
"""
|
|
try:
|
|
icon_xml = app_dir / 'apktool_out' / icon_xml_file
|
|
parsed = parseString(icon_xml.read_text('utf8', 'ignore'))
|
|
foreground = parsed.getElementsByTagName('foreground')
|
|
background = parsed.getElementsByTagName('background')
|
|
ficon = foreground[0].getAttribute(
|
|
'android:drawable').rsplit('/', 1)[1]
|
|
bicon = background[0].getAttribute(
|
|
'android:drawable').rsplit('/', 1)[1]
|
|
fpath, bpath = None, None
|
|
for f in icon_xml.parent.rglob('*.svg'):
|
|
if ficon in f.name:
|
|
fpath = f
|
|
if bicon in f.name:
|
|
bpath = f
|
|
if fpath and bpath:
|
|
break
|
|
# To not break existing users
|
|
output = icon_xml.parent / f'{icon_xml.stem}.svg'
|
|
return transform_svg(fpath, bpath, output)
|
|
except Exception:
|
|
try:
|
|
fsvg, bsvg = None, None
|
|
search_loc = app_dir / 'apktool_out' / 'res' / 'drawable'
|
|
if not search_loc.exists():
|
|
return None
|
|
rand_icon = ''
|
|
for f in search_loc.rglob('*.svg'):
|
|
rand_icon = f.as_posix()
|
|
if 'ic_launcher_foreground.svg' in f.name:
|
|
fsvg = f
|
|
if 'ic_launcher_background.svg' in f.name:
|
|
bsvg = f
|
|
if fsvg and bsvg:
|
|
break
|
|
if fsvg and bsvg:
|
|
output = search_loc / 'ic_launcher.svg'
|
|
return transform_svg(fsvg, bsvg, output)
|
|
else:
|
|
return rand_icon
|
|
except Exception:
|
|
logger.exception('Guessing icon svg')
|
|
|
|
|
|
def convert_axml_to_xml(app_dir, icon_file):
|
|
"""Convert AXML to XML for icons from /res."""
|
|
try:
|
|
logger.info('Converting icon axml to xml')
|
|
icon_bin_xml = app_dir / icon_file
|
|
out_xml = app_dir / icon_file
|
|
aobj = axml.AXMLPrinter(
|
|
icon_bin_xml.read_bytes()).get_xml_obj()
|
|
xml_txt = etree.tostring(
|
|
aobj, pretty_print=True, encoding='utf-8')
|
|
out_xml.write_bytes(xml_txt)
|
|
if b'<adaptive-icon' in xml_txt:
|
|
return False
|
|
return True
|
|
except Exception:
|
|
logger.exception('Failed to convert axml to xml')
|
|
|
|
|
|
def convert_vector_to_svg(app_dir, tools_dir, icon_name, apktool_res):
|
|
"""Convert android vector graphics to svg."""
|
|
try:
|
|
fnull = open(os.devnull, 'w')
|
|
userbin = getattr(settings, 'VD2SVG_BINARY', '')
|
|
if userbin and is_file_exists(userbin):
|
|
vd2svg = userbin
|
|
else:
|
|
vd2svg = Path(tools_dir) / 'vd2svg-0.4.4.jar'
|
|
# When xml is android vector
|
|
values = app_dir / 'res' / 'values'
|
|
direct = app_dir / icon_name
|
|
cwd = direct.parent.as_posix()
|
|
|
|
# When xml is referencing to vector use apktool_res
|
|
files = []
|
|
if apktool_res:
|
|
values = app_dir / 'apktool_out' / 'res' / 'values'
|
|
drawable = app_dir / 'apktool_out' / 'res' / 'drawable'
|
|
cwd = drawable.as_posix()
|
|
for f in drawable.parent.rglob('*.xml'):
|
|
files.append(f.name)
|
|
# Pass xml filename(s) and set cwd as parent
|
|
args = [
|
|
find_java_binary(),
|
|
'-jar',
|
|
vd2svg.as_posix(),
|
|
'-r',
|
|
values.as_posix(),
|
|
]
|
|
if files:
|
|
args.extend(files)
|
|
else:
|
|
args.extend([direct.name])
|
|
subprocess.run(
|
|
args,
|
|
stdout=fnull,
|
|
stderr=subprocess.STDOUT,
|
|
cwd=cwd,
|
|
timeout=30)
|
|
except Exception:
|
|
logger.exception('Android vector to svg conversion failed')
|