신한은행 대출모집인 서비스에서 실제로 뚫린 패턴 — 본인확인 절차를 우회해 타인의 고객 정보를 조회하는 IDOR(권한 우회) 취약점을 재현하고, 세션 신원에 조회 권한을 강제로 묶는 방어 코드를 구현했습니다. 지금은 전부 무료로 공개합니다.
진짜 은행 시스템 운영자용이 아닙니다 — 백엔드 개발자가 IDOR(Insecure Direct Object Reference)가 실제로 뭘 의미하는지, 왜 OWASP가 매번 최상위권에 올려두는지 코드로 직접 확인하기 위한 학습 자료입니다.
클라이언트가 보낸 고객 ID를 그대로 신뢰하는 취약한 대출조회 API.
// 취약한 대출조회 API — 신한은행 사고(2026-10-01) 모델링:
// 대출모집인 서비스의 본인확인 절차가 우회되어 약 2만5000명의
// 개인정보가 유출됨. 결함: 엔드포인트가 세션이 아니라
// 클라이언트가 보낸 customerId를 그대로 신뢰함.
class VulnerableLoanLookup {
constructor(customerDb) {
this.db = customerDb; // { customerId: {name, rrn, ci, phone, income, loanLimit} }
}
// session은 로그인한 사람의 실제 신원.
// requestedId는 클라이언트가 보낸 파라미터 — 예: ?customerId=CUST-1042
// 버그: requestedId를 그대로 쓰고, session과 비교하지 않음.
lookup(session, requestedId) {
const record = this.db[requestedId];
if (!record) return { status: 'not_found' };
return { status: 'ok', record }; // 로그인한 아무에게나 타인 정보 유출
}
}
module.exports = { VulnerableLoanLookup };
모든 조회를 호출자 본인의 세션 신원에 강제로 묶는 방어 코드.
// 해결책: 모든 조회를 호출자 본인의 세션 신원에 묶는다.
// 클라이언트가 뭘 보내든, 다른 고객 id를 요청하면 거부하고
// 로그로 남긴다 — 신한은행식 본인확인 우회를 막는 방법.
class SessionBoundLoanLookup {
constructor(customerDb) {
this.db = customerDb;
this.deniedAttempts = [];
}
lookup(session, requestedId) {
if (!session || !session.customerId) {
return { status: 'unauthenticated' };
}
if (requestedId !== session.customerId) {
this.deniedAttempts.push({
sessionOwner: session.customerId,
attemptedId: requestedId,
at: Date.now(),
});
return { status: 'denied', reason: 'session_id_mismatch' };
}
const record = this.db[requestedId];
if (!record) return { status: 'not_found' };
return { status: 'ok', record };
}
}
module.exports = { SessionBoundLoanLookup };
별도의 격리 샌드박스(ARTEX로 실제 공격 검증 완료)에서 돌리고 있는 2개 확장 레이어 요약 — 전체 구현은 proxy.js + isolation-forest.js.
// 확장 레이어 — session-bound-guard.js로는 못 잡는 2가지 회피를 막음
// (1) 출처(IP) 교차신원 상관: "차단되면 다른 신원으로 재로그인"을 무력화
// (2) Isolation Forest 이상탐지: 위반 0건인 비정상 패턴(자기계정 폭주)도 잡음
const ipViolatingOwners = new Map(); // ip -> Set<ownerCustomerId>
const ipBlocked = new Set();
function onAuthorizationViolation(owner, ip) {
const owners = ipViolatingOwners.get(ip) || new Set();
owners.add(owner);
ipViolatingOwners.set(ip, owners);
// 같은 출처가 서로 다른 신원 2개 이상에서 위반을 내면 그 출처 자체를 차단
if (owners.size >= 2) ipBlocked.add(ip);
}
function onEveryRequest(owner, ip, requestsPerMinute, distinctIdsAttempted) {
if (ipBlocked.has(ip)) {
return { status: 'blocked', reason: 'source_blocked_cross_identity_pattern' };
}
// 권한 위반이 전혀 없어도 "평소와 다른 모양"의 요청은 점수로 잡음
// (모델 전체는 isolation-forest.js — 시드 고정 PRNG로 재시작해도 동일한 점수)
const anomalyScore = anomalyModel.score([requestsPerMinute, distinctIdsAttempted]);
if (anomalyScore >= 0.70) {
// 기본값은 로깅만 — 실제 차단은 운영자가 ML_BLOCK=true로 명시해야 켜짐
logFlag({ owner, anomalyScore });
}
return { status: 'ok' };
}
이 상품에 대한 피드백이나 질문이 있으면 알려주세요. 회신을 원하시면 이메일을 남겨주세요(선택).