FeiFeiCms 前台逻辑漏洞分析

代码审计 2019-11-10

本文作者:myndtt

1 、版本 4.0.181010

2、下载链接:

http://daicuo.co/forum-1653-1-1.html

3、前台可注册用户

漏洞详情

注册处

用户注册一个账号对应处理函数为:

LibLibActionHomeUserAction.class.php

文件下的 post 函数。

public function post(){
    #var_dump($_POST); 测试
    $info = D("User")->ff_update($_POST);#跟进
    #var_dump($info);测试
    if($info){
        //注册积分
        if(C('user_register_score')){
            D('Score')->ff_user_score($info['user_id'], 2, intval(C('user_register_score')));
        }
        //推广积分
        if($info['user_pid'] && C('user_register_score_pid')){
     #echo '1';#测试
            D('Score')->ff_user_score($info['user_pid'], 4, intval(C('user_register_score_pid')));
        }
        //json返回
        $data = array('id'=>$info['user_id'],'referer'=>cookie('ff_register_referer'));
        //欢迎邮件信息
        if( C('user_register_welcome') ){
            $content = str_replace(array('{username}','{sitename}','{time}'), array($info['user_name'],C('site_name'),time()), C('user_register_welcome'));
            D("Email")->send($info['user_email'], $info['user_name'], $info['user_name'].'您好,感谢您的注册', $content);
        }
        //返回注册结果
        if (C('user_register_check')) {
            $this->ajaxReturn($data, "我们会尽快审核你的注册!", 201);
        }else{
            $this->ajaxReturn($data, "感谢你的注册!", 200);
        }
    }else{
        $this->ajaxReturn(0, D("User")->getError(), 500);
    }
}

该函数直接将 post 的数据传入,则跟进ff_update函数至\Lib\Lib\Model\UserModel.class.php文件

public function ff_update($data, $group='home'){
   // 创建安全数据对象TP
   $data = $this->create($data);#对字段进行验证
   if(false === $data){
      $this->error = $this->getError();
      return false;
   }
   /* 添加或修改行为 */
   if(empty($data['user_id'])){
      $data['user_id'] = $this->add();
      if(!$data['user_id']){
         $this->error = $this->getError();
         return false;
      }
      if($group == 'home'){
         //写入注册时间防刷新注册
         cookie('ff_register_time', time());
         //写入登录信息
         $this->ff_login_write(array('user_id'=>$data['user_id'],'user_name'=>$data['user_name'],'user_pwd'=>$data['user_pwd']));
      }
   } else {
      $status = $this->save();
      if(false === $status){
         $this->error = $this->getError();
         return false;
      }
   }
   return $data;
}

跟进create函数,来到\Lib\Think\Core\Model.class.php文件

public function create($data='',$type='') {
   // 如果没有传值默认取POST数据
   if(empty($data)) {
       $data    =   $_POST;
   }elseif(is_object($data)){
       $data   =   get_object_vars($data);
   }elseif(!is_array($data)){
       $this->error = L('_DATA_TYPE_INVALID_');
       return false;
   }
   // 状态
   $type = $type?$type:(!empty($data[$this->getPk()])?self::MODEL_UPDATE:self::MODEL_INSERT);

   // 表单令牌验证
   if(C('TOKEN_ON') && !$this->autoCheckToken($data)) {
       $this->error = L('_TOKEN_ERROR_');
       return false;
   }

   // 检查字段映射
   if(!empty($this->_map)) {
       foreach ($this->_map as $key=>$val){
           if(isset($data[$key])) {
               $data[$val] =   $data[$key];
               unset($data[$key]);
           }
       }
   }

   // 数据自动验证
   if(!$this->autoValidation($data,$type)) return false;#对传入数据进行验证

   // 验证完成生成数据对象
   $vo   =  array();
   foreach ($this->fields as $key=>$name){
       if(substr($key,0,1)=='_') continue;
       $val = isset($data[$name])?$data[$name]:null;
       //保证赋值有效
       if(!is_null($val)){
           $vo[$name] = (MAGIC_QUOTES_GPC && is_string($val))?   stripslashes($val)  :  $val;
       }
   }
   // 创建完成对数据进行自动处理
   $this->autoOperation($vo,$type);
   // 赋值当前数据对象
   $this->data =   $vo;
   // 返回创建的数据以供其他调用
   return $vo;
}

跟进 autoValidation 函数查看程序如何对数据进行验证

protected function autoValidation($data,$type) {
    // 属性验证
    if(!empty($this->_validate)) {
        // 如果设置了数据自动验证
        // 则进行数据验证
        // 重置验证错误信息
        foreach($this->_validate as $key=>$val) {#程序需要验证的事务
            // 验证因子定义格式
            // array(field,rule,message,condition,type,when,params)
            // 判断是否需要执行验证
            if(empty($val[5]) || $val[5]== self::MODEL_BOTH || $val[5]== $type ) {
                if(0==strpos($val[2],'{%') && strpos($val[2],'}'))
                    // 支持提示信息的多语言 使用 {%语言定义} 方式
                    $val[2]  =  L(substr($val[2],2,-1));
                $val[3]  =  isset($val[3])?$val[3]:self::EXISTS_VAILIDATE;
                $val[4]  =  isset($val[4])?$val[4]:'regex';
                // 判断验证条件
                switch($val[3]) {
                    case self::MUST_VALIDATE:   // 必须验证 不管表单是否有设置该字段
                        if(false === $this->_validationField($data,$val)){
                            $this->error    =   $val[2];
                            return false;
                        }
                        break;
                    case self::VALUE_VAILIDATE:    // 值不为空的时候才验证
                        if('' != trim($data[$val[0]])){
                            if(false === $this->_validationField($data,$val)){
                                $this->error    =   $val[2];
                                return false;
                            }
                        }
                        break;
                    default:    // 默认表单存在该字段就验证
                        if(isset($data[$val[0]])){#字段为空就可以绕过检测
                            if(false === $this->_validationField($data,$val)){
                                $this->error    =   $val[2];
                                return false;
                            }
                        }
                }
            }
        }
    }
    return true;
}

需要验证的事务有

protected $_validate = array(
        // 防刷新注册
        array('user_register','validate_user_register','注册速度过快!',1,'callback',1),
        // 验证呢称
        array('user_name','require','用户呢称必须填写!',0,'',3),
        array('user_name', '', '用户呢称被占用,请重新填写', 2, 'unique',3),#后面要进行验证
        /* 验证邮箱 */
        array('user_email', 'email', ' ', 0,'',3),
        array('user_email', '', '邮箱被占用,请重新填写', 0, 'unique',3),#后面要进行验证
        /* 验证密码 */
        array('user_pwd_re', 'user_pwd', '两次密码输入不一样', 2, 'confirm'), //两次密码输入不一样!
    );

则需要验证的字段有 user_name,user_name,user_email,user_pwd_re,user_pwd. 这些都是我们正常注册需要填写的数据,当然也是我们可以控制的数据,因为它们都取自于$_POST。这时候我们来看default的部分:if(isset($data[$val[0]]))只要传入的数据为空就不必进入检测了,这样会带来问题。

接着继续来看看_validationField函数吧

protected function _validationField($data,$val) {
    switch($val[4]) {
        case 'function':// 使用函数进行验证
        case 'callback':// 调用方法进行验证
            $args = isset($val[6])?$val[6]:array();
            array_unshift($args,$data[$val[0]]);
            if('function'==$val[4]) {
                return call_user_func_array($val[1], $args);
            }else{
                return call_user_func_array(array(&$this, $val[1]), $args);
            }
        case 'confirm': // 验证两个字段是否相同
            return $data[$val[0]] == $data[$val[1]];
        case 'in': // 验证是否在某个数组范围之内
            return in_array($data[$val[0]] ,$val[1]);
        case 'equal': // 验证是否等于某个值
            return $data[$val[0]] == $val[1];
        case 'unique': // 验证某个值是否唯一
            if(is_string($val[0]) && strpos($val[0],','))
                $val[0]  =  explode(',',$val[0]);
            $map = array();
            if(is_array($val[0])) {
                // 支持多个字段验证
                foreach ($val[0] as $field)
                    $map[$field]   =  $data[$field];
            }else{
                $map[$val[0]] = $data[$val[0]];
            }
            if(!empty($data[$this->getPk()])) { // 完善编辑的时候验证唯一
                $map[$this->getPk()] = array('neq',$data[$this->getPk()]);#真正问题所在!
            }
            if($this->where($map)->find())
                return false;
            break;
        case 'regex':
        default:    // 默认使用正则验证 可以使用验证类中定义的验证名称
            // 检查附加规则
            return $this->regex($data[$val[0]],$val[1]);
    }
    return true;
}

不太清楚为什么程序在验证字段是否唯一的时候为什么要加入这段

 if(!empty($data[$this->getPk()])) { // 完善编辑的时候验证唯一
                $map[$this->getPk()] = array('neq',$data[$this->getPk()]);#问题所在!
            }
if($this->where($map)->find())
                return false;

$this->getPk() 函数是得到当前要判断的字段所在表的主键名称(注册时影响的表即为 ff_user,主键为 user_id。在thinkphp 中也有该函数)。如果存在,那么就用 'neq', 也即不等于。这里需要出现黑人问号?。等于说注册的时候我传入一个字段user_id就可以做一些事情了。例如下图

img

如果已经注册了一个user_name=myndtt并且user_id=2的用户,那么这样就完全绕过了字段验证。或者只需要传入user_id这个字段就可以绕过了。字段验证完以后没问题就会更新数据库了。例如下图(这里没有传入 user_name, user_email 等字段,仅仅传入了 user_id 和密码),那么程序就会对user_id对应的用户进行密码更改。

img

同时网站可以通过user_id来遍历得到注册用户的user_name。可以检测 user_id 是否存在。如

img

img

总之就可以利用user_id来更改ff_user表中的许多字段。

接着回到最早的post函数

if($info){#得到注册积分
   //注册积分
   if(C('user_register_score')){
      D('Score')->ff_user_score($info['user_id'], 2, intval(C('user_register_score')));
   }
   //推广积分
   if($info['user_pid'] && C('user_register_score_pid')){
       #echo '';#测试
      D('Score')->ff_user_score($info['user_pid'], 4, intval(C('user_register_score_pid')));
   }
   //json返回
   $data = array('id'=>$info['user_id'],'referer'=>cookie('ff_register_referer'));
   //欢迎邮件信息
   if( C('user_register_welcome') ){
      $content = str_replace(array('{username}','{sitename}','{time}'), array($info['user_name'],C('site_name'),time()), C('user_register_welcome'));
      D("Email")->send($info['user_email'], $info['user_name'], $info['user_name'].'您好,感谢您的注册', $content);
   }

如果user_id=自己的id话就可以无限注册给自己加分了。

img

那么问题来了,为什么不直接:加上一个 user_score 字段呢。如 post user_id=2&user_score=30000

回到post函数

$info = D("User")->ff_update($_POST);#执行完后表中user_id对应user_score为30000
        #var_dump($info);测试
        if($info){
            //注册积分
            if(C('user_register_score')){
                D('Score')->ff_user_score($info['user_id'], 2, intval(C('user_register_score')));#此时用给会计算ff_score表中对应id的score。以此为基础加上注册#的分数
            }

遗憾的是D('Score')->ff_user_score($info['user_id'], 2, intval(C('user_register_score')));会继续更新一次。在此中可以考虑时间竞争获得高额积分,否则就一次次发包,每次获得注册奖励的分数。

登入处

上述的更改用户密码,看似不能直接可以登入前台(登入需要邮箱),因为只能获得user_name

来到处理登入处的逻辑代码部分

public function loginpost(){    
    $user_id = D("User")->ff_login($_POST);#不好的现象
    if($user_id){
        $this->ajaxReturn($user_id, "登录成功", 200);
    }else{
        $this->ajaxReturn(0, D("User")->getError(), 500);
    }
}

进入ff_login函数

public function ff_login($post){
        $where = array();
        //用户名与邮箱登录
        if(filter_var($post['user_email'], FILTER_VALIDATE_EMAIL)){#如果user_email不符合#email的正则
            $where['user_email'] = array('eq', htmlspecialchars(trim($post['user_email'])));
        }else{
            $where['user_name'] = array('eq', #那么考虑用户输入的user_email可能是user_name
                                        htmlspecialchars(trim($post['user_email'])));
        }
        //查库
        $info = $this->field('user_id,user_name,user_pwd,user_email,user_status')->where($where)->find();
        if(!$info){

这种选择,考虑如果用户输入的不是邮箱就是用户名,经常在该一些 cms 中出现。可能在一种程度上方便了用户,但是也带来隐患。这里就是可以用 user_name 直接登入

img

危害总结

1、任意前台用户密码重置

2、任意用户刷分(影币)

3、用户其他数据的更改(头像链接,之类等)

修改

1、注册,登入处没必要用$_POST直接获取所有的 post 数据,多写几条代码,拿到自己想要的就好。

2、验证字段为空处的处理逻辑有问题,不空才检测,应当做限制。

3、验证具体字段唯一的时候何必去请求主键。

小结

像这种前台用户修改数据的地方往往是比较容易出现越权的地方。程序员为了方便,一次性获取所有用户 POST 的数据,没考虑用户在修改某一些字段的同时没其他字段数据是不是也会被修改,也很少考虑修改的数据是不是当前登入的用户。黑盒测试时,容易发现,白盒测试时,需要一段时间调试找到具体关键问题点。


本文由 信安之路 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

楼主残忍的关闭了评论