1 Intro

Banking4 is a third-party banking app by Subsembly that is available for iOS, Android, Windows, and macOS. Via white labelling, it is also available as the first-party banking app of several banks. I took a cursory look at how Banking4 encrypts user data and found two minor issues. The developers were responsive, open to feedback, and easy to work with.

2 Identified issues

The first issue is that the number of rounds of the key derivation function for the user passwords in older data safes was configured with zero iterations. The second issue concerns the password strength meter, which significantly overestimated the strength of supplied passwords.

There is a third and final issue, which likely has no practical impact on security: The pseudorandom number generator used to generate 128-bit encryption keys was only seeded with 122 bits of strong entropy and is still, by definition, not a cryptographically secure random number generator (CSPRNG).

2.1 Old data safes used 0 iterations for PBKDF

Issue overview
ComponentPassword-protection of the data safe
CWECWE-916: Use of Password Hash With Insufficient Computational Effort
SeverityLow
ConfidenceCertain
StatusFixed

The data safe files that contain the user data are encrypted with an AES key. The AES key, in turn, is protected by a key derived from the user’s password via a password-based key derivation function (PBKDF). The used PBKDF is custom, based on RIPEMD-160 and AES and works roughly like this:

  1. Initial Hash Construction (_ComputePassHash()/compute_pass_hash())
    1. A fixed 32-byte constant is used as an initial seed.
    2. A RIPEMD-160 hash is computed over the fixed constant, the encoded password, and, optionally, a device-specific device_guid.
    3. The resulting 20-byte digest is expanded to 32 bytes using a custom XOR-based expansion routine.
  2. Key Derivation (_DerivePassKey()/derive_key())
    1. A 32-byte salt1 is split into two 16-byte blocks.
    2. For a given number of iterations:
      1. The current pass_hash is used as an AES-256 key.
      2. The salt blocks are encrypted with AES-256 in ECB mode.
      3. The encrypted output is XORed back into pass_hash.
Re-implementation of the password hashing algorithm


1// Compute the “passHash” base value from the password.
2fn compute_pass_hash(password: &str, device_guid: &[u8], use_device_guid: bool) -> [u8; 32] {
3 // Fixed 32-byte constant.
4 let initial_data: [u8; 32] = [
5 144, 60, 74, 22, 64, 180, 67, 129, 177, 153, 134, 106, 164, 81, 109, 25, 12, 176, 220, 184,
6 239, 161, 76, 26, 151, 206, 4, 59, 52, 26, 52, 207,
7 ];
8
9 let mut hasher = Ripemd160::new();
10 hasher.update(&initial_data);
11
12 // Convert the password to UTF-16LE.
13 let password_utf16: Vec<u8> = password
14 .encode_utf16()
15 .flat_map(|w| w.to_le_bytes())
16 .collect();
17 hasher.update(&password_utf16);
18
19 if use_device_guid {
20 hasher.update(device_guid);
21 }
22 let digest = hasher.finalize(); // 20 bytes
23
24 // Expand the 20-byte digest into a 32-byte value.
25 let mut pass_hash = [0u8; 32];
26 let mut acc: u8 = 0;
27 for i in 0..32 {
28 acc ^= digest[i % digest.len()];
29 pass_hash[i] = acc;
30 }
31 pass_hash
32}
33
34// Derive the key using the iterative process.
35fn derive_key(
36 password: &str,
37 iterations: u32,
38 device_guid: &[u8],
39 use_device_guid: bool,
40 salt: &[u8; 32],
41) -> [u8; 32] {
42 let mut pass_hash = compute_pass_hash(password, device_guid, use_device_guid);
43
44 // Prepare salt blocks.
45 let salt_blocks = [
46 GenericArray::clone_from_slice(&salt[0..16]),
47 GenericArray::clone_from_slice(&salt[16..32]),
48 ];
49
50 // For each iteration, use the current pass_hash as the AES key
51 // to encrypt the two salt blocks in batch.
52 for _ in 0..iterations {
53 let cipher = Aes256::new(GenericArray::from_slice(&pass_hash));
54 let mut blocks = salt_blocks; // copy the two blocks
55
56 // Encrypt both blocks at once.
57 cipher.encrypt_blocks(&mut blocks);
58
59 // XOR the encrypted blocks back into pass_hash.
60 for i in 0..16 {
61 pass_hash[i] ^= blocks[0][i];
62 pass_hash[16 + i] ^= blocks[1][i];
63 }
64 }
65 pass_hash
66}
67
68// Initialize the password by deriving the key and encrypting the salt.
69fn init_password(
70 password: &str,
71 iterations: u32,
72 device_guid: &[u8],
73 use_device_guid: bool,
74 salt: &[u8; 32],
75) -> ([u8; 32], [u8; 32]) {
76 let derived = derive_key(password, iterations, device_guid, use_device_guid, salt);
77 let cipher = Aes256::new(GenericArray::from_slice(&derived));
78 let mut blocks = [
79 GenericArray::clone_from_slice(&salt[0..16]),
80 GenericArray::clone_from_slice(&salt[16..32]),
81 ];
82 cipher.encrypt_blocks(&mut blocks);
83 let mut encrypted = [0u8; 32];
84 encrypted[..16].copy_from_slice(&blocks[0]);
85 encrypted[16..].copy_from_slice(&blocks[1]);
86 (derived, encrypted)
87}
: Re-implementation of the password hashing algorithm in Rust. {#lst:pwhash}
Decompilation of Banking4’s password hashing implementation


1public SubFileResult InitPassword(
2 string sPassword,
3 int nPassIterations,
4 Guid tDeviceGuid,
5 SubFileSecurityFlags nSecFlags,
6 SubFileCipher nCipher,
7 byte[] vbCipherKey)
8{
9 byte[] numArray1 = new byte[32];
10 CryRandom.FillRandomBytes(numArray1, 0, 32);
11 byte[] numArray2 = SubFileHeader._DerivePassKey(sPassword, nPassIterations, tDeviceGuid, nSecFlags, numArray1);
12 byte[] numArray3 = new byte[32];
13 CryAES cryAes = new CryAES(32);
14 cryAes.Initialize(numArray2, true);
15 cryAes.CryptBlock(numArray1, 0, numArray3, 0);
16 cryAes.CryptBlock(numArray1, 16, numArray3, 16);
17 byte[] src = SubFileHeader._Encrypt(numArray2, vbCipherKey);
18 this.m_vbCipherKey = vbCipherKey;
19 SubUtil.ConvertToBytes(this.m_vbHeader, 16, (int) nSecFlags);
20 SubUtil.ConvertToBytes(this.m_vbHeader, 20, (int) nCipher);
21 SubUtil.ConvertToBytes(this.m_vbHeader, 24, nPassIterations);
22 Buffer.BlockCopy((Array) numArray1, 0, (Array) this.m_vbHeader, 28, 32);
23 Buffer.BlockCopy((Array) numArray3, 0, (Array) this.m_vbHeader, 60, 32);
24 byte[] vbHeader = this.m_vbHeader;
25 Buffer.BlockCopy((Array) src, 0, (Array) vbHeader, 256, 256);
26 return SubFileResult.Success;
27}
28
29private static byte[] _DerivePassKey(
30 string sPassword,
31 int nIterations,
32 Guid tDeviceGuid,
33 SubFileSecurityFlags nSecFlags,
34 byte[] vbSalt)
35{
36 byte[] passHash = SubFileHeader._ComputePassHash(sPassword, tDeviceGuid, nSecFlags);
37 if (nIterations > 0)
38 {
39 CryAES cryAes = new CryAES(32);
40 byte[] vbToData = new byte[32];
41 for (int index1 = 0; index1 < nIterations; ++index1)
42 {
43 cryAes.Initialize(passHash, true);
44 cryAes.CryptBlock(vbSalt, 0, vbToData, 0);
45 cryAes.CryptBlock(vbSalt, 16, vbToData, 16);
46 for (int index2 = 0; index2 < 32; ++index2)
47 passHash[index2] ^= vbToData[index2];
48 }
49 }
50 return passHash;
51}
52
53private static byte[] _ComputePassHash(
54 string sPassword,
55 Guid tDeviceGuid,
56 SubFileSecurityFlags nSecFlags)
57{
58 byte[] vbData = new byte[32]...
59 {
60 (byte) 144,
61 (byte) 60,
62 (byte) 74,
63 (byte) 22,
64 (byte) 64,
65 (byte) 180,
66 (byte) 67,
67 (byte) 129,
68 (byte) 177,
69 (byte) 153,
70 (byte) 134,
71 (byte) 106,
72 (byte) 164,
73 (byte) 81,
74 (byte) 109,
75 (byte) 25,
76 (byte) 12,
77 (byte) 176,
78 (byte) 220,
79 (byte) 184,
80 (byte) 239,
81 (byte) 161,
82 (byte) 76,
83 (byte) 26,
84 (byte) 151,
85 (byte) 206,
86 (byte) 4,
87 (byte) 59,
88 (byte) 52,
89 (byte) 26,
90 (byte) 52,
91 (byte) 207
92 };
93 byte[] bytes = Encoding.Unicode.GetBytes(sPassword);
94 CryRipeMD160 cryRipeMd160 = new CryRipeMD160();
95 cryRipeMd160.HashCore(vbData);
96 cryRipeMd160.HashCore(bytes);
97 if (nSecFlags.HasFlag((Enum) SubFileSecurityFlags.UseDeviceGuid))
98 cryRipeMd160.HashCore(tDeviceGuid.ToByteArray());
99 byte[] numArray = cryRipeMd160.HashFinal();
100 byte[] passHash = new byte[32];
101 byte num = 0;
102 int length = numArray.Length;
103 for (int index = 0; index < 32; ++index)
104 {
105 num ^= numArray[index % length];
106 passHash[index] = num;
107 }
108 return passHash;
109}

The PBKDF iteration count was originally set to 0, but was increased to 100’000 a few years ago. While newly created data safes use the new iteration count, existing ones weren’t automatically updated. This left these data safes more vulnerable to brute-force attacks, see lst. 1.

For existing data safes, the higher iteration count only effect if the user manually changed the password with a version of the software that used the new default iteration count.

Listing 1: Comparing 0 and 100’000 iterations on a laptop. The slowdown is about 20’000x. Code



# with ARMv8 AES intrinsics
0 iterations: 23735506.41 attempts/sec
100k iterations: 1181.93 attempts/sec
Slowdown (0 iter/s / 100k iter/s): 20081.93x

2.1.1 Remediation

Subsembly said that updated versions of Banking4 check for the iteration count data safes opened in them use and, if needed, automatically increase it to the new default. This ensures future bumps to the iteration count will also be automatically applied.

2.2 Password strength overestimation

Issue overview
ComponentPassword-protection of the data safe
SeverityLow
ConfidenceCertain
StatusPartially fixed

The app stores user data in an encrypted data safe. The key used to encrypt the data is generated randomly by the app and in turn encrypted by a key derived from a password the user enters. See lst. 2 for pseudocode.

Listing 2: Pseudocode showing how the different keys are produced and used for data encryption.



derived_key = key_derivation_function(password)
cipher_key = decrypt(derived_key, encrypted_cipher_key)
# use cipher_key to en-/decrypt data

When creating or changing a password, the app provides the user with an estimation of its strength. The strength rating is primarily driven by the password’s length and the complexity of its character set – with higher scores for using more diverse classes (e.g., Latin or ASCII vs. digits only).

While length is a good start, the resulting estimation isn’t good. Passwords like Password1! or Computer1! are given a “Very Strong” rating, even though they are comprised of a common word and a suffix that can easily be guessed2.

2.2.1 Password strength calculation

If you are interest, here’s a more detailed breakdown of the algorithm.

Detailed algorithm description
  1. Character Class Constant \(n\):
    Determine \(n\) based on the password’s character set:

    • Latin (non-ASCII > 127): \(n = 497088\)
    • Ascii (special characters): \(n = 430562\)
    • Uppercase (A-Z): \(n = 390214\)
    • Lowercase (a-z): \(n = 338816\)
    • Digits only (0-9): \(n = 217705\)
  2. Base Strength:
    For a password of length \(L\), compute: \[ \text{strength}_{\text{base}} = \frac{n \times L + 32768}{65536} \] (using integer division, equivalent to a right-shift by 16 bits).

  3. Iteration Bonus:
    Given iteration count \(I\), add a bonus: \[ \text{bonus}(I) = \begin{cases} 10, & I \ge 1024 \\ 9, & 512 \le I < 1024 \\ 8, & 256 \le I < 512 \\ 7, & 128 \le I < 256 \\ 6, & 64 \le I < 128 \\ 5, & 32 \le I < 64 \\ 4, & 16 \le I < 32 \\ 3, & 8 \le I < 16 \\ 2, & 4 \le I < 8 \\ 1, & 2 \le I < 4 \\ 0, & I < 2 \\ \end{cases} \]

  4. Final Strength:
    \[ \text{strength} = \text{strength}_{\text{base}} + \text{bonus}(I) \]

2.2.2 zxcvbn

zxcvbn[1], a password strength estimator by Dropbox, is among the best available tools for password strength estimation against both online and offline brute-force attacks, from what I could find [2], [3]. While it is outperformed by some in either category, they aren’t available in as many programming languages.

To calculate a password’s strength, zxcvbn takes into account various factors, including

  • dictionaries of common words
  • patterns on the keyboard
  • common patterns, i.e. word + number + special character

While the C# implementation linked in the zxcvbn repository results in a 404, there is a different C# implementation.

My recommendation was to replace their in-house password strength estimator with zxcvbn.

2.2.3 Password strength estimator comparison

Below, you can compare the rating of Subsembly’s password strength estimator with the rating from zxcvbn’s algorithm. A short password such as Computer1!, consisting of a common word, a number and a special character character, produces wildly different ratings.

Note: The score ranges from 0 (lowest) to 4 (highest).

Detailed output

2.2.4 Remediation

Subsembly wrote to me that they’ll look into zxcvbn and its approach. From what I can tell, as version 8.8.4.9259 of the android app, they only slightly improved their own password strength estimator by removing the strength bonus from the amount of iterations.

2.3 Less-than-ideal pseudo-random number generator

Issue overview
ComponentIdentity keys
SeverityInformational
ConfidenceCertain
StatusPartially but sufficiently fixed

The code for the PRNG used in Banking4 is shown in lst. 3.

Listing 3: Subsembly’s PRNG implementation. During initialization, it is seeded with data from the execution environment and a GUID that is generated using Guid.NewGuid(). The RIPEMD-160 hash of the environment data and the GUID is also stored in g_vbRandomSeed. The next random number is the RIPEMD-160 hash of the seed (g_vbRandomSeed) and the previous random number (g_vbRandomBytes).



1// Decompiled with JetBrains decompiler...
2// Type: Subsembly.Crypto.CryRandom
3// Assembly: Subsembly.Crypto, Version=2.9.0.8354, Culture=neutral, PublicKeyToken=9de4f82bf36e76d7
4
5using System;
6using System.Collections;
7using System.Collections.Generic;
8using System.Text;
9
10#nullable disable
11namespace Subsembly.Crypto
12{
13 public static class CryRandom
14 {
15 private static CryDigest g_aMD = (CryDigest) new CryRipeMD160();
16 private static byte[] g_vbRandomSeed = CryRandom._GetRandomSeed();
17 private static byte[] g_vbRandomBytes;
18 private static int g_nNextByte;
19...
20 static CryRandom()
21 {
22 CryRandom.g_aMD.HashCore(CryRandom.g_vbRandomSeed, 0, CryRandom.g_vbRandomSeed.Length);
23 CryRandom.g_vbRandomBytes = CryRandom.g_aMD.HashFinal();
24 CryRandom.g_nNextByte = 0;
25 }
26
27 public static byte[] GetRandomBytes(int nLength)
28 {
29 byte[] vbRandom = nLength >= 0 ? new byte[nLength] : throw new ArgumentOutOfRangeException();
30 if (nLength > 0)
31 CryRandom.FillRandomBytes(vbRandom, 0, nLength);
32 return vbRandom;
33 }
34
35 public static void FillRandomBytes(byte[] vbRandom, int nOffset, int nLength)
36 {
37 if (vbRandom == null)
38 throw new ArgumentNullException();
39 if (nOffset < 0 || nLength < 0 || nOffset + nLength > vbRandom.Length)
40 throw new ArgumentOutOfRangeException();
41 for (; nLength > 0; --nLength)
42 vbRandom[nOffset++] = CryRandom.GetRandomByte();
43 }
44
45 public static void FillRandomNonZeroBytes(byte[] vbRandom, int nOffset, int nLength)
46 {
47 if (vbRandom == null)
48 throw new ArgumentNullException();
49 if (nOffset < 0 || nLength < 0 || nOffset + nLength > vbRandom.Length)
50 throw new ArgumentOutOfRangeException();
51 for (; nLength > 0; --nLength)
52 {
53 byte num = 0;
54 while (num == (byte) 0)
55 num = CryRandom.GetRandomByte();
56 vbRandom[nOffset++] = num;
57 }
58 }
59
60 public static byte GetRandomByte()
61 {
62 lock (CryRandom.g_aMD)
63 {
64 if (CryRandom.g_nNextByte >= CryRandom.g_vbRandomBytes.Length)
65 CryRandom._GetRandomBytes();
66 return CryRandom.g_vbRandomBytes[CryRandom.g_nNextByte++];
67 }
68 }
69
70 public static int GetRandomInt32() => BitConverter.ToInt32(CryRandom.GetRandomBytes(4), 0);
71
72 private static void _GetRandomBytes()
73 {
74 CryRandom.g_aMD.Initialize();
75 CryRandom.g_aMD.HashCore(CryRandom.g_vbRandomSeed, 0, CryRandom.g_vbRandomSeed.Length);
76 CryRandom.g_aMD.HashCore(CryRandom.g_vbRandomBytes, 0, CryRandom.g_vbRandomBytes.Length);
77 CryRandom.g_vbRandomBytes = CryRandom.g_aMD.HashFinal();
78 CryRandom.g_nNextByte = 0;
79 }
80
81 private static byte[] _GetRandomSeed()
82 {
83 List<byte> byteList = new List<byte>(100);
84 long totalMemory = GC.GetTotalMemory(false);
85 byteList.AddRange((IEnumerable<byte>) BitConverter.GetBytes(totalMemory));
86 Guid guid = Guid.NewGuid();
87 byteList.AddRange((IEnumerable<byte>) guid.ToByteArray());
88 long ticks = DateTime.Now.Ticks;
89 byteList.AddRange((IEnumerable<byte>) BitConverter.GetBytes(ticks));
90 int tickCount = Environment.TickCount;
91 byteList.AddRange((IEnumerable<byte>) BitConverter.GetBytes(tickCount));
92 try
93 {
94 foreach (DictionaryEntry environmentVariable in Environment.GetEnvironmentVariables())
95 {
96 byteList.AddRange((IEnumerable<byte>) Encoding.ASCII.GetBytes(environmentVariable.Key.ToString()));
97 byteList.AddRange((IEnumerable<byte>) Encoding.ASCII.GetBytes(environmentVariable.Value.ToString()));
98 }
99 }
100 catch
101 {
102 }
103 int num = new Random().Next();
104 byteList.AddRange((IEnumerable<byte>) BitConverter.GetBytes(num));
105 return byteList.ToArray();
106 }
107 }
108}

The Remarks section of the Guid.NewGuid() documentation states that in .NET versions prior to .NET 6, the entropy is not guaranteed to be generated by a CSPRNG. It further states that the generated GUID contains 122 bits of strong entropy.

Since there are only ever 122 bits of strong entropy, any keys generated from that seed can also only be considered to be 122 bits strong3. In practice, that’s probably still good enough.

Tangent: .NET version confusion

I was initially confused about .NET version names and standards, which is why I thought that the use of .NET Standard v2.0 for the .NET Assemblies used in Banking4 meant that GUIDs generated using Guid.NewGuid() are not guaranteed to contain strong entropy. However, I learnt that .NET Standard is the name of specification, while only .NET, .NET Core and .NET Framework are different implementations of the specification.

2.3.1 CSPRNG vs. PRNG

By definition, (1) the output of a CSPRNG has to be indistinguishable from random data and (2) CSPRNGs have to withstand state compromise extension attacks4. CryRandom is not a CSPRNG, as once its state is compromised, the attacker can predict all future outputs.

2.3.2 Remediation

Subsembly now seeds Banking4’s PRNG with output from .NET’s RandomNumberGenerator, which provides cryptographically strong random values.

In theory that is still a bit worse than exclusively using RandomNumberGenerator. But compromising state isn’t easy to do remotely5 and would probably require some speculative execution vulnerability from the browser, which is pretty unlikely.

3 Disclosure Timeline

2025‑02‑04 Report sent to Subsembly with a 90-day deadline
2025‑02‑05 Report acknowledged by Subsembly
2025‑02‑26 Call with Subsembly to discuss the issues. Subsembly applied PBKDF iteration and PRNG fixes that day, to be included in the (then) next update
2025‑05‑05 90-days deadline passed.
2025‑05‑30 Release of this blog post

References

[1]
D. L. Wheeler, “zxcvbn: {Low-Budget} Password Strength Estimation,” 2016, pp. 157–173. Accessed: Feb. 18, 2025. [Online]. Available: https://www.usenix.org/conference/usenixsecurity16/technical-sessions/presentation/wheeler
Cited: 1
[2]
M. Golla and M. Dürmuth, “On the Accuracy of Password Strength Meters,” in Proceedings of the 2018 ACM SIGSAC Conference on Computer and Communications Security, in CCS ’18. New York, NY, USA: Association for Computing Machinery, Oct. 2018, pp. 1567–1582. doi: 10.1145/3243734.3243769.
Cited: 1
[3]
D. Wang, X. Shan, Q. Dong, and Y. Shen, “No Single Silver Bullet: Measuring the Accuracy of Password Strength Meters.”
Cited: 1
[4]
E. Barker and J. Kelsey, “Recommendation for Random Number Generation Using Deterministic Random Bit Generators,” National Institute of Standards and Technology, NIST Special Publication (SP) 800-90A Rev. 1, Jun. 2015. doi: 10.6028/NIST.SP.800-90Ar1.
Cited: 1

Footnotes

  1. The salt is generated in SubFileHeader.InitPassword() using CryRandom.FillRandomBytes()↩︎

  2. see e.g. NIST’s Recommendation for Selecting Passwords↩︎

  3. “The entropy input shall have entropy that is equal to or greater than the security strength of the instantiation. […]” — Section 8.6.3 of NIST’s Recommendation for Random Number Generation Using Deterministic Random Bit Generators [4].↩︎

  4. this can be realised by regularly adding entropy to the PRNG’s state↩︎

  5. if the attacker can already read and write memory on the target machine, they can just use a keylogger or read out the encryption key from memory or just directly manipulate application behaviour↩︎