[보안] 취약점 보고서와 인증/인가
회사에서 취약점 보고서를 받았다. 계열사 보안 담당자가 회의실에서 발표를 했다.
- 어떤 프록시 프로그램으로 API 응답값을 바꿔서 다시 보내면, 관리자 화면이 그냥 열린다.
- 요청에 들어가는 id를 남의 것으로 바꾸면, 남의 회원정보가 조회된다.
- 심지어 다른 사람의 비밀번호를 내가 바꿀 수 있다.
글로만 보면 “에이 설마” 싶었다. 그래서 직접 해봤다. 내 토큰만 들고 curl로 때려보니… 진짜 됐다. 남의 id를 넣어도 200이 떨어지고, 남의 데이터가 그대로 나왔다. 처음보는거라 진짜 신기했다.
인증은 했는데 인가가 없었다
신가한건 신기한거고 원인을 찾아야지..
원인은 하나였다. 이 시스템은 로그인(인증)은 하는데, 그 다음 검증(인가)이 통째로 비어 있었다.
- 인증: “너 누구야?” → 로그인 성공하면 토큰 발급
- 인가: “너 이거 해도 돼?” → 이 데이터·기능에 접근할 자격이 있나?
토큰만 유효하면 어떤 API든 통과였다. 보고서에 적힌 지적 3건이 전부 여기서 나온 거였다. 로그인은 건물 출입증인데, 정작 방마다 잠금이 하나도 없었던 셈이다.
직접 재현해보기
가장 먼저 한 건 보고서를 그대로 따라 해보는 거였다. 말로만 들으면 안 믿기는데, 직접 재현되면 그제서야 문제의 크기가 보인다.
비밀번호 변경이 제일 충격이었다. 경로가 대충 이런 식이었는데,
1
2
3
4
5
# 내 토큰으로, 남의 userId를 넣어서 호출
curl -X POST 'https://.../api/my/1002/password' \
-H "Authorization: Bearer <내 토큰>" \
-H 'Content-Type: application/json' \
-d '{"newPassword":"hacked1234!"}'
1002는 내 계정이 아니었다. 그런데 200이 떨어졌다. 서버는 “토큰이 유효한가”만 보고, “이 토큰의 주인이 진짜 1002번 사용자인가”는 안 보고 있었다.
조직 데이터도 마찬가지였다. ?orgId=2를 ?orgId=3으로 바꾸니 다른 조직 데이터가 나왔다. 관리자 화면은 프록시로 응답의 userType을 USER에서 ADMIN으로 바꿔주니 그냥 열렸다. 프론트가 그 값으로 메뉴 노출을 결정하고 있었기 때문인데, 공격자는 화면을 안 보고 엔드포인트를 직접 때리니 프론트 분기는 아무 의미가 없었다.
정리하면 세 종류였다. 전부 IDOR(Insecure Direct Object Reference) — 요청에 든 식별자를 바꿔서 남의 것에 접근하는 공격이다.
| 유형 | 내가 해본 것 | 바꾼 값 |
|---|---|---|
| 수직 (권한 상승) | 응답 userType을 ADMIN으로 변조 |
역할 |
| 수평 (타 조직) | ?orgId= 바꿔서 타 조직 조회 |
조직 id |
| 본인 확인 누락 | /my/{userId}/password로 남의 비번 변경 |
사용자 id |
공통점은 분명했다. 서버가 “이 요청자가 이 객체에 접근해도 되는가”를, 요청 파라미터가 아니라 로그인된 본인(principal) 기준으로 판정하지 않는다는 것.
Spring Security method security로 막기
이 프로젝트는 Spring Boot라서, 컨트롤러마다 if문을 박는 대신 method security를 쓰기로 했다. @PreAuthorize를 켜면 메서드 호출 직전에 AOP로 권한식이 평가된다.
핵심은 본인 확인 누락이었다. @PreAuthorize 안에서 쓸 수 있는 hasRole(...) 같은 함수들은 SecurityExpressionRoot라는 객체의 메서드인데, 여기에 커스텀 메서드를 끼우면 내가 만든 함수도 권한식에서 부를 수 있다. 그래서 isSelf를 만들었다.
1
2
3
4
5
6
7
// 커스텀 표현식 루트 (SecurityExpressionRoot 확장)
public final boolean isSelf(long userId) {
Object p = super.getPrincipal();
if (!(p instanceof UserPrincipal)) return false; // anonymous/null 방어
User u = ((UserPrincipal) p).getUser();
return u != null && u.getId() != null && u.getId() == userId;
}
1
2
3
4
// 컨트롤러
@PreAuthorize("isSelf(#userId)") // #userId = 메서드 파라미터 바인딩
@PostMapping("/{userId}/password")
public Response changePassword(@PathVariable Long userId, ...) { ... }
여기서 하나 배운 게, 권한 판정의 기준이 될 수 있는 값이 뭐냐는 거였다. 요청 파라미터의 userId는 당연히 못 믿는다(공격자가 바꾸니까). 그런데 JWT 클레임도 못 믿었다 — 이 프로젝트는 JWT에 권한이 특정 값으로 하드코딩돼 있었다. 결국 믿을 수 있는 건 로그인된 principal에 매달린 DB 엔티티뿐이었다. 그래서 isSelf도 DB에서 가져온 User의 id로 비교한다.
제일 많이 배운 것 — 권한으로 막으려다 다 막아버릴 뻔했다
수평 IDOR(타 조직 데이터 조회)을 막을 때 한 번 크게 헤맸는데, 이게 이번 작업에서 제일 기억에 남는다.
처음엔 “메뉴별 세부 권한”으로 막으려고 했다. 상품 목록 조회면 hasPermission(orgId, 'DATA_VIEW') 같은 식으로. 그럴듯해 보였다.
그런데 프론트를 따라가 보니 상품 목록 API가 ‘Data 메뉴’ 전용이 아니었다. 케이스 작성·수정, 대시보드, 업로드, 각종 요약 화면 등 여러 군데에서 불려오고 있었다. 여기에 DATA_VIEW 권한을 걸면, 그 권한이 없는 같은 조직의 정당한 사용자가 대거 막힌다.
확인 사살은 테스트하다 발견했다. 로그인 직후 모든 사용자가 호출하는 엔드포인트 하나에 관리자 권한을 걸어봤더니, 전 사용자가 로그인하자마자 튕겼다. 그때 깨달았다.
이 취약점의 본질은 “타 조직 차단”이지, “조직 안에서의 세부 권한”이 아니었다. 그러면 맞는 통제는 권한이 아니라 멤버십이다 — “이 사용자가 이 조직 소속인가”. 타 조직이면 무조건 차단, 같은 조직이면 등급과 무관하게 통과. 세부 권한(최소 권한 원칙)은 그것대로 의미 있지만, 그건 이 finding과 별개의 더 깊은 작업이다.
보안 통제를 빡세게 걸수록 좋은 게 아니라는 걸 여기서 체감했다. 정당한 사용자를 막아버리는 과차단도 똑같이 장애다. 그래서 인가를 붙이기 전에 프론트에서 그 엔드포인트를 부르는 모든 곳을 먼저 추적하는 버릇이 생겼다. 누가, 어떤 맥락에서 이걸 부르는지 알아야 통제 수준을 제대로 고를 수 있다.
401 vs 403
원래 인가 거부는 403(인증은 됐는데 권한 없음)이고, 401은 인증 자체가 안 된 거다. 그런데 막아놓고 테스트하니 403이 아니라 401이 떴다. “어? 토큰 문제인가?” 하면서 한참 토큰만 들여다봤다.
알고 보니 글로벌 예외 핸들러가 AccessDeniedException을 401로 매핑하고 있었다. @PreAuthorize가 막은 게 맞는데 상태코드가 401로 변환돼 나온 거다. 그래서 차단인지 토큰 문제인지를 상태코드만으로 판단하면 안 되고, 어느 핸들러가 그 코드를 만들었는지와 응답 바디를 같이 봐야 했다. 같은 401이라도 “토큰 무효”랑 “인가 거부”는 전혀 다른 데서 나온다.
비슷하게 헤맨 것 몇 개 더:
- 만료된 토큰으로 호출해서 401이 떴는데 “인가 차단됐네” 했다가, 재로그인하니 200. 그냥 토큰 만료였다. 검증 전에 토큰 신선도부터 확인하자.
- 토큰을 DB에 저장하는 구조라 서버를 재시작해도 토큰이 살아 있었다. “재시작했는데 왜 안 돼”는 보통 다른 원인이다.
- 소속을
user.orgId필드로 판단하려 했는데, 이 필드가 비어 있어도 별도 멤버 테이블엔 레코드가 있었다. 필드 하나로 판단하면 안 되고 도메인 모델을 봐야 했다.
테스트
단위 테스트랑 통합 테스트를 둘 다 썼는데, 이유가 있었다.
isSelf 같은 표현식 메서드는 단위 테스트로 허용/거부/비정상 입력을 빠르게 검증했다. 그런데 @PreAuthorize("isSelf(#userId)")의 SpEL 문자열과 메서드 바인딩은 런타임에 해석된다. 오타나 시그니처 불일치는 단위 테스트로는 안 잡히고, 실제 로그인 → 토큰 → 엔드포인트 호출로 가야만 잡힌다. 그래서 통합 테스트로 상태코드 매트릭스를 찍었다.
이때 대조군이 필수다. 본인=200 / 타인=거부 / 관리자=200처럼 통과와 차단을 둘 다 찍어야 “정말 그 조건으로 갈리는지”가 증명된다. 거부만 확인하면 “원래부터 다 막혀 있던 것”과 구분이 안 된다.
다음에 인가 붙일 때 볼 것
이번에 데인 걸 잊지 않으려고 적어둔다.
- 이 엔드포인트가 보호하는 게 누구 소유·소속인가? (user id / org id / role 중 무엇으로 판정?)
- 판정 기준은 요청 파라미터·JWT 클레임이 아니라 로그인된 principal + DB.
- 막기 전에 프론트에서 이 API를 부르는 모든 곳을 추적했나? (과차단 방지)
- 통제 수준이 위협에 맞나? 타 조직 차단이면 멤버십, 세부 기능이면 권한.
- anonymous/null principal을 방어했나?
- 거부 시 상태코드·응답이 프론트 흐름(로그아웃 트리거 등)이랑 충돌하지 않나?
본인=허용 / 타인=거부 / 관리자대조군까지 테스트했나?
더 파보고 싶으면 OWASP의 Broken Access Control(A01), Spring Security method security와 PermissionEvaluator, 멀티테넌시 격리 패턴 쪽을 보면 된다.
처음엔 보고서가 그냥 문서였는데, curl로 직접 재현해보고 나서야 진짜 무서운 걸 깨달았다. 인증이 됐다고 안전한 게 아니라는, 너무 당연한 말을 코드로 체감한 작업이었다.
이 글은 실제 업무 경험을 일반화한 것으로, 특정 시스템의 식별 정보·미패치 취약점·인프라 정보는 포함하지 않았습니다.