gaining access to anyones browser without them even visiting a website

and of course, firebase was the cause (CVE-2024-45489)

we start at the homepage of arc. where i first landed when i first heard of it. i snatched a download and started analysing, the first thing i realised was that arc requires an account to use, why do they require an account?

introducing arcs cloud features

so i boot up my mitmproxy instance and i sign up, and i see that they are using firebase for authentication, but no other requests, are they really just using firebase only for authentication?

after poking around for a bit, i discovered that there was a arc featured called easels, easels are a whiteboard like interface, and you can share them with people, and they can view them on the web. when i clicked the share button however, there was no requests in my mitmproxy instance, so whats happening here?

hacking objective-c based firebase apps

from previous experience hacking an IOS based app, i immediately had a hunch on what this was, firestore.

firestore is a database-as-a-backend service that allows for developers to not care about writing a backend, and instead write database security rules and make users directly access the database.

this has of course sparked a lot of services having insecure or insufficient security rules and since researching that, i would like to call myself a firestore expert.

firestore has a tendency to not abide by the system proxy settings in the Swift SDK for firebase, so going off my hunch, i wrote a frida script to dump the relevant calls.

var documentWithPath =
  ObjC.classes.FIRCollectionReference["- documentWithPath:"];
var queryWhereFieldIsEqualTo =
  ObjC.classes.FIRQuery["- queryWhereField:isEqualTo:"];
var collectionWithPath = ObjC.classes.FIRFirestore["- collectionWithPath:"];

function getFullPath(obj) {
  if (obj.path && typeof obj.path === "function") {
    return obj.path().toString();
  }
  return obj.toString();
}

var queryStack = [];

function logQuery(query) {
  var queryString = `firebase.${query.type}("${query.path}")`;
  query.whereClauses.forEach((clause) => {
    queryString += `.where("${clause.fieldName}", "==", "${clause.value}")`;
  });
  console.log(queryString);
}

Interceptor.attach(documentWithPath.implementation, {
  onEnter: function (args) {
    var parent = ObjC.Object(args[0]);
    var docPath = ObjC.Object(args[2]).toString();
    var fullPath = getFullPath(parent) + "/" + docPath;
    var query = { type: "doc", path: fullPath, whereClauses: [] };
    queryStack.push(query);
    logQuery(query);
  },
});

Interceptor.attach(collectionWithPath.implementation, {
  onEnter: function (args) {
    var collectionPath = ObjC.Object(args[2]).toString();
    var query = { type: "collection", path: collectionPath, whereClauses: [] };
    queryStack.push(query);
  },
});

Interceptor.attach(queryWhereFieldIsEqualTo.implementation, {
  onEnter: function (args) {
    var fieldName = ObjC.Object(args[2]).toString();
    var value = ObjC.Object(args[3]).toString();

    if (queryStack.length > 0) {
      var currentQuery = queryStack[queryStack.length - 1];
      currentQuery.whereClauses.push({ fieldName: fieldName, value: value });
    }
  },
  onLeave: function (retval) {},
});

var executionMethods = [
  "- getDocuments",
  "- addSnapshotListener:",
  "- getDocument",
  "- addDocumentSnapshotListener:",
  "- getDocumentsWithCompletion:",
  "- getDocumentWithCompletion:",
];

executionMethods.forEach(function (methodName) {
  if (ObjC.classes.FIRQuery[methodName]) {
    Interceptor.attach(ObjC.classes.FIRQuery[methodName].implementation, {
      onEnter: function (args) {
        if (queryStack.length > 0) {
          var query = queryStack.pop();
          logQuery(query);
        }
      },
    });
  }
});

function formatFirestoreData(data) {
  if (data.isKindOfClass_(ObjC.classes.NSDictionary)) {
    let result = {};
    data.enumerateKeysAndObjectsUsingBlock_(
      ObjC.implement(function (key, value) {
        result[key.toString()] = value.toString();
      })
    );
    return JSON.stringify(result);
  }
  return data.toString();
}

var documentMethods = [
  { name: "- updateData:completion:", type: "update" },
  { name: "- updateData:", type: "update" },
  { name: "- setData:completion:", type: "set" },
  { name: "- setData:", type: "set" },
];

documentMethods.forEach(function (method) {
  if (ObjC.classes.FIRDocumentReference[method.name]) {
    Interceptor.attach(
      ObjC.classes.FIRDocumentReference[method.name].implementation,
      {
        onEnter: function (args) {
          var docRef = ObjC.Object(args[0]);
          var data = ObjC.Object(args[2]);
          var fullPath = getFullPath(docRef);
          var formattedData = formatFirestoreData(data);
          console.log(
            `firebase.doc("${fullPath}").${method.type}(${formattedData})`
          );
        },
      }
    );
  } else {
    console.log("Warning: " + method.name + " not found");
  }
});

hacky script, but it works. so i launched arc with the script loaded on startup and this is what i got:

firebase.doc("preferences/UvMIUnuxJ2h0E47fmZPpHLisHn12");
firebase.doc(
  "preferences/UvMIUnuxJ2h0E47fmZPpHLisHn12/stringValues/autoArchiveTimeThreshold"
);
firebase.doc("preferences/UvMIUnuxJ2h0E47fmZPpHLisHn12");
firebase.doc(
  "preferences/UvMIUnuxJ2h0E47fmZPpHLisHn12/stringValues/autoArchiveLittleArcTimeThreshold"
);
firebase.doc("preferences/UvMIUnuxJ2h0E47fmZPpHLisHn12");
firebase.doc(
  "preferences/UvMIUnuxJ2h0E47fmZPpHLisHn12/stringValues/autoArchiveTimeThresholdsPerProfile"
);
firebase.doc("users/UvMIUnuxJ2h0E47fmZPpHLisHn12");
firebase
  .collection("user_referrals")
  .where("inviter_id", "==", "UvMIUnuxJ2h0E47fmZPpHLisHn12");
firebase
  .collection("boosts")
  .where("creatorID", "==", "UvMIUnuxJ2h0E47fmZPpHLisHn12");

sick. so it looks like arc stores some preferences in firestore, along with a basic user object, referrals and boosts

what the hell are arc boosts

arc boosts are a way for users to customize websites, by blocking elements, changing fonts, colors, and even using their own custom css and js.

do you see where this is going?, so, i manually logged into my account using my dummy page to test firebase accounts, and executed the exact same query to get my boosts:

cool, let me create a simple boost on google.com

hey! theres our boost, lets try changing some parameters around.

i see that it queries by creatorID, and we cant query a different creator ID than the original, but what if we update our own boost to have another users id?

well, i tried it with another account of mine, and this way the result when i went to google.com on the other computer (the victim one)

what the fuck? it works?

quick recap

  • arc boosts can contain arbitrary javascript
  • arc boosts are stored in firestore
  • the arc browser gets which boosts to use via the creatorID field
  • we can arbitrarily chage the creatorID field to any user id

thus, if we were to find a way to easily get someone elses user id, we would have a full attack chain

getting another users id

user referrals

when someone referrs you to arc, or you referr someone to arc, you automatically get their user id in the user_referrals table, which means you could just ask someone for their arc invite code and they'd likely give it

published boosts

you can share arc boosts (only if they don't have js in them) with other people, and arc has a public site with boosts, and boostSnapshots (published boosts) contain the user id of the creator.

user easels

arc has a feature called easels, which are basically whiteboards, you can share easels, and this also allows you to get someones user id.

putting it together

this would be the final attack chain:

  • obtain the user id of the victim via one of the mentioned methods
  • create a malicious boost with whatever payload you want on your own account
  • update the boost creatorID field to the targets
  • whenever the victim visits the targeted website, they will get compromised

the browser company normally does not do bug bounties, but for this catastrophic of a vuln, they decided to award me with $2,000 USD

the timeline for the vulnerability:

  • aug 25 5:48pm: got initial contact over signal (encrypted) with arc co-founder hursh
  • aug 25 6:02pm: vulnerability poc executed on hursh's arc account
  • aug 25 6:13pm: added to slack channel after details disclosed over encrypted format
  • aug 26 9:41pm: vulnerability patched, bounty awarded
  • sep 6 7:49pm: cve assigned (CVE-2024-45489)

rce on priviliged pages

while poking around, i saw that boosts actually execute for other protocols aswell (even though you cant create them in the client), so someone could create a boost targeting the page settings, and it would execute on chrome://settings, which allows further escalation of priviliges.

privacy concerns

while researching, i saw some data being sent over to the server, like this query everytime you visit a site:

firebase
  .collection("boosts")
  .where("creatorID", "==", "UvMIUnuxJ2h0E47fmZPpHLisHn12")
  .where("hostPattern", "==", "www.google.com");

the hostPattern being the site you visit, this is against arc's privacy policy which clearly states arc does not know which sites you visit.