Investigating Google Cast: Disabling device authentication on Android with Xposed
Background
Google Cast is a proprietary protocol by Google which enables controlling playback of Internet-streamed audiovisual content on the Chromecast, Android TV and other compatible devices.
From the consumer perspective, Google Cast connects two devices: a sender (such as a smartphone) and a receiver (such as a Chromecast device). Using the Google Cast framework, an application on the sender device (say, YouTube or Netflix) communicates with a corresponding browser application on the receiver to play and control media.
Ostensibly, Google Cast is an open, albeit largely closed-source, framework. On the sender application side, content providers can use the Google Cast SDK, Android, iOS and Google Chrome applications can communicate with receiver devices. Similarly, on the receiver application side, using the Google Cast Application Framework (CAF) Receiver SDK, browser applications can be hosted on a Chromecast or similar device, and communicate with sender applications over Google Cast.
The trick, however, lies with the receiver device. The Google Cast protocol includes device authentication mechanisms to ensure that sender applications can only communicate with approved receiver devices. The authentication is optional at the protocol level, but is enforced by the Google Cast SDK, and this behaviour cannot be configured. The end result is that Google holds a strict monopoly on the Google Cast protocol – only devices manufactured or approved by Google can run Google Cast receiver software.
Previous work and protocol overview
The Google Cast protocol itself has been well-studied. While the Android and iOS SDKs are closed-source, the sender functionality is implemented within Chromium and therefore open-source. Most of this discussion is drawn from Thibaut Séguy's work on node-castv2 (1, 2), Huaiyuan Gu's chromecast-receiver-emulator and Romain Picard's detailed writeups on his blog (1, 2, 3).
Modern Google Cast receiver devices announce themselves to sender devices using mDNS.1 The devices then communicate over a very simple TCP protocol, using Protobuf to exchange messages. When it comes time to launch a receiver application, the receiver device spawns a browser to host the receiver application, which communicates with the receiver device using WebSockets. Messages are forwarded over the TCP link between the sender and receiver applications as necessary. These protocols are well-described at the links above.
Device authentication protocol
The TCP connection between the sender and receiver devices is secured with TLS. The certificate used for the connection (the peer certificate) is self-signed by the receiver device, and valid for 24 hours. The receiver device also possesses a platform certificate, which is signed by a trusted Google CA.
When the device authentication mechanism is engaged, the receiver device signs the peer certificate using the platform certificate. The sender device can then verify that the signature is valid, and the platform certificate has been duly signed by the trusted Google CA.
The protocol is secure, and effectively prevents a stock sender device from communicating with an unauthorised receiver device. As before, this behaviour cannot be disabled within the official Google Cast SDK.
Finding the device authentication implementation
We know, from previous work (and by sniffing the traffic between the sender and receiver devices), that the device authentication takes place over the urn:x-cast:com.google.cast.tp.deviceauth Protobuf namespace. On an Android device, we hypothesise that there are a few places this device authentication code could be located:
- In the Google Home app
- In the Google Cast SDK (1, 2, etc.)
- In the Google Play Services app
We begin our investigation by downloading and unpacking each of these, and performing a simple grep for the string deviceauth
, which leads us to the Google Play Services app.2 We can then use jadx to decompile the Google Play Services APK file, and look for the specific class where this is implemented.
The code has been obfuscated, but we do indeed find a single occurrence of this string:
public final class ptd extends qbq {
private static final String e = qbz.c("com.google.cast.tp.deviceauth");
The next line has some promising strings, too:
private static final String[] f = {"success", "error received", "client auth cert malformed", "client auth cert not X509", "client auth cert not trusted", "SSL cert not trusted", "response malformed", "device capability not supported", "CRL is invalid", "CRL revocation check failed"};
And scrolling down slightly further, we find a very revealing declaration:
static {
try {
j = new HashSet();
k = CertificateFactory.getInstance("X.509");
j.add(new TrustAnchor(a("MIIDwzCC[...]3ov1Mw=="), (byte[]) null));
j.add(new TrustAnchor(a("MIIDxTCC[...]Rhx1LB9N"), (byte[]) null));
} catch (CertificateException e2) {
Log.wtf("DeviceAuthChannel", "Error parsing built-in cert.", e2);
}
}
TrustAnchor is a Java class used as a ‘trust anchor for validating X.509 certification paths’, so it seems reasonable to suspect that these are the trusted Google CAs used to validate the platform certificates.
Digging deeper
Looking now to references for this j HashSet (containing the trusted Google CAs), we come across a lengthy method called a. jadx has failed to decompile this method, so it is a little harder to make sense of, but the broad strokes are clear:
/* Code decompiled incorrectly, please refer to instructions dump. */
public final void a(byte[] r15) {
/*
r14 = this;
[...]
java.util.HashSet r9 = j // Note the reference to the HashSet "j" from earlier
r8.<init>(r9)
r8.setRevocationEnabled(r0)
java.lang.String r9 = "PKIX"
java.security.cert.CertPathValidator r9 = java.security.cert.CertPathValidator.getInstance(r9)
java.security.cert.CertPathValidatorResult r8 = r9.validate(r6, r8)
java.security.cert.PKIXCertPathValidatorResult r8 = (java.security.cert.PKIXCertPathValidatorResult) r8
Clearly, this method is responsible for performing the platform certificate validation, among other things. My initial hypothesis was that, since the return type is void (does not return any value), this method would throw an Exception if the certificate validation failed.
Watching the code live
Having found a method of interest, we can now turn to the Xposed Framework, a framework for rooted Android devices to hook and modify arbitary Java code.3 With the code below, we hook the certificate validation ptd.a method to log the method call and result:
public class GCastNoAuth implements IXposedHookLoadPackage {
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
if (!lpparam.packageName.equals("com.google.android.gms")) {
return;
}
// Hook certificate check function
findAndHookMethod("ptd", lpparam.classLoader, "a", "[B", new XC_MethodHook() {
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("[GCastNoAuth] beforeHookedMethod");
}
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("[GCastNoAuth] afterHookedMethod");
if (param.hasThrowable()) {
XposedBridge.log("[GCastNoAuth] An exception was raised");
XposedBridge.log(param.getThrowable());
} else {
XposedBridge.log("[GCastNoAuth] No exception");
}
}
});
}
}
Connecting to a receiver device with a valid certificate, then an invalid certificate, we now observe the following log output:
12-18 18:53:37.574 6208 6347 I EdXposed-Bridge: [GCastNoAuth] beforeHookedMethod
12-18 18:53:37.600 6208 6347 I EdXposed-Bridge: [GCastNoAuth] afterHookedMethod
12-18 18:53:37.600 6208 6347 I EdXposed-Bridge: [GCastNoAuth] No exception
[...]
12-18 18:56:40.453 6208 8297 I EdXposed-Bridge: [GCastNoAuth] beforeHookedMethod
12-18 18:56:40.461 6208 8297 I CastService: [instance-4] onConnectionFailed: package: com.google.android.apps.chromecast.app status=AUTHENTICATION_FAILED
12-18 18:56:40.463 6208 8297 I EdXposed-Bridge: [GCastNoAuth] afterHookedMethod
12-18 18:56:40.463 6208 8297 I EdXposed-Bridge: [GCastNoAuth] No exception
Success! – kind of. We have successfully hooked the certificate validation function, but the result returned from the a method was the same in both cases. Curious! We must look further.
Locating and enabling debug output
Returning to the ptd.a method, we find some handlers for some error cases:
L_0x021d:
qco r15 = r14.s
java.lang.Object[] r0 = new java.lang.Object[r0]
java.lang.String r1 = "Received DeviceAuthMessage with no response (ignored)."
r15.a(r1, r0)
return
Note from the earlier code block that r15 contains a reference to this, so r15.a(r1, r0)
in this code snippet is calling the method this.s.a
, passing a message and an empty array.
A quick bit of digging reveals that this.s
is a reference to an object of class qco:
import android.util.Log;
/* [...] */
public class qco {
/* [...] */
public final void a(String str, Object... objArr) {
if (a() || a) {
e(str, objArr);
}
}
public final void b(Throwable th, String str, Object... objArr) {
Log.w(this.b, e(str, objArr), th);
}
public final void c(Throwable th, String str, Object... objArr) {
Log.e(this.b, e(str, objArr), th);
}
public final void a(Throwable th, String str, Object... objArr) {
Log.i(this.b, e(str, objArr), th);
}
}
Clearly, this is a class that has something to do with logging. The final three methods call the warning, error and information logging methods of the standard Android Log class. If you look closely, however, the top a function (the one called by our code from earlier), does not. It calls the e function (which ostensibly calculates the log message), but does not do anything with it. We deduce that a, therefore, is probably some debug logging function. The actual logging itself has been disabled or optimised away, but the method call itself remains.
Returning to Xposed, with the code below, we can now hook into the debug logging qco.a method, and print the actual debug messages:
// Hook debug logging function to show logs
findAndHookMethod("qco", lpparam.classLoader, "a", String.class, "[Ljava.lang.Object;", new XC_MethodHook() {
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
String logMessage = (String) callMethod(param.thisObject, "e", new Class<?>[] {String.class, Class.forName("[Ljava.lang.Object;")}, param.args);
XposedBridge.log("[GCastNoAuth] [Debug] " + logMessage);
}
});
With this code, we now see a lot more debug output! The debug output when connecting to a receiver with an invalid certificate now shows:
12-18 19:12:52.872 3234 4283 I EdXposed-Bridge: [GCastNoAuth] beforeHookedMethod
[...]
12-18 19:12:52.873 3234 4283 I EdXposed-Bridge: [GCastNoAuth] [Debug] [controller-0003-com.google.android.apps.chromecast.app API] Received a protobuf: # bmbs@1f762c8
12-18 19:12:52.873 3234 4283 I EdXposed-Bridge: [GCastNoAuth] [Debug] [controller-0003-com.google.android.apps.chromecast.app API] Device authentication failed: error received - 0
[...]
12-18 19:12:52.880 3234 4283 I CastService: [instance-3] onConnectionFailed: package: com.google.android.apps.chromecast.app status=AUTHENTICATION_FAILED
12-18 19:12:52.881 3234 4283 I EdXposed-Bridge: [GCastNoAuth] afterHookedMethod
12-18 19:12:52.881 3234 4283 I EdXposed-Bridge: [GCastNoAuth] No exception
Now we're cooking with gas!
Honing in on the target
The string Device authentication failed appears in another function in the ptd class, also (unfortunately) called a (but with a different method signature):
private final void a(int i2, Exception exc) {
String str;
if (i2 == 0) {
this.s.a("Device authentication succeeded.", new Object[0]);
/* [...] */
} else {
/* [...] */
qco.a(String.format(locale, "Device authentication failed: %s - %s", objArr), new Object[0]);
}
if (i2 == 0) {
this.s.a("authentication succeeded", new Object[0]);
/* Lots more interesting communication-related code! */
return;
}
this.n.a(2000, false, pqf.b(i2));
}
(For your interest, the constant 2000 appears in another class in connection with the string AUTHENTICATION_FAILED from the log!)
It appears that this is a callback method, which is passed the result of the certificate validation in the argument i2. If i2 is 0, then authentication succeeds, and further interesting communication-related things are proceeded with. If i2 is nonzero, then device authentication fails, and some other method is called with an error code.
The final implementation
Armed with this knowledge, our plan of attack is clear. Using Xposed, we hook the ptd.a method (the second one), and modify the argument i2 so that it is always 0, and so device authentication always succeeds:
public class GCastNoAuth implements IXposedHookLoadPackage {
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
if (!lpparam.packageName.equals("com.google.android.gms")) {
return;
}
// Hook certificate post-check function
findAndHookMethod("ptd", lpparam.classLoader, "a", int.class, Exception.class, new XC_MethodHook() {
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
// Force success
param.args[0] = 0;
}
});
}
}
With this code in place, our Android sender device now duly connects to and communicates with a custom receiver!
Future steps and limitations
Given that open-source receiver software implementations already exist (e.g. node-castv2), this Xposed module removes the final barrier to creating a third-party receiver compatible with the Android Google Cast sender framework. This could be used, for example, to sniff traffic between the Android Google Cast sender and an official receiver device. It could also be used – subject to the limitations below – to supplant an official receiver device entirely.
However, this result does not blow the Google Cast ecosystem entirely open. While device authentication does provide the ‘keys to the kingdom’ at a Google Cast protocol level, actual Google Cast receiver applications by content providers (e.g. YouTube and Netflix) make use of further security measures, such as EME, Widevine Verified Media Path and HDCP, which make use of hardware security functionality on the Google Cast device itself, and are significantly more difficult to overcome.
In view of this, my thoughts on where this could go are that it could be used to receive Google Cast transmissions, without emulating the entire Google Cast receiver stack. For example, an Android phone could be used to communicate with our custom receiver software running on, say, a Raspberry Pi, which would translate the commands received into, say, the relevant Kodi video addon. This would obviate the need ourselves to emulate or defeat the DRM functionality used by Google Cast-specific receiver applications.4
Code and binaries
The code for the Xposed module in this blog post is available at https://yingtongli.me/git/GCastNoDeviceAuth/.
Prebuilt APK files ready for installation may be found at https://yingtongli.me/blog/assets/posts/docvcs/GCastNoDeviceAuth/.
Footnotes
-
Original Chromecast devices used a discovery mechanism called SSDP/DIAL, but this has been superseded by mDNS. ↩
-
The Google Home app appears to have a separate implementation of the device authentication, but this appears only to be relevant to configuring devices, not streaming to them. ↩
-
I am running Android Pie, so I have actually used the Xposed-compatible EdXposed. ↩
-
As an illustrative example, when the Amazon Prime Video internal API detects a Google Cast device, it serves EME keys which mandate the use of HDCP, which is not generally supported on Linux, even when calls to the same API by a standard Linux browser serve EME keys which do not require HDCP. To overcome this restriction would require rewriting requests to the API (by both the sender – which performs authentication/authorisation to enable access to the content – and the receiver – which requests the EME keys) to change the reported device type from Google Cast into a non-Google-Cast device. ↩