记对某CMS的一次代码审计

前言

最近对某CMS进行了一次审计,发现该CMS在处理登陆认证时底层数据库查询语句存在设计缺陷导致admin用户在不校验密码的情况下直接登录oa系统,下面对该漏洞进行分析介绍。

漏洞分析

文件位置:CMSoa.php
代码内容:

代码逻辑:该php文件为第一次访问OA子功能模块是的登陆认证页面,默认传递参数c(Public)、a(login),从下面的代码中可以看到此处会先获取参数c和a的值,之后判断参数是否为空,如果为空则赋予相对应的值,如果不为空则值不变,之后判断login是否在参数c中,如果在则导入配置文件,如果不再则继续下面的逻辑,在第一登陆时默认c的值为Public,不会去加载配置信息,在之后的if语句中定义了三个文件路径,其中$control_path为'source/control/oa/login.php',之后回去判断当前的control_path是否存在,如果不存在则提示处理文件未发现,如果存在则将上面的三个文件包含进来,这里简单说明一下下面三个文件的功能:

  • source/control/oabase.php:登陆认证与授权检查、查询系统配置信息、报存登陆日志、显示菜单界面等
  • source/control/apphook.php:加载广告、类型、标签等特征信息。
  • source/control/oa/login.php:登陆检测、登出、获取登陆ip地址。

之后再去new一个control类对象,然后检测action_login方法是否存在,如果存在且a参数值(login)的第一个字符不为'下划线',之后调用该方法,跟踪逻辑,进入到action_login,在这里会去直接调用$this->cptpl.'login.tpl'

文件位置:CMSsourcecontroloaPublic.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的值如下:

  • c:Public
  • a:check_login

之后我们再次回到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函数位于:CMSsourcemodeloamodel.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语句时,应该先分析当前要实现的功能,如果有点乱可以使用括号进行区分使得代码逻辑更加规范~

原文地址:https://www.cnblogs.com/0daybug/p/12610824.html