How to protect your website from Brute force attacks using queues.

Here today at Codingsec we look at how to protect your login areas from Brute force attacks. A Brute force attack also known as a dictionary attack is a technique where security experts or hackers, try words in the dictionary, common used phrases and numerical combinations. Securing your log in area is extremely significant.
The method that we will show you today is called Queuing, this keeps login attempts to a minimum preventing a Brute force attack.
Methods of limiting log in attempts
There are alternative methods, specifically created to prevent brute-forcing, but vast amounts of them have issues that make them unsuitable.Below are a list of other techniques that are commonly used to prevent Brute force attacks, and their downfalls:
  • A commonly used technique is to monitor the number of login attempts made during a session – or just a cookie – if there an excess amount of unsuccessful login attempts, the client is usually blocked from attempting to log in for a period of time. The problem with this method is that it relies on the user-agent to maintain a session/cookie. Those can be blocked or deleted very easily, which would bypass the block entirely.
  • A variation on the above technique is to log the IP address rather than using a session or cookie. This technique deciphers the implausibility of the cookies, although it initiates other issues. IP addresses are not distinctly unique.When blocking an IP, you could in fact be blocking large volumes of unrelated users.IP addresses are easily customised or masked, hackers can also initiate other IP addresses from hacked networks. To outline this monitoring/blocking of IP addresses is not a fully effective technique.]
  • In order to surpass the above issues, simply remove the reliance on client identification and instead block the user who registers to many log in attempts. The significant issue here is that any prankster could easily keep large amounts of users blocked indefinitely by routinely sending a number of invalid log in attempts.
  • Yet another attempt to defy brute-forcing is to slow down the requests themselves, making brute force attacks too slow to be of use. This – in theory – is an good plan, and is in fact the basis of the queuing method I will be demonstrating. However, many implement this rather poorly. We’ve seen people simply drop a sleep(1); into all login code, making the request take 1 second before it completes. The issue with this approach is that even though you are slowing down the request, you aren’t really preventing the hacker from making an obscene amount of attempts. It’ll just take one second longer for the results to start piling in. Issuing 50 requests per second won’t cause those 50 requests to take 50 seconds, it’ll only cause them to take one second + the time it takes each request to execute.
  Issues with queuing
So is queueing free of all those problems? No, definitely not. The main problem you may face with a queueing system is DOS attacks. Similar to that of the third method I describe above, a prankster could easily keep the queue full of invalid requests and make normal login requests take intolerably long. An attacker may also try to continually execute several requests per second, which would in no time overload the server with queued login attempts, potentially even crashing it. (Depending on the server config.)
However, there are ways to minimize these risks:
  • By adding a total queue size you could stop the server from becoming overloaded with login attempts. It would simply drop login requests once the queue has reached a certain size.
  • By splitting the queue into per-user queues, at least a prankster would not be able to stall all attempts by keeping the queue full, only those meant for specific users. (Unless the number of targeted users allows them to exceeds the overall queue size.)
  • By only allowing one queue entry per IP address, you would prevent simple attacks from a single source. An attacker would have to use several IP addresses to make any difference. (Not that it would stop anybody determined, but it may be enough to get script kiddies just messing with your queue times to lose interest.) – It’s worth noting that, as always, any restrictions based on IP addresses are not exactly reliable, and can cause issues for some users. Organizations, for example, frequently fall under the same network routers or proxies, and all the users within that network will therefore share an external IP. Only one of those users could use the system at a time if an IP restriction like this is put in place. – Consider it very carefully before deciding to add such a restriction.
In the example I will be implementing here, I will demonstrate all three of these measures. Don’t take that to mean you should necessarily do so as well!
The Theory
The key to deterring brute-force attacks is to delay each request long enough for the attack to become impractical. If you can only test one password per second, then it’ll take forever to test them all. This makes Brute force attacks a waste of time as they will take too long, normal users will not be affected by this delay.
So, how do we implement this in PHP? The effective technique is to set up a database where each login attempt is entered and an ID is generated for it. The attempt with the lowest ID will then be allowed to be processed, after which it is removed from the database, and the attempt following it is allowed to proceed. The best practice is to have a background process that handles the validation; finding the first unprocessed attempt, validating it, updating it’s status in the database, and moving on to the next one. Requests for login attempts would add their entries to the database, and then periodically check it to see if their attempt has been processed yet, after which they remove the attempt from the database and return the result to the user.
However, background processes can be troublesome for vast amounts of PHP hosts. The technique demonstrated is where each request is responsible for validating their own attempts. They add an entry to the database, then periodically check the database to see if their entry is the first entry listed, then process it, and finally remove it. Simple enough.
Implementation
The first thing we need to do here is set up a database. Because of it’s general availability in the world of PHP, I’m going to use MySQL as my database. Note, however, that you may just as well use in-memory systems like Memcache or the APC extension’s user cache mechanisms. Those would in fact most likely perform better.
The table I’m going to use will contain four columns:
  • An ID column that we can use to order the attempts, and find out which attempt is next to be processed.
  • A last_checked column, which will be updated by each attempt each time the code checks if the attempt is ready. This last_checked column will be used to filter out dead attempts; attempts added by requests that have since been killed off. If we don’t take this precaution, any dead request will stall the entire queue until it’s manually removed.
  • An ip_address column, which will store the unsigned integer representation of the client’s IP address. This column will have a UNIQUE key restraint on it, to make sure that each IP can only exist once in the queue. (You could just as easily store the IP string, but I have a thing about wasted storage space.)
  • A user name column, to store the name of the user that attempt is waiting for. This will be used to split the queue up into per-user queues. This means that even if there are ten attempts queued up for one user, an attempt for another user will not have to wait.

1CREATE TABLE `login_attempt_queue` (
2    `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
3    `last_checked` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
4    `ip_address` INT UNSIGNED NOT NULL,
5    `username` VARCHAR(100) NOT NULL,
6    PRIMARY KEY (`id`),
7    UNIQUE KEY(`ip_address`)
8) ENGINE=MEMORY;
Additionally, since this is a user login system, I will be using this as the table where the password hashes we are validating are stored:
1CREATE TABLE `users` (
2    `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
3    `username` VARCHAR(100) NOT NULL,
4    `password` CHAR(60) NOT NULL,
5    PRIMARY KEY (`id`)
6) ENGINE=InnoDB;
To manage the entire process, the creation of a class can be used to create login attempts, which waits for them to be processed, and then handle the validation result.
Note that this code uses the PHP 5.5 password hashing functions. If you do not have PHP 5.5, there are 3rd party libraries that let you easily add this functionality to older versions. (Down to 5.3, with that particular library.)
001<?php
002
003/**
004 * Provides everything needed to manage and use a login queue.
005 * The login queue will store each login attempt in a database table
006 * and process the entries one at at time, with a delay between
007 * each one. The idea here is to make brute-force attacks on the
008 * login system impractically slow.
009 *
010   *
011 * 
012   *
013 *                      added the single IP restriction.
014 */
015class LoginAttempt
016{
017    /**
018     * @var int The number of milliseconds to sleep between login attempts.
019     */
020    const ATTEMPT_DELAY = 1000;
021
022    /**
023     * @var int The number of milliseconds before an unchecked attempt is
024     *          considered dead.
025     *
026     */
027    const ATTEMPT_EXPIRATION_TIMEOUT = 5000;
028
029    /**
030     * @var int Number of queued attempts allowed per user.
031     */
032    const MAX_PER_USER = 5;
033
034    /**
035     * @var int Number of queued attempts allowed overall.
036     */
037    const MAX_OVERALL = 30;
038
039    /**
040     * The ID assigned to this attempt in the database.
041     *
042     * @var int
043     */
044    private $attemptID;
045
046    /**
047     * @var string
048     */
049    private $username;
050
051    /**
052     * @var string
053     */
054    private $password;
055
056    /**
057     * After the login has been validated, this attribute will hold the
058     * result. Subsequent calls to isValid will return this value, rather
059     * that try to validate it again.
060     *
061     * @var bool
062     */
063    private $isLoginValid;
064
065    /**
066     * An open PDO instance.
067     *
068     * @var PDO
069     */
070    private $pdo;
071
072    /**
073     * Stores the statement used to check whether the attempt is ready to be processed.
074     * As it may be used multiple times per attempt, it makes sense not to initialize
075     * it each ready check.
076     *
077     * @var PDOStatement
078     */
079    private $readyCheckStatement;
080
081    /**
082     * The statement used to update the attempt entry in the database on
083     * each isReady call.
084     *
085     * @var PDOStatement
086     */
087    private $checkUpdateStatement;
088
089    /**
090     * Creates a login attempt and queues it.
091     *
092     * @param string $username
093     * @param string $password
094     * @var \PDO $pdo
095     * @throws Exception
096     */
097    public function __construct($username$password, \PDO $pdo)
098    {
099        $this->pdo = $pdo;
100        if ($this->pdo->getAttribute(PDO::ATTR_ERRMODE) !== PDO::ERRMODE_EXCEPTION) {
101            $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
102        }
103
104        $this->username = $username;
105        $this->password = $password;
106
107        if (!$this->isQueueSizeExceeded()) {
108            $this->addToQueue();
109        }
110        else {
111            throw new Exception("Queue size has been exceeded.", 503);
112        }
113    }
114
115    /**
116     * Creates an entry for the attempt in the database, fetching the id
117     * of it and storing it in the class. Note that no values need to
118     * be entered in the database; the defaults for both columns are fine.
119     */
120    private function addToQueue()
121    {
122        $sql = "INSERT INTO login_attempt_queue (ip_address, username)
123                VALUES (?, ?)";
124        $stmt $this->pdo->prepare($sql);
125        try {
126            $stmt->execute(array(
127                sprintf('%u'ip2long($_SERVER["REMOTE_ADDR"])),
128                $this->username
129            ));
130            $this->attemptID = (int)$this->pdo->lastInsertId();
131        }
132        catch (PDOException $e) {
133            throw new Exception("IP address is already in queue.", 403);
134        }
135    }
136
137    /**
138     * Checks the queue size. Throws an exception if it has been exceeded. Otherwise it does nothing.
139     *
140     * @throws Exception
141     * @return bool
142     */
143    private function isQueueSizeExceeded()
144    {
145        $sql = "SELECT
146                    COUNT(*) AS overall,
147                    COUNT(IF(username = ?, TRUE, NULL)) AS user
148                FROM login_attempt_queue
149                WHERE last_checked > NOW() - INTERVAL ? MICROSECOND";
150        $stmt $this->pdo->prepare($sql);
151        $stmt->execute(array(
152            $this->username,
153            self::ATTEMPT_EXPIRATION_TIMEOUT * 1000
154        ));
155
156        $count $stmt->fetch(PDO::FETCH_OBJ);
157        if (!$count) {
158            throw new Exception("Failed to query queue size", 500);
159        }
160
161        return ($count->overall >= self::MAX_OVERALL || $count->user >= self::MAX_PER_USER);
162    }
163
164    /**
165     * Checks if the login attempt is ready to be processed, and updates the
166     * last_checked timestamp to keep the attempt alive.
167     *
168     * @return bool
169     */
170    private function isReady()
171    {
172        if (!$this->readyCheckStatement) {
173            $sql = "SELECT id FROM login_attempt_queue
174                    WHERE
175                        last_checked > NOW() - INTERVAL ? MICROSECOND AND
176                        username = ?
177                    ORDER BY id ASC
178                    LIMIT 1";
179            $this->readyCheckStatement = $this->pdo->prepare($sql);
180        }
181        $this->readyCheckStatement->execute(array(
182            self::ATTEMPT_EXPIRATION_TIMEOUT * 1000,
183            $this->username
184        ));
185        $result = (int)$this->readyCheckStatement->fetchColumn();
186
187        if (!$this->checkUpdateStatement) {
188            $sql = "UPDATE login_attempt_queue
189                    SET last_checked = CURRENT_TIMESTAMP
190                    WHERE id = ? LIMIT 1";
191            $this->checkUpdateStatement = $this->pdo->prepare($sql);
192        }
193        $this->checkUpdateStatement->execute(array($this->attemptID));
194
195        return $result === $this->attemptID;
196    }
197
198    /**
199     * Checks if the login attempt is valid. Note that this function will cause
200     * the delay between attempts when first called. If called multiple times,
201     * only the first call will do so.
202     *
203     * @return bool
204     */
205    public function isValid()
206    {
207        if ($this->isLoginValid === null) {
208            $sql = "SELECT password
209                    FROM users
210                    WHERE username = ?";
211            $stmt $this->pdo->prepare($sql);
212            $stmt->execute(array($this->username));
213            $realHash $stmt->fetchColumn();
214
215            if ($realHash) {
216                $this->isLoginValid = password_verify($this->password, $realHash);
217            }
218            else {
219                $this->isLoginValid = false;
220            }
221
222            // Sleep at this point, to enforce a delay between login attempts.
223            usleep(self::ATTEMPT_DELAY  * 1000);
224
225            // Remove the login attempt from the queue, as well as any login
226            // attempt that has timed out.
227            $sql = "DELETE FROM login_attempt_queue
228                    WHERE
229                        id = ? OR
230                        last_checked < NOW() - INTERVAL ? MICROSECOND";
231            $stmt $this->pdo->prepare($sql);
232            $stmt->execute(array(
233                $this->attemptID,
234                self::ATTEMPT_EXPIRATION_TIMEOUT * 1000
235            ));
236        }
237
238        return $this->isLoginValid;
239    }
240
241    /**
242     * Calls the callback function when the login attempt is ready, passing along the
243     * result of the validation as the first parameter.
244     *
245     * @param callable|string $callback
246     * @param int $checkTimer Delay between checks, in milliseconds.
247     */
248    public function whenReady($callback$checkTimer=250)
249    {
250        while (!$this->isReady()) {
251            usleep($checkTimer * 1000);
252        }
253
254        if (is_callable($callback)) {
255            call_user_func($callback$this->isValid());
256        }
257    }
258}
Usage
To sum up the functionality of the class, we only have two public methods we need to concern ourselves with.
  • __construct creates the attempt, taking the username, the password and a PDO instance. It sets their respective class attributes to those values, and then triggers the addToQueue function, which goes on to create a new database entry for the class and set the attemptID attribute.
  • whenReady is our “listener”, so to speak. It takes a callable function as the first parameter, and optionally a delay timer value as the second parameter. It will keep calling the isReady function in a loop, each iteration delayed by the value of that second parameter, until it returns TRUE, thus reporting that the attempt is next in line to be processed. Then it will go on to call the the isValid function, which checks the validity of the attempt and removes it from the database. Finally it calls the callback function, and passes the validity value with it as it’s only parameter.
Here is an example of how this could be used on a login form’s action page:
01<?php
02require "LoginAttempt.php";
03
04if (!empty($_POST["username"]) && !empty($_POST["password"])) {
05    $dsn "mysql:host=localhost;dbname=test";
06    $pdo new PDO($dsn"username""password");
07
08    try {
09        $attempt new LoginAttempt($_POST["username"],$_POST["password"], $pdo);
10        $attempt->whenReady(function($success) {
11            echo $success "Valid" "Invalid";
12        });
13    }
14    catch (Exception $e) {
15        if ($e->getCode() == 503) {
16            header("HTTP/1.1 503 Service Unavailable");
17            exit;
18        }
19        else if ($e->getCode() == 403) {
20            header("HTTP/1.1 403 Forbidden");
21            exit;
22        }
23        else {
24            echo "Error: " $e->getMessage();
25        }
26
27        // Note here that it may be advisable to show the
28        // same response for error messages that you show
29        // for invalid requests. That way it'll be less
30        // obvious to attackers that their requests are
31        // being rejected rather than processed and
32        // invalidated.
33    }
34}
35else {
36    echo "Error: Missing user input.";
37}
For PHP 5.2 or lower, the above method of using a closure for the whenReady function is not possible. Instead you would have to define a function, and then pass the name of it as the first parameter to whenReady:
1function onReady($isValid) {
2    echo $isValid "Valid" "Invalid";
3}
4
5$attempt new LoginAttempt($_POST["username"], $_POST["password"],$pdo);
6$attempt->whenReady("onReady");
Conclusion
If using this technique, be aware that certain things need to be taken in to consideration. Make sure the ATTEMPT_DELAY value is appropriate. The value of 1000 ms presented is just a suggestion. You may want to alter this and tailor it to the amount of web traffic you get on your server. Make sure the execution time of the login script is also appropriately set. Requests may need to wait in line for some time, so make sure your PHP doesn’t cancel the request before it is processed effectively. Don’t set it too high either; request lingering open forever isn’t a good thing. It is essential to tailor the configuration to your specific needs, I hope you have enjoyed reading this article stay tuned for more soon!

Comments