blob: 508e8312270de1ed7cc922f23f8350dc224a57fc [file] [log] [blame]
#!/usr/bin/env vpython
# Copyright 2018 The Chromium Authors. All rights reserved.
# 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.
"""
from __future__ import print_function
import argparse
import exceptions
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."""
pass
class UnsupportedDeviceError(Exception):
"""Raised when the device is not supported by this script."""
pass
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 PathToDexForPlatformVersion(device, package_name):
"""Gets the full path to the dex file 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))
if len(paths_to_apk) != 1:
raise DeviceOSError(
'Expected exactly one path for {} but found {}'.format(
package_name,
paths_to_apk))
path_to_apk = paths_to_apk[0]
if version_codes.LOLLIPOP <= sdk_level <= version_codes.LOLLIPOP_MR1:
# Of the form "com.example.foo-\d", where \d is some 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:
return 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.')
raise DeviceOSError('Unable to find odex file ' + odex_file)
def _AdbOatDumpForPackage(device, package_name, out_file):
"""Runs oatdump on the device."""
# Get the path to the odex file.
odex_file = PathToDexForPlatformVersion(device, package_name)
device.RunShellCommand(
['oatdump', '--oat-file=' + odex_file, '--output=' + out_file],
timeout=420,
shell=True,
check_return=True)
class JavaClass(object):
"""This represents a Java Class and its ART Class Verification status."""
def __init__(self, name, verification_status):
self.name = name
self.verification_status = verification_status
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)
else:
return obfuscated_name
def ListClassesAndVerificationStatus(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 exceptions.RuntimeError('Unexpected status: {0}'.format(
java_class.verification_status))
else:
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.warn('Skipping deobfuscation because no map file was provided.')
device = DetermineDeviceToUse(device_arg)
device.EnableRoot()
with device_temp_file.DeviceTempFile(
device.adb) as file_on_device:
_AdbOatDumpForPackage(device, package, file_on_device.name)
file_on_host = os.path.join(workdir, 'out.dump')
device.PullFile(file_on_device.name, file_on_host, timeout=220)
proguard_mappings = (_ParseMappingFile(mapping) if mapping else None)
with open(file_on_host, 'r') as f:
java_classes = ListClassesAndVerificationStatus(f, proguard_mappings)
_PrintVerificationResults(status, 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.warn('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()