OAuth state参数 CSRF

记录

之前觉得问题都是出在RP端的,比如没有正确校验state参数,没有正确使用state参数这些。刚刚突然发现其实IdP端也没有校验好。

有些网站压根就没有使用state参数,这种情况下,作为IdP端,应该是提示需要有随机参数的,但是像微信、微博都是当做正常情况下处理的。(可能是为了给应用网站提供更大的自主空间,允许他们使用自己定义的参数,毕竟讲道理只要应用网站做好校验,就可以避免漏洞的)

还发现CSDN在使用OAuth来绑定微信账号的时候,state参数是固定为了csdn,但是因为CSDN在绑定账号前需要进行身份认证,所以,其实也是不存在可利用的漏洞的,无法发起CSRF

另外,还有一个问题,如果应用网站的处理是:校验了state参数。讲道理应该是没有问题的。但是我用的这个OAuth client端的模版(thephpleague),给的示例代码里其实是存在问题的。

示例代码如下(https://oauth2-client.thephpleague.com/usage/):

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
$provider = new \League\OAuth2\Client\Provider\GenericProvider([
    'clientId'                => 'XXXXXX',    // The client ID assigned to you by the provider
    'clientSecret'            => 'XXXXXX',    // The client password assigned to you by the provider
    'redirectUri'             => 'https://my.example.com/your-redirect-url/',
    'urlAuthorize'            => 'https://service.example.com/authorize',
    'urlAccessToken'          => 'https://service.example.com/token',
    'urlResourceOwnerDetails' => 'https://service.example.com/resource'
]);

// If we don't have an authorization code then get one
if (!isset($_GET['code'])) {

    // Fetch the authorization URL from the provider; this returns the
    // urlAuthorize option and generates and applies any necessary parameters
    // (e.g. state).
    $authorizationUrl = $provider->getAuthorizationUrl();

    // Get the state generated for you and store it to the session.
    $_SESSION['oauth2state'] = $provider->getState();

    // Redirect the user to the authorization URL.
    header('Location: ' . $authorizationUrl);
    exit;

// Check given state against previously stored one to mitigate CSRF attack
} elseif (empty($_GET['state']) || (isset($_SESSION['oauth2state']) && $_GET['state'] !== $_SESSION['oauth2state'])) {

    if (isset($_SESSION['oauth2state'])) {
        unset($_SESSION['oauth2state']);
    }

    exit('Invalid state');

} else {

    try {

        // Try to get an access token using the authorization code grant.
        $accessToken = $provider->getAccessToken('authorization_code', [
            'code' => $_GET['code']
        ]);

        // We have an access token, which we may use in authenticated
        // requests against the service provider's API.
        echo 'Access Token: ' . $accessToken->getToken() . "<br>";
        echo 'Refresh Token: ' . $accessToken->getRefreshToken() . "<br>";
        echo 'Expired in: ' . $accessToken->getExpires() . "<br>";
        echo 'Already expired? ' . ($accessToken->hasExpired() ? 'expired' : 'not expired') . "<br>";

        // Using the access token, we may look up details about the
        // resource owner.
        $resourceOwner = $provider->getResourceOwner($accessToken);

        var_export($resourceOwner->toArray());

        // The provider provides a way to get an authenticated API request for
        // the service, using the access token; it returns an object conforming
        // to Psr\Http\Message\RequestInterface.
        $request = $provider->getAuthenticatedRequest(
            'GET',
            'https://service.example.com/resource',
            $accessToken
        );

    } catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {

        // Failed to get the access token or user details.
        exit($e->getMessage());

    }

}

代码逻辑是:

  1. 先处理没有code参数的情况,也就是用户请求登录时,而不是从第三方认证网站重定向回来时
  2. 再处理state参数为空(包括没有state参数和state参数为''的情况),或者session中有state且提交的state参数与sessionstate不相同的情况。这种情况就是认为不合法(此时为从第三方认证网站重定向回来时)
  3. 最后处理的就是合法情况(此时为从第三方认证网站重定向回来时)

但是如果是这样的情况:受害者未曾请求过绑定第三方账号,那么session中就不会有state的信息,而攻击者在自己的客户端发起绑定请求(state参数应该是和他自己的客户端对应的,或者就是随便一个state参数),之后用自己的第三方账号认证,然后拦截带code的跳转链接,发送给受害者,受害者点击之后,就会以受害者的身份去访问这个请求。

在这样的情况下,服务器端没有与受害者对应的state参数,那么进行判断的时候,上述第一步判断不通过,第二步,state不为空,且session::has('Oauth2state')不成立,第二步也判断不成功,最终就会进入到第三步合法情况中。导致让攻击者的第三方账号与受害者的应用网站账号绑定在一起。(OAuth的一个典型CSRF攻击)

所以应当在上述第三步之前再增加一步校验当前session中有无state信息,如果没有的话,直接判定为不合法行为。

Server端代码修改

我需要模拟现在的很多Server端不校验state参数是否存在的情况。我使用的模版https://bshaffer.github.io/oauth2-server-php-docs/cookbook/,给的参考代码是默认是会检查state参数是否存在的。

检查的代码在Controller/AuthorizeController.php中,位于AuthorizeController这个类中,创建这个类的时候有可选配置,传递$config = ['enforce_state' => false]即可。

创建AuthorizeController类是在Server.php中,这个类是属于Server类的一个变量。然后查看Server类的__construct函数中,也有一个配置参数$config,可供开发者使用。其中就包括对state使用情况的配置。因此只需要在参考代码的基础上,添加$config配置即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
        /* OAuth server相关配置 */
        $dsn = "mysql:dbname=$db_name;host=$host";
        // $dsn = 'mysql:dbname=thinkphp;host=127.0.0.1';
        \OAuth2\Autoloader::register();
 
        // $dsn is the Data Source Name for your database, for exmaple "mysql:dbname=my_oauth2_db;host=localhost"
        $storage = new \OAuth2\Storage\Pdo(array('dsn' => $dsn, 'username' => $username, 'password' => $password));
       
        $config = ['enforce_state' => false];
        // Pass a storage object or array of storage objects to the OAuth2 server class
        $server = new \OAuth2\Server($storage, $config);  // 在此处传递$config参数即可

        // Add the "Client Credentials" grant type (it is the simplest of the grant types)
        $server->addGrantType(new \OAuth2\GrantType\ClientCredentials($storage));
 
        // Add the "Authorization Code" grant type (this is where the oauth magic happens)
        $server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($storage));