Protecting your game server from bots


#1

Hey guys,

A lot of people have been asking me how they can protect their Club Penguin server from bots, this is an inherently difficult thing to do due to the way flash works on the web and differentiating between real users and bots from the server-side can be really difficult (almost impossible).

Bots aren’t always a problem, but unfortunately some people use bots as a way to cause nuisance for other players, and in some cases even use them for malicious purposes such as restricting entry to rooms or even the entire server. Things like this can be prevented with decent firewall rules and swift staff members, but even that can sometimes fall short.

Many sites, such as forums and blogs, make use of Google’s reCAPTCHA for filtering out bots, it has proven quite effective for some time now, and the same solution can be applied here, with a little external interface magic :~).

This will be more of a proof-of-concept than it is a tutorial, hence why I am only providing code for the AS2 client and Houdini’s async branch for the server-side (since that’s what I happened to be working with at the time), so you guys will have to figure things out on your own (by modifying my code or starting from scratch) if you want this to work with the AS3 client and across other server emulation softwares.

The first step is to create a new actionscript 2 flash document:

Now open up the actions pane (F9) and paste the following code:

import flash.external.ExternalInterface;

System.security.allowDomain("*");

var AIRTOWER = _global.getCurrentAirtower();
var SHELL = _global.getCurrentShell();

var connectToLogin = function(gtoken) {
	// invoked when captcha is completed
	if(!AIRTOWER.is_logged_in) {
		AIRTOWER.gtoken = gtoken;
		// the rest of this is just copied from airtower.swf
		AIRTOWER.server.onConnection = com.clubpenguin.util.Delegate.create(AIRTOWER, AIRTOWER.handleLoginConnection);
		AIRTOWER.server.onRandomKey = com.clubpenguin.util.Delegate.create(AIRTOWER, AIRTOWER.handleLoginRandomKey);
		AIRTOWER.server.onExtensionResponse = com.clubpenguin.util.Delegate.create(AIRTOWER, AIRTOWER.onAirtowerResponse);
		AIRTOWER.addListener(AIRTOWER.HANDLE_LOGIN, AIRTOWER.handleOnLogin, AIRTOWER);
		
		var loginPort = AIRTOWER.getLoginServerPort(in_username);
		AIRTOWER.server.connect(AIRTOWER.LOGIN_IP, loginPort);
	}
};

AIRTOWER.login = function() {
	// this is here so we can transport the recaptcha response token to the server, we just append it to the password hash
	AIRTOWER.server.login("w1", AIRTOWER.username, AIRTOWER.getLoginHash() + AIRTOWER.gtoken);
};

AIRTOWER.connectToLogin = function(in_username, in_pass, login_response) {
	// intercept the login process, execute the captcha and then wait for response before doing anything else
	AIRTOWER.on_login_response = login_response;
	AIRTOWER.username = in_username;
	AIRTOWER.password = in_pass;
	ExternalInterface.call("grecaptcha.execute");
};

// assign callback for when captcha is completed
ExternalInterface.addCallback("finishedCaptcha", null, connectToLogin);

Finally export and save in /play/v2/client/ directory of your media server. If you’re unsure about how to use Flash for creating and exporting SWF files, the exported SWF file is attached to this post along with the FLA document so you can skip this step, I only put this here for anyone interested in the code itself.

Now you need to add recaptcha.swf to your dependencies.json (see Arthur’s ooolllllddddd post if you don’t know much about how dependencies in the Club Penguin client work). The dependencies.json is found inside /play/v2/client and should look like this after you’re done:

{
	
	boot: [
		{
			id: 'airtower',
			title: 'Communication'
		},
		{
			id: 'sentry',
			title: 'Communication'
		}
	],
	
	login: [
		{
			id: 'login',
			title: 'Login Screen'
		},
		{
			id: 'recaptcha',
			title: 'Communication'
		},
	],
	// .... code below ommitted 

You’ll see the additional entry below the login id. Again if you’re unsure about this step, my depedencies.json is attached below, just remember that if you’ve already made other new entries then you’ll have to merge your existing one with mine.

Next you need to embed reCAPTCHA on your game play page, but pay attention, you need to do this in a very specific way so that your flash dependency can communicate with the CAPTCHA.

<style type="text/css">
  .grecaptcha-badge {
  display: none;
  }
</style>
<script src='https://www.google.com/recaptcha/api.js'></script>
<script type="text/javascript">
  function onSubmit() {
    document.getElementById("game").finishedCaptcha(grecaptcha.getResponse());
    grecaptcha.reset()
  }
</script>
<form>
  <div id='recaptcha' class="g-recaptcha"
    data-sitekey="YOUR_PUBLIC_KEY_HERE"
    data-callback="onSubmit"
    data-size="invisible"></div>
</form>

All you need to do is embed this code inside index.html and you’re off to the races, just remember to replace YOUR_PUBLIC_KEY_HERE with your reCAPTCHA public key, if you don’t have a reCAPTCHA key-pair for your site already, you can obtain one for free from Google.

You may notice that this HTML code looks very similar to the code for my AS2 flash registration form, and that’s because it is! In fact, if you’ve already followed that tutorial and you have it working, you probably don’t need a second reCAPTCHA on your play page, so you can skip this step!

Finally we need to tell our server to validate reCAPTCHA tokens that are now going to be transmitted. I have written a Houdini plugin for this, remember this is for the ASYNC BRANCH ONLY, please don’t try to use this with the non-async version of Houdini.

import zope.interface, logging, time, bcrypt, json, os
from datetime import datetime

from Houdini.Plugins import Plugin
from Houdini.Handlers import Handlers
from Houdini.Data.Penguin import Penguin, BuddyList
from Houdini.Data.Ban import Ban
from Houdini.Crypto import Crypto
from Houdini.Handlers.Login.Login import handleLogin

from twisted.internet.defer import inlineCallbacks, returnValue
from twisted.internet import threads

from sqlalchemy.sql import select

import treq

class Captcha(object):
    zope.interface.implements(Plugin)

    author = "Ben"
    version = 0.1
    description = "Google recaptcha support in Houdini"

    def __init__(self, server):
        self.logger = logging.getLogger("Houdini")

        self.server = server

        configFile = os.path.dirname(os.path.realpath(__file__)) + "/captcha.conf"
        with open(configFile, "r") as fileHandle:
            self.config = json.load(fileHandle)

        self.verifyRecaptchaUrl = self.config["VerifyUrl"]
        self.secretRecaptchaKey = self.config["SecretKey"]
        self.recaptchaHost = self.config["Hostname"]

        Handlers.Login -= handleLogin
        Handlers.Login += self.handleLogin

    @inlineCallbacks
    def handleLogin(self, player, data):
        if player.randomKey is None:
            returnValue(player.transport.loseConnection())

        if not hasattr(self.server, "loginAttempts"):
            self.server.loginAttempts = {}

        loginTimestamp = time.time()
        username = data.Username
        password = data.Password[:32]
        captchaToken = data.Password[32:]

        self.logger.info("{0} is attempting to login..".format(username))

        user = yield player.engine.first(Penguin.select(Penguin.c.Username == username))

        if user is None:
            returnValue(player.sendErrorAndDisconnect(100))

        ipAddr = player.transport.getPeer().host

        passwordCorrect = yield threads.deferToThread(bcrypt.checkpw, password, user.Password)

        if not passwordCorrect:
            self.logger.info("{} failed to login.".format(username))

            if ipAddr in self.server.loginAttempts:
                lastFailedAttempt, failureCount = self.server.loginAttempts[ipAddr]

                failureCount = 1 if loginTimestamp - lastFailedAttempt >= self.server.server["LoginFailureTimer"] \
                    else failureCount + 1

                self.server.loginAttempts[ipAddr] = [loginTimestamp, failureCount]

                if failureCount >= self.server.server["LoginFailureLimit"]:
                    returnValue(player.sendErrorAndDisconnect(150))

            else:
                self.server.loginAttempts[ipAddr] = [loginTimestamp, 1]

            returnValue(player.sendErrorAndDisconnect(101))

        if ipAddr in self.server.loginAttempts:
            previousAttempt, failureCount = self.server.loginAttempts[ipAddr]

            maxAttemptsExceeded = failureCount >= self.server.server["LoginFailureLimit"]
            timerSurpassed = (loginTimestamp - previousAttempt) > self.server.server["LoginFailureTimer"]

            if maxAttemptsExceeded and not timerSurpassed:
                returnValue(player.sendErrorAndDisconnect(150))
            else:
                del self.server.loginAttempts[ipAddr]

        postData = {
            "secret": self.secretRecaptchaKey,
            "response": captchaToken,
            "remoteip": ipAddr
        }

        googleResponse = yield treq.get(self.verifyRecaptchaUrl, params=postData)
        captchaResult = yield googleResponse.json()

        if not captchaResult["success"]:
            returnValue(player.sendErrorAndDisconnect(101))

        if bool(self.recaptchaHost) and captchaResult["hostname"] != self.recaptchaHost:
            returnValue(player.sendErrorAndDisconnect(101))

        if not user.Active:
            returnValue(player.sendErrorAndDisconnect(900))

        if user.Permaban:
            returnValue(player.sendErrorAndDisconnect(603))

        activeBan = yield player.engine.first(
            Ban.select((Ban.c.PenguinID == user.ID) & (Ban.c.Expires >= datetime.now())))

        if activeBan is not None:
            hoursLeft = round((activeBan.Expires - datetime.now()).total_seconds() / 60 / 60)

            if hoursLeft == 0:
                returnValue(player.sendErrorAndDisconnect(602))

            else:
                player.sendXt("e", 601, hoursLeft)
                returnValue(player.transport.loseConnection())

        self.logger.info("{} logged in successfully".format(username))

        randomKey = Crypto.generateRandomKey()
        loginKey = Crypto.hash(randomKey[::-1])

        player.user = user
        player.engine.execute(Penguin.update(Penguin.c.ID == user.ID).values(LoginKey=loginKey))

        buddyWorlds = []
        worldPopulations = []

        serversConfig = self.server.config["Servers"]

        for serverName in serversConfig.keys():
            if serversConfig[serverName]["World"]:
                serverPopulation = yield threads.deferToThread(self.server.redis.get,
                                                               "{}.population".format(serverName))

                if not serverPopulation is None:
                    serverPopulation = int(serverPopulation) / (serversConfig[serverName]["Capacity"] / 6)
                else:
                    serverPopulation = 0

                serverPlayers = yield threads.deferToThread(self.server.redis.smembers, "{}.players".format(serverName))

                worldPopulations.append("%s,%s" % (serversConfig[serverName]["Id"], serverPopulation))

                if not len(serverPlayers) > 0:
                    self.logger.debug("Skipping buddy iteration for %s " % serverName)
                    continue

                buddies = yield player.engine.fetchall(
                    select([BuddyList.c.BuddyID]).where(BuddyList.c.PenguinID == player.user.ID))

                for buddyId, in buddies:
                    if str(buddyId) in serverPlayers:
                        buddyWorlds.append(serversConfig[serverName]["Id"])
                        break

        player.sendXt("l", user.ID, loginKey, "|".join(buddyWorlds), "|".join(worldPopulations))
        player.transport.loseConnection()

    def ready(self):
        self.logger.info("Google ReCAPTCHA plugin is ready!")

I would reccomend for this you just download the recaptcha_plugin.zip and extract to /Houdini/Houdini/Plugins directory of your Houdini installation, as there is a configuration file that comes with it. Make sure to remember to enable this plugin for all of your login servers (and NONE of your world servers).

Houdini config.py example:

// ... code above ommitted
"Servers": {
	"Login": {
		"Address": "127.0.0.1",
		"Port": 6112,
		"World": False,
		"Plugins": [
			"Captcha"
		],
// ... code below ommitted

Now you should have a new directory in your Houdini installation /Houdini/Houdini/Plugins/Captcha and it should contain the recaptcha plugin __init__.py and captcha.conf, open up captcha.conf in your favourite text editor and paste in your site hostname and secret key:

{
    "Hostname": "HOST_NAME_HERE",
    "VerifyUrl": "https://www.google.com/recaptcha/api/siteverify",
    "SecretKey": "SECRET_KEY_HERE"
}

Replace HOST_NAME_HERE with the hostname of the site where your captcha is embedded, for example if my play page was https://play.clubpenguin.com my hostname would be play.clubpenguin.com, if you’re unsure about this, just leave it blank and Houdini will ignore it.
Replace SECRET_KEY_HERE with your reCAPTCHA secret key you obtained from Google, if you are unsure about this, please see above.

Restart your server, clear your cache and go to login to your game. When you try to login, you should be asked to fill out a captcha, you can keep doing this until you get it right and after you have finished, the login will proceed:

I cannot guarantee that this is completely fail-safe, after all, people could still fill out the CAPTCHA manually, and some users may find this a hassle to complete on every login, although Google’s invisible reCAPTCHA doesn’t ALWAYS provide a challenge, so real users who have completed CAPTCHAs elsewhere may be able to login without any change. Other precautions like limiting connections based on IP at firewall level and monitoring your game closely should still be taken.

Now see the goodies:

recaptcha_plugin.zip (5.3 KB)
recaptcha_dependency.zip (8.9 KB)