Build-time dependency patching for Android

Vincent Bernat

This post shows how to patch an ex­ter­nal de­pen­dency for an An­droid project at build-time with Gra­dle. It re­lies on the Trans­form API and Javas­sist, a Java byte­code ma­nip­u­la­tion 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'
    }
}

Dis­claimer: I am not a sea­soned An­droid pro­gram­mer, so take this with a grain of salt.

Context#

This sec­tion adds some con­text to the ex­am­ple. Feel free to skip it.

Dashkiosk man­ages dash­boards on many dis­plays. It pro­vides an An­droid ap­pli­ca­tion you can in­stall on one of these cheap An­droid sticks. Un­der the hood, the ap­pli­ca­tion em­beds a we­b­view backed by the Cross­walk Project web run­time that brings an up-to-date web en­gine, even for older ver­sions of Android.1

A se­cu­rity vul­ner­a­bil­ity sur­faced in how in­valid cer­tifi­cates were han­dled. When cer­tifi­cate ver­i­fi­ca­tion fails, the we­b­view de­fers the de­ci­sion to the host ap­pli­ca­tion by call­ing the onReceivedSslError() method:

No­tify the host ap­pli­ca­tion that an SSL er­ror oc­curred while load­ing a re­source. The host ap­pli­ca­tion must call ei­ther callback.​onReceiveValue(true) or callback.​onReceiveValue(false). Note that the de­ci­sion may be re­tained for use in re­sponse to fu­ture SSL er­rors. The de­fault be­hav­ior is to pop up a dialog.

The de­fault be­hav­ior is spe­cific to Cross­walk’s we­b­view: the An­droid built-in one can­cels the load. Un­for­tu­nately, Cross­walk’s fix has a side ef­fect: the onReceivedSslError() method no longer fires.2

Dashkiosk comes with an op­tion to ig­nore TLS er­rors.3 This se­cu­rity fix breaks that fea­ture. The fol­low­ing ex­am­ple demon­strates how to patch Cross­walk to re­cover the pre­vi­ous be­havior.4

Simple method replacement#

Let’s re­place 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#

Gra­dle’s Trans­form API lets you ma­nip­u­late com­piled class files be­fore they be­come DEX files. To de­clare and reg­is­ter a trans­form, in­clude the fol­low­ing 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 re­turn the set of data types that the trans­form con­sumes. In our case, we want to trans­form classes. An­other pos­si­bil­ity is to trans­form re­sources.

The getScopes() method should re­turn a set of scopes for the trans­form. In our case, we are only in­ter­ested in the ex­ter­nal li­braries. You can also trans­form your own classes.

The isIncremental() method re­turns true be­cause we sup­port in­cre­men­tal builds.

The transform() method should take all pro­vided in­puts and copy them (with or with­out mod­i­fi­ca­tions) to the lo­ca­tion that the out­put provider sup­plies. We haven’t im­ple­mented this method yet. This re­moves all ex­ter­nal de­pen­den­cies from the ap­plication.

Noop transform#

To keep all ex­ter­nal de­pen­den­cies un­mod­i­fied, 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 ad­di­tional imports:

import com.android.build.api.transform.Status
import org.apache.commons.io.FileUtils

Since we han­dle ex­ter­nal de­pen­den­cies, we only need to man­age JAR files. There­fore, we only it­er­ate over jarInputs and not over directoryInputs. In­cre­men­tal builds have two cases: ei­ther the file was re­moved (❶) or it was mod­i­fied (❷). In all other cases, we can safely as­sume the file was al­ready copied.

JAR patching#

When the ex­ter­nal de­pen­dency is the Cross­walk JAR file, we also need to mod­ify it. Here is the first part of the code (re­placing ❷):

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 fol­low­ing ad­di­tional im­ports to use Javassist:

import javassist.ClassPath
import javassist.ClassPool
import javassist.CtNewMethod

Once we lo­cate the tar­get JAR file, we add it to our class­path and re­trieve the tar­get class in ❸. We find the method and delete it (❹). Then, we add our cus­tom method with the same name (❺). Ev­ery­thing hap­pens in mem­ory. We re­trieve the byte­code of the mod­i­fied class in ❻.

The re­main­ing step is to re­build 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 fol­low­ing ad­di­tional 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, ex­cept the SslUtil class. In ❽, we add the mod­i­fied byte­code for SslUtil to the JAR.

That’s all! You can view the com­plete ex­am­ple on GitHub.

More complex method replacement#

In the above ex­am­ple, the new method doesn’t use any ex­ter­nal de­pen­dency. Let’s sup­pose we also want to re­place the sslErrorFromNetErrorCode() method from the same class with the fol­lowing 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 dif­fer­ence from the pre­vi­ous ex­am­ple is that we need to im­port ad­di­tional classes.

Android SDK import#

The classes from the An­droid SDK are not part of the ex­ter­nal de­pen­den­cies. You need to im­port them sep­a­rately. The full path of the JAR file is:

androidJar = "${android.getSdkDirectory().getAbsolutePath()}/platforms/" +
             "${android.getCompileSdkVersion()}/android.jar"

Load it be­fore 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 im­port org.​chromium.​net.​NetError and there­fore need to add the ap­pro­pri­ate JAR to our class­path. The eas­i­est way is to it­er­ate through all ex­ter­nal de­pen­den­cies 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!