Files
Mobile-Security-Framework-M…/mobsf/StaticAnalyzer/views/android/icon_analysis.py
T
Ajin Abraham 62563ca429 [HOTFIX][Security] Security Updates July 5 2026 (#2627)
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.
2026-07-05 16:55:49 -07:00

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')