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 (update: see at the end of post), 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.
update
in light of these vulnerabilities and to introduce new features arc is switching off of firebase. additionally, arc has published their own write-up addressing these issues
a tldr version would be:
- confirming they had fixed the issue
- they are adding a feature to disable boosts in the client, preventing this vulnerability from happening on people that do not use boosts
- they are doing an audit of their current firebase ACL rules internally
- they have estabilished proper protocols for security issues
additionally, from internal discussions with arc they are also:
- are fixing the mentioned privacy concerns in the v1.61.1 update
- moving off firebase for new features and products
- they are doing a external security audit for this version
- are starting a bug bounty program for further vulnerabilities