Magento Instant Purchase custom_price, authenticated underpay
Magento 2.4.8-p5 Instant Purchase copies every POST field into the buyRequest because $knownRequestParams is a list and isset() looks for keys. Checkout RequestInfoFilter strips custom_price. Instant Purchase does not. Quote Processor setCustomPrice. Lab charged 0.01 against catalog 99.99. No CVE yet.
- Name
- Magento Instant Purchase custom_price, authenticated underpay
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Adobe
- Affected
- Magento Open Source / Adobe Commerce through 2.4.8-p5 Instant Purchase; unpublished. Guest add-to-cart is not this bug.
- Published
- 26 Sept 2026
- Updated
- 26 Sept 2026
- Tags
- 0-day, magento, adobe, instant-purchase, cwe-472, cwe-639, authenticated
isset on a list is always false
I am @abraxas_null. The proof of concept is on GitHub: abraxas/magento-instant-purchase-custom-price (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, seed, InstantVault module. Authorized lab only. It talks to loopback.
This is Magento Open Source 2.4.8-p5, Instant Purchase. No CVE yet. CWE-472 / CWE-639. 6.5 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N). Authenticated customer. Not a shell. Not unauthenticated.
The one-click button is supposed to be a thin wrapper around a vault token and saved addresses. PlaceOrder still copies unknown POST fields into the product buyRequest. $knownRequestParams is a list. isset($list[$param]) on a list is always false, so every field is "unknown." Including custom_price.
Normal add-to-cart runs frontend RequestInfoFilter, which strips custom_price. Instant Purchase QuoteFilling::addProduct does not. Quote\Item\Processor::prepare then setCustomPrice. The order item unit price becomes whatever you POSTed.
Lab catalog 99.99. Instant Purchase with custom_price=0.01. Order 000000004: base_subtotal=0.01, shipping 5, grand 5.01. Product name GHSA-MAGENTO-IP-CUSTOM-PRICE. Guest cart is not this bug.
I am not printing a form-urlencoded body you can paste at someone else's /instantpurchase/button/placeOrder/. The list-vs-keys mistake is the useful part. Isolated lab. The stack is in the GitHub repo so you can run it at home.
What an attacker can do
Log in as a customer who already has Instant Purchase available. POST custom_price on /instantpurchase/button/placeOrder/. The order item unit price becomes whatever they posted. Guest add-to-cart is not this bug: that path strips the field.
Lab catalog 99.99. Instant Purchase with custom_price=0.01. Order 000000004: base_subtotal=0.01, shipping 5, grand 5.01. They do not get a PHP shell. They get the SKU for a cent.
The lab (run this at home)
Source of truth is lab/. Magento is shinsenter/magento:php8.3-nginx at 2.4.8-p5. Compose also runs mysql:8.0 and opensearchproject/opensearch:2.11.1. The Dockerfile pins OpenSearch. Port 18096.
# Loopback lab image pin for magento-instant-purchase-custom-price. Full stack: docker-compose.yml
FROM opensearchproject/opensearch:2.11.1name: magento-instant-purchase-custom-price
services:
mysql:
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password --log-bin-trust-function-creators=1
environment:
MYSQL_ROOT_PASSWORD: magento
MYSQL_DATABASE: magento
MYSQL_USER: magento
MYSQL_PASSWORD: magento
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-umagento", "-pmagento"]
interval: 5s
timeout: 5s
retries: 40
start_period: 20s
opensearch:
image: opensearchproject/opensearch:2.11.1
environment:
discovery.type: single-node
DISABLE_SECURITY_PLUGIN: "true"
DISABLE_INSTALL_DEMO_CONFIG: "true"
OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m"
cluster.routing.allocation.disk.threshold_enabled: "false"
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9200 >/dev/null"]
interval: 10s
timeout: 5s
retries: 40
start_period: 40s
magento:
image: shinsenter/magento:php8.3-nginx
ports:
- "127.0.0.1:18096:80"
environment:
DISABLE_AUTORUN_CREATING_PROJECT: "1"
DISABLE_AUTORUN_COMPOSER_INSTALL: "1"
DISABLE_AUTORUN_GENERATING_INDEX: "1"
PHP_MEMORY_LIMIT: 2G
PHP_MAX_EXECUTION_TIME: "600"
PHP_DISPLAY_ERRORS: "1"
DEBUG: "1"
volumes:
- magento_html:/var/www/html
- ./setup-magento.sh:/startup/50-setup-magento:ro
depends_on:
mysql:
condition: service_healthy
opensearch:
condition: service_healthy
volumes:
magento_html:Instant Purchase needs a vault token, default addresses, and a shipping rate. The lab module Lab_InstantVault is a no-op payment gateway so you do not need a real PSP. It is not the bug. Seed plants product SKU ip-custom-price at 99.99, name Lab Instant Purchase GHSA-MAGENTO-IP-CUSTOM-PRICE, a customer, and token hash lab_ip_hash_1.
git clone https://github.com/abraxas/magento-instant-purchase-custom-price
cd magento-instant-purchase-custom-price/lab
./run.shGraphQL can take a few minutes. run.sh waits, seeds, then runs the client. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:18096.
What the tree actually registers
PlaceOrder on 2.4.8-p5:
private static $knownRequestParams = [
'form_key',
'product',
'instant_purchase_payment_token',
'instant_purchase_shipping_address',
'instant_purchase_billing_address',
];
private function getRequestUnknownParams(RequestInterface $request): array
{
$requestParams = $request->getParams();
$unknownParams = [];
foreach ($requestParams as $param => $value) {
if (!isset(self::$knownRequestParams[$param])) {
$unknownParams[$param] = $value;
}
}
return $unknownParams;
}$knownRequestParams is [0 => 'form_key', 1 => 'product', ...]. isset($knownRequestParams['custom_price']) is false. isset($knownRequestParams['form_key']) is also false. The "filter" copies everything, including the fields it just finished handling by name. Those extras become the $productRequest passed to placeOrder().
Checkout's frontend DI, same tag:
<type name="Magento\Checkout\Model\Cart\RequestInfoFilter">
<arguments>
<argument name="filterList" xsi:type="array">
<item name="form_key" xsi:type="string">form_key</item>
<item name="custom_price" xsi:type="string">custom_price</item>
</argument>
</arguments>
</type>That filter is why a guest cart with custom_price does not underpay. Instant Purchase never runs it.
Then Processor::prepare:
$customPrice = $request->getCustomPrice();
if (!empty($customPrice) && !$candidate->getParentProductId()) {
$item->setCustomPrice($customPrice);
$item->setOriginalCustomPrice($customPrice);
}Admin quote editing is supposed to set custom price. Instant Purchase is a storefront controller. The buyRequest still has the field, so the processor treats a customer POST like an admin edit.
No public patch in this tree. Until Adobe ships one: disable Instant Purchase, or wrap getRequestUnknownParams so it only copies known product options, or run the same RequestInfoFilter Instant Purchase skipped.
The 200 that charged 99.99
The first client I pointed at this was polite. It POSTed custom_price at /checkout/cart/add. Filter stripped it. Catalog price. It POSTed Instant Purchase as a guest. No vault, no addresses, generic error JSON.
A few other ways to lose without learning anything:
- Missing
form_key. CSRF validator. - No payment token / address ids. Instant Purchase
available:false. - Looking at
grand_totalonly. Shipping is still 5. The oracle is item unit price 0.01 vs original 99.99. - A reverse shell. Theatre. The witness is the order JSON.
- Unauthenticated. Session cookie plus vault token.
The tell is small JSON {"response":"Your order number is: 000000004."} then admin/REST order lookup with base_subtotal: 0.01.
What I actually did
Treat the on-disk product as the spec. I read getRequestUnknownParams, then the Checkout filter, then setCustomPrice. Proof of concept: magento-instant-purchase-custom-price-Abraxas-Labs.py.
Login, section, placeOrder, lookup. GraphQL catalog 99.99. Customer login. customer/section/load for Instant Purchase token and addresses. POST placeOrder with custom_price=0.01. REST order item unit_price=0.01 original_price=99.99. I am not reprinting session cookies.
Last lab run, trimmed:
catalog_price=99.99 name='Lab Instant Purchase GHSA-MAGENTO-IP-CUSTOM-PRICE'
placeOrder status=200 body='{"response":"Your order number is: 000000004."}'
oracle sku=ip-custom-price unit_price=0.01 original_price=99.99 catalog=99.99 underpay=0.01
SUCCESS Magento Instant Purchase custom_price underpayThe client that produced it is on GitHub.
Wrong turns already recorded: missing form_key (CSRF validator); no payment token / address ids (available:false); looking at grand_total only (shipping is still 5 - the oracle is item unit price 0.01 vs original 99.99); a reverse shell. Theatre. Unauthenticated is not this bug.
What this is not
It is not "Magento lets anyone set custom_price." The cart path strips it. Instant Purchase forgot. It is not unauthenticated. It is not RCE. A logged-in customer with Instant Purchase available pays pennies for a catalog SKU.
The fix
Treat $knownRequestParams as a key set, and strip custom_price on Instant Purchase the way add-to-cart already does. Re-run the loopback client against a patched build: the unit price must stay 99.99.
I am not going to print a storefront POST you can paste at someone else's Instant Purchase button. The list-vs-assoc isset is the useful part. If you own the box, run the proof of concept against loopback.
Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like Instant Purchase was just a button.
References
- Proof of concept: abraxas/magento-instant-purchase-custom-price · magento-instant-purchase-custom-price-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · InstantVault - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-472 · CWE-639
- 2.4.8-p5:
PlaceOrder.php·QuoteFilling.php·Processor.php· CheckoutRequestInfoFilter - Product: Magento Open Source · Instant Purchase