아는 분이랑 이야기 나누던 중, 아는 분께서 한번 풀어보라고 추천을 주셔서 한번 풀어봤다.
답지에는 핵심 취약점과 추가 취약점이 있는데, 필자는 일단 30분 제한 시간에서 외부의 도움, 개입 없이 4개의 정답 중 2개를 풀었다.
코드를 고치기보다 취약점을 어떻게 찾았는지를 중점적으로 설명한다.
출처 : https://drive.google.com/drive/folders/1-02qvJzB5zMLt3DVCrYSSREkuYCW68Kj
ASE 1차 면접 문제 - Google Drive
이 브라우저 버전은 더 이상 지원되지 않습니다. 지원되는 브라우저로 업그레이드하세요. 닫기
drive.google.com
1. 상황
* 당신은 이커머스 플랫폼의 Application Security Engineer입니다.
* 개발자가 "포인트 결제" 기능의 코드 리뷰를 요청했습니다. 해당 코드를 분석하고 문제를 풀이해주세요.
2. 전제 조건
* 기술 스택 : Next.js 15 + React Query + Typescript
* 기타 라이브러리에 대해서는 알려진 취약점이나 버그가 없다는 전제 조건으로 진행
3. 문제
Q1. 위 코드에서 보안 취약점을 최대한 식별하고, 각각의 공격 시나리오를 구체적으로 작성하시오.
Q2. 식별한 취약점 중 비즈니스에 가장 치명적인 것 1가지를 선택하고, 그 이유와 수정된 코드를 작성하시오
Q3. 이 PR을 승인하기 전, 개발자에게 요청할 아키텍쳐/설계 관점의 보안 개선사항 3가지를 작성하시오.
코드가 길어지니 그냥 서버사이드 코드만 첨부하여 설명한다.
// app/checkout/actions.ts [서버 코드]
'use server';
import { cookies } from 'next/headers';
import { db } from '@/lib/database';
import { verifyToken } from '@/lib/auth';
import { revalidatePath } from 'next/cache';
interface PurchaseRequest {
productId: string;
quantity: number;
usePoints: number;
}
export async function purchaseWithPoints(data: PurchaseRequest) {
const token = cookies().get('auth_token')?.value;
const payload = verifyToken(token as string);
const userId = payload.sub;
// 상품 정보 조회
const product = await db.product.findUnique({
where: { id: data.productId }
});
if (!product) {
return { error: '상품을 찾을 수 없습니다' };
}
const totalPrice = product.price * data.quantity;
// 사용자 포인트 조회
const user = await db.user.findUnique({
where: { id: userId },
select: { points: true, role: true }
});
// 포인트 검증
if (data.usePoints > user!.points) {
return { error: '포인트가 부족합니다' };
}
if (data.usePoints > totalPrice) {
return { error: '사용 포인트가 결제 금액을 초과합니다' };
}
// 결제 처리
const remainingAmount = totalPrice - data.usePoints;
await db.$transaction([
// 포인트 차감
db.user.update({
where: { id: userId },
data: { points: { decrement: data.usePoints } }
}),
// 주문 생성
db.order.create({
data: {
userId,
productId: data.productId,
quantity: data.quantity,
pointsUsed: data.usePoints,
amountCharged: remainingAmount,
status: remainingAmount === 0 ? 'PAID' : 'PENDING_PAYMENT'
}
})
]);
revalidatePath('/mypage/points');
return { success: true, remainingAmount };
}
일단 핵심 취약점 중 하나로 내가 찾은 취약점을 먼저 소개해보겠다.
정식 명칭..? 은 CWE 기준으로 Improper Validation of Specified Quantity in Input, CWE-1284가 아닐까 싶다.
일단 interface PurchaseRequest를 보면
interface PurchaseRequest {
productId: string;
quantity: number;
usePoints: number;
}
이런 파라미터를 받는다.
그러면
if (data.usePoints > user!.points) {
return { error: '포인트가 부족합니다' };
}
if (data.usePoints > totalPrice) {
return { error: '사용 포인트가 결제 금액을 초과합니다' };
}
해당 분기문을 말로 풀어보면?
사용할 포인트가 유저의 현재 포인트보다 많으면 포인트가 부족합니다. 라는 문구가 return된다.
현재 usePoints에 만약 -10000 을 넣는다면 서버 검증을 통과할 수 있다.
항상 유저의 포인트는 양수값일거기 때문에 따라서 usePoints에 음수가 들어오면 usePoints > user.points는
-10000 > 1000 처럼 항상 false가 되어 1차 검증을 우회한다.
그리고 두번째 분기문에서도 -10000 > totalPrice 도 false가 되어 2차 검증도 통과된다.
이후 decrement: -10000이 그대로 Prisma에 전달되어 $transaction에 전달된다.
약간 이런 느낌이다.
우리가 고등학교에서 공부할때 도형의 길이가 음수가 되지 않는것처럼,
이커머스에서 사용하는 포인트도 음수가 될 일이 거의 없다시피 한다.
그런 느낌으로 풀어본다면.. 되지 않을까 생각한다.
그리고 두번째 취약점이다.
이 취약점도 CWE 기준으로 Improper Validation of Specified Quantity in Input, CWE-1284가 아닐까 싶다.
근데 취약점이 일어나는 원인, 취약점의 영향도에 따라 CWE가 바뀌기에 그냥 취약점이 일어나는 원인으로 mapping해봤다.
interface PurchaseRequest {
productId: string;
quantity: number;
usePoints: number;
}
우리는 클라이언트에서 quantity라는 파라미터로 물건의 수량 갯수를 파라미터로 서버에 보내고,
물건가격 * 갯수로 최종 가격이 결정되는데,
const totalPrice = product.price * data.quantity;
음...... 감이 오지 않는가?
data.quantity 파라미터에 수량 값에 대한 검증이 없다.
글개서 quantity 값에 0을 전달 시
최종 가격 = 0 이 되어버린다. 또한, usePoints 0을 넣고 모든 검증에 통과할 수 있다.
최종적으로 결제금액 0원에 status가 PAID인 무료 주문 생성이 가능하다는 것이다.
세번째는 Race condition, TOCTOU 취약점이 있다. (대충 감은 잡았는데 만족스러운 답을 말하지 못한것 같다.)
일단 코드를 단계별로 분리하면
검증과 사용 사이에 시간 간격이 존재하고, 그 사이에 다른 요청이 끼어들 수 있다는 점이다.
이게 처음에는 감이 안왔는데
// transaction 외부에서 검증
const user = await db.user.findUnique({
where: { id: userId },
select: { points: true, role: true }
});
if (data.usePoints > user!.points) { // ← 검증 시점의 포인트
return { error: '포인트가 부족합니다' };
}
// transaction 내부 사용
await db.$transaction([
db.user.update({
data: { points: { decrement: data.usePoints } } // ← 사용 시점
}),
...
]);
findUnique로 포인트를 읽은 시점과 $transaction으로 포인트를 차감하는 시점 사이에 시간이 흐른다.
웹 서버는 동시에 여러 사용자의 요청을 처리한다.
문제 전제 조건이 뭐였냐면 "이커머스 플랫폼" 이라는 거기 때문에 사용자 개입이 있을거라고 생각하면 조금 더 감을 잡을 수 있을 것이다.
만약 사용자가 보유 포인트 1000점, usePoints: 1000인 요청을 거의 동시에 2번(A, B) 호출한다고 가정해보면
Request A, [findUnique 시작 -> points=1000 받음 -> 검증 통과 -> transaction 시작 -> 차감 완료, point=0]
Request B, [findUnique 시작 -> points=1000 받음 -> 검증 통과 -> transaction 시작 -> 차감 완료, point=0]
이런 플로우로 정리가 가능할텐데,
Request A가 아직 차감을 안했는데, Request B가 새로 조회하고 이 시점에는 DB는 여전히 1000을 저장하고 있을것디고 (A는 포인트에 대한 검증만 수행했고, transaction을 통해서 쓰지 않았다.) 그리고 A가 이미 차감했지만 B는 여전히 자기가 읽은 100을 기준으로 차감한다.
Request A가 findUnique로 1000을 읽고, 하지만 읽기만 했지 아직 DB의 값을 바꾸지 않았다.
그 사이 요청 B도 findUnique로 100을 읽는다. DB는 아직 1000이니니까 당연히 1000이 보인다.
A와 B 모두 1000 >= 100이니 통과라고 판단한다.
A가 transaction을 통해서 차감하면 DB는 0이 된다.
B는 자기가 본 1000을 기준으로 차감하면 DB는 -1000이 된다.
B가 차감할때 지금 DB의 실제 값을 다시 확인하지 않는다. 그냥 decrement: 1000 이라는 명령을 내릴 뿐이다.
DB는 시키는 대로 0에서 -1000을 빼서 DB값은 -1000이 된다.
DB를 조회한 시점과 실제로 차감하는 사이에, 누군가 먼저 돈을 빼버릴 수 있다는 걸 고려하지 않은 것이다.
이런 느낌이다.
네번째 취약점으로는 답지 상으로는 purchaseWithPoints 에서 입력값에 대한 검증이 없다는 취약점이다.
NextJS 에서의 Server Action은 Client 없이 직접 호출이 가능하고, 서버에 음수, 소수점 및 범위 검증이 없이 클라이언트 max 속석에 의존하는 구조적 결함이고, 앞서 설명한 첫번째, 두번째 취약점의 근본 원인이라고 설명하고 있다.
이상이다!!
출처 링크에 문제를 첨부했으니, 한번 다들 꼭 풀어보면 좋겠다.