Apr 01 2024
[go reverse-engineering]
On April 1, 2018, Cloudflare released 1.1.1.1 a solution to resolve DNS queries faster with the added benefit that it was privacy-first. Fast forward about 2 years, they released WARP which behind the scenes was a VPN (Virtual Private Network). While WARP was free, they were working on WARP+, a premium version of WARP, that would be even faster by utilizing Cloudflare’s virtual private backbone and Argo Smart Routing.
This article describes how I reverse-engineered their android app to create a "referral generation system" called Warper.
After the release of WARP+, I was eager to test its performance and explore the pricing options. I discovered that you could earn 1 GB of WARP+ data for each friend you referred, with the alternative being WARP+ Unlimited at $4.99 per month.
Curious about how many GBs I could collect, I saw the ending of the WARP+ referral program as the perfect opportunity to challenge myself. But I quickly realized that to reach my goal, I couldn't rely solely on inviting my friends—so I turned to some clever software!
Firing up my rooted emulator and installing the 1.1.1.1 APK, I encountered a problem. All my sniffed requests were failing due to SSL handshake errors, which was expected as most apps use SSL Pinning. To bypass this, I started up a Frida server and ran a quick script to hook onto functions used in certificate pinning.

Above are the API calls the app was making after bypassing SSL pinning. I filtered out requests to third-party services like Zendesk, New Relic, etc., to make it easier to focus on the important requests (for Charles, you can use the Block List feature to achieve the same).
It was fairly easy to figure out that the endpoint /v0a3092/reg was used to register a new account after installation.
1{
2 "key": "Va+UXf7C4B3Vu9c34/FIGYCSrD6S/VFS25IG8BlnJ2M=",
3 "install_id": "cgyhWcCnR3-B5gchaCptjn",
4 "fcm_token": "cgyhWcCnR3-B5gchaCptjn:APA91bHVwkGfmh-28Lzz1aiAexBNxiUtySYL2nRxhBckdZ2EUcM51cMow0xdHLUJ5depJzG3qDMtkkgLvdj0NffOoxm3tT8evhbG9OOZX17fsa5zt1Q0GLkK1ijjPEcuKdEghBugNuLw",
5 "tos": "2024-08-08T18:14:53.67-04:00",
6 "model": "Samsung SM-S908N",
7 "serial_number": "cgyhWcCnR3-B5gchaCptjn",
8 "locale": "en_US"
9}
From the JSON data posted to the endpoint we can tell a few things:
Those were pretty straightforward to figure out; however, the most interesting one to me was the key field, as it was changing randomly with every new registration request. But we'll leave this alone for now and take a look at the other two fields, install_id and fcm_token. From my previous experience with reversing Android apps, I could tell this is a Firebase installation ID and is generated randomly by the Firebase SDK.
I quickly recreated the code to simulate requests to initiate a new installation to their two endpoints, /v1/projects/project-8285292058764338105/installations and /c2dm/register3, in Golang. I'm not going to go into the details about this at the moment, as it's not the main focus of this article, but I might write a new article focusing on this.
Note: Check out the android.go file to see how I did this (the approach is not the best but it does the job for this case)
Now that those two are figured out, we can focus on the key field. Just to see if it was possible to reuse the key value, I sent another request with the same key value; however, that didn't work and resulted in an error. Always worth a try, though!
1{
2 "result": null,
3 "success": false,
4 "errors": [{
5 "code": 1001,
6 "message": "Invalid public key"
7 }],
8 "messages": []
9}
To figure out how the key field is generated, we have to disassemble the 1.1.1.1 APK. For this, I used jadx, which decompiles Dalvik bytecode to Java code.
After running jadx on it, you can search for one of the keys that was in the JSON data; in our case, I started with "install_id".

We can see the first result is public RegistrationRequest(String str, @q(name = "install_id"), we can now check everywhere where this function is used.

The first result again seems the most likely as the second one is named copy, however it's not always as straightforward as this in other cases.
1public /* synthetic */ RegistrationRequest(String str, String str2, String str3, String str4, String str5, String str6, String str7, String str8, String str9, int i, DefaultConstructorMarker defaultConstructorMarker) {
2 this(str, str2, str3, str4, str5, str6, r9, str8, r11);
3 String str10;
4 String str11 = (i & 64) != 0 ? null : str7;
5 if ((i & RecyclerView.a0.FLAG_TMP_DETACHED) != 0) {
6 String locale = Locale.getDefault().toString();
7 h.b(locale, "Locale.getDefault().toString()");
8 str10 = locale;
9 } else {
10 str10 = str9;
11 }
12 }
From this code, we can see the function is calling another constructor of the RegistrationRequest class. We can also see how the locale is being set, but we don't have to look into that as we will keep using the same one used in the POST request.
Let's keep following the chain and see where this function is used. While checking all the functions, I wrote a few scripts to hook onto them and checked if the key parameter was present, which is how I followed it to the main function where it was being generated.

To keep this article short (and also to spare you the pain of checking all the functions and writing scripts to hook onto them), I went through both of these functions, following the chain to the top. I eventually found myself at this function at the end.
1public final b0.a.a b(d0.m.b.q<? super String, ? super String, ? super String, RegistrationRequest> qVar, String str) {
2 d0.d<String, String> a2 = this.f505d.a();
3 String str2 = a2.b;
4 String str3 = a2.c;
5 FirebaseInstanceId h = FirebaseInstanceId.h();
6 d0.m.c.h.b(h, "FirebaseInstanceId.getInstance()");
7 d.d.a.c.l.g<d.d.c.m.q> i = h.i();
8 d0.m.c.h.b(i, "FirebaseInstanceId.getInstance().instanceId");
9 d0.m.c.h.f(i, "$this$toSingle");
10 b0.a.w e = b0.a.w.e(new d.a.a.i.d(i));
11 d0.m.c.h.b(e, "Single.create<T> { emitt\u2026 }\n }\n }");
12 b0.a.w p = e.p(a0.b);
13 d0.m.c.h.b(p, "FirebaseInstanceId.getIn\u2026Single().map { it.token }");
14 b0.a.g0.e.a.m mVar = new b0.a.g0.e.a.m(p.v(b0.a.l0.a.c).q(b0.a.c0.a.a.a()).l(new a(qVar, str2, str)).j(new b(str2, str3, str)).j(new c(str)).j(new d()));
15 d0.m.c.h.b(mVar, "getFirebaseToken()\n \u2026 .ignoreElement()");
16 return mVar;
17 }
This piece of code is the main focus for us, as these two variables hold the values for the keys.
1d0.d<String, String> a2 = this.f505d.a();
2 String str2 = a2.b;
3 String str3 = a2.c;
Following the method a, I found it was linked to this class.
1public final class t0 {
2 public final int a = 32;
3 public final BoringTunJNI b = new BoringTunJNI();
4
5 public final d0.d<String, String> a() {
6 ByteBuffer allocateDirect = ByteBuffer.allocateDirect(this.a);
7 ByteBuffer allocateDirect2 = ByteBuffer.allocateDirect(this.a);
8 BoringTunJNI boringTunJNI = this.b;
9 d0.m.c.h.b(allocateDirect, "privateKeyBuffer");
10 d0.m.c.h.b(allocateDirect2, "publicKeyBuffer");
11 if (boringTunJNI != null) {
12 d0.m.c.h.f(allocateDirect, "privateKey");
13 d0.m.c.h.f(allocateDirect2, "publicKey");
14 if (!BoringTunJNI.b.generate_key_pair(allocateDirect, allocateDirect2)) {
15 h0.a.a.f1410d.g("WarpKeyGenerator: Failed to generate wireguard keypair.", new Object[0]);
16 return new d0.d<>(HttpUrl.FRAGMENT_ENCODE_SET, HttpUrl.FRAGMENT_ENCODE_SET);
17 } else if (this.b != null) {
18 d0.m.c.h.f(allocateDirect2, "key");
19 String x25519_key_to_base64 = BoringTunJNI.b.x25519_key_to_base64(allocateDirect2);
20 if (this.b != null) {
21 d0.m.c.h.f(allocateDirect, "key");
22 return new d0.d<>(x25519_key_to_base64, BoringTunJNI.b.x25519_key_to_base64(allocateDirect));
23 }
24 throw null;
25 } else {
26 throw null;
27 }
28 }
29 throw null;
30 }
31}
After a quick Google search, I found that BoringTun was an implementation of the WireGuard protocol written in Rust by Cloudflare. Knowing this (and the error we got at the start), I guessed that the key field in the POST request was the base64 encoding of the public key generated by the BoringTun library, which turned out to be true after a bit of testing.
Now that I had all the fields figured out, all that was left was writing code to automate the generation of those values.
Note: I haven't shown what the referral PATCH request looks like here to leave you with something to figure out yourself. However you can check out my source code to see how I am sending that request.
My main two reasons for choosing Golang were the speed of the language and its cross-platform support. You can read the whole source code I wrote on GitHub or use the program by downloading one of the prebuilt binaries.
There are still a few features I'd like to add, which are listed in the repo; however, I'll probably work on them later when I have time. I'm open to contributions if any of you want to help add some of those features.
I took on this challenge as a fun project and not to disrupt any of Cloudflare's services. Feel free to contact me if you want the code on GitHub to be removed.