Skip to main content

Drupal Commerce: CVE-2026-118539

Abuse a missing authorization check in Drupal Commerce (CVE-2026-118539), then detect and patch it.

Task 1: Introduction

On the morning of 28 October, Marisol Vega read a security advisory she wished she hadn't. Marisol runs operations at Copperleaf Provisions, a small online retailer of specialty kitchenware and pantry goods. Drupal publishes these alerts as a Security Advisory (SA), and this one, SA-CONTRIB-2026-254, concerned the exact software her storefront uses to check out orders. It said the flaw "is being actively exploited in the wild."

The Advisory at a Glance

  • What's affected: Commerce Core (machine name commerce), a contributed module that gives Drupal a shopping cart and an order system. The advisory covers this module, not Drupal core itself.
  • What's wrong: in Drupal's own words, the module "doesn't sufficiently protect certain routes and entity fields, leading to an access bypass that may financially impact the vendor."
  • How it's classified: the Common Vulnerabilities and Exposures (CVE) record, published on 30 October, lists the bug as CVE-2026-118539. It's a missing authorization flaw (CWE-862) that allows forceful browsing, which the Common Attack Pattern Enumeration and Classification (CAPEC) catalog lists as CAPEC-87. In plain terms, an attacker reaches a page by asking for it directly, without the access that page was supposed to require.
  • How severe it is: highly critical, 21 out of 25 on Drupal's own risk scale. That scale comes from a scoring standard published by the National Institute of Standards and Technology (NIST), not the Common Vulnerability Scoring System (CVSS) you'll see on most advisories. On 31 October, the Cybersecurity and Infrastructure Security Agency (CISA) added a CVSS 3.1 score to the CVE record: 6.5, Medium. The two scales measure different things, so read both before you decide how urgent a bug is.
  • What the rating says: no login is needed and every configuration is exposed. It also records exploit code as documented or deployed in the wild, which means exploitation has been seen, not that a public exploit is available.
  • What's still unknown: Drupal hasn't said how the attack works, who is behind it, or how many stores were hit.
  • How far it reaches: about 30,047 self-reported installs ran an affected release the week before the fix. That's a count of installs, not victims.
  • Public exploit: no public proof-of-concept existed as of 30 October, so we'll work the attack out ourselves, from Drupal's own published fix.
  • What Drupal asks of stores: upgrade, and "audit their orders for inconsistencies."

Marisol has already upgraded Copperleaf's staging store; production is still on the vulnerable release. You've been asked to answer a few questions: is production exposed, and what could an attacker do with that? Are there any signs that it has already happened? Does the staging upgrade actually close the hole? You'll be working from the AttackBox as an anonymous shopper with no account, using a browser, Burp, and curl.

That anonymous bit is the odd one out. The rating says an attacker needs no login, yet the damage targets pages under the /admin/ path. So how does someone without a login get to a page designed specifically for staff?

References

Learning Objectives

This room walks you through a live, actively exploited access bypass in a Drupal Commerce storefront from advisory through to the fix and the cleanup. In particular, you'll learn how to:

  • Explain what the Commerce Core access bypass is
  • Describe how Drupal decides who may open a route
  • Trace cart ownership into an admin route
  • Reach staff-only order data as an anonymous shopper
  • Override an order price through the bypass
  • Detect the attack and audit the orders
  • Confirm the patched release refuses the request

Room Prerequisites

This room assumes you can make and receive HTTP requests via curl and an intercepting proxy (Burp Suite would be the obvious choice). You should be able to carry a session cookie from one request to the next and be at home in the AttackBox terminal. No prior Drupal or Commerce experience is required, as we'll build what you need as we go. If you're new to HTTP requests, cookies, and web proxies, then the web fundamentals material in the Junior Penetration Tester path is a good place to start.

Before you start Task 2, press the Start AttackBox button to start the Attacker machine and press the Start Machine button to start the Target machine so that they are ready when you need them in later tasks.

Task 2: Routes and Entity Access

A web address that begins with /admin/ looks like a door marked Staff Only. The advisory about the software behind Copperleaf's storefront says otherwise. Its vector carries A:None, which means an anonymous visitor with no account can reach the bug. So who decides whether that visitor may open an admin page, and what does that decision actually look at?

What Commerce Core Is Made Of

Copperleaf's front end runs on Commerce Core (not Drupal core), a contributed module comprising a family of sub-modules, each handling one aspect of the selling process. There's a store module, a product catalog module, an order module, a cart module, a checkout module, and a payment module. The one that's relevant here is the cart. In Commerce, an order starts life as a draft cart and only becomes a placed order at checkout. Hold that thought, because the cart module is precisely where this bug lives.

How a Route Decides

In Drupal, every page is behind a route, and each route has its own set of access requirements. Common ones are _permission, _entity_access (written entity_type.operation, for example commerce_order.update), _custom_access and _csrf_token. If a route has more than one of these requirements, then the Drupal documentation is very direct: "all results must be AccessResult::allowed()". Every requirement must return yes; otherwise, the route is denied.

An access check returns one of three results: Allowed grants the request. Forbidden refuses it outright. Neutral expresses no opinion, leaving the decision to others.

Why Entity Access Is Different

Imagine a door with several locks. The route is that door, and every lock has to turn; otherwise you're still outside. Entity access works the other way round. Imagine a guest list where any host can let you in unless someone at the door has specifically barred you by name.

Drupal combines entity access results with a rule called orIf. Its documentation sums it up: "a Neutral result might become Allowed (if there are no Forbidden results but there is an Allowed)". Drupal gathers every entity access result, then walks this flow from the top and stops at the first match:

  • Any result says Forbidden? The request is refused, however many other results said yes.
  • Otherwise, any result says Allowed? The request is allowed. One yes is enough.
  • Otherwise, every result is Neutral. The combined result is Neutral, and nothing has granted the request.

Let's run one case. A visitor comes along and asks to view an order. The order's own access control handler has nothing to say on the matter and returns Neutral. One module's hook comes to the rescue and says Allowed. A second module's hook comes along and says Forbidden. The refusal is there, so it wins. The view is refused despite there being a grant sitting right next to it.

The /admin/ Prefix Guards Nothing

Now the admin page makes perfect sense. A route which has _entity_access defined but no _permission is protected solely by the combined entity access. The /admin/ at the front of the URL is part of a path, not a permission. If the combined entity access permits the request, the page will open wherever that route happens to be. An anonymous user can therefore in fact go to a page under /admin/: the route didn't ask for a permission, so the only question was one of entity access, and that was answered: "Allowed".

One More Layer: Field Access

There's a decision sitting below the entity. An access control handler can override a method called checkFieldAccess(). It can choose to deny edit on a particular named field even though the operation on the entity as a whole is permitted. So a visitor who is allowed to update an order might still be prevented from editing one specific field on that order. Field access is a separate decision from entity access.

We've seen how Drupal combines route, entity, and field checks. We haven't seen who answers the 'entity' question in the case of a shopping cart, though. There's a module in Commerce whose sole purpose is to allow shoppers to update their own carts. When Drupal comes to it and asks, "is this cart request allowed?" what exactly is it saying in answer to that? Task 3 opens by reading that module's source.

Answer the questions below

  1. A route requires _permission and _entity_access. The permission check grants, and the order's access control handler has no opinion. Four module hooks also vote on entity access, and none refuses outright. At most how many of those hooks can have no opinion if the route is still served?

  2. For an update on an order, the order's access control handler has no opinion, no module forbids the update, and a cart module's access hook grants it. What access result does the update operation get?

  3. Which access control handler method can refuse edit on a single field even when the operation on the whole entity is permitted?

Task 3: Find the Open Routes

The advisory names no route and no field. It says Commerce "doesn't sufficiently protect certain routes and entity fields" and stops there. So how do we find the ones an attacker uses? We read the patch. Commerce Core 3.3.13 shipped as a single squashed commit, 2ddd3a34, titled "fix: Improve access control". It touches 84 files and carries no advisory reference. The release page lists six advisories, and five of them have changes somewhere in that diff. To find the gap, we read the one cluster that matches the advisory's words: the order and cart access control.

What the Cart Module Vouched For

Task 2 closed with a question. Commerce has a module whose entire purpose is to allow shoppers to update their own carts. When Drupal asks it about a cart request, what does it say?

In 3.3.12, the cart module answers in its access hook, orderAccess(). This cart hook returns Allowed for both view and update if the account owns the order. Ownership is straightforward: either a signed in customer whose user ID matches or an anonymous visitor whose cart session contains that order ID. For updates, there's one further condition: the order must still be a draft. The order items go through the same check. It never asks what route we're on.

The missing permission is what the entire bug is predicated on. With entity access using the orIf logic, a single 'Allowed' will satisfy an entity access requirement wherever that requirement occurs, including on a page located at /admin/.

The Routes That Trusted Ownership

Two of the sets of routes asked only an entity access question in 3.3.12.

The first is the form-mode route, /admin/commerce/orders/{commerce_order}/form/{form_mode}. It has _entity_access: commerce_order.update and a custom check, with no _permission at all. The second set is the order item routes (the edit, delete, and duplicate forms) generated by the OrderItemRouteProvider, with no permission requirement either.

Now contrast that with the main order edit form at /admin/commerce/orders/{commerce_order}/edit. That one was already refusing a cart owner in 3.3.12 (OrderRouteProvider adds the permission to every route it builds). The gap wasn't in the order module's own edit route at all. It was in routes defined elsewhere that relied solely on ownership.

What the Fix Added

The patch closes the gap in two places. It adds the _permission requirement access commerce administration pages to the form-mode route and to the order-item route collection. It also changes the cart hook: for an update on an admin route, the hook now returns Neutral instead of Allowed, with a comment that customers may update their carts while shopping "but this must never grant access to the admin pages". A new route subscriber then adds that same permission to every /admin/ route that takes an order which still doesn't have it. New checkFieldAccess() overrides then lock down the individual fields (the order item's unit price being one of them).

Why Neutral rather than Forbidden? The fix provides no justification per se, but one inference is drawn from the orIf order. A Forbidden from the cart hook would nullify all grants, so a staff member would be locked out of their own draft cart's admin page. To simply return Neutral is to merely withdraw the cart hook's vote and leave the route's permission to determine the outcome.

The fix includes a test (CartAdminRouteAccessTest): "Tests that cart access does not grant access to the admin order routes." It denies a normal customer, an anonymous customer, and a customer with just the admin-pages permission on both the form-mode and order-item routes. A test that asserts a denial suggests those routes were open before, but it doesn't prove that. Task 4 proves it on the box.

Why an Overridden Price Matters

Here's what the field-level part of the fix tells us, read as an inference from the code rather than a confirmed attacker method. The unit price widget on an order item has an "Override the unit price" checkbox. If that's ticked, Commerce will mark the price as overridden. OrderRefresh::refresh() re-prices a draft cart's items from the catalog, but only for items that were not overridden. So if a draft cart contains a single Carbon Steel Wok at a catalog price of 58.00, overridden to 50.00, a refresh will leave it at 50.00 as the override flag tells Commerce to keep it. An unprotected price change therefore gets into the order total. A route that's merely asking "is this your cart?" has effectively become a way to change what the store is charging.

Build Your Own Cart

Time to become a cart owner, twice: once on Copperleaf's production store and once on the staging store Marisol has already upgraded. On the AttackBox, map both names to the target (staging only answers to its own name):

echo "MACHINE_IP copperleaf.thm staging.copperleaf.thm" | sudo tee -a /etc/hosts

Open Burp Suite, turn the intercept off, and launch the Burp browser. The store's home page has no product links yet, so go straight to the product: http://copperleaf.thm/product/1 is the Copper Saucepan 2 qt. Click Add to cart. Open /cart, set the quantity to 2, and click Update cart, so the one line reads a quantity of two. Proceed to checkout and stop at the first step (the contact info page) without completing it. The cart hook only allows updates while the order is a draft; once you go to checkout and complete it, the order will be placed.

Now record two things for this production cart. The order ID is in the checkout URL: when you start checkout, you're taken to /checkout/{order ID}/order_information, and the number in the middle is your production order ID. The session cookie (SESS...) is in Burp's HTTP history on any request to the store; that cookie is what makes the order yours.

Now build the staging cart the same way, in the same browser. Go to http://staging.copperleaf.thm:8080/product/1, add the saucepan, start checkout, and stop at the contact info page again. Write down this second order ID and label it staging, since the two stores keep separate databases and can hand out the same number. Staging runs the patched Commerce Core 3.3.13 and issues its own session cookie, so the two carts don't interfere, and the browser keeps both sessions for you. You won't touch the staging cart until Task 5, where it tests whether the patch refuses the request.

Try It Yourself: in Burp's HTTP history, find the request for your checkout page and send it to Repeater. Delete the Cookie header and resend it. See how the store responds to a request that no longer has the session that owns the cart.

Read the patch backward (i.e., from what it adds to what it was protecting), and that's how you work out where the vulnerability is when you've got no PoC to copy. You now have what the cart module calls ownership: a draft order, its ID, and a cookie the store accepts as its owner. You've not requested an admin page at all. The form-mode route was simply asking "is this cart yours?" and the answer is yes. Does that route actually open up for you on the Copperleaf production store? Task 4 sends the request.

Answer the questions below

  1. Reading the 3.3.13 fix, the form-mode route and the order-item admin routes gain one new access requirement. Give that requirement as its key and value. (Answer Format: _permission: <the permission string>, lowercase.)

  2. On a 3.3.12 store you are signed in to your own customer account and own two orders: order A, your current draft cart, and order B, an order you placed and paid for last week. Order C is another customer's draft cart. For how many of these three orders would the cart hook's ownership grant open the form-mode route?

  3. A Copperleaf draft cart holds two Walnut Cutting Boards, catalog price 42.00, whose unit price someone overrode to 5.00, and one Smoked Paprika Tin that was never overridden, added at its then-catalog price of 7.00 before catalog rose to 9.50. The order has no adjustments. After OrderRefresh::refresh() runs on the cart, what is the order total? (Answer Format: a dollar total, two decimals, no currency symbol.)

Task 4: Exploit the Bypass

Somewhere in the last week, stores like Copperleaf were attacked through this flaw. Drupal's advisory, SA-CONTRIB-2026-254, says the bug is being actively exploited, but still hasn't said how, by whom, or against how many stores. You hold everything you need to find out what a shopper can actually do here: your two-saucepan cart, its order ID, and the session cookie that marks the cart as yours.

The routes you're about to request aren't guesswork. The 3.3.13 fix shipped as commit 2ddd3a34 with a test, CartAdminRouteAccessTest, whose own description is "Tests that cart access does not grant access to the admin order routes." That test names the admin order routes cart access should never have reached. No public proof of concept existed as of writing, so we work the attack from that fix, by hand, one request at a time.

Opening Your Cart's Admin Order Form

In Task 3 you saw why the form-mode route in 3.3.12 only asks one question: is this your cart? The cart hook gives the owner of a draft cart an update, that grant fulfills the route's entity access check, and no permission is ever requested. The route should therefore be open to you. Let's send it.

In the same browser session from Task 3, open the order_details form mode for your order:

http://copperleaf.thm/admin/commerce/orders/<order ID>/form/order_details

Replace <order ID> with your production order ID from Task 3. The page loads. You're an anonymous shopper, and an admin order form has just loaded.

To confirm it outside of the browser, simply replay the request in Burp Repeater or send it via curl (make sure to carry your production session cookie):

curl -s -o /dev/null -w '%{http_code}\n' -b 'SESS<hash>=<value>' http://copperleaf.thm/admin/commerce/orders/<order ID>/form/order_details

curl prints 200. The store served the page.

The form has two fields that Copperleaf staff never intended customers to see. One is labeled Fulfillment note (staff only), which the packing team uses for instructions. The other is Unit cost (staff only), which staff enters as the supplier cost when pricing a replacement line at cost. Read both. The Fulfillment note will answer the first question. Keep the Unit cost in front of you as you'll need it shortly.

Before you go any further, test the edge of the bypass. Copperleaf seeded order 1, a placed order your session doesn't own. Predict what the same form/order_details request will return for it. You never had order 1 in your session, and the cart hook only grants updates on a draft you own, so it has nothing to work with. Send the request for order 1 and confirm you get a 403 Forbidden. The bypass opens only a draft your own session owns, so it is never a way into another customer's order.

Repricing Your Own Line

Now use what the page gives you. First, open /cart in a separate tab and note the total that's displayed there (so you can see the change later). The same order_details form will show your cart line (two Copper Saucepans) with an Edit button next to it. Open that. In the fields for the item, tick Override the unit price and then enter the unit cost you just read into the Unit Price field. Leave the quantity alone and save the form. That's all done inline in the browser. If you want to use Burp, then intercept that same save and replay it from Repeater. The request contains nothing secret other than the session cookie you already have.

One thing the lab makes very clear is that if you don't tick Override the unit price, Drupal will simply ignore your number and re-resolve the price from the catalog. Burp's HTTP history shows the save responding with a 303 redirect (the standard Post/Redirect/Get following a form submit).

Your browser follows that redirect to the order's main admin page, /admin/commerce/orders/<order ID>, and lands on Access denied. The save didn't fail. It happened on the request before the redirect. In 3.3.12, the order's own routes, its main page among them, already require access commerce administration pages. The form-mode route you just used sits outside that set. One page served you and the page it redirected to refused you, and that's the bug in a single click.

Reload /cart. The order total has changed. You'll already know why from reading OrderRefresh in Task 3 (it leaves the overridden price alone while re-pricing all the other items so the override survives the refresh and just flows straight into the total). That new total answers the second question. A shopper has just set their own price on a production store (that's the financial impact the advisory is warning about) on one order manually.

Try It Yourself: The fix has a test that names a second form mode on the same route (add-items). Swap order_details for add-items in your form-mode URL and send it over with your session cookie. See if the route serves it and think about what that means for every admin form mode that the test lists.

This task ran read before write for a reason. You'd confirm the route served you before you'd trusted it with a change, and you'd read the unit cost off the page before you'd had a price to set. Most access bypass work is done in that order, really. You find the thing that's restricted, you confirm that the control which should be stopping you is actually missing, and then you go through the gap. The cart hook answered "is this yours?" and never asked "where is this being used?" and that one missing question is the whole bug.

You've left a trail to match. A few GETs of admin order sub-routes, a form POST that repriced a line, all with the shopper's session cookie. If someone else went down that same path through Copperleaf before you, it's in the store's logs, and Marisol has already upgraded staging to the patched release. The question is what that earlier attacker left behind and whether the upgraded store actually refuses the request you just sent.

Answer the questions below

  1. With your cart's session cookie, open /admin/commerce/orders/<order ID>/form/order_details and read the staff-only fulfillment note. What is its value? (Answer Format: THM{...})

  2. Override your two-saucepan line's unit price to the staff-only unit cost shown on the order_details form, save, then reload /cart. What order total does the cart show now? (Answer Format: a dollar total, two decimals, no symbol)

Task 5: Detect and Patch

Commerce maintains an activity log on every order, a live feed of who's done what. Open it after last week, and you'd see placed orders, state changes, customer assignments. You'd see absolutely nothing in there to indicate that a line had been repriced. Commerce Core 3.3.12 doesn't ship with an activity log template for a unit price or order item edit, so the store's own order record is completely blind to the very change you made in Task 4. If an attacker had repriced the order before you did, what in Copperleaf's evidence would still show that? And does Marisol's upgraded staging store actually reject the request you just sent?

What the Store's Own Logs Miss

Two of Copperleaf's logs appear to be completely blind to this. Commerce Core has templates for order events in its activity log, but has none for a unit price or order item edit. A successful override therefore leaves no order activity entry. Drupal's watchdog will record access denials (a refused request, a 403) in its access denied channel. It records the probes the attacker was turned away on, but not the request that actually went through, because it logs denials, not successes. On 3.3.12, the one edit that actually matters slips through both application logs. Two sources still have it: the web server access log and the order data itself.

Before you go hunting, it helps to know the difference between normal and suspicious.

Pattern Normal Suspicious
Storefront paths Customers request /product, /cart and /checkout A shopper's session requesting /admin/commerce/orders/...
Order-admin requests Staff open order admin pages after a login, from the office network 10.20.4.0/24 The same pages requested from an address outside that range
An overridden price Owen Marsh prices a wholesale or replacement line from the office network An override on a customer order no staff session edited

Hunting the Served Request

Copperleaf exported a week of evidence to http://copperleaf.thm:8081/. Pull both files to the AttackBox:

curl -s http://copperleaf.thm:8081/access.log
curl -s http://copperleaf.thm:8081/orders_audit.csv

The log is in Apache combined format and cuts off before your own testing, so all the entries in it predate you.

A researcher authored a rule to point the hunt. It's illustrative rather than vendor guidance. It's looking for the order admin sub-routes (the form/... and items/.../edit, /delete, /duplicate paths), returning 200, 302, or 303 from a client outside the staff network. In Apache combined format, field 1 is the client IP, field 7 is the path, and field 9 is the status so one filter will pick out the served item edits:

curl -s http://copperleaf.thm:8081/access.log | awk '$7 ~ /\/items\/[0-9]+\/edit$/ && $9 == 200'

Drop the 10.20.4.0/24 staff lines from what comes back. One client outside the office network remains, served an order item edit form.

The rule is tuned for Copperleaf's clean export. On a real store, you'd obviously broaden the filter out to your actual staff networks. This export is already full of the kind of noise you'd want to filter out (a crawler at 192.0.2.44 doing /admin and /wp-login.php probes). It gets filtered out when you scope the filter to the order sub-routes, since those probes never actually reach an order route.

Now go back to every order-admin request (including the ones that were refused), not just the served edits your filter picked up. Two external clients show up. One was refused on every order it ever requested. The other (the client your filter had already surfaced) was refused on one order and then served on the order that its own checkout trail shows it to own. The split is down to Task 3's ownership rule in the log: the cart hook only vouches for a draft that the session has.

Try It Yourself: find the outside client that got a 403 on every order-admin request it made. Line up those requests against the two conditions the cart hook puts on update. Which orders did it reach for, and why was each one refused?

Joining the Log to the Orders

You've got the attacker's IP. Now work out what it changed. orders_audit.csv has no client IP column, so the log is the only link between a client and an order. The served edit line's path names two things: an order ID and an order item ID, in /admin/commerce/orders/<order>/items/<item>/edit. Carry both into the CSV, match the order_id and order_item_id columns, and read that row's unit_price.

Don't trust the override flag by itself. orders_audit.csv holds four rows with overridden_unit_price set, and some are legitimate. Owen Marsh priced a wholesale line and discounted a couple of customer orders. Each of those had a corresponding staff edit from 10.20.4.0/24 in the log. The tampered order even has a second item still at catalog price. Only the order and item IDs from that one served log line name the row that was changed.

And the upgrade won't repair it. As Brandefense put it, "A fixed version does not establish that earlier orders are intact." Patching to 3.3.13 stops new abuse; it undoes nothing already done. The advisory tells stores to audit their orders, not just upgrade, for exactly that reason.

Does the Patch Refuse It?

Marisol has upgraded the staging environment but not production. Staging runs Commerce Core 3.3.13 on its own database, and you already own a draft cart there: you built it in Task 3. Predict what staging will return, then send Task 4's form/order_details request for your staging order ID from the same browser, which still holds your staging session:

http://staging.copperleaf.thm:8080/admin/commerce/orders/<staging order ID>/form/order_details

If the target machine has restarted since Task 3, it has a new IP address and an empty staging cart: update both names in /etc/hosts to the new MACHINE_IP, then build a new staging cart the way Task 3 did before you send the request.

It's refused. You own the cart; the form just won't open. Task 3 showed why. The fix in the cart hook no longer has the entity access to vouch for the update on an admin route. Entity access is then left with nothing to grant. The route has its own permission (access commerce administration pages), which is left to decide for itself, and an anonymous shopper doesn't have it.

The hunt began in the quiet logs. The application's own logs were blind, so the only record of the request was in the access log, and the only record of its effect was in the order data. Neither of those alone was enough. The log named a client and a path but no price; the CSV named a price but no client IP. Joining them on the order and item IDs turned a suspicious request into one very specific tampered line. When two sources share a single key, that key is the investigation.

You now have what Marisol needs: the external client that was refused on someone else's order and then reached the admin form for its own, the line that it re-priced, and a refusal from a patched store that production still isn't running. The upgrade effectively closes the door. It doesn't explain who has already walked through that door.

Answer the questions below

  1. In access.log, which client IP outside Copperleaf's office network was served an order-item admin edit form? (Answer Format: an IPv4 address.)

  2. That client repriced an item on its order. In orders_audit.csv, what unit price is recorded for that item? (Answer Format: a dollar price, two decimals, no symbol.)

  3. Your staging cart session owns a draft order, yet Commerce Core 3.3.13 refused its form-mode request. For an update on an admin route, what access result does the 3.3.13 cart hook now return? (Answer Format: the result's name.)

Task 6: Conclusion

On the first morning, Marisol wanted to know whether production was exposed, what an attacker could do with it, whether it had already happened, and whether the staging upgrade closes the hole. You can now answer every one of them with evidence instead of a guess, so what's left is a plan the store can act on.

Upgrade, Then Verify

The fix is a version upgrade. Move Commerce Core to 3.3.13 on the 3.x line, or to 8.x-2.41 on the older 8.x-2.x branch. 3.3.13 keeps the same drupal/core and PHP constraints as 3.3.12, so Drupal core stays where it is:

composer require drupal/commerce:^3.3.13
drush updatedb
drush cache:rebuild

The advisory offers no workaround. If the upgrade has to wait, denying /admin/commerce/orders/* to non-staff networks at a reverse proxy is a stopgap you can reason your way to from Task 3, not Drupal guidance, so treat it as a bridge to the upgrade and nothing more.

Then send Task 4's form/order_details request to the upgraded production store, as you did on staging in Task 5. A refusal, recorded in the watchdog access denied channel, confirms the route is closed.

Audit What the Patch Leaves Behind

The upgrade stops new abuse but repairs nothing: the tampered order you found is still tampered on a patched store. The fields the fix now locks to staff are your audit guide. Check every order placed since production first ran the affected release for:

  • Overridden unit prices on customer orders no staff session touched
  • Order totals that don't match their line items
  • Unexpected adjustments on an order
  • State or ownership changes with no matching staff edit

Hold any tampered order, keep its record rather than editing it, and contact the customer before reversing the charge.

What to Take Away

Your report to Marisol answers her questions in turn: production on 3.3.12 was exposed, a shopper could reprice their own order through it, someone outside the office network did exactly that before you, and staging on 3.3.13 refuses the same request. Then add the line she didn't ask for: the orders still need an audit.

As of the catalog's 2026-11-03 release, the CVE was not in CISA's Known Exploited Vulnerabilities (KEV) catalog, though the advisory calls it actively exploited, so re-check before you rely on that. A community Nuclei detection template by Alex Ryabov was merged into ProjectDiscovery's template library on 4 November. It reads an owned cart's admin order route and never submits the form, so it's a low-impact way to check stores you're responsible for.

The lesson underneath it all: an access check that asks "is this yours?" must also ask "where is this being used?" A path that starts with /admin/ is a path, not a permission.

Back to all walkthroughs
Room Icon

Drupal Commerce: CVE-2026-118539

Abuse a missing authorization check in Drupal Commerce (CVE-2026-118539), then detect and patch it.

medium

40 min

119

User profile photo.
User profile photo.
User profile photo.

To access material, start machines and answer questions login.

On the morning of 28 October, Marisol Vega read a security advisory she wished she hadn't. Marisol runs operations at Copperleaf Provisions, a small online retailer of specialty kitchenware and pantry goods. Drupal publishes these alerts as a Security Advisory (SA), and this one, SA-CONTRIB-2026-254, concerned the exact software her storefront uses to check out orders. It said the flaw "is being actively exploited in the wild."

The Advisory at a Glance

  • What's affected: Commerce Core (machine name commerce), a contributed module that gives Drupal a shopping cart and an order system. The advisory covers this module, not Drupal core itself.
  • What's wrong: in Drupal's own words, the module "doesn't sufficiently protect certain routes and entity fields, leading to an access bypass that may financially impact the vendor."
  • How it's classified: the Common Vulnerabilities and Exposures (CVE) record, published on 30 October, lists the bug as CVE-2026-118539. It's a missing authorization flaw (CWE-862) that allows forceful browsing, which the Common Attack Pattern Enumeration and Classification (CAPEC) catalog lists as CAPEC-87. In plain terms, an attacker reaches a page by asking for it directly, without the access that page was supposed to require.
  • How severe it is: highly critical, 21 out of 25 on Drupal's own risk scale. That scale comes from a scoring standard published by the National Institute of Standards and Technology (NIST), not the Common Vulnerability Scoring System (CVSS) you'll see on most advisories. On 31 October, the Cybersecurity and Infrastructure Security Agency (CISA) added a CVSS 3.1 score to the CVE record: 6.5, Medium. The two scales measure different things, so read both before you decide how urgent a bug is.
  • What the rating says: no login is needed and every configuration is exposed. It also records exploit code as documented or deployed in the wild, which means exploitation has been seen, not that a public exploit is available.
  • What's still unknown: Drupal hasn't said how the attack works, who is behind it, or how many stores were hit.
  • How far it reaches: about 30,047 self-reported installs ran an affected release the week before the fix. That's a count of installs, not victims.
  • Public exploit: no public proof-of-concept existed as of 30 October, so we'll work the attack out ourselves, from Drupal's own published fix.
  • What Drupal asks of stores: upgrade, and "audit their orders for inconsistencies."

Marisol has already upgraded Copperleaf's staging store; production is still on the vulnerable release. You've been asked to answer a few questions: is production exposed, and what could an attacker do with that? Are there any signs that it has already happened? Does the staging upgrade actually close the hole? You'll be working from the AttackBox as an anonymous shopper with no account, using a browser, Burp, and curl.

That anonymous bit is the odd one out. The rating says an attacker needs no login, yet the damage targets pages under the /admin/ path. So how does someone without a login get to a page designed specifically for staff?

References

Learning Objectives

This room walks you through a live, actively exploited access bypass in a Drupal Commerce storefront from advisory through to the fix and the cleanup. In particular, you'll learn how to:

  • Explain what the Commerce Core access bypass is
  • Describe how Drupal decides who may open a route
  • Trace cart ownership into an admin route
  • Reach staff-only order data as an anonymous shopper
  • Override an order price through the bypass
  • Detect the attack and audit the orders
  • Confirm the patched release refuses the request

Room Prerequisites

This room assumes you can make and receive HTTP requests via curl and an intercepting ( would be the obvious choice). You should be able to carry a from one request to the next and be at home in the AttackBox terminal. No prior Drupal or Commerce experience is required, as we'll build what you need as we go. If you're new to requests, cookies, and web proxies, then the web fundamentals material in the Junior Penetration Tester path is a good place to start.

Before you start Task 2, press the Start AttackBox button to start the Attacker machine and press the Start Machine button to start the Target machine so that they are ready when you need them in later tasks.

Set up your virtual environment

To successfully complete this room, you'll need to set up your virtual environment. This involves starting both your AttackBox (if you're not using your VPN) and Lab Machines, ensuring you're equipped with the necessary tools and access to tackle the challenges ahead.
Attacker machine
Status:Off
Lab machine
Status:Off

A web address that begins with /admin/ looks like a door marked Staff Only. The advisory about the software behind Copperleaf's storefront says otherwise. Its vector carries A:None, which means an anonymous visitor with no account can reach the bug. So who decides whether that visitor may open an admin page, and what does that decision actually look at?

What Commerce Core Is Made Of

Copperleaf's front end runs on Commerce Core (not Drupal core), a contributed module comprising a family of sub-modules, each handling one aspect of the selling process. There's a store module, a product catalog module, an order module, a cart module, a checkout module, and a payment module. The one that's relevant here is the cart. In Commerce, an order starts life as a draft cart and only becomes a placed order at checkout. Hold that thought, because the cart module is precisely where this bug lives.

How a Route Decides

In Drupal, every page is behind a route, and each route has its own set of access requirements. Common ones are _permission, _entity_access (written entity_type.operation, for example commerce_order.update), _custom_access and _csrf_token. If a route has more than one of these requirements, then the Drupal documentation is very direct: "all results must be AccessResult::allowed()". Every requirement must return yes; otherwise, the route is denied.

An access check returns one of three results: Allowed grants the request. Forbidden refuses it outright. Neutral expresses no opinion, leaving the decision to others.

Why Entity Access Is Different

Imagine a door with several locks. The route is that door, and every lock has to turn; otherwise you're still outside. Entity access works the other way round. Imagine a guest list where any host can let you in unless someone at the door has specifically barred you by name.

Drupal combines entity access results with a rule called orIf. Its documentation sums it up: "a Neutral result might become Allowed (if there are no Forbidden results but there is an Allowed)". Drupal gathers every entity access result, then walks this flow from the top and stops at the first match:

  • Any result says Forbidden? The request is refused, however many other results said yes.
  • Otherwise, any result says Allowed? The request is allowed. One yes is enough.
  • Otherwise, every result is Neutral. The combined result is Neutral, and nothing has granted the request.

Let's run one case. A visitor comes along and asks to view an order. The order's own access control handler has nothing to say on the matter and returns Neutral. One module's hook comes to the rescue and says Allowed. A second module's hook comes along and says Forbidden. The refusal is there, so it wins. The view is refused despite there being a grant sitting right next to it.

The /admin/ Prefix Guards Nothing

Now the admin page makes perfect sense. A route which has _entity_access defined but no _permission is protected solely by the combined entity access. The /admin/ at the front of the URL is part of a path, not a permission. If the combined entity access permits the request, the page will open wherever that route happens to be. An anonymous user can therefore in fact go to a page under /admin/: the route didn't ask for a permission, so the only question was one of entity access, and that was answered: "Allowed".

How Drupal combines a route's own requirements with its entity-access checks, and where a route that asks for no permission leaves an admin page open.

One More Layer: Field Access

There's a decision sitting below the entity. An access control handler can override a method called checkFieldAccess(). It can choose to deny edit on a particular named field even though the operation on the entity as a whole is permitted. So a visitor who is allowed to update an order might still be prevented from editing one specific field on that order. Field access is a separate decision from entity access.

We've seen how Drupal combines route, entity, and field checks. We haven't seen who answers the 'entity' question in the case of a shopping cart, though. There's a module in Commerce whose sole purpose is to allow shoppers to update their own carts. When Drupal comes to it and asks, "is this cart request allowed?" what exactly is it saying in answer to that? Task 3 opens by reading that module's source.

Answer the questions below

A route requires _permission and _entity_access. The permission check grants, and the order's access control handler has no opinion. Four module hooks also vote on entity access, and none refuses outright. At most how many of those hooks can have no opinion if the route is still served?

For an update on an order, the order's access control handler has no opinion, no module forbids the update, and a cart module's access hook grants it. What access result does the update operation get?

Which access control handler method can refuse edit on a single field even when the operation on the whole entity is permitted?

The advisory names no route and no field. It says Commerce "doesn't sufficiently protect certain routes and entity fields" and stops there. So how do we find the ones an attacker uses? We read the patch. Commerce Core 3.3.13 shipped as a single squashed commit, 2ddd3a34, titled "fix: Improve access control". It touches 84 files and carries no advisory reference. The release page lists six advisories, and five of them have changes somewhere in that diff. To find the gap, we read the one cluster that matches the advisory's words: the order and cart access control.

What the Cart Module Vouched For

Task 2 closed with a question. Commerce has a module whose entire purpose is to allow shoppers to update their own carts. When Drupal asks it about a cart request, what does it say?

In 3.3.12, the cart module answers in its access hook, orderAccess(). This cart hook returns Allowed for both view and update if the account owns the order. Ownership is straightforward: either a signed in customer whose user ID matches or an anonymous visitor whose cart session contains that order ID. For updates, there's one further condition: the order must still be a draft. The order items go through the same check. It never asks what route we're on.

The missing permission is what the entire bug is predicated on. With entity access using the orIf logic, a single 'Allowed' will satisfy an entity access requirement wherever that requirement occurs, including on a page located at /admin/.

The Routes That Trusted Ownership

Two of the sets of routes asked only an entity access question in 3.3.12.

The first is the form-mode route, /admin/commerce/orders/{commerce_order}/form/{form_mode}. It has _entity_access: commerce_order.update and a custom check, with no _permission at all. The second set is the order item routes (the edit, delete, and duplicate forms) generated by the OrderItemRouteProvider, with no permission requirement either.

Now contrast that with the main order edit form at /admin/commerce/orders/{commerce_order}/edit. That one was already refusing a cart owner in 3.3.12 (OrderRouteProvider adds the permission to every route it builds). The gap wasn't in the order module's own edit route at all. It was in routes defined elsewhere that relied solely on ownership.

What the Fix Added

The patch closes the gap in two places. It adds the _permission requirement access commerce administration pages to the form-mode route and to the order-item route collection. It also changes the cart hook: for an update on an admin route, the hook now returns Neutral instead of Allowed, with a comment that customers may update their carts while shopping "but this must never grant access to the admin pages". A new route subscriber then adds that same permission to every /admin/ route that takes an order which still doesn't have it. New checkFieldAccess() overrides then lock down the individual fields (the order item's unit price being one of them).

Why Neutral rather than Forbidden? The fix provides no justification per se, but one inference is drawn from the orIf order. A Forbidden from the cart hook would nullify all grants, so a staff member would be locked out of their own draft cart's admin page. To simply return Neutral is to merely withdraw the cart hook's vote and leave the route's permission to determine the outcome.

The fix includes a test (CartAdminRouteAccessTest): "Tests that cart access does not grant access to the admin order routes." It denies a normal customer, an anonymous customer, and a customer with just the admin-pages permission on both the form-mode and order-item routes. A test that asserts a denial suggests those routes were open before, but it doesn't prove that. Task 4 proves it on the box.

Why an Overridden Price Matters

Here's what the field-level part of the fix tells us, read as an inference from the code rather than a confirmed attacker method. The unit price widget on an order item has an "Override the unit price" checkbox. If that's ticked, Commerce will mark the price as overridden. OrderRefresh::refresh() re-prices a draft cart's items from the catalog, but only for items that were not overridden. So if a draft cart contains a single Carbon Steel Wok at a catalog price of 58.00, overridden to 50.00, a refresh will leave it at 50.00 as the override flag tells Commerce to keep it. An unprotected price change therefore gets into the order total. A route that's merely asking "is this your cart?" has effectively become a way to change what the store is charging.

Build Your Own Cart

Time to become a cart owner, twice: once on Copperleaf's production store and once on the staging store Marisol has already upgraded. On the AttackBox, map both names to the target (staging only answers to its own name):

echo "MACHINE_IP copperleaf.thm staging.copperleaf.thm" | sudo tee -a /etc/hosts

Open Burp Suite, turn the intercept off, and launch the Burp browser. The store's home page has no product links yet, so go straight to the product: http://copperleaf.thm/product/1 is the Copper Saucepan 2 qt. Click Add to cart. Open /cart, set the quantity to 2, and click Update cart, so the one line reads a quantity of two. Proceed to checkout and stop at the first step (the contact info page) without completing it. The cart hook only allows updates while the order is a draft; once you go to checkout and complete it, the order will be placed.

Now record two things for this production cart. The order ID is in the checkout URL: when you start checkout, you're taken to /checkout/{order ID}/order_information, and the number in the middle is your production order ID. The session cookie (SESS...) is in Burp's HTTP history on any request to the store; that cookie is what makes the order yours.

Now build the staging cart the same way, in the same browser. Go to http://staging.copperleaf.thm:8080/product/1, add the saucepan, start checkout, and stop at the contact info page again. Write down this second order ID and label it staging, since the two stores keep separate databases and can hand out the same number. Staging runs the patched Commerce Core 3.3.13 and issues its own session cookie, so the two carts don't interfere, and the browser keeps both sessions for you. You won't touch the staging cart until Task 5, where it tests whether the patch refuses the request.

Try It Yourself: in Burp's HTTP history, find the request for your checkout page and send it to Repeater. Delete the Cookie header and resend it. See how the store responds to a request that no longer has the session that owns the cart.

Read the patch backward (i.e., from what it adds to what it was protecting), and that's how you work out where the vulnerability is when you've got no to copy. You now have what the cart module calls ownership: a draft order, its ID, and a cookie the store accepts as its owner. You've not requested an admin page at all. The form-mode route was simply asking "is this cart yours?" and the answer is yes. Does that route actually open up for you on the Copperleaf production store? Task 4 sends the request.

Answer the questions below

Reading the 3.3.13 fix, the form-mode route and the order-item admin routes gain one new access requirement. Give that requirement as its key and value. (Answer Format: _permission: <the permission string>, lowercase.)

On a 3.3.12 store you are signed in to your own customer account and own two orders: order A, your current draft cart, and order B, an order you placed and paid for last week. Order C is another customer's draft cart. For how many of these three orders would the cart hook's ownership grant open the form-mode route?

A Copperleaf draft cart holds two Walnut Cutting Boards, catalog price 42.00, whose unit price someone overrode to 5.00, and one Smoked Paprika Tin that was never overridden, added at its then-catalog price of 7.00 before catalog rose to 9.50. The order has no adjustments. After OrderRefresh::refresh() runs on the cart, what is the order total? (Answer Format: a dollar total, two decimals, no currency symbol.)

Somewhere in the last week, stores like Copperleaf were attacked through this flaw. Drupal's advisory, SA-CONTRIB-2026-254, says the bug is being actively exploited, but still hasn't said how, by whom, or against how many stores. You hold everything you need to find out what a shopper can actually do here: your two-saucepan cart, its order ID, and the that marks the cart as yours.

The routes you're about to request aren't guesswork. The 3.3.13 fix shipped as commit 2ddd3a34 with a test, CartAdminRouteAccessTest, whose own description is "Tests that cart access does not grant access to the admin order routes." That test names the admin order routes cart access should never have reached. No public proof of concept existed as of writing, so we work the attack from that fix, by hand, one request at a time.

Opening Your Cart's Admin Order Form

In Task 3 you saw why the form-mode route in 3.3.12 only asks one question: is this your cart? The cart hook gives the owner of a draft cart an update, that grant fulfills the route's entity access check, and no permission is ever requested. The route should therefore be open to you. Let's send it.

In the same browser session from Task 3, open the order_details form mode for your order:

http://copperleaf.thm/admin/commerce/orders/<order ID>/form/order_details

Replace <order ID> with your production order ID from Task 3. The page loads. You're an anonymous shopper, and an admin order form has just loaded.

To confirm it outside of the browser, simply replay the request in Burp Repeater or send it via curl (make sure to carry your production session cookie):

curl -s -o /dev/null -w '%{http_code}\n' -b 'SESS<hash>=<value>' http://copperleaf.thm/admin/commerce/orders/<order ID>/form/order_details

curl prints 200. The store served the page.

The form has two fields that Copperleaf staff never intended customers to see. One is labeled Fulfillment note (staff only), which the packing team uses for instructions. The other is Unit cost (staff only), which staff enters as the supplier cost when pricing a replacement line at cost. Read both. The Fulfillment note will answer the first question. Keep the Unit cost in front of you as you'll need it shortly.

Before you go any further, test the edge of the bypass. Copperleaf seeded order 1, a placed order your session doesn't own. Predict what the same form/order_details request will return for it. You never had order 1 in your session, and the cart hook only grants updates on a draft you own, so it has nothing to work with. Send the request for order 1 and confirm you get a 403 Forbidden. The bypass opens only a draft your own session owns, so it is never a way into another customer's order.

The access-bypass chain from an anonymous draft cart to the unprotected admin order form, with a not-owned order refused.

Repricing Your Own Line

Now use what the page gives you. First, open /cart in a separate tab and note the total that's displayed there (so you can see the change later). The same order_details form will show your cart line (two Copper Saucepans) with an Edit button next to it. Open that. In the fields for the item, tick Override the unit price and then enter the unit cost you just read into the Unit Price field. Leave the quantity alone and save the form. That's all done inline in the browser. If you want to use Burp, then intercept that same save and replay it from Repeater. The request contains nothing secret other than the session cookie you already have.

One thing the lab makes very clear is that if you don't tick Override the unit price, Drupal will simply ignore your number and re-resolve the price from the catalog. Burp's HTTP history shows the save responding with a 303 redirect (the standard Post/Redirect/Get following a form submit).

Your browser follows that redirect to the order's main admin page, /admin/commerce/orders/<order ID>, and lands on Access denied. The save didn't fail. It happened on the request before the redirect. In 3.3.12, the order's own routes, its main page among them, already require access commerce administration pages. The form-mode route you just used sits outside that set. One page served you and the page it redirected to refused you, and that's the bug in a single click.

Reload /cart. The order total has changed. You'll already know why from reading OrderRefresh in Task 3 (it leaves the overridden price alone while re-pricing all the other items so the override survives the refresh and just flows straight into the total). That new total answers the second question. A shopper has just set their own price on a production store (that's the financial impact the advisory is warning about) on one order manually.

Try It Yourself: The fix has a test that names a second form mode on the same route (add-items). Swap order_details for add-items in your form-mode URL and send it over with your session cookie. See if the route serves it and think about what that means for every admin form mode that the test lists.

This task ran read before write for a reason. You'd confirm the route served you before you'd trusted it with a change, and you'd read the unit cost off the page before you'd had a price to set. Most access bypass work is done in that order, really. You find the thing that's restricted, you confirm that the control which should be stopping you is actually missing, and then you go through the gap. The cart hook answered "is this yours?" and never asked "where is this being used?" and that one missing question is the whole bug.

You've left a trail to match. A few GETs of admin order sub-routes, a form POST that repriced a line, all with the shopper's session cookie. If someone else went down that same path through Copperleaf before you, it's in the store's logs, and Marisol has already upgraded staging to the patched release. The question is what that earlier attacker left behind and whether the upgraded store actually refuses the request you just sent.

Answer the questions below

With your cart's session cookie, open /admin/commerce/orders/<order ID>/form/order_details and read the staff-only fulfillment note. What is its value? (Answer Format: THM{...})

Override your two-saucepan line's unit price to the staff-only unit cost shown on the order_details form, save, then reload /cart. What order total does the cart show now? (Answer Format: a dollar total, two decimals, no symbol)

Commerce maintains an activity log on every order, a live feed of who's done what. Open it after last week, and you'd see placed orders, state changes, customer assignments. You'd see absolutely nothing in there to indicate that a line had been repriced. Commerce Core 3.3.12 doesn't ship with an activity log template for a unit price or order item edit, so the store's own order record is completely blind to the very change you made in Task 4. If an attacker had repriced the order before you did, what in Copperleaf's evidence would still show that? And does Marisol's upgraded staging store actually reject the request you just sent?

What the Store's Own Logs Miss

Two of Copperleaf's logs appear to be completely blind to this. Commerce Core has templates for order events in its activity log, but has none for a unit price or order item edit. A successful override therefore leaves no order activity entry. Drupal's watchdog will record access denials (a refused request, a 403) in its access denied channel. It records the probes the attacker was turned away on, but not the request that actually went through, because it logs denials, not successes. On 3.3.12, the one edit that actually matters slips through both application logs. Two sources still have it: the web server access log and the order data itself.

Before you go hunting, it helps to know the difference between normal and suspicious.

Pattern Normal Suspicious
Storefront paths Customers request /product, /cart and /checkout A shopper's session requesting /admin/commerce/orders/...
Order-admin requests Staff open order admin pages after a login, from the office network 10.20.4.0/24 The same pages requested from an address outside that range
An overridden price Owen Marsh prices a wholesale or replacement line from the office network An override on a customer order no staff session edited

Hunting the Served Request

Copperleaf exported a week of evidence to http://copperleaf.thm:8081/. Pull both files to the AttackBox:

curl -s http://copperleaf.thm:8081/access.log
curl -s http://copperleaf.thm:8081/orders_audit.csv

The log is in Apache combined format and cuts off before your own testing, so all the entries in it predate you.

A researcher authored a rule to point the hunt. It's illustrative rather than vendor guidance. It's looking for the order admin sub-routes (the form/... and items/.../edit, /delete, /duplicate paths), returning 200, 302, or 303 from a client outside the staff network. In Apache combined format, field 1 is the client IP, field 7 is the path, and field 9 is the status so one filter will pick out the served item edits:

curl -s http://copperleaf.thm:8081/access.log | awk '$7 ~ /\/items\/[0-9]+\/edit$/ && $9 == 200'

Drop the 10.20.4.0/24 staff lines from what comes back. One client outside the office network remains, served an order item edit form.

The rule is tuned for Copperleaf's clean export. On a real store, you'd obviously broaden the filter out to your actual staff networks. This export is already full of the kind of noise you'd want to filter out (a crawler at 192.0.2.44 doing /admin and /wp-login.php probes). It gets filtered out when you scope the filter to the order sub-routes, since those probes never actually reach an order route.

Now go back to every order-admin request (including the ones that were refused), not just the served edits your filter picked up. Two external clients show up. One was refused on every order it ever requested. The other (the client your filter had already surfaced) was refused on one order and then served on the order that its own checkout trail shows it to own. The split is down to Task 3's ownership rule in the log: the cart hook only vouches for a draft that the session has.

Try It Yourself: find the outside client that got a 403 on every order-admin request it made. Line up those requests against the two conditions the cart hook puts on update. Which orders did it reach for, and why was each one refused?

Joining the Log to the Orders

You've got the attacker's IP. Now work out what it changed. orders_audit.csv has no client IP column, so the log is the only link between a client and an order. The served edit line's path names two things: an order ID and an order item ID, in /admin/commerce/orders/<order>/items/<item>/edit. Carry both into the CSV, match the order_id and order_item_id columns, and read that row's unit_price.

Don't trust the override flag by itself. orders_audit.csv holds four rows with overridden_unit_price set, and some are legitimate. Owen Marsh priced a wholesale line and discounted a couple of customer orders. Each of those had a corresponding staff edit from 10.20.4.0/24 in the log. The tampered order even has a second item still at catalog price. Only the order and item IDs from that one served log line name the row that was changed.

And the upgrade won't repair it. As Brandefense put it, "A fixed version does not establish that earlier orders are intact." Patching to 3.3.13 stops new abuse; it undoes nothing already done. The advisory tells stores to audit their orders, not just upgrade, for exactly that reason.

Does the Patch Refuse It?

Marisol has upgraded the staging environment but not production. Staging runs Commerce Core 3.3.13 on its own database, and you already own a draft cart there: you built it in Task 3. Predict what staging will return, then send Task 4's form/order_details request for your staging order ID from the same browser, which still holds your staging session:

http://staging.copperleaf.thm:8080/admin/commerce/orders/<staging order ID>/form/order_details

If the target machine has restarted since Task 3, it has a new IP address and an empty staging cart: update both names in /etc/hosts to the new MACHINE_IP, then build a new staging cart the way Task 3 did before you send the request.

It's refused. You own the cart; the form just won't open. Task 3 showed why. The fix in the cart hook no longer has the entity access to vouch for the update on an admin route. Entity access is then left with nothing to grant. The route has its own permission (access commerce administration pages), which is left to decide for itself, and an anonymous shopper doesn't have it.

The hunt began in the quiet logs. The application's own logs were blind, so the only record of the request was in the access log, and the only record of its effect was in the order data. Neither of those alone was enough. The log named a client and a path but no price; the CSV named a price but no client IP. Joining them on the order and item turned a suspicious request into one very specific tampered line. When two sources share a single key, that key is the investigation.

You now have what Marisol needs: the external client that was refused on someone else's order and then reached the admin form for its own, the line that it re-priced, and a refusal from a patched store that production still isn't running. The upgrade effectively closes the door. It doesn't explain who has already walked through that door.

Answer the questions below

In access.log, which client IP outside Copperleaf's office network was served an order-item admin edit form? (Answer Format: an IPv4 address.)

That client repriced an item on its order. In orders_audit.csv, what unit price is recorded for that item? (Answer Format: a dollar price, two decimals, no symbol.)

Your staging cart session owns a draft order, yet Commerce Core 3.3.13 refused its form-mode request. For an update on an admin route, what access result does the 3.3.13 cart hook now return? (Answer Format: the result's name.)

On the first morning, Marisol wanted to know whether production was exposed, what an attacker could do with it, whether it had already happened, and whether the staging upgrade closes the hole. You can now answer every one of them with evidence instead of a guess, so what's left is a plan the store can act on.

Upgrade, Then Verify

The fix is a version upgrade. Move Commerce Core to 3.3.13 on the 3.x line, or to 8.x-2.41 on the older 8.x-2.x branch. 3.3.13 keeps the same drupal/core and PHP constraints as 3.3.12, so Drupal core stays where it is:

composer require drupal/commerce:^3.3.13
drush updatedb
drush cache:rebuild

The advisory offers no workaround. If the upgrade has to wait, denying /admin/commerce/orders/* to non-staff networks at a reverse proxy is a stopgap you can reason your way to from Task 3, not Drupal guidance, so treat it as a bridge to the upgrade and nothing more.

Then send Task 4's form/order_details request to the upgraded production store, as you did on staging in Task 5. A refusal, recorded in the watchdog access denied channel, confirms the route is closed.

Audit What the Patch Leaves Behind

The upgrade stops new abuse but repairs nothing: the tampered order you found is still tampered on a patched store. The fields the fix now locks to staff are your audit guide. Check every order placed since production first ran the affected release for:

  • Overridden unit prices on customer orders no staff session touched
  • Order totals that don't match their line items
  • Unexpected adjustments on an order
  • State or ownership changes with no matching staff edit

Hold any tampered order, keep its record rather than editing it, and contact the customer before reversing the charge.

What to Take Away

Your report to Marisol answers her questions in turn: production on 3.3.12 was exposed, a shopper could reprice their own order through it, someone outside the office network did exactly that before you, and staging on 3.3.13 refuses the same request. Then add the line she didn't ask for: the orders still need an audit.

As of the catalog's 2026-11-03 release, the CVE was not in CISA's Known Exploited Vulnerabilities (KEV) catalog, though the advisory calls it actively exploited, so re-check before you rely on that. A community Nuclei detection template by Alex Ryabov was merged into ProjectDiscovery's template library on 4 November. It reads an owned cart's admin order route and never submits the form, so it's a low-impact way to check stores you're responsible for.

The lesson underneath it all: an access check that asks "is this yours?" must also ask "where is this being used?" A path that starts with /admin/ is a path, not a permission.