最近对某CMS进行了一次审计,发现该CMS在处理登陆认证时底层数据库查询语句存在设计缺陷导致admin用户在不校验密码的情况下直接登录oa系统,下面对该漏洞进行分析介绍。
文件位置:CMS\oa.php
代码内容:
代码逻辑:该php文件为第一次访问OA子功能模块是的登陆认证页面,默认传递参数c(Public)、a(login),从下面的代码中可以看到此处会先获取参数c和a的值,之后判断参数是否为空,如果为空则赋予相对应的值,如果不为空则值不变,之后判断login是否在参数c中,如果在则导入配置文件,如果不再则继续下面的逻辑,在第一登陆时默认c的值为Public,不会去加载配置信息,在之后的if语句中定义了三个文件路径,其中$control_path为'source/control/oa/login.php',之后回去判断当前的control_path是否存在,如果不存在则提示处理文件未发现,如果存在则将上面的三个文件包含进来,这里简单说明一下下面三个文件的功能:
之后再去new一个control类对象,然后检测action_login方法是否存在,如果存在且a参数值(login)的第一个字符不为'下划线',之后调用该方法,跟踪逻辑,进入到action_login,在这里会去直接调用$this->cptpl.'login.tpl'
文件位置:CMS\source\control\oa\Public.php
代码内容:
代码逻辑:上面的'$this->cptpl'变量的定义位于文件oabase.php的32行代码:
之后,继续跟踪代码到tpl/oa/login.tpl下,具体代码如下(有点多,我就直接贴下面了):
<!--{include file="<!--{$oapath}-->public/ins_base.tpl"}--> <block name="content"> <div class="Public container"> <!-- /container --> <div class="row"> <div class="col-xs-12 hidden-xs" style="margin-top:120px;"></div> </div> <div class="row"> <div class="col-sm-8 hidden-xs"> <div class="img"></div> </div> <div class="col-sm-4 well"> <div style="margin-bottom:44px;margin-top:20px;"> <h1 class="text-center"><!--{$config.system_name}--></h1> </div> <form method="post" id="form_login" class="form-horizontal"> <div class="form-group"> <label class="col-sm-3 control-label" for="emp_no">帐号:</label> <div class="col-sm-9"> <input class="form-control" id="emp_no" name="emp_no" /> </div> </div> <div class="form-group"> <label class="col-sm-3 control-label" for="password">密码:</label> <div class="col-sm-9"> <input class="form-control" id="password" type="password" name="password" /> </div> </div> <!--{if $config.login_verify_code =='1'}--> <div class="form-group"> <label class="col-sm-3 control-label" for="verify">验证码:</label> <div class="col-sm-9 row"> <div class="col-xs-6"> <input class="form-control" id="verify" name="verify" /> </div> <div class="col-xs-6"> <img src="<!--{$urlpath}-->source/include/imagecode.php?act=verifycode" style='cursor:pointer' title='刷新验证码' id='verifyImg' onclick='freshVerify()'> </div> </div> </div> <!--{/if}--> <div class="form-group hidden"> <span class="col-sm-3 control-label"> </span> <div class="col-sm-9"> <label class="inline pull-left col-3"> <input class="ace" type="checkbox" name="remember" value="1" /> <span class="lbl"> </span> </label> <label for="remember-me">记住我的登录状态</label> </div> </div> <div class="form-group"> <div class="col-sm-offset-3 col-sm-9"> <input type="button" value="登录" onclick="login();" class="btn btn-sm btn-primary col-10"> </div> </div> </form> </div> </div> <div class="row text-right"> </div> --> </div> </block> <block name="js"> <script type="text/javascript"> function login() { sendForm("form_login", "oa.php?c=Public&a=check_login"); } </script> </block>
在这里提供了一个登陆认证的表单,之后当表单提交后会进入到最下面的处理逻辑中,重新赋予c和a的值,之后提交到oa.php中,其中c和a的值如下:
之后我们再次回到oa.php文件中,代码如下:
此时,和之前分析的逻辑一致,唯一不同的是最后会调用control处理类中的action_check_login函数,因为此时的a已经成为了check_login,那么我们再跟踪到Public.php中的control类中的action_check_login函数中,具体代码如下(由于较多,直接贴进来了,可能有点不是那么好看,读者可以自我贴会sublime Text中查看):
<?php class control extends oabase { public function action_login() { // $is_verify_code = $this->get_system_config("login_verify_code"); TPL::display($this->cptpl . 'login.tpl'); } public function action_check_login() { $is_verify_code = $this->get_system_config(); if (!empty($is_verify_code['login_verify_code'])) { parent::loadUtil('session'); if ($_POST['verify'] != XSession::get('verifycode')) { XHandle::halt('对不起,验证码不正确!', '', 1); } } if (empty($_POST['emp_no'])) { XHandle::halt('对不起,帐号必须!', '', 1); $this->error('!'); } elseif (empty($_POST['password'])) { XHandle::halt('密码必须!', '', 1); } if ($_POST['emp_no'] == 'admin') { $_SESSION['ADMIN_AUTH_KEY'] = true; } // if(C("LDAP_LOGIN")&&!$is_admin){ if (false) { $where['emp_no'] = array('eq', $_POST['emp_no']); $dept_name = D('UserView')->where($where)->getField('dept_name'); if (empty($dept_name)) { XHandle::halt('帐号或密码错误!', '', 1); } $ldap_host = C("LDAP_SERVER");//LDAP 服务器地址 $ldap_port = C("LDAP_PORT");//LDAP 服务器端口号 $ldap_user = "CN=" . $_POST['emp_no'] . ",OU={$dept_name}," . C('LDAP_BASE_DN'); $ldap_pwd = $_POST['password']; //设定服务器密码 $ldap_conn = ldap_connect($ldap_host, $ldap_port) or die("Can't connect to LDAP server"); ldap_set_option($ldap_conn, LDAP_OPT_PROTOCOL_VERSION, 3); $r = ldap_bind($ldap_conn, $ldap_user, $ldap_pwd);//与服务器绑定 if ($r) { $map['emp_no'] = $_POST['emp_no']; $map["is_del"] = array('eq', 0); $model = M("User"); $auth_info = $model->where($map)->find(); } else { $this->error(ldap_error($ldap_conn)); } } else { $model = parent::model('login', 'oa'); $map = array(); // 支持使用绑定帐号登录 $map['emp_no'] = $_POST['emp_no']; $map["is_del"] = 0; $map['password'] = md5($_POST['password']); //print_r($map);die; $auth_info = $model->check_login($map); } //使用用户名、密码和状态的方式进行认证 if (false == $auth_info) { XHandle::halt('帐号或密码错误!', '', 1); } else { $_SESSION['USER_AUTH_KEY'] = $auth_info['id']; $_SESSION['emp_no'] = $auth_info['emp_no']; $_SESSION['user_name'] = $auth_info['name']; $_SESSION['user_pic'] = $auth_info['user_pic']; $_SESSION['dept_id'] = $auth_info['dept_id']; //保存登录信息 $ip = $this->get_client_ip(); $time = time(); $data = array(); $data['last_login_time'] = $time; $data['login_count'] = $auth_info['login_count'] + 1; $data['last_login_ip'] = $ip; $model->save($auth_info['id'], $data); header('Location: oa.php'); } }
在以上的代码中,会优先判断验证码是否存在,如果存在则交易验证码的正确性,知乎判断账号和密码是否填写,之后检查emp_no是否为'admin',也就是我们的账号名称,之后由于if(false)为假所以不会进入到if后面的语句中,直接进入到else处理逻辑,在这里会定义一个数组map,之后存储用户传递过来的认证信息,同时对密码进行md5加密存储,之后调用check_login函数进行检查,check_login函数位于:CMS\source\model\oa\model.login.php
代码逻辑如下:
在这里会分别存刚才传递的数组map中再次取出用户提交的认证信息并将其分别赋值,之后拼接到SQL语句中去查询,在这里大家也许注意到了,这里的SQL语句是否有一些不正常呢?确实存在问题,我们将SQL语句复制下来看看:
$sql = "SELECT * FROM ".DB_PREFIXOA."user WHERE emp_no='".$emp_no."' OR name='".$emp_no."' AND is_del='".$is_del."' AND password='".$password."'";
很多人可能会说,这里直接拼接未做过滤处理,应该存在SQL注入漏洞,我们这里先不去管SQL注入问题,我们先来看看这里的SQL语句的设计是否有问题。上面的SQL查询语句格式可以简化为如下:
SELECT * FROM PREFIXOA_user WHERE 条件1=条件值1 or 条件2=条件值2 and 条件3=条件值3 and 条件4=条件值4
由于SQL语句中'And'的优先级会高于or的运行级别,所以最后的执行语句应该是这样的:
SELECT * FROM PREFIXOA_user WHERE (条件1=条件值1) or ((条件2=条件值2) and (条件3=条件值3) and (条件4=条件值4))
之后,我们回到原来的SQL语句中,并对语句进行一个划分:
从上面可以看到,该sql语句执行后会查询出账号名为admin的所有信息 或 账号名为admin且密码为正确且is_del值正确的所有数据信息,在正常登陆情况下(账号/密码全部正确)查询出的信息为admin用户的一行记录信息,在账号名为admin但是密码错误的情况下,查出来的依旧为admin用户的一行信息,所以账号的密码在这里根本没有任何校验的作用,而这里程序开发者真正想要的设计应该是这样的:
$sql = "SELECT * FROM ".DB_PREFIXOA."user WHERE ((emp_no='".$emp_no."')OR (name='".$emp_no."')) AND (is_del='".$is_del."') AND (password='".$password."'");
即,查询name为admin或者emp_no为admin 且 密码正确 且 账号依旧有效未被删除的记录信息!
下面我们使用数据库来比对一下二者的区别,可以看到一个有正确的数据(当前错误的设计),一个没有(真正想要的设计)
基于以上简要分析,可以看到这里我们输入admin+任意密码登陆都可以成功查询到用户原有的数据库内正确的信息,下面我们继续跟踪后续流程:
在执行完SQL语句之后,会返回一个查询结果:
之后我们回到之前的Public.php中的action_check_login函数中:
可以看到在后门的代码中会将查询结果返回给auth_info,如果auth_info非false,则将数据库中的信息存储到session中,之后保存,同时最后重定向到oa.php中,通过之前的分析可以知晓,如果这里的用户名为admin,那么密码不管正确与否,返auth_info都不会为False,都会为true。
之后我们回过来再看oa.php中如果进行后续的操作:
此时,oa中的参数c与a为空值,那么在L5~6将会将其赋值为Index与run,之后成功进入到L18函加载配置类信息,这里就不再继续跟踪后续的配置加载了,分析到目前用户已经完成了登陆认证,并且成功进入oa了~
下面进行漏洞复现~
访问一下URL,之后输入admin/sjdkgljsdkgjdkg:
http://192.168.174.160/oa.php?c=Public&a=login
之后成功进入后台管理界面:
在这里我们使用admin+任意密码即可登录~
从上面的实例可以看到有时候sql语句的设计如果不合理也会导致某些强硬的判断条件被绕过,尤其是在使用and、OR连接SQL语句时,应该先分析当前要实现的功能,如果有点乱可以使用括号进行区分使得代码逻辑更加规范~