Stolen accounts in the blue, blue skies
Using the Bluesky firehose to monitor the growth of a network of repurposed accounts
A recent article on this blog discussed a network of Bluesky accounts that appear to be have been hijacked from legitimate users for spam purposes, with their profiles updated to include recycled biographies. In the ensuing time, additional accounts have been added to the network, and the pool of repeated biographies has grown. While the original set of accounts all masqueraded as U.S. liberals, some of the more recently repurposed accounts instead use a pro-Trump biography, “I am a woman from Asia. I am a big fan of Trump and I like the political stance of the Republican Party.”. By monitoring the Bluesky firehose for profile updates, one can detect the exact point in time when the accounts’ biographies and display names changed.
import atproto
import atproto_firehose as hose
from atproto_firehose.models import MessageFrame
from atproto_client.models import get_or_create
import json
import pandas as pd
import time
import warnings
warnings.filterwarnings ("ignore")
def retry (method, params):
retries = 5
delay = 1
while retries > 0:
try:
r = method (params)
return r
except:
print (" error, sleeping " + str (delay) + "s")
time.sleep (delay)
delay = delay * 2
retries = retries - 1
return None
def get_profiles (actors, client):
profiles = []
while len (actors) > 0:
if len (actors) > 25:
batch = actors[:25]
actors = actors[25:]
else:
batch = actors
actors = []
r = retry (client.app.bsky.actor.get_profiles,
{"actors" : batch})
profiles.extend (r.profiles)
return profiles
# not all message types are used in this example
def on_message (message, test_function, handler):
message = hose.parse_subscribe_repos_message (message)
if isinstance (message,
atproto.models.ComAtprotoSyncSubscribeRepos.Commit):
blocks = atproto.CAR.from_bytes (message.blocks).blocks
for op in message.ops:
uri = atproto.AtUri.from_str ("at://" + message.repo \
+ "/" + op.path)
raw = blocks.get (op.cid)
if raw:
record = get_or_create (raw, strict=False)
if record is not None and \
record.py_type is not None:
rdict = record.model_dump ()
item = {
"repo" : message.repo,
"revision" : message.rev,
"sequence" : message.seq,
"timestamp" : message.time,
"action" : op.action,
"cid" : str (op.cid),
"path" : op.path,
"collection" : uri.collection,
"record" : rdict,
"type" : "commit"
}
if test_function (item):
handler (item)
elif isinstance (message,
atproto.models.ComAtprotoSyncSubscribeRepos.Identity):
item = {
"did" : message.did,
"sequence" : message.seq,
"timestamp" : message.time,
"handle" : message.handle,
"type" : "handle"
}
if test_function (item):
handler (item)
elif isinstance (message,
atproto.models.ComAtprotoSyncSubscribeRepos.Account):
item = {
"did" : message.did,
"sequence" : message.seq,
"timestamp" : message.time,
"active" : message.active,
"status" : message.status,
"type" : "account"
}
if test_function (item):
handler (item)
def monitor_bsky_firehose (test_function, handler):
firehose = hose.FirehoseSubscribeReposClient ({"cursor" : 0})
while True:
try:
print ("connecting to firehose...")
firehose.start (lambda message: on_message (message,
test_function, handler))
except:
print ("firehose error, sleeping 20s...")
time.sleep (20)
def is_profile_update (item):
return item["type"] == "commit" and \
item["collection"] == "app.bsky.actor.profile" and \
item["path"] == "app.bsky.actor.profile/self"
def to_profile_update (d):
r = d["record"]
return {
"did" : d["repo"],
"revision" : d["revision"],
"sequence" : d["sequence"],
"timestamp" : d["timestamp"],
"created_at" : r["created_at"],
"description" : "" if r["description"] is None else \
r["description"].strip (),
"display_name" : "" if r["display_name"] is None else \
r["display_name"].strip ()
}
def monitor_firehose (item):
if is_profile_update (item):
profile_queue.append (to_profile_update (item))
if len (profile_queue) % 1000 == 0:
df = pd.DataFrame (profile_queue)
dids = list (set (df["did"]))
profiles = pd.DataFrame ([p.model_dump () for p in get_profiles (dids, client)])
df = df.merge (profiles, on="did", how="left")
df.to_csv ("bsky_renames/profile-updates-" + \
str (time.time ()) + ".csv", index=False)
profile_queue.clear ()
client = atproto.Client ()
client.login ("******","******")
profile_queue = []
monitor_bsky_firehose (lambda x: True, monitor_firehose)
The above Python code uses the atproto module to monitor the Bluesky firehose for profile updates, which include changes to biographies and display names, and save them in batches to the local filesystem. These updates include the timestamp at which the profile was changed, which allows one to differentiate between new accounts in the process of being set up, and established accounts that may have been taken over or otherwise repurposed. For the sake of this experiment, the firehose was monitored for roughly 48 hours, with brief gaps in monitoring.
After manually removing false positives, this monitoring process resulted in 36 recently repurposed accounts with seven distinct repeated biographies. The profile changes generally happen in batches, with multiple accounts being switched to the same repeated biography in the span of a few minutes. Most of the biographies express support for the Democratic Party and/or opposition to Donald Trump and the MAGA movement, although four of the accounts use a repeated biography expressing the opposite political stance. Several of the repeated biographies mention a distaste for cryptocurrency and porn.
Searching for additional accounts with the same biographies broadens the set to 50 accounts with creation dates spanning the last two years. (It is likely that the network is actually larger, and includes groups of accounts with biographies that did not appear during the period of time monitored.) A quick review of the accounts’ older posts indicates that they were at one point a mix of random personal, commentary, and business accounts with no obvious similarities or apparent connection to one another; the sudden makeovers and adoption of duplicate biographies strongly suggests that the accounts have all been taken over by the same entity.
Adding weight to the theory that the accounts in the network are hijacked, several of the accounts with the biography “I am a woman from Asia. I am a big fan of Trump and I like the political stance of the Republican Party.” were reposting anti-Trump posts just prior to the biography change. Additionally, quick perusal of Wayback Machine turns up archives of the original states of several of the accounts, showing that they originally had different purposes and presented as different people. The obvious explanation is that the accounts are no longer in the hands of their original owners.
As is frequently the case with spam networks, these repurposed accounts use stolen profile images, many of which have frequently appeared on other social media platforms. The plagiarized photographs are generally outdoor photos of random women; golf and beaches are recurring themes. In a departure from recent experiences with reverse image search tools, Google was more effective at finding previous uses of the photographs than TinEye.
Sad truth: most accounts on Bluesky are bots. Nobody actually uses that app. Everyone on it still has X/Twitter or made a ghost account during their clown protest "I refuse to be here" - then why do they keep logging in? I figured Bluesky wasn't gonna go far. They immediately ban the right and the left echo chambers til they get bored and go to Twitter.