Magento for Security Audit
Magento, also known as Adobe Commerce since it was bought by Adobe in 2018, is a popular CMS for e-commerce web applications, powering 2.3% of them as of 2021 (according to Statista). This article provides an overview of its inner workings from a security point of view as well as some key points to keep in mind when auditing Magento-based applications.
Magento is a large project (>2.8M single lines of PHP code in >20k files). This blog post tries to answer most of the questions one would have when encountering Magento for the fist time in a security context, that is:
- How is authentication handled ? How are authorization checked on routes / actions ?
- How are user inputs handled ?
- Does the framework use serialized objects ? Are they passed to clients ?
- Is there a templating engine in use ? How does it work ?
- What can you do once you have acquired admin access to the CMS ? How can you persist ? What can you loot ? Can you execute arbitrary code on the underlying server ?
In order to fully answer these questions, some general knowledge on Magento is required. While this information is best provided by the official Magento developer documentation1, and especially the part related to security2, I will condense some of it here.
Magento 101
Application structure
The first thing that comes to mind when you open a Magento application is the enormous number of files and folders it contains. Let’s go over the structure real quick3:
/app/
: contains the components of the CMS- modules in
/app/code
- themes in
/app/design
- language packs in
/app/i18n
- modules in
/lib/
: contains the libraries used by the application. In particular,/lib/Magento/Framework
contains the core classes of the CMS (app engine, class factories, database connections, …)/pub/
: this is the folder exposed by the web server. Contains mostlyindex.php
, the entry point for every request received
When auditing a custom Magento application, the specific code will most likely be located under the /app/code
or /app/design
folder, with maybe some of it in /lib
for lower-level generic code. A classic way to identify such code is to run a diff
against the right tag from the GitHub repository of Magento Open Source. You can easily find the version of Magento your custom application is based on by looking in the composer.json
at the root of the project.
Modules, stored in /app/code
, are key components of Magento and are made of:
/app/code/<Module_Vendor>/<Module_Name>/registration.php
: this file is required at the root of the module, and is used by Magento to register the module classes to the core engine when the application starts/app/code/<Module_Vendor>/<Module_Name>/etc/
: contains XML configuration files. Note that these can be overridden by area in the correspondingetc/[area]/
folder (see next paragraph for a definition of “areas”):module.xml
: defines the module and its dependency on other modulesroutes.xml
: registers the URI paths that this module can handleacl.xml
: defines custom resources in the “Role Resources” menu (which define what actions a given role can execute, see ACL)di.xml
: specifies the arguments and attributes of objects for the Object Manager (see Dependency Injection)config.xml
: contains the configuration of the module (like the module options)- and much more…
Different “areas”
In order to improve its efficiency and security, Magento splits the applicative part in multiple sections called areas4:
-
frontend
: customer-facing website, with the products view, the payment, the account settings, … This area is accessible by anyone. It contains a login page, which is part of the “Customer” module. -
adminhtml
: administration panel, with menus and options aimed at website administrators. This is accessible by administrators only, after having logged in on the admin panel. The login form is part of the “Backend” module, and the authentication here is completely separate from the “Customer” (frontend) authentication mechanism. -
crontab
: things which must run in cron tasks either via the/pub/cron.php
file or via thebin/magento cron:run
shell command (anyway, the first one ends up safely executing the shell command) -
APIs (
graphql
,webapi_rest
,webapi_soap
) : APIs, which are accessible via routes defined in a module’setc/webapi.xml
Indeed, some modules act on multiple of these areas, and may have different requirements depending on it. For example, the same module called in the context ofwebapi_rest
may not load the HTML rendering classes, while it would in the context offrontend
. As mentioned before, modules can override the general configuration files in[module_path]/etc/
with area-specific configuration files in[module_path]/etc/[area]/
.
Dependency Injection, Object Factories and the Object Manager
Usually, controllers do not create their objects themselves but rather use an Object Factory, which uses the Object Manager internally. The Object Manager is a class responsible for creating the objects based on the parameters defined in the etc/di.xml
5 file, like this:
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Path\To\Class">
<arguments>
<argument name="argumentName" xsi:type="string">argumentValue</argument>
</arguments>
</type>
</config>
Then, in the Object Factory’s code, the objects are acquired using the following code:
// to create an instance of a class
$this->_objectManager->create(Magento\Path\To\Class::class);
// or to get a global object
$objectManager->get(Magento\Path\To\Class::class)
And in the controller’s code:
$myObj = $this->myobjFactory->create($myObjParams);
As the controller itself is created using the dependency injection mechanism, obtaining an instance of a factory is as easy as asking for it in the controller’s constructor, in the form of an extra argument. Often, controllers extend base classes, so these constructor requirements are likely to be met higher in the heritage chain. The same applies to Object Factories needing a reference to the Object Manager.
Admin panel login page
By default, the URI of the admin login page is randomized and displayed on the terminal when starting the Magento application. The random URI might look something like /admin_5dr7al
(more generally, /admin_[0-9a-z]{6}
), and is stored in /app/etc/env.php
. By the way, this file also contains some juicy data such as the main encryption key, the database credentials and other stuff. You might want to exfiltrate it if you found a way to read arbitrary files on the server.
Note that most of the time, administrators tend to change this URI using the --backend-frontname=...
flag during installation, or later via the admin panel. Trying to spray things like /admin
, /backend
, … might thus have a non-zero chance of success. Also note that in the case it isn’t changed, the generated random URL does not follow a uniform probability distribution. Indeed, due to a flawed random generation, the first digit has a >50% chance of being a 1
, which drastically reduces the average number of requests needed to find the correct URL. This is however security through obscurity, with the real security mechanism being the authentication form you encounter on that page.
Authorizations and ACL
The ACL system of the Magento backend is pretty standard6 : users are assigned roles, and roles are assigned “resources”, which are atomic permissions. A module can define its resources in etc/acl.xml
7, and base its authorizations on these resources. In order to properly apply a permission restriction on a controller, a number of distinct actions are needed:
- first, the button / link to the controller must be hidden to the users which do not have the permission to use this controller,
- then, the controller itself must restrict its execution to authorized users only, by setting the
const ADMIN_RESOURCE
at the top of the controller’s file. This is handled properly if the controller inherits from theMagento\Backend\App\Action
class, - if the controller is also accessible via an API area, its access must be restricted in the
etc/webapi.xml
configuration file.
As you can see, some of these actions are less obvious than others, as a developer could do only the first one and think it is sufficient. In the case you encounter a custom module, you should definitely check all these elements to properly validate ACL rules. Also, keep in mind an important warning present in Magento documentation : When assigning resources, be sure to disable access to the Permissions tool if you are limiting access for a given role. Otherwise, users are able to modify their own permissions. In the frontend (customer) part, a page can be restricted to only logged-in customers by getting a reference to a UserContextInterface
object (by requiring it as a constructor argument and specifying it in the di.xml
file), and querying the getUserId()
method on it.
Routing engine
The routing engine parses URI in the following way :
[area]/[frontName]/[controller folder]/[controller class]
The frontName
refers to a module’s name (as defined in modules routes.xml
), and the mapping is done by parsing every module’s etc/[area/]routes.xml
file. The next URI parts are optional and refer to the path of the file that will be invoked inside the Controller/
folder of the matching module. If they are not defined, the Index/Index.php
file is called. The targeted file implements the abstract class Action
, and overrides the method execute()
.
Plugins
The plugins mechanism8 in Magento is not as straightforward as one could think. Indeed, plugins act on modules and hook specific functions to run code before, after and around the original function. Plugins can sometimes be referred to as Interceptors in the Magento documentation and code, and you can think of them as a form of instrumentation of the original code. Plugins register their hooks inside the etc/di.xml
file of the module they live in.
<config>
<type name="{ObservedType}">
<plugin name="{pluginName}" type="{PluginClassName}" sortOrder="1" disabled="false" />
</type>
</config>
As you can see, only the hooked class is specified and not the hooked methods. This class can be in any module and is not restricted to the module the plugin lives in. The hooked methods are determined at runtime from a naming convention: when calling a method myMethod
from a hooked class, the app engine looks for functions named beforeMyMethod
, aroundMyMethod
and afterMyMethod
(with precisely these naming conventions) in the plugins registered on this class.
Serializers
Magento possesses a couple of custom Serializer classes9. They all implement the Magento\Framework\Serialize\SerializerInterface
interface (which declares the serialize($data)
and unserialize($data)
methods) and are responsible for transforming a basic object (boolean, integer, floating point number, string or array of these) into a string and vice-versa. Magento documentation states (with reasons) that these serializers should never be used to process any other object in order to reduce the exposure to deserialization-type vulnerabilities. The default serializer implementation is the JSON serializer, however the Serialize
serializer (which uses the infamous native PHP serialize
and unserialize
functions) is also available to modules and used in certain cases in core Magento classes, mostly in deprecated methods and legacy code. This “default to JSON” mechanism is enforced by the Object Manager, with models requesting a SerializerInterface
object in their di.xml
(and in their constructors).
Storage
Magento documentation10 advises developers not to store files unless it is strictly necessary. Operations on the filesystem are done through the Magento\Framework\Filesystem
core library, and publicly accessible files (like profile pictures or product images) are stored in subdirectories of pub/media/
. Regarding the storage of secrets and configuration options, most of it is done in the app/etc
folder, with database credentials being in app/etc/env.php
. Other configuration options might be overwritten by the core_config_data
table in the database. For the database, Magento uses the Entity Attribute Value (EAV) model11, which is described in details here. Basically, data objects (like customers, products, UI blocks, …) are stored as rows in a table named xxxx_entity
with xxxx
being the object name, e.g. catalog_product
, with a set of common attributes. Then, all the custom attributes specific to one entity which are not part of the common set of attributes are stored in other tables depending on the type of the attribute (if it is a varchar
or an int
for example). For instance, if you wanted to add a warranty to a product, you could have a table catalog_product_entity_int
with a content like:
Entity_ID | Attribute | Value |
---|---|---|
4 | ... | ... |
5 | years_of_warranty | 3 |
8 | ... | ... |
Below is a list of tables containing potentially sensitive data12:
admin_user
: list of users registered on the backend. If their passwords are not there, they are stored in theadmin_passwords
tableadmin_user_session
: last few sessions on the backend. If you are trying to be stealth, clean this table after your actionsauthorization_role
: to check on ACL rolesauthorization_rule
: links ACL resources to rulescustomer_log
: login/logout logs of customerscustomer_entity
: user data (login, password_hash)eav_attribute
: A few store attributescms_page
: content and metadata of blog pagescron_schedule
: list of future cron jobs scheduled as well as past cron jobs with their statuscore_config_data
: previously mentionedlogin_as_customer
: used for assistance, an admin can log in as a customer to help him with actions on his accountmagento_login_as_customer_log
: logs of login_as_customer actionspersistent_session
: users can persist their cart data in the application, and the data will get stored here, with a hashed identifier to get it back the next time they comequote_payment
: contains credit card datasession
: customer session data is stored here, JSON-serializedurl_rewrite
: lists URL-rewriting rules
Post-exploitation
So, what do you do when you have admin access to the dashboard ? Is there an easy RCE-as-a-service like the plugins mechanism on WordPress ? Sadly, the answer is no. These past years, Magento has put a significant effort to lock down possibilities for admin users with only a web access to execute code on the underlying server. Every sensitive maintenance operation, like the plugin installation procedure or crontab actions is done via the command line interface using the bin/magento
utility. Having a write access to the whole database or any subset of tables is not helpful either (let aside xp_cmdshell
and other classic databases code execution features) because every serialized blob in the database is JSON-serialized, and no table contains data which could be interpreted as PHP code. Cron jobs are a set of actions defined in the code of the app, and the cron-related table only store "metadata" on these jobs like when to execute them and their logs after they are executed.
However, the attack surface of the admin panel is still pretty large, with some templating available as well as multiple sensitive features. I hope this article series will help you bootstrap your research towards the next CVE on Magento ! If you already have console access (or can access the database), you can refer to the Storage chapter for interesting things to loot on the server and the database.
Component analysis methodology
Once you have picked a target component, the first thing you can do is to check its configuration files, with a focus on etc/[*/]di.xml
and etc/[*/]routes.xml
. These files will point you to the URL paths that are handled by this module, as well as the underlying objects and classes it uses.
In the following article, we will study Magento’s email templating engine. The targeted module will be /app/code/Magento/Email
, which uses the /lib/internal/Magento/Framework/Filter/Template
core class. This module does not own an interesting routes.xml
config file as it is not responsible for any view on the application. An easy way to hunt for the entrypoints is with dynamic analysis. You can put a breakpoint on the first line of a pretty general method of the module or of the underlying core classes (I use custom-built Docker containers with Magento & XDebug inside, to which I connect using Dev Containers in VSCode13) and to navigate on the app, keeping in mind what this module is supposed to do. Then you can start to wrap your head around the inner workings of the module and underlying core libraries by reading the code and putting breakpoints to inspect local variables at various points of the execution. The plugin system implemented by Magento adds a substantial noise to the call stack as it intercepts and wraps many method calls, but the code paths are still followable without too much effort once you get used to it.
- 1. https://developer.adobe.com/commerce/php/development/
- 2. https://developer.adobe.com/commerce/php/development/security/
- 3. https://developer.adobe.com/commerce/php/development/build/component-fi…
- 4. https://developer.adobe.com/commerce/php/architecture/modules/areas/
- 5. https://developer.adobe.com/commerce/php/development/build/dependency-i…
- 6. https://experienceleague.adobe.com/docs/commerce-admin/systems/user-acc…
- 7. https://developer.adobe.com/commerce/php/tutorials/backend/create-acces…
- 8. https://developer.adobe.com/commerce/php/development/components/plugins/
- 9. https://developer.adobe.com/commerce/php/development/framework/serializ…
- 10. https://developer.adobe.com/commerce/php/development/security/file-uplo…
- 11. https://developer.adobe.com/commerce/php/development/components/attribu…
- 12. https://mage2db.com/db/
- 13. https://code.visualstudio.com/docs/devcontainers/containers