为什么在密码中char[]比String更受欢迎?

3836
在Swing中,密码字段具有getPassword()方法(返回char[]),而不是通常的getText()方法(返回String)。同样,我发现建议不要使用String来处理密码。
为什么String在涉及密码时会对安全构成威胁? 使用char[]感觉很不方便。
17个回答

4728

字符串是不可变的。这意味着一旦创建了String,如果另一个进程可以转储内存,除非使用反射(reflection),否则在垃圾回收开始之前,你无法摆脱数据。

使用数组后,您可以在完成后显式清除数据。您可以用任何东西覆盖数组,并且密码将不会存在于系统中的任何地方,即使在垃圾回收之前。

因此,是的,这确实是一个安全问题 - 但即使仅使用char[],也仅仅减少了攻击者的机会窗口,而且仅适用于此特定类型的攻击。

正如评论中所述,垃圾收集器移动数组可能会在内存中留下杂散的数据副本。我认为这取决于具体实现 - 垃圾回收器可能会在进行时清除所有内存,以避免此类情况。即使如此,在char[]包含实际字符作为攻击窗口的时间内,仍然存在攻击的可能。


37
如果一个进程可以访问你的应用程序内存,那么这已经属于安全漏洞了,是吗? - Yeti
34
@Yeti:是的,但这不是非黑即白的。如果他们只能获取内存的快照,那么您希望减少该快照可能造成的损害,或者缩短真正严重的快照可以被拍摄的时间窗口。 我的翻译:是的,但情况并非非黑即白。如果他们只能获得内存的快照,你需要减少该快照可能造成的损害,或者缩短真正严重的快照可以被拍摄的时间窗口。 - Jon Skeet
74
一种常见的攻击方法是运行一个进程来分配大量内存,然后扫描其中留下的有用数据,如密码。该进程不需要对另一个进程的内存空间进行神奇访问,它只依赖于其他进程在死亡之前没有清除敏感数据以及操作系统在将内存(或页面缓冲区)提供给新进程之前未清除内存。清除存储在char[]位置的密码可以切断这种攻击方式,而使用String时则不可能实现这一点。 - Ted Hopp
24
如果操作系统在将内存分配给另一个进程之前没有清除它,那么操作系统会存在重大安全问题!然而,从技术上讲,清除通常是通过受保护模式的技巧来完成的,如果CPU出现故障(例如Intel Meltdown),仍然可能读取旧的内存内容。 - Mikko Rantalainen
12
只有在源代码中存在或明确使用intern函数才会将其放入字面常量池中。但这两种方式通常都不是一个好主意... - Jon Skeet
显示剩余19条评论

1335
虽然这里的其他建议似乎是有效的,但还有一个很好的原因。使用普通的String会更容易意外将密码打印到日志、监视器或其他不安全的地方,而char[]则更少受到攻击。
考虑以下情况:
public static void main(String[] args) {
    Object pw = "Password";
    System.out.println("String: " + pw);

    pw = "Password".toCharArray();
    System.out.println("Array: " + pw);
}

输出:

String: Password
Array: [C@5829428e

51
@voo,但我怀疑您会通过直接写入流和连接来记录日志。记录框架将把char[]转换为良好的输出。 - bestsss
49
toString 的默认实现是 classname@hashcode。其中 [C 表示 char[],其余部分为十六进制哈希码。 - Konrad Garus
22
有趣的想法。我想指出的是,这个想法不能应用于Scala中的数组,因为Scala的数组具有有意义的toString函数。 - mauhiz
48
我会为此编写一个密码类类型。这样不仅更清晰易懂,而且更难意外传递到其他地方。 - user1804599
13
数组的哈希码并不取决于数组的内容,它实际上是随机的:在线示例 - Octavia Togami
显示剩余7条评论

745
为引用官方文件,Java加密体系结构指南对于char[]String密码(关于基于密码的加密,但这当然更普遍地涉及密码)有以下说法:

收集并存储密码在类型为java.lang.String的对象中似乎很合理。然而,这里有一个警告:类型为String的对象是不可变的,即没有定义允许您在使用后更改(覆盖)或清除String内容的方法。这个特性使得String对象不适合存储诸如用户密码之类的安全敏感信息。您应该始终将安全敏感信息收集和存储在char数组中。

Java编程语言安全编码准则第4.0版的2-2指南也提到了类似的内容(尽管它最初是在日志记录的上下文中):

指南2-2:不记录高度敏感信息

某些信息,例如社会安全号码(SSN)和密码等是非常敏感的。这些信息不应该保存的时间比必要的更长,并且不能被管理员甚至看到。例如,它不应该被发送到日志文件中,并且其存在不应通过搜索可检测。一些短暂的数据可以保存在可变数据结构中,例如字符数组,并在使用后立即清除。由于对象在内存中透明地移动,因此在典型的Java运��时系统上,清除数据结构的效果降低。

这个指南还涉及到不具有数据语义知识的低级别库的实现和使用。例如,一个低级别的字符串解析库可能会记录它所处理的文本。一个应用程序可能使用该库解析SSN。这将创建一个情况,其中可以通过访问日志文件的管理员获得SSNs。


6
这正是我在Jon回答下面提到的有缺陷/虚假的参考资料,它是一个备受批评的广为人知的来源。 - bestsss
46
@bestass,你能否提供一个参考文献? - user961954
17
很抱歉,@bestass,"String"在JVM中被广泛理解,并且其行为已经被很好地定义了... 在安全处理密码时,使用"char[]"代替"String"是有很好的原因的。请注意,此处仅进行翻译,没有解释或其他额外内容。 - SnakeDoc
5
即使密码以字符串形式从浏览器传递到请求中,也应该作为“字符串”而不是“字符”进行处理,这意味着无论如何它都是一个字符串。此时应对其进行处理并且立即丢弃,永远不要将其存储在内存中。 - Dawesi
6
“在哪个时间点”这取决于具体的应用程序,但一般规则是尽早处理那些被认为是密码(无论是明文还是其他形式)的东西。例如,你可以从浏览器作为HTTP请求的一部分获取它。虽然你不能控制传输,但你可以控制自己的存储方式,因此一旦获取到密码,将其放入char[]中,进行必要的处理后,将所有值设置为'0',并让垃圾回收器回收它。 - luis.espinal
显示剩余2条评论

392

字符数组(char[])可以通过将每个字符设置为零来清除使用后的数据,但字符串则不行。如果有人能够以某种方式查看内存图像,而使用字符串时密码会以明文形式显示,但如果使用char[],在用0清除数据后,密码就是安全的。


17
默认情况下不安全。如果我们谈论的是Web应用程序,大多数Web容器将以明文方式将密码传递到“HttpServletRequest”对象中。如果JVM版本为1.6或更低,则会在permgen空间中。如果是1.7,则仍然可以读取直到被收集(无论何时)。 - avgvstvs
6
字符串不会自动移至永久代空间,这仅适用于被intern的字符串。此外,永久代空间也会受到垃圾收集的影响,只是速率较低而已。永久代空间的真正问题在于它的大小是固定的,这正是为什么没有人应该毫无节制地对任意字符串调用intern()的原因。但你说得对,String实例一开始就存在(直到被回收),之后将其转换为char[]数组并不会改变这一点。 - Holger
4
在其他情况下,将创建一个包含CONSTANT_String_info结构所给出的Unicode字符序列的String类实例;该类实例是字符串字面值派生的结果。最后,将调用新String实例的intern方法。在1.6中,当JVM检测到相同序列时,将为您调用intern方法。 - avgvstvs
4
@Holger,你是正确的,我混淆了常量池和字符串池,但是它也不准确地说永久代空间 仅仅 应用于内部化字符串。在1.7版本之前,常量池和字符串池都存储在永久代空间中。这意味着分配到堆上的唯一字符串类别就像你所说的那样是 new String() 或者 StringBuilder.toString()。我管理有大量字符串常量的应用程序,结果导致了很多永久代空间增长,直到1.7版本。 - avgvstvs
7
字符串常量根据JLS的规定始终会被放入intern池中,因此将这些字符串放入永久代空间中的说法是隐含的。唯一的区别在于字符串常量最初就是在永久代空间中创建的,而对任意字符串调用intern()可能会导致等效的字符串分配到永久代空间。如果没有与该对象共享相同内容的字面字符串,则后者可能会被垃圾回收(GC)。 - Holger
显示剩余5条评论

237
有些人认为,当你不再需要密码时,必须覆盖用于存储密码的内存。这样可以缩短攻击者从系统中读取密码的时间窗口,但完全忽略了攻击者已经需要足够的访问权限来劫持JVM内存。拥有那么高的访问权限的攻击者可以捕捉到你的关键事件,使这种方法完全无用(据我所知,请纠正我如果我错了)。
更新:
感谢评论,我需要更新我的答案。显然有两种情况下,这可以增加(非常)微小的安全改进,因为它可以缩短密码可能落在硬盘上的时间。尽管如此,我认为对于大多数用例来说,这是过度的。
  • 你的目标系统可能配置不良,或者你必须假设它是这样的,并且你必须对核心转储保持偏执(如果系统没有由管理员管理,则可以有效)。
  • 你的软件必须过度谨慎,以防止数据泄漏,攻击者获得硬件访问权限 - 使用诸如 TrueCrypt(已停用),VeraCryptCipherShed 等工具。

如果可能,禁用核心转储和交换文件将解决这两个问题。但是,它们需要管理员权限,可能会降低功能(可用内存较少),从运行中的系统中提取 RAM 仍然是一个有效的问题。


39
我会尽力进行翻译并使其通顺易懂,但不会改变原意。下面是需要翻译的内容:我想把“完全没有用”的说法改为“只是一个小小的安全提升”。举个例子,如果你恰好有读取tmp目录的权限、一台配置不良的机器和应用程序崩溃,你就可以访问内存转储。在这种情况下,你可能无法安装键盘记录器,但确实可以分析核心转储。 - Joachim Sauer
52
在你使用完未加密的数据后立即清除内存中的数据被认为是最佳实践,不是因为它是绝对安全的(它并不是),而是因为它可以降低威胁程度。这样做并不能防止实时攻击,但因为它作为一种损害减轻工具,显著地减少了在对内存快照(例如应用程序内存的副本被写入交换文件或从正在运行的服务器中提取并移动到另一个服务器之前读取的状态)进行的反向攻击中暴露出的数据量。 - Dan Is Fiddling By Firelight
11
我倾向于同意这个回复的态度。我敢提出一个假设,即大多数有影响的安全漏洞发生在比内存位更高的抽象层次上。当然,在超级安全的防御系统中可能会有这样的情况,但严肃地思考这个层次对于使用.NET或Java进行垃圾回收的99%的应用程序来说是过度杀伤力的。 - kingdango
13
在Heartbleed攻击中,服务器内存被侵入并暴露了密码后,我会将“只是一个小的安全改进”替换为“绝对必要不使用String来存储密码,而是使用char []”。 - Peter vdL
11
心脏出血漏洞只允许读取一个特定的重复缓冲区集合(用于安全关键数据和网络I/O,由于性能原因,在两者之间不清除),您不能与Java字符串混合使用,因为它们从设计上是不可重用的。也不能使用Java读取随机内存以获取字符串的内容。导致心脏出血漏洞的语言和设计问题在Java字符串中是不可能发生的。 - josefx
显示剩余15条评论

93

我认为这不是一个有效的建议,但是我至少可以猜测原因。

我认为动机是希望确保在使用密码后可以确定和及时地从内存中删除所有密码痕迹。使用 char[] 可以确保用空白或其他内容覆盖数组的每个元素。你不能通过这种方式编辑 String 的内部值。

但单凭这一点还不足以解决问题; 为什么不只是确保 char[]String 的引用没有泄露? 那么就没有安全问题了。但问题在于,String 对象在理论上可以被 intern() ,并保留在常量池中。我想使用 char[] 就可以避免这种可能性。


4
我认为问题不在于你的引用是否会“逃逸”,而是字符串在内存中会保持不变一段时间,而char[]可以被修改,所以它是否被回收无关紧要。由于对于非文字常量,需要显式地进行字符串池化,这就相当于说一个char[]可以被静态字段引用。请注意,不会改变原来的意思。 - vgru
3
密码不是以字符串形式从表单提交后保存在内存中的吗? - Dawesi

78
答案已经给出,但我想分享一个问题,就是我最近在Java标准库中发现的。虽然它们现在非常注意用char[]替换密码字符串(这当然是一件好事),但是其他安全关键数据似乎被忽略了,例如PrivateKey类。考虑从PKCS#12文件加载私有RSA密钥并使用它执行某些操作的情况。在这种情况下,仅仅嗅探密码并不能帮助您太多,只要物理访问密钥文件受到适当限制。作为攻击者,如果直接获得密钥而不是密码,将会更加有利。所需信息可以通过各种方式泄漏,如核心转储、调试器会话或交换文件等。

事实证明,目前没有任何API可以让您清除来自PrivateKey的私有信息,因为没有API可以擦拭形成相应信息的字节。

这是一个糟糕的情况,因为这篇论文描述了这种情况可能被潜在地利用。例如,OpenSSL库会在释放私钥之前覆盖关键内存部分。由于Java是垃圾收集的,我们需要明确的方法来擦除和失效Java密钥的私有信息,而这些方法必须在使用密钥后立即应用。


一种解决方法是使用一个不会在内存中加载私有内容的PrivateKey实现:例如通过PKCS#11硬件令牌。也许PKCS#11的软件实现可以手动清理内存。或者使用类似NSS存储(它与Java中的PKCS11存储类型共享大部分实现)之类的东西更好。KeychainStore(OSX密钥库)将私钥的完整内容加载到其PrivateKey实例中,但它不应该需要这样做。(不知道WINDOWS-MY KeyStore在Windows上做了什么。) - Bruno
@Bruno 硬件令牌不会受到此类问题的影响,但是在被迫使用软件密钥的情况下该怎么办呢?并非每个部署都有足够的预算来购买 HSM。软件密钥存储库必须在某个时候将密钥加载到内存中,因此我认为我们至少应该被赋予再次随意清除内存的选项。 - emboss
我只是在想,是否有一些软件实现类似于HSM的东西,在清理内存方面表现更好。例如,在使用Safari/OSX的客户端身份验证时,Safari进程实际上从未看到私钥,由操作系统提供的底层SSL库直接与安全守护程序通信,提示用户使用来自密钥链的密钥。虽然这都是在软件中完成的,但如果签名被委托给一个不同的实体(甚至是基于软件的实体),那么类似于这样的分离可能会有所帮助,可以更好地卸载或清除内存。 - Bruno
@Bruno:有趣的想法,另外增加一个间接层来处理内存清理确实可以透明地解决这个问题。为软件密钥库编写PKCS#11包装器就可以达到这个目的吗? - emboss
有趣的是你这么说,他们非常注意现在使用char[],因为我正在看 JDK 9 中添加的这个漂亮的新ConnectionBuilder类,它仍然有password(String),根本没有传递char[]的选项。似乎在Java中有很多"说一套做一套"的情况。 - Hakanai

59
正如Jon Skeet所说,除了使用反射,没有其他方法。
然而,如果您可以使用反射,您可以这样做。
public static void main(String[] args) {
    System.out.println("please enter a password");
    // don't actually do this, this is an example only.
    Scanner in = new Scanner(System.in);
    String password = in.nextLine();
    usePassword(password);

    clearString(password);

    System.out.println("password: '" + password + "'");
}

private static void usePassword(String password) {

}

private static void clearString(String password) {
    try {
        Field value = String.class.getDeclaredField("value");
        value.setAccessible(true);
        char[] chars = (char[]) value.get(password);
        Arrays.fill(chars, '*');
    } catch (Exception e) {
        throw new AssertionError(e);
    }
}

运行时

please enter a password
hello world
password: '***********'

注意:如果字符串的char[]在GC循环的一部分中被复制,那么先前的副本有可能仍然存在于内存中。这个旧的副本不会出现在堆转储中,但是如果您直接访问进程的原始内存,则可以看到它。通常情况下,您应该避免任何人具有这样的访问权限。

3
最好还是做一些防止打印我们从“'***********'”获取的密码长度的措施。 - chux - Reinstate Monica
@chux 你可以使用零宽字符,但这可能会更加混乱而不是有用。没有使用 Unsafe 的情况下无法改变 char 数组的长度。 ;) - Peter Lawrey
11
由于Java 8的字符串去重功能,我认为这样做可能会很破坏性...你可能会清除程序中其他具有与密码字符串相同值的字符串,尽管概率很小但仍然有可能发生。 - jamp
@jamp 预计Java 9将具备此功能以及紧凑字符串(即使用byte[])。我不认为这在Java 8中发生过。 - Peter Lawrey
1
@PeterLawrey,它必须通过JVM参数启用,但是已经存在了。您可以在此处阅读有关它的信息:https://blog.codecentric.de/en/2014/08/string-deduplication-new-feature-java-8-update-20-2/ - jamp
2
有很高的概率密码仍然在"Scanner"的内部缓冲区中,并且由于您没有使用"System.console().readPassword()",因此以可读形式出现在控制台窗口中。但对于大多数实际应用情况而言,“usePassword”的执行时间是实际问题。例如,在连接到另一台机器时,它需要相当长的时间,并告诉攻击者现在是搜索堆中的密码的正确时间。唯一的解决方案是防止攻击者读取堆内存... - Holger

47

与 String 相比,除非在使用后手动清理,否则 char 数组不会提供任何优势,而我没有见过有人真正这样做。因此,在我看来,char[] 与 String 的偏好有些夸大了。

看看广泛使用的 Spring Security 库(链接),问问自己——Spring Security 的人员是否无能或 char[] 密码是否毫无意义。当一些恶意黑客获取您 RAM 的内存转储时,请确保他们即使使用复杂的方法来隐藏密码,也将获得所有密码。

然而,Java 始终在变化,一些可怕的特性,如Java 8 的字符串去重复功能可能会在您不知情的情况下对字符串对象进行内部化操作。但那是另一个话题。


3
为什么字符串去重很吓人?它只在至少有两个具有相同内容的字符串时才适用,因此让这两个已经相同的字符串共享同一个数组会引起什么危险呢?或者换句话说:如果没有字符串去重,那么这两个字符串都有一个不同的数组(内容相同)有什么优势呢?在任何一种情况下,具有该内容的数组至少与具有该内容的最长寿命字符串一样长。 - Holger
@Holger,任何超出你控制范围的事情都是潜在的风险...例如,如果两个用户使用相同的密码,这个神奇的功能将把它们都存储在单个char[]中,表明它们是相同的,不确定这是否是一个巨大的风险,但仍然需要注意。 - Oleg Mikheev
1
如果您可以访问堆内存和两个字符串实例,那么无论这些字符串指向相同的数组还是两个具有相同内容的数组,都很容易找出。特别是因为这并不重要。如果您已经到达了这一点,那么无论密码是否相同,都会获取两个密码。实际错误在于使用明文密码而不是加盐哈希值。 - Holger
@Holger 要验证密码,它必须在内存中以明文形式存在一段时间,即10毫秒,即使只是为了创建一个加盐哈希。然后,如果有两个相同的密码在内存中保留了10毫秒,可能会出现去重。如果它真的将字符串放入内存中,则会在内存中保存更长时间。长时间不重新启动的系统将收集大量这些数据。只是理论推测。 - Oleg Mikheev
5
似乎你对字符串去重有一个基本的误解。它并不是“将字符串内部化”,它所做的只是让具有相同内容的字符串指向同一个数组,这实际上减少了包含明文密码的数组实例数量,因为除了一个实例外,所有其他实例都可以立即被回收并被其他对象覆盖。这些字符串仍然像任何其他字符串一样被收集。也许如果你理解去重实际上是由垃圾收集器完成的,而且只针对已经经过多次GC循环的字符串,可能会有所帮助。 - Holger
2
“Spring Security的人员是否无能”:在这个背景下,这是一个重要的问题。我之前也曾想过这个问题,当我研究BCryptBCryptPasswordEncoder之间的区别时。即使在同时初始提交中,他们采取了不一致的方法:对于Bcrypt采用String,而对于调用BcryptBCryptPasswordEncoder则采用CharSequence - Brent Bradburn

44

编辑:回到这个答案一年后的安全研究中,我意识到它不幸地暗示你可能会真正比较明文密码。请不要这样做。使用带有盐和合理迭代次数的安全单向哈希。考虑使用库:这东西很难搞定!

原始回答:String.equals()使用短路求值,因此容易受到时序攻击的影响吗?这可能不太可能,但您可以理论上计时密码比较以确定字符的正确顺序。

public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        // Quits here if Strings are different lengths.
        if (n == anotherString.value.length) {
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            // Quits here at first different character.
            while (n-- != 0) {
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

关于时序攻击的更多资源:


但是char[]比较中也可能存在这种情况,我们在密码验证中也会做相同的事情。那么char[]怎么比string更好呢? - Mohit Kanwar
3
你说得很对,这种错误两种方式都可能出现。重要的是知道这个问题,因为在Java中,无论是基于String还是基于char[]的密码都没有明确的密码比较方法。我认为使用compare()比较字符串的诱惑是采用char[]的一个好理由。这样,你至少可以控制比较的方式(而不需要扩展String,我认为这很麻烦)。 - Graph Theory
1
除了明文密码比较本来就不是正确的做法之外,使用 Arrays.equals 来比较 char[] 的诱惑与使用 String.equals 相同。如果有人在意的话,可以使用专门的密钥类封装实际密码并处理相关问题——哦等等,真正的安全包 已经 有了专门的密钥类,这个 Q&A 只是关于它们之外的一个 习惯,比如说,在 JPasswordField 中使用 char[] 而不是 String(实际算法中仍然使用 byte[])。 - Holger
1
安全相关的软件应该在拒绝登录尝试之前执行类似于 sleep(secureRandom.nextInt()) 的操作,这不仅消除了时序攻击的可能性,还可以抵御暴力破解尝试。 - Holger

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接