From time to time, I participate in bug bounty programs. When I choose a target, I base my decision on its popularity. I found a great target called Magento. Later, I discovered that it belonged to Adobe. As soon as the bug was fixed, I was surprised by the attention it received and came across this article. This vulnerability CVE-2024-34102
together with CVE-2024-2961
has been named CosmicSting
.
This topic is about CVE-2024-34102
only.
Beginning
The latest version of a product should be downloaded from https://github.com/magento/magento2. At that time, the latest version was 2.4.6-p2
. There is also an alternative way to install it from https://marketplace.digitalocean.com/apps/magento-2-open-source. However, this is still a vulnerable version at the time of writing!
Entry point
Magento is an HTTP PHP server application. In most cases, such applications have two global entry points: User Interface and API. I started with the REST API because it is faster to start using Python
, there is no CSRF protection
, and so on. Magento uses REST API, GraphQL, and SOAP. Let's focus on the REST API. Summary notes are here.
For example, REST API request looks like
POST /rest/default/V1/carts/mine/estimate-shipping-methods HTTP/1.1
Host: foo.example
Content-Type : application/json
Content-Length: 1402
{
"address": {
"region": "incididunt",
"region_id": -67220712,
"region_code": "nisi laboris aute in Duis",
"country_id": "consectetur fugiat ",
"street": [
"in non fugiat consequat",
"fugiat aliqua non commodo"
],
"telephone": "dolor enim nisi culpa",
"postcode": "ex Excepteur reprehenderit",
"city": "laborum cupidatat est ut",
"firstname": "voluptate minim ",
"lastname": "do exercitation",
"email": "ut dolore occaecat sunt",
"company": "eu officia in",
"custom_attributes": [
{
"attribute_code": "anim nulla cupidatat Lorem aute",
"value": "voluptate Lore"
},
{
"attribute_code": "dolore",
"value": "tempor proident nostrud"
}
],
"customer_address_id": 22984299,
"customer_id": -43661864,
"extension_attributes": {
"gift_registry_id": 17076753
},
"fax": "adipisicing id",
"id": 3672246,
"middlename": "et tempor enim",
"prefix": "sunt dolor esse eiusmod",
"same_as_billing": -57436048,
"save_in_address_book": -15023339,
"suffix": "anim ipsum proident",
"vat_id": "est elit Duis "
}
}
Implementation
The first challenge is to discover all possible URLs for REST API. There is the configuration file webapi.xml
, it connects URLs with PHP request handlers.
Here is part of it:
<route url="/V1/carts/:cartId/estimate-shipping-methods" method="POST"> <service class="Magento\Quote\Api\ShipmentEstimationInterface" method="estimateByExtendedAddress"/> <resources> <resource ref="Magento_Cart::manage" /> </resources> </route> <route url="/V1/guest-carts/:cartId/collect-totals" method="PUT"> <service class="Magento\Quote\Api\GuestCartTotalManagementInterface" method="collectTotals"/> <resources> <resource ref="anonymous"/> </resources> </route>
Important note: some of REST API's can be used without authentication, like /V1/guest-carts/:cartId/collect-totals
, because
<resources> <resource ref="anonymous"/> </resources>
I did some research and found that each interface class has a hardcoded implementation in the file di.xml
.
Here is part of it:
<preference for="Magento\Quote\Api\ShipmentEstimationInterface" type="Magento\Quote\Model\ShippingMethodManagement" />
As soon as an user sends an HTTP request, the method estimateByExtendedAddress
is called from the class ShippingMethodManagement
.
let's take a look at this method:
public function estimateByExtendedAddress($cartId, AddressInterface $address) { /** @var Quote $quote */ $quote = $this->quoteRepository->getActive($cartId); // no methods applicable for empty carts or carts with virtual products if ($quote->isVirtual() || 0 == $quote->getItemsCount()) { return []; } return $this->getShippingMethods($quote, $address); }
There is one question: How is plain text from the HTTP body converted to an AddressInterface
object? What does the deserialization process look like? Also, if the REST API is public, an attacker can try to deserialize data without any authentication.
Let's dive deep.
Deserialization
The logic of the whole deserialization process is tricky. It is little bit simplified, but in general these steps are true. Let's examine an example involving AddressInterface $address
-
Find implementation of
AddressInterface
(in filedi.xml
).Magento\Customer\Api\Data\AddressInterface
=>Magento\Customer\Model\Data\Address
-
Find constructor of class
Address
public function __construct( \Magento\Framework\Api\ExtensionAttributesFactory $extensionFactory, AttributeValueFactory $attributeValueFactory, \Magento\Customer\Api\AddressMetadataInterface $metadataService, $data = [] ) { ... }
-
Check if the
json
HTTP body containsextensionFactory
,attributeValueFactory
,metadataService
, ordata
field. For example, ifextensionFactory
is present, recursively repeat the process from step 1. Otherwise, retrieve the default generic value for the classExtensionAttributesFactory
. -
Check if there is value http body
set
+ value is one of methods of classAddress
. For exampleaaaa
, then method issetaaaa
. if so, call it and resolve only parameter (process from step 1)
The issue is the deserialization is too flexible. It uses both user data and system data without separation
Recovering all inputs parameters
To discover all possible input parameters, I patched the program code:
-
The deserializer checks if a parameter (for example,
extensionFactory
) is in the JSON body. Always it retrievesyes
and stores it in the dictionary. -
The same applies to the
set
methods. -
The dictionary is written to disk as a new JSON file.
here is example of output:
{ "method": "POST", "uri": "\/rest\/V1\/guest-carts\/:cartId\/gift-message", "data": { "cartId": "aaaaaaaaaaaaaaaaaaaaaa", "giftMessage": { "context": { "eventDispatcher": { "instanceName": "aaaaaaaaaaaaaaaaaaaaaa", "shared": true }, "appState": { "configScope": { "areaList": { "default": "aaaaaaaaaaaaaaaaaaaaaa" }, "defaultScope": "aaaaaaaaaaaaaaaaaaaaaa", "CurrentScope": "aaaaaaaaaaaaaaaaaaaaaa" }, "mode": "aaaaaaaaaaaaaaaaaaaaaa", "AreaCode": "aaaaaaaaaaaaaaaaaaaaaa" } }, "resource": { "context": { "resource": { "resourceConfig": { "instanceName": "aaaaaaaaaaaaaaaaaaaaaa", "shared": true }, "tablePrefix": "aaaaaaaaaaaaaaaaaaaaaa" } }, "connectionName": "aaaaaaaaaaaaaaaaaaaaaa" }, "GiftMessageId": 42, "CustomerId": 42, "Sender": "aaaaaaaaaaaaaaaaaaaaaa", "Recipient": "aaaaaaaaaaaaaaaaaaaaaa", "Message": "aaaaaaaaaaaaaaaaaaaaaa", "ExtensionAttributes": { "EntityId": "aaaaaaaaaaaaaaaaaaaaaa", "EntityType": "aaaaaaaaaaaaaaaaaaaaaa" } } } }
Use gathered parameters
The data has not been filtered at all. For example, if an attacker sends an HTTP request with this body, there will be interesting errors.
like
"Class \"aaaaaaaaaaaaaaaaaaaaaa\" does not exist",
or from another request
"Warning: SessionHandler::read(): open(aaaaaaaaaaaaaaaaaaaaaa\/sess_aaaaaaaaaaaaaaaaaaaaaa, O_RDWR) failed: No such file or directory (2) in ...
Vulnerability
During parameters gathering one interesting sub parameter was discovered and it was possible to deserialize SimpleXMLElement
. Let's take a look at the constructor's parameters:
public __construct( string $data, int $options = 0, bool $dataIsURL = false, string $namespaceOrPrefix = "", bool $isPrefix = false )
Thus, an attacker could pass parameters in data
similar to a classic XXE attack
, and he controls options
as well, he can set LIBXML_NOENT|LIBXML_PARSEHUGE
, which is disabled by default. Pay attention to dataIsURL
, because in the article there was a flawed patch that allowed an attacker to bypass it.
Using this blind XXE attack
with reverse connection, an attacker can download env.php
<!ENTITY file SYSTEM "../app/etc/env.php">
using this key from env.php
'crypt' => [
'key' => '4ba8fa7a14627e3b812858091ed163ec'
],
It is possible to create admin JSON Web Token JWT
Impact
An attacker can get admin access to REST API, GraphQL or Soap without authorization.
Time line
-
submitted to HackerOne
December 20, 2023
-
submitted to Adobe from Hacker one
January 8, 2024
-
bounty was paid
May 21, 2024
-
CVE-2024-34102
publishedJune 11, 2024
About emergency fix from SanSec
There was proposed emergency https://sansec.io/research/cosmicsting#emergency-fix, It can be easily bypassed by an attacker due to JSON encoding
for example
becomes
I also suggest capturing sourceData
in the payload because dataIsURL
can be omitted.
emergency fix looks like
if (strpos(json_encode(json_decode(file_get_contents('php://input'))), 'sourceData') !== false) { header('HTTP/1.1 503 Service Temporarily Unavailable'); header('Status: 503 Service Temporarily Unavailable'); exit; }