Introduction
Hey, have you ever thought about how cool and unique your algorithms are? 😎 A lot of programmers and companies do, which is why they might be hesitant to share their work with everyone. This problem gets a little better if part of the code is moved to the server (for client-server applications), but this approach isn't always possible. Sometimes, we have to leave sensitive code sections right out in the open.
In this article, we're going to take a look at obfuscation in the JavaScript, creating ways to hide algorithms and make it harder to study code. We'll also be exploring what AST is and providing tools that can be used to interact with it to implement obfuscation.
What's the deal?
Here's a silly example. Let's imagine this situation:
- Bob goes to a site that's giving away computer monitors (here it is -> 📺). Bob's monitor is better, but free stuff is always nice!
- When Bob visits the site, JavaScript runs in the browser, collecting data about the user's device and sending it to the server. Let's say this is it:
let w = screen.width, h = screen.height;
// Let's say there's a logic with some check.
console.info(w, h);
Unfortunately, Bob can't access the giveaway page, and he's pretty upset about it. He doesn't understand why. Then he learns in the rules of the giveaway that users with big, good monitors are not allowed.
Luckily, Bob had taken some computer science classes in high school. He opens the developer console decisively by pressing F12, studies the script, and realizes that the organizers check the screen resolution. He then decides to participate from his phone and successfully passes the test.
A fictional story with a happy ending - but it couldn't have been this good if the main character had seen this instead of the previous code:
l=~[];l={___:++l,$$$$:(![]+"")[l],__$:++l,$_$_:(![]+"")[l],_$_:++l,$_$$:({}+"")[l],$$_$:(l[l]+"")[l],_$$:++l,$$$_:(!""+"")[l],$__:++l,$_$:++l,$$__:({}+"")[l],$$_:++l,$$$:++l,$___:++l,$__$:++l};l.$_=(l.$_=l+"")[l.$_$]+(l._$=l.$_[l.__$])+(l.$$=(l.$+"")[l.__$])+((!l)+"")[l._$$]+(l.__=l.$_[l.$$_])+(l.$=(!""+"")[l.__$])+(l._=(!""+"")[l._$_])+l.$_[l.$_$]+l.__+l._$+l.$;l.$$=l.$+(!""+"")[l._$$]+l.__+l._+l.$+l.$$;l.$=(l.___)[l.$_][l.$_];l.$(l.$(l.$$+"\""+(![]+"")[l._$_]+l.$$$_+l.__+"\\"+l.$__+l.___+"\\"+l.__$+l.$$_+l.$$$+"\\"+l.$__+l.___+"=\\"+l.$__+l.___+"\\"+l.__$+l.$$_+l._$$+l.$$__+"\\"+l.__$+l.$$_+l._$_+l.$$$_+l.$$$_+"\\"+l.__$+l.$_$+l.$$_+".\\"+l.__$+l.$$_+l.$$$+"\\"+l.__$+l.$_$+l.__$+l.$$_$+l.__+"\\"+l.__$+l.$_$+l.___+",\\"+l.$__+l.___+"\\"+l.__$+l.$_$+l.___+"\\"+l.$__+l.___+"=\\"+l.$__+l.___+"\\"+l.__$+l.$$_+l._$$+l.$$__+"\\"+l.__$+l.$$_+l._$_+l.$$$_+l.$$$_+"\\"+l.__$+l.$_$+l.$$_+".\\"+l.__$+l.$_$+l.___+l.$$$_+"\\"+l.__$+l.$_$+l.__$+"\\"+l.__$+l.$__+l.$$$+"\\"+l.__$+l.$_$+l.___+l.__+";\\"+l.__$+l._$_+l.$$__+l._$+"\\"+l.__$+l.$_$+l.$$_+"\\"+l.__$+l.$$_+l._$$+l._$+(![]+"")[l._$_]+l.$$$_+".\\"+l.__$+l.$_$+l.__$+"\\"+l.__$+l.$_$+l.$$_+l.$$$$+l._$+"(\\"+l.__$+l.$$_+l.$$$+",\\"+l.$__+l.___+"\\"+l.__$+l.$_$+l.___+");"+"\"")())();
I assure you, it's not gibberish, it's JavaScript! And it performs the same actions. You can try to run the code in the console here.
I guess in this case, our hero would've just accepted his fate by not taking part in the giveaway, and the organizers would've kept their plan.
So what's the point here? Congrats - you've learned about the jjencode tool and what obfuscation is and what role it can play.
In summary, obfuscation is the process of converting program code or data into a form that's hard for humans to understand but still works for a machine or program.
Hiding secrets. Quick way
Enough theories, let's move on to more practical examples 👨🔬. Now let's try to convert the code with the help of obfuscations that you are more likely to find on the Internet. Let's take a more interesting code that contains our “know-how” operations. And it is highly undesirable that everyone who is not too lazy to reach F12 can find out about them:
function getGpuData(){
let cnv = document.createElement("canvas");
let ctx = cnv.getContext("webgl");
const rendererInfo = ctx.getParameter(ctx.RENDERER);
const vendorInfo = ctx.getParameter(ctx.VENDOR);
return [rendererInfo, vendorInfo]
}
function getLanguages(){
return window.navigator.languages;
}
let data = {};
data.gpu = getGpuData();
data.langs = getLanguages();
console.log(JSON.stringify(data))
This code collects device and browser data and outputs the result to the console, for example (we'll use the output as a metric of the code's performance):
{"gpu":["ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar","Mozilla"],"langs":["en-US","en"]}
Now let's take the above code and modify it with a popular obfuscator for JS - obfuscator.io. As a result, we will get a code like this:
function _0x9591(_0x587f42,_0x4b4b1a){const _0x581ade=_0x581a();return _0x9591=function(_0x9591f0,_0x20e0a4){_0x9591f0=_0x9591f0-0x18a;let _0x2b716b=_0x581ade[_0x9591f0];return _0x2b716b;},_0x9591(_0x587f42,_0x4b4b1a);}const _0x5d747e=_0x9591;(function(_0x41cdc1,_0x26e305){const _0xd47419=_0x9591,_0x3c47fc=_0x41cdc1();while(!![]){try{const _0x1c6e63=-parseInt(_0xd47419(0x18c))/0x1*(parseInt(_0xd47419(0x18d))/0x2)+parseInt(_0xd47419(0x18f))/0x3+-parseInt(_0xd47419(0x18b))/0x4+-parseInt(_0xd47419(0x195))/0x5+parseInt(_0xd47419(0x196))/0x6+-parseInt(_0xd47419(0x19e))/0x7*(parseInt(_0xd47419(0x192))/0x8)+parseInt(_0xd47419(0x19a))/0x9;if(_0x1c6e63===_0x26e305)break;else _0x3c47fc['push'](_0x3c47fc['shift']());}catch(_0x2210e4){_0x3c47fc['push'](_0x3c47fc['shift']());}}}(_0x581a,0x5b85c));function _0x59d10d(){const _0x12260c=_0x9591;let _0x14403e=document[_0x12260c(0x197)](_0x12260c(0x191)),_0xf297ee=_0x14403e[_0x12260c(0x19b)](_0x12260c(0x199));const _0x16d7eb=_0xf297ee[_0x12260c(0x19f)](_0xf297ee[_0x12260c(0x198)]),_0x3174f4=_0xf297ee[_0x12260c(0x19f)](_0xf297ee[_0x12260c(0x193)]);return[_0x16d7eb,_0x3174f4];}function _0x157cda(){const _0x52b881=_0x9591;return window[_0x52b881(0x19c)][_0x52b881(0x18a)];}let _0x421797={};_0x421797[_0x5d747e(0x19d)]=_0x59d10d(),_0x421797[_0x5d747e(0x194)]=_0x157cda(),console[_0x5d747e(0x190)](JSON[_0x5d747e(0x18e)](_0x421797));function _0x581a(){const _0x3fdf5e=['webgl','15135525QqurjW','getContext','navigator','gpu','304409xUlnUb','getParameter','languages','1546148RYMKQN','14903JFRqxJ','96TioORm','stringify','817929YcOxtF','log','canvas','80ELkOfJ','VENDOR','langs','3339820dAlRZJ','3751338qfcHSk','createElement','RENDERER'];_0x581a=function(){return _0x3fdf5e;};return _0x581a();}
Voila! Now, only a machine will be happy to parse this code (you and I are probably not among them 🤔). Nevertheless, it still works and produces the same result. Note the changes:
- Line breaks and extra spaces are gone.
- Variable names have been replaced with uninformative ones like
_0x587f42
. - Strings and object properties have been converted into function calls that return their values from an array by index. For example,
document.createElement(“canvas”)
turned intodocument[_0x12260c(0x197)](_0x12260c(0x191))
. This was made possible by using computed properties.
The last technique is perhaps the most nasty in this case, in terms of burdening static code analysis.
Alright, looks like all the secrets are hidden. Shall we deploy the code to production?
Wait... If there are services for code obfuscation, perhaps there are some that can pull this stuff back? Absolutely 😏, and more than one! Let's try to use one of them - webcrack. And see if we can get the original, readable code. Below is the result of using this deobfuscator:
function _0x59d10d() {
let _0x14403e = document.createElement("canvas");
let _0xf297ee = _0x14403e.getContext("webgl");
const _0x16d7eb = _0xf297ee.getParameter(_0xf297ee.RENDERER);
const _0x3174f4 = _0xf297ee.getParameter(_0xf297ee.VENDOR);
return [_0x16d7eb, _0x3174f4];
}
function _0x157cda() {
return window.navigator.languages;
}
let _0x421797 = {};
_0x421797.gpu = _0x59d10d();
_0x421797.langs = _0x157cda();
console.log(JSON.stringify(_0x421797));
Oops 🙊. Of course, it did not return the names of variables, but thanks for that.
So it turns out that the only obstacle to calmly studying our code in this case is the researcher's willpower to use deobfuscator. Undoubtedly, it is also possible to use other solutions and customizations, but for any popular obfuscation, we should most likely expect popular deobfuscation.
Should we despair and give up our secrets without a fight? Of course not! Let's see what more we can do....
How you become an obfuscator
An obfuscator - sounds like some kind of mage from a fantasy universe, doesn't it? 🧙🏻♂️
Definitely, someone can obfuscate code while writing it and is a born magician. You may even have unintentionally been able to cast such spells yourself for a while. But what should you do now if the skills have disappeared thanks to the criticism of “senior programmers” and you have an idea that potentially allows you to make the program difficult to investigate? In this case, it makes sense to turn to tools that interact with the code structure itself and allow you to modify it. Let's take a look at them.
ASTools
It's also possible to try to modify the code by interacting with it simply as with text, replacing certain constructions with regular expressions and so on. But I would say that following this way, you have more chances to ruin your code and time than to obfuscate it.
For more reliable and controlled modification it makes sense to bring it to an abstract structure, a tree (AST - abstract syntax tree), passing through which we can change the elements and constructs we are interested in.
There are different solutions for working with JS code, with differences in the final AST. In this article, we will use babel for this purpose. You don't need to install anything, you can experiment with everything on such resource as astexplorer.
(If you don't want to mess with babel, check out shift-refactor. It allows you to interact with AST using **CSS selectors. Pretty minimalistic and convenient approach for learning and modifying code. But it uses a specific version of AST, different from babel. You can test your CSS queries for this tool at shift-query interactive demo).
0. Working with AST
Now let's see how these tools can be easily used without leaving the browser, based on a simple example. Suppose we need to change the name of the test
variable in the same-named function to changed
:
function test(){ // Not here
let test = "some data"; // Should become changed
let id = "";
console.log(test); // Should become changed
}
test(); // Not here
Paste this code into astexplorer (select JavaScript and @babel/parser from above), it should appear as an AST there. You can click on the test
variable to see the syntax for this code section in the right window:
To solve our problem, we can write the following babel plugin, which will parse our code and look for all names\identifiers in it and rename them if certain conditions are met. Let's paste it into the bottom left window in astexplorer (turn on the transform slider and select babelv7 to make it appear):
function transformCode() {
return {
name: "change-name",
visitor: {
Identifier(path) {
// Output information about the current node and environment
console.log(path)
// We are only interested in the name “test”
// + it should be in the function
if(
path.node.name === "test" &&
path.parent.type === "FunctionDeclaration"
) {
// Rename. This method will take care of all the references
path.scope.rename(path.node.name, "changed")
}
},
},
};
}
module.exports = transformCode;
Console output is included in this plugin for a reason. This allows us to debug our plugin by examining the output in the browser console. In this case, we output information about all nodes of Identifier
type. This information contains data about the node itself (node
), the parent node (parent
), and the environment (scope
- contains variables created in the current context and references to them):
Thus, in the bottom right window, we can notice that the variable in our source code has been successfully changed without affecting other identifiers:
function test() {
let changed = "some data"; // <-
let id = "";
console.log(changed); // <-
}
I hope, based on this example, it became a little clearer how we can parse and modify the code. Anyway, let me summarize the work done:
- We converted the code to AST using babel via astexplorer.
- By examining the AST, we saw that the
test
variable is labeled with theIdentifier
type, the name of which can be defined using thename
property. - Next, using our babel plugin, we bypassed all the identifiers and changed the name of those in the function with the name
test
tochanged
.
1. Hiding function and variable names
It is clear now how code modification can be done. Let's try something more useful, which we will be able to call obfuscation :) We'll take a more complex code we tried to obfuscate in the previous section. Now we'll change all the names of variables and functions in it to random ones. So, a potential reverse engineer would have less info about the purpose of some code elements.
Also, feel free to use any JS code to debug problems. As they say, there's no better teacher than pain 🥲.
The following plugin will help us to get the job done:
// Storage with used identifier names
const usedIdentifiers = new Set();
// Generates a random string from the `characters` alphabet up to 3 chars long
// The generated string must be unique (not previously used by identifiers)
function generateRndName() {
const characters = "abcdefghijklmnopqrstuvwxyz_";
let randomIdentifier = "";
do {
const length = Math.floor(Math.random() * 4);
for (let i = 0; i <= length; i++) {
randomIdentifier += characters.charAt(
Math.floor(Math.random() * characters.length)
);
}
} while (usedIdentifiers.has(randomIdentifier));
usedIdentifiers.add(randomIdentifier);
return randomIdentifier;
}
// Go through all nodes of `Identifier` type
// Change their names to random ones along with all references
function transformCode() {
return {
name: "hide-names",
visitor: {
Identifier(path) {
path.scope.rename(path.node.name, generateRndName());
},
},
};
}
module.exports = transformCode;
What does this code do? Pretty much the same as in the previous example:
- We pass through all AST nodes of
Identifier
type; - This time, we use the
generateRndName
function to randomly generate names for identifiers without any conditions; - Generating unique names guarantees that we will not randomly get a duplicate name that may break the program logic.
As a result of our plugin execution, we get the following code with random variables names and functions:
function hjj() {
let bq = document.createElement("canvas");
let c = bq.getContext("webgl");
const a_x = c.getParameter(c.RENDERER);
const nry = c.getParameter(c.VENDOR);
return [a_x, nry];
}
function sztc() {
return window.navigator.languages;
}
let _o = {};
_o.gpu = hjj();
_o.langs = sztc();
console.log(JSON.stringify(_o));
You can check it by executing the code in console - after our manipulations, it still works! And this is the main quality of a good obfuscator ✨.
But what about the quality of our obfuscation? As for me - the evil is not too strong yet: even by replacing the names, it will be easy for an experienced programmer to understand the purpose of this code. And what's the point if any JS minifier can handle this task. Is it possible now to do something more practical and troublesome for a reverser? There is one more spell...
2. Hide 🪄 everything!
I may have been a bit confident when I wrote “everything”, but what we are going to do now will hide the actions of our code to the maximum extent possible. In this section, we will conceal strings and various object properties in order to complicate static analysis and potentially prevent the “client” from digging into our code!
Let's take the code with hidden names obtained at the previous stage and apply the following plugin to it:
function transformCode(babel) {
const { types: t } = babel;
// All of our properties/strings to be replaced in the code will be here.
let data = [];
return {
name: "hide-props-strings",
visitor: {
//1. Find the `Program` root node
// And insert into the beginning of the code a function that returns string properties by index
Program(path) {
// Body of the created function
let funcBody = t.blockStatement([
// Declare the variable that stores the string properties
// let data = [...]
t.variableDeclaration("let", [
t.variableDeclarator(t.identifier("data"), t.arrayExpression(data)),
]),
// return data before decoding it from base64
// return atob(data[data_index])
t.returnStatement(
t.callExpression(t.identifier("atob"), [
t.memberExpression(
t.identifier("data"),
t.identifier("data_index"),
true
),
])
),
]);
// Create a `getData` function with 1 argument `data_index`
let func = t.functionDeclaration(
t.identifier("getData"),
[t.identifier("data_index")],
funcBody
);
// Insert the function at the beginning
path.node.body.unshift(func);
},
// 2. Bypass nodes of type `MemberExpression`. Replace properties with `getData` calls
// For example `document.createElement` will be `document[getData(0)]]
MemberExpression({ node }) {
// Avoid nodes that have already been “checked out” and where the `data_index` property is present, so as not to affect the new `getData` function.
let prop = node.property.name;
if (node.computed) return;
if (prop == "data_index") return;
// Put this property into the “storage” in `getData`
data.push(t.stringLiteral(btoa(prop)));
// Replace the property with a `getData` function call with the appropriate index
node.property = t.callExpression(t.identifier("getData"), [
t.numericLiteral(data.length - 1),
]);
// Make the property computable
node.computed = true;
},
// 3. Bypass nodes of `StringLiteral` type. Replacing strings with `getData` calls
StringLiteral(path) {
// Put the string into the “storage” in `getData`
data.push(t.stringLiteral(btoa(path.node.value)));
// Create a call to the `getData` function with the appropriate index
const c = t.callExpression(t.identifier("getData"), [
t.numericLiteral(data.length - 1),
]);
// Replace this node with a newly created node
path.replaceWith(c);
},
},
};
}
module.exports = transformCode;
I have already described a little bit the work of this plugin in the code comments, but let's briefly describe step by step what it does:
- We create an array
data
in which will store all properties and strings to be replaced in the code. This array will be used in thegetData
function that returns our data; - Next, we traverse the AST and find the root node
Program
, using which thegetData
function (returns properties and strings at a given index) will be inserted at the beginning of our code; - Then we bypass the nodes of type
MemberExpression
. We replace properties with calls to thegetData
function. In this case, constructs likedocument.createElement
will be turned intodocument[getData(0)]
, thanks to calculated properties. Along the way, we put the names of the properties into thedata
array; - Finally, we bypass nodes of type
StringLiteral
, where we also replace strings with a call togetData
with the desired index.
It is worth mentioning that parsing operations are not performed sequentially, but as the necessary node is found during AST processing.
As a result of executing this plugin, we will get the following code:
function getData(data_index) {
let data = ["Y3JlYXRlRWxlbWVudA==", "Y2FudmFz", "Z2V0Q29udGV4dA==", "d2ViZ2w=", "Z2V0UGFyYW1ldGVy", "UkVOREVSRVI=", "Z2V0UGFyYW1ldGVy", "VkVORE9S", "bGFuZ3VhZ2Vz", "bmF2aWdhdG9y", "Z3B1", "bGFuZ3M=", "bG9n", "c3RyaW5naWZ5"];
return atob(data[data_index]);
}
function hjj() {
let bq = document[getData(0)](getData(1));
let c = bq[getData(2)](getData(3));
const a_x = c[getData(4)](c[getData(5)]);
const nry = c[getData(6)](c[getData(7)]);
return [a_x, nry];
}
function sztc() {
return window[getData(9)][getData(8)];
}
let _o = {};
_o[getData(10)] = hjj();
_o[getData(11)] = sztc();
console[getData(12)](JSON[getData(13)](_o));
As you can see from the resulting code, all properties have been replaced by getData
function calls with a given index. We did the same thing with strings and started to get them through function calls. The property names and strings themselves were encoded with base64
to make them more difficult to notice...
I guess you have already noticed - this plugin, and the code in general, has flaws at this stage. For example, the following things could be corrected:
- The functions returning our properties and strings scream about their purpose -
getData
. But the good thing is that this point can be corrected by applying our first plugin, which renames identifiers. - The strings themselves inside the
getData
function are not reliably protected, it is quite easy to find their initial values, because it is onlybase64
. It is more challenging to solve this problem, for example, you can remake thegetData
function and apply encryption instead of the well-known encoding. - The
getData
function is the only one, and it is not difficult to write a script that will replace all its calls with the original value by pulling and executing the function itself.
Despite all this simplicity and downsides, I think it can already be called obfuscation. But then again, how do we differ from the open-source obfuscators, since they do similar things?
We've got to remember the original problem — those obfuscations were a piece of cake for public deobfuscators. Now, let's take that code we got and deobfuscate it in webcrack! (hopefully it still can't tackle our spell🤞). I guess you could say the practical importance has been achieved - our “protected” code can no longer be pulled back in one click via a public deobfuscator
Bonus. Deobfuscation
Now let's learn a brand-new spell. Although public deobfuscators are not able to handle our plugins, however, having studied the actual concept of our obfuscation we can notice some patterns that can be used to restore the source code.
Let's get into it, and specifically take advantage of:
- Having a single invoked function that stores our properties and strings;
- The calls to this function are not masked;
Given these disadvantages, we can implement the following plugin:
// Name of the function, which retrieves properties and strings
let functionName = "getData"
// Function copied from the obfuscated code
function getData_copy(data_index) {
let data = ["Y3JlYXRlRWxlbWVudA==", "Y2FudmFz", "Z2V0Q29udGV4dA==", "d2ViZ2w=", "Z2V0UGFyYW1ldGVy", "UkVOREVSRVI=", "Z2V0UGFyYW1ldGVy", "VkVORE9S", "bGFuZ3VhZ2Vz", "bmF2aWdhdG9y", "Z3B1", "bGFuZ3M=", "bG9n", "c3RyaW5naWZ5"];
return atob(data[data_index]);
}
function transformCode(babel) {
const { types: t } = babel;
return {
name: "deobf-str-props",
visitor: {
// 1. remove the `getData` function from the code
FunctionDeclaration(path){
if(path.node.id.name !== functionName) return
path.remove()
},
// 2. Go through all calls with the name `getData`
// Call the copied function with the current argument
// Replace the call with the obtained result
CallExpression(path) {
if(path.node.callee.name !== functionName) return
let index = path.node.arguments[0].value
let str = t.stringLiteral(getData_copy(index))
path.replaceWith(str)
}
},
};
}
module.exports = transformCode;
Let's describe the functionality of this deobfuscation plugin:
- We copied the
getData
function from the obfuscated code, by executing it with the required argument (index) we can get the required string; - We went through all the
getData
function calls and replaced them with the result of its execution; - Finally, we found the
getData
function in AST and removed it from the code, since it is no longer needed there.
As a result, we get the following code:
function hjj() {
let bq = document["createElement"]("canvas");
let c = bq["getContext"]("webgl");
const a_x = c["getParameter"](c["RENDERER"]);
const nry = c["getParameter"](c["VENDOR"]);
return [a_x, nry];
}
function sztc() {
return window["navigator"]["languages"];
}
let _o = {};
_o["gpu"] = hjj();
_o["langs"] = sztc();
console["log"](JSON["stringify"](_o));
Thus, we were able to get rid of obfuscation that hides properties and strings by writing a simple plugin for babel using the shown disadvantages.
I hope this small example explained how you can fight such nuisances with the help of babel. Using these approaches, you can also solve more complex obfuscations - the main thing is to find patterns in the code and skillfully operate with AST.
Conclusion
We've learned about obfuscation, a technique that complicates code reverse engineering, and the tools to implement it. Although there are public solutions that obfuscate JavaScript code, there are just as many public solutions that can remove this protection in an instant.
Therefore, you need to write your own solutions to protect code that can't be removed by public deobfuscators. One reliable way to implement obfuscation in JS is to write custom babel plugins that interact with the AST of the desired code, turning it into a less readable form.
Of course, this area has known techniques and approaches to obfuscation, but nevertheless remains open to creativity and new “tricks” that can potentially make learning the code more difficult. Despite the large number of such techniques, they do not guarantee the secrecy of algorithms at all, because code is always “in the hands” of the client. Besides, there is a possibility of debugging, which can make it easier to study the code. Obfuscation allows you to rather turn away poorly motivated researchers, thus increasing the cost of reverse engineering.
There are some advanced approaches, for example, one of them among obfuscation is virtualization of code, or simply speaking, creating a virtual machine in JS that will execute custom bytecode. This approach almost completely removes the chance of static analysis and makes debugging as difficult as possible. However, this is a separate subject for discussion 🍺....
I hope it was useful for you to get information on this topic, and you won't blame yourself or your programmers for initially obfuscated code anymore. Appreciate these wizards 🧙🏻♀️! I will be glad to discuss with you the latest trends in magic here😏
Top comments (0)