Logo run_as_root - Magento B2B Agency Würzburg
SECURITY

The 7 Security Findings We See in Almost Every Magento 2 Code Audit

The seven most common Magento 2 security flaws we find in code audits. From Adminer in webroot to silently disabled reCAPTCHA, with detection snippets for each.


On this page

    Every Magento 2 code audit we run turns up roughly the same seven findings. Not because the codebases are identical, but because the failure modes are. The same handful of anti-patterns ship in Marketplace modules, get copy-pasted into custom code, survive a dozen platform upgrades, and end up in front of us when someone finally books an audit after an incident or a procurement review.

    This is the technical companion to Is My Magento 2 Store Actually Safe? A 10-Minute Self-Check for Merchants. Hand that one to your merchant. This one is for you.

    TL;DR

    Every Magento 2 code audit we've run in the last year has turned up at least four of these seven. Usually five.

    1. A database tool in the webroot. Adminer, phpMyAdmin, or a renamed file like _db.php1. Grep pub/ for it, remove it on sight.
    2. Hardcoded Basic Auth credentials in admin blocks. Vendor "version check" modules fetching from their home server with plain text credentials shipped in PHP.
    3. Raw SQL concatenation in Marketplace modules. Classic SQLi, usually in admin controllers that the vendor assumed were "safe because admin-only".
    4. @escapeNotVerified debt at scale. Templates still carry the Magento 2.1 migration marker, sometimes into the four-digit instance count, each one a latent XSS.
    5. Frontend POST controllers missing CsrfAwareActionInterface. Public-facing endpoints accepting anything from any origin.
    6. reCAPTCHA quietly disabled via <preference>. A single DI override silently turns off captcha on REST without any signal in the admin UI.
    7. Third-party profilers logging raw POST bodies to disk in production. Including passwords and card data, depending on the flow. GDPR on its own is reason enough.

    Use them in order. The earlier findings are both more common and higher severity than the later ones.

    1. Adminer, phpMyAdmin, or a renamed database tool in your webroot

    A single-file database administration tool placed in pub/ or the document root, protected by whatever password the developer who dropped it there happened to pick. The URL is usually guessable: _db.php1, adminer.php, pma/, phpmyadmin/, or the personal favourite, dbadmin.php. A Cloudflare rule might challenge the request, but that rule is one misconfiguration away from bypassed. The file itself is a full interactive SQL console to whatever connection string it was dropped in with.

    This is almost always the single worst finding in an audit. The attack surface isn't "a vulnerability in the tool." It's "the tool is an admin console for your database, exposed to the entire internet, behind one password."

    1# Detection: grep the public document root for single-file PHP dumps that
    2# aren't part of Magento or a known entry point. Adjust pub/ to your webroot.
    3find pub/ -maxdepth 2 -name '*.php*' \
    4 ! -name 'index.php' ! -name 'get.php' ! -name 'health_check.php' \
    5 ! -name 'static.php' ! -name 'cron.php' ! -name 'errors/*' -print
    6 
    7# Also check for common names at the edge:
    8for path in adminer.php _db.php1 pma/ phpmyadmin/ dbadmin.php; do
    9 curl -s -o /dev/null -w "%{http_code} $path\n" "https://yourstore.example.com/$path"
    10done

    Incident-response framing: this is the one finding you act on the same day. Every other finding in this article goes into a backlog ticket. This one gets removed from the webroot and rotated out of the backup history before you do anything else.

    2. Hardcoded Basic Auth credentials in vendor admin blocks

    A handful of Marketplace vendors ship modules that "phone home" to the vendor's own server on every admin page load, usually for licence checks or update notifications. The authentication is HTTP Basic. The username and password are string literals in PHP, sitting in app/code/Vendor/Module/Block/Adminhtml/ and shipped in every release of the module.

    Mageants_ExtensionVersionInformation is the canonical example we keep running into. The credentials are admin / admin@324, used to fetch version metadata from mageants.com. They're in the module's published source on Magento Marketplace and GitHub. Any store running the module is sending those credentials on every admin request, to a domain outside your control, in a header a Marketplace plugin author can inspect. The same strings also sit in your production codebase, your git history, and every support dump anyone has ever taken.

    The fix depends on the module. If it's genuinely needed, plug the credentials through env.php and keep them out of git; if it's a "version check" side feature, disable the module. The more general rule: any module that hardcodes credentials anywhere in source is a module you don't trust enough to run.

    1# Detection: search for Basic-style auth headers with inline credentials.
    2grep -rnE "Authorization.*Basic|base64_encode\(['\"][^'\"]+:" app/code/ vendor/ | grep -v Test

    3. Raw SQL concatenation in Marketplace modules

    This is the classic. A vendor module building SQL with PHP string concatenation, user-supplied input flowing in without parameter binding, and nobody using the ORM. It's almost always in an Adminhtml controller, which the vendor relied on being "safe because admin-only", forgetting that admins get compromised and that admin CSRF is a real risk path.

    Itoris_DynamicProductOptions is the pattern we keep citing because the module is on the Marketplace and the concatenation shape is in published source. The shape is a ->query() call where the SQL is assembled from "SELECT ... WHERE id = " . $postedId with no placeholder. Several files in the module ship the same anti-pattern. Newer versions patch some of it; older store installations carry it forward.

    For the ORM-first fix (and the broader framing of why Magento's query builder exists), we've written the SQL-queries explainer separately. The short version: every query goes through $this->_connection->select()->where('id = ?', $id) or the higher-level collection API. No exceptions for "admin-only" callers.

    1# Detection: find ->query() calls with concatenation adjacent to them.
    2grep -rnE "->query\s*\(\s*['\"][^'\"]*\"\s*\." app/code/ vendor/
    3 
    4# Also flag raw exec usage patterns:
    5grep -rn "->exec(" app/code/ | grep -E "\.|\\$" | head

    4. @escapeNotVerified template debt at scale

    @escapeNotVerified (and its block-method sibling $block->escapeNotVerified(...)) is a migration marker left behind by the Magento 2.0-to-2.1 upgrade, when Magento started requiring explicit output escaping in templates. The marker means "this output used to be unescaped and nobody has confirmed whether it should be escapeHtml, escapeHtmlAttr, escapeJs, or escapeUrl." Every instance is a potential XSS sink pending triage. On stores that skipped the migration work back in 2017, the counts can be in the hundreds or thousands.

    It's not an emergency in the sense of Adminer, but it's a debt that compounds. Every time a dev opens one of those templates and copies the pattern into a new file, the count grows. The fix is mechanical but takes time: read each occurrence, pick the right escape* method for the context, remove the marker. Do it in slices, not a single PR, so reviewers can read diffs.

    1# Detection: count instances and list offending templates.
    2grep -rn --include='*.phtml' -E '(@@|->)escapeNotVerified' app/code/ vendor/ | wc -l
    3grep -rlE '(@@|->)escapeNotVerified' --include='*.phtml' app/code/ vendor/ | head -30
    ⚠️
    Don't write a shell script that globally replaces escapeNotVerified with escapeHtml. The right escape method depends on where the output lands: attribute, JS context, URL, or body text. A blanket swap fixes the marker and introduces real XSS by escaping the wrong thing into the wrong context. Do it by hand, per file.

    5. Frontend POST controllers missing CsrfAwareActionInterface

    Since Magento 2.3, every non-GET controller is expected to implement Magento\Framework\App\CsrfAwareActionInterface or inherit from a base class that does. The interface gives the dev two hooks: validateForCsrf() (return an InvalidRequestException if the request should be rejected) and createCsrfValidationException() (optional helper). Without the interface, Magento 2.3+ applies the default CSRF validation, which usually rejects the request, which is why developers sometimes "fix" the problem by implementing the interface to always return null and declaring the controller safe.

    The finding looks like either missing the interface entirely on a custom frontend POST controller, or implementing it to bypass validation unconditionally. Both are live CSRF vulnerabilities. The highest-risk subset is file-upload controllers: a custom FAQ form, a custom RMA flow, a "contact us" endpoint accepting attachments. Those let an attacker upload files from a victim's browser on any origin.

    1# Detection: list all frontend controller action classes that don't implement
    2# CsrfAwareActionInterface. Filter down to ones that extend Action or similar.
    3grep -rln --include='*.php' 'extends \\Magento\\Framework\\App\\Action' app/code/ \
    4 | xargs grep -L 'CsrfAwareActionInterface'
    5 
    6# Then eyeball each one: does it accept POST? Check etc/frontend/routes.xml
    7# and the class's HTTP method hints.

    6. reCAPTCHA quietly disabled via <preference>

    Magento ships reCAPTCHA validation for its REST endpoints (admin login, customer login, form submissions) as a plugin: Magento\ReCaptchaWebapiRest\Plugin\RestValidationPlugin. A single <preference> node in a module's di.xml (the DI instruction that globally replaces a class with another one) can swap that plugin for a no-op and silently turn off reCAPTCHA on every REST-fronted flow. The admin UI shows reCAPTCHA as "enabled". The validator class is bypassed. Nobody notices until a password-stuffing campaign gets through.

    This is a specific instance of a larger anti-pattern: using <preference> to solve problems that plugins solve better. We've written the full preference-vs-plugin writeup if you want the broader picture. For the security finding, the detection is simpler: look for any preference targeting a reCAPTCHA class.

    1# Detection: grep for preferences on any ReCaptcha* class.
    2grep -rn --include='di.xml' -E 'preference.*ReCaptcha' app/code/
    3 
    4# Also, list every preference in the codebase so you can triage the rest:
    5find app/code -name di.xml | xargs grep -l '<preference' | while read f; do
    6 echo "--- $f ---"
    7 grep -oE '<preference[^>]+for="[^"]+"' "$f"
    8done

    7. Third-party profilers logging raw POST bodies in production

    The last one is niche but severe when it appears. A performance profiling module, installed at some point for a debugging session, never removed, logging every request's full payload to a file on disk. Mirasvit Profiler is the canonical example: it writes the POST body into a rolling log under var/log/ on production sites, because the module was configured for dev and someone forgot to turn it off.

    The problem is what ends up in those logs. Login POSTs contain the plaintext password. Payment POSTs contain the card number (briefly, before Magento strips it, but "briefly" means "in the log"). Checkout POSTs contain address, email, and often passport-like identifiers in markets that require them. All of it sits unencrypted in var/log until someone rotates the file, and "someone" is often "log-rotate on a 30-day window".

    ⚠️
    The GDPR and PCI-DSS implications are obvious. The non-obvious implication is that "raw POST logs" also end up in your backup volumes, your support dumps, and your error-aggregator exports. One developer downloading a var/log tarball onto a laptop to debug an issue becomes a customer-data leak the moment the laptop does anything bad.

    The fix is to remove any profiling or WebAPI-logging module from production installations. If you need profiling, run it in staging against a production-like dataset, not on the customer-facing site.

    1# Detection: look for file writes of $_POST / $request->getContent().
    2grep -rn --include='*.php' -E 'file_put_contents|fwrite|error_log' app/code/ vendor/ \
    3 | grep -E '\$_POST|getPost|getContent|getParams'
    4 
    5# And scan var/log for concerning content on a staging snapshot:
    6grep -rE '(password=|"password"\s*:|card_number)' var/log/ 2>/dev/null | head

    The 7-point audit checklist

    1. Grep the webroot for database tools

      find pub/ -maxdepth 2 -name '*.php*' with the standard entrypoints excluded. Curl the common names (adminer.php, _db.php1, phpmyadmin/, pma/, dbadmin.php) from the edge. Any non-404 is an incident. Remove before doing anything else.

    2. Grep for hardcoded Basic Auth in source

      grep -rnE "Authorization.*Basic|base64_encode\(['\"][^'\"]+:" app/code/ vendor/. Read each hit. Any plain credentials in PHP source goes into env.php or gets removed with the module.

    3. Find raw query concatenation

      grep for ->query(...) and ->exec(...) with string concatenation or variables adjacent to the SQL. Every match is a candidate for SQLi triage. Fix with parameterised ORM calls, not by escaping inline.

    4. Count escapeNotVerified and plan the drain

      grep -rn --include='*.phtml' for @escapeNotVerified and the method variant. Pick a slice per sprint. Replace each instance with the context-correct escape method by hand. Never do a global sed replace.

    5. List frontend controllers without CsrfAwareActionInterface

      grep for Action subclasses and subtract the ones that implement CsrfAwareActionInterface. Prioritise POST-accepting controllers, especially file-upload flows. Implementing the interface to always return null is the same as not implementing it.

    6. Find preference overrides on reCAPTCHA and other security plugins

      grep di.xml files for preferences on Magento\ReCaptcha* classes. While you're in there, list every preference in app/code and classify it as safe or risky. Security-critical plugin replacements are always risky.

    7. Audit logging of request bodies

      Grep for file_put_contents / fwrite / error_log calls near $_POST or getContent usage. On a staging snapshot of var/log, grep for "password=" and card patterns. Remove any profiler or verbose WebAPI logger from production.

    8. Keep the snippets in CI

      The detection one-liners above belong in a CI job or a pre-merge hook that fails the build when a new instance appears. Cheap insurance against the next developer reintroducing any of the seven.

    Found three of the seven? Time for a full audit.

    We run focused Magento 2 security audits. You get a prioritised remediation plan, grep-level detection snippets you can keep in CI, and code-level fixes. Two to three days. Straight to grep output and PR diffs, no 80-page PDF.

    Book a security audit