10 个你必须知道的 Java 安全最佳实践
发布于 18 天前 作者 yan 35 次浏览

(给ImportNew加星标,提高Java技能)

编译:ImportNew/唐尤华

snyk.io/blog/10-java-security-best-practices/

1.用查询参数化防止注入

在2017版OWASP十大漏洞中,注入攻击在当年名列前茅。查看典型的Java SQL注入,会发现查询参数拼接进了SQL语句。下面Java代码执行的SQL非常不安全,攻击者会利用它来获取设定之外的信息。

public void selectExample(String parameter) throws SQLException {
  Connection connection = DriverManager.getConnection(DB_URL, USER, PASS);
  String query = "SELECT * FROM USERS WHERE lastname = " + parameter;
  Statement statement = connection.createStatement();
  ResultSet result = statement.executeQuery(query);

  printResult(result);
} 

如果例子中的参数写成 ‘’ OR 1=1,那么查询结果会包含表中所有条目。如果数据库支持多个查询参数问题会更严重,例如把参数写成 ‘’; UPDATE USERS SET lastname=’’。

为了防止出现这种情况,应该在Java程序中使用PreparedStatement对查询参数化。这应该成为创建数据库查询的唯一方法。通过定义完整的SQL代码并在之后把参数传给查询,代码更容易理解。最重要的是,通过区分SQL代码和参数数据,查询不会被恶意输入劫持。

public void prepStatmentExample(String parameter) throws SQLException {
  Connection connection = DriverManager.getConnection(DB_URL, USER, PASS);
  String query = "SELECT * FROM USERS WHERE lastname = ?";
  PreparedStatement statement = connection.prepareStatement(query);
  statement.setString(1, parameter);
  System.out.println(statement);
  ResultSet result = statement.executeQuery();

  printResult(result);
} 

在上面的示例中,输入类型绑定为 String,成为查询代码的一部分。这样可以防止输入参数干扰SQL代码。

2.使用带双因子验证的OpenID Connect

身份管理与访问控制是非常困难的,而身份验证失败通常是造成数据泄露的主要原因。实际上,这个问题在OWASP十大漏洞列表中排名第二。自己进行身份验证时需要考虑很多因素:密码安全存储、强加密、凭证检索等。很多时候,使用类似OpenID Connect这样的解决方案更安全更简单。OpenID Connect(OIDC)可以实现跨网站和应用程序用户身份验证。这样不再需要保存和管理密码文件。OpenID Connect是一个OAuth 2,0扩展,可以提供用户信息。除访问令牌外,还提供了一个ID令牌和/userinfo端点,可以获得更多信息。它还提供了端点发现和客户端动态注册功能。

用Spring Security设置OpenID Connect很简单。请确保您的应用程序强制执行2FA(双因子身份验证)或MFA(多因素身份验证),在系统中提供额外的安全层。

向 Spring Boot应程程序添加oauth2-client和Spring Security依赖可以利用Google、Github和Okta等第三方客户端处理OIDC。创建应用程序后,只需在应用程序配置中根据需要像下面这样指定客户端,可以是GitHub或者Okta client-id与client-secret。

pom.xml

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-security</artifactId>
</dependency> 

application.yaml

spring:
security:
  oauth2:
    client:
        registration:
          github:
            client-id: 796b0e5403be4729ca01
            client-secret: f379318daa27502254a05e054361074180b840a9
          okta:
            client-id: 0oa1a4wascEpYu6yk358
            client-secret: hqxj7a9lVe_TudbS2boBW7AWwxTlZiHNrJxdc_Sk
            client-name: Okta
        provider:
          okta:
            issuer-uri: https://dev-844689.okta.com/oauth2/default 

3.扫描依赖项查找已知漏洞

您很有可能不知道自己的应用程序究竟使用了多少个直接依赖项,也极有可能不知道应用程序包含了多少个可传递依赖项。尽管依赖项构成了整个应用程序的绝大部分,但实际的结果通常如此。攻击者越来越多地将目标锁定在开源依赖项上,使用它们会成为恶意攻击的受害者。确保应用程序的整个依赖树中没有已知漏洞很重要。

Snyk可以测试应用程序生成的构件,把那些有漏洞的依赖项标记出来。Snyk会在仪表盘中展示软件包存在的漏洞列表。

此外,通过对代码仓库pull request,还会给出升级建议或者补丁程序补救安全漏洞。Snyk使用WebHook对pull request执行自动测试,确保不会引入新的已知漏洞。

Snyk支持Web和CLI,可以集成到CI流程。经过配置,当漏洞的严重性超过设置的阈值时能中断构建。

开源项目或者每个月测试次数不多的私人项目可以免费使用Snyk。

4.处理敏感数据要小心

暴露敏感数据会带来风险,比如客户的个人数据或者信用卡号。一些注意不到的细节同样有风险,例如在系统中公开唯一标识符,该标识符可以用来在其他调用中获取额外的数据。

首先,仔细查看程序设计确认是否真的需要这些数据。最重要的是确保敏感数据不被泄露,包括日志、自动完补全、数据传输等。

要防止日志泄露敏感数据,一种简单的方法是清理域实体中的toString()方法。这样可以避免意外打印出敏感字段。如果项目使用Lombok生成toString()方法,可以用@ToString.Exclude把字段排除在toString()输出之外。

另外,为外部提供数据时也要非常小心。例如:如果系统中的某个端点可以显示所有用户名,那么不要提供系统内部唯一标识符。唯一标识符可能被用来获取用户其他敏感信息。如果使用Jackson实现POJO的JSON序列化和反序列化,可以用@JsonIgnore@JsonIgnoreProperties阻止这些属性被序列化或反序列化。

如果需要把敏感数据发送到其他服务,请对其进行适当的加密,并确保使用HTTPS保护连接安全。

5.清理所有输入

跨站点脚本攻击(XSS)是一个众所周知的问题,通常出现在JavaScript应用程序中。然而,Java也不能幸免。XSS只是注入远程执行的JavaScript代码。根据OWASP的说法,预防XSS的#0号规则是“不要在允许的位置插入非信任数据”。要解决这个问题,最基本的方法是在使用数据之前,尽可能地预防不可信数据并清除所有其他内容。OWASP Java encoding是一个很好的选择,提供了多种encoder。

<dependency>
  <groupId>org.owasp.encoder</groupId>
  <artifactId>encoder</artifactId>
  <version>1.2.2</version>
</dependency> 
String untrusted = "<script> alert(1); </script>";
System.out.println(Encode.forHtml(untrusted));

// output: <script> alert(1); </script> 

对用户输入的文本进行处理是必须的。但如果是从数据库检索出的数据呢?假如数据库遭到破坏,有人在数据库字段或文档中植入了一些恶意文本该怎么办?

另外,对传入的文件也要注意。Zip-slip漏洞在很多库中都存在,原因就是没有对zip文件路径进行检查。Zip包含的文件可能带有…/…/…/…/foo.xy这样的名字,在解压缩时可能会覆盖任意文件。尽管这不是XSS攻击,但是这个例子充分说明了为什么必须清理所有输入。每个输入都可能是恶意的,因此都要进行清理。

6.配置XML解析器防止XXE

启用XML外部实体(XXE)后,可能被利用创建恶意XML,像下面这样读取计算机上任意文件的内容。XXE攻击是OWASP排名前十的攻击之一。Java XML库特别容易受到XXE注入,因为大多数XML解析器默认启用了外部实体。

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!DOCTYPE bar [
      <!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<song>
  <artist>&xxe;</artist>
  <title>Bohemian Rhapsody</title>
  <album>A Night at the Opera</album>
</song> 

下面的DefaultHandler和Java SAX解析器实现了对XML文件解析并显示passwd文件内容。虽然这里演示用的是Java SAX解析器,其他解析器(比如DocumentBuilder和DOM4J)都具有类似的默认行为。

SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser saxParser = factory.newSAXParser();

DefaultHandler handler = new DefaultHandler() {

   public void startElement(String uri, String localName,String qName,Attributes attributes) throws SAXException {
       System.out.println(qName);
   }

   public void characters(char ch[], int start, int length) throws SAXException {
       System.out.println(new String(ch, start, length));
   }
}; 

更改默认设置,禁止xerces1或xerces2的外部实体和doctype可防止此类攻击。

...
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser saxParser = factory.newSAXParser();

factory.setFeature("https://xml.org/sax/features/external-general-entities", false);
saxParser.getXMLReader().setFeature("https://xml.org/sax/features/external-general-entities", false);
factory.setFeature("https://apache.org/xml/features/disallow-doctype-decl", true);
... 

更多防止恶意XXE注入方法,可以查阅 OWASP XXE备忘。

7.避免Java序列化

Java序列化可以把对象转换为字节流。转换后的字节流可以存到磁盘上或者传给其他系统。相反的过程称为反序列化,可以从字节流重新创建原始对象。

最大的问题在于反序列化,通常看起来像下面这样:

ObjectInputStream in = new ObjectInputStream( inputStream );
return (Data)in.readObject(); 

在解码之前,无法知道反序列化的内容。攻击者可能会构建恶意对象,序列化以后发送给应用程序。一旦调用 readObject(),恶意对象就会实例化。您可能认为这些攻击不可能发生,因为这意味着classpath上会出现易受攻击的类。但如果考虑classpath上类的数量,包括自己的代码、Java库、第三方库和框架,很有可能出现易受攻击的类。

由于这些年来出现了很多问题,Java序列化也被戏称为“送礼送不停”。Oracle计划把Java序列化作为Project Amber的一部分删除。但这可能需要一些时间,而且不太可能在之前的版本中解决。因此,明智的做法是尽可能避免Java序列化。如果需要在域实体上实现可序列化,最好自己实现readObject(),像下面这样。可以防止反序列化。

private final void readObject(ObjectInputStream in) throws java.io.IOException {
  throw new java.io.IOException("Deserialized not allowed");
} 

如果需要对输入流自己进行反序列化,应该使用ObjectsInputStream并进行限制。一个很好的例子是Apache Commons IO的ValidatingObjectInputStream。这个ObjectInputStream会查对象是否允许反序列化。

FileInputStream fileInput = new FileInputStream(fileName);
ValidatingObjectInputStream in = new ValidatingObjectInputStream(fileInput);
in.accept(Foo.class);

Foo foo_ = (Foo) in.readObject(); 

对象反序列化问题不仅限于Java序列化。从JSON反序列化为Java对象也有类似的问题。

8.使用强加密和哈希算法

要在系统中存储敏感数据,必须确保加密。首先需要选择加密类型,比如对称加密或者非对称加密。另外需要确认安全性做到何种程度。加密越强花费的时间越多,消耗CPU资源也越多。最重要的是不必自己实现加密算法。加密是一项很难的活,可以选择合适的加密开发库解决。

例如,如果要加密信用卡详细信息之类的内容,可能需要对称算法,因为需要能够获取原始号码。假如使用高级加密标准(AES),该标准目前是美国联邦组织的标准对称加密算法。要完成加密和解密工作,没有理由深入研究底层Java加密技术。建议使用开发库完成繁重的工作。例如Google Tink。

<dependency>
  <groupId>com.google.crypto.tink</groupId>
  <artifactId>tink</artifactId>
  <version>1.3.0-rc1</version>
</dependency> 

下面是一个简短的示例,展示了如何使用带有AES的关联数据身份验证加密(AEAD)。这段程序对明文进行加密并进行身份验证,但是相关数据没有加密。

private void run() throws GeneralSecurityException {
  AeadConfig.register();
  KeysetHandle keysetHandle = KeysetHandle.generateNew(AeadKeyTemplates.AES256_GCM);

  String plaintext = "I want to break free!";
  String aad = "Queen";

  Aead aead = keysetHandle.getPrimitive(Aead.class);
  byte[] ciphertext = aead.encrypt(plaintext.getBytes(), aad.getBytes());
  String encr = Base64.getEncoder().encodeToString(ciphertext);
  System.out.println(encr);

  byte[] decrypted = aead.decrypt(Base64.getDecoder().decode(encr), aad.getBytes());
  String decr = new String(decrypted);
  System.out.println(decr);
} 

密码使用非对称加密比较安全,因为不需要检索原始密码,只要哈希匹配即可。BCrypt配合SCrypt可以很好地完成任务。两者都采用密码散列(单向函数),计算复杂,需要消耗大量计算时间。这正是你想要的,因为蛮力攻击需要很长时间。

Spring security为各种算法提供了出色的支持。可以使用Spring Security tool 5提供的SCryptPasswordEncoder和BCryptPasswordEncoder对密码进行哈希。

今天的强加密算法一年后可能会变成弱加密算法。因此,需要定期检查确保使用正确的算法。使用经过审查的安全开发库并保持最新。

9.启用Java安全管理器

默认情况下,Java进程没有任何限制。可以访问各种资源,包括文件系统、网络、外部进程等。Java安全管理器机制可以控制这些权限。Java安全管理器默认没有激活,JVM对机器具有无限控制权。尽管可能不希望JVM访问系统的某些部分,但它的确具有访问权限。更重要的是,Java API会搞出一些意想不到的麻烦。

在我看来最恐怖的一种是Attach API。使用这个API,可以连接到其他正在运行的JVM并对其进行控制。例如,如果能够访问机器,那么更改正在运行中的JVM字节码是非常容易的。Nicolas Frankel的 这篇博客给出了示例。

激活Java安全管理器很容易。启动Java时带上java -Djava.security.manager参数,会用默认策略激活安全管理器。

但是,默认策略可能并能不完全满足系统要求。可能需要创建自定义策略并将其提供给JVM。java -Djava.security.manager -Djava.security.policy==/foo/bar/custom.policy

请注意双等号,这样可以替换默认策略。使用单个等号可以基于默认策略进行自定义。

有关JDK权限以及如何编写策略文件,请查阅Java官方文档。

10.集中记录日志和监控

安全不仅仅是预防,还要知道问题发生的时间,以便采取相应的措施。使用哪个日志库并不重要。OWASP Top 10指出,重要的是记录很多日志。日志记录不足是一个大问题。通常,所有可供审核的事件都应该记录。像异常、登录和登录失败这样的事件都需要记录,还可能希望记录每个传入请求及其来源。万一被黑客入侵,至少能够知道发生了什么、发生的具体时间和方式。

建议集中记录日志。例如,如果使用logback或log4j,可以很容易连上集中式日志分析平台Elastic Stack。使用像Kibana这样的工具,可以访问和搜索来自所有服务器和系统的日志进行调查。

仅次于记录日志,还应当主动监视系统并把这些内容集中存储到易于访问的地方。出现像CPU峰值或者单个IP地址负载过高的情况,很可能表明出现了问题或者攻击。把集中式日志记录和实时监视与警报结合在起来,可以在出现情况时及时收到通知,比如管理员密码重置、内部服务器受到外部IP访问、URL参数包含‘UNION’等这样不正常的情形。收到类似的问题警报时,及时跟踪发生的情况能够防止遭受更大的损失并能及时修复漏洞。

推荐阅读

(点击标题可跳转阅读)

数据库选择需要考虑的 12 个问题

JDK 12 Collectors.teeing 实例介绍

使用 Redis 和 Spring Boot 执行异步任务

看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

回到顶部