The starting line
Private servers for older MMORPGs live in a strange world. They are run by passionate people in their spare time, on a patchwork of community emulators and homegrown web panels. The community keeps them alive. The threat model around them is the same as any other web service on the public internet, plus the in-game economy as a financial layer on top.
I was a player on Spidpex. One evening the website went unreachable. Player accounts started behaving strangely — unauthorized cash shop purchases, items vanishing, password reset emails landing on the wrong addresses. Classic script-kiddie symptoms: low-effort attacks landing because the front door was thin.
I sent the owner a Discord message: "I'm an engineer. I can help. Free of charge." The owner accepted. The next week I had server access and a long list of holes to plug.
The threat model in plain terms
Game-server attackers are not the well-funded APT in the security textbooks. They are a mix of:
- Account farmers running brute-force credential stuffing against the login form using leaked password lists.
- Cash shop abusers hunting for race conditions in the purchase flow so they can double-spend in-game currency.
- Item dupers looking for TOCTOU races between the web inventory and the game inventory.
- Web kiddies firing automated SQLi / XSS / CSRF payloads at every form on the site to see what sticks.
- Network-level pests firing volumetric DDoS at the login page during peak hours.
None of these are sophisticated. All of them work if you leave the door open.
What I found on day one
- Passwords stored as unsalted MD5, the way many community emulator schemas still ship.
- Login endpoint with no rate limit and no captcha.
- Cash shop purchase using two separate queries with no transaction — deduct currency, then add item. A failure between them either lost the item or doubled it.
- User-supplied input rendered into the forum and the character profile pages without escaping, perfect for stored XSS.
- CSRF protection on the account panel was either missing or one-token-for-the-whole-session, easily replayable.
- Password reset flow leaking which emails existed in the database via different response text.
Not bad luck. The default state of a community panel glued together over years.
What I changed, in order of who was getting hurt first
1. Stop the credential stuffing
The login endpoint went behind Cloudflare, with bot
fight mode and a strict rate-limit rule for
POST /login. Application-level rate limiting
per IP + per username, with exponential backoff and a
captcha after the third bad attempt. Successful logins from
a new device send an email to the account holder with the
IP, country, and a single-click revoke link.
// app/Http/Middleware/LoginThrottle.php
$ipKey = 'login:ip:' . $request->ip();
$userKey = 'login:user:' . strtolower($request->input('username'));
foreach ([$ipKey, $userKey] as $key) {
if (RateLimiter::tooManyAttempts($key, 10)) {
return $this->captchaChallenge($request);
}
RateLimiter::hit($key, decaySeconds: 600);
}
2. Rehash every password on next login
Migrating MD5 to Argon2id in one shot was not possible — we didn't have the plaintext. I shipped a silent re-hash on next successful login: when a user logs in and their stored hash is the old MD5, the password they just typed gets re-hashed with Argon2id and the column is upgraded in place. After a few weeks of regular logins, the legacy hashes were gone from active accounts.
3. Wrap the cash shop in a real transaction
The double-spend hole in the cash shop was the most obvious financial bug, so it was the first to fix. The purchase flow now opens an explicit database transaction, re-reads the user's balance with a row lock, deducts the cost, inserts the item grant, and commits. A failure at any point rolls back. Item delivery to the in-game database is a separate idempotent step keyed by the purchase ID so a retry can never grant the item twice.
// app/Services/CashShop.php
DB::transaction(function () use ($user, $product) {
$row = User::lockForUpdate()->find($user->id);
if ($row->balance < $product->price) {
throw new InsufficientFunds();
}
$row->decrement('balance', $product->price);
Purchase::create([
'user_id' => $row->id,
'product_id' => $product->id,
'amount' => $product->price,
'idempotency_key' => (string) Str::uuid(),
'status' => 'pending_delivery',
]);
});
4. Escape everything user-supplied. Everywhere.
The XSS holes in the forum and the character profile pages were closed by switching every render path to escape output by default. Vue handles this for the bulk of the modern UI; the few Blade views that still rendered raw HTML for nostalgic reasons were rewritten. A strict Content Security Policy via Nginx adds a second wall: inline scripts blocked, only same-origin assets allowed, and a reporting endpoint catches the few places I missed.
5. CSRF and SameSite, the boring way
Laravel's CSRF middleware is enabled site-wide, with a
freshly-rotated token on every form. Session cookies set to
SameSite=Lax; Secure; HttpOnly. The account
panel endpoints reject any cross-origin POST without an
explicit allow. Boring. Effective.
6. The leaky password-reset response
The password reset endpoint returned a different message for “email not found” vs “email sent” — a classic user-enumeration leak. Both branches now return the same success response, and the actual email is queued for delivery only when the address exists. Account farmers can no longer scrape the database for valid emails one form post at a time.
The boring infrastructure pass
- Cloudflare in front of everything, with the origin firewalled to accept connections only from Cloudflare IPs.
- Nginx with strict client-body limits, per-endpoint rate limits, and a long block list of suspicious user agents seen in the access log during the first attack week.
- Fail2ban on the SSH side — basic, but the login attempts on the box itself were nonzero.
- Daily encrypted offsite backups of the database and the in-game character files. If the next attack succeeds, we can roll back.
- Discord webhook alerts on suspicious patterns: sudden spike in failed logins, cash shop purchases above a threshold per hour, item grants for items not currently for sale.
What broke, or surprised me
Players also tried to hack the server
The biggest threat after the initial attack wave was not outsiders. It was a small group of regular players probing the cash shop and the inventory APIs with modified clients, looking for new races. Some of the most useful security fixes came from reading their attempts in the access log and patching ahead of the next try.
Logging was the actual product
The most valuable thing I built was not a defense. It was the structured log. Every login, every purchase, every item grant, every password change, written to a searchable store with the IP, the user agent, the request ID, and the previous and next state. When something looked wrong, the log answered “what actually happened” in seconds. Without it, every incident is a guess.
What it does today
- Live Dragonica private server with an active player base and community features.
- Hardened login flow: rate limits, captcha, device email alerts, Argon2id hashing.
- Cash shop with transactional purchases, idempotent item delivery, anomaly alerts.
- Forum and profile pages safe from stored XSS, with a strict CSP as backup.
- Cloudflare + Nginx + Fail2ban perimeter.
- Structured audit log of every financially-sensitive action.
What I'd do differently next time
- Threat-model the cash economy first. The web panel got most of my initial attention. The in-game-currency ↔ cash-shop seam was where the money actually was, and where the more patient attackers went next.
- Set up the log first, fix second. I built the structured log in week two. Should have been day one. Half the early fixes were based on stitching guesses from multiple log files.
- Treat client modifications as the default. Anything the server trusts the client to tell it — positions, inventory state, kill counts — gets checked server-side. Easy to write down. Hard to remember in every feature.
What I take away
Hardening a service under attack is one of the most honest pieces of engineering work you can do. You can see the attempts in the logs. You can measure the failure rate before and after. The fixes are boring, the wins are visible, and the server stays up.