Research/magento-instant-purchase-custom-price
0-dayNo CVEHighPublic

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.

Dockerfile
# Loopback lab image pin for magento-instant-purchase-custom-price. Full stack: docker-compose.yml
FROM opensearchproject/opensearch:2.11.1
YAML
name: 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.

Plain text
git clone https://github.com/abraxas/magento-instant-purchase-custom-price
cd magento-instant-purchase-custom-price/lab
./run.sh

GraphQL 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:

PHP
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:

XML
<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:

PHP
$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_total only. 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:

Plain text
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 underpay

The 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