如何在PHP中限制用户登录尝试次数

61

我刚阅读了这篇文章《网站认证的权威指南》,其中有关于防止快速登录尝试的内容。

最佳实践#1:一个短暂的时间延迟,随着失败次数的增加而逐渐增加,如下:

1次失败尝试=没有延迟
2次失败尝试=2秒延迟
3次失败尝试=4秒延迟
4次失败尝试=8秒延迟
5次失败尝试=16秒延迟
等等。

攻击此方案的DoS攻击是非常不现实的,但另一方面,这可能会是灾难性的,因为延迟呈指数增长。

我想知道如何在我的PHP登录系统中实现类似的功能?


4
请确保没有算术溢出,否则可能会导致负延迟。 - Hamish Grubijan
7
不要延迟,只需在x次尝试后完全阻止。当机器人尝试登录时发送404。没有必要通过调整延迟来使事情更复杂化。此外,人类失败3次(并获得长时间延迟)也是可能的。 - sestocker
29
@sestocker,实际上我建议在这里使用“418我是茶壶”而不是404。http://en.wikipedia.org/wiki/Http_status_codes; o) - deceze
1
@sestocker:你不想阻止登录有很多原因;DoS是一个原因,实用性是另一个原因(如果你的网站有数百万个账户,你不想手动重新激活你的用户)。此外,4或8秒对于人类来说不是一个“长时间”的延迟,但在暴力攻击中却是一个严重的麻烦。你应该将延迟限制在15或30分钟左右,并提供基于IP或cookie的“直接线路”,以便真正的用户不会因为多次失败的尝试而被锁定。详细信息请阅读链接的帖子。 - Jens Roland
2
4或8秒对于忘记密码并想要检查十几个变体的人来说是一个很长的延迟(“那是一个!还是@在结尾?”)。看看安卓如何处理屏幕锁定:5次尝试,延迟,5次尝试,延迟...这是一种更人性化的方法。 - Kos
显示剩余3条评论
11个回答

85

仅仅通过将限流应用于单个IP或用户名,无法简单地防止DoS攻击。使用此方法实际上甚至无法防止快速登录尝试。

为什么? 因为攻击可以跨越多个IP和用户帐户来绕过您的限流尝试。

我在其他地方看到过类似的建议,最好跟踪整个站点中的所有失败的登录尝试并将它们与时间戳关联起来,例如:

CREATE TABLE failed_logins (
    id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(16) NOT NULL,
    ip_address INT(11) UNSIGNED NOT NULL,
    attempted DATETIME NOT NULL,
    INDEX `attempted_idx` (`attempted`)
) engine=InnoDB charset=UTF8;

关于ip_address字段的快速说明:你可以使用INET_ATON()和INET_NTOA()函数存储和检索数据,这些函数实际上等同于将IP地址转换为无符号整数,以及从无符号整数转换回IP地址。

# example of insertion
INSERT INTO failed_logins SET username = 'example', ip_address = INET_ATON('192.168.0.1'), attempted = CURRENT_TIMESTAMP;
# example of selection
SELECT id, username, INET_NTOA(ip_address) AS ip_address, attempted;

根据给定时间内(例如在15分钟内)失败登录的总数,确定某些延迟阈值。您应该基于从 failed_logins 表中获取的统计数据进行此操作,因为它会随着用户数量和能够回忆起(并键入)密码的用户数量而随着时间而变化。


> 10 failed attempts = 1 second
> 20 failed attempts = 2 seconds
> 30 failed attempts = reCaptcha

查询登录失败的表以查找给定时间段(比如15分钟)内失败登录的次数:


SELECT COUNT(1) AS failed FROM failed_logins WHERE attempted > DATE_SUB(NOW(), INTERVAL 15 minute);

如果在指定的时间段内尝试次数超过限制,可以通过实施限速或强制所有用户使用验证码(例如 reCaptcha),直到在给定时间段内失败的尝试次数小于阈值为止。

// array of throttling
$throttle = array(10 => 1, 20 => 2, 30 => 'recaptcha');

// retrieve the latest failed login attempts
$sql = 'SELECT MAX(attempted) AS attempted FROM failed_logins';
$result = mysql_query($sql);
if (mysql_affected_rows($result) > 0) {
    $row = mysql_fetch_assoc($result);

    $latest_attempt = (int) date('U', strtotime($row['attempted']));

    // get the number of failed attempts
    $sql = 'SELECT COUNT(1) AS failed FROM failed_logins WHERE attempted > DATE_SUB(NOW(), INTERVAL 15 minute)';
    $result = mysql_query($sql);
    if (mysql_affected_rows($result) > 0) {
        // get the returned row
        $row = mysql_fetch_assoc($result);
        $failed_attempts = (int) $row['failed'];

        // assume the number of failed attempts was stored in $failed_attempts
        krsort($throttle);
        foreach ($throttle as $attempts => $delay) {
            if ($failed_attempts > $attempts) {
                // we need to throttle based on delay
                if (is_numeric($delay)) {
                    $remaining_delay = time() - $latest_attempt - $delay;
                    // output remaining delay
                    echo 'You must wait ' . $remaining_delay . ' seconds before your next login attempt';
                } else {
                    // code to display recaptcha on login form goes here
                }
                break;
            }
        }        
    }
}

使用reCaptcha在一定阈值内可以确保多方面的攻击被停止,同时正常的站点用户在合法的登录失败尝试中不会经历显著的延迟。


1
我喜欢这个想法。也许你可以看一下这篇文章:https://dev59.com/tHRB5IYBdhLWcg3w4bGv它讨论了完全相同的问题(分布式暴力破解),如果你能在那里详细发表你的想法,那就太好了。 - Jens Roland
我认为"$remaining_delay = time() - $latest_attempt - $delay;"是不正确的。应该改成这样:"$remaining_delay = $delay - (time() - $latest_attempt);" - Curt
3
我根据您在此评论中提出的概念,并进行了一些改进,创建了一个名为BruteForceBlocker的类,供任何想要使用它的人使用。https://github.com/ejfrancis/BruteForceBlocker - ejfrancis
我有一个问题。如果我屏蔽用户名的IP地址,这样可以吗?例如,如果IP地址和用户名尝试了10次,则屏蔽该IP地址以防止再次尝试该用户名。但是,真正的用户可以使用其不同的IP地址正常登录。这种方法可以吗,还是应该将其与上面的答案集成在一起? - Martynogea
我很好奇为什么failed_logins表中需要id列?假设您经常清空该表,但也许还有其他原因?顺便说一句,感谢您提供的出色答案。 - Kosta Kontos
显示剩余2条评论

6
你有三种基本方法:存储会话信息、存储Cookie信息或存储IP信息。
如果你使用会话信息,最终用户(攻击者)可以强制调用新会话,绕过你的策略,然后再次登录而没有延迟。会话实现起来非常简单,只需在会话变量中存储用户的最后已知登录时间,将其与当前时间进行匹配,并确保延迟足够长。
如果你使用Cookie,攻击者可以简单地拒绝Cookie,总的来说,这并不可行。
如果你跟踪IP地址,你需要以某种方式存储来自IP地址的登录尝试,最好是在数据库中。当用户尝试登录时,只需更新记录的IP列表。你应该在合理的间隔内清除此表,删除一段时间内未活动的IP地址。缺点(总有缺点)是,一些用户可能最终共享一个IP地址,在边界条件下,你的延迟可能会意外地影响用户。由于你正在跟踪失败的登录,而且只有失败的登录,所以这不应该造成太多痛苦。

IP地址不是一个好的解决方案: 1)它们经常被共享 2)使用TOR很容易更改地址 - symcbean
2
@symcbean 我提出了多种解决方案,其中任何一种组合都可以阻止某些攻击者,没有神奇的解决方案。IP地址共享的问题不是很大,正如我在我的答案中所讨论的那样;使用TOR更改它似乎比强制新会话的可能性要小。我错过了第四个选项吗? - Mark Elliot
2
如果使用TOR,则通常会通过多个涉及的层次结构的开销进行隐式限流。按设计,TOR将必然增加暴力攻击的复杂度。 - humanityANDpeace

4
登录过程需要减慢成功和失败的登录速度。登录尝试本身的时间不应该快于1秒钟。如果快于1秒钟,暴力破解会利用这个延迟知道尝试失败,因为成功比失败短。然后,每秒可以评估更多的组合。
每台机器的同时登录尝试次数需要由负载均衡器限制。最后,您只需要跟踪同一用户或密码是否被多个用户/密码登录尝试重复使用。人类无法以超过每分钟200个单词的速度输入。因此,每分钟超过200个单词的连续或同时登录尝试来自一组机器。因此,这些可以安全地管道到黑名单中,因为它不是您的客户。主机每秒的黑名单时间不需要超过1秒钟。这永远不会对人类造成不便,但却对串行或并行的暴力攻击造成了麻烦。
在4亿个独立IP地址上并行运行每秒钟一个组合的2 * 10^19组合将花费158年来耗尽搜索空间。为了对抗40亿攻击者持续一天,您至少需要具有9位完全随机的字母数字密码。考虑培训用户使用至少13个位置的密码短语,1.7 * 10^20组合。
这种延迟将激励攻击者窃取您的密码哈希文件,而不是暴力破解您的站点。使用经过批准的命名哈希技术。禁止整个互联网IP人口一秒钟,将限制并行攻击的影响,而不需要人类欣赏的延迟。最后,如果您的系统允许每秒钟有超过1000次失败的登录尝试而没有任何响应来禁止系统,则您的安全计划存在更大的问题。首先修复自动响应。

4
简短的回答是:不要这样做。你不会保护自己免受暴力破解攻击,甚至可能会使情况更糟。
所有提出的解决方案都行不通。如果你使用IP作为任何限制的参数,攻击者将跨越大量IP进行攻击。如果你使用会话(cookie),攻击者将只需删除任何cookie。总之,暴力破解攻击者可以克服你能想到的一切。
然而,有一件事——你只依赖于试图登录的用户名。因此,不考虑所有其他参数,你可以追踪用户尝试登录的次数并进行限制。但攻击者想要伤害你。如果他意识到这一点,他也会暴力破解用户名。
这将导致几乎所有用户在尝试登录时被限制到最大值。你的网站将毫无用处。攻击者:成功了。
你可以普遍延迟密码检查约200ms——网站用户几乎不会注意到。但暴力破解者会注意到。(同样,他可以跨越IP)然而,所有这些都无法保护你免受暴力破解或DDoS攻击——因为你无法进行编程。
唯一的方法是使用基础设施。
你应该使用bcrypt而不是MD5或SHA-x来哈希密码,这将使解密你的密码变得更加困难,如果有人窃取了你的数据库(因为我猜你在共享或托管主机上)。
很抱歉让你失望,但这里所有的解决方案都有弱点,在后端逻辑中无法克服它们。

3

将失败的尝试按IP地址存储在数据库中。(由于您有一个登录系统,我认为您知道如何做。)

显然,会话是一种诱人的方法,但真正专注的人可以很容易地意识到他们可以在尝试失败时删除其会话cookie以完全规避限制。

尝试登录时,获取最近(例如,最近15分钟)登录尝试的数量以及最新尝试的时间。

$failed_attempts = 3; // for example
$latest_attempt = 1263874972; // again, for example
$delay_in_seconds = pow(2, $failed_attempts); // that's 2 to the $failed_attempts power
$remaining_delay = time() - $latest_attempt - $delay_in_seconds;
if($remaining_delay > 0) {
    echo "Wait $remaining_delay more seconds, silly!";
}

1
数据库绝对是解决这个问题的方法。这样你还可以回顾历史记录。 - sestocker
我在想像这样的东西,我认为vbulletin论坛做了类似的事情,会话也可以通过关闭浏览器并回来来重置。 - JasonDavis
你能解释一下 pow(2, $failed_attempts) 会创建什么样的时间吗? - JasonDavis
3
我建议你不要使用睡眠功能,因为它会阻塞 PHP 实例直到睡眠结束。如果攻击者打开多个连接来暴力破解服务器,PHP 请求会很快被积压。在该 IP 的“延迟”时间内,最好失败所有登录尝试。 - Kendall Hopkins
2
我会将 $remaining_delay = min(3600, $remaining_delay); 设为上限。 - Alix Axel
显示剩余2条评论

2
session_start();
$_SESSION['hit'] += 1; // Only Increase on Failed Attempts
$delays = array(1=>0, 2=>2, 3=>4, 4=>8, 5=>16); // Array of # of Attempts => Secs

sleep($delays[$_SESSION['hit']]); // Sleep for that Duration.

或者像Cyro建议的那样:
sleep(2 ^ (intval($_SESSION['hit']) - 1));

这段代码还有些粗糙,但基本组件已经齐备。如果您刷新此页面,每次刷新时延迟时间会变长。

您也可以将计数保存在数据库中,并检查IP地址的失败尝试次数。通过基于IP地址使用它并将数据保存在您的服务器上,您可以防止用户清除cookie以停止延迟。

基本上,开始的代码应该是:

$count = get_attempts(); // Get the Number of Attempts

sleep(2 ^ (intval($count) - 1));

function get_attempts()
{
    $result = mysql_query("SELECT FROM TABLE WHERE IP=\"".$_SERVER['REMOTE_ADDR']."\"");
    if(mysql_num_rows($result) > 0)
    {
        $array = mysql_fetch_assoc($array);
        return $array['Hits'];
    }
    else
    {
        return 0;
    }
}

1
你也可以使用:sleep(2 ^ (intval($_SESSION['hit']) - 1)); - nortron
29
显而易见的问题是,一个严肃的暴力攻击者不会费心去处理 cookies,因此会使 session 变得毫无价值。 - deceze
睡眠(2 ^ (intval($count) - 1)); 我有点喜欢数组,因为我可以设置等待的时间,但我很好奇,这是怎么算出来的?另外,如果我要保存到数据库,用户登录后,我是否应该从数据库中删除他们的点击记录,以便下次登录时重新开始? - JasonDavis
你需要设置一个过期时间,因为延迟应该在一定时间后过期。其他的事情就由你决定了。如果有人登录/注销并尝试重新登录,你可能会或可能不想保留他们过去的延迟计时器。这取决于你自己。 - Tyler Carter
也请记住,Cryo的答案未使用数组。 - Tyler Carter
-1 是因为任何试图这样做的人都不会为他们的机器人启用 cookies。 - Lotus Notes

2

在我看来,对抗DOS攻击最好是在Web服务器级别(甚至是在网络硬件中)处理,而不是在你的PHP代码中。


8
没错,但有时你需要用手中的棍子来战斗。 - Xeoncross

1
根据上述讨论,会话、Cookie和IP地址都不够安全——攻击者可以操纵它们。
如果您想要防止暴力破解攻击,那么唯一实际可行的解决方案是基于提供的用户名来限制尝试次数。但请注意,这样做会允许攻击者通过阻止有效用户登录来进行拒绝服务攻击。
例如:
$valid=check_auth($_POST['USERNAME'],$_POST['PASSWD']);
$delay=get_delay($_POST['USERNAME'],$valid);

if (!$valid) {
   header("Location: login.php");
   exit;
}
...
function get_delay($username,$authenticated)
{
    $loginfile=SOME_BASE_DIR . md5($username);
    if (@filemtime($loginfile)<time()-8600) {
       // last login was never or over a day ago
       return 0;
    }
    $attempts=(integer)file_get_contents($loginfile);
    $delay=$attempts ? pow(2,$attempts) : 0;
    $next_value=$authenticated ? 0 : $attempts + 1;
    file_put_contents($loginfile, $next_value);
    sleep($delay); // NB this is done regardless if passwd valid
    // you might want to put in your own garbage collection here
 }

请注意,按照当前的写法,此过程会泄露安全信息 - 即攻击者可以看到用户何时登录(攻击者的响应时间将降至0)。您还可以调整算法,使延迟基于先前的延迟和文件时间戳计算。

一个更实际的方法是记录所有失败的登录尝试,并查看最近 ~10 分钟内失败尝试的次数是否有问题,否则攻击者可以不断交替使用用户名。我为您编写了一个执行此操作的类 https://github.com/ejfrancis/BruteForceBlocker - ejfrancis

1

在这种情况下,Cookie或基于会话的方法当然是无用的。应用程序必须检查以前的登录尝试的IP地址或时间戳(或两者兼而有之)。

如果攻击者有多个IP地址可以启动请求,则可以绕过IP检查,并且如果多个用户从同一个IP连接到您的服务器,则可能会出现问题。在后一种情况下,多次登录失败的人将阻止共享相同IP的所有人使用该用户名登录一段时间。

时间戳检查与上述问题相同:每个人都可以通过尝试多次来防止其他人登录特定帐户。使用验证码而不是长时间等待最后一次尝试可能是一个很好的解决方法。

登录系统唯一需要防止的额外事项是尝试检查函数上的竞争条件。例如,在以下伪代码中:

$time = get_latest_attempt_timestamp($username);
$attempts = get_latest_attempt_number($username);

if (is_valid_request($time, $attempts)) {
    do_login($username, $password);
} else {
    increment_attempt_number($username);
    display_error($attempts);
}

如果攻击者向登录页面发送同时请求会发生什么?可能所有请求都会以相同的优先级运行,很有可能在其他请求通过第二行之前没有请求到达increment_attempt_number指令。因此,每个请求都会获得相同的$time和$attempts值并被执行。对于复杂的应用程序来说,防止这种安全问题可能很困难,并涉及锁定和解锁数据库中的某些表/行,当然会减慢应用程序的速度。

标准应用程序在VPS或共享主机上运行时,每秒只能处理约5-30个请求。因此,您的方法确实有效,但可能会在您封锁它们之前进行30次尝试。还要检查您的Apache日志,特别是POST请求等内容。 - Xeoncross

1
您可以使用会话。每当用户登录失败时,您都会增加存储尝试次数的值。您可以根据尝试次数计算所需延迟时间,或者在会话中设置用户允许再次尝试的实际时间。
更可靠的方法是将尝试次数和新尝试时间存储在特定IP地址的数据库中。

我目前做的类似于这样,但我在想如果有DoS攻击,我不确定机器人或其他任何东西是否仍然可以使用会话,但我猜它必须能够工作。 - JasonDavis
4
机器人可以轻松选择忽略会话 cookie。使用基于 IP 的数据库,机器人除了更换 IP 之外无能为力。 - Matchu
@Matchu - 如果你这样做,你会冒着进行成千上万甚至数百万次不必要的数据库调用和以其他方式耗费资源的风险。我相信有一些综合解决方案比你的建议更好。 - JM4

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接