Build-time dependency patching for Android
Vincent Bernat
This post shows how to patch an external dependency for an Android project at build-time with Gradle. It relies on the Transform API and Javassist, a Java bytecode manipulation tool.
buildscript { dependencies { classpath 'com.android.tools.build:gradle:2.2.+' classpath 'com.android.tools.build:transform-api:1.5.+' classpath 'org.javassist:javassist:3.21.+' classpath 'commons-io:commons-io:2.4' } }
Disclaimer: I am not a seasoned Android programmer, so take this with a grain of salt.
Context#
This section adds some context to the example. Feel free to skip it.
Dashkiosk manages dashboards on many displays. It provides an Android application you can install on one of these cheap Android sticks. Under the hood, the application embeds a webview backed by the Crosswalk Project web runtime that brings an up-to-date web engine, even for older versions of Android.1
A security vulnerability surfaced in how invalid certificates were
handled. When certificate verification fails, the webview defers the
decision to the host application by calling the
onReceivedSslError() method:
Notify the host application that an SSL error occurred while loading a resource. The host application must call either
callback.onReceiveValue(true)orcallback.onReceiveValue(false). Note that the decision may be retained for use in response to future SSL errors. The default behavior is to pop up a dialog.
The default behavior is specific to Crosswalk’s webview: the Android
built-in one cancels the load. Unfortunately, Crosswalk’s
fix has a side effect: the
onReceivedSslError() method no longer fires.2
Dashkiosk comes with an option to ignore TLS errors.3 This security fix breaks that feature. The following example demonstrates how to patch Crosswalk to recover the previous behavior.4
Simple method replacement#
Let’s replace the shouldDenyRequest() method
from the org.xwalk.core.internal.SslUtil class with this version:
// In SslUtil class public static boolean shouldDenyRequest(int error) { return false; }
Transform registration#
Gradle’s Transform API lets you manipulate compiled class files
before they become DEX files. To declare and register a transform,
include the following code in your build.gradle:
import com.android.build.api.transform.Context import com.android.build.api.transform.QualifiedContent import com.android.build.api.transform.Transform import com.android.build.api.transform.TransformException import com.android.build.api.transform.TransformInput import com.android.build.api.transform.TransformOutputProvider import org.gradle.api.logging.Logger class PatchXWalkTransform extends Transform { Logger logger = null; public PatchXWalkTransform(Logger logger) { this.logger = logger } @Override String getName() { return "PatchXWalk" } @Override Set<QualifiedContent.ContentType> getInputTypes() { return Collections.singleton(QualifiedContent.DefaultContentType.CLASSES) } @Override Set<QualifiedContent.Scope> getScopes() { return Collections.singleton(QualifiedContent.Scope.EXTERNAL_LIBRARIES) } @Override boolean isIncremental() { return true } @Override void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException { // We should do something here } } // Register the transform android.registerTransform(new PatchXWalkTransform(logger))
The getInputTypes() method should return the set of data types that
the transform consumes. In our case, we want to transform
classes. Another possibility is to transform resources.
The getScopes() method should return a set of scopes for the
transform. In our case, we are only interested in the external
libraries. You can also transform your own classes.
The isIncremental() method returns true because we support
incremental builds.
The transform() method should take all provided inputs and copy them
(with or without modifications) to the location that the output
provider supplies. We haven’t implemented this method yet. This
removes all external dependencies from the application.
Noop transform#
To keep all external dependencies unmodified, we must copy them:
@Override void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException { inputs.each { it.jarInputs.each { def jarName = it.name def src = it.getFile() def dest = outputProvider.getContentLocation(jarName, it.contentTypes, it.scopes, Format.JAR); def status = it.getStatus() if (status == Status.REMOVED) { // ❶ logger.info("Remove ${src}") FileUtils.delete(dest) } else if (!isIncremental || status != Status.NOTCHANGED) { // ❷ logger.info("Copy ${src}") FileUtils.copyFile(src, dest) } } } }
We also need two additional imports:
import com.android.build.api.transform.Status import org.apache.commons.io.FileUtils
Since we handle external dependencies, we only need to manage JAR
files. Therefore, we only iterate over jarInputs and not over
directoryInputs. Incremental builds have two cases: either the file
was removed (❶) or it was modified (❷). In all other cases, we can
safely assume the file was already copied.
JAR patching#
When the external dependency is the Crosswalk JAR file, we also need to modify it. Here is the first part of the code (replacing ❷):
if ("${src}" ==~ ".*xwalk_core_library.*/classes.jar") { def pool = new ClassPool() pool.insertClassPath("${src}") def ctc = pool.get('org.xwalk.core.internal.SslUtil') // ❸ def ctm = ctc.getDeclaredMethod('shouldDenyRequest') ctc.removeMethod(ctm) // ❹ ctc.addMethod(CtNewMethod.make(""" public static boolean shouldDenyRequest(int error) { return false; } """, ctc)) // ❺ def sslUtilBytecode = ctc.toBytecode() // ❻ // Write back the JAR file // … } else { logger.info("Copy ${src}") FileUtils.copyFile(src, dest) }
We also need the following additional imports to use Javassist:
import javassist.ClassPath import javassist.ClassPool import javassist.CtNewMethod
Once we locate the target JAR file, we add it to our classpath and retrieve the target class in ❸. We find the method and delete it (❹). Then, we add our custom method with the same name (❺). Everything happens in memory. We retrieve the bytecode of the modified class in ❻.
The remaining step is to rebuild the JAR file:
def input = new JarFile(src) def output = new JarOutputStream(new FileOutputStream(dest)) // ❼ input.entries().each { if (!it.getName().equals("org/xwalk/core/internal/SslUtil.class")) { def s = input.getInputStream(it) output.putNextEntry(new JarEntry(it.getName())) IOUtils.copy(s, output) s.close() } } // ❽ output.putNextEntry(new JarEntry("org/xwalk/core/internal/SslUtil.class")) output.write(sslUtilBytecode) output.close()
We need the following additional imports:
import java.util.jar.JarEntry import java.util.jar.JarFile import java.util.jar.JarOutputStream import org.apache.commons.io.IOUtils
There are two steps. In ❼, we copy all classes to the new JAR, except
the SslUtil class. In ❽, we add the modified bytecode for SslUtil
to the JAR.
That’s all! You can view the complete example on GitHub.
More complex method replacement#
In the above example, the new method doesn’t use any external
dependency. Let’s suppose we also want to replace
the sslErrorFromNetErrorCode() method
from the same class with the following one:
import org.chromium.net.NetError; import android.net.http.SslCertificate; import android.net.http.SslError; // In SslUtil class public static SslError sslErrorFromNetErrorCode(int error, SslCertificate cert, String url) { switch(error) { case NetError.ERR_CERT_COMMON_NAME_INVALID: return new SslError(SslError.SSL_IDMISMATCH, cert, url); case NetError.ERR_CERT_DATE_INVALID: return new SslError(SslError.SSL_DATE_INVALID, cert, url); case NetError.ERR_CERT_AUTHORITY_INVALID: return new SslError(SslError.SSL_UNTRUSTED, cert, url); default: break; } return new SslError(SslError.SSL_INVALID, cert, url); }
The main difference from the previous example is that we need to import additional classes.
Android SDK import#
The classes from the Android SDK are not part of the external dependencies. You need to import them separately. The full path of the JAR file is:
androidJar = "${android.getSdkDirectory().getAbsolutePath()}/platforms/" + "${android.getCompileSdkVersion()}/android.jar"
Load it before adding the new method to the SslUtil class:
def pool = new ClassPool() pool.insertClassPath(androidJar) pool.insertClassPath("${src}") def ctc = pool.get('org.xwalk.core.internal.SslUtil') def ctm = ctc.getDeclaredMethod('sslErrorFromNetErrorCode') ctc.removeMethod(ctm) pool.importPackage('android.net.http.SslCertificate'); pool.importPackage('android.net.http.SslError'); // …
External dependency import#
We must also import org.chromium.net.NetError and therefore need to
add the appropriate JAR to our classpath. The easiest way is to
iterate through all external dependencies and add them to the
classpath.
def pool = new ClassPool() pool.insertClassPath(androidJar) inputs.each { it.jarInputs.each { def jarName = it.name def src = it.getFile() def status = it.getStatus() if (status != Status.REMOVED) { pool.insertClassPath("${src}") } } } def ctc = pool.get('org.xwalk.core.internal.SslUtil') def ctm = ctc.getDeclaredMethod('sslErrorFromNetErrorCode') ctc.removeMethod(ctm) pool.importPackage('android.net.http.SslCertificate'); pool.importPackage('android.net.http.SslError'); pool.importPackage('org.chromium.net.NetError'); ctc.addMethod(CtNewMethod.make("…")) // Then, rebuild the JAR...
Happy hacking!
-
Before Android 4.4, the webview was severely outdated. Starting from Android 5, the webview ships as a separate component with updates. Embedding Crosswalk is still convenient as you know exactly which version you can rely on. ↩︎
-
I hope to have this fixed in later versions. ↩︎
-
This may seem harmful and you are right. But if you have an internal CA, you cannot provide a custom trust store to a webview. The webview doesn’t use the system trust store either. You also may want to use TLS for authentication only with client certificates, a feature Dashkiosk supports. ↩︎
-
Crosswalk being an open-source project, an alternative would have been to patch Crosswalk source code and recompile it. But Crosswalk embeds Chromium and recompiling everything requires significant resources. ↩︎