Files
Gnuboard5/lib/kcp_notification.lib.php
T
whitedot 7a80ae913d security: KVE-2026-2346 KCP 통보 검증과 입금·망취소 재처리 보강
테스트·운영 발신지와 결제 당시 상점·거래·계좌·금액을 대조하여 위조 입금 처리를 차단한다. 개인결제와 연결 주문의 중복 가산을 막고 통보 이력과 목표값으로 MyISAM/InnoDB 중간 실패를 재처리한다.

DB 마이그레이션과 기존 거래 적용·복구 안내를 추가했다. 격리 HTTP/MySQL 검사 420개, PHP 문법 검사와 설치 스키마 대조가 통과했다. 실제 KCP 연동 확인과 배포·KISA 회신은 별도 진행이 필요하다.
2026-09-10 05:17:59 +00:00

264 lines
13 KiB
PHP

<?php
if (!defined('_GNUBOARD_')) exit;
// KVE-2026-2346: 테스트 통보도 발신지와 저장된 거래를 모두 검증한다.
function kcp_noti_request($post, $server, $default)
{
if (!isset($server['REQUEST_METHOD']) || $server['REQUEST_METHOD'] !== 'POST') return false;
// https://developer.kcp.co.kr/guide/webhook (2026-09-10 확인)
$ips = !empty($default['de_card_test'])
? array('210.122.176.144')
: array('103.215.144.173', '103.215.144.174', '210.122.72.173');
if (!isset($server['REMOTE_ADDR']) || !in_array($server['REMOTE_ADDR'], $ips, true)) return false;
// 전달 헤더나 로그인 세션은 PG 발신 인증에 사용하지 않는다.
$patterns = array(
'site_cd' => '/\A[A-Z0-9]{5}\z/', 'tno' => '/\A[0-9]{14}\z/',
'order_no' => '/\A[1-9][0-9]{0,19}\z/', 'tx_cd' => '/\ATX0[0-7]\z/',
'tx_tm' => '/\A[0-9]{14}\z/'
);
if (isset($post['tx_cd']) && $post['tx_cd'] === 'TX00') {
$patterns += array('ipgm_mnyx' => '/\A[0-9]{1,12}\z/',
'account' => '/\A[T]?[0-9]{1,19}\z/', 'noti_id' => '/\A[0-9]{20}\z/',
'op_cd' => '/\A(?:50|13)\z/');
}
$data = array();
foreach ($patterns as $name => $pattern) {
if (!isset($post[$name]) || !is_string($post[$name]) || !preg_match($pattern, $post[$name])) return false;
$data[$name] = $post[$name];
}
$time = $data['tx_tm'];
if ((int)substr($time, 0, 4) < 1000 || !checkdate((int)substr($time, 4, 2), (int)substr($time, 6, 2), (int)substr($time, 0, 4))
|| substr($time, 8, 2) > 23 || substr($time, 10, 2) > 59 || substr($time, 12, 2) > 59) return false;
$test = in_array($data['site_cd'], array('T0000', 'T0007'), true);
if ($test !== !empty($default['de_card_test'])) return false;
if (!$test && substr($data['site_cd'], 0, 2) !== 'SR') return false;
if ($data['tx_cd'] === 'TX00') {
// 주문 금액 컬럼은 signed INT이다. 변환 전에 범위를 검사한다.
if ((float)$data['ipgm_mnyx'] < 1 || (float)$data['ipgm_mnyx'] > 2147483647) return false;
$data['ipgm_mnyx'] = (string)(int)$data['ipgm_mnyx'];
}
return $data;
}
function kcp_noti_query($sql)
{
$result = sql_query($sql, false);
if (!$result) throw new Exception('KCP notification database failure');
return $result;
}
function kcp_noti_rows($sql)
{
$result = kcp_noti_query($sql);
$rows = array();
while ($row = sql_fetch_array($result)) $rows[] = $row;
return $rows;
}
function kcp_noti_quote($value)
{
return "'".sql_escape_string((string)$value)."'";
}
function kcp_noti_tables()
{
global $g5;
return array('order' => $g5['g5_shop_order_table'], 'personal' => $g5['g5_shop_personalpay_table'],
'cart' => $g5['g5_shop_cart_table'],
'event' => isset($g5['g5_shop_kcp_noti_table']) ? $g5['g5_shop_kcp_noti_table'] : G5_SHOP_TABLE_PREFIX.'kcp_noti');
}
function kcp_noti_process($data)
{
$tables = kcp_noti_tables();
$locked = false;
try {
// 기본 설치의 MyISAM에서도 다른 통보 및 관리자 UPDATE와 경쟁하지 않게 한다.
// DDL은 관리자 DB 업그레이드에서만 실행하며 스키마/권한이 없으면 실패 응답한다.
$locks = array();
foreach ($tables as $table) $locks[] = '`'.$table.'` WRITE';
kcp_noti_query('LOCK TABLES '.implode(', ', $locks));
$locked = true;
$success = kcp_noti_locked($data, $tables);
} catch (Exception $e) {
error_log('KCP notification: processing failed; reconciliation/retry required');
$success = false;
} catch (Throwable $e) {
error_log('KCP notification: processing failed; reconciliation/retry required');
$success = false;
}
if ($locked) {
try {
kcp_noti_query('UNLOCK TABLES');
} catch (Exception $e) {
$success = false;
} catch (Throwable $e) {
$success = false;
}
}
return $success;
}
// 변경 전/후 값을 함께 기록한다. 재시도는 가산 SQL 대신 목표값을 대입한다.
function kcp_noti_step($table, $id_field, $row, $changes, $guards)
{
$before = array();
foreach (array_unique(array_merge(array($id_field), array_keys($changes), $guards)) as $field) {
if (!array_key_exists($field, $row)) throw new Exception('Missing notification schema');
$before[$field] = (string)$row[$field];
}
$after = $before;
foreach ($changes as $field => $value) $after[$field] = (string)$value;
return array('table' => $table, 'id_field' => $id_field, 'id' => (string)$row[$id_field],
'before' => $before, 'after' => $after);
}
function kcp_noti_matches($row, $expected)
{
foreach ($expected as $field => $value) {
if (!array_key_exists($field, $row) || (string)$row[$field] !== $value) return false;
}
return true;
}
function kcp_noti_apply($plan, $tables)
{
// 먼저 모든 행을 검사한다. 실패 후 외부에서 수정한 행을 과거 값으로 덮어쓰지 않는다.
foreach ($plan as $step) {
$rows = kcp_noti_rows('SELECT * FROM `'.$tables[$step['table']].'` WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id']));
if (count($rows) !== 1 || (!kcp_noti_matches($rows[0], $step['before']) && !kcp_noti_matches($rows[0], $step['after']))) return false;
}
foreach ($plan as $step) {
$sets = array();
foreach ($step['after'] as $field => $value) {
if ($value !== $step['before'][$field]) $sets[] = '`'.$field.'` = '.kcp_noti_quote($value);
}
if ($sets) kcp_noti_query('UPDATE `'.$tables[$step['table']].'` SET '.implode(', ', $sets)
.' WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id']));
}
foreach ($plan as $step) {
$rows = kcp_noti_rows('SELECT * FROM `'.$tables[$step['table']].'` WHERE `'.$step['id_field'].'` = '.kcp_noti_quote($step['id']));
if (count($rows) !== 1 || !kcp_noti_matches($rows[0], $step['after'])) return false;
}
return true;
}
function kcp_noti_locked($data, $tables)
{
$order_no = kcp_noti_quote($data['order_no']);
$personal = kcp_noti_rows("SELECT * FROM `{$tables['personal']}` WHERE pp_id = $order_no");
$orders = kcp_noti_rows("SELECT * FROM `{$tables['order']}` WHERE od_id = $order_no");
// 동일 번호가 두 종류에 존재하면 임의로 한쪽을 선택하지 않는다.
if ($personal && $orders) return false;
$pp = $personal ? $personal[0] : null;
if ($pp) {
$prefix = 'pp'; $payment = $pp;
$orders = $pp['od_id'] ? kcp_noti_rows("SELECT * FROM `{$tables['order']}` WHERE od_id = ".kcp_noti_quote($pp['od_id'])) : array();
if ($pp['od_id'] && !$orders) return false;
} else {
if (!$orders) return false;
$prefix = 'od'; $payment = $orders[0];
}
$od = $orders ? $orders[0] : null;
if ($payment[$prefix.'_pg'] !== 'kcp' || $payment[$prefix.'_tno'] !== $data['tno']
|| empty($payment[$prefix.'_kcp_site_cd']) || $payment[$prefix.'_kcp_site_cd'] !== $data['site_cd']) return false;
// 처리하지 않는 기존 에스크로 이벤트는 거래를 확인한 뒤 수신만 확인한다.
if ($data['tx_cd'] !== 'TX00') return true;
if ($payment[$prefix.'_settle_case'] !== '가상계좌') return false;
if ($od && (bool)$od['od_test'] !== in_array($data['site_cd'], array('T0000', 'T0007'), true)) return false;
$account_parts = preg_split('/\s+/', trim($payment[$prefix.'_bank_account']));
if (end($account_parts) !== $data['account']) return false;
$trade = hash('sha256', $data['site_cd'].'|'.$data['tno'].'|'.$data['order_no']);
$key = hash('sha256', $trade.'|'.$data['noti_id'].'|'.$data['op_cd']);
$payload = hash('sha256', $data['ipgm_mnyx'].'|'.$data['account']);
$events = kcp_noti_rows("SELECT * FROM `{$tables['event']}` WHERE kn_trade = '$trade'");
$deposit = null; $cancel = null; $active = false;
$by_id = array();
foreach ($events as $event) {
if ($event['kn_key'] === $key) {
if ($event['kn_payload'] !== $payload) return false;
if ($event['kn_done']) return true;
$plan = json_decode($event['kn_plan'], true);
if (!is_array($plan) || !kcp_noti_apply($plan, $tables)) return false;
kcp_noti_query("UPDATE `{$tables['event']}` SET kn_done = 1 WHERE kn_key = '$key'");
return true;
}
if (!$event['kn_done']) return false;
$by_id[$event['kn_noti_id']][$event['kn_op_cd']] = $event;
if ($event['kn_noti_id'] === $data['noti_id']) {
if ($event['kn_payload'] !== $payload) return false;
if ($event['kn_op_cd'] === '50') $deposit = $event;
else $cancel = $event;
}
}
foreach ($by_id as $pair) {
if (isset($pair['50']) && !isset($pair['13'])) $active = true;
}
// 망취소가 먼저 도착했거나 취소 뒤 입금 통보가 재전송되어도 재입금하지 않는다.
if ($data['op_cd'] === '50' && $cancel) return true;
$canceling = $data['op_cd'] === '13';
if (!$canceling && $active) return false;
if ($canceling && !$deposit && $active) return false;
if ($pp && !$pp['pp_use']) return false;
$amount = (int)$data['ipgm_mnyx'];
$receipt = (int)$payment[$prefix.'_receipt_price'];
if ($pp && ((int)$pp['pp_price'] !== $amount || (int)$pp['pp_price'] <= 0)) return false;
if (!$canceling && $receipt !== 0) return false;
if ($canceling && $deposit && $receipt < $amount) return false;
if ($canceling && !$deposit && $receipt !== 0) return false;
$od_id = $od ? $od['od_id'] : '0';
if ($od) {
$pending = kcp_noti_rows("SELECT kn_key FROM `{$tables['event']}` WHERE od_id = ".kcp_noti_quote($od_id)." AND kn_done = 0");
if ($pending) return false;
$due = (int)$od['od_cart_price'] + (int)$od['od_send_cost'] + (int)$od['od_send_cost2']
- (int)$od['od_cart_coupon'] - (int)$od['od_coupon'] - (int)$od['od_send_coupon']
- (int)$od['od_cancel_price'] - (int)$od['od_receipt_point'] + (int)$od['od_refund_price'];
$misu = $due - (int)$od['od_receipt_price'];
if ($misu !== (int)$od['od_misu'] || $due <= 0) return false;
if (!$pp && ($due !== $amount || (int)$od['od_cancel_price'] !== 0 || (int)$od['od_refund_price'] !== 0)) return false;
if (!$canceling && ($od['od_status'] !== '주문' || $misu < $amount)) return false;
if ($canceling && !in_array($od['od_status'], array('주문', '입금', '준비', '배송', '완료'), true)) return false;
if ($canceling && $deposit && (int)$od['od_receipt_price'] < $amount) return false;
}
$time = substr($data['tx_tm'], 0, 4).'-'.substr($data['tx_tm'], 4, 2).'-'.substr($data['tx_tm'], 6, 2)
.' '.substr($data['tx_tm'], 8, 2).':'.substr($data['tx_tm'], 10, 2).':'.substr($data['tx_tm'], 12, 2);
$delta = $canceling ? ($deposit ? -$amount : 0) : $amount;
$plan = array();
if ($pp && $delta) {
$plan[] = kcp_noti_step('personal', 'pp_id', $pp,
array('pp_receipt_price' => $receipt + $delta, 'pp_receipt_time' => $time),
array('od_id', 'pp_pg', 'pp_tno', 'pp_kcp_site_cd', 'pp_settle_case', 'pp_price', 'pp_use', 'pp_bank_account'));
}
if ($od && $delta) {
$new_receipt = (int)$od['od_receipt_price'] + $delta;
$new_misu = $due - $new_receipt;
if ($new_receipt < 0 || $new_receipt > 2147483647 || $new_misu > 2147483647) return false;
$status = $od['od_status'];
if (!$canceling && $new_misu === 0) $status = '입금';
if ($canceling && $status === '입금') $status = '주문';
$cart = kcp_noti_rows("SELECT ct_id, od_id, ct_status FROM `{$tables['cart']}` WHERE od_id = ".kcp_noti_quote($od_id));
if (!$cart) return false;
foreach ($cart as $item) {
if (!$canceling && $item['ct_status'] !== '주문') return false;
if ($status !== $od['od_status'] && $item['ct_status'] === $od['od_status']) {
$plan[] = kcp_noti_step('cart', 'ct_id', $item, array('ct_status' => $status), array('od_id'));
}
}
// 마지막에 주문 상태를 변경하여 중간 실패 시 출고 판정 노출을 줄인다.
$plan[] = kcp_noti_step('order', 'od_id', $od,
array('od_receipt_price' => $new_receipt, 'od_receipt_time' => $time, 'od_misu' => $new_misu, 'od_status' => $status),
array('od_pg', 'od_tno', 'od_kcp_site_cd', 'od_settle_case', 'od_test', 'od_cart_price', 'od_send_cost',
'od_send_cost2', 'od_cart_coupon', 'od_coupon', 'od_send_coupon', 'od_cancel_price', 'od_receipt_point', 'od_refund_price'));
}
$json = json_encode($plan);
if ($json === false) return false;
kcp_noti_query("INSERT INTO `{$tables['event']}` SET kn_key = '$key', kn_trade = '$trade', kn_noti_id = ".kcp_noti_quote($data['noti_id'])
.", kn_op_cd = ".kcp_noti_quote($data['op_cd']).", kn_payload = '$payload', od_id = ".kcp_noti_quote($od_id)
.", kn_plan = ".kcp_noti_quote($json).", kn_created_at = ".kcp_noti_quote($time));
if (!kcp_noti_apply($plan, $tables)) return false;
kcp_noti_query("UPDATE `{$tables['event']}` SET kn_done = 1 WHERE kn_key = '$key'");
return true;
}