A full technical audit of a coordinated follower inflation network — methodology, findings, and a detection rule simple enough to run in one query.
On May 19, 2026, I published "Found a Coordinated GitHub Follow Botnet" — a piece documenting a coordinated follower inflation network on GitHub. The next day, my DEV.to follower count started climbing.
Fast.
| Date | New Followers |
|---|---|
| May 19 | 75 |
| May 20 | 288 |
| May 21 | 447 |
| May 22 | 399 |
| May 23 | 311+ |
From ~600 followers to 3,045 as of May 24 — and still climbing. Not from a viral post. Not from a mention by a big account. The deployment timing closely followed publication of the article.
So I audited every single one.
TL;DR: 897 of my new followers match a coordinated inauthentic behavior pattern — warehoused accounts deployed from a commercial engagement marketplace called upvote.club ($0.90/follow). The mechanism is a Chrome extension with surveillance-grade permissions that dispatches follow tasks to real human operators. I captured the full task protocol live, reverse-engineered the HMAC signing formula, and confirmed the API is fully replayable without a browser. Everything is reproducible from the repo.
The short version of what I found: every account in the audited follower cohort was following exactly one person — me. Not two. Not ten. One. Across all 1,409 accounts, across four separate account creation waves spanning six months, the Following count was uniformly 1. That's not a heuristic suspicion. That's a graph signature. The rest of this post is the full methodology showing how I got there.
The Setup
# Environment
# Pop!_OS, Python 3.12, venv
pip install requests Pillow imagehash
export DEVTO_API_KEY='your_key'
The full codebase lives at github.com/GnomeMan4201/devto-botnet-hunter. Here's the methodology end-to-end.
Step 1: Pull Every Follower
DEV.to's public API exposes your follower list. Paginate through it and store everything:
import requests, time
API_KEY = 'your_devto_api_key'
BASE = 'https://dev.to/api'
def get_all_followers():
followers = []
page = 1
while True:
resp = requests.get(
f'{BASE}/followers/users',
headers={'api-key': API_KEY},
params={'page': page, 'per_page': 1000},
)
data = resp.json()
if not data:
break
followers.extend(data)
page += 1
time.sleep(0.25)
return followers
Then for each follower, fetch their full profile:
def get_profile(username):
resp = requests.get(
f'{BASE}/users/by_username',
headers={'api-key': API_KEY},
params={'url': username},
timeout=15,
)
return resp.json() if resp.status_code == 200 else None
Total audited: 1,409 followers.
Step 2: Score Each Account
Note on username patterns: S3 ID analysis reveals the operator runs three username generators simultaneously —
firstname_lastname_[random_hex](458 accounts), short simple handles likemousefilter,johnmaveric(295 accounts), and creative phrase handles likelawn_meower,just_404_fun,nova_123(156 accounts). All three styles cluster in the same S3 ID range (3.4M–3.94M), confirming they were created in the same waves. The mixed naming is consistent with deliberate obfuscation — varying username style across three distinct patterns reduces detectability against any single regex-based classifier.
Seven heuristic signals, each worth 1 point. Score ≥ 3 = flagged:
import re # move to top of your script
def score_account(profile):
score = 0
reasons = []
username = profile.get('username', '')
followers = profile.get('followers_count', 0)
following = profile.get('following_count', 0)
articles = profile.get('public_articles_count', 0)
bio = profile.get('summary', '') or ''
avatar = profile.get('profile_image', '')
if re.search(r'_[a-f0-9]{6,}$', username):
score += 1; reasons.append('hash_username')
if not bio.strip():
score += 1; reasons.append('empty_bio')
if articles == 0:
score += 1; reasons.append('zero_articles')
if avatar.endswith('default_profile_image.png'):
score += 1; reasons.append('default_avatar')
if following == 1:
score += 1; reasons.append('following_one') # Step 3 shows this is 100% across all 1,409 — treated here as one signal among six
if followers == 0:
score += 1; reasons.append('zero_followers')
return score, reasons
Results across 1,409 accounts:
| Tier | Count |
|---|---|
| High-confidence coordinated pattern match (score ≥ 3) | 897 (63.7%) |
| Low-confidence / insufficient evidence (score 1–2) | 510 (36.2%) |
| Clear indicators of sustained organic participation (posting, commenting, multi-account follow graph, or profile customization) | 0 |
Every account in the observed cohort scored suspicious to some degree. None showed strong indicators of sustained organic participation such as posting history, meaningful social graph expansion, or long-term engagement activity. That said, heuristic scoring indicates a pattern, not a proven fact — some dormant real users can superficially resemble low-effort inauthentic accounts. Two accounts (@leob, S3 ID 28,704; @bah123, S3 ID 2,707,292) were removed from the flagged list after S3 ID analysis confirmed their creation predates the operator network by years — legitimate dormant accounts swept in by heuristic scoring. What makes this case different is what came next.
Step 3: The Following=1 Discovery
While reviewing the scored data, I checked the Following field distribution across all 1,409 accounts:
from collections import Counter
import csv
with open('devto_bot_audit_full.csv') as f:
rows = list(csv.DictReader(f))
dist = Counter(r.get('Following', '0') for r in rows)
for val, cnt in sorted(dist.items(), key=lambda x: -x[1]):
print(f'Following={val}: {cnt} accounts')
Output:
Following=1: 1409 accounts
Because these accounts appeared in my follower list, a following_count of 1 implies their sole outbound follow edge points to this account. The entire audited spike cohort. All 1,409. Following exactly one person: me.
At that point the investigation stopped being heuristic classification and became graph-pattern detection.
The core invariant: every account in the dataset collapses to a single outgoing follow edge. That is the structural fact from which everything else follows.
The statistical argument does not require a baseline distribution. The anomaly is not that Following=1 is rare on DEV.to — it's that 1,409 accounts independently arrived at the same single target, in synchronized waves, with no other activity. Even without assuming a baseline distribution, the observed convergence of 1,409 independently created accounts onto a single outbound follow edge with no other social activity represents an extreme structural anomaly inconsistent with ordinary organic growth patterns.
A real user who follows only one account is plausible. A thousand accounts — each independently created, each with different usernames, different join dates, different avatars — all following exactly one person, with zero other activity? That's not a coincidence. That's consistent with a follower-inflation deployment pattern rather than organic social behavior — pure follower-count inflation with no engagement attached.
This is a candidate-generation filter — not enforcement logic. Matching accounts should be reviewed, not automatically actioned. With that framing clear:
-- Triage filter for coordinated follower investigation
-- Produces candidates for review, not a ban list
-- joined_at threshold is dataset-specific — adjust to your spike window
-- remove entirely to scan full follower base regardless of join date
SELECT username FROM users
WHERE following_count = 1
AND public_articles_count = 0
AND comments_count = 0
AND joined_at >= '2025-11-01' -- adjust or remove for your context
On large platforms, this query will surface dormant newcomers, abandoned accounts, and legitimate lurkers alongside coordinated accounts — expected false positives at scale. The value is not precision enforcement but rapid candidate generation: every account in this network would appear in that result set, making it an extremely effective first pass for a coordinated-follower investigation.
Step 4: Batch Creation Waves
Join dates cluster in ways that organic growth doesn't. I parsed the JoinedDate field across the flagged accounts:
from collections import Counter
dates = Counter()
for r in rows:
d = r.get('JoinedDate', '')
if d:
dates[d[:6].strip()] += 1
for date, count in sorted(dates.items(), key=lambda x: -x[1])[:10]:
print(f'{date}: {count} accounts')
Four distinct creation waves:
| Wave | Period | Accounts | Notes |
|---|---|---|---|
| November 2025 | Nov 13–19, 2025 | 218 high-confidence / 339 full cohort | Dormant 187 days before activation |
| January 2026 | Jan 2026 | 17 | Small batch |
| April 2026 | Apr 2026 | 92 | Mid-size batch |
| May 2026 | May 13–19, 2026 | 615 | Active deployment wave |
194 accounts were created on a single day — May 14, 2026. Organic user creation typically disperses over time rather than concentrating nearly 200 accounts onto a single day within a tightly linked cohort. That single-day concentration represents roughly 13% of the entire audited dataset arriving in one 24-hour window.
Step 5: S3 User ID Sequencing
DEV.to avatar URLs route through a CDN proxy that URL-encodes the original S3 path. Decoding them reveals the underlying user ID — a monotonically increasing integer that reflects account creation order:
import urllib.parse, re
def extract_s3_id(avatar_url):
"""
Input: https://media2.dev.to/dynamic/image/.../
https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2F
uploads%2Fuser%2Fprofile_image%2F3611242%2F...
Output: 3611242
"""
decoded = urllib.parse.unquote(avatar_url)
m = re.search(r'/profile_image/(\d+)/', decoded)
return int(m.group(1)) if m else None
The November 2025 wave extracted to:
S3 ID range : 3,610,947 → 3,619,885
Span : 8,938 IDs across 5 days
Gaps : 0 significant sequence breaks
218 accounts spread across a span of 8,938 sequential IDs with no significant gaps. The pattern is consistent with accounts created in a single continuous run. The May 2026 wave begins around ID ~3,940,000. The ~320,000 ID gap between the two waves over 6 months tracks with DEV.to's organic signup rate, giving this sequencing value as a rough timestamp proxy for future attribution work.
Step 6: The 187-Day Dormancy
The November 2025 cohort (218 high-confidence flagged accounts, S3 IDs 3,610,947–3,619,885) was created November 13–14.
Two numbers matter here: 218 is the high-confidence flagged subset (score ≥ 3); 339 is all audited accounts with a November join date, including the lower-confidence suspicious tier. I checked Following across all 339 — both tiers:
nov_bots = [r for r in rows if r.get('JoinedDate','').startswith('Nov')]
following = Counter(r.get('Following','0') for r in nov_bots)
print(following)
# Counter({'1': 339})
All 339 November accounts in the observed cohort — high-confidence and suspicious tier alike — had Following=1, pointing at me. The behavioral uniformity holds across both scoring tiers.
The evidence is consistent with a warehoused aged-account inventory: accounts from multiple cohorts manufactured in bulk, held dormant to accumulate age, then deployed on demand. Alternative explanations — accounts created speculatively and abandoned, or purchased in bulk from a separate supplier — cannot be ruled out from observable data alone. The warehousing hypothesis is the most parsimonious fit for the observed batch structure and synchronized activation.
Aged accounts are more valuable to follower inflation services because they appear to have existed before the deployment event. A November 2025 account following you in May 2026 looks 6 months old to a casual observer.
The timing is consistent with a deployment event temporally associated with the publication of the botnet article. I don't have access to purchase records or session logs — only DEV.to's internal telemetry could confirm that directly. But the behavioral evidence is consistent with an on-demand fulfillment event: pre-staged inventory activated in response to a specific trigger.
Step 7: Avatar Fingerprinting
Most accounts that never uploaded a custom avatar (821 of 895) ended up with DEV.to's default letter placeholder — a 96×96 colored square. Not useful for fingerprinting. But the remaining 74 accounts used real custom images, and those tell a different story.
from PIL import Image
import imagehash, hashlib
from pathlib import Path
def fingerprint(path):
img = Image.open(path)
return {
'md5': hashlib.md5(path.read_bytes()).hexdigest(),
'phash': str(imagehash.phash(img)),
'mode': img.mode,
'size': img.size,
}
Three distinct layers of evidence from the image analysis:
Exact duplicates (same MD5 hash): 55 groups across 131 accounts. Different usernames, different join dates, same bytes. Large-scale exact avatar duplication across otherwise unrelated accounts is difficult to explain organically.
Perceptual similarity clusters (pHash distance ≤ 10): 56 clusters. Images that aren't identical but are visually close — same style, same source material, minor encoding differences from re-uploads.
Stylistic provenance: All 74 real illustrations share a consistent aesthetic — black crosshatch/stipple engravings on transparent backgrounds, natural history subjects (insects, fish, bears, mushrooms). Classic 19th century scientific illustration style. Most accounts used default avatars; the custom-avatar subset exhibited repeated reuse patterns and shared artistic provenance pointing to a single asset source. Independent organic users rarely converge on the same narrow set of obscure public-domain engravings across dozens of otherwise unrelated accounts — the convergence here is consistent with shared asset sourcing rather than independent selection.
Step 8: Tracing the Asset Source
The bear engraving was the most distinctive image — used by @machatter1 and @minakshisrivastava001 among others. I converted it to PNG and ran it through Google Lens.
Two source hits:
DepositPhotos — "American Black bear (Ursus americanus), vintage engraving — Vector", uploaded September 12, 2011.
ClipArt ETC (Florida Center for Instructional Technology) — etc.usf.edu/clipart/2100/2134/grizzly-bear_1.htm — a free public domain archive maintained by the University of South Florida, organized by taxonomy: Animals → Mammals → Bears, with equivalent galleries for fish, insects, birds, reptiles, fungi, and marine invertebrates.
Lens also returned the same bear image appearing as a DEV.to profile avatar going back to December 2023 — across multiple unrelated accounts in what appear to be separate campaigns. The same asset library has been in use for at least 2.5 years across multiple deployments.
Visual survey of the 74 illustration avatars confirms subjects drawn from across the ClipArt ETC natural history collection: bear, grizzly bear, fish, mushroom, chameleon, pelican, horse, death's-head hawk moth, axolotl, deer/stag, jellyfish, stink bug, bat, and fly. The avatar set spans multiple ETC galleries rather than a single narrow source category, suggesting deliberate curation of a varied image pool rather than a bulk download. The selection covers 6+ taxonomic categories — bears, fish, insects, fungi, birds, reptiles, marine invertebrates — a breadth inconsistent with incidental or random asset selection.
No paid pack. No proprietary license to protect. Entirely public domain, EXIF metadata stripped, deployed across accounts and campaigns spanning at least 2.5 years.
Step 9: The Marketplace
With the behavioral signatures mapped, the next question is where this service is sold.
A Google search for "buy DEV.to followers" surfaces an active commercial listing at upvote.club — a points-exchange engagement marketplace that explicitly sells DEV.to followers at $0.90 per follow, with 24-hour delivery. The same platform sells GitHub followers, and also sells downvotes on Hacker News and Indie Hackers — extending the service from follower inflation into active content suppression. That cross-platform coverage directly matches the infrastructure pattern in this investigation: separate account pools per platform, coordinated deployment.
The platform operates on a community points model: users register, earn points by completing follow tasks for others, and spend points to receive follows back. Paid tiers let buyers purchase points directly. New accounts receive 13 free points on registration — an incentive structure that encourages bulk account creation.
Live task queue: An authenticated API call to api.upvote.club/api/tasks1/ during active investigation returned 217 available tasks across 14 platforms — with 3 active DEV.to orders in the queue. The cross-platform breakdown at time of capture:
| Platform | Active Tasks |
|---|---|
| Quora | 52 |
| GitHub | 51 |
| 34 | |
| 12 | |
| 10 | |
| Substack | 10 |
| DEV.to | 3 |
| Others | 45 |
DEV.to manipulation was actively in progress at the time of investigation.
On the "real users" question: These accounts are not bots in the traditional sense — they are real human-operated accounts whose owners installed a browser extension and completed one follow task for points. The distinction matters operationally: the behavior is coordinated and inauthentic, but the mechanism is human-mediated rather than fully automated. This is precisely why the behavioral signature (Following=1, zero engagement, zero content) is so uniform — the extension dispatches a single action per task assignment and stops. Real organic users don't behave this way at scale; real users completing paid micro-tasks do.
This model explains every behavioral signature the audit detected:
- Following=1 — accounts complete one follow task (the target) and stop. Task fulfilled, points spent.
- 187-day dormancy — the November accounts weren't sitting idle. They were likely earning points by completing follow tasks across the network for six months before being redeemed against this account.
- Batch creation waves — bulk account registration maximizes free starting points. 218 accounts × 13 free points = 2,834 free points on signup alone.
- Zero engagement beyond the follow — task completion, not organic interest. The follow is the deliverable.
- Synchronized activation — a single purchase order pointing all available inventory at one target simultaneously.
At the listed $0.90 per follow rate, the ~920 accounts that followed this account during the spike would cost roughly ~$828 at full price — illustrative math based on public pricing, not a confirmed transaction record.
Platform infrastructure: Authentication for upvote.club is handled by Firebase (upvote-club.firebaseapp.com), confirmed via the Google OAuth consent screen during account creation. This means the platform's user database, authentication tokens, and session management run on Google Cloud infrastructure.
Membership scale discrepancy: upvote.club's marketing claims "50K+ Members." The authenticated API response tells a different story:
"community_rank": {
"rank": 4127,
"total_users": 4126,
"completed_tasks": 0
}
A new account registered in May 2026 received rank 4,127 out of 4,126 total users — an active user base of roughly 4,100, not 50,000. User IDs in the 79,000 range suggest approximately 79,000 accounts have been created historically, but the vast majority are inactive or abandoned. The "50K+ Members" claim overstates the active community by more than 12x.
Important framing note: This analysis identifies upvote.club as a marketplace whose model and pricing are consistent with the deployment pattern observed. I don't have access to purchase records, account registration logs, or payment data — only DEV.to's backend telemetry could confirm which specific service was used. What the behavioral evidence supports is this: the accounts behave exactly as task-completion accounts from a points-exchange follower service would behave, and upvote.club is an active, public-facing service matching that profile for DEV.to and GitHub simultaneously. The marketplace, warehousing behavior, and extension infrastructure are treated here as converging systems consistent with a unified operation — not as a confirmed single-operator pipeline.
Step 10: The Infrastructure Behind the Network
With the marketplace identified, I downloaded and decompiled the upvote.club Chrome extension (ID: fkiaohmeeoiipoknngcppjbkinaamnof, version 1.1.26) directly from the Chrome Web Store to understand how task verification actually works.
The extension is published under the name "Helper App" with the description "Just Helper App." No mention of upvote.club in the listing.
Permissions
The manifest requests the following:
<all_urls> — content scripts run on every website
webRequest — intercepts all network requests
tabs — access to all open tabs and URLs
scripting — can inject code into any page
storage — persistent local data
activeTab — access to current tab
sidePanel — persistent browser sidebar
webNavigation — monitors all navigation events
This is a highly privileged permission set spanning all browsing contexts. Additionally, the platform collects browser fingerprint data at registration — user agent string, OS name, device type, landing URL, and referrer timestamp are all stored server-side on the Firebase backend. It is substantially broader than what task verification requires.
What It Actually Does
Note: The following is based on static analysis of the decompiled extension source. Behavior was inferred from code, not observed in a runtime environment. Static analysis alone cannot determine the full runtime behavior, data retention policies, or operational intent of the extension operators.
On DEV.to (social/devto.js): The content script attaches a click listener to every button on every DEV.to page. It detects follow, like, unicorn, save, comment, and reaction actions by inspecting aria-label, className, data-testid, and button text. It also intercepts POST requests to dev.to/follows, dev.to/reactions, and dev.to/comments via the network request layer. Detected actions are reported to the upvote.club backend.
Screenshot capture: The background worker includes captureVisibleTabAsDataUrl() — it takes a PNG screenshot of the active browser window and uploads it to api.upvote.club/api/social-profiles/upload-verification-screenshot/ along with the full extracted text of the page.
Request body interception: The extension intercepts raw POST bodies across 30+ platforms — Twitter/X, Facebook, LinkedIn, Reddit, GitHub, Instagram, TikTok, YouTube, Threads, DEV.to, Quora, Medium, Substack, Mastodon, Hacker News, Bluesky, and Indie Hackers. For each platform it decodes and parses the request body to identify the action type.
Token extraction: When the extension detects an upvote.club tab, it executes localStorage.getItem("accessToken") via chrome.scripting.executeScript to read the user's auth token and sync it to extension storage.
Google redirect interception: The background worker monitors www.google.com/url redirects and extracts task parameters embedded in the destination URLs.
The Shadow Domain
The extension source contains a production config referencing an undisclosed second domain:
production: {
API_URL: "https://api.upvote.club",
NS_API_URL: "https://ns.upvote.club",
SITE_URL: "https://upvote.club",
NS_SITE_URL: "https://nsboost.xyz" // not mentioned publicly
}
nsboost.xyz resolves to a separate IP (216.150.1.1) from upvote.club (172.67.182.120), but deeper analysis confirms they are the same operation. Three pieces of evidence establish this definitively:
-
Same Yandex Metrica ID (
98568698) — identical analytics account across both domains, indicating the same operator and business entity. -
nsboost.xyz's own sitemap points to upvote.club — both
sitemap.xmlandserver-sitemap.xmlon nsboost.xyz listhttps://upvote.clubURLs as canonical, not nsboost.xyz URLs. -
robots.txt declares
Host: https://upvote.club— the canonical host directive explicitly names upvote.club as the authoritative domain.
nsboost.xyz is a white-label frontend running on separate infrastructure but sharing the same backend, analytics, and operator as upvote.club. The Chrome extension handles both domains transparently — members logged into nsboost.xyz complete tasks that fulfill upvote.club orders and vice versa. The extension currently shows 2,000 active installs.
Hardcoded Secret
The extension ships with a hardcoded API secret visible in plaintext source. This authenticates screenshot uploads to their backend. Anyone who downloads the CRX file — which is public — has this key. The value has been redacted here and disclosed directly to the vendor and to Google.
What This Means for the Network
The accounts completing follow tasks on your DEV.to profile are running a browser extension with a highly privileged permission set. The extension monitors their activity across every major social platform, captures screenshots of their browser, reads their auth tokens, and intercepts their network requests — all while branded as "Helper App."
The behavioral uniformity observed in the audit (Following=1, zero engagement, synchronized activation) is a direct consequence of this architecture: every follow action is mechanically dispatched by the extension in response to a task assignment, with no organic browsing behavior attached.
The Operator's Own Words: GitHub Referrer Spoofing
While mapping the authenticated API, I retrieved the platform's internal blog feed — a members-only section accessible only to logged-in accounts, not publicly indexed. One post, published April 14, 2026 and titled "GitHub is back to platform," contains an explicit technical description of how the platform engineers its GitHub task flow to evade fraud detection:
"when someone completes a GitHub task through our extension, GitHub sees Google as the referrer. Not our platform, not some unknown source. As far as GitHub is concerned, someone found this repo through a Google search and decided to star it."
"Stars coming in from what looks like Google search traffic is exactly the pattern GitHub considers healthy. Repos pick up organic stars from Google all the time. That's the signal we're mimicking."
This is not behavioral inference or structural analysis. This is the operator describing, in their own words, a deliberate technical mechanism built to make fraudulent GitHub engagement appear as organic Google search traffic.
The post also explains that GitHub was previously removed from the platform entirely — suggesting GitHub's fraud detection had identified and suppressed the original approach. The referrer spoofing was built specifically to circumvent that detection:
"We needed it back. Just not the old way."
What this means technically: The Chrome extension, when completing a GitHub star or follow task, injects or overrides the HTTP Referer header on the outbound request to github.com, replacing the actual origin (upvote.club) with google.com or a Google search URL. From GitHub's server logs, the action appears indistinguishable from a user who arrived via organic search.
This is a materially different threat than fake engagement volume. This is active, engineered evasion of platform fraud detection infrastructure — designed specifically to survive the detection mechanisms platforms use to identify and remove inauthentic behavior.
The same referrer spoofing mechanism almost certainly applies to other platforms in the task queue. The GitHub post describes it as an extension-level capability, not a GitHub-specific implementation.
This finding has been reported to GitHub Security separately.
Step 11: Inside the Platform — Authenticated Investigation
Static analysis and passive traffic capture only go so far. To observe the platform from the inside — the task queue, the economics, the actual API structure — I created a controlled burner account and conducted an authenticated investigation under mitmproxy capture.
Lab Setup
# Isolated Brave profile + mitmproxy on :8181
# Extension loaded as unpacked — no Chrome Web Store install
/opt/brave.com/brave/brave \
--user-data-dir=~/extension_lab/chrome_profile \
--proxy-server="http://127.0.0.1:8181" \
--ignore-certificate-errors \
--load-extension=$EXT_PATH \
--no-first-run
All traffic routed through mitmproxy with the CA certificate installed. The lab browser had no saved sessions, no real accounts, and no connection to any personal identity.
Getting there took some friction. The Ubuntu snap package for Chromium ignores --user-data-dir and routes new instances to the existing browser session — meaning the extension would have loaded into my real browser with my real accounts visible. That was unacceptable for a surveillance-capable extension with <all_urls> permissions. The fix was switching to Brave, called directly via its binary path to bypass the snap wrapper entirely. mitmproxy also hit a port collision on first launch, requiring a clean restart on a different port. Neither is a remarkable finding — just the normal friction of building an isolated lab from scratch on short notice.
Registration: OAuth Only, No Email Option
upvote.club offers no email/password registration path. The only options are "Continue with Google" and "Continue with Apple." This is a deliberate architectural choice — every member account is backed by a verified Google or Apple identity.
The OAuth consent screen revealed something significant: the backend Firebase app identifier is upvote-club.firebaseapp.com. upvote.club runs on Google Firebase — their user database, authentication tokens, and session management all run on Google Cloud infrastructure.
A throwaway Google account (marcus.delray.dev@gmail.com) was created for this investigation and used exclusively for this purpose.
Onboarding Flow
After OAuth login, the onboarding flow asked:
- Select your country — US selected (not real location)
- What platforms do you want to boost? — DEV.to selected
- What engagement types? — Likes, Comments, Saves, Followers, Unicorns (all pre-checked)
- Describe your goal — free text field, required before continuing
- Paywall — $1 for 7 days trial, then $49/month
The paywall has no skip button and no free tier path through the onboarding UI. However, direct navigation to https://upvote.club/dashboard bypasses it entirely — the paywall is a UX gate, not an access restriction. The free account was fully functional after direct navigation.
Each step POSTed to api.upvote.club/api/onboarding-progress/ — the platform stores buyer intent data including selected platforms, engagement types, and free-text goals.
What the Platform Showed in Real Time
The dashboard's live activity feed showed ongoing manipulation across all platforms while I was watching:
+678 Dev.to Likes
+123 Dev.to Comments
+89 Dev.to Unicorns
+156 Dev.to Saves
+28.9k Total Actions Delivered Yesterday
DEV.to engagement inflation was actively in progress during the investigation.
Account Profile — Authenticated API Response
The GET api.upvote.club/api/profile/ endpoint returned:
{
"id": 79083,
"user": 79080,
"balance": 13,
"status": "FREE",
"daily_task_limit": 2,
"available_tasks_for_completion": 331,
"potential_earnings": 829.5,
"community_rank": {
"rank": 4127,
"total_users": 4126,
"completed_tasks": 0
},
"referrer_user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36...",
"device_type": "desktop",
"os_name": "Linux",
"landing_url": "https://upvote.club/login",
"referrer_timestamp": "2026-05-24T21:33:21.186000Z"
}
Several findings from this response:
The 50K+ Members claim is false. The community_rank field shows rank: 4127, total_users: 4126 — a newly registered account in May 2026 is ranked last out of 4,126 total active users. upvote.club's marketing prominently claims "50K+ Members." The authenticated API puts the real active user base at roughly 4,100 — more than 12x smaller than advertised. User IDs in the 79,000 range suggest ~79,000 accounts created historically, but the vast majority are inactive or abandoned.
Browser fingerprint collected on registration. The platform stores referrer_user_agent, device_type, os_name, landing_url, and referrer_timestamp for every account. Members completing tasks have their full browser identity logged.
Free account economics. Balance: 13 points (signup bonus). Daily task limit: 2. With 331 available tasks showing potential_earnings: $829.50, the implied per-task earnings exceed the base $0.90 — suggesting higher-value tasks exist in the queue.
Live Task Queue
GET api.upvote.club/api/tasks1/ returned the current task distribution across platforms:
Total available: 217 tasks across 14 platforms
Quora: 52 GitHub: 51
LinkedIn: 34 Facebook: 12
Instagram: 10 Substack: 10
Threads: 9 Bluesky: 9
Reddit: 9 TikTok: 9
Twitter: 8 YouTube: 2
IndieHackers: 1 HackerNews: 1
DEV.to: 3 active orders
Three active DEV.to manipulation orders were in the queue at the time of capture. One caveat worth being explicit about: the API returned tasks: [] for the DEV.to-filtered call despite the count showing 3. The task objects were present in the system but not returned to the new account. Free accounts with zero completed tasks appear to be gated out of the actual task delivery — the platform likely requires a completion history before assigning tasks to a member. The count confirms active DEV.to orders exist. The empty task list is a free-tier restriction, not an absence of activity.
Task Initiation Flow — Confirmed at Runtime
Clicking "Complete Task" on an Instagram task triggered:
POST https://api.upvote.club/api/initiate-task/64903/
This confirms the task assignment sequence from static analysis: the upvote.club page sends a message to the extension via externally_connectable, the extension stores the task parameters, and the target URL opens in a new tab. Task ID 64903 was a real Instagram story from @thehawaiianmayan — a real person's content being targeted for fake engagement.
A Test That Failed — and What It Revealed
Before the authenticated session, I tried to trigger the extension's webRequest.onBeforeRequest listener directly by crafting a fake task URL:
https://dev.to/gnomeman4201?taskid=99999&userid=88888&ct=faketoken123&domain=upvote.club
Static analysis showed this listener watches for taskid, userid, and ct parameters in URLs and writes them to chrome.storage.local. The expectation was that navigating to this URL would write currentTaskId: "99999" to storage. The storage stayed empty. The listener never fired.
The reason: the listener's URL filter is *://*.dev.to/* — which requires a subdomain. The bare apex domain dev.to doesn't match *.dev.to. Since DEV.to's production URLs use the apex domain without www, the webRequest listener is functionally dead for real DEV.to pages.
This means the URL parameter injection path doesn't work for DEV.to. The actual task assignment mechanism routes through externally_connectable message passing from an active upvote.club tab directly to the extension — not from URL traffic interception. The extension needs an open upvote.club tab to receive task parameters. It's not passively watching URLs.
That's a meaningful constraint on the threat model — and I only found it because the test failed.
Extension Balance Sync — Confirmed at Runtime
After login, the extension icon badge updated to show 9 — the account balance minus the 4 points spent navigating the onboarding. This confirms the getUserBalance() → updateBadgeWithBalance() flow from static analysis fires on authentication and keeps the badge in sync with the server balance.
Task Completion Protocol — Full Live Capture
Static analysis identified the initiate/complete two-phase task flow. What remained unverified was the actual POST body structure for complete-task — the proof mechanism. That was confirmed through live mitmproxy capture on May 24, 2026.
After initiating an X (Twitter) follow task for @nferhattaleb (task ID 64923), the extension auto-navigated to the target profile and waited. Completing the task required executing one follow action — an unavoidable step in observing the live protocol under realistic conditions. After the follow was completed, the extension initiated and fully completed a second task (ID 64918) autonomously — without any additional user interaction. The full protocol, captured verbatim:
Phase 1 — Initiation:
POST https://api.upvote.club/api/initiate-task/64918/
Body: {}
Response:
{
"client_sync_pending": false,
"completion_token": "t6EdrgY1AM-uPRC90bOdLeXmY7Dmbzk8b2Xopxa5RRg",
"meaningful_comment_text": "",
"server_ts": 1779667395
}
The server issues a completion_token — a per-task secret that serves as the HMAC signing key for the completion POST.
Phase 2 — Completion:
POST https://api.upvote.club/api/complete-task/64918/
Headers:
x-uc-client: ext
x-uc-ext-id: fkiaohmeeoiipoknngcppjbkinaamnof
x-uc-ext-version: 1.1.26
origin: chrome-extension://fkiaohmeeoiipoknngcppjbkinaamnof
Body:
{
"action": "FOLLOW",
"completion_token": "t6EdrgY1AM-uPRC90bOdLeXmY7Dmbzk8b2Xopxa5RRg",
"user": "79083",
"x_sig": "a5853cefa2fb1f8f2bcdbff54acaf8e33a36c4ba030eb84350fd932810bc12fc",
"x_ts": 1779667400
}
Response:
{
"message": "Task completed successfully",
"new_balance": 14.0,
"reward": 1.0,
"task_status": "ACTIVE"
}
Phase 3 — Redirect:
GET /dashboard?success-action-redirect&reward=1&balance=14&completed_task_id=64918
Phase 4 — Queue de-duplication:
GET /api/tasks1/?exclude_browser_task_ids=64918
The completed task is filtered from the local task list immediately after completion.
The HMAC Signing Formula — Reverse Engineered
The x_sig field in the completion POST is a verified HMAC-SHA256 signature. Decompiling background.js from the extension source revealed the exact signing function:
async function buildCompleteTaskSignature(taskId, action, completionToken, submittedComment, clockOffsetSec) {
const ts = Math.floor(Date.now() / 1e3) + offset;
const commentHashHex = bufferToHex(await crypto.subtle.digest("SHA-256", enc.encode(submittedComment || "")));
const message = `${taskId}|${action}|${ts}|${commentHashHex}`;
const key = await crypto.subtle.importKey("raw", enc.encode(completionToken), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sigBuf = await crypto.subtle.sign("HMAC", key, enc.encode(message));
return { x_sig: bufferToHex(sigBuf), x_ts: ts };
}
The signing key is the completion_token issued by initiate-task. There is no separate hardcoded secret — the server can verify every signature because it knows the token it issued.
The formula was verified against the live capture using only captured values:
import hmac, hashlib
token = "t6EdrgY1AM-uPRC90bOdLeXmY7Dmbzk8b2Xopxa5RRg"
ts = 1779667400
comment_hash = hashlib.sha256(b"").hexdigest()
message = f"64918|FOLLOW|{ts}|{comment_hash}"
sig = hmac.new(token.encode(), message.encode(), hashlib.sha256).hexdigest()
# sig == "a5853cefa2fb1f8f2bcdbff54acaf8e33a36c4ba030eb84350fd932810bc12fc"
# MATCH: True
The signature matches exactly. The complete task API is replayable from a valid JWT and a task ID — no browser, no extension required. This verification script is in the repo at evidence/verify_sig.py.
What this means: Anyone with a valid upvote.club JWT can programmatically initiate tasks, construct valid HMAC signatures, and submit completions at scale. The extension is not a technical enforcement boundary — it is a convenience wrapper around a fully scriptable API. The architecture does not prevent automated abuse; it outsources the action to an installed browser extension and trusts the token-based signature as proof.
Autonomous Task Execution — Extension Acts Without User Interaction
The most significant behavioral finding from the live session: the extension initiated and completed task 64918 autonomously, without any additional user action after the initial follow on task 64923.
The sequence captured in the mitmproxy log:
[user clicks Follow on @nferhattaleb — task 64923]
[extension scrapes ~300 Twitter profile images from pbs.twimg.com — follower list verification]
[extension initiates task 64918 — new task, no user prompt]
[extension navigates to YouTube target — No Text To Speech channel]
[extension completes task 64918 — full HMAC-signed POST]
[dashboard reloads: success-action-redirect&reward=1&balance=14]
The extension does not wait for user input between tasks. After one task completes, it automatically queues and executes the next. From the user's perspective, they installed a browser extension, completed one follow, and received points. Behind that interaction, the extension performed additional tasks on their behalf.
This has a direct consequence for the platform's "real humans" framing: users completing tasks may not be aware of the full scope of actions the extension takes in their browser session.
Follower-Scrape Verification Mechanism — Captured Live
Between task initiation and the complete-task POST, the mitmproxy capture showed a burst of over 300 sequential GET requests to pbs.twimg.com/profile_images/ — Twitter's CDN for profile photos.
The extension was crawling the follower list of the target account (@nferhattaleb) to verify that the burner account's follow had landed. Rather than relying on Twitter's API response to confirm the follow action, the extension scrapes the visual follower list and cross-references profile images to confirm the new follower appears.
This is the mechanism behind the captureVisibleTab permission identified in static analysis. Screenshot-based verification — not API confirmation — is how the extension proves task completion to the server.
Google Referrer Spoofing — Captured Live
The internal blog post describing GitHub referrer spoofing (quoted in Step 10) was confirmed in the live traffic capture. During the session, the following request was observed:
GET https://www.google.com/url?q=https%3A%2F%2Fx.com%2FBAxCoinbase HTTP/2.0
The extension routed an X (Twitter) navigation through a Google redirect URL — google.com/url?q=<target> — before opening the target profile. From the destination platform's server logs, this request arrives with google.com as the referring domain. The platform sees what appears to be a user who found the account through Google, not through upvote.club task dispatch.
The Google session cookies present in the browser were transmitted to Google's redirect endpoint as a side effect, leaking authenticated Google session state to the redirect intermediary. This is a collateral privacy consequence of the spoofing architecture.
This confirms the referrer spoofing described in the operator's own blog post is not limited to GitHub — it is a general mechanism applied across platform tasks.
Why This Account, Why Now
The deployment timing raises a question worth addressing directly: why did a follower flood targeting a security researcher begin one day after that researcher published botnet exposure work?
Two interpretations are consistent with the evidence:
Targeted retaliation. Someone connected to the fake engagement ecosystem purchased a follower inflation order specifically against this account in response to the GitHub botnet article. The 24-hour lag is consistent with a human purchasing decision rather than automated monitoring.
Reputation poisoning as an attack vector. A DEV.to account that gains 900 followers in four days from accounts with no organic activity could trigger automated platform integrity systems — potentially flagging the target as the bad actor. For security researchers specifically, having your account suspended for artificial follower inflation immediately after publishing botnet research would be a highly effective way to discredit the work. Whether or not this was the intent, it is the structural effect.
This is temporal correlation, not proven attribution — but the convergence of timing, target profile, and deployment scale makes organic coincidence the least parsimonious explanation.
On the single-use account question: the November 2025 accounts show no sign of having followed and unfollowed previous targets. The observed behavior is consistent with a single-use deployment model — accounts created, aged, deployed once against one target, then warehoused indefinitely. That makes the aged-account inventory more valuable but also more wasteful: 897 accounts burned for one order.
End-to-End Timeline
Nov 13-14, 2025 ── 218 accounts created (S3 IDs 3,610,947–3,619,885)
Zero activity. Warehoused.
Jan 2026 ── 17 more accounts created. Warehoused.
Apr 2026 ── 92 more accounts created. Warehoused.
May 13-14, 2026 ── 615 accounts created across 2 days.
May 19, 2026 ── "Found a Coordinated GitHub Follow Botnet" published.
May 20, 2026 ── Deployment begins. 288 new followers in one day.
All four waves activated. Following=1, target=GnomeMan4201.
May 19–23, 2026 ── 2,300+ accounts added. Count: ~600 → 3,045+. Still active.
The timing is consistent with a targeted follower inflation deployment temporally associated with the article publication. Four account batches created across six months, warehoused, then activated in close temporal proximity to a specific publication event.
How to Audit Your Own Followers
You don't need my full toolchain. The Following=1 signal is enough to get started:
import requests, time
API_KEY = 'your_key'
def audit_your_followers():
page, flagged = 1, []
while True:
resp = requests.get(
'https://dev.to/api/followers/users',
headers={'api-key': API_KEY},
params={'page': page, 'per_page': 1000},
)
batch = resp.json()
if not batch:
break
for user in batch:
u = requests.get(
'https://dev.to/api/users/by_username',
headers={'api-key': API_KEY},
params={'url': user['username']},
).json()
time.sleep(0.25)
if (u.get('following_count') == 1
and u.get('public_articles_count') == 0
and u.get('followers_count') == 0):
flagged.append(user['username'])
print(f'[FLAGGED] @{user["username"]}')
page += 1
print(f'\n{len(flagged)} accounts matching coordinated inauthentic pattern')
return flagged
audit_your_followers()
If you see a sudden follower spike after publishing — especially security or platform research — run this. Accounts matching this behavioral profile will surface immediately. For deeper analysis (batch wave detection, image fingerprinting, S3 sequencing), the full toolchain is in the repo.
What I Reported
I disclosed everything across four channels:
DEV.to security (four emails) — 897 flagged usernames, scored CSV, audit scripts, S3 sequencing, dormancy timeline, image fingerprinting, asset attribution, extension analysis. DEV.to previously suspended a related fraud marketplace (3 accounts, 34 articles) the same day I reported it and has been responsive throughout. Notably, the follower flood remained active through the entire investigation and disclosure period — from first detection May 19 through at least May 24, reaching 3,045+ total followers while this research was being compiled and reported.
Google Chrome Web Store — policy violation report filed against the "Helper App" extension for misleading name/description and undisclosed data collection. Ticket: 1-1457000040647.
GitHub Security — filed May 24, 2026. The internal blog post explicitly admitting GitHub referrer spoofing constitutes documented, intentional fraud detection evasion targeting GitHub’s platform integrity systems. The HMAC signing formula and replayable API were included as supplementary technical findings.
upvote.club directly — the hardcoded API secret identified in static analysis was disclosed to the vendor prior to publication. The value has been redacted throughout this article.
I'm publishing this because developers deserve to know what these campaigns look like and how to detect them. Follower counts carry real social weight — they affect credibility signals, algorithm visibility, and how new readers decide whether to trust your work. Artificially inflating those numbers is platform manipulation, and it's more technically sophisticated than most people expect.
The more developers understand these mechanics, the harder these networks are to run quietly. Transparency makes the community harder to exploit.
Limitations
This audit used publicly observable metadata and heuristic scoring — not internal platform telemetry. I don't have access to:
- IP address or device fingerprints
- Session linkage data
- Payment or purchase records
- Internal moderation signals
As a result, this analysis identifies coordinated inauthentic behavior patterns rather than attributing activity to a specific individual or organization. The findings are strong enough to act on at the platform level, but the full picture requires data that only DEV.to's backend can provide.
Full Findings Summary
| Metric | Value |
|---|---|
| Total followers audited | 1,409 (snapshot taken May 23) |
| Flagged as likely coordinated inauthentic | 897 (63.7%) |
| False positives identified and removed | 2 (via S3 ID analysis) |
| Accounts with Following=1 | 1,409 (100%) |
| Accounts with zero posts | 1,393+ |
| Exact duplicate image groups | 55 |
| Perceptual similarity clusters | 56 |
| Custom illustration avatars | 74 |
| Username generators identified | 3 (hex-suffix, simple, creative phrase) |
| Creation waves identified | 4 |
| November wave dormancy | 187 days |
| GitHub follower crossover | 0 of 897 — complete platform separation confirmed |
| Asset source | Public domain (ClipArt ETC / DepositPhotos) |
| Asset library in use since | At least Dec 2023 |
| Marketplace identified | upvote.club ($0.90/follow, 24hr delivery) |
| Shadow domain | nsboost.xyz (confirmed same operator) |
| Extension active installs | 2,000 |
| Extension published as | "Helper App" / "Just Helper App" |
| Estimated order value | ~$828 at public pricing |
| Follower count at publication | 3,045+ and climbing |
| Platform backend | Firebase (Google Cloud) |
| Claimed membership | 50K+ |
| Actual active users (API confirmed) | ~4,126 |
| Active task orders at time of investigation | 217 across 14 platforms |
| Active DEV.to orders in queue | 3 |
| Task completion protocol | Two-phase: initiate (server issues token) → complete (HMAC-SHA256 signed POST) |
| Signing key source | Server-issued completion_token doubles as HMAC key — no hardcoded secret |
| API replayability | Fully scriptable from JWT + task ID — no browser or extension required |
| Autonomous task execution | Extension completes tasks without user interaction between assignments |
| Verification mechanism | Follower-list scrape via pbs.twimg.com (~300 profile image GETs per task) |
| Referrer spoofing — confirmed live |
google.com/url?q=<target> routing captured in mitmproxy session |
| Disclosures filed | DEV.to (4 emails), Chrome Web Store (ticket 1-1457000040647), GitHub Security (May 24), vendor direct |
A note on the cross-platform null result: Finding zero GitHub overlap across all 897 flagged accounts was initially a disappointing outcome — the same accounts appearing on multiple platforms would have been a stronger finding. But the null result is itself the finding. The operator runs completely siloed account pools. Detection on DEV.to gives zero signal about their GitHub, Reddit, or Bluesky accounts. The compartmentalization is consistent with the extension architecture: task assignments are platform-specific, and account identities are not recycled. This also means platform-level enforcement is necessarily blind to the broader network.
Full toolchain at github.com/GnomeMan4201/devto-botnet-hunter. Methodology critiques and PRs welcome.
Coordinated follower inflation looks organic at the individual-account level. At graph scale, it becomes a structurally degenerate pattern — detectable not by individual account properties, but by the topology of the graph itself.




















