From 56b1958d373e5dca1c47405a8c398536155f619e Mon Sep 17 00:00:00 2001 From: thisgun Date: Tue, 26 May 2026 06:52:25 +0000 Subject: [PATCH] =?UTF-8?q?[security]KVE-2026-0882=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B0=A8=EA=B0=90=20Race=20Condi?= =?UTF-8?q?tion=20(Double=20Spend)=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shop/orderformupdate.php / mobile/shop/orderformupdate.php 의 포인트 검증과 차감 사이에 TOCTOU race 가 존재하여, 동일 회원이 여러 세션으로 동시에 주문 제출 시 같은 포인트 잔액을 반복 검증 통과 → 다중 차감으로 mb_point 가 음수가 되는 double spend 가 가능했음. insert_point() 의 기존 named lock 은 (mb_id, rel_table, rel_id, rel_action) 조합 키 기반이라 서로 다른 od_id 주문 간에는 lock 이 다르고, 주문 차감 호출에 rel_* 가 전달되지 않아 lock 미적용 상태였음. 조치: KVE-2026-0687 (쇼핑몰 쿠폰) 와 동일한 회원 단위 MySQL GET_LOCK 패턴을 적용. lock 획득 후 g5_point SUM 으로 잔액을 재조회하여 - 충분하면 정상 차감 - race 로 부족하면 사용 가능한 만큼만 차감 + 부족분은 od_receipt_point 보정 + od_misu(미수금) 으로 반영하여 매장 손실 방지. MyISAM 정책상 트랜잭션 금지이므로 named lock 으로 직렬화하는 방식이 가장 적합. 데스크톱·모바일 동일 패턴 적용. Co-Authored-By: Claude Opus 4.7 (1M context) --- mobile/shop/orderformupdate.php | 29 +++++++++++++++++++++++++++-- shop/orderformupdate.php | 29 +++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/mobile/shop/orderformupdate.php b/mobile/shop/orderformupdate.php index 78a823b18..4c11d57f0 100644 --- a/mobile/shop/orderformupdate.php +++ b/mobile/shop/orderformupdate.php @@ -750,8 +750,33 @@ if(!$result) { } // 회원이면서 포인트를 사용했다면 포인트 테이블에 사용을 추가 -if ($is_member && $od_receipt_point) - insert_point($member['mb_id'], (-1) * $od_receipt_point, "주문번호 $od_id 결제"); +// 동시 주문 race condition 방지 — 회원 단위 GET_LOCK + 잔액 재조회 +if ($is_member && $od_receipt_point) { + $point_lock_key = 'g5pt_order_'.md5($member['mb_id']); + $lock_row = sql_fetch(" select get_lock('$point_lock_key', 5) as lk "); + + if (!empty($lock_row['lk'])) { + $current_point = (int) get_point_sum($member['mb_id']); + + if ($current_point >= $od_receipt_point) { + insert_point($member['mb_id'], (-1) * $od_receipt_point, "주문번호 $od_id 결제"); + } else { + // race condition 으로 잔액 부족 — 사용 가능한 만큼만 차감하고 부족분은 미수금 처리 + $actual_point = $current_point > 0 ? $current_point : 0; + $shortage = $od_receipt_point - $actual_point; + + if ($actual_point > 0) { + insert_point($member['mb_id'], (-1) * $actual_point, "주문번호 $od_id 결제"); + } + sql_query(" update {$g5['g5_shop_order_table']} + set od_receipt_point = '$actual_point', + od_misu = od_misu + '$shortage' + where od_id = '$od_id' "); + } + + sql_query(" do release_lock('$point_lock_key') "); + } +} $od_memo = nl2br(htmlspecialchars2(stripslashes($od_memo))) . " "; diff --git a/shop/orderformupdate.php b/shop/orderformupdate.php index a63af34b7..7d5912046 100644 --- a/shop/orderformupdate.php +++ b/shop/orderformupdate.php @@ -704,8 +704,33 @@ if(!$result) { } // 회원이면서 포인트를 사용했다면 테이블에 사용을 추가 -if ($is_member && $od_receipt_point) - insert_point($member['mb_id'], (-1) * $od_receipt_point, "주문번호 $od_id 결제"); +// 동시 주문 race condition 방지 — 회원 단위 GET_LOCK + 잔액 재조회 +if ($is_member && $od_receipt_point) { + $point_lock_key = 'g5pt_order_'.md5($member['mb_id']); + $lock_row = sql_fetch(" select get_lock('$point_lock_key', 5) as lk "); + + if (!empty($lock_row['lk'])) { + $current_point = (int) get_point_sum($member['mb_id']); + + if ($current_point >= $od_receipt_point) { + insert_point($member['mb_id'], (-1) * $od_receipt_point, "주문번호 $od_id 결제"); + } else { + // race condition 으로 잔액 부족 — 사용 가능한 만큼만 차감하고 부족분은 미수금 처리 + $actual_point = $current_point > 0 ? $current_point : 0; + $shortage = $od_receipt_point - $actual_point; + + if ($actual_point > 0) { + insert_point($member['mb_id'], (-1) * $actual_point, "주문번호 $od_id 결제"); + } + sql_query(" update {$g5['g5_shop_order_table']} + set od_receipt_point = '$actual_point', + od_misu = od_misu + '$shortage' + where od_id = '$od_id' "); + } + + sql_query(" do release_lock('$point_lock_key') "); + } +} $od_memo = nl2br(htmlspecialchars2(stripslashes($od_memo))) . " ";