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
Component | Password-protection of the data safe |
---|---|
CWE | CWE-916: Use of Password Hash With Insufficient Computational Effort |
Severity | Low |
Confidence | Certain |
Status | Fixed |
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:
- Initial Hash Construction (
_ComputePassHash()
/compute_pass_hash()
)- A fixed 32-byte constant is used as an initial seed.
- A RIPEMD-160 hash is computed over the fixed constant, the encoded password, and, optionally, a device-specific
device_guid
. - The resulting 20-byte digest is expanded to 32 bytes using a custom XOR-based expansion routine.
- Key Derivation (
_DerivePassKey()
/derive_key()
)- A 32-byte
salt
1 is split into two 16-byte blocks. - For a given number of
iterations
:- The current
pass_hash
is used as an AES-256 key. - The salt blocks are encrypted with AES-256 in ECB mode.
- The encrypted output is XORed back into
pass_hash
.
- The current
- A 32-byte
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);1112 // Convert the password to UTF-16LE.13 let password_utf16: Vec<u8> = password14 .encode_utf16()15 .flat_map(|w| w.to_le_bytes())16 .collect();17 hasher.update(&password_utf16);1819 if use_device_guid {20 hasher.update(device_guid);21 }22 let digest = hasher.finalize(); // 20 bytes2324 // 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_hash32}3334// 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);4344 // Prepare salt blocks.45 let salt_blocks = [46 GenericArray::clone_from_slice(&salt[0..16]),47 GenericArray::clone_from_slice(&salt[16..32]),48 ];4950 // For each iteration, use the current pass_hash as the AES key51 // 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 blocks5556 // Encrypt both blocks at once.57 cipher.encrypt_blocks(&mut blocks);5859 // 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_hash66}6768// 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 intrinsics0 iterations: 23735506.41 attempts/sec100k iterations: 1181.93 attempts/secSlowdown (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
Component | Password-protection of the data safe |
---|---|
Severity | Low |
Confidence | Certain |
Status | Partially 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. Character Class Constant \(n\): Base Strength: Iteration Bonus: Final Strength:Detailed algorithm description
Determine \(n\) based on the password’s character set:
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).
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} \]
\[ \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
Component | Identity keys |
---|---|
Severity | Informational |
Confidence | Certain |
Status | Partially 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 catch101 {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.
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
Footnotes
The salt is generated in
SubFileHeader.InitPassword()
usingCryRandom.FillRandomBytes()
↩︎see e.g. NIST’s Recommendation for Selecting Passwords↩︎
“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].↩︎
this can be realised by regularly adding entropy to the PRNG’s state↩︎
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↩︎