Hacking the Subway Android app

15 minute read Published: 2018-09-28

Introduction

I never really went to Subway (sandwiches) much, I'd been there maybe three times tops. One day I was at school and a friend of mine showed me a trick. He had something called a "secure" folder on his phone, if I recall correctly. This folder allowed him to drag apps in and out of it, making apps behave differently than they normally would. In this case dragging the subway app in allowed him to bypass the 24-hour timer for getting random coupons. Anytime he dislikes the coupon he got, he drags it out of the secure folder and back in and then he can just retry.

When he showed me this I noticed that something's off.

Motivation

When I saw my friend performing his trick with the secure folder I knew it was some kind of closed off environment where it's as if the app is cleanly installed. The moment I realized this I knew the app wasn't sound and I thought it'd be fun to see if we could reset the timer without the secure folder, which is way more convenient, and maybe find even more things to tamper with, like doing coupon selection.

Analysis

The flow

The first thing we should do is download and install the app from the Google Play Store on an Android device and see how it works, I live in the Netherlands, so I'll be using the Dutch version of the Subway app, everything from here on will apply to the Dutch version of the Android app and may or may not apply to other versions. Let's start the app and see what it looks like!

It seems like we have to click on the start button and then shake the device to get the coupon. Let's try doing that.

Alright, it seems like we got our coupon, the button below it says: "Hand in now". So let's try handing it in.

It seems we've almost handed it in, the strip at the bottom says we should swipe it away at the cash register. Let's swipe!

So now it tells us we've used the coupon on the date of today. As you can see there's no QR- or barcode to scan at the cash register. This means that they have no way to really verify if the coupon is real or not.

Now that we've established this we can move on to the next phase.

Plan of attack

Now that we've seen how the app operates we have to find out what we want to achieve. There were three things I wanted to achieve, which were:

  1. A way to select which coupon I want to use.
  2. A way to inject fake coupons.
  3. A way to reset the timer.

Now we've established what we want to achieve, we have to find out how we're going to achieve this. To me it seems that the best way would be to reverse engineer the app to see how it operates under the hood and then write an Xposed module to alter the behaviour of the app to our needs.

So that's what I went with.

Reverse engineering

The easiest way to reverse engineer an Android app is to decompile it back to Java. There are various tools which you can use for this, but I won't be going into which tools I'll be using.

After looking around in the decompiled code for a bit I found the mechanism which determines which coupon you're going to get (only relevant parts shown and are deobfuscated).

public class d {
    private final Coupon[] coupons;
    private final Random rng;

    public d(Resources res, com.esites.subway.data.b.b arg3) {
        this.coupons = ReadCoupons.getAllCoupons(res);
        this.rng = new Random();
    }

    public Coupon getRandomCoupon() {
        return this.coupons[this.rng.nextInt(this.coupons.length)];
    }
}
final class ReadCoupons {
    private static Coupon getCouponByIndex(Resources res, int index) {
        TypedArray names = res.obtainTypedArray(0x7F0E0002);
        TypedArray disclaimers = res.obtainTypedArray(0x7F0E0000);
        TypedArray imageIds = res.obtainTypedArray(0x7F0E0001);
        Coupon coupon = new Coupon(index + 1, names.getString(index), disclaimers.getString(index), imageIds.getResourceId(index, 0));
        names.recycle();
        disclaimers.recycle();
        imageIds.recycle();
        return coupon;
    }

    static Coupon[] getAllCoupons(Resources res) {
        int max = 5;
        Coupon[] coupons = new Coupon[max];
        int i;
        for (i = 0; i < max; ++i) {
            coupons[i] = ReadCoupons.getCouponByIndex(res, i);
        }

        return coupons;
    }
}

Class ReadCoupons is reading 3 arrays from the resources, which are the name, the disclaimer and the ID of the image of the coupon. However, note that the first thing it passes to it are the second argument, an int, and it adds 1. This is the index of the Coupon, I've never seen it being used somewhere, but it's there. Then we have method getRandomCoupon in class d which just takes out such a Coupon and returns it. Note that the coupons are read from the resources and are not retrieved from a server, they're hardcoded.

So if we'll hook the getRandomCoupon method in class d and change the return value to another Coupon we can choose which coupon we want to use.

So now for the second goal, injecting a fake coupon. For this we should hook the getAllCoupons method and add the coupons to the result of the method. We can hook the constructor of the Coupon class and we can create our own Coupons using that.

So now for our third goal, resetting the timer. Instead of reverse engineering the app further I redeemed a coupon and decided to take a look at the shared preferences of the app.

root@zeroflte:/data/data/nl.subway.subway/shared_prefs $ ls
TwitterAdvertisingInfoPreferences.xml
WebViewChromiumPrefs.xml
campaign.xml
com.crashlytics.prefs.xml
com.crashlytics.sdk.android:answers:settings.xml
com.facebook.sdk.appEventPreferences.xml
com.facebook.sdk.attributionTracking.xml
com.google.android.gms.analytics.prefs.xml
com.google.android.gms.appid.xml
com.google.android.gms.measurement.prefs.xml
com.mobileapptracking.xml
com.tune.ma.profile.xml
coupon.xml
mat_queue.xml
tutorial.xml
xpmod.df.xml

The file coupon.xml sounds interesting enough, let's take a look at the content of the file.

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="current_coupon">{&quot;id&quot;:1,&quot;end&quot;:1533259786102,&quot;redeemed&quot;:true}</string>
</map>

When I removed the string node from the map the timer was reset and I could just get another coupon.

A not-so-wise developer once said:

In the client we trust.

Implementation

Like I said, we're going to use the Xposed framework for this. I'll be writing the Xposed module in Kotlin, because I enjoy the language and think it's a better alternative to Java on Android. I won't be going into the specifics of the Xposed framework or how to write an Xposed module, if there's enough interest I'll maybe write about that in the future.

The first thing which we need is a way to communicate from our selection screen (which still needs to be created) with the Subway app. To me there's one obvious way to go about this and that is to register a BroadcastReceiver in the Subway app. This way we can actually create a small API for ourselves inside of the Subway app, where we can request certain resources and make it do certain things. In our case we need to be able to do a few things:

  1. Get all the available coupons.
  2. Set the current coupon.
  3. Reset coupon timer.
  4. Clear coupon selection.

The following code will do exactly that.

override fun onReceive(p0: Context?, intent: Intent?) {
    val action = intent!!.action.toAction()  // The IntentFilter assures there's always an action
    logInfo("Received intent: $action")

    when (action) {
        Action.GETCOUPONS -> {
            val coupons = callStaticMethod(couponReadClass, "a", xres) as Array<*>
            logInfo("Received COUPONS = ${Arrays.toString(coupons)}")

            val subs = coupons.map { c ->
                val index = getIntField(c, "a")
                val name = getObjectField(c, "b") as String
                "$index;$name"
            }.toString().replace(" ", "")

            val subsIntent = Intent()
            subsIntent.action = "SUBS"
            subsIntent.putExtra("subs", subs)
            context.sendBroadcast(subsIntent)
            logInfo("Subs send")
        }
        Action.SETCOUPON -> {
            val newCouponIndex = intent.getIntExtra("coupon", -1)

            if (newCouponIndex > -1) {
                resetCoupon(context)
                val coupons = callStaticMethod(couponReadClass, "a", xres) as Array<*>
                val name = getObjectField(coupons[newCouponIndex], "b") as String

                fakeCoupons.firstOrNull { fc -> fc.name == name }?.let {
                    xres.setReplacement(packageName, coupon30CmMenuType, coupon30CmMenuName, modRes.fwd(it.image))
                    logInfo("Set unused resource's identifier to identifier ${it.image} of resource $coupon30CmMenuName")
                }

                logInfo("Wrote value: $newCouponIndex, last value was: $couponIndex")
                couponIndex = newCouponIndex
            } else {
                logErr("Got an invalid coupon index: $couponIndex")
            }
        }
        Action.RESETCOUPON -> resetCoupon(context)
        Action.CLEARCOUPON -> couponIndex = null
        Action.UNKNOWN -> logErr("Got an unknown intent, there is an intent filter and this cannot happen.")
    }
}
private fun resetCoupon(context: Context) {
    val prefs = context.getSharedPreferences(sharedPrefsName, Context.MODE_PRIVATE)
    val editor = prefs.edit()
    editor.clear()
    editor.apply()
    logInfo("Coupon has been reset")
}

For those who don't know, BroadcastReceivers use the Android IPC mechanism called Intents. An Intent is a message you can use to request something from another app component. We will use the Intents to send broadcasts, which are messages all apps on the device are able to receive. You want to add (almost always) an IntentFilter because you want to receive only a select few Intents you actually need.

So here we've registered a BroadcastReceiver inside of the Subway app which exposes the aforementioned actions to us. Let's break down the code from the top down beginning from within the when expression.

GETCOUPONS

Action.GETCOUPONS -> {
    val coupons = callStaticMethod(couponReadClass, "a", xres) as Array<*>
    logInfo("Received COUPONS = ${Arrays.toString(coupons)}")

    val subs = coupons.map { c ->
        val index = getIntField(c, "a")
        val name = getObjectField(c, "b") as String
        "$index;$name"
    }.toString().replace(" ", "")

    val subsIntent = Intent()
    subsIntent.action = "SUBS"
    subsIntent.putExtra("subs", subs)
    context.sendBroadcast(subsIntent)
    logInfo("Subs send")
}

So we'll firstly dissect the GETCOUPONS action. I think the name is quite semantic, it returns the currently available coupons. We're calling a static method within the Subway app, using reflection, to get an array of all currently available coupons. Because we cannot access the coupon class directly we cast it to an array with generics. After this we iterate over the coupons and get the index and the name. We then put this all in a String with an easy-to-parse format and add it to the Intent, then we send a broadcast which will be received at the other side.

SETCOUPON

private val fakeCoupons = arrayOf(
        Coupon(
                "Chicken Filet Supreme of Steak & Cheese",
                "Deze coupon is 24 uur geldig (zie countdown) in deelnemende Subway restaurants in Nederland. Geef voor het plaatsen van je bestelling aan dat je gebruik wilt maken van de coupon. Geldig voor de 15 cm Chicken Filet Supreme of Steak & Cheese (combineren is ook mogelijk). Bij combineren is de goedkoopste Sub gratis. Niet geldig voor dubbel vlees, extra kaas, bacon, pepperoni of avocado. Niet geldig i.c.m. andere aanbiedingen. Een coupon per klant per bezoek. Bij deze actie worden geen spaarzegels verstrekt. SUBWAY is a Registered Trademark of Subway IP Inc. 2018 Subway IP Inc.",
                R.drawable.coupon_chicken_filet_supreme
        ),
        Coupon(
                "Chicken Teriyaki of Subway Melt",
                "Deze coupon is 24 uur geldig (zie countdown) in deelnemende Subway restaurants in Nederland. Geef voor het plaatsen van je bestelling aan dat je gebruik wilt maken van de coupon. Geldig voor de 15 cm Chicken Teriyaki of Subway Melt (combineren is ook mogelijk). Bij combineren is de goedkoopste Sub gratis. Niet geldig voor dubbel vlees, extra kaas, bacon, pepperoni of avocado. Niet geldig i.c.m. andere aanbiedingen. Een coupon per klant per bezoek. Bij deze actie worden geen spaarzegels verstrekt. SUBWAY is a Registered Trademark of Subway IP Inc. 2018 Subway IP Inc.",
                R.drawable.coupon_yaki_melt
        )
)
Action.SETCOUPON -> {
    val newCouponIndex = intent.getIntExtra("coupon", -1)

    if (newCouponIndex > -1) {
        resetCoupon(context)
        val coupons = callStaticMethod(couponReadClass, "a", xres) as Array<*>
        val name = getObjectField(coupons[newCouponIndex], "b") as String

        fakeCoupons.firstOrNull { fc -> fc.name == name }?.let {
            xres.setReplacement(packageName, coupon30CmMenuType, coupon30CmMenuName, modRes.fwd(it.image))
            logInfo("Set unused resource's identifier to identifier ${it.image} of resource $coupon30CmMenuName")
        }

        logInfo("Wrote value: $newCouponIndex, last value was: $couponIndex")
        couponIndex = newCouponIndex
    } else {
        logErr("Got an invalid coupon index: $couponIndex")
    }
}

As you can see we take an Int out of the Intent called coupon, if there's none available we use -1. This Int will represent the index of the array which contains the currently available coupons. Then we'll check if the value is > -1, this is because -1 is our "undefined" value, so if it's -1 we shouldn't do anything. If the value is > -1 I first call the resetCoupon method, which resets the timer.

Like I said before, we want to inject fake coupons. We want to be able to select these and they will also be send just like any coupon via an Intent. The way these fake coupons are going to work exactly is we're going to photoshop an existing coupon and add it to our module's resources. Then when we get an index, we'll check if the index is of a fake coupon. If we have an index of a fake coupon, we'll use a technique called resource forwarding.

Resource forwarding is a way to replace an existing resource in the target app with another one. In our case we want to give all of our fake coupons the same resource from inside of the app and then replace them on-demand i.e. we select on of our fake coupons in the selection screen and send the Intent to the BroadcastReceiver, then we'll check to see if it's one of the fake coupons and we detect that it is. Now we take the resource the fake coupon uses (unaltered from inside the target app) and we replace it with another resource from our own module. Now the target app will show the resource from our module instead of the one it originally wanted to show.

Then I have an instance variable of type Int? (this means a nullable integer in Kotlin) to which I assign the value I retrieved from the Intent. We're not done yet, though. This is merely to supply the app with the information on which coupon to use. Now we need to make it actually do it.

First we're going to inject the fake coupons into the application.

findAndHookMethod("$subwayBasePath.c", lpparam.classLoader, "a", Resources::class.java, object : XC_MethodHook() {
    override fun afterHookedMethod(param: MethodHookParam?) {
        logInfo("$subwayBasePath.c has been invoked")

        val couponConstructor = findConstructorExact("$subwayBasePath.b", lpparam.classLoader, Int::class.java, String::class.java, String::class.java, Int::class.java)
        val couponArray = param!!.result as Array<*> // This call can never fail
        val newCouponsArray = Arrays.copyOf(couponArray, couponArray.size + fakeCoupons.size)

        for (index in couponArray.size until newCouponsArray.size) {
            val fakeCoupon = fakeCoupons[index - couponArray.size]
            val coupon = couponConstructor.newInstance(index + 1, fakeCoupon.name, fakeCoupon.disclaimer, coupon30CmMenuId)
            logInfo("Created new coupon: ${fakeCoupon.name}")
            logInfo("Before adding it: ${Arrays.toString(newCouponsArray)}")
            newCouponsArray[index] = coupon
            logInfo("After adding it: ${Arrays.toString(newCouponsArray)}")
        }

        param.result = newCouponsArray
        logInfo("Set new coupon array")
    }
})

Here you see an Xposed method hook, this enables you to alter the behaviour of a method. You can override the beforeHookedMethod and afterHookedMethod, the former will run your code before the hooked method is run and the latter after. In our case we want to alter the result of the method, so we only override the afterHookedMethod.

As you can see we get a reference to the Coupon's constructor, then we get the array with the currently available coupons. Then we copy this array to a new array and make the size bigger so our fake coupons can be added. Then we do a simple for-loop which uses the Coupon's constructor to create a fake Coupon and put it in the newly created spaces inside of the newCouponsArray.

At the end we alter the result of the method to our newly created array.

findAndHookMethod("$subwayBasePath.d", lpparam.classLoader, getRandomCoupon, object : XC_MethodHook() {
    override fun afterHookedMethod(param: MethodHookParam?) {
        logInfo("$subwayBasePath.d.$getRandomCoupon has been invoked.")

        couponIndex?.let { i ->
            val self = param?.thisObject
                    ?: logErr("We're hooking a non-static method, param cannot be null")
            val coupons = getObjectField(self, "a") as Array<*>
            val coupon = coupons[i]
            param?.result = coupon ?: logErr("This method is returning something, this can never happen!")
            logInfo("Return value set to coupon index: $i")
        } ?: logInfo("No coupon set")
    }
})

Here we're hooking the aforementioned method which determines which coupon you get. If the couponIndex is not null we try to get a handle to the current object we're in, this should always be possible because we're hooking an instance method. Then we try to get the array with coupon objects from the current object we're in right now, we index into it with the couponIndex, which we retrieved through the SETCOUPON action of the BroadcastReceiver. Then we change the result of the method, also known as the return-value, to the coupon object we got out of the array.

Now that we've implemented the SETCOUPON action and the corrresponding hooks, let's move on.

RESETCOUPON

Action.RESETCOUPON -> resetCoupon(context)
private fun resetCoupon(context: Context) {
    val prefs = context.getSharedPreferences(sharedPrefsName, Context.MODE_PRIVATE)
    val editor = prefs.edit()
    editor.clear()
    editor.apply()
    logInfo("Coupon has been reset")
}

Alright, this is a very short and clear one. Whenever we get the RESETCOUPON action we want to call the resetCoupon method. This method gets the shared preferences which we were talking about during the reverse engineering phase. It just clears that shared preferences file, so now the times is reset.

CLEARCOUPON

Action.CLEARCOUPON -> couponIndex = null

An even shorter one, whenever we get the CLEARCOUPON action we want to set the couponIndex to null, this restores the default behaviour of the app. I've never really used this one beyond testing, but it's always nice to have a way to restore the default behaviour of the app.

The frontend

The way the frontend works is pretty straightforwad, it will make use of the BroadcastReceiver we've defined before and send Intents. We'll have a fancy selection screen with some buttons which'll trigger these Intents to be send with the right information, this is basic Android development so I won't be going deeper into this.

This is what the frontend looks like:

You can just select the coupon you prefer, click the upper button and it'll launch the Subway app:

As you can see, we got the coupon we selected.

Conclusion

I think we've learned quite a bit. The takeaway from this is that the client is never to be trusted and you should always use a backend to verify anything, without exception (if you care about your verification being legitimate).

Luckily, Subway fixed these huge issues by deprecating this app and releasing a new app called Subcard.

If anyone's interested I can open source the code for the Xposed module and add some more comments. There aren't a lot of resources on Xposed out there and I can imagine it could be helpful to some.

Edit 29-10-2018: I open sourced the Xposed module!