关于安全性:如何限制PHP中的用户登录尝试

How can I throttle user login attempts in PHP

我刚刚读了这篇文章,关于防止快速登录尝试的基于表单的网站认证的最终指南。

最佳实践1:随着失败尝试次数的增加而增加的短时间延迟,例如:

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

DOS攻击这个方案是非常不切实际的,但另一方面,由于延迟呈指数级增长,可能具有破坏性。

我很好奇如何在PHP中为我的登录系统实现这样的功能?


您不能简单地通过将限制链接到单个IP或用户名来防止DOS攻击。该死的,你甚至不能真正阻止使用这种方法的快速登录尝试。

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

我在其他地方看到过,理想情况下,您应该跟踪整个站点中所有失败的登录尝试,并将它们关联到时间戳,也许:

1
2
3
4
5
6
7
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地址转换为无符号整数和将其转换为无符号整数。

1
2
3
4
# 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表中提取的统计数据,因为它会随着时间的推移而变化,这取决于用户的数量以及他们中有多少人可以调用(和键入)他们的密码。

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

查询每个登录失败尝试的表,以查找给定时间段内失败登录的次数,例如15分钟:

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

如果给定时间段内的尝试次数超过了您的限制,则强制限制或强制所有用户使用验证码(即recaptcha),直到给定时间段内失败的尝试次数小于阈值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 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可以确保停止来自多个前端的攻击,并且正常站点用户不会因合法的失败登录尝试而经历显著延迟。


您有三种基本方法:存储会话信息、存储cookie信息或存储IP信息。

如果使用会话信息,最终用户(攻击者)可以强制调用新会话,绕过策略,然后立即重新登录。会话的实现非常简单,只需将用户上次已知的登录时间存储在会话变量中,将其与当前时间匹配,并确保延迟足够长。

如果你使用cookie,攻击者可以简单地拒绝cookie,总之,这是不可行的。

如果跟踪IP地址,则需要以某种方式(最好是在数据库中)存储来自IP地址的登录尝试。当用户试图登录时,只需更新您记录的IP列表。您应该以合理的间隔清除此表,转储一段时间内未处于活动状态的IP地址。陷阱(总是有一个陷阱)是,有些用户最终可能会共享一个IP地址,在边界条件下,您的延迟可能会不小心地影响用户。因为您跟踪的是失败的登录,而只有失败的登录,所以这不会造成太多的痛苦。


登录过程需要降低成功登录和不成功登录的速度。登录尝试本身的速度不应超过大约1秒。如果是这样,蛮力使用延迟来知道尝试失败,因为成功比失败短。然后,每秒可以评估更多的组合。

每台计算机同时登录尝试的次数需要受到负载均衡器的限制。最后,您只需要跟踪同一个用户或密码是否被多个用户/密码登录尝试重复使用。人类的打字速度不能超过每分钟200字。因此,连续或同时的登录尝试速度超过每分钟200字来自一组机器。因此,可以安全地将这些数据传输到黑名单,因为它不是您的客户。每个主机的黑名单时间不需要大于大约1秒。这永远不会给人类带来不便,但会对一次暴力尝试造成严重破坏,无论是串联还是并联。

2*10^19个组合,每秒一个组合,在40亿个单独的IP地址上并行运行,需要158年才能作为搜索空间耗尽。为了让每个用户能够抵御40亿攻击者,您需要一个完全随机的字母数字密码,密码长度至少为9位。考虑将用户训练成至少13个位置长、1.7*10^20个组合的及格短语。

这种延迟,将激发攻击者窃取您的密码散列文件,而不是暴力强迫您的网站。使用批准的、命名的散列技术。禁止整个互联网IP用户使用一秒钟,将限制并行攻击的效果,而不需要一个人类会欣赏的交易。最后,如果您的系统在一秒钟内允许超过1000次失败的登录尝试,而没有对禁止系统做出响应,那么您的安全计划就有更大的问题需要解决。首先修复自动响应。


按IP在数据库中存储失败尝试。(既然你有登录系统,我想你很清楚该怎么做。)

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

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

1
2
3
4
5
6
7
$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
2
3
4
5
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.

或者按照赛罗的建议:

1
sleep(2 ^ (intval($_SESSION['hit']) - 1));

它有点粗糙,但基本的部件都在那里。如果刷新此页,每次刷新时延迟将变长。

您还可以将计数保存在数据库中,在数据库中检查IP的失败尝试次数。通过基于IP使用它并将数据保存在您身边,您可以阻止用户清除他们的cookie以停止延迟。

基本上,开始代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$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;
    }
}


imho,防御DoS攻击在Web服务器级别(甚至在网络硬件中)处理得更好,而不是在您的PHP代码中。


您可以使用会话。每当用户登录失败时,您都会增加存储尝试次数的值。您可以根据尝试次数计算所需的延迟,也可以设置允许用户在会话中重试的实际时间。

一个更可靠的方法是将尝试和新的尝试时间存储在该特定IP地址的数据库中。


简短的回答是:不要这样做。你不会保护自己免受野蛮的强迫,你甚至会使你的处境更糟。

所有建议的解决方案都不起作用。如果您使用IP作为限制的任何参数,攻击者只会跨越大量IP进行攻击。如果使用会话(cookie),攻击者只会丢弃任何cookie。总之,你能想到的是,没有任何东西是一个蛮力的攻击者无法克服的。

不过,有一件事是-你只需依赖尝试登录的用户名。所以,不考虑所有其他参数,而是跟踪用户尝试登录和限制的频率。但攻击者想伤害你。如果他认识到这一点,他也会粗暴地使用用户名。

这将导致几乎所有用户在尝试登录时都被限制到最大值。你的网站将毫无用处。攻击者:成功。

一般来说,您可以将密码检查延迟约200毫秒-网站用户几乎不会注意到这一点。但是蛮力的钳子会的。(同样,他可以跨IP)但是,所有这些都不能保护您免受暴力强迫或DDOS的攻击-因为您不能编程。

唯一的方法就是使用基础设施。

您应该使用bcrypt而不是md5或sha-x来散列密码,如果有人窃取了您的数据库,这将使解密密码变得更加困难(因为我猜您在共享或托管主机上)。

很抱歉让您失望,但是这里的所有解决方案都有一个弱点,在后端逻辑中无法克服它们。


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

如果攻击者有多个IP来启动他/她的请求,则可以绕过IP检查;如果多个用户从同一IP连接到您的服务器,则可能会遇到麻烦。在后一种情况下,如果有人多次登录失败,将阻止所有共享同一IP的人在一段时间内使用该用户名登录。

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

登录系统应防止的唯一额外事情是尝试检查功能上的竞争条件。例如,在下面的伪代码中

1
2
3
4
5
6
7
8
9
$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);
}

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


根据上面的讨论,会话、cookie和IP地址都是无效的——所有这些都可以被攻击者操纵。

如果您想防止暴力攻击,那么唯一可行的解决方案是根据所提供的用户名确定尝试次数,但是请注意,这允许攻击者通过阻止有效用户登录来完成站点操作。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$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)。您还可以调整算法,以便根据以前的延迟和文件上的时间戳计算延迟。

高温高压

C.


我通常创建登录历史和登录尝试表。尝试表将记录用户名、密码、IP地址等。查询表以查看是否需要延迟。我建议在给定时间(例如一小时)内完全阻止超过20次的尝试。


cballo提供了一个很好的答案。我只是想通过提供一个支持mysqli的更新版本来回报这个好处。我稍微修改了sqls中的表/字段列和其他一些小的东西,但是它应该可以帮助任何寻找mysqli等价物的人。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function get_multiple_rows($result) {
  $rows = array();
  while($row = $result->fetch_assoc()) {
    $rows[] = $row;
  }
  return $rows;
}

$throttle = array(10 => 1, 20 => 2, 30 => 5);

$query ="SELECT MAX(time) AS attempted FROM failed_logins";    

if ($result = $mysqli->query($query)) {

    $rows = get_multiple_rows($result);

$result->free();

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

$query ="SELECT COUNT(1) AS failed FROM failed_logins WHERE time > DATE_SUB(NOW(),
INTERVAL 15 minute)";  

if ($result = $mysqli->query($query)) {

$rows = get_multiple_rows($result);

$result->free();

    $failed_attempts = (int) $rows[0]['failed'];

    krsort($throttle);
    foreach ($throttle as $attempts => $delay) {
        if ($failed_attempts > $attempts) {
                echo $failed_attempts;
                $remaining_delay = (time() - $latest_attempt) - $delay;

                if ($remaining_delay < 0) {
                echo 'You must wait ' . abs($remaining_delay) . ' seconds before your next login attempt';
                }                

            break;
        }
     }        
  }
}