如何使用PHP正确添加跨站请求伪造(CSRF)令牌

125

我正在尝试为我的网站中的表单添加一些安全性。其中一个表单使用了 AJAX,另一个是一个简单的“联系我们”表单。我尝试添加 CSRF 令牌。问题在于,有时令牌只会在 HTML 的“value”中显示,而其他情况下则为空。以下是我在 AJAX 表单上使用的代码:

PHP:

if (!isset($_SESSION)) {
    session_start();
    $_SESSION['formStarted'] = true;
}

if (!isset($_SESSION['token'])) {
    $token = md5(uniqid(rand(), TRUE));
    $_SESSION['token'] = $token;
}

HTML :

<input type="hidden" name="token" value="<?php echo $token; ?>" />

有什么建议吗?


只是好奇,token_time 用于什么? - zerkms
@zerkms 我目前没有使用 token_time。我原本想限制令牌有效的时间,但是代码还没有完全实现。为了清晰起见,我已经从上面的问题中将其删除。 - Ken
1
@Ken:所以用户可以在打开表单时获取令牌,提交后却无效(因为它已被使无效)? - zerkms
@zerkms:谢谢你,但我有点困惑。你可以给我提供一个例子吗? - Ken
2
@Ken:好的。假设令牌在上午10:00过期。现在是上午09:59。用户打开表单并获取一个令牌(仍然有效)。然后用户填写表单2分钟,并发送它。由于现在是10:01,因此令牌被视为无效,因此用户会收到表单错误。 - zerkms
4个回答

370

为了安全起见,请不要使用以下方式生成您的令牌:$token = md5(uniqid(rand(), TRUE));

请尝试以下代码:

生成 CSRF 令牌

PHP 7

session_start();
if (empty($_SESSION['token'])) {
    $_SESSION['token'] = bin2hex(random_bytes(32));
}
$token = $_SESSION['token'];

附注:我的雇主的开源项目之一是将random_bytes()random_int()回溯到PHP 5项目的倡议。它采用MIT许可证,在Github和Composer上可用,名称为paragonie/random_compat

PHP 5.3+(或使用ext-mcrypt)

session_start();
if (empty($_SESSION['token'])) {
    if (function_exists('mcrypt_create_iv')) {
        $_SESSION['token'] = bin2hex(mcrypt_create_iv(32, MCRYPT_DEV_URANDOM));
    } else {
        $_SESSION['token'] = bin2hex(openssl_random_pseudo_bytes(32));
    }
}
$token = $_SESSION['token'];

验证CSRF令牌

不要只使用==甚至===,使用hash_equals()(仅适用于PHP 5.6+,但可通过hash-compat库提供给早期版本)。

if (!empty($_POST['token'])) {
    if (hash_equals($_SESSION['token'], $_POST['token'])) {
         // Proceed to process the form data
    } else {
         // Log this as a warning and keep an eye on these attempts
    }
}

深入了解Per-Form Tokens

您可以使用hash_hmac()来进一步限制令牌仅在特定表单中可用。HMAC是一种特定的密钥哈希函数,即使使用较弱的哈希函数(例如MD5),也是安全的。但是,我建议改用SHA-2哈希函数族。

首先,生成第二个令牌以用作HMAC密钥,然后使用类似以下逻辑进行呈现:

<input type="hidden" name="token" value="<?php
    echo hash_hmac('sha256', '/my_form.php', $_SESSION['second_token']);
?>" />

然后在验证令牌时使用一致的操作:

$calc = hash_hmac('sha256', '/my_form.php', $_SESSION['second_token']);
if (hash_equals($calc, $_POST['token'])) {
    // Continue...
}

一个表单生成的令牌不能在没有知道$_SESSION['second_token']的情况下在其他上下文中重复使用。 重要的是,您使用与刚刚在页面上放置的令牌不同的令牌作为HMAC密钥。

奖励:混合方法+Twig集成

任何使用Twig模板引擎的人都可以通过将此过滤器添加到其Twig环境中来受益于简化的双重策略:

$twigEnv->addFunction(
    new \Twig_SimpleFunction(
        'form_token',
        function($lock_to = null) {
            if (empty($_SESSION['token'])) {
                $_SESSION['token'] = bin2hex(random_bytes(32));
            }
            if (empty($_SESSION['token2'])) {
                $_SESSION['token2'] = random_bytes(32);
            }
            if (empty($lock_to)) {
                return $_SESSION['token'];
            }
            return hash_hmac('sha256', $lock_to, $_SESSION['token2']);
        }
    )
);

使用这个Twig函数,您可以像这样使用通用的令牌:
<input type="hidden" name="token" value="{{ form_token() }}" />

或者是锁定的变体:

<input type="hidden" name="token" value="{{ form_token('/my_form.php') }}" />

Twig仅关注模板渲染;您仍然必须正确验证令牌。在我看来,Twig策略提供了更大的灵活性和简单性,同时保持了最大安全性的可能性。

单次使用CSRF令牌

如果您有一个安全要求,每个CSRF令牌只允许使用一次,最简单的策略是在每次成功验证后重新生成它。然而,这样做将使之前的所有令牌无效,这与同时浏览多个选项卡的用户不太匹配。

Paragon Initiative Enterprises为这些特殊情况维护一个 Anti-CSRF库。它仅适用于每个表单令牌一次使用。当会话数据中存储了足够的令牌(默认配置:65535)时,它将首先循环出最旧的未兑换令牌。


1
дёҚй”ҷпјҢдҪҶжҳҜз”ЁжҲ·жҸҗдәӨиЎЁеҚ•еҗҺеҰӮдҪ•жӣҙж”№$tokenпјҹеңЁжӮЁзҡ„жғ…еҶөдёӢпјҢдёҖдёӘд»ӨзүҢз”ЁдәҺз”ЁжҲ·дјҡиҜқгҖӮ - Akam
2
仔细观察 https://github.com/paragonie/anti-csrf 的实现方式。令牌是一次性的,但它存储了多个。 - Scott Arciszewski
3
关于验证CSRF令牌我有一个问题。如果$_POST['token']是空的,我们就不应继续进行,因为这个post请求是没有带上令牌的,对吗? - Hiroki
是的,这被称为关闭失败。 - Scott Arciszewski
1
因为它将被回显到HTML表单中,你希望它是不可预测的,这样攻击者就不能伪造它。你真正实现的是挑战-响应身份验证,而不仅仅是“是的,这个表单是合法的”,因为攻击者可以轻易地欺骗它。 - Scott Arciszewski
显示剩余8条评论

27

安全警告: md5(uniqid(rand(), TRUE)) 不是一种安全的生成随机数的方法。请查看此答案了解更多信息和一种利用加密安全随机数生成器的解决方案。

看起来你的 if 语句需要一个 else。

if (!isset($_SESSION['token'])) {
    $token = md5(uniqid(rand(), TRUE));
    $_SESSION['token'] = $token;
    $_SESSION['token_time'] = time();
}
else
{
    $token = $_SESSION['token'];
}

14
注意:在安全场景中,我不会信任md5(uniqid(rand(), TRUE)); - Scott Arciszewski
直截了当地说,这是对那个人问题的正确回答。另一个获得300多赞的答案解决了OP没有提出的问题。 - timhj

3
变量$token没有从会话中检索出来,即使它已经存在。

-1

你可以使用time()方法和md5()方法使其唯一。

if (!isset($_SESSION['token'])) 
{
   $time = time();
   $_SESSION['token'] = md5($time);
   $_SESSION['token_time'] = $time;
}
else
{
   $token = $_SESSION['token'];
}

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