class Driver

Constants

DRIVER_CLASS
OUTPUT_HEADER
UNESCAPES
UNESCAPE_REGEX

Public Class Methods

new(device_id, timeout = 60) click to toggle source
# File lib/dex-oracle/driver.rb, line 23
def initialize(device_id, timeout = 60)
  @device_id = device_id
  @timeout = timeout

  device_str = device_id.empty? ? '' : "-s #{@device_id} "
  @adb_base = "adb #{device_str}%s"
  @driver_dir = get_driver_dir
  unless @driver_dir
    logger.error 'Unable to find writable driver directory. Make sure /data/local or /data/local/tmp exists and is writable.'
    exit -1
  end
  logger.debug "Using #{@driver_dir} as driver directory ..."
  @cmd_stub = "export CLASSPATH=#{@driver_dir}/od.zip; app_process /system/bin #{DRIVER_CLASS}"

  @cache = {}
end

Public Instance Methods

install(dex) click to toggle source
# File lib/dex-oracle/driver.rb, line 40
def install(dex)
  has_java = Utility.which('java')
  raise 'Unable to find Java on the path.' unless has_java

  begin
    # Merge driver and target dex file
    # Congratulations. You're now one of the 5 people who've used this tool explicitly.
    raise "#{Resources.dx} does not exist and is required for DexMerger" unless File.exist?(Resources.dx)
    raise "#{Resources.driver_dex} does not exist" unless File.exist?(Resources.driver_dex)
    logger.debug("Merging input DEX (#{dex.path}) and driver DEX (#{Resources.driver_dex}) ...")
    tf = Tempfile.new(%w(oracle-driver .dex))
    cmd = "java -cp #{Resources.dx} com.android.dx.merge.DexMerger #{tf.path} #{dex.path} #{Resources.driver_dex}"
    stdout, stderr = exec(cmd)
    # Ideally, it says something like this:
    # Merged dex #1 (36 defs/87.9KiB)
    # Merged dex #2 (1225 defs/1092.7KiB)
    # Result is 1261 defs/1375.0KiB. Took 0.2s
    # But it might say this:
    # Exception in thread "main" com.android.dex.DexIndexOverflowException: Cannot merge new index 65776 into a non-jumbo instruction!
    if stderr.start_with?('Exception in thread "main"')
      logger.error("Failure to merge input DEX and driver DEX:\n#{stderr}")
      if stderr.include?('DexIndexOverflowException')
        logger.error("Your input DEX inexplicably contains const-string and const-string/jumbo. This probably means someone fucked with it. In any case, it means DexMerge is failing because there are too many strings.\nTry this: baksmali the DEX, replace all const-string instructions with const-string/jumbo, then recompile with smali and use that DEX as input. Sorry, I don't want to do this for you. It's too complicated.")
      end
      exit -1
    end
    tf.close

    # Zip merged dex and push to device
    logger.debug('Pushing merged driver to device ...')
    tz = Tempfile.new(%w(oracle-driver .zip))
    # Could pass tz to create_zip, but Windows doesn't let you rename if file open
    # And zip internally renames files when creating them
    tempzip_path = tz.path
    tz.close
    Utility.create_zip(tempzip_path, 'classes.dex' => tf)
    adb("push #{tz.path} #{@driver_dir}/od.zip")
  rescue => e
    puts "Error installing driver: #{e}\n#{e.backtrace.join("\n\t")}"
  ensure
    tf.close if tf
    tf.unlink if tf
    tz.close if tz
    tz.unlink if tz
  end
end
make_target(class_name, signature, *args) click to toggle source
# File lib/dex-oracle/driver.rb, line 126
def make_target(class_name, signature, *args)
  method = SmaliMethod.new(class_name, signature)
  target = {
    className: method.class.tr('/', '.'),
    methodName: method.name,
    arguments: build_arguments(method.parameters, args)
  }
  # Identifiers are used to map individual inputs to outputs
  target[:id] = Digest::SHA256.hexdigest(target.to_json)

  target
end
run(class_name, signature, *args) click to toggle source
# File lib/dex-oracle/driver.rb, line 87
def run(class_name, signature, *args)
  method = SmaliMethod.new(class_name, signature)
  cmd = build_command(method.class, method.name, method.parameters, args)
  output = nil
  retries = 1
  begin
    output = drive(cmd)
  rescue => e
    # If you slam an emulator or device with too many app_process commands,
    # it eventually gets angry and segmentation faults. No idea why.
    # This took many frustrating hours to figure out.
    raise e if retries > 3

    logger.debug("Driver execution failed. Taking a quick nap and retrying, Zzzzz ##{retries} / 3 ...")
    sleep 5
    retries += 1
    retry
  end

  output
end
run_batch(batch) click to toggle source
# File lib/dex-oracle/driver.rb, line 109
def run_batch(batch)
  push_batch_targets(batch)
  retries = 1
  begin
    drive("#{@cmd_stub} @#{@driver_dir}/od-targets.json", true)
  rescue => e
    raise e if retries > 3 || !e.message.include?('Segmentation fault')

    # Maybe we just need to retry
    logger.debug("Driver execution segfaulted. Taking a quick nap and retrying, Zzzzz ##{retries} / 3 ...")
    sleep 5
    retries += 1
    retry
  end
  pull_batch_outputs
end

Private Instance Methods

adb(cmd) click to toggle source
# File lib/dex-oracle/driver.rb, line 241
def adb(cmd)
  adb_with_stderr(cmd)[0]
end
adb_with_stderr(cmd) click to toggle source
# File lib/dex-oracle/driver.rb, line 245
def adb_with_stderr(cmd)
  full_cmd = @adb_base % cmd
  stdout, stderr = exec(full_cmd, false)
  [stdout.rstrip, stderr.rstrip]
end
build_argument(parameter, argument) click to toggle source
# File lib/dex-oracle/driver.rb, line 282
def build_argument(parameter, argument)
  if parameter[0] == 'L'
    java_type = parameter[1..-2].tr('/', '.')
    if java_type == 'java.lang.String'
      # Need to unescape smali string to get the actual string
      # Converting to bytes just avoids any weird non-printable characters nonsense
      argument = "[#{unescape(argument).bytes.to_a.join(',')}]"
    end
    "#{java_type}:#{argument}"
  else
    argument = (argument == '1' ? 'true' : 'false') if parameter == 'Z'
    "#{parameter}:#{argument}"
  end
end
build_arguments(parameters, args) click to toggle source
# File lib/dex-oracle/driver.rb, line 278
def build_arguments(parameters, args)
  parameters.map.with_index { |o, i| build_argument(o, args[i]) }
end
build_command(class_name, method_name, parameters, args) click to toggle source
# File lib/dex-oracle/driver.rb, line 251
def build_command(class_name, method_name, parameters, args)
  class_name.tr!('/', '.') # Make valid Java class name
  class_name.gsub!('$', '\$') # inner classes
  method_name.gsub!('$', '\$') # synthetic method names
  target = "'#{class_name}' '#{method_name}'"
  target_args = build_arguments(parameters, args)
  "#{@cmd_stub} #{target} #{target_args * ' '}"
end
drive(cmd, batch = false) click to toggle source
# File lib/dex-oracle/driver.rb, line 217
def drive(cmd, batch = false)
  return @cache[cmd] if @cache.key?(cmd)

  full_cmd = "shell \"#{cmd}\"; echo $?"
  full_output = adb(full_cmd)
  output = validate_output(full_cmd, full_output)

  # The driver writes any actual exceptions to the filesystem
  # Need to check to make sure the output value is legitimate
  logger.debug('Checking if execution had any exceptions ...')
  exception = adb("shell cat #{@driver_dir}/od-exception.txt").strip
  unless exception.end_with?('No such file or directory')
    adb("shell rm #{@driver_dir}/od-exception.txt")
    raise exception
  end
  logger.debug('No exceptions found :)')

  # Cache successful results for single method invocations for speed!
  @cache[cmd] = output unless batch
  logger.debug("output = #{output}")

  output
end
exec(cmd, silent = true) click to toggle source
# File lib/dex-oracle/driver.rb, line 177
def exec(cmd, silent = true)
  logger.debug("exec: #{cmd}")

  retries = 1
  begin
    status = Timeout.timeout(@timeout) do
      if silent
        Open3.popen3(cmd) { |_, stdout, stderr, _| [stdout.read, stderr.read] }
      else
        [`#{cmd}`, '']
      end
    end
  rescue => e
    raise e if retries > 3

    logger.debug("ADB command execution timed out, retrying #{retries} ...")
    sleep 5
    retries += 1
    retry
  end
end
get_driver_dir() click to toggle source
# File lib/dex-oracle/driver.rb, line 151
def get_driver_dir
  # On some older devices, /data/local is world writable
  # But on other devices, it's /data/local/tmp
  %w(/data/local /data/local/tmp).each do |dir|
    stdout = adb("shell -x ls #{dir}")
    next if stdout == "ls: #{dir}: Permission denied"
    next if stdout == "ls: #{dir}: No such file or directory"
    return dir
  end

  nil
end
pull_batch_outputs() click to toggle source
# File lib/dex-oracle/driver.rb, line 164
def pull_batch_outputs
  output_file = Tempfile.new(['oracle-output', '.json'])
  logger.debug('Pulling batch results from device ...')
  adb("pull #{@driver_dir}/od-output.json #{output_file.path}")
  adb("shell rm #{@driver_dir}/od-output.json")
  outputs = JSON.parse(File.read(output_file.path))
  outputs.each { |_, (_, v2)| v2.gsub!(/(?:^"|"$)/, '') if v2.start_with?('"') }
  logger.debug("Pulled #{outputs.size} outputs.")
  output_file.close
  output_file.unlink
  outputs
end
push_batch_targets(batch) click to toggle source
# File lib/dex-oracle/driver.rb, line 141
def push_batch_targets(batch)
  target_file = Tempfile.new(%w(oracle-targets .json))
  target_file << batch.to_json
  target_file.flush
  logger.info("Pushing #{batch.size} method targets to device ...")
  adb("push #{target_file.path} #{@driver_dir}/od-targets.json")
  target_file.close
  target_file.unlink
end
unescape(str) click to toggle source
# File lib/dex-oracle/driver.rb, line 262
def unescape(str)
  str.gsub(UNESCAPE_REGEX) do
    if Regexp.last_match[1]
      if Regexp.last_match[1] == '\\'
        Regexp.last_match[1]
      else
        UNESCAPES[Regexp.last_match[1]]
      end
    elsif Regexp.last_match[2] # escape \u0000 unicode
      [Regexp.last_match[2].hex].pack('U*')
    elsif Regexp.last_match[3] # escape \0xff or \xff
      [Regexp.last_match[3]].pack('H2')
    end
  end
end
validate_output(full_cmd, full_output) click to toggle source
# File lib/dex-oracle/driver.rb, line 199
def validate_output(full_cmd, full_output)
  output_lines = full_output.split(/\r?\n/)
  exit_code = output_lines.last.to_i
  if exit_code != 0
    # Non zero exit code would only imply adb command itself was flawed
    # app_process, dalvikvm, etc. don't propigate exit codes back
    raise "Command failed with #{exit_code}: #{full_cmd}\nOutput: #{full_output}"
  end

  # Successful driver run should include driver header
  # Otherwise it may be a Segmentation fault or Killed
  logger.debug("Full output: #{full_output.inspect}")
  header = output_lines[0]
  raise "app_process execution failure, output: '#{full_output}'" if header != OUTPUT_HEADER

  output_lines[1..-2].join("\n").rstrip
end