Castiel's Blog

OneThink1.0远程代码执行漏洞WriteUp

OneThink1.0远程代码执行漏洞WriteUp
2020-04-03 · 12 min read

0x00 前言

最近在恶补以往ctf的web类题型,想借此以弥补一些因之前的懒散而错过的知识点。OneThink1.0这个题是百度杯”CTF比赛 2017 二月场的一道Web题,刚拿到还以为是考点是SQL注入、文件上传、逻辑漏洞或者是其他一些奇葩知识点。搞半天没一点进展,忍不住还是看Writeup,原来考点就是OneThink1.0的远程代码执行漏洞。看了writeup大概了解,还是决定下载源码来自己跟一下,所以有了此文。写个Writup以便日后查阅,更是为了加深下印象。

0x01 分析

漏洞成因是在缓存设计上的一些缺陷造成的,利用流程是用户注册时候注册恶意用户名,在登录后系统生成用户列表缓存时候造成代码执行。

用户注册机制

首先还是从用户注册开始说起,流程从Application、Home、Controller、UserController.class.php开始,代码如下:

	/* 注册页面 */
	public function register($username = '', $password = '', $repassword = '', $email = '', $verify = ''){
		if(IS_POST){ //注册用户
			/* 检测验证码 */
			if(!check_verify($verify)){
				$this->error('验证码输入错误!');
			}

			/* 检测密码 */
			if($password != $repassword){
				$this->error('密码和重复密码不一致!');
			}			

			/* 调用注册接口注册用户 */
            $User = new UserApi;
			$uid = $User->register($username, $password, $email);
			if(0 < $uid){ //注册成功
				//TODO: 发送验证邮件
				$this->success('注册成功!',U('login'));
			} else { //注册失败,显示错误信息
				$this->error($this->showRegError($uid));
			}

		} else { //显示注册表单
			$this->display();
		}
	}

控制器调用Application/User/Api/UserApi.class.php接口的register方法,继续跟进UserApi.class.phpregister方法:

public function register($username, $password, $email, $mobile = ''){
		return $this->model->register($username, $password, $email, $mobile);
	}

该方法调用Application/User/Model/UcenterMemberModel.class.phpregister方法,继续跟进UcenterMemberModel.class.php

	public function register($username, $password, $email, $mobile){
		$data = array(
			'username' => $username,
			'password' => $password,
			'email'    => $email,
			'mobile'   => $mobile,
		);

		//验证手机
		if(empty($data['mobile'])) unset($data['mobile']);

		/* 添加用户 */
		if($this->create($data)){
			$uid = $this->add();
			return $uid ? $uid : 0; //0-未知错误,大于0-注册成功
		} else {
			return $this->getError(); //错误详情见自动验证注释
		}
	}

UcenterMemberModel中配置了用户模型验证机制,如下:

/* 用户模型自动验证 */
	protected $_validate = array(
		/* 验证用户名 */
		array('username', '1,30', -1, self::EXISTS_VALIDATE, 'length'), //用户名长度不合法
		array('username', 'checkDenyMember', -2, self::EXISTS_VALIDATE, 'callback'), //用户名禁止注册
		array('username', '', -3, self::EXISTS_VALIDATE, 'unique'), //用户名被占用

		/* 验证密码 */
		array('password', '6,30', -4, self::EXISTS_VALIDATE, 'length'), //密码长度不合法

		/* 验证邮箱 */
		array('email', 'email', -5, self::EXISTS_VALIDATE), //邮箱格式不正确
		array('email', '1,32', -6, self::EXISTS_VALIDATE, 'length'), //邮箱长度不合法
		array('email', 'checkDenyEmail', -7, self::EXISTS_VALIDATE, 'callback'), //邮箱禁止注册
		array('email', '', -8, self::EXISTS_VALIDATE, 'unique'), //邮箱被占用

		/* 验证手机号码 */
		array('mobile', '//', -9, self::EXISTS_VALIDATE), //手机格式不正确 TODO:
		array('mobile', 'checkDenyMobile', -10, self::EXISTS_VALIDATE, 'callback'), //手机禁止注册
		array('mobile', '', -11, self::EXISTS_VALIDATE, 'unique'), //手机号被占用
	);

可见对用户输入验证只有一些格式、长度等验证,并未做特殊字符过滤等相关安全机制。

系统缓存机制

根据解题思路,漏洞触发点就在用户登录时候,加上漏洞成因那肯定是用户登录的时候有读取和生成缓存文件的操作。跟下登录流程,起始点和注册流程一样,位于控制器UserController.class.php,控制器再调用UserApi.class.phplogin方法验证用户,返回用户的uid,再调用模块Application/Home/Model/MemberModel.class.phplogin方法,传入uid

	/* 登录页面 */
	public function login($username = '', $password = '', $verify = ''){
		if(IS_POST){ //登录验证
			/* 检测验证码 */
			if(!check_verify($verify)){
				$this->error('验证码输入错误!');
			}

			/* 调用UC登录接口登录 */
			$user = new UserApi;
			$uid = $user->login($username, $password);
			if(0 < $uid){ //UC登录成功
				/* 登录用户 */
				$Member = D('Member');
				if($Member->login($uid)){ //登录用户
					//TODO:跳转到登录前页面
					$this->success('登录成功!',U('Home/Index/index'));
				} else {
					$this->error($Member->getError());
				}

			} else { //登录失败
				switch($uid) {
					case -1: $error = '用户不存在或被禁用!'; break; //系统级别禁用
					case -2: $error = '密码错误!'; break;
					default: $error = '未知错误!'; break; // 0-接口参数错误(调试阶段使用)
				}
				$this->error($error);
			}

		} else { //显示登录表单
			$this->display();
		}
	}

跟进MemberModel.class.phplogin方法

    public function login($uid){
        /* 检测是否在当前应用注册 */
        $user = $this->field(true)->find($uid);
        if(!$user){ //未注册
            /* 在当前应用中注册用户 */
        	$Api = new UserApi();
        	$info = $Api->info($uid);
            $user = $this->create(array('nickname' => $info[1], 'status' => 1));
            $user['uid'] = $uid;
            if(!$this->add($user)){
                $this->error = '前台用户信息注册失败,请重试!';
                return false;
            }
        } elseif(1 != $user['status']) {
            $this->error = '用户未激活或已禁用!'; //应用级别禁用
            return false;
        }

        /* 登录用户 */
        $this->autoLogin($user);

        //记录行为
        action_log('user_login', 'member', $uid, $uid);

        return true;
    }

这里前半部分主要是根据uid查询用户信息并验证是否注册和激活,然后进入autoLogin方法:

private function autoLogin($user){
        /* 更新登录信息 */
        $data = array(
            'uid'             => $user['uid'],
            'login'           => array('exp', '`login`+1'),
            'last_login_time' => NOW_TIME,
            'last_login_ip'   => get_client_ip(1),
        );
        $this->save($data);

        /* 记录登录SESSION和COOKIES */
        $auth = array(
            'uid'             => $user['uid'],
            'username'        => get_username($user['uid']),
            'last_login_time' => $user['last_login_time'],
        );

        session('user_auth', $auth);
        session('user_auth_sign', data_auth_sign($auth));

    }

在该方法中设置一些用户基本信息,其中$auth数组中的username字段从get_username方法获取,继续更新get_username方法,该方法在Application/Common/Common/function.php 中定义:

function get_username($uid = 0){
    static $list;
    if(!($uid && is_numeric($uid))){ //获取当前登录用户名
        return session('user_auth.username');
    }

    /* 获取缓存数据 */
    if(empty($list)){
        $list = S('sys_active_user_list');
    }

    /* 查找用户信息 */
    $key = "u{$uid}";
    if(isset($list[$key])){ //已缓存,直接使用
        $name = $list[$key];
    } else { //调用接口获取用户信息
        $User = new User\Api\UserApi();
        $info = $User->info($uid);
        if($info && isset($info[1])){
            $name = $list[$key] = $info[1];
            /* 缓存用户 */
            $count = count($list);
            $max   = C('USER_MAX_CACHE');
            while ($count-- > $max) {
                array_shift($list);
            }
            S('sys_active_user_list', $list);
        } else {
            $name = '';
        }
    }
    return $name;
}

可见在该方法中就有存取缓存信息的操作了,如果有缓存则直接使用,否则调用接口查询用户信息,并将用户名信息传入S函数创建缓存,根据S方法,这里还传入了个sys_active_user_list标识:

function S($name,$value='',$options=null) {
    static $cache   =   '';
    if(is_array($options) && empty($cache)){
        // 缓存操作的同时初始化
        $type       =   isset($options['type'])?$options['type']:'';
        $cache      =   Think\Cache::getInstance($type,$options);
    }elseif(is_array($name)) { // 缓存初始化
        $type       =   isset($name['type'])?$name['type']:'';
        $cache      =   Think\Cache::getInstance($type,$name);
        return $cache;
    }elseif(empty($cache)) { // 自动初始化
        $cache      =   Think\Cache::getInstance();
    }
    if(''=== $value){ // 获取缓存
        return $cache->get($name);
    }elseif(is_null($value)) { // 删除缓存
        return $cache->rm($name);
    }else { // 缓存数据
        if(is_array($options)) {
            $expire     =   isset($options['expire'])?$options['expire']:NULL;
        }else{
            $expire     =   is_numeric($options)?$options:NULL;
        }
        return $cache->set($name, $value, $expire);
    }
}

在该函数中根据传入的$name(也就是字符串sys_active_user_list)实例化文件缓存类ThinkPHP/Library/Think/Cache/Driver/File.class.php,并调用get方法,继续跟进:

public function get($name) {
        $filename   =   $this->filename($name);
        if (!is_file($filename)) {
           return false;
        }
        N('cache_read',1);
        $content    =   file_get_contents($filename);
        if( false !== $content) {
            $expire  =  (int)substr($content,8, 12);
            if($expire != 0 && time() > filemtime($filename) + $expire) {
                //缓存过期删除缓存文件
                unlink($filename);
                return false;
            }
            if(C('DATA_CACHE_CHECK')) {//开启数据校验
                $check  =  substr($content,20, 32);
                $content   =  substr($content,52, -3);
                if($check != md5($content)) {//校验错误
                    return false;
                }
            }else {
            	$content   =  substr($content,20, -3);
            }
            if(C('DATA_CACHE_COMPRESS') && function_exists('gzcompress')) {
                //启用数据压缩
                $content   =   gzuncompress($content);
            }
            $content    =   unserialize($content);
            return $content;
        }
        else {
            return false;
        }
    }

在该方法中首先设置缓存文件的文件名$filename = $this->filename($name);$name值继续传递:

private function filename($name) {
        $name	=	md5($name);
        if(C('DATA_CACHE_SUBDIR')) {
            // 使用子目录
            $dir   ='';
            for($i=0;$i<C('DATA_PATH_LEVEL');$i++) {
                $dir	.=	$name{$i}.'/';
            }
            if(!is_dir($this->options['temp'].$dir)) {
                mkdir($this->options['temp'].$dir,0755,true);
            }
            $filename	=	$dir.$this->options['prefix'].$name.'.php';
        }else{
            $filename	=	$this->options['prefix'].$name.'.php';
        }
        return $this->options['temp'].$filename;
    }

在该函数中还涉及到一个文件名前缀$this->options['prefix']的问题,在看其他人的writeup的时候有的分析里是有onethink_这个前缀的,而且在Application/Home/Conf/config.php文件中确实也配置了'DATA_CACHE_PREFIX' => 'onethink_', // 缓存前缀。但正确的CTF题解中却没有这个前缀,我还以为CTF使用的源码有做过更改。这个问题也让我困惑了下,直到自己动手跟代码才搞清楚。原来实际程序中Application/Home/Conf/config.phpDATA_CACHE_PREFIX配置并未覆盖系统的convention.php的配置,convention.php中的默认配置为空,所以这里缓存文件名就没有前缀了。
回到正题,通过以上分析可以确定缓存文件名是一个固定值也就是md5('sys_active_user_list') 其值为2bb202459c30a1628513f40ab22fa01a.php,接下来看看如果控制写入内容。

S函数中,未提交$value参数是调用文件缓存类的get方法取缓存,取到缓存并使用了之后再调用set方法写缓存。这里跟进set方法:

<?
public function set($name,$value,$expire=null) {
        N('cache_write',1);
        if(is_null($expire)) {
            $expire =  $this->options['expire'];
        }
        $filename   =   $this->filename($name);
        $data   =   serialize($value);
        if( C('DATA_CACHE_COMPRESS') && function_exists('gzcompress')) {
            //数据压缩
            $data   =   gzcompress($data,3);
        }
        if(C('DATA_CACHE_CHECK')) {//开启数据校验
            $check  =  md5($data);
        }else {
            $check  =  '';
        }
        $data    = "<?php\n//".sprintf('%012d',$expire).$check.$data."\n?>";
        $result  =   file_put_contents($filename,$data);
        if($result) {
            if($this->options['length']>0) {
                // 记录缓存队列
                $this->queue($name);
            }
            clearstatcache();
            return true;
        }else {
            return false;
        }
    }
    ?>

这里将$data序列化之后进行字符串拼接并写入到缓存文件中,在字符串拼接中php标签后面紧跟了//用于注释后面的信息,默认情况下被注释的行内所有php代码都不会执行。但//是单行注释符,如果我们控制后面的内容能换行,则就顺利绕过这个限制了,所以在利用的过程中我们注册的时候需要使用%0a来绕过。%0a是换行符的URL编码,在提交注册和登录的时候我们提交的%0a还会再次被编码变成%250a所以这里需要进行两次解码之后还原换行符,然后提交注册或是登录,最终利用代码%0aphpinfo();//,后面的//是为了代码插入后注释掉后半部的内容,如下图:

登录之后即可在生成Runtime/Temp/2bb202459c30a1628513f40ab22fa01a.php的缓存文件,内容如下图所示:

访问后成功执行phpinfo()

总结

漏洞也是比较经典的缓存机制漏洞,分析和利用比较简单,但是挖掘这样的漏洞还是需要时间的,其中辛苦也是漏洞作者才能体会了。路漫漫其修远兮,吾将上下而求索。