Files
Gnuboard7/plugins/_bundled/sirsoft-pay_nhnkcp/tests/scenarios/security-callback-defense.yaml
T
HeuJung 21fc114f37 fix(core,extensions): 보안 결함 3건과 이중저장소·레이아웃 중복키 결함군 폐쇄
KISA 제보 3건(KVE-2026-2010/2011/2018)과 그 동일 계열 형제 결함을 전수 조치하고,
그 과정에서 드러난 두 결함군을 함께 닫는다.

- 검증 시점과 연결 시점이 host 를 다르게 읽던 SSRF 통로를 정규화 SSoT 한 곳으로 모았다
- 세션을 여는 지점(2FA 완료·토큰 재발급)이 잠금 검사를 거치지 않아 계정 잠금이 우회됐다
- 인증도 서명도 없는 브라우저 리턴 콜백이 주문 상태를 바꾸던 통로를 4 PG 전부에서 닫고,
 소유권을 대조하는 close-report 를 토스에도 신설했다. 그 결과 정리 주체를 잃는
 결제창 미완료 주문은 만료 자동취소가 거둔다
- 저장소 A(_local)에만 쓰는 경로가 B 의 값을 조용히 덮던 회귀를 정본 writer 로 닫았다
 (engine-v1.63.5). 한 방향만 보던 정적 검사에 반대 방향 축과 양방향 계약 테스트를 더했다
- 레이아웃 JSON 의 같은 객체 중복 키가 앞선 선언을 오류 없이 삼키던 결함군을 닫았다
2026-09-02 17:36:11 +09:00

155 lines
10 KiB
YAML

# audit:allow test-scenario-coverage reason: Phase 2 SSoT — 본 매니페스트는 NHN KCP 콜백 보안 매트릭스의 명세 문서. CLI sanitization 단위 테스트가 핵심 경로를 커버하며, replay/net-cancel 의 cross product 전체 자동 검증은 Phase 3 (침투 PoC) 에서 처리.
feature: 콜백 보안 방어 (replay attack / CLI command injection / post-approve net cancel / 비인증 브라우저 콜백의 주문 변조)
description: |
NHN KCP 결제 콜백 처리 경로의 3가지 핵심 보안 위협에 대한 방어 매트릭스.
방어 대상 위협:
1. Replay attack — PG 거래 ID (tno) 로 콜백 재전송 시 두 번 결제 처리되어
알림/마일리지/재고 중복. PreventsReplayCallback trait + DB unique index
(ecommerce module beta.5) 로 이중 방어.
2. CLI command injection — NHN KCP 가 제공하는 결제 모듈 CLI 바이너리 호출 시
MID/금액/거래번호 등을 인수로 전달. 메타문자/제어문자가 포함되면 셸 인젝션
위험. assertSafeCliValue() allowlist + escapeshellarg() 이중 방어.
3. Post-approve 실패 시 net cancel 누락 — CLI 가 PG 승인 성공 응답을 반환했으나
이후 도메인 처리 (금액 검증, 주문 상태 전이) 가 실패하면 PG 측은 승인 상태이지만
G7 주문은 미생성. autoCancelIfApproved() 헬퍼로 PG 승인 자동 취소.
axes:
context: [paymentCallback_card, paymentCallback_vbank, mobileApproval]
threat: [replay, cli_injection, post_approve_failure, forged_browser_callback]
callback_state: [first_arrival, second_arrival_same_tno, cli_arg_normal, cli_arg_unsafe, domain_succeeds, domain_fails, approval_not_yet_attempted, approval_failed, approval_succeeded]
caller: [pg_server_verified, unauthenticated_browser, ownership_verified_buyer]
exclusions:
- { threat: replay, callback_state: cli_arg_normal, reason: "replay 는 CLI 단계 이전 분기" }
- { threat: replay, callback_state: cli_arg_unsafe, reason: "replay 는 CLI 단계 이전 분기" }
- { threat: replay, callback_state: domain_succeeds, reason: "replay 는 domain 처리 이전 단계" }
- { threat: replay, callback_state: domain_fails, reason: "replay 는 domain 처리 이전 단계" }
- { threat: cli_injection, callback_state: first_arrival, reason: "CLI 단계는 도착 차수 무관" }
- { threat: cli_injection, callback_state: second_arrival_same_tno, reason: "CLI 단계는 도착 차수 무관" }
- { threat: cli_injection, callback_state: domain_succeeds, reason: "CLI 인젝션은 CLI 실행 단계 분기" }
- { threat: cli_injection, callback_state: domain_fails, reason: "CLI 인젝션은 CLI 실행 단계 분기" }
- { threat: cli_injection, context: mobileApproval, reason: "모바일 승인은 별도 callback flow 로 CLI 직접 호출 아님" }
- { threat: post_approve_failure, callback_state: first_arrival, reason: "post-approve 실패는 도메인 결과 분기" }
- { threat: post_approve_failure, callback_state: second_arrival_same_tno, reason: "post-approve 실패는 도메인 결과 분기" }
- { threat: post_approve_failure, callback_state: cli_arg_normal, reason: "post-approve 실패는 CLI 와 무관" }
- { threat: post_approve_failure, callback_state: cli_arg_unsafe, reason: "post-approve 실패는 CLI 와 무관" }
- { threat: post_approve_failure, context: paymentCallback_vbank, reason: "vbank 통보는 PG 측 비동기 입금 — post-approve 단계 없음" }
- { threat: forged_browser_callback, callback_state: first_arrival, reason: "위조 콜백은 도착 차수가 아니라 승인 성립 여부로 갈린다" }
- { threat: forged_browser_callback, callback_state: second_arrival_same_tno, reason: "동일" }
- { threat: forged_browser_callback, callback_state: cli_arg_normal, reason: "위조 콜백 축은 CLI 인수 정제 단계가 아니다" }
- { threat: forged_browser_callback, callback_state: cli_arg_unsafe, reason: "동일" }
- { threat: forged_browser_callback, callback_state: domain_succeeds, reason: "도메인 처리 이전에 승인 성립 여부로 판정한다" }
- { threat: forged_browser_callback, callback_state: domain_fails, reason: "동일" }
- { threat: replay, callback_state: approval_not_yet_attempted, reason: "승인 성립 축은 위조 콜백 전용 — replay 는 도착 차수 분기" }
- { threat: replay, callback_state: approval_failed, reason: "동일" }
- { threat: replay, callback_state: approval_succeeded, reason: "동일" }
- { threat: cli_injection, callback_state: approval_not_yet_attempted, reason: "CLI 인젝션은 인수 정제 단계 분기" }
- { threat: cli_injection, callback_state: approval_failed, reason: "동일" }
- { threat: cli_injection, callback_state: approval_succeeded, reason: "동일" }
- { threat: post_approve_failure, callback_state: approval_not_yet_attempted, reason: "정의상 승인 이후 분기" }
- { threat: post_approve_failure, callback_state: approval_failed, reason: "동일" }
- { threat: post_approve_failure, callback_state: approval_succeeded, reason: "post-approve 실패는 domain_fails 로 표현한다" }
- { caller: ownership_verified_buyer, threat: replay, reason: "close-report 는 결제창 콜백이 아니라 별도 소유권 검증 엔드포인트" }
- { caller: ownership_verified_buyer, threat: cli_injection, reason: "동일 — CLI 를 호출하지 않는다" }
- { caller: ownership_verified_buyer, threat: post_approve_failure, reason: "동일 — 승인 단계가 없다" }
- { caller: pg_server_verified, threat: forged_browser_callback, reason: "위조 축의 전제가 비인증 브라우저 입력이다" }
- { caller: unauthenticated_browser, threat: cli_injection, reason: "CLI 인수는 상점 설정·주문 저장값에서 오고 브라우저 입력이 아니다" }
effects:
# Replay defense — PreventsReplayCallback trait
- replay_detected_on_second_arrival
- replay_response_is_idempotent_ok
- replay_no_double_completion
- replay_logged_with_context
- replay_first_arrival_proceeds_normally
- replay_db_unique_blocks_concurrent_insert
# CLI sanitization — assertSafeCliValue + escapeshellarg
- cli_arg_rejects_double_quote
- cli_arg_rejects_backtick
- cli_arg_rejects_dollar_paren
- cli_arg_rejects_pipe
- cli_arg_rejects_redirect
- cli_arg_rejects_semicolon
- cli_arg_rejects_null_byte
- cli_arg_rejects_control_char_low
- cli_arg_rejects_control_char_high
- cli_arg_accepts_alnum_only
- cli_arg_accepts_normal_korean_label
- cli_arg_throws_runtime_exception_on_unsafe
- cli_exec_uses_escapeshellarg
- cli_no_shell_executed_on_unsafe_input
# 비인증 브라우저 콜백의 주문 변조 차단 (KVE-2026-2018 + 형제)
- unauthenticated_callback_cannot_cancel_another_users_order
- browser_result_code_alone_never_transitions_order_state
- pre_approval_failure_sink_does_not_call_fail_payment
- pre_approval_amount_mismatch_does_not_mutate_the_order
- post_approval_failure_still_records_failure_with_tno
- payment_method_branch_is_decided_by_stored_order_value_only
- request_pay_method_cannot_route_a_card_order_into_vbank_branch
- forged_vbank_callback_cannot_cancel_a_card_order
- forged_vbank_callback_cannot_write_an_account_number_to_the_order
- mobile_vbank_issuance_failure_does_not_mutate_the_order
- pc_vbank_cli_exception_does_not_mutate_the_order
- vbank_save_failure_mutates_only_on_pc_server_confirmed_issuance
- cancelled_order_is_restored_only_after_approval_or_issuance_is_confirmed
- legitimate_failure_is_recorded_by_ownership_verified_close_report
- close_report_rejects_buyer_mismatch
- normal_approval_still_completes_the_order
# Post-approve net cancel
- post_approve_failure_triggers_auto_cancel
- net_cancel_sends_pg_cancel_api_call
- net_cancel_passes_approved_tno
- net_cancel_passes_full_amount
test_files:
- plugins/_bundled/sirsoft-pay_nhnkcp/tests/Feature/Controllers/PaymentCallbackControllerTest.php
manual_verification:
- description: "위조 콜백 PoC — 남의 주문번호로 결제 실패 유도"
steps:
- "피해자 계정으로 결제대기 주문 1건 생성 (pending_order / ready)"
- "로그아웃 상태에서 authCallback 에 피해 ordr_idxx + res_cd=0000 + 위조 암호문 POST"
- "302 리다이렉트만 발생하고 주문 상태·결제 상태가 불변인지 재조회로 확인"
- "요청에 use_pay_method=VCNT 를 섞어도 카드 주문이 가상계좌 분기로 흐르지 않는지 확인"
- "위조 bankname/account 가 주문에 기록되지 않는지 확인"
- description: "Replay PoC — paymentCallback 두 번 전송"
steps:
- "정상 카드 콜백 1차 전송 → 200 OK, payment_status=paid"
- "동일 페이로드 (tno 동일) 2차 전송 → 200 OK (멱등 응답)"
- "Log 에 'NHN KCP: replay detected' 확인"
- "DB: g7_ecommerce_order_payments 에 동일 tno row 1건만 존재"
- description: "CLI injection PoC — MID 위장"
steps:
- "관리자 PG 설정에서 site_cd (MID) 를 `SR_shop_001\"; id > /tmp/g7_kcp_poc; #` 로 설정 시도"
- "또는 KCP CLI 호출 시 amt 인수에 `1000;id>/tmp/g7_kcp_poc;echo` 전달"
- "assertSafeCliValue 가 RuntimeException throw → CLI 미실행"
- "/tmp/g7_kcp_poc 미생성 확인"
- description: "Post-approve 실패 net cancel PoC"
steps:
- "정상 carrier card 결제로 paymentCallback 진입"
- "PG CLI 응답: 승인 성공 (res_cd=0000), tno 발급됨"
- "이후 OrderProcessingService 에서 PaymentAmountMismatchException 발생 시뮬"
- "autoCancelIfApproved 가 NhnKcpApiService::cancelPayment(tno, amount) 호출"
- "PG 측 거래 상태: 승인 후 취소 (정산 영향 없음)"
- "G7 주문: 미생성 상태 유지"
notes: |
본 매니페스트는 Phase 2 보안 강화 작업의 SSoT.
관련 코드:
- src/Concerns/PreventsReplayCallback.php (replay 가드)
- src/Controllers/PaymentCallbackController.php (paymentCallback + autoCancelIfApproved)
- src/Services/NhnKcpApiService.php (assertSafeCliValue + escapeshellarg)
- src/Exceptions/NhnKcpApiException.php (도메인 예외)
관련 modules (참고 — plugin scope 외):
- modules/_bundled/sirsoft-ecommerce/database/migrations/2026_05_16_000001_add_unique_index_to_transaction_id_on_ecommerce_order_payments_table.php
Phase 3 침투 PoC 에서 본 manual_verification 항목을 자동화 PoC 스크립트로 변환 예정.