Java 安全漏洞 (CVE) 终极指南 - Lmyslinski


在过去两年中,我花了很大一部分时间研究、验证、修补和更新基于 JVM 的大型企业代码库。这不好玩。我的目标是创建一个关于该主题的综合资源,以便面临类似挑战的每个人都可以从中吸取教训并节省一些时间/精力。

基础知识
每个软件产品都由代码组成。代码可能有漏洞。因为现在没有人从头开始写代码,我们都使用(大部分)开源的库。当一个新的漏洞被发现和验证时,它会被添加到Mitre和/或NIST CVE数据库中,并被分配一个ID,如CVE-2022-42004。

在以前,这并不是什么大问题--我们没有那么多的软件在云端运行,很多软件都隐藏在公司网络中,所以攻击面相当有限。如今,特别是自从Log4Shell 影响了半个互联网之后,其他每家公司都在担心他们的软件会被暴露。这对大型实体来说尤其如此,因为有潜在的政府监督/严格的法规(更不用说GDPR和类似法律)。

即使你不是为一家拥有庞大遗留代码库的大公司工作,这也不意味着你就可以脱身了--有可能,你公司的客户是大公司。这些人由于各种原因往往更喜欢内部解决方案,而且他们在系统中安装的每一个软件包都会扫描CVEs。销售部门抱怨因为我们的代码库有太多的CVE而无法扩展到新客户,这是真实的事情。

因此,让我们假设你是一名工程师,一名经理向你走来,大喊 "我们必须控制住我们的CVEs!"。这就把我们带到了第一部分,扫描工具。

第一部分:CVE扫描工具
我们必须首先确定我们的代码库中有哪些CVE。这听起来很简单,但并不是那么简单的事。我已经尝试了很多工具,下面是一个简单的分类。

Veracode
绝对是垃圾。不惜一切代价避免。我的判断可能是不公平的,因为我对它的了解是,除了厨房水槽之外,它扫描了所有的东西,在我们的系统上几乎需要24小时才能完成,并且生成了一份100多页的PDF报告,几乎没有任何价值。我仍然不知道它应该带来什么确切的价值,但从一个工程师的角度来看,在这种情况下,它远不是一个有用的工具。从他们的网站来看,它应该与其他工具相提并论,但从我与它互动的程度来看--不,谢谢。


SnykApiiro
这两个工具都很不错,有大量的选项,提供有用的图表,工作速度相当快,总体来说很好用。只是有一个问题--它们不能与Maven的依赖关系正常工作。这对我们的团队来说绝对是个麻烦事。仪表盘、花哨的图形和图表很好,但要处理假阳性的问题就非常乏味了。一旦我们开始讨论解决CVE的细节,我将会对此进行更多阐述。

我推荐使用它们来分析非Java的依赖关系(npm、系统级等),但对于Java来说,这是不可能的。


Trivy
它是安全扫描的圣杯,至少对Java来说是这样。它只是一个扫描docker镜像的CLI工具,不过,它也可以扫描文件系统内容。在接下来的部分,我将会解释如何有效地使用它。

我确实注意到,它不能从捆绑的JS中检测出CVE(一旦你构建了你的前端项目并将其捆绑到Docker镜像中),所以如果你想同时关注这一点,Snyk/Apiiro可能是一个更好的工具。


DependencyCheck
我从未遇到过它,然而人们向我指出,它绝对应该在名单上。


第二部分:工作流程
我把这篇文章称为 "Java安全漏洞 "指南,然而我想澄清的是,这里几乎所有的内容都适用于所有JVM语言--无论是Java、Scala、Kotlin、Groovy等。所有的JVM世界都在Maven资源库上运行。即使你使用Gradle/sbt/等作为你的构建工具,你仍然在使用Maven库。本指南将重点介绍Gradle,但几乎每一种构建工具都存在等效的工具。

假设我们的扫描结果显示我们有一个或多个cve-2021-27568的出现需要解决。通常,CVE的描述非常明确地指出了哪些库受到影响。在这种情况下,正如CVE中描述的那样:
netplex json-smart-v1到2015-10-23和json-smart-v2到2.4是受影响的版本。

首先,让我们看看有哪些版本可用,也就是说,让我们通过搜索 mvnrepository/mavencentral来检查是否有已经打过补丁的版本。
值得庆幸的是有一个补丁,看起来我们需要 2.4.4^,太好了。现在让我们看看为什么它首先在我们的类路径中:
./gradlew dependencies > deps

这将生成我们项目的完整依赖项列表。Maven 或 sbt 有等效的命令,你明白了。如果我们对其中一个build.gradles 有明确的依赖关系,那就非常简单了。但通常情况并非如此。打开该deps文件后,我们可以看到一个树状结构:

compileClasspath - Compile classpath for source set 'main'.
+--- org.scala-lang:scala-library:2.11.12
+--- org.slf4j:slf4j-api:1.7.32
+--- project :shared:logging
|    +--- org.apache.logging.log4j:log4j-slf4j-impl:2.17.2
|    |    +--- org.slf4j:slf4j-api:1.7.25 -> 1.7.32
|    |    \--- org.apache.logging.log4j:log4j-api:2.17.2
|    +--- org.apache.logging.log4j:log4j-core:2.17.2
|    |    \--- org.apache.logging.log4j:log4j-api:2.17.2
|    +--- org.apache.httpcomponents:httpclient:4.5.2 -> 4.5.9
|    |    +--- org.apache.httpcomponents:httpcore:4.4.11
|    |    +--- commons-logging:commons-logging:1.2
|    |    \--- commons-codec:commons-codec:1.11
|    \--- javax.servlet:javax.servlet-api:3.1.0


我们在这里看到的是一个给定项目的所有横向依赖关系的列表。这块内容告诉我们如下。

  • 有一个叫做logging的模块的编译依赖,它直接依赖org.apache.logging.log4j:log4j-slf4j-impl:2.17.2,它依赖org.slf4j:slf4j-api:1.7.25。所有这些罐子将被下载并包含在我们的生产包中。

org.slf4j:slf4j-api:1.7.25 -> 1.7.32 的箭头告诉我们,虽然这个库依赖于 1.7.25,但 1.7.32 也在我们的 classpath 上,所以 1.7.25 被驱逐了。一个被驱逐的依赖关系意味着它已经被一个较新的版本推掉了,并且使用较新的版本来代替。我不会在这里说得太详细,因为Maven的依赖管理是一个相当广泛的话题,你可以在这里了解更多。


让我们看看一大块真实的依赖性报告,并尝试摆脱那个json-smart问题:

|    |         |    |    +--- project :shared:custom-msal-lib
|    |         |    |    |    \--- com.microsoft.azure:msal4j:1.10.1
|    |         |    |    |         +--- com.nimbusds:oauth2-oidc-sdk:9.7
|    |         |    |    |         |    +--- com.github.stephenc.jcip:jcip-annotations:1.0-1
|    |         |    |    |         |    +--- com.nimbusds:content-type:2.1
|    |         |    |    |         |    +--- net.minidev:json-smart:[1.3.3,2.4.7] -> 2.3

看起来我们的gradle子模块名为custom-msal-lib,依赖于com.nimbusds:oauth2-oidc-sdk:9.7,它正在拉入一个json-smart:2.3库。那个括号里的列表[1.3.3,2.4.7]意味着库的作者已经努力列出了兼容的版本。这种情况很少发生。这里的例子告诉我们,我们目前正在使用2.3。

下面是custom-msla-lib的build.gradle内容的样子:

dependencies {
    compile "com.microsoft.azure:msal4j:1.10.1"
}

超级简单明了。现在我们在这里有两个选择:

  1. 如果msal4j的新版本没有json-smart的依赖(或者至少它依赖的版本不再受影响),我们可以直接升级库本身。
  2. 如果没有更新的版本,我们可以为 json-smart 添加一个明确的依赖关系。让我们走这条路吧。

dependencies {
    compile "com.microsoft.azure:msal4j:1.10.1"
    compile 'net.minidev:json-smart:2.4.8'
}

一旦我们重新运行扫描:

|    |         |    |    +--- project :shared:custom-msal-lib
|    |         |    |    |    +--- com.microsoft.azure:msal4j:1.10.1
|    |         |    |    |    |    +--- com.nimbusds:oauth2-oidc-sdk:9.7
|    |         |    |    |    |    |    +--- com.github.stephenc.jcip:jcip-annotations:1.0-1
|    |         |    |    |    |    |    +--- com.nimbusds:content-type:2.1
|    |         |    |    |    |    |    +--- net.minidev:json-smart:[1.3.3,2.4.7] -> 2.4.8

很完美!我们在这里都完成了。冲洗和重复,直到你替换了依赖关系树中的所有出现的内容。

你可能已经注意到,我们已经升级到一个比官方支持的版本更新的版本。这是一件完全可以做的事情,因为90%的时候我们都没有这个信息。如果我们打补丁打得太远,导致一些不兼容,我们就需要开始回滚,找到一个仍能工作但确实解决我们的CVE的版本。

第三部分:问题
Snyk和Apiiro

如json-smart例子所示,我们已经摆脱了易受攻击的依赖关系,我们可以通过构建工具运行依赖报告来验证这一点。Trivy也会正确地识别出我们不再包括一个受攻击的jar。不幸的是,Snyk和Apiiro(可能还有其他一些工具)的情况并非如此。

Snyk和Apiiro是静态扫描器--这意味着它们只是解析build.gradle的声明层,而不是实际的依赖树。简单地说,它们太笨了,无法构建依赖树,也无法查看哪些东西实际上被捆绑在我们的应用程序中。他们所做的只是看看顶级的依赖关系,与CVE数据库进行交叉检查,如果发现这个版本有漏洞,他们就会标记它。

因此,如果你的代码库包含大量来自驱逐版本的CVE修复,你会从这些工具中得到大量的误报。Trivy没有这个问题,因为它看的是归档文件中实际存在的罐子,而不是我们构建工具中声明的内容。

模块
在小型代码库中,我们通常不会有大量的模块。不幸的是,有时一些人喜欢在他们的项目中使用模块,这导致了地狱般的依赖关系图。你可能可以猜到,我最近就是这种情况。当你在模块之间引入循环和其他混乱的关系时,情况会变得更糟。幸运的是,有一个简单的策略来简化这种情况。

你需要在构建过程中确定负责组装最终存档的模块,并在那里进行扫描。你不需要分析每一个模块,只需要关注那些与构建过程的最后一步有关的模块。在我的例子中。

./gradlew :project:assemble:dependencies > deps

这将显示最终存档中捆绑的内容树。

...除非你有一些绝对必要的自定义逻辑在你的构建代码库中,比如手动将jars从另一个位置复制到最终的捆绑包中。这当然不会在构建工具的扫描中被发现。幸好,Trivy还是会注意到它。

第四部分:真正的大问题
Classpath问题
你的代码库中可能包含一些东西,比如催生子进程并为这些进程动态传递classpath。作为一个例子。

你的应用程序A运行另一个应用程序B,其classpath被定义在A的项目结构中的不同模块中。

这是一个相当......奇特的设计选择,但这是另一个话题了。在升级依赖关系时,任何一种动态的classpath操作都是非常危险的,因为你可能在运行时遇到ClassNotFoundException时才发现依赖关系被破坏。情况甚至会更糟,因为库之间可能不兼容,导致致命的情况。

  • 库X在2.3.4版本之前只能和库Y一起工作
  • 库Z从2.3.4版本开始只能和库Y一起工作。

如果由于某种原因,你无法提升X/Z(即升级的成本太高),你就完蛋了。我几乎在org.apache.hadoop:hadoop-azure上遇到过这种情况。
  • 3.2.3破坏了代码库的很大一部分,需要进行大规模重写
  • 3.2.1仍有漏洞
  • 幸运的是,3.2.2已经足够好,可以同时工作,但这是一个非常小的差距。

Jackson和地狱的 DDOS
Jackson是几乎所有 Java 软件都依赖的序列化库。当我们从 升级2.11.4到 时2.13.3,一切都乱套了。这可能是迄今为止我见过的最有趣和最烦人的问题 - 升级后,仅在某些生产环境中,一旦某些帐户登录,系统就会自行 DDOSing 直到它死掉。这是一个关于它自己的整个博客文章的主题。过了很久,我们发现了Jackson序列化的一个问题。事实证明,随着我们完成升级,布尔参数的序列化发生了变化。
在前端访问的 DTO 有一个布尔属性,被引用为isAdd. 此引用现在正在返回null,add应该改为使用。在 Typescript 出现之前用大量代码编写的代码库中,这是一个难以解决的问题。
我在 Jackson 团队中诅咒了很多次,因为他们对如此受欢迎的图书馆进行了如此根本的改变,以至于我无法相信他们有意识地做出了这个决定。这很可能是真的——在他们的发行说明中找不到这种变化。从发现这个问题到现在,问题已从 jackson-databind 转移到 jackson-module-kotlin。所以这不是由Jackson本身引起的,而是由jackson-module-kotlin类路径上的事实引起的。我们没有通过任何升级触及该库,但它导致了数周的乏味打地鼠。

阴影依赖
在我们谈论Jackson时,我还需要提及一个小众问题。一些库在它们的包中包含阴影依赖项。可以在此处找到一个很好的解释。这意味着库 A 可以将整个库 B 捆绑为它的代码库的一部分,而不是作为通过pom.xml(通常情况下)表达的依赖关系。这很糟糕,因为我们无法升级该阴影依赖项。一方面,这很可能不会被任何 CVE 扫描器(包括 Trivy)检测到。另一方面,您仍然会受到该库导致的任何 CVE 的影响。如果父库没有与您的代码库兼容的补丁版本,那您就不走运了。你能做的最好的事情就是分叉库并尝试自己升级它。


估算CVE工作
我希望到此为止,你能看到与CVE工作有关的所有错综复杂的细微差别。估算任何 "正常 "的软件开发工作都很难,首先。估计CVE的修补工作几乎是不可能的。这是一个没有经理会接受的答案,所以这里有一些关于如何进行估算的提示:

  • 这项工作在很大程度上受你的团队的知识影响。你对代码库的了解程度如何?总共使用了多少个库?它有多复杂?
  • 集成和e2e测试覆盖率是早期发现问题的关键。单元测试的作用要小得多,所以你的IT/e2e测试越多越好。
  • 编制一个要解决的CVE列表,并根据潜在的影响/难度对它们进行排序。一个protobuf/jackson/spark的升级很可能比碰撞一个电子邮件客户端要严重得多。
  • 如果可能的话,建议分批减少CVE的百分数,而不是解决具体的CVE。如果你遇到了路障,就转到下一个,以后再回来。正如我所提到的,你可能会遇到一些几乎不可能修补的CVE。上级领导需要意识到这一点。可能会有例外情况(没有人会让Log4Shell通过),但你需要确保整个公司都能充分理解这项任务的难度。同意在下一个版本中减少X%的CVE数量,在下一个版本中减少Y%,等等。
  • 最后但并非最不重要的是,要实现自动化。

第五部分:自动化救援
正如任何一位好医生会告诉你的那样,预防问题远比治疗问题要好。CVE扫描应该从第一天起就成为你CI管道的一部分。现在用Github Actions和类似的CI工具来设置它是很容易的。对于新项目,我建议如果检测到任何高/关键的CVE,就放弃你的CI扫描工作(用Trivy超级容易做到)。我们可以让整个管道失效,但是,我们每天都有CVE出现,而这些CVE可能在一段时间内不会被打上稳定版本。

尽力解决所有高度/关键的问题,因为你正在进行。只使用稳定版本。在每次构建时产生一份报告。这是我不久前为Gitlab CI写的一个CI扫描工作样本:

test:cve-scanning:
  stage: test
  image: docker:18-git
  services:
    - docker:18-dind
  variables:
    DOCKER_IMAGE: $DOCKER_IMAGE_REPOSITORY:$CI_COMMIT_SHA
    FILENAME: cve-report-xxx-$CI_COMMIT_TIMESTAMP
  before_script:
    - export TRIVY_VERSION=$(wget -qO - "https://api.github.com/repos/aquasecurity/trivy/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
    - echo $TRIVY_VERSION
    - wget --no-verbose https:
//github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz -O - | tar -zxvf -
    - mkdir ~/.docker && echo
"$DOCKER_AUTH_CONFIG" > ~/.docker/config.json
  allow_failure: true
  script:
    # Build image
    - docker build --pull --no-cache -f docker/xxx/yyy/Dockerfile -t $DOCKER_IMAGE .
    # Build report
    - ./trivy image --exit-code 0 --no-progress -o $FILENAME $DOCKER_IMAGE
    # Print report
    - ./trivy image --exit-code 0 --no-progress $DOCKER_IMAGE
  cache:
    paths:
      - .trivycache/
  artifacts:
    paths:
      - $FILENAME

这里的Docker构建是前一个步骤的重复,但直接重建它要比其他方法容易得多。如果你不生产docker镜像,而是生产可安装的软件包,你仍然可以使用Trivy的文件系统模式和一些解包工具(比如RPM文件的rpm2cpio)。

下面是另一个取自Teamcity Kotlin DSL 的例子(如果可能的话,尽量避免使用Teamcity)。

    steps {
        script {
            name = "Download Trivy"
            scriptContent =
"""
                wget --no-verbose https:
//github.com/aquasecurity/trivy/releases/download/v${trivyVersion}/trivy_${trivyVersion}_Linux-64bit.tar.gz -O - | tar -zxvf -
           
""".trimIndent()
        }
        script {
            name =
"Extract all RPMs"
            scriptContent =
"""
                cd $directory
                ls -al
                find . -name
"*.rpm" -exec rpm2cpio {} \; | cpio -idmv
           
""".trimIndent()
        }
        script {
            name =
"Trivy scan"
            scriptContent =
"""
                ./trivy rootfs --severity HIGH,CRITICAL --security-checks vuln --format json $directory > $jobName.json
           
""".trimIndent()
        }
    }

总结
我希望你会发现这对你有帮助。尽管CVE补丁并不是大多数程序员想要从事的工作,但它确实有其好处。解决我上面描述的那些问题可以提高你的整体知识和资历,远远超过实现另一个CRUD。

因此,即使你发现自己在凌晨2点用头撞墙,也有大量的价值,最终,解决这样的难题会让你感到满意。即使它们是乏味的。说到这里,让我们希望我们都能体验到不同于CVE补丁的挑战。祝你们好运!