| #!/usr/bin/env vpython3 |
| # Copyright 2018 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """A helper script to list class verification errors. |
| |
| This is a wrapper around the device's oatdump executable, parsing desired output |
| and accommodating API-level-specific details, such as file paths. |
| """ |
| |
| |
| |
| import argparse |
| import dataclasses # pylint: disable=wrong-import-order |
| import logging |
| import os |
| import re |
| |
| import devil_chromium |
| from devil.android import device_errors |
| from devil.android import device_temp_file |
| from devil.android import device_utils |
| from devil.android.ndk import abis |
| from devil.android.sdk import version_codes |
| from devil.android.tools import script_common |
| from devil.utils import logging_common |
| from py_utils import tempfile_ext |
| |
| STATUSES = [ |
| 'NotReady', |
| 'RetryVerificationAtRuntime', |
| 'Verified', |
| 'Initialized', |
| 'SuperclassValidated', |
| ] |
| |
| |
| def DetermineDeviceToUse(devices): |
| """Like DeviceUtils.HealthyDevices(), but only allow a single device. |
| |
| Args: |
| devices: A (possibly empty) list of serial numbers, such as from the |
| --device flag. |
| Returns: |
| A single device_utils.DeviceUtils instance. |
| Raises: |
| device_errors.NoDevicesError: Raised when no non-denylisted devices exist. |
| device_errors.MultipleDevicesError: Raise when multiple devices exist, but |
| |devices| does not distinguish which to use. |
| """ |
| if not devices: |
| # If the user did not specify which device, we let HealthyDevices raise |
| # MultipleDevicesError. |
| devices = None |
| usable_devices = device_utils.DeviceUtils.HealthyDevices(device_arg=devices) |
| # If the user specified more than one device, we still only want to support a |
| # single device, so we explicitly raise MultipleDevicesError. |
| if len(usable_devices) > 1: |
| raise device_errors.MultipleDevicesError(usable_devices) |
| return usable_devices[0] |
| |
| |
| class DeviceOSError(Exception): |
| """Raised when a file is missing from the device, or something similar.""" |
| |
| |
| class UnsupportedDeviceError(Exception): |
| """Raised when the device is not supported by this script.""" |
| |
| |
| def _GetFormattedArch(device): |
| abi = device.product_cpu_abi |
| # Some architectures don't map 1:1 with the folder names. |
| return {abis.ARM_64: 'arm64', abis.ARM: 'arm'}.get(abi, abi) |
| |
| |
| def FindOdexFiles(device, package_name): |
| """Gets the full paths to the dex files on the device.""" |
| sdk_level = device.build_version_sdk |
| paths_to_apk = device.GetApplicationPaths(package_name) |
| if not paths_to_apk: |
| raise DeviceOSError( |
| 'Could not find data directory for {}. Is it installed?'.format( |
| package_name)) |
| |
| ret = [] |
| for path_to_apk in paths_to_apk: |
| if version_codes.LOLLIPOP <= sdk_level <= version_codes.LOLLIPOP_MR1: |
| # Of the form "com.example.foo-\d", where \d is a digit (usually 1 or 2). |
| package_with_suffix = os.path.basename(os.path.dirname(path_to_apk)) |
| arch = _GetFormattedArch(device) |
| dalvik_prefix = '/data/dalvik-cache/{arch}'.format(arch=arch) |
| odex_file = '{prefix}/data@app@{package}@base.apk@classes.dex'.format( |
| prefix=dalvik_prefix, package=package_with_suffix) |
| elif sdk_level >= version_codes.MARSHMALLOW: |
| arch = _GetFormattedArch(device) |
| odex_file = '{data_dir}/oat/{arch}/base.odex'.format( |
| data_dir=os.path.dirname(path_to_apk), arch=arch) |
| else: |
| raise UnsupportedDeviceError( |
| 'Unsupported API level: {}'.format(sdk_level)) |
| |
| odex_file_exists = device.FileExists(odex_file) |
| if odex_file_exists: |
| ret.append(odex_file) |
| elif sdk_level >= version_codes.PIE: |
| raise DeviceOSError( |
| 'Unable to find odex file: you must run dex2oat on debuggable apps ' |
| 'on >= P after installation.') |
| else: |
| raise DeviceOSError('Unable to find odex file ' + odex_file) |
| return ret |
| |
| |
| def _AdbOatDump(device, odex_file, out_file): |
| """Runs oatdump on the device.""" |
| # Get the path to the odex file. |
| with device_temp_file.DeviceTempFile(device.adb) as device_file: |
| device.RunShellCommand( |
| ['oatdump', '--oat-file=' + odex_file, '--output=' + device_file.name], |
| timeout=420, |
| shell=True, |
| check_return=True) |
| device.PullFile(device_file.name, out_file, timeout=220) |
| |
| |
| @dataclasses.dataclass(order=True, frozen=True) |
| class JavaClass: |
| """This represents a Java Class and its ART Class Verification status.""" |
| name: str |
| verification_status: str |
| |
| |
| def _ParseMappingFile(proguard_map_file): |
| """Creates a map of obfuscated names to deobfuscated names.""" |
| mappings = {} |
| with open(proguard_map_file, 'r') as f: |
| pattern = re.compile(r'^(\S+) -> (\S+):') |
| for line in f: |
| m = pattern.match(line) |
| if m is not None: |
| deobfuscated_name = m.group(1) |
| obfuscated_name = m.group(2) |
| mappings[obfuscated_name] = deobfuscated_name |
| return mappings |
| |
| |
| def _DeobfuscateJavaClassName(dex_code_name, proguard_mappings): |
| return proguard_mappings.get(dex_code_name, dex_code_name) |
| |
| |
| def FormatJavaClassName(dex_code_name, proguard_mappings): |
| obfuscated_name = dex_code_name.replace('/', '.') |
| if proguard_mappings is not None: |
| return _DeobfuscateJavaClassName(obfuscated_name, proguard_mappings) |
| return obfuscated_name |
| |
| |
| def ParseOatdump(oatdump_output, proguard_mappings): |
| """Lists all Java classes in the dex along with verification status.""" |
| java_classes = [] |
| pattern = re.compile(r'\d+: L([^;]+).*\(type_idx=[^(]+\((\w+)\).*') |
| for line in oatdump_output: |
| m = pattern.match(line) |
| if m is not None: |
| name = FormatJavaClassName(m.group(1), proguard_mappings) |
| # Some platform levels prefix this with "Status" while other levels do |
| # not. Strip this for consistency. |
| verification_status = m.group(2).replace('Status', '') |
| java_classes.append(JavaClass(name, verification_status)) |
| return java_classes |
| |
| |
| def _PrintVerificationResults(target_status, java_classes, show_summary): |
| """Prints results for user output.""" |
| # Sort to keep output consistent between runs. |
| java_classes.sort(key=lambda c: c.name) |
| d = {} |
| for status in STATUSES: |
| d[status] = 0 |
| |
| for java_class in java_classes: |
| if java_class.verification_status == target_status: |
| print(java_class.name) |
| if java_class.verification_status not in d: |
| raise RuntimeError('Unexpected status: {0}'.format( |
| java_class.verification_status)) |
| d[java_class.verification_status] += 1 |
| |
| if show_summary: |
| for status in d: |
| count = d[status] |
| print('Total {status} classes: {num}'.format( |
| status=status, num=count)) |
| print('Total number of classes: {num}'.format( |
| num=len(java_classes))) |
| |
| |
| def RealMain(mapping, device_arg, package, status, hide_summary, workdir): |
| if mapping is None: |
| logging.warning('Skipping deobfuscation because no map file was provided.') |
| proguard_mappings = None |
| else: |
| proguard_mappings = _ParseMappingFile(mapping) |
| device = DetermineDeviceToUse(device_arg) |
| host_tempfile = os.path.join(workdir, 'out.dump') |
| device.EnableRoot() |
| odex_files = FindOdexFiles(device, package) |
| java_classes = set() |
| for odex_file in odex_files: |
| _AdbOatDump(device, odex_file, host_tempfile) |
| with open(host_tempfile, 'r') as f: |
| java_classes.update(ParseOatdump(f, proguard_mappings)) |
| _PrintVerificationResults(status, sorted(java_classes), not hide_summary) |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser(description=""" |
| List Java classes in an APK which fail ART class verification. |
| """) |
| parser.add_argument( |
| '--package', |
| '-P', |
| type=str, |
| default=None, |
| required=True, |
| help='Specify the full application package name') |
| parser.add_argument( |
| '--mapping', |
| '-m', |
| type=os.path.realpath, |
| default=None, |
| help='Mapping file for the desired APK to deobfuscate class names') |
| parser.add_argument( |
| '--hide-summary', |
| default=False, |
| action='store_true', |
| help='Do not output the total number of classes in each Status.') |
| parser.add_argument( |
| '--status', |
| type=str, |
| default='RetryVerificationAtRuntime', |
| choices=STATUSES, |
| help='Which category of classes to list at the end of the script') |
| parser.add_argument( |
| '--workdir', |
| '-w', |
| type=os.path.realpath, |
| default=None, |
| help=('Work directory for oatdump output (default = temporary ' |
| 'directory). If specified, this will not be cleaned up at the end ' |
| 'of the script (useful if you want to inspect oatdump output ' |
| 'manually)')) |
| |
| script_common.AddEnvironmentArguments(parser) |
| script_common.AddDeviceArguments(parser) |
| logging_common.AddLoggingArguments(parser) |
| |
| args = parser.parse_args() |
| devil_chromium.Initialize(adb_path=args.adb_path) |
| logging_common.InitializeLogging(args) |
| |
| if args.workdir: |
| if not os.path.isdir(args.workdir): |
| raise RuntimeError('Specified working directory does not exist') |
| RealMain(args.mapping, args.devices, args.package, args.status, |
| args.hide_summary, args.workdir) |
| # Assume the user wants the workdir to persist (useful for debugging). |
| logging.warning('Not cleaning up explicitly-specified workdir: %s', |
| args.workdir) |
| else: |
| with tempfile_ext.NamedTemporaryDirectory() as workdir: |
| RealMain(args.mapping, args.devices, args.package, args.status, |
| args.hide_summary, workdir) |
| |
| |
| if __name__ == '__main__': |
| main() |