2013년 해커들은 선불카드 프로세서의 인출한도 검증 로직을 공략해, 잔액 확인과 차감 사이의 틈(TOCTOU 경쟁조건)을 이용해 27개국 ATM에서 동시에 현금을 뽑아 $45M을 탈취했습니다. 같은 구조의 버그를 재현하고, 10개의 동시 인출 요청으로 직접 공격해 원자적 잠금 방어가 이를 막는 걸 검증했습니다.
잔액이나 재고 같은 공유 자원을 확인(check) 후 차감(act)하는 로직을 짜본 적 있는 백엔드 개발자용 학습 자료입니다 — 이 둘 사이의 틈이 바로 취약점입니다.
잔액 확인과 차감 사이에 틈이 있는 취약한 인출 API (TOCTOU).
// 취약한 인출 API — 2013년 선불카드 프로세서 해킹 모델링.
// 잔액을 "확인"하는 것과 "차감"하는 것이 분리된 두 단계라,
// 그 사이의 틈(실제 DB 왕복 지연)에 동시 요청이 끼어들면
// 전부 차감 전의 잔액을 보고 통과해버림.
class RaceConditionWithdrawal {
constructor(account) {
this.account = account; // { balance }
}
async withdraw(amount) {
const current = this.account.balance; // CHECK
await simulatedDbRoundTrip(); // 경쟁 윈도우
if (current < amount) return { status: 'insufficient_funds' };
this.account.balance = current - amount; // ACT — 이미 낡은 값 기준
return { status: 'ok', newBalance: this.account.balance };
}
}
module.exports = { RaceConditionWithdrawal };
해결책: 확인과 차감을 하나의 원자적(잠긴) 연산으로 묶는다.
// 해결책: 확인과 차감 사이에 다른 요청이 끼어들 수 없도록
// 계정 단위 잠금으로 둘을 하나의 원자적 연산으로 묶는다.
// (실전에선 DB 행 잠금이나 원자적 UPDATE...WHERE로 구현)
class AtomicWithdrawalGuard {
constructor(account) {
this.account = account;
this.locked = false;
}
async withdraw(amount) {
while (this.locked) await sleep(5);
this.locked = true;
try {
await simulatedDbRoundTrip(); // 같은 지연이지만 잠금 안쪽
if (this.account.balance < amount) return { status: 'insufficient_funds' };
this.account.balance -= amount;
return { status: 'ok', newBalance: this.account.balance };
} finally {
this.locked = false;
}
}
}
module.exports = { AtomicWithdrawalGuard };
이 상품에 대한 피드백이나 질문이 있으면 알려주세요. 회신을 원하시면 이메일을 남겨주세요(선택).