0x00 前言

前段时间在测试某商城系统时,发现使用了某通用CMS,本着学习的态度,于是下载下来对源码进行分析。
因为自己懒得搭环境,喜欢实战环境,所以直接找官方Demo,因为漏洞提交至今未修复,为了不涉及某商城系统,故对截图进行了打码。

0x01 漏洞分析

远程代码执行

打开源码包,看到这个,struts2架构,远古时期,包都不用解,一看就知道ST2-16。

不搭环境,带上工具,直接官网Demo

任意文件上传
struts2架构,相关页面关联信息都写在了xml里面,找起来方便
我们看看上传页面的前端文件upload.jsp

<script type="text/javascript">
        $(function() {
            var appType = $('#appType').val();
            var url = ctx + '/FileUpload!save.do?appType='+appType;
            var fileDesc = '';
            var fileExt = '';
            if(appType == 'magazine'){
                fileDesc = '支持格式:doc/docx/pdf/rar/zip/txt.';
                fileExt = '*.doc;*.docx;*.pdf;*.rar;*.zip;*.txt';
            }else{
                fileDesc = '支持格式:jpg/gif/jpeg/png/bmp.';
                fileExt = '*.jpg;*.gif;*.jpeg;*.png;*.bmp';
            }
            $("#fileupload").uploadify({
                'uploader'       : ctx + '/scripts/framework/uploadify/uploadify.swf',
                'script'         : url,                                         //servlet的路径或者.jsp 
                'cancelImg'      : ctx + '/scripts/framework/uploadify/cancel.png',
                'fileDataName'  : 'fileupload',                                 //必须
                'queueID'        : 'fileQueue',
                'auto'           : false,                                       //选定文件后是否自动上传,默认false
                'multi'          : false,                                       //是否允许同时上传多文件,默认false
                'simUploadLimit' : 1,                                           //一次同步上传的文件数目  
                'sizeLimit'      : 2000000,                                     //设置单个文件大小限制,单位为byte  
                'queueSizeLimit' : 5,                                           //限制在一次队列中的次数(可选定几个文件)。默认值= 999,而一次可传几个文件有 simUploadLimit属性决定。
                'fileDesc'       : fileDesc,                                    //如果配置了以下的'fileExt'属性,那么这个属性是必须的  
                'fileExt'        : fileExt,                                     //允许的格式
                onComplete: function (event, queueID, fileObj, response, data) {
                    var arrTemp = response.split(',');
                    var idStr = arrTemp[0];
                    var picPath = arrTemp[1];
                    if('false'==idStr){
                        idStr = '';
                        alert("文件:" + fileObj.name + "上传失败");
                    }else{
                        alert("文件:" + fileObj.name + "上传成功");

                        if('Good'== appType || 'GoodAlbum' == appType || 'magazine' == appType || 'Packaging' == appType || 'Promote' == appType || 'promotionActivity'==appType || 'gift'==appType || 'AdvertisePic'==appType ||'goodType' ==appType ||'GoodBrand'==appType || 'Customer'==appType || 'costomer'==appType || 'Ware' == appType){
                            parent.upload.close(idStr,picPath);
                        }else if('GoodExtend' == appType || 'Advertise' == appType || 'magazinePic' == appType || 'Information' == appType|| 'GreetingCard' == appType|| 'promotePic' == appType || 'promotionActivityPic' == appType || 'StoreSet' == appType || 'giftPic' == appType ){
                            window.returnValue = picPath;
                            window.close();
                        }
                    }
                },  
                onError: function(event, queueID, fileObj) {
                    alert("文件:" + fileObj.name + "上传失败");
                },  
                onCancel: function(event, queueID, fileObj){
                    //alert("取消了" + fileObj.name);  
                } 
            });   

        });
    </script>

文件对appType进行判断,继而进行处理,对于我们来讲,appType在这里没有实质性的作用,只是选择上传的类型目录而已。

来看下处理上传的文件,在文件FileUploadAction.class中

public void save(){
String folderPath = Static.APACHE_CONTEXT_PATH + Static.FILE_PATH;
Date now = new Date();
String nowStr = DateUtil.date2Str(now, "yyyyMMdd");
now = DateUtil.str2Date(nowStr, "yyyyMMdd");

folderPath = folderPath + "/" + this.appType + "/" + nowStr;

logger.info("relativePath:" + folderPath);
String idStr = "";
String imgPath = "";
String fileName = "";

boolean isOk = true;
if ((this.fileupload != null) && (this.fileupload.length > 0))
{
  logger.info("fileupload.length:" + this.fileupload.length);

  File savedir = new File(folderPath);
  if (!savedir.exists()) {
    savedir.mkdirs();
  }
  for (int i = 0; i < this.fileupload.length; i++)
  {
    fileName = this.fileuploadFileName[i];
    String postfix = fileName.substring(fileName.lastIndexOf(".") + 1);
    logger.info("uploadFileName[" + i + "]=" + fileName);

    String id = this.fileUploadService.makeId();
    idStr = idStr + (i == 0 ? id : new StringBuilder(",").append(id).toString());

    String fileNewName = id + "." + postfix;
    File savefile = new File(savedir, fileNewName);
    logger.info("save file:" + fileNewName + " to folder:" + savedir.getPath());
    try
    {
      FileUtils.copyFile(this.fileupload[i], savefile);

      FileUpload fileUpload = new FileUpload();
      fileUpload.setId(id);
      fileUpload.setAppType(this.appType);
      fileUpload.setCreateTime(now);
      fileUpload.setPostfix(postfix);
      fileUpload.setOriginalName(fileName);

      StringBuffer relativePath = new StringBuffer();
      relativePath.append(Static.FILE_PATH)
        .append("/").append(this.appType)
        .append("/").append(nowStr)
        .append("/").append(id).append(".").append(postfix);
      fileUpload.setRelativePath(relativePath.toString());
      imgPath = relativePath.toString();
      this.fileUploadService.insert(fileUpload);
    }
    catch (Exception e)
    {
      if (isOk) {
        isOk = false;
      }
      logger.error("error when copyFile,savefile:" + savefile, e);
    }
  }
}
else
{
  logger.warn("fileupload is null or fileupload.length <=0");
  isOk = false;
}
if (!isOk) {
  responseFlag(isOk);
} else if (this.appType.equals("News")) {
  responseFlag(imgPath);
} else if (this.appType.equals("OrderGood")) {
  responseFlag(idStr + ',' + fileName);
} else {
  responseFlag(idStr + ',' + imgPath);
}

首先对appType和目录进行了拼接,也就是上传的路径

folderPath = folderPath + "/" + this.appType + "/" + nowStr;

判断文件长度大小

if ((this.fileupload != null) && (this.fileupload.length > 0))

然后取后缀,到这里为止,文件都没有对上传的内容进行任何判断,后缀也是一样,直接读取拼接,不做判断。

加之在文件中也未发现任何的登录权限验证,所以造成了前端无限制任意文件上传。

String postfix = fileName.substring(fileName.lastIndexOf(".") + 1);
String fileNewName = id + "." + postfix;

下面就是存储过程了,最后返回上传结果。附上传成功并getshell截图。

存储型XSS
这个系统好像通篇没有过滤XSS的脚本,不知道有没有过滤文件反正我没有看到.可以在商品收货地址或商品展示处等地方插入XSS。
因为通篇XSS,所以就挑一个来说
在jsp文件edit_SysUser.jsp中,这个是用于修改个人信息的,定位源码SysUserAction.class
下面是两个重要函数
首先edit()从jsp页面获取到登录用户的信息,对信息进行修改,save()函数接收修改的信息,对用户信息进行存储更新,在文件里面,我们没有看到任何的过滤函数存在。

省略无关代码......
public String edit(){
SysUser loginMan = getSessionUserInfo();
if (this.sysUser == null) {
  this.sysUser = new SysUser();
}
String id = this.sysUser.getId();
if (StringUtils.isBlank(id))
{
  super.initModel(true, this.sysUser, loginMan);
}
else
{
  this.sysUser = ((SysUser)this.sysUserService.getModel(id));

  super.initModel(false, this.sysUser, loginMan);
}
this.sysRoleList = this.sysRoleService.select(null);
if (this.sysRoleList == null) {
  this.sysRoleList = new ArrayList();
} else {
  for (int i = 0; i < this.sysRoleList.size(); i++) {
    if ("admin".equals(((SysRole)this.sysRoleList.get(i)).getCode()))
    {
      this.sysRoleList.remove(i);
      break;
    }
  }
}
return "edit_SysUser";

省略无关代码......

public void save(){
try
{
  String id = this.sysUser.getId();

  String roleId = this.sysUser.getRoleId();
  if (StringUtils.isNotBlank(roleId))
  {
    SysRole sysRole = (SysRole)this.sysRoleService.getModel(roleId);
    this.sysUser.setRoleCode(sysRole.getCode());
    this.sysUser.setRoleName(sysRole.getName());
  }
  if (StringUtils.isBlank(id)) {
    this.sysUserService.insert(this.sysUser);
  } else {
    this.sysUserService.update(this.sysUser);
  }
  responseFlag(true);
}
catch (Exception e)
{
  responseFlag(false);
  logger.error("error occur when save model!", e);
}

}

测试结果,后台和前台

任意帐号密码修改

漏洞发生在app\front\action\UserManageAction.class文件中

首先在重置密码处会先进行一次帐号验证,也就是邮箱地址验证是否正确,然后会返回注册手机号码(下面会用到),代码就不贴了,这个不是重点,重点是sendEmail()这个函数。

首先会获取提交过来的手机号码和邮箱地址

this.customer = getSessionCustomerInfo();
String toMail = this.customer.getEmail();
String registerName = this.customer.getCode();

接下来,直接设置发送邮件的帐号密码,url构造随机数ID连接。
然后就是理想的发送邮件验证重置密码连接了。

String userName = "[email protected]";
String password = "XXXXX";

String registerId = Math.random() * Math.random();
String url = "http://localhsot:8080/frontLogin.do?registerId=" + registerId;

MimeMessage msg = new MimeMessage(session);
msg.setFrom(from);
msg.setSubject("邮箱验证");
msg.setSentDate(new Date());
msg.setContent("<a href='" + url + "'>点击" + url + "</a>", "text/html;charset=utf-8");
msg.setRecipient(Message.RecipientType.TO, to);
Transport.send(msg);

直接构造和修改邮箱,即可修改密码。
http://www.xxx.com/sendEmail.do?customer.code=135xxxxxxx6&[email protected]

0x02 最后

分析出了这几个漏洞和看了官网后,越发觉得这家公司为什么还能活着?
因为代码通用,影响旗下所有电商系统。
吐槽:一个软件卖到9000+,3年不升级。还有谁?

源链接

Hacking more

...