back to portfolio
// Case Study 05  ·  game server hardening

Dragonica Spidpex — hardening a server I was playing on

A live Dragonica private server I joined as a player. One night the server went sideways — attackers were chewing on the web layer, the cash shop, and account credentials. I messaged the owner offering to help. I stayed on as the engineer who patched the holes and locked the doors.

Role
Web + security engineer — offered, accepted, ongoing
Stack
Laravel, Vue.js, Socket.io, MySQL, Nginx, Redis, Cloudflare
Status
dragonicaspidpex.com — live, in daily use by an active player base

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:

None of these are sophisticated. All of them work if you leave the door open.

What I found on day one

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.

// Lesson Most of what stops script kiddies is unglamorous. Rate limiting, transactions, escaped output, CSRF tokens, equal response branches. None of it is a clever idea. All of it is a closed door.

The boring infrastructure pass

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

What I'd do differently next time

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.

Have a service taking heat? I take on hardening engagements for web platforms, game servers, and small SaaS products end-to-end.
Start a conversation