Java Cipher - PBE 线程安全问题

Posted

技术标签:

【中文标题】Java Cipher - PBE 线程安全问题【英文标题】:Java Cipher - PBE thread-safety issue 【发布时间】:2018-04-08 20:48:47 【问题描述】:

我的 Cipher 和/或 PBEKeySpec 似乎存在线程安全问题。

JDK:1.8.0_102、1.8.0_151 和 9.0.1+11 PBKDF2 算法:PBKDF2WithHmacSHA1 密码算法:AES/CFB/NoPadding 密钥算法:AES

我知道如果我们使用相同的实例,这些类是不安全的,但事实并非如此,我在每次解码时都会得到一个新实例。 但即便如此,有时解码失败,也没有例外,只是一个意想不到的解码值。

我已经能够重现该问题:

@Test
public void shouldBeThreadSafe() 

    final byte[] encoded = 
        27, 26, 18, 88, 84, -87, -40, -91, 70, -74, 87, -21, -124,
        -114, -44, -24, 7, -7, 104, -26, 45, 96, 119, 45, -74, 51
    ;
    final String expected = "dummy data";
    final Charset charset = StandardCharsets.UTF_8;

    final String salt = "e47312da-bc71-4bde-8183-5e25db6f0987";
    final String passphrase = "dummy-passphrase";

    // Crypto configuration
    final int iterationCount = 10;
    final int keyStrength = 128;
    final String pbkdf2Algorithm = "PBKDF2WithHmacSHA1";
    final String cipherAlgorithm = "AES/CFB/NoPadding";
    final String keyAlgorithm = "AES";

    // Counters
    final AtomicInteger succeedCount = new AtomicInteger(0);
    final AtomicInteger failedCount = new AtomicInteger(0);

    // Test
    System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "10");
    IntStream.range(0, 1000000).parallel().forEach(i -> 
        try 

            SecretKeyFactory factory = SecretKeyFactory.getInstance(pbkdf2Algorithm);
            KeySpec spec = new PBEKeySpec(passphrase.toCharArray(), salt.getBytes(charset), iterationCount, keyStrength);
            SecretKey tmp = factory.generateSecret(spec);
            SecretKeySpec key = new SecretKeySpec(tmp.getEncoded(), keyAlgorithm);
            Cipher cipher = Cipher.getInstance(cipherAlgorithm);


            int blockSize = cipher.getBlockSize();
            IvParameterSpec iv = new IvParameterSpec(Arrays.copyOf(encoded, blockSize));
            byte[] dataToDecrypt = Arrays.copyOfRange(encoded, blockSize, encoded.length);
            cipher.init(Cipher.DECRYPT_MODE, key, iv);
            byte[] utf8 = cipher.doFinal(dataToDecrypt);

            String decoded = new String(utf8, charset);
            if (!expected.equals(decoded)) 
                System.out.println("Try #" + i + " | Unexpected decoded value: [" + decoded + "]");
                failedCount.incrementAndGet();
             else 
                succeedCount.incrementAndGet();
            
         catch (Exception e) 
            System.out.println("Try #" + i + " | Decode failed");
            e.printStackTrace();
            failedCount.incrementAndGet();
        
    );

    System.out.println(failedCount.get() + " of " + (succeedCount.get() + failedCount.get()) + " decodes failed");

输出:

Try #656684 | Unexpected decoded value: [�jE    |S���]
Try  #33896 | Unexpected decoded value: [�jE    |S���]

2 of 1000000 decodes failed

我不明白这段代码怎么会失败,Cipher 和/或 PBEKeySpec 类中是否存在错误?还是我在测试中遗漏了什么?

非常欢迎任何帮助。


更新

OpenJDK 问题:https://bugs.openjdk.java.net/browse/JDK-8191177

【问题讨论】:

在 Windows 7 SP1 上与 jdk1.8.0_112 的结果相同。作为 JUnit 测试运行时很少可重现。在发布模式下作为应用运行时更常见。 用 jdk1.8.0_151 和 9.0.1+11 测试过,还是有问题 有趣,如果它会产生使用线程执行器而不是并行流运行的任何错误。 嗯,直接替换为 10 线程 ThreadPollExecutorRunnable 到目前为止,在 1000 万次迭代中没有显示任何问题。尝试使用 JDK 1.8.0_112 和 1.7.0_55。 另一种理论 - com.sun.crypto.provider.PBKDF2KeyImpl 具有终结器,其中 key:byte[] 被重置为零并设置为空。可能,虽然看似不可能,但在执行解码时会以某种方式影响密钥。如果我在PBKDF2KeyImpl.getEncoded() 中停止调试器并在方法返回之前用0 填充this.key,似乎我得到完全相同的错误。即使终结器与此无关,但至少有一个事实可能是正确的——编码值是用全零密钥解密的。 【参考方案1】:

确实是PBKDF2KeyImpl.getEncoded()方法中的一个JDK bug。

错误报告https://bugs.openjdk.java.net/browse/JDK-8191177 和相关问题https://bugs.openjdk.java.net/browse/JDK-8191002 中的更多详细信息。

它已在 Java 2018 年 1 月 CPU 版本中修复并发布。

更新:JDK 9 及更高版本已通过使用reachabilityFence() 修复了此问题。

由于早期版本的 JDK 中缺少此栅栏,您应该使用解决方法:« as first discovered by Hans Boehm, it just so happens that one way to implement the equivalent of reachabilityFence(x) even now is "synchronized(x) " »

在我们的例子中,解决方法是:

SecretKeyFactory factory = SecretKeyFactory.getInstance(pbkdf2Algorithm);
KeySpec spec = new PBEKeySpec(passphrase.toCharArray(), salt.getBytes(charset), iterationCount, keyStrength);
SecretKey secret = factory.generateSecret(spec);
SecretKeySpec key;
//noinspection SynchronizationOnLocalVariableOrMethodParameter
synchronized(secret) 
  key = new SecretKeySpec(secret.getEncoded(), keyAlgorithm);

【讨论】:

【参考方案2】:

我倾向于认为这很可能是与终结和数组相关的 JVM 错误的表现。下面是一个更通用的测试用例。使用java -Xmx10m -cp . UnexpectedArrayContents 运行,堆越小越有可能失败。不确定对clone() 的调用是否真的很重要,只是试图接近原始的sn-p。

// Omitting package and imports for brevity
// ...
public class UnexpectedArrayContents

    void demonstrate()
    
        IntStream.range(0, 20000000).parallel().forEach(i -> 
            String expected = randomAlphaNumeric(10);
            byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8);
            ArrayHolder holder = new ArrayHolder(expectedBytes);
            byte[] actualBytes = holder.getBytes();
            String actual = new String(actualBytes, StandardCharsets.UTF_8);
            if (!Objects.equals(expected, actual))
            
                System.err.println("attempt#" + i + " failed; expected='" + expected + "' actual='" + actual + "'");
                System.err.println("actual bytes: " + DatatypeConverter.printHexBinary(actualBytes));
            
        );
    

    static class ArrayHolder
    
        private byte[] _bytes;
        ArrayHolder(final byte[] bytes)
        
            _bytes = bytes.clone();
        

        byte[] getBytes()
        
            return _bytes.clone();
        

        @Override
        protected void finalize()
            throws Throwable
        
            if (_bytes != null)
            
                Arrays.fill(_bytes, (byte) 'z');
                _bytes = null;
            
            super.finalize();
        
    

    private static final String ALPHA_NUMERIC_STRING = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    private static final Random RND = new Random();

    static String randomAlphaNumeric(int count) 
        final StringBuilder sb = new StringBuilder();
        while (count-- != 0) 
            int character = RND.nextInt(ALPHA_NUMERIC_STRING.length());
            sb.append(ALPHA_NUMERIC_STRING.charAt(character));
        
        return sb.toString();
    

    public static void main(String[] args)
        throws Exception
    
        new UnexpectedArrayContents().demonstrate();
    

更新

现在该错误被跟踪为JDK-8191002。受影响的版本:8、9、10。

【讨论】:

我已经为此提交了错误报告。 我会尽量找时间测试一下。我已经打开了一个问题,但你的更准确 我的错误报告已经链接到你的:bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8191177 我对幕后发生的事情的评估是错误的——OpenJDK 的人似乎有一个better explanation。因此,不当行为的原因是过早的(非常违反直觉但与 JLS 一致)终结器调用,而密钥被克隆到 getEncoded 方法中。我想一个可能的解决方法是提示编译器在此调用后仍使用密钥。他们将错误标记为重复,但我希望他们在 com.sun.crypto.provider 包以及 JDK 中的其他事件中修复它。

以上是关于Java Cipher - PBE 线程安全问题的主要内容,如果未能解决你的问题,请参考以下文章

Cipher 线程安全吗?

java 对称加解密

8.Java 加解密技术系列之 PBE

java并发之线程安全问题

Vigenere Cipher(Java)[重复]

Python的RSA加密和PBE加密