Magento 2 Encryption Key Rotation: How we worked around core deficiencies with a flexible script
In June of 2024 Adobe announced an urgent security bulletin announcing the most severe bug for Magento since 2022. This vulnerability is known as CosmicSting (CVE-2024-34102) and is already being exploited in the wild. See CosmicSting attack threatens 75% of Adobe Commerce stores, which was published by Sansec, the global leader in ecommerce malware detection.
If your Magento 2 or Adobe Commerce store was not patched or upgraded within roughly 72 hours of June 28th, 2024, it is assumed your store's encryption key was compromised and you must rotate it to prevent malicious actors from gaining access to your store and your sensitive data.
As our team went through an exercise for an affected store we came upon an issue with the native Magento 2 encryption key rotation tool that prevented us from using it to rotate the keys! Encryption key | Adobe Commerce - Encryption key change doesn't re-encrypt any config values using the new key · Issue #35061 · magento/magento2 ).
The following is a detailed analysis of the native tool and of our solution to work around its deficiencies with a script that we developed in the heat of the moment as a solution. Read for information about our findings and for the link to the script repository for public use.
Analysis of Adobe's Native Magento Encryption Key Rotator Tool
We found a few flaws in the native Magento encryption key rotator tool that we had to work around to cover against the potential threats of CosmicSting::
Adobe’s Encryption tool:
Native Magento Encryption Key Rotation code:
Magento\EncryptionKey\Model\ResourceModel\Key\Change::changeEncryptionKey
if (!$this->writer->checkIfWritable()) {
throw new \Exception(__('Deployment configuration file is not writable.'));
}
if (null === $key) {
// md5() here is not for cryptographic use. It used for generate encryption key itself
// and do not encrypt any passwords
// phpcs:ignore Magento2.Security.InsecureFunction
$key = md5($this->random->getRandomString(ConfigOptionsListConstants::STORE_KEY_RANDOM_STRING_SIZE));
}
$this->encryptor->setNewKey($key);
$encryptSegment = new ConfigData(ConfigFilePool::APP_ENV);
$encryptSegment->set(ConfigOptionsListConstants::CONFIG_PATH_CRYPT_KEY, $this->encryptor->exportKeys());
$configData = [$encryptSegment->getFileKey() => $encryptSegment->getData()];
// update database and config.php
$this->beginTransaction();
try {
$this->_reEncryptSystemConfigurationValues();
$this->_reEncryptCreditCardNumbers();
$this->writer->saveConfig($configData);
$this->commit();
return $key;
} catch (\Exception $e) {
$this->rollBack();
throw $e;
}
Native Magento Encryption Key Rotation code:
1. In order to generate a new key Magento uses:
$key = md5($this->random->getRandomString(ConfigOptionsListConstants::STORE_KEY_RANDOM_STRING_SIZE));
$this->random uses random_int to produce a set of cryptographically secure random numbers.
2. Magento stops at reEncryptSystemConfigurationValues and _reEncryptCreditCardNumbers to protect against fraud threats.
The attack vector of CosmicSting is much wider, and further re-encryption is critical:
3. Look at the core_config_data re-encryption:
protected function _reEncryptSystemConfigurationValues()
{
// look for encrypted node entries in all system.xml files
/** @var \Magento\Config\Model\Config\Structure $configStructure */
$configStructure = $this->structure;
$paths = $configStructure->getFieldPathsByAttribute(
'backend_model',
\Magento\Config\Model\Config\Backend\Encrypted::class
);
// walk through found data and re-encrypt it
if ($paths) {
$table = $this->getTable('core_config_data');
$values = $this->getConnection()->fetchPairs(
$this->getConnection()
->select()
->from($table, ['config_id', 'value'])
->where('path IN (?)', $paths)
->where('value NOT LIKE ?', '')
);
foreach ($values as $configId => $value) {
$this->getConnection()->update(
$table,
['value' => $this->encryptor->encrypt($this->encryptor->decrypt($value))],
['config_id = ?' => (int)$configId]
);
}
}
}
The native encryption key rotation code leaves out re-encryption of any fields in core_config_data that are created by another backend model such as those created by third party modules.
4. And finally there is a bug in retrieving all encrypted fields, so that the code only re-encrypts some of the fields.
We determined that fixing this bug is of no use, since many important records are missing in the scope of the native encryption key rotation tool!
What other obstacles do you have to work around?
Magento does not track what it encrypts, and to add to this complexity, it also offers the flexibility to encrypt many types of data. So it is possible to have multiple keys and multiple algorithms. It is also possible for a single database to have fields encrypted differently. In short, there isn’t a streamlined way to cover all possible encryption scenarios!
Before moving on to the solution, lets review how encryption works in Magento.
Encryption in Magento
Long story short, encryption code is located at Magento\Framework\Encryption. The key class is Magento\Framework\Encryption\Encryptor, This class is responsible for multiple operations:
At this point, it is important to describe the difference between encryption and hashing. For example if you have a licence key for some service, Magento will encrypt it in core_config_data, so that it can be decrypted and used as a plain text later. But some data should never be decrypted, like passwords - customer and admin passwords. They are hashed with one-way hashing algorithm like SHA-256. So, please do not try to decrypt these!
The latest version of Magento 2 is using sodium to encrypt, but may use multiple algorithms to decrypt. For example mcrypt. For all new development sodium should and must be used.
But for legacy purposes mcrypt is also supported.
Every encryption algorithm has its parameters. Fortunately, we don’t have to care much about it in Magento. What we do have to care about, however, is that Magento allows multiple encryption keys. Make sure to store them in env.php this way:
Correct!
'crypt' => 'key1 key2'
Incorrect
'crypt' => [
'key1', 'key2'
]
?
But here we have another problem.
If Magento used a key to encrypt a value, how does it know which key to use to decrypt it?
There is no way of getting this information from the encrypted data itself, so Magento simply adds the number of the key to each encrypted string. So every encrypted string in Magento starts with: NUMBER:
If we only have one key, which is usually the case, it starts with 0: , if there’s more than one key, we can see records like 1:, 2: and so on.
After the number there is a second digit, which may have a different meaning:
public function decrypt($data)
{
if ($data) {
$parts = explode(':', $data, 4);
$partsCount = count($parts);
$initVector = null;
// specified key, specified crypt, specified iv
if (4 === $partsCount) {
list($keyVersion, $cryptVersion, $iv, $data) = $parts;
$initVector = $iv ? $iv : null;
$keyVersion = (int)$keyVersion;
$cryptVersion = self::CIPHER_RIJNDAEL_256;
// specified key, specified crypt
} elseif (3 === $partsCount) {
list($keyVersion, $cryptVersion, $data) = $parts;
$keyVersion = (int)$keyVersion;
$cryptVersion = (int)$cryptVersion;
// no key version = oldest key, specified crypt
} elseif (2 === $partsCount) {
list($cryptVersion, $data) = $parts;
$keyVersion = 0;
$cryptVersion = (int)$cryptVersion;
// no key version = oldest key, no crypt version = oldest crypt
} elseif (1 === $partsCount) {
$keyVersion = 0;
$cryptVersion = self::CIPHER_BLOWFISH;
// not supported format
} else {
return '';
}
// no key for decryption
if (!isset($this->keys[$keyVersion])) {
return '';
}
?
In modern Magento versions, the second number is fixed to '3'. The most typical encrypted string in Magento looks like 0:3:STRING, however 0:2:STRING or 1:3:STRING may also occur.
Now, regarding the STRING itself. Encryption algorithms are encrypting bytes, and storing raw bytes is unsafe. So we always use base64_encode encrypted data. Thus, the final format is:
NUMBER:NUMBER:BASE64_ENCODED_STRING, and typically it is 0:3:BASE64_ENCODED_STRING
This gives us a hint on how we should rotate encryption keys. If you can identify all the relevant encrypted values in the database, you can then re-encrypt them. Bingo!
The solution itself.
We should scan the whole database and check every value for matches with the pattern %\d\:\d\:% (which means NUMBER:NUMBER:).
Note, if we do just that we will get a ton of json, serialized values, search queries (including all possible injection attempts) and so on. Filtering that out automatically or manually is not an easy task.
So what I did was adding “^” to the regex: %^\d\:\d\:%, so we only look for values that start with NUMBER:NUMBER:. This approach will skip encrypted values inside of a json, in url parameter and other cases, but it is really difficult to manually handle all such scenarios.
So, we created a script https://github.com/bemeir/magento2-rotate-encryption-keys (which standalone script, as we did not want to require any sort of deployment, setup:upgrade for this operation) which loops through all tables (except some pre-defined tables like catalog_ tables ), reads every value, and checks for a match with this pattern.
If there’s a match, it writes the record in a CSV file, which tracks all tables, fields, and encrypted values and tries to identify the table’s id field. The script is capable of decrypting all values and re-encrypting them again in the same CSV file.
The script should be executed in the root folder of Magento, it reads app/etc/env.php and uses db credentials and active encryption key(s).
?
This is the first function of the script: scan mode.
It allows us to evaluate how many tables contain encrypted data. One major advantage of this approach is that it covers all tables, even custom ones.
Once we identified the encrypted values, we should re-encrypt them.
?
The second function the script performs is to re-encrypt the data and update records in the database, or, generate an SQL file with a number of UPDATE statements, to update each value individually. It can also generate similar backup file with the current values, so that we can backup data if something goes wrong. The script is capable of taking into account multiple existing encryption keys, you just have to specify which one to use (and thus which one to re-encrypt).
The script was executed on multiple projects in production with consistent results.
Notes on the style of the solution:
This script was designed under the urgent need to find a solution for a compromised store and therefore may not follow all best practices:
It employs fetchAll . Making an assumption that giant tables are usually only catalog_ related.
It also only works with sodium, ignoring mcrypt.
Other solutions available:
There are other alternatives out there. Most of what we've seen have major flaws, like
The best alternative to our script we have seen so far happens takes a module based approach was written by the renowned UK Agency Gene Commerce : https://github.com/genecommerce/module-encryption-key-manager/
It has an important feature of allowing to preserve media files cache which prevents mass scale regeneration of images. However it is not clear if it allows a user re-encrypt oauth or custom tables, and it doesn’t seem to provide backups or a summary of data to re-encrypt. But the module is written in a very good style, and organised as Magento module. Personally, in this case we preferred a standalone, all-in-one php script for flexibility purposes, but this is a matter of taste and preference.
Conclusion.
As painful as the the CosmicSting attack is, and how much of a headache rotating the keys might be for store owners and their teams, YOU CAN NOT IGNORE THE SEVERITY OF THIS SITUATION AND PRETEND IT WILL GO AWAY.
It is unfortunate the native Magento 2 Key Rotation Tool is not sufficient to solve this situation, as we would expect the provider of the software to ensure its function especially for licensed Adobe Commerce customers.
Whichever approach you decide be sure you don't miss important tables such as: core_config_data, oauth_token, oauth_consumer, sales_order_payment (which may store encrypted cc numbers or last 4 digits), etc.. Sometimes some residual credit card data is encrypted too, such the last 4 digits which is separately stored. rp_tokens for customers and admins are also encrypted. And of course many extensions use encryption in their tables which is why you must be as thorough as possible in your approach.
If you need help or advice on how to use this script or how to approach this situation:
We have provided our script free for public use at: https://github.com/bemeir/magento2-rotate-encryption-keys
If you need guidance or hands on support please reach out to the Bemeir team
Feedback is important:
Did you find this article helpful? Have a different approach or a comment on our approach? Leave a comment below!
Software Developer | PHP | Certified Adobe Commerce Architect
7 个月Thank you for such a great article!
php programmer
7 个月Thank you!
?? Magento-Master ?? IT-Security professional ?? Software-Engineering powered by AI
7 个月This is a very great write up and summary! Thanks a lot! ??