Android重打包

Android重打包

Android重打包还是挺常用的,因为更为细致的修改可以直接用frida去hook,如果用修改APK,重打包的方式,还需要涉及到smali代码的修改,所以重打包比较适用于对资源的替换,或者一些常量的替换,比较方便

流程刨析

objection Patcher

重打包这种比较常见的需求,肯定有不少开源的项目,Objection里就有重打包的工具

Patching Android Applications · sensepost/objection Wiki (github.com)

其重打包的目的把frida-gadget.so重打包进去,好让objection在免root的手机上也能使用,其代码在

objection/android.py at master · sensepost/objection (github.com)

这里分析一些其中的比较重要的函数

_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def __init__(self, skip_cleanup: bool = False, skip_resources: bool = False):
super(AndroidPatcher, self).__init__()

self.apk_source = None
self.apk_temp_directory = tempfile.mkdtemp(suffix='.apktemp')
self.apk_temp_frida_patched = self.apk_temp_directory + '.objection.apk'
self.apk_temp_frida_patched_aligned = self.apk_temp_directory + '.aligned.objection.apk'
self.aapt = None
self.skip_cleanup = skip_cleanup
self.skip_resources = skip_resources

self.keystore = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../assets', 'objection.jks')
self.netsec_config = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../assets',
'network_security_config.xml')

初始化操作过程中的路径,这里是用的tempfile,其是在tmp路径生成对应的temp文件夹,这里我们可以修改成我们常用的目录

unpack_apk

用的是apktool,decode是解包,-f清空解压目录,-r如果指定的话就是不解压资源,apk_temp_directory是解压目录,apk_source是apk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def unpack_apk(self):
"""
Unpack an APK with apktool.

:return:
"""

click.secho('Unpacking {0}'.format(self.apk_source), dim=True)

o = delegator.run(self.list2cmdline([
self.required_commands['apktool']['location'],
'decode',
'-f',
'-r' if self.skip_resources else '',
'-o',
self.apk_temp_directory,
self.apk_source
]), timeout=self.command_run_timeout)

if len(o.err) > 0:
click.secho('An error may have occurred while extracting the APK.', fg='red')
click.secho(o.err, fg='red')

_get_appt_output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def _get_appt_output(self):
"""
Get the output of `aapt dump badging`.

:return:
"""

if not self.aapt:
o = delegator.run(self.list2cmdline([
self.required_commands['aapt']['location'],
'dump',
'badging',
self.apk_source
]), timeout=self.command_run_timeout)

if len(o.err) > 0:
click.secho('An error may have occurred while running aapt.', fg='red')
click.secho(o.err, fg='red')

self.aapt = o.out

return self.aapt

image-20220610092223657

badging是获得label和icon

_get_launchable_activity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def _get_launchable_activity(self) -> str:
"""
Determines the class name for the activity that is
launched on application startup.

This is done by first trying to parse the output of
aapt dump badging, then falling back to manually
parsing the AndroidManifest for activity-alias tags.

:return:
"""

activities = (match.groups()[0] for match in
re.finditer(r"^launchable-activity: name='([^']+)'", self._get_appt_output(), re.MULTILINE))
activity = next(activities, None)

# If we got the activity using aapt, great, return that
if activity is not None:
return activity

# if we dont have the activity yet, check out activity aliases
click.secho(('Unable to determine the launchable activity using aapt, trying '
'to manually parse the AndroidManifest for activity aliases...'), dim=True, fg='yellow')

# Try and parse the manifest manually
manifest = self._get_android_manifest()
root = manifest.getroot()

# grab all of the activity-alias tags
for alias in root.findall('./application/activity-alias'):

# Take not of the current activity
current_activity = alias.get('{http://schemas.android.com/apk/res/android}targetActivity')
categories = alias.findall('./intent-filter/category')

# make sure we have categories for this alias
if categories is None:
continue

for category in categories:

# check if the name of this category is that of LAUNCHER
# its possible to have multiples, but once we determine one
# that fits we can just return and move on
category_name = category.get('{http://schemas.android.com/apk/res/android}name')

if category_name == 'android.intent.category.LAUNCHER':
return current_activity

# getting here means we were unable to determine what the launchable
# activity is
click.secho('Unable to determine the launchable activity for this app.', fg='red')
raise Exception('Unable to determine launchable activity')

方法是先尝试从aapt的output中获取,aapt获取app信息用的是

_get_appt_output,如果这里找不到,可能APP用的是别名的写法,所以这里手动解析AndroidMenifest,判断是不是launch activity是看有没有这个’android.intent.category.LAUNCHER’

_determine_smali_path_for_class

1
2
3
4
5
6
7
8
9
10
11
12
13
def _determine_smali_path_for_class(self, target_class) -> str:
"""
Attempts to determine the local path for a target class' smali

:param target_class:
:return:
"""

# convert to a filesystem path, just like how it would be on disk
# from the apktool dump
target_class = target_class.replace('.', '/')

activity_path = os.path.join(self.apk_temp_directory, 'smali', target_class) + '.smali'

首先是拼装目录,一般正常情况下,就存在于这个目录里面,但是如果multiDex的情形,apktool的命名是smali_classes2 smali_classes3这种方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# check if the activity path exists. If not, try and see if this may have been
# a multidex setup
if not os.path.exists(activity_path):

click.secho('Smali not found in smali directory. This might be a multidex APK. Searching...', dim=True)

# apk tool will dump the dex classes to a smali directory. in multidex setups
# we have folders such as smali_classes2, smali_classes3 etc. we will search
# those paths for the launch activity we detected.
for x in range(2, 100):
smali_path = os.path.join(self.apk_temp_directory, 'smali_classes{0}'.format(x))

# stop if the smali_classes directory does not exist.
if not os.path.exists(smali_path):
break

# determine the path to the launchable activity again
activity_path = os.path.join(smali_path, target_class) + '.smali'

# if we found the activity, stop the loop
if os.path.exists(activity_path):
click.secho('Found smali at: {0}'.format(activity_path), dim=True)
break

# one final check to ensure we have the target .smali file
if not os.path.exists(activity_path):
raise Exception('Unable to find smali to patch!')

return activity_path

所以下面就是从smali_classes中遍历,去寻找目标activity的smali

inject_load_library

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def inject_load_library(self, target_class: str = None):
"""
Injects a loadLibrary call into a class.
If a target class is not specified, we will make an attempt
at searching for a launchable activity in the target APK.

Most of the idea for this comes from:
https://koz.io/using-frida-on-android-without-root/

:return:
"""

# determine the path to the smali we should inject the load_library
# call into. a user may specify a specific class to target, otherwise
# we get a class name from the internal launchable activity method
# of this class.

if target_class:
click.secho('Using target class: {0} for patch'.format(target_class), fg='green', bold=True)
else:
click.secho('Target class not specified, searching for launchable activity instead...', fg='green',
bold=True)

activity_path = self._determine_smali_path_for_class(
target_class if target_class else self._get_launchable_activity())

click.secho('Reading smali from: {0}'.format(activity_path), dim=True)

# apktool d smali will have a comment line line: '# direct methods'
with open(activity_path, 'r') as f:
smali_lines = f.readlines()

# search for the line starting with '# direct methods' in it
inject_marker = [i for i, x in enumerate(smali_lines) if '# direct methods' in x]

# ensure we got a marker
if len(inject_marker) <= 0:
raise Exception('Unable to determine position to inject a loadLibrary call')

# pick the first position for the inject. add one line as we
# want to inject right below the comment we matched
inject_marker = inject_marker[0] + 1

patched_smali = self._patch_smali_with_load_library(smali_lines, inject_marker)
patched_smali = self._revalue_locals_count(patched_smali, inject_marker)

click.secho('Writing patched smali back to: {0}'.format(activity_path), dim=True)

with open(activity_path, 'w') as f:
f.write(''.join(patched_smali))

首先是利用前面几个函数找到对应的smail,然后是确定插桩函数

,最后就是写入load library

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# search for the line starting with '# direct methods' in it
inject_marker = [i for i, x in enumerate(smali_lines) if '# direct methods' in x]

# ensure we got a marker
if len(inject_marker) <= 0:
raise Exception('Unable to determine position to inject a loadLibrary call')

# pick the first position for the inject. add one line as we
# want to inject right below the comment we matched
inject_marker = inject_marker[0] + 1

patched_smali = self._patch_smali_with_load_library(smali_lines, inject_marker)
patched_smali = self._revalue_locals_count(patched_smali, inject_marker)

click.secho('Writing patched smali back to: {0}'.format(activity_path), dim=True)

with open(activity_path, 'w') as f:
f.write(''.join(patched_smali))

Patch_smali_with_load_library

这个函数插桩分两种情况,如果smali里面有clinit即构造函数存在,就部分插入,即插入一个invoke就行

否则就要插入一个完整的构造函数clinit

_revalue_locals_count

负责修复local指令

add_gadget_to_apk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def add_gadget_to_apk(self, architecture: str, gadget_source: str, gadget_config: str):
"""
Copies a frida gadget for a specific architecture to
an extracted APK's lib path.

:param architecture:
:param gadget_source:
:param gadget_config:
:return:
"""

libs_path = os.path.join(self.apk_temp_directory, 'lib', architecture)

# check if the libs path exists
if not os.path.exists(libs_path):
click.secho('Creating library path: {0}'.format(libs_path), dim=True)
os.makedirs(libs_path)

click.secho('Copying Frida gadget to libs path...', fg='green', dim=True)
shutil.copyfile(gadget_source, os.path.join(libs_path, 'libfrida-gadget.so'))

if gadget_config:
click.secho('Adding a gadget configuration file...', fg='green')
shutil.copyfile(gadget_config, os.path.join(libs_path, 'libfrida-gadget.config.so'))

把gadget.so拷贝到lib下面

上述都是解包的过程,下面是重打包的函数

build_new_apk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def build_new_apk(self, use_aapt2: bool = False):
"""
Build a new .apk with the frida-gadget patched in.

:return:
"""

click.secho('Rebuilding the APK with the frida-gadget loaded...', fg='green', dim=True)
o = delegator.run(
self.list2cmdline([self.required_commands['apktool']['location'],
'build',
self.apk_temp_directory,
] + (['--use-aapt2'] if use_aapt2 else []) + [
'-o',
self.apk_temp_frida_patched
]), timeout=self.command_run_timeout)

if len(o.err) > 0:
click.secho(('Rebuilding the APK may have failed. Read the following '
'output to determine if apktool actually had an error: \n'), fg='red')
click.secho(o.err, fg='red')

click.secho('Built new APK with injected loadLibrary and frida-gadget', fg='green')

build后面跟的就是要打包apk的目录,-o就是打包的生成文件名

下一步是对齐apk

zipalign_apk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def zipalign_apk(self):
"""
Performs the zipalign command on an APK.

:return:
"""

click.secho('Performing zipalign', dim=True)

o = delegator.run(self.list2cmdline([
self.required_commands['zipalign']['location'],
'-p',
'4',
self.apk_temp_frida_patched,
self.apk_temp_frida_patched_aligned
]))

if len(o.err) > 0:
click.secho(('Zipaligning the APK may have failed. Read the following '
'output to determine if zipalign actually had an error: \n'), fg='red')
click.secho(o.err, fg='red')

click.secho('Zipalign completed', fg='green')

用的是zipalign,没什么好说的

最后是sign_apk

sign_apk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def sign_apk(self):
"""
Signs an APK with the objection key.

The keystore itself was created with:
keytool -genkey -v -keystore objection.jks -alias objection -keyalg RSA -keysize 2048 -validity 3650
pass: basil-joule-bug

:return:
"""

click.secho('Signing new APK.', dim=True)

o = delegator.run(self.list2cmdline([
self.required_commands['jarsigner']['location'],
'-sigalg',
'SHA1withRSA',
'-digestalg',
'SHA1',
'-tsa',
'http://timestamp.digicert.com',
'-storepass',
'basil-joule-bug',
'-keystore',
self.keystore,
self.apk_temp_frida_patched,
'objection'
]))

if len(o.err) > 0 or 'jar signed' not in o.out:
click.secho('Signing the new APK may have failed.', fg='red')
click.secho(o.out, fg='yellow')
click.secho(o.err, fg='red')

click.secho('Signed the new APK', fg='green')

签名需要先生成一个keystore,命令如下

1
2
3
The keystore itself was created with:
keytool -genkey -v -keystore objection.jks -alias objection -keyalg RSA -keysize 2048 -validity 3650
pass: basil-joule-bug

这里可以直接用objection的key

objection/objection/utils/assets at master · sensepost/objection (github.com)

签名命令具体什么意思 可以看这里

为应用签名 | Android 开发者 | Android Developers

抽取代码 MyRepackAPP.py

这里先只扣取objection里面的基本的解包打包流程,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if __name__ == '__main__':
if len(sys.argv) < 2:
print("command sample: unpack x.apk")
print("command sample: pack x.apk.dir")
exit(0)
apkrePacker = AndroidPatcher()
# set target apk

#print(sys.argv[0] + sys.argv[1] + sys.argv[2])
if sys.argv[1] == "unpack":
apkrePacker.set_apk_source(sys.argv[2])
# use apktool to unpack target apk
apkrePacker.unpack_apk()
elif sys.argv[1] == "pack":
apkrePacker.build_new_apk()
apkrePacker.zipalign_apk()
apkrePacker.sign_apk()

所以只涉及到这几条命令

执行下面命令对 my.apk进行解包,解包结果会存放在同级目录下的apk_unpack里

1
python3 MyRepackAPP.py unpack my.apk

修改完毕后,执行下面命令重打包,最终生成的apk为apk_unpack.aligned.objection.apk

1
python3 MyRepackAPP.py pack

环境依赖

主要会用到下面工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# required tools
required_commands = {
'aapt': {
'installation': 'apt install aapt (Kali Linux)'
},
'adb': {
'installation': 'apt install adb (Kali Linux); brew install adb (macOS)'
},
'jarsigner': {
'installation': 'apt install default-jdk (Linux); brew cask install java (macOS)'
},
'apktool': {
'installation': 'apt install apktool (Kali Linux)'
},
'zipalign': {
'installation': 'apt install zipalign'
}
}

其中apktool,最好不要直接用apt去安装,这样安装的版本不是最新的,可能会有些问题

Apktool - How to Install (ibotpeaches.github.io)

Release Apktool v2.6.1 · iBotPeaches/Apktool (github.com)

先把下面的wrapper脚本创建出来

https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool

然后再下载最新版本jar

最后再把两个都放在 /usr/local/bin里