My attempt at reverse engineering a crypto/ meme exchange platform to front-run newly listed coins.

Motivation

Moonshot is an Android app that allows memes to be listed on their exchange platform which users trade on. As the app description on Google Play declares itself:

Cryptocurrency memecoins are not assets and do not possess any intrinsic utility or value.

moonshot

Hence, users are simply attempting to make money in a game of chicken, betting on the greater fool that will buy in after them.

The catch about this app is that users can search for coins by name but not by when coins are listed.

Newly listed coins tend to have the greatest potential for multiple X growth. Hence, if a user can (automatically) buy-in upon the launch of any new coin, it puts them at a great advantage compared to the rest of the buyers who pour in after the coin hits their news feed.

Coins are listed based on the platform developer’s own discretion - it’s difficult to believe that they aren’t front running all the other users too.

With this motivation, I decided to try to reverse-engineer the API calls that would allow me to ping the Moonshot server and retrieve the latest coin data, hopefully being able to detect the newest coin listings.

Although I wasn’t successful, I learned alot about how the app functioned.

Approach

Logging network requests to public servers

The first step is to get some idea of where the app was getting its data from.

For this I ran the app directly on my phone and logged all network requests using PCAPDroid.

While the requests themselves were TLS-encrypted, I could see which hosts they were pinging with HTTP requests.

There were 5 that stood out:

  • cdn.moonshot.money
  • srv.moonshot.money
  • api.instabug.com
  • realtime.ably.io

A quick google shows that Instabug is a bug tracking service, thus irrelevant.

Moonshot’s CDN servers are likely for loading image and video content in the app.

The Moonshot server (srv) itself didn’t reveal much, but it was probably tracking some direct user authentication based on the user device itself.

Most importantly, we have ably.io, which is a platform that delivers realtime updates. This is likely the data source, since market prices are being updated realtime in the app.

MITM proxy-ing the app

Now that I know what kind of domains to look for in the network requests, I had to see the exact network requests being made by the app.

To do this, I had to proxy all traffic from my phone through wifi into my Burpsuite proxy so that I could monitor it. Since the requests are HTTPS, I had to install certificates for Burpsuite on the phone - see this guide. However, for Android devices that are not rooted, the system only allows you to install User Certificates and not System Certificates.

Android apps have a configuration which specifies which certificate it can use, most apps which are secure will enforce System Certificates. Since I did not write the app, there was no way for me to get Moonshot to use the User Certificate.

Android Emulators

I didn’t want to root my phone just for this, but I had to get a hold of a rooted Android somehow. This is where emulators come in. They let you emulate an Android device on PC as the root user.

I used Genymotion for this. First, I set up the proxy based on this guide. Basically, this involves exporting the Burpsuite CA certificate and pushing it to the Android file system. Then, I installed Moonshot on the emulated device like a normal user. Now, all traffic from the emulated device will pass through Burpsuite where I can analyse each request.

Breaking down HTTPS requests

These are the network requests that came through: burpsuite

We can see that the app is sending out requests to the Instabug and PostHog (another user tracking service) servers, but strangely there are no requests to ably.io.

Further checking in Wireshark, we can see some incoming packets from the Ably server (13.33.88.XXX) and Moonshot server (172.67.221.50)

ip wireshark

The only other way that the app can get live data is through a WebSocket connection, which allows a server to push data to the app instead of it having to request it from the server.

Exploring Source Files

Now, I took a different approach to look into the source files of the app. The first step is to download the app as an .apk file.

Unfortunately, this app is not available on the usual APK sites. So, we have to extract it directly from the device itself.

After installing the app on my phone, I used adb(Official Google download) to access my device file system and pull the .apk from it. Follow this guide. It consists of listing all installed packages and then pulling the one you want.

From here, there are many ways to decompile the .apk,

First, I ran Diggy on to extract out all endpoints that the app was calling. This includes HTTP endpoints as well as function calls

Diggy

We can see that it is using the ably.io library’s requestToken and rest APIs.

These are all located inside the classes2 file, suggesting that the main authentication and API calls should be located within ``classes2` as well.

I then used the method in this article which decompiles the raw dex code from the unzipped .apk into a .jar.

As per the article, I used dex2jar from this repo on the classes2.dex file I extracted.

From there, I used the JD-GUI to explore the source code. Unfortunately, the code was obfuscated:

obfuscated jar

I first tried to deobfuscate with java-deobfuscator, but was unable to detect the obfuscation method that was used. So, there was no way to deobfuscate the code, I had to trace through the folders one by one.

Ably.io

First, we need to know what to look for in the code.

Having a read through ably’s docs, the app was probably using these few modules from the Java SDK:

  • Token Request/ Token Authentication docs
  • Connections docs
  • Channels docs

So we can assume that the app is following this pattern to call the Ably API:

ably auth

In this case, to get any data from the Ably servers, we would need to know:

  • The parameters that the app uses to call srv.moonshot.money such that it can get a signed TokenRequest
  • The clientId that the app is sending along with the AblyToken - assuming that this clientId is not randomly assigned each time a new token is retrieved.

Ably Java functions

After trawling through the code and searching for specific keywords used in the Ably module, I found a key file which contains most of the Ably functionality:

classes2/pe/d3.class

A snippet from this class shows references to the Channel methods from the Ably Java SDK:

...
private String H1(ye.d paramd) {
    switch (c.d[paramd.ordinal()]) {
      default:
        throw se.b.c(paramd, String.class);
      case 8:
        return "update";
      case 7:
        return "suspended";
      case 6:
        return "failed";
      case 5:
        return "detached";
      case 4:
        return "detaching";
      case 3:
        return "attached";
      case 2:
        return "attaching";
      case 1:
        break;
    } 
    return "initialized";
  }
  
  private Map<String, Object> I1(Message paramMessage) {
    if (paramMessage == null)
      return null; 
    HashMap<Object, Object> hashMap = new HashMap<>();
    v3((Map)hashMap, "id", ((BaseMessage)paramMessage).id);
    v3((Map)hashMap, "clientId", ((BaseMessage)paramMessage).clientId);
    v3((Map)hashMap, "connectionId", ((BaseMessage)paramMessage).connectionId);
    v3((Map)hashMap, "timestamp", Long.valueOf(((BaseMessage)paramMessage).timestamp));
    v3((Map)hashMap, "name", paramMessage.name);
    v3((Map)hashMap, "data", ((BaseMessage)paramMessage).data);
    v3((Map)hashMap, "encoding", ((BaseMessage)paramMessage).encoding);
    v3((Map)hashMap, "extras", paramMessage.extras);
    return (Map)hashMap;
  }
  
  private Map<String, Object> J1(MessageExtras paramMessageExtras) {
    if (paramMessageExtras == null)
      return null; 
    HashMap<String, Object> hashMap = (HashMap)f.fromJson(paramMessageExtras.asJsonObject().toString(), HashMap.class);
    DeltaExtras deltaExtras = paramMessageExtras.getDelta();
    if (deltaExtras != null) {
      HashMap<Object, Object> hashMap1 = new HashMap<>();
      v3((Map)hashMap1, "format", deltaExtras.getFormat());
      v3((Map)hashMap1, "from", deltaExtras.getFrom());
      v3(hashMap, "delta", hashMap1);
    } 
    return hashMap;
  }
  
  private String K1(ye.e parame) {
    switch (c.c[parame.ordinal()]) {
      default:
        throw se.b.c(parame, String.class);
      case 7:
        return "suspended";
      case 6:
        return "failed";
      case 5:
        return "detached";
      case 4:
        return "detaching";
      case 3:
        return "attached";
      case 2:
        return "attaching";
      case 1:
        break;
    } 
    return "initialized";
  }
  ...

And another snippet shows it forming some sort of server side authentication payload:

private Param[] B1(Map<String, Object> paramMap) {
    if (paramMap == null)
      return null; 
    Param[] arrayOfParam = new Param[paramMap.size()];
    byte b1 = 0;
    Object object1 = paramMap.get("limit");
    Object object2 = paramMap.get("clientId");
    paramMap = (Map<String, Object>)paramMap.get("connectionId");
    if (object1 != null) {
      arrayOfParam[0] = new Param("limit", object1);
      b1 = 1;
    } 
    int i = b1;
    if (object2 != null) {
      arrayOfParam[b1] = new Param("clientId", (String)object2);
      i = b1 + 1;
    } 
    if (paramMap != null)
      arrayOfParam[i] = new Param("connectionId", (String)paramMap); 
    return arrayOfParam;
  }
  
  private void C0(ByteArrayOutputStream paramByteArrayOutputStream, JsonElement paramJsonElement) {
    if (paramJsonElement instanceof JsonObject) {
      super.p(paramByteArrayOutputStream, f.fromJson(paramJsonElement, Map.class));
    } else if (paramJsonElement instanceof JsonArray) {
      super.p(paramByteArrayOutputStream, f.fromJson(paramJsonElement, ArrayList.class));
    } 
  }
  
  private Auth.TokenDetails C1(Map<String, Object> paramMap) {
    if (paramMap == null)
      return null; 
    Auth.TokenDetails tokenDetails = new Auth.TokenDetails();
    u3(paramMap, "token", new k(tokenDetails));
    u3(paramMap, "expires", new v(tokenDetails));
    u3(paramMap, "issued", new g0(tokenDetails));
    u3(paramMap, "capability", new r0(tokenDetails));
    u3(paramMap, "clientId", new c1(tokenDetails));
    return tokenDetails;
  }
  
  private Auth.TokenParams D1(Map<String, Object> paramMap) {
    if (paramMap == null)
      return null; 
    Auth.TokenParams tokenParams = new Auth.TokenParams();
    u3(paramMap, "capability", new m(tokenParams));
    u3(paramMap, "clientId", new n(tokenParams));
    u3(paramMap, "timestamp", new o(this, tokenParams));
    u3(paramMap, "ttl", new p(this, tokenParams));
    return tokenParams;
  }
  
  private Auth.TokenRequest E1(Map<String, Object> paramMap) {
    if (paramMap == null)
      return null; 
    Auth.TokenRequest tokenRequest = new Auth.TokenRequest();
    u3(paramMap, "keyName", new k1(tokenRequest));
    u3(paramMap, "nonce", new l1(tokenRequest));
    u3(paramMap, "mac", new m1(tokenRequest));
    u3(paramMap, "capability", new o1(tokenRequest));
    u3(paramMap, "clientId", new p1(tokenRequest));
    u3(paramMap, "timestamp", new q1(this, tokenRequest));
    u3(paramMap, "ttl", new r1(this, tokenRequest));
    return tokenRequest;
  }
  
  private Param[] F1(Map<String, String> paramMap) {
    Param[] arrayOfParam = null;
    if (paramMap == null)
      return null; 
    for (String str : paramMap.keySet())
      arrayOfParam = Param.set(arrayOfParam, str, paramMap.get(str)); 
    return arrayOfParam;
  }
  
  private Map<String, Object> G1(i parami) {
    if (parami == null)
      return null; 
    HashMap<Object, Object> hashMap = new HashMap<>();
    v3((Map)hashMap, "registrationHandle", parami.a);
    Byte byte_ = e2(parami.b);
    f f = this.d.get(byte_);
    if (byte_ != null && f != null) {
      v3((Map)hashMap, "type", Integer.valueOf(byte_.byteValue() & 0xFF));
      v3((Map)hashMap, "message", f.b(parami.b));
    } 
    return (Map)hashMap;
  }

And also the calling of Ably.io API with a AblyToken supplied. Here, we can see the useTokenAuth parameter which is a parameter specific to the Ably SDK when making any sort of request to Ably.

private Auth.AuthOptions n1(Map<String, Object> paramMap) {
    if (paramMap == null)
      return null; 
    Auth.AuthOptions authOptions = new Auth.AuthOptions();
    u3(paramMap, "authUrl", new b1(authOptions));
    u3(paramMap, "authMethod", new d1(authOptions));
    u3(paramMap, "key", new e1(authOptions));
    u3(paramMap, "tokenDetails", new f1(this, authOptions));
    u3(paramMap, "authHeaders", new g1(this, authOptions));
    u3(paramMap, "authParams", new h1(this, authOptions));
    u3(paramMap, "queryTime", new i1(authOptions));
    u3(paramMap, "useTokenAuth", new j1(authOptions));
    return authOptions;
  }
  
  private Message o1(Map<String, Object> paramMap) {
    if (paramMap == null)
      return null; 
    Message message = new Message();
    u3(paramMap, "id", new n1(message));
    u3(paramMap, "clientId", new s1(message));
    u3(paramMap, "name", new t1(message));
    u3(paramMap, "data", new u1(this, message));
    u3(paramMap, "encoding", new v1(message));
    u3(paramMap, "extras", new l(message));
    return message;
  }
  
  private Object p1(Map<String, Object> paramMap) {
    return (paramMap == null) ? null : v1(paramMap.get("data"));
  }

Hitting a wall

The app was following all the best practices according to Ably docs. I could not find any exposed hardcoded API keys. Since the token request has to be retrieved from srv.moonshot.money and then used to request a token from Ably, it means we cannot spoof the TokenRequest - it is signed with a MAC, as per the API docs.

I’m not sure where else to go from here, as there doesn’t seem to be a way to crack the TokenRequest authentication. In addition, there is still the problem of knowing which Channels to subscribe to once authenticated.

Still, I learned more about how pub/sub WebSocket workflows work. There may be another way to get new coin listings without having to crack the Ably authentication mechanism.