Reverse engineering and fixing a streaming app
What happens when a major TV network decides to soft-block an older version of their own streaming app? Yep, we make it work again!
What happens when a major TV network decides to soft-block an older version of their own streaming app? Yep, we make it work again!
Story time!
I subscribe to a streaming service for a major TV network in Brazil. Besides being able to watch their channel live, I can also enjoy some movies and series from them, as well as imported ones (with proper subtitles). There's a catch, however: they do not support the Fire TV.
You see, before their service even became relevant for me and my family, I've added Fire TV sticks to pretty much all TVs around here. They run the basic stuff: YouTube (or an ad-free version of it), Plex, Disney+, Netflix, Prime Video, etc. Recently I added this major TV network app to this list, and enjoyed it so far, but their app was never available for the Fire TV: we had to side-load it by downloading the APK from a third-party and installing it manually. It also had to be a very specific version: 2.4.4.
In the last months the app started stating it was an older version and that we should update to a newer one. This, however, isn't possible: all newer versions won't load correctly or you just won't be able to sign in to your account, with a generic error on your screen. This wasn't a big deal though: you could just say "no" to the update and carry on with your business.
Unfortunately, somewhere around last month, this TV operator decided that this had to come to an end: they soft-blocked version 2.4.4 by stating that users must update, and as such blocking the user from watching their streams with the app. As you can imagine, many customers complained against this change, including myself. They do provide an alternative though: if you do it in some public manner (like I did), they send you a Roku Express for free (and no strings attached (as far as they say)) to test their new app there. Sure enough, it works great there - the Roku is a good platform with great performance. I actually installed it in my living room and have been using it for a few days - so far, so good.
But this isn't a solution. This is, IMHO, a bad decision, as, by not supporting Fire TV devices, you're basically ignoring a nice market share in Brazil. Also, this got me curious: why was the old version blocked? And can we bypass it? Also, should you do it?
Disclaimer
I'll be hiding the TV network's name and logos, as well as any content they provide on their platform in any image or code from now one. I do not endorse any kind of hacking for illegal reasons, such as removing content restrictions and unauthorized access. This reverse engineering process was done for fun and because I was bored. I won't provide any binary files.
The old version
The old version got soft-blocked. You actually see a screenshot of this here:
Originally this screen had a second button to bypass the update requirement. I was curious: something, somewhere was changing its visibility. Maybe an API response? Can we change it?
Well, Fire TV apps are just Android apps. This means you can actually use something like apktool to extract their code and even rebuild the package itself. If you want to know where I learned to do this, it's very simple: I just followed a guide. Yep, and it's this one. Anyway, by extracting the code, I was able to grep through the Smali code and find a lot of really interesting things:
Those strings mean "required version control", "suggested version control" and "version control". This gave me a starting point: the version control activity.
Note: I am not a mobile developer and I have no experience with reverse engineering Android apps. This is me trying to thinker with stuff and checking what happens. You have been warned.
My first attempt was to replace this controle_de_versao_requerido
with the controle_de_versao_sugerido
string. I mean, it can't be bad, right? This didn't change anything though: same screen, same issue.
The second idea I had was based on the visibility. Something was changing the second button to make it invisible. The question is what. A quick search for "visibility" got me this very nice piece of code:
.method private final m()V
.locals 2
.line 105
sget v0, Lcom/foobar/foobartv/a$a;->activity_version_control_button_update_later:I
invoke-virtual {p0, v0}, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->c(I)Landroid/view/View;
move-result-object v0
check-cast v0, Landroid/support/v7/widget/AppCompatTextView;
const-string v1, "activity_version_control_button_update_later"
invoke-static {v0, v1}, Lkotlin/jvm/internal/Intrinsics;->checkExpressionValueIsNotNull(Ljava/lang/Object;Ljava/lang/String;)V
iget-boolean v1, p0, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->o:Z
if-eqz v1, :cond_0
const/16 v1, 0x8
goto :goto_0
:cond_0
const/16 v1, 0x0
:goto_0
invoke-virtual {v0, v1}, Landroid/support/v7/widget/AppCompatTextView;->setVisibility(I)V
return-void
.end method
See the last call, the setVisibility
one? It is being called with two arguments: v0
and v1
. It seems that v0
might be an object, while v1
is an integer argument (based on the 0x8
and 0x0
values it receives). This makes sense, as if you google it, you'll find similar pieces of code. This code is conditional though: it's based off v1
. If v1
is equal to zero (see docs), it'll jump to cond_0
, setting v0
to 0x0
. Otherwise the code will continue, setting v0
to 0x8
. Finally, whatever value is there, will be used as the new visibility. So this would be something like this in Java:
whatever->setVisibility(v1 == 0 ? 0x0 : 0x8)
Then comes the question: what the hell is v1
? Well, v1
seems to be some kind of property: o:Z
. I have no idea what it is, but it seems to be storing a vital piece of information: whether the button to ignore the old version warning should or not be visible. So maybe it holds the information of we can or not bypass the version requirement? Let's dig deeper!
The "Oz" variable
By looking for ->o:Z
, we can see multiple uses of this "property" (or whatever it is). And there are only two calls setting its boolean value, and one of them give us a huge hint:
.method protected onRestoreInstanceState(Landroid/os/Bundle;)V
.locals 2
.param p1 # Landroid/os/Bundle;
.annotation build Lorg/jetbrains/annotations/Nullable;
.end annotation
.end param
.line 57
invoke-super {p0, p1}, Lcom/foobar/foobartv/activity/BaseActivity;->onRestoreInstanceState(Landroid/os/Bundle;)V
const/4 v0, 0x0
if-eqz p1, :cond_0
const-string v1, "instance_state_update_required"
.line 58
invoke-virtual {p1, v1, v0}, Landroid/os/Bundle;->getBoolean(Ljava/lang/String;Z)Z
move-result v0
.line 59
:cond_0
iput-boolean v0, p0, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->o:Z
.line 60
invoke-direct {p0}, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->m()V
return-void
.end method
Whatever is being done here, seems to be update related, as you can see the instance_state_update_required
. This string is then used on a getBoolean
call, whose result is being stored on v0
. Then this same boolean value is being set on the o:Z
property.
Now comes the question: if we assume this property means "update is required", what happens if we override with 0x0
(false)? Well, we can try! As I said before, there are only two calls setting o:Z
, so it should be pretty easy to mod them by setting their values to 0x0
using const/4
right before they are used in the property:
.method protected onRestoreInstanceState(Landroid/os/Bundle;)V
# (...)
.line 59
:cond_0
const/4 v0, 0x0
iput-boolean v0, p0, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->o:Z
# (...)
.end method
.method protected onCreate(Landroid/os/Bundle;)V
# (...)
const/4 v0, 0x0
iput-boolean v0, p0, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;->o:Z
# (...)
.end method
So, with this property hard-wired to false
, what happens with the app?
Aha! The button is back! Nice! But it doesn't work: if you click on it, the app doesn't do anything (or crashes, I don't really recall at this moment, sorry). Let's go deeper!
Making it work
We now carry on by looking for param_update_is_required
on the code. I mean, it's a good start point as any other, right? Well, this brought me to a very long function:
# virtual methods
.method public final a(Lcom/foobar/foobartv/n/h;)V
.locals 4
.param p1 # Lcom/foobar/foobartv/n/h;
.annotation build Lorg/jetbrains/annotations/Nullable;
.end annotation
.end param
.annotation system Ldalvik/annotation/Signature;
value = {
"(",
"Lcom/foobar/foobartv/n/h<",
"Lcom/foobar/foobartv/n/e/b/a;",
">;)V"
}
.end annotation
const/4 v0, 0x0
if-eqz p1, :cond_0
.line 119
invoke-virtual {p1}, Lcom/foobar/foobartv/n/h;->a()Lcom/foobar/foobartv/n/h$a;
move-result-object v1
goto :goto_0
:cond_0
move-object v1, v0
:goto_0
if-nez v1, :cond_1
goto/16 :goto_2
:cond_1
sget-object v2, Lcom/foobar/foobartv/splash/a;->a:[I
invoke-virtual {v1}, Lcom/foobar/foobartv/n/h$a;->ordinal()I
move-result v1
aget v1, v2, v1
const/4 v2, 0x1
if-eq v1, v2, :cond_9
const/4 v3, 0x2
if-eq v1, v3, :cond_3
const/4 p1, 0x3
if-eq v1, p1, :cond_2
goto/16 :goto_2
.line 146
:cond_2
iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;
new-instance v0, Landroid/content/Intent;
invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->getBaseContext()Landroid/content/Context;
move-result-object v1
const-class v2, Lcom/foobar/foobartv/activity/NoConnectionActivity;
invoke-direct {v0, v1, v2}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V
invoke-virtual {p1, v0}, Lcom/foobar/foobartv/splash/SplashActivity;->startActivity(Landroid/content/Intent;)V
.line 147
iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;
invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->finish()V
goto :goto_2
.line 124
:cond_3
invoke-virtual {p1}, Lcom/foobar/foobartv/n/h;->b()Ljava/lang/Object;
move-result-object v1
check-cast v1, Lcom/foobar/foobartv/n/e/b/a;
if-eqz v1, :cond_4
invoke-virtual {v1}, Lcom/foobar/foobartv/n/e/b/a;->b()Lcom/foobar/foobartv/n/e/b/e;
move-result-object v0
.line 125
:cond_4
invoke-virtual {p1}, Lcom/foobar/foobartv/n/h;->b()Ljava/lang/Object;
move-result-object p1
check-cast p1, Lcom/foobar/foobartv/n/e/b/a;
sput-object p1, Lcom/foobar/foobartv/TVApplication;->a:Lcom/foobar/foobartv/n/e/b/a;
const/4 p1, 0x0
if-eqz v0, :cond_5
.line 128
invoke-virtual {v0}, Lcom/foobar/foobartv/n/e/b/e;->a()I
move-result v1
goto :goto_1
:cond_5
const/4 v1, 0x0
:goto_1
const/16 v3, 0x277f
if-le v1, v3, :cond_6
.line 129
iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;
new-instance v0, Landroid/content/Intent;
invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->getBaseContext()Landroid/content/Context;
move-result-object v1
const-class v3, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;
invoke-direct {v0, v1, v3}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V
const-string v1, "param_update_is_required"
.line 130
invoke-virtual {v0, v1, v2}, Landroid/content/Intent;->putExtra(Ljava/lang/String;Z)Landroid/content/Intent;
move-result-object v0
.line 129
invoke-virtual {p1, v0}, Lcom/foobar/foobartv/splash/SplashActivity;->startActivity(Landroid/content/Intent;)V
.line 131
iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;
invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->finish()V
goto :goto_2
:cond_6
if-eqz v0, :cond_7
.line 134
invoke-virtual {v0}, Lcom/foobar/foobartv/n/e/b/e;->b()I
move-result p1
:cond_7
if-le p1, v3, :cond_8
.line 135
iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;
new-instance v0, Landroid/content/Intent;
invoke-virtual {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->getBaseContext()Landroid/content/Context;
move-result-object v1
const-class v2, Lcom/foobar/foobartv/versioncontrol/VersionControlActivity;
invoke-direct {v0, v1, v2}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V
const/16 v1, 0x1100
invoke-virtual {p1, v0, v1}, Lcom/foobar/foobartv/splash/SplashActivity;->startActivityForResult(Landroid/content/Intent;I)V
goto :goto_2
.line 140
:cond_8
iget-object p1, p0, Lcom/foobar/foobartv/splash/SplashActivity$b;->a:Lcom/foobar/foobartv/splash/SplashActivity;
invoke-static {p1}, Lcom/foobar/foobartv/splash/SplashActivity;->a(Lcom/foobar/foobartv/splash/SplashActivity;)V
:cond_9
:goto_2
return-void
.end method
Honestly, to me, this function doesn't make any sense. But there's an interesting line on it: if-le v1, v3, :cond_6
. And if you take a look at the block of code right after it, and the one after the cond_6
label, you see that one has the update required string, and the other does not. So... how about we force to go through the path that does not have the required update stuff? And that's an easy mod:
if-le v1, v3, :cond_6
goto :cond_6 # Yep, a single goto.
Ironically, this is all that was needed. For real! Once I added that, the app loads just fine, as you can see here from the about screen here:
Ricardo from the future here: I tried making a video of it loading and everything. Turns out I'm terrible on editing videos! :)
But why not a newer version?
You see, having an old version pinging their services even though they blocked is probably a bad idea. Although very unlikely, they could find me through their logs and eventually block my account for this. This would be very annoying. They could also hire me, but I find that very unlikely as well :)
So here comes the question: how about we do this with a newer version? We don't have to unlock it (as it's a recent one, so that's fine), but we have to figure out why the hell the app does not work. This is what happens when I try to sign in using a code:
Ricardo from the future: yeah, bad photos from now on. Sorry, this issue only happens on real hardware, so no screenshots as I don't have any mean of capturing HDMI right now.
The app also didn't allow me to not use a username and password for login, as somehow this option isn't selectable. This is probably a bug with the Fire TV only. I found the code responsible for opening the proper activity for logging in with an activation code (like Plex does) and with an email and inverted them. Sure enough, the app asks me for my credentials, but unfortunately it also fails to sign me in:
So it has to be something on a lower level, something used by both. Maybe network? Maybe a flag? I started looking for anything that looked weird, disabling specifics parts of the code to make sure it could slowly debug every single call on the Smali code. This was slow, and it had to be done on real hardware, as the normal virtual Android TV works just fine. By reverse engineering where I was getting the error from, I saw a huge function with a bunch of try-catches just like this in the activity responsible for the sign-in process:
I mean, for Java/Kotlin code this is fine, but the weird part is what happened when an exception got caught:
What is happening here is that all exceptions are, in one way or another, being caught and sent all the way down to this function, which is responsible to show an error (probably a generic one?). This makes perfect sense, as depending as how you code your projects, you could have a centralized exception handler.
Anyway, I started to wonder: what would happen if I actually stop catching exceptions? We all know that if an exception is not caught, it will eventually go so high in the stack that the VM will get it. So, if I removed all catch
instructions from this function, what would happen? Well, this happens:
Gotcha! That is the exception! It's an InvalidDeviceTokenException
(within the more generic one). The device token is invalid... but why?
Gimme tokens!
Once we got the exception, we had to figure out what was triggering it. That took a simple file search:
.method public request(Lokhttp3/y;Lkotlin/jvm/functions/Function1;)V
# (...)
invoke-virtual {v0}, Lcom/foobar/foobarid/connect/core/model/FoobarIdConnectSettings;->getDeviceToken$foobarid_connect_tvRelease()Ljava/lang/String;
move-result-object v0
if-eqz v0, :cond_0
# (...)
.line 7
:cond_0
sget-object p1, Lkotlin/Result;->Companion:Lkotlin/Result$Companion;
new-instance p1, Lcom/foobar/foobarid/connect/core/networking/error/InvalidDeviceTokenException;
const/4 v0, 0x1
const/4 v1, 0x0
invoke-direct {p1, v1, v0, v1}, Lcom/foobar/foobarid/connect/core/networking/error/InvalidDeviceTokenException;-><init>(Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
invoke-static {p1}, Lkotlin/ResultKt;->createFailure(Ljava/lang/Throwable;)Ljava/lang/Object;
# (...)
.end method
This function seems to be responsible for doing HTTP requests using OkHttp. Nice. It works like this:
- Call the
getDeviceToken
method and stores its result inv0
. - If
v0
is zero (ornull
in this case), jump tocond_0
. Otherwise continue (we'll check this part later) - Create an instance of
InvalidDeviceTokenException
and throw it.
So, whatever getDeviceToken
is doing, it is returning null
. This got me curious: what is a device token? Is it something they created themselves? Is it a device-sourced information? Since the app works on standard Android TVs and not on the Fire TV, this could to be something Google-related, right? Well, here's what it is done with it:
.method public request(Lokhttp3/y;Lkotlin/jvm/functions/Function1;)V
# (...)
.line 2
invoke-virtual {p1}, Lokhttp3/y;->i()Lokhttp3/y$a;
move-result-object p1
.line 3
iget-object v1, p0, Lcom/foobar/foobarid/connect/core/networking/client/DeviceValidationHttpClient;->foobarIDSettings:Lcom/foobar/foobarid/connect/core/model/FoobarIdConnectSettings;
invoke-virtual {v1}, Lcom/foobar/foobarid/connect/core/model/FoobarIdConnectSettings;->getAppId()Ljava/lang/String;
move-result-object v1
const-string v2, "X-APP-ID"
invoke-virtual {p1, v2, v1}, Lokhttp3/y$a;->a(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/y$a;
const-string v1, "X-DEVICE-TOKEN"
.line 4
invoke-virtual {p1, v1, v0}, Lokhttp3/y$a;->a(Ljava/lang/String;Ljava/lang/String;)Lokhttp3/y$a;
.line 5
invoke-virtual {p1}, Lokhttp3/y$a;->b()Lokhttp3/y;
move-result-object p1
.line 6
iget-object v0, p0, Lcom/foobar/foobarid/connect/core/networking/client/DeviceValidationHttpClient;->httpClient:Lcom/foobar/foobarid/connect/core/networking/client/HttpClient;
invoke-interface {v0, p1, p2}, Lcom/foobar/foobarid/connect/core/networking/client/HttpClient;->request(Lokhttp3/y;Lkotlin/jvm/functions/Function1;)V
return-void
# (...)
.end method
Based on this code, what I can tell is that it's being used as custom header of some sort together with the app id. Interesting. Let's check what is getDeviceToken
doing:
.method public final getDeviceToken$foobarid_connect_tvRelease()Ljava/lang/String;
.locals 1
.annotation build Lorg/jetbrains/annotations/Nullable;
.end annotation
.line 1
iget-object v0, p0, Lcom/foobar/foobarid/connect/core/model/FoobarIdConnectSettings;->deviceToken:Ljava/lang/String;
return-object v0
.end method
That seems to be just a private field read, so it has to be getting it from somewhere. Despites my efforts, I wasn't really able to find its source, but from what I could see it is analytics-related. So I decided to do something dumb: set it to a constant. How about an empty string? Would that work? I mean, it's not null
!
And, to my honest surprise, it works!
You can sign in and watch everything just as normal. Here's the version I modified to make it work:
Finally. Geez, what a messy journey.
Ricardo from the future here: turns out that hacking this device token function does not make everything work, only the token-based sign-in. The email and password login option still fails, but noone uses that... right? Right! :)
Addressing the elephant in the room
The big question is: should you do it? And, to be honest, no, you should not.
Well, it depends. If you don't care about their terms of use (which most likely forbid you to do this) and you don't care about maybe getting kicked or even banned out of their service, sure, be my guest. My reasons for doing this modification were clearly out of curiosity, fun and to learn something new and different. This was my first time modifying Android apps, so this was quite an interesting journey.
But besides that, we can clearly see the app works just fine on the Fire TV: it's just a matter of fixing a few bugs on their side. Based on my experience, it seems that not supporting the Fire TV was a product decision, and as such the developers never get to know about those issues.
Well, maybe one day - we can all hope! For now, I'll stick with their own device (the Roku Express they gave me) for watching their streaming service. Maybe the Roku is an interesting platform to have some fun as well, who knows! :)