웹 취약점 스캐너 결과가 깨끗할 때 더 봐야 할 곳
ZAP 같은 무료 웹 취약점 스캐너가 잘 잡는 취약점과 구조적으로 놓치는 인가 누락·비즈니스 로직·다단계 흐름을 나누고, 결과가 깨끗할 때 추가로 점검할 곳을 체크리스트로 정리합니다.
무료 웹 취약점 스캐너 결과만 믿어도 되는가? 짧게 답하면 "스캐너가 잡는 영역에서는 믿어도 되지만, 그 영역이 서비스 위험의 전부는 아니다"입니다. ZAP 같은 도구는 보안 헤더 누락, 알려진 버전의 라이브러리, 단순한 반사형 XSS처럼 응답만 보고 판단할 수 있는 문제를 빠르고 꾸준하게 찾습니다. 반면 "이 사용자가 이 주문을 봐도 되는가", "이 쿠폰을 두 번 써도 되는가"처럼 서비스의 규칙을 알아야 판단할 수 있는 문제는 구조적으로 놓칩니다.
그래서 스캐너 리포트가 깨끗하다는 건 "기본 위생 상태가 괜찮다"는 뜻이지 "취약점이 없다"는 뜻은 아닙니다. OWASP Top 10:2025에서도 1위는 여전히 접근 통제 실패(Broken Access Control)인데, 이 유형이 바로 스캐너가 가장 약한 영역입니다.
이 글에서는 스캐너를 깎아내리지 않고, 잘하는 일과 못하는 일을 나눈 뒤 스캐너 결과가 깨끗할 때 사람이 추가로 봐야 할 곳을 실무 기준으로 정리합니다.
스캐너가 잘 잡는 것: 믿어도 되는 영역
스캐너는 요청을 보내고 응답을 보며 패턴을 비교하는 도구입니다. 판단 기준이 응답 안에 다 있는 문제라면 사람보다 빠르고 빠짐이 적습니다.
- 보안 헤더·쿠키 설정:
Content-Security-Policy,Strict-Transport-Security,X-Content-Type-Options누락, 쿠키의Secure·HttpOnly·SameSite속성 누락 - 알려진 버전 취약점: 응답이나 스크립트 경로에 드러난 오래된 jQuery, 서버 배너에 노출된 버전 정보
- 단순 반사형 XSS: 파라미터 값이 이스케이프 없이 그대로 응답에 찍히는 경우
- 정보 노출: 스택 트레이스가 담긴 에러 페이지, 디렉터리 리스팅, 주석 속 내부 정보
- 일부 인젝션: 에러 메시지나 응답 시간 차이로 드러나는 SQL 인젝션 패턴
이 항목들은 사람이 매번 수작업으로 확인하기엔 지루하고 빠뜨리기 쉽습니다. CI에 넣어 배포마다 돌리는 용도로는 스캐너가 사람보다 낫습니다. 스캐너가 이런 항목을 잡았다면 우선순위를 매겨 고치면 되고, 리포트가 깨끗하다면 이 영역은 일단 안심해도 됩니다.
스캐너가 구조적으로 못 잡는 이유: '정답'을 모른다
스캐너가 못 잡는 문제의 공통점은 응답만 봐서는 정상인지 아닌지 알 수 없다는 점입니다.
예를 들어 GET /api/orders/1002 요청에 200 OK와 주문 정보가 돌아왔다고 해 봅시다. 스캐너 입장에서는 정상 응답입니다. 그런데 요청한 사람이 주문 1002의 주인이 아니라면 이건 심각한 개인정보 노출입니다. 이걸 판단하려면 "주문은 주문자 본인만 볼 수 있다"는 서비스 규칙을 알아야 하는데, 이 규칙은 코드와 기획서 안에 있지 HTTP 응답에는 없습니다.
OWASP 웹 보안 테스팅 가이드(WSTG)도 비즈니스 로직 테스트에 대해 자동화 도구는 맥락을 이해하기 어려워 사람이 수행해야 한다고 설명합니다. 스캐너의 성능이 나빠서가 아니라, 애초에 입력으로 받지 못한 정보를 판단할 수는 없다는 구조적 한계입니다.
못 잡는 유형 1: 인가 누락(IDOR·권한 상승)
가장 흔하고 피해가 큰 유형입니다. 로그인은 확인하지만 "이 리소스가 이 사용자의 것인가"는 확인하지 않는 코드입니다.
// 취약한 코드: 로그인 여부만 확인
app.get("/api/orders/:id", requireLogin, async (req, res) => {
const order = await db.order.findUnique({
where: { id: req.params.id },
});
if (!order) return res.status(404).end();
res.json(order); // 남의 주문도 그대로 반환
});
// 고친 코드: 소유자 조건을 조회 자체에 포함
app.get("/api/orders/:id", requireLogin, async (req, res) => {
const order = await db.order.findFirst({
where: { id: req.params.id, userId: req.user.id },
});
if (!order) return res.status(404).end(); // 존재 여부도 숨김
res.json(order);
});
스캐너는 두 코드 모두에서 200 응답을 받으므로 차이를 구분하지 못합니다. 사람은 계정 A로 만든 주문 ID를 계정 B로 요청해 보는 것만으로 바로 확인할 수 있습니다. 같은 원리로 일반 사용자 토큰으로 관리자 API(DELETE /api/admin/users/:id 등)를 호출해 보는 권한 상승 점검도 사람이 해야 합니다. 주문·예약 API에서 생기는 패턴은 IDOR 취약점 가이드에 자세히 정리해 두었습니다.
못 잡는 유형 2: 비즈니스 로직
입력값은 문법적으로 완전히 정상인데, 서비스 규칙상 허용되면 안 되는 경우입니다.
- 수량에 음수를 넣어 총액이 줄어드는 장바구니
- 1회용 쿠폰을 동시에 여러 요청으로 보내 중복 적용
- 클라이언트가 보낸 결제 금액을 서버가 그대로 신뢰
- 무료 체험을 탈퇴 후 재가입으로 무한 반복
// 취약한 코드: 클라이언트가 보낸 금액과 쿠폰 상태를 신뢰
app.post("/api/checkout", requireLogin, async (req, res) => {
const { items, amount, couponId } = req.body;
await pg.requestPayment({ amount }); // 금액 변조 가능
await db.coupon.update({ where: { id: couponId }, data: { used: true } });
});
// 고친 코드: 서버에서 금액 재계산, 쿠폰 사용은 조건부 원자 갱신
app.post("/api/checkout", requireLogin, async (req, res) => {
const { items, couponId } = req.body;
if (items.some((i) => !Number.isInteger(i.qty) || i.qty < 1)) {
return res.status(400).end();
}
const amount = await calcAmountFromDb(items); // DB 가격 기준
const result = await db.coupon.updateMany({
where: { id: couponId, userId: req.user.id, used: false },
data: { used: true },
});
if (couponId && result.count !== 1) return res.status(409).end();
await pg.requestPayment({ amount: applyCoupon(amount, couponId) });
});
스캐너는 qty: -1이 "이상한 값"이라는 걸 모르고, 쿠폰을 동시에 두 번 쓰는 시나리오를 스스로 설계하지도 않습니다. 결제 단계별 서버 검증은 결제 금액 변조 취약점 글에서 PG 연동 흐름에 맞춰 다룹니다.
못 잡는 유형 3: 여러 단계 흐름과 로그인 뒤 기능
단계 건너뛰기
본인인증 → 비밀번호 재설정, 장바구니 → 결제 → 주문 완료처럼 순서가 있는 흐름에서, 앞 단계를 건너뛰고 마지막 요청만 보내도 처리되는 경우가 있습니다.
// 취약한 코드: 재설정 요청만 오면 처리
app.post("/api/password/reset", async (req, res) => {
await setPassword(req.body.email, req.body.newPassword);
});
// 고친 코드: 앞 단계 완료를 서버 상태로 확인
app.post("/api/password/reset", async (req, res) => {
const ticket = await db.resetTicket.findFirst({
where: { token: req.body.token, verified: true, usedAt: null,
expiresAt: { gt: new Date() } },
});
if (!ticket) return res.status(400).end();
await setPassword(ticket.userId, req.body.newPassword);
await db.resetTicket.update({ where: { id: ticket.id }, data: { usedAt: new Date() } });
});
스캐너의 크롤러는 링크를 따라가며 요청을 모을 뿐, "2단계를 생략하고 3단계를 호출해 본다"는 발상을 하지 않습니다.
로그인 뒤 기능
인증 설정 없이 돌린 스캔은 로그인 페이지와 공개 페이지만 봅니다. 실제 서비스의 대부분인 마이페이지, 관리자 화면, 내부 API는 스캔 범위에 들어가지 않습니다. ZAP은 Context와 User를 정의해 인증된 상태로 스캔하는 기능을 제공하지만, 세션 만료 감지·역할별 사용자 설정을 제대로 해야 하고, 설정을 해도 역할 간 비교(A 사용자의 리소스를 B가 접근)까지 판단해 주지는 않습니다.
또한 SPA처럼 화면이 자바스크립트로 그려지는 서비스는 기본 크롤러가 API 엔드포인트를 충분히 발견하지 못할 수 있습니다. 리포트가 깨끗하다면 먼저 스캐너가 실제로 몇 개의 URL을 방문했는지부터 확인하는 게 좋습니다.
스캐너 결과가 깨끗할 때 추가로 볼 곳: 실무 체크리스트
스캐너를 돌린 뒤 사람이 30분~반나절 정도 투자해 확인할 항목입니다. 계정 두 개(일반 A, 일반 B)와 관리자 계정 하나를 준비하면 대부분 확인할 수 있습니다.
범위 확인
- 스캔 리포트의 방문 URL 목록에 로그인 뒤 페이지와 주요 API가 포함되어 있는가
- 모바일 앱 전용 API, 관리자 도메인, 스테이징에만 있는 엔드포인트가 빠지지 않았는가
인가
- A가 만든 리소스(주문, 게시글, 파일, 예약) ID로 B가 조회·수정·삭제를 시도하면 거부되는가
- 일반 사용자 토큰으로 관리자 API를 호출하면 403이 나오는가
- 목록 API에
userId,role같은 파라미터를 추가하거나 바꿨을 때 결과가 달라지지 않는가 - 화면에서 버튼만 숨긴 기능이 API에서도 막혀 있는가
비즈니스 로직
- 금액·수량·포인트에 음수, 0, 매우 큰 값, 소수를 넣었을 때 서버가 거부하는가
- 결제 금액을 클라이언트 값이 아니라 서버 DB 기준으로 계산하는가
- 쿠폰·포인트·재고 차감이 동시 요청에서도 한 번만 처리되는가
흐름
- 인증·결제·승인 흐름의 마지막 요청만 단독으로 보내면 거부되는가
- 토큰·인증코드가 1회 사용 후 무효화되고, 만료 시간이 있는가
- 로그아웃 후 기존 세션·토큰으로 요청하면 거부되는가
이 목록에서 막히는 항목이 나오면 그 기능은 코드 리뷰까지 같이 보는 것을 권합니다. 같은 실수가 비슷한 패턴의 다른 API에도 반복되어 있는 경우가 많기 때문입니다.
스캐너를 더 잘 쓰는 법
스캐너의 한계를 안다고 해서 덜 쓸 이유는 없습니다. 오히려 역할을 분명히 하면 효과가 커집니다.
- Baseline과 Full 스캔을 구분한다. ZAP 공식 문서에 따르면 Baseline 스캔은 기본 1분간 스파이더를 돌린 뒤 수동(passive) 검사 결과만 보고하며 실제 공격 요청은 보내지 않습니다. 배포 파이프라인에는 Baseline을, 공격 페이로드를 보내는 능동 스캔은 운영 환경이 아닌 스테이징에서 돌리는 것이 안전합니다.
- 인증 스캔을 설정한다. 역할별 테스트 계정을 만들어 Context에 등록하면 로그인 뒤 기능도 기본 패턴 검사는 받을 수 있습니다. 테스트 계정 비밀번호는 저장소에 커밋하지 말고 실행 시점에 환경 변수로 주입하세요.
- 알림을 튜닝한다. 오탐이나 의도된 설정은 규칙 단위로 IGNORE 처리하고, 꼭 막아야 할 항목은 FAIL로 올려 빌드를 멈추게 하면 리포트가 "읽히는 문서"가 됩니다.
- 인가는 테스트 코드로 고정한다. OWASP Top 10:2025도 기능적 접근 통제를 단위·통합 테스트에 포함하라고 권고합니다. 위 체크리스트에서 확인한 "B는 A의 주문을 못 본다"를 통합 테스트로 남기면 회귀를 막을 수 있습니다.
// 인가 회귀 테스트 예시
it("다른 사용자의 주문은 404를 반환한다", async () => {
const order = await createOrderAs(userA);
const res = await request(app)
.get(`/api/orders/${order.id}`)
.set("Authorization", `Bearer ${tokenOf(userB)}`);
expect(res.status).toBe(404);
});
자주 묻는 질문
유료 스캐너를 쓰면 이런 한계가 없어지나요?
상용 도구는 크롤링 품질, 인증 처리, 리포트 정리 면에서 편한 경우가 많지만, 서비스 규칙을 모른다는 구조적 한계는 같습니다. 인가 누락과 비즈니스 로직은 도구 가격과 관계없이 사람이 시나리오를 세워 확인해야 합니다.
스캐너 결과에 High가 하나도 없으면 출시해도 되나요?
기본 위생 상태가 괜찮다는 신호로는 충분히 의미가 있습니다. 다만 결제, 개인정보 조회, 관리자 기능이 있는 서비스라면 위 체크리스트의 인가·로직 항목을 최소한 한 번은 사람이 확인한 뒤 출시하는 것을 권합니다.
운영 중인 서비스에 스캐너를 돌려도 괜찮나요?
수동 검사 위주의 Baseline 스캔은 공격 요청을 보내지 않아 상대적으로 부담이 적습니다. 능동 스캔은 데이터 생성·삭제나 부하를 일으킬 수 있으므로 운영 데이터와 분리된 스테이징 환경에서 돌리세요. 어느 쪽이든 본인이 관리 권한을 가진 서비스에만 실행해야 합니다.
정리
- 웹 취약점 스캐너는 헤더 누락, 알려진 버전 취약점, 단순 반사형 XSS 같은 패턴형 문제를 빠르고 꾸준하게 잡는 좋은 도구입니다.
- 인가 누락, 비즈니스 로직, 단계 건너뛰기, 로그인 뒤 기능은 서비스 규칙을 알아야 판단할 수 있어 스캐너가 구조적으로 놓칩니다.
- 리포트가 깨끗하다면 스캔 범위부터 확인하고, 계정 두 개로 인가·로직·흐름 체크리스트를 직접 점검한 뒤 결과를 테스트 코드로 고정하세요.
- 직접 점검할 여력이 없다면 무료 분석 신청으로 우리 서비스에서 먼저 볼 곳을 함께 정리해 볼 수 있습니다.
참고 자료
함께 읽으면 좋은 글
AWS 보안 설정 가이드: 초기 스타트업이 하루에 잠글 5가지
AWS 보안 설정을 루트 계정, IAM, S3, 보안 그룹, 감사 로그 순으로 정리했다. 소규모 팀이 하루 안에 점검하고 고칠 수 있는 명령어와 수정 전후 예시, 판단 기준을 담았다.
취약점 점검 리포트 읽는 법: 위험도 해석부터 조치 우선순위까지
취약점 점검 리포트의 위험도를 서비스 맥락으로 해석하고, 조치 순서 결정, 오탐 확인, 재점검 요청, 수정 완료 근거 정리까지 개발팀의 처리 흐름을 안내합니다.
개인정보 유출 사고 사례: 개인정보위 처분에서 반복된 원인 5가지
개인정보 유출 사고 사례를 개인정보위 처분 기준으로 분석해 반복된 원인 5가지를 정리하고, 개발팀이 바로 쓸 코드 예시와 점검 항목으로 바꿔 줍니다.