Android APK patching
Recently I’ve been job hunting, and so I’ve been reflecting on my past work experience. One position I applied to had a required “portfolio” link, and at first I thought that must have been a mistake in the application form. But upon further reflection, I thought, for a front-end engineer, why not? This got me wondering if I could dig up screenshots/recordings of any of the features I’ve worked on in previous roles.
Specifically, I was thinking about my time working on the Android Google Maps app. During my time on the team, we went through not one but two major app redesigns, so a lot of the UI I had worked on back then has since been completely replaced.
Archived builds
Fortunately, APKMirror has tons of archived Google Maps APKs. Their coverage gets pretty spotty before 2014, but still includes a decent selection. Most relevant for my nostalgia, they have a build of Google Maps 7.0. This was the major redesign that was underway when I first joined the team.
I wasn’t sure if these old builds would work with modern Android device images, but I was glad to see that Android Studio still provides emulator system images for devices from around the same era.
After I had downloaded Android Studio, fired up Device Manager, selected what
I thought to be an appropriately-ancient Pixel 2 device image with Android 5.0
(Lollipop), installed adb
, and
then finally installed the old Google Maps APK, I was greeted with this:
D’oh!
I had completely forgotten about the server-controlled kill switch built in to the app. The Google Maps team put a strong emphasis on backwards-compatibility, but also realized there would be cases where we might need to force users to stop using a particular released version.
Is there any way to bypass this update prompt?
apktool
to the rescue
We can disassemble the archived Google Maps APKs using the open-source
apktool
:
$ apktool d -r com.google.android.apps.maps_7.0.2-700022123_minAPI18\(320dpi\)_apkmirror.com.apk -o maps-7.0
I: Using Apktool 2.7.0 on com.google.android.apps.maps_7.0.2-700022123_minAPI18(320dpi)_apkmirror.com.apk
I: Copying raw resources...
I: Baksmaling classes.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
(The -r
option avoids decoding resources, as apktool
seemed to have some
issues with the resources.)
The resulting output includes a folder named “smali” containing all of the
disassembled Java class bytecode. Many of the class names have been obfuscated,
but we’re in luck: there’s a class named KillSwitchFragment
.
We can see one of its methods finds and initializes a WebView, so it certainly looks like we’re on the right track:
.method private d()Landroid/view/View;
.locals 2
.prologue
.line 83
sget v0, Lcom/google/android/apps/gmm/i;->dv:I
const/4 v1, 0x0
invoke-virtual {p0, v0, v1}, Lcom/google/android/apps/gmm/terms/KillSwitchFragment;->a(ILandroid/view/ViewGroup;)Landroid/view/View;
move-result-object v0
.line 84
sget v1, Lcom/google/android/apps/gmm/g;->gN:I
invoke-virtual {v0, v1}, Landroid/view/View;->findViewById(I)Landroid/view/View;
move-result-object v0
check-cast v0, Landroid/webkit/WebView;
.line 86
new-instance v1, Lcom/google/android/apps/gmm/terms/a;
invoke-direct {v1, p0}, Lcom/google/android/apps/gmm/terms/a;-><init>(Lcom/google/android/apps/gmm/terms/KillSwitchFragment;)V
invoke-virtual {v0, v1}, Landroid/webkit/WebView;->setWebViewClient(Landroid/webkit/WebViewClient;)V
.line 98
iget-object v1, p0, Lcom/google/android/apps/gmm/terms/KillSwitchFragment;->b:Ljava/lang/String;
invoke-virtual {v0, v1}, Landroid/webkit/WebView;->loadUrl(Ljava/lang/String;)V
.line 100
return-object v0
.end method
This isn’t the easiest to read. A quick primer to help make sense of it:
- Method parameter values are referred to as
p0
,p1
,p2
, etc. - For an instance method, parameter
p0
is thethis
reference. - Local variables are referred to as
v0
,v1
,v2
, etc. - Java classes are referenced with an
L
, followed by their fully-qualified class name (with all.
characters replaced by/
characters), followed by a;
(e.g.android.webkit.WebView
becomesLandroid/webkit/WebView;
) - Other types include
I
(int),Z
(boolean), andV
(void). - The
invoke-
mnemonics represent a method call. - Method references consist of the class name followed by
->
, the method name, the types of the method parameters (in parentheses), and the method return type. - Constructors are referred to like a void method with the name
<init>
.
So this method may have looked something like this in the source code:
private View createView() {
View view = this.inflateLayout(some_layout_id, null);
WebView webView = (WebView) view.findViewById(some_view_id);
WebViewClient client = new KillSwitchWebViewClient(this);
webView.setWebViewClient(client);
webView.loadUrl(this.url);
return webView;
}
(I’m guessing at some of the method names based on the signatures.)
We can search through all the disassembled classes to look for references
to KillSwitchFragment
, and after tracing a few method call chains, I came
to this line in a class named GmmActivity
:
invoke-static {p0, v0}, Lcom/google/android/apps/gmm/terms/KillSwitchFragment;->a(Lcom/google/android/apps/gmm/base/activities/GmmActivity;Z)V
Let’s just comment that line out with a #
and see what happens….
Building a patched APK
We can reassemble an APK by running:
$ apktool b maps-7.0
I: Using Apktool 2.7.0
I: Checking whether sources has changed...
I: Smaling smali folder into classes.dex...
I: Checking whether resources has changed...
I: Copying raw resources...
I: Building apk file...
I: Copying unknown files/dir...
I: Built apk into: maps-7.0/dist/com.google.android.apps.maps_7.0.2-700022123_minAPI18(320dpi)_apkmirror.com.apk
However in order to install to a device (or emulator), we need to sign the APK.
We can generate a dummy signing key for this with keytool
(part of the Java
runtime):
$ keytool -genkey -keystore dummy.keystore -alias dummy -keyalg RSA -keysize 2048 -validity 100
After following through the prompts, we can then use apksigner
(from the
Android SDK build tools) to sign our patched APK with the dummy key:
$ apksigner sign --ks dummy.keystore maps-7.0/dist/com.google.android.apps.maps_7.0.2-700022123_minAPI18\(320dpi\)_apkmirror.com.apk
And then we can launch a device emulator and finally install our patched APK
using adb
:
$ adb install -r maps-7.0/dist/com.google.android.apps.maps_7.0.2-700022123_minAPI18\(320dpi\)_apkmirror.com.apk
And we get this…
Hmm. Progress?
More patching
Well, at least we can use adb logcat
to look for crash information, and
what do you know:
FATAL EXCEPTION: main
Process: com.google.android.apps.maps, PID: 3012
java.lang.SecurityException: GoogleCertificatesRslt: not whitelisted: pkg=com.google.android.apps.maps, sha1=9459608ef2083805c6f6075779366c5831df2f8f, atk=true, ver=202414010.true (go/gsrlt)
Well yes, our patched APK would fail whatever hash-based verification it might be subjected to, wouldn’t it.
The logcat output also has a stack trace to work with, and there’s only one
stack frame matching the com.google.android.apps.gmm
package name:
--------- beginning of crash
E/AndroidRuntime( 3012): FATAL EXCEPTION: main
E/AndroidRuntime( 3012): Process: com.google.android.apps.maps, PID: 3012
E/AndroidRuntime( 3012): java.lang.SecurityException: GoogleCertificatesRslt: not whitelisted: pkg=com.google.android.apps.maps, sha1=9459608ef2083805c6f6075779366c5831df2f8f, atk=true, ver=202414010.true (go/gsrlt)
E/AndroidRuntime( 3012): at android.os.Parcel.readException(Parcel.java:1540)
E/AndroidRuntime( 3012): at android.os.Parcel.readException(Parcel.java:1493)
E/AndroidRuntime( 3012): at com.google.android.gms.internal.v.c(Unknown Source)
E/AndroidRuntime( 3012): at com.google.android.gms.internal.aS.a(Unknown Source)
E/AndroidRuntime( 3012): at com.google.android.gms.internal.g.b(Unknown Source)
E/AndroidRuntime( 3012): at com.google.android.gms.internal.k.onServiceConnected(Unknown Source)
E/AndroidRuntime( 3012): at com.google.android.gms.internal.m.a(Unknown Source)
E/AndroidRuntime( 3012): at com.google.android.gms.internal.g.c(Unknown Source)
E/AndroidRuntime( 3012): at com.google.android.gms.location.reporting.c.a(Unknown Source)
E/AndroidRuntime( 3012): at com.google.android.apps.gmm.ulr.UlrPromoFragment.onResume(SourceFile:89)
E/AndroidRuntime( 3012): at android.app.Fragment.performResume(Fragment.java:2096)
E/AndroidRuntime( 3012): at android.app.FragmentManagerImpl.moveToState(FragmentManager.java:928)
E/AndroidRuntime( 3012): at android.app.FragmentManagerImpl.moveToState(FragmentManager.java:1067)
E/AndroidRuntime( 3012): at android.app.BackStackRecord.popFromBackStack(BackStackRecord.java:1595)
E/AndroidRuntime( 3012): at android.app.FragmentManagerImpl.popBackStackState(FragmentManager.java:1504)
E/AndroidRuntime( 3012): at android.app.FragmentManagerImpl$2.run(FragmentManager.java:490)
E/AndroidRuntime( 3012): at android.app.FragmentManagerImpl.execPendingActions(FragmentManager.java:1452)
E/AndroidRuntime( 3012): at android.app.FragmentManagerImpl$1.run(FragmentManager.java:447)
E/AndroidRuntime( 3012): at android.os.Handler.handleCallback(Handler.java:739)
E/AndroidRuntime( 3012): at android.os.Handler.dispatchMessage(Handler.java:95)
E/AndroidRuntime( 3012): at android.os.Looper.loop(Looper.java:135)
E/AndroidRuntime( 3012): at android.app.ActivityThread.main(ActivityThread.java:5221)
E/AndroidRuntime( 3012): at java.lang.reflect.Method.invoke(Native Method)
E/AndroidRuntime( 3012): at java.lang.reflect.Method.invoke(Method.java:372)
E/AndroidRuntime( 3012): at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:899)
E/AndroidRuntime( 3012): at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:694)
W/ActivityManager( 1178): Force finishing activity com.google.android.apps.maps/com.google.android.maps.MapsActivity
Let’s see if we can do without the UlrPromoFragment
as well. After some more
spelunking through the disassembled classes, I found this section in the class
com/google/android/apps/gmm/base/activities/d
:
invoke-static {v0}, Lcom/google/android/apps/gmm/ulr/UlrPromoFragment;->a(Lcom/google/android/apps/gmm/base/activities/GmmActivity;)Z
move-result v0
if-eqz v0, :cond_0
.line 566
iget-object v0, p0, Lcom/google/android/apps/gmm/base/activities/d;->a:Lcom/google/android/apps/gmm/base/activities/GmmActivity;
new-instance v1, Lcom/google/android/apps/gmm/ulr/UlrPromoFragment;
invoke-direct {v1}, Lcom/google/android/apps/gmm/ulr/UlrPromoFragment;-><init>()V
invoke-virtual {v0, v1}, Lcom/google/android/apps/gmm/base/activities/GmmActivity;->a(Lcom/google/android/apps/gmm/base/fragments/GmmActivityFragment;)V
.line 568
:cond_0
iget-object v0, p0, Lcom/google/android/apps/gmm/base/activities/d;->a:Lcom/google/android/apps/gmm/base/activities/GmmActivity;
This is a call to a static method on UlrPromoFragment
, and then depending
on the return value from that static method, we may or may not initialize
a UlrPromoFragment
instance and do something with it.
Let’s just make that an unconditional jump to :cond_0
instead, so we never
create a UlrPromoFragment
instance here:
invoke-static {v0}, Lcom/google/android/apps/gmm/ulr/UlrPromoFragment;->a(Lcom/google/android/apps/gmm/base/activities/GmmActivity;)Z
move-result v0
goto :cond_0
.line 566
iget-object v0, p0, Lcom/google/android/apps/gmm/base/activities/d;->a:Lcom/google/android/apps/gmm/base/activities/GmmActivity;
new-instance v1, Lcom/google/android/apps/gmm/ulr/UlrPromoFragment;
invoke-direct {v1}, Lcom/google/android/apps/gmm/ulr/UlrPromoFragment;-><init>()V
invoke-virtual {v0, v1}, Lcom/google/android/apps/gmm/base/activities/GmmActivity;->a(Lcom/google/android/apps/gmm/base/fragments/GmmActivityFragment;)V
.line 568
:cond_0
iget-object v0, p0, Lcom/google/android/apps/gmm/base/activities/d;->a:Lcom/google/android/apps/gmm/base/activities/GmmActivity;
Repeat the same dance as before: apktool b
, apksigner sign
, uninstall and
then adb install
, and then…
The map renders! Search works! The Street View thumbnails don’t seem to load anymore, but other than that it seems totally functional.