보안2026년 5월 21일3분 읽기

클라이언트가 보낸 권한 플래그를 믿으면 안 되는 이유

QA 자동 테스트를 돌리다 발견한 권한 격상 취약점. 백엔드가 클라이언트에서 넘어온 isMaster 플래그를 JWT role과 교차검증 없이 그대로 신뢰하고 있었다. 발견부터 수정까지, 그리고 role 기반 권한을 다룰 때 챙겨야 할 것.

#보안#권한#인증#QA#Backend

클라이언트가 보낸 권한 플래그를 믿으면 안 되는 이유

상황

검사 기록 조회 화면에 role 기반 접근 제어를 넣는 작업이었다. role은 세 가지였다. TESTER(검사자)는 본인이 등록한 수검자만, SM과 MASTER는 전체를 볼 수 있어야 했다.

백엔드 조회는 isMaster라는 플래그로 분기하고 있었다.

kotlin
// ExamineeRepositoryImpl.kt (단순화)
if (!isMaster) {
    predicate.and(staff.id.eq(staffId))  // 본인 것만
}
// isMaster면 staffId 무시하고 전체 조회

isMaster가 true면 전체, false면 본인 것만. 화면 쪽에서는 로그인한 사용자의 role을 보고 이 플래그를 정해서 API로 넘겼다.

ts
// 프론트에서 role을 보고 isMaster를 정해서 쿼리 파라미터로 전달
searchParams.isMaster = userInfo.role === "MASTER" || userInfo.role === "SM"

여기까지는 동작했다. 문제는 이 구조 자체에 있었다.

QA 테스트를 돌리다 발견했다

권한 작업이라 role별로 자동 테스트를 돌렸다. TESTER로 로그인하면 본인 것만, SM/MASTER면 전체가 나오는지 확인하는 과정이었다.

테스트를 짜다 보니 한 가지가 눈에 걸렸다. isMaster를 정하는 게 프론트라는 점이다. 백엔드는 그 값을 그대로 받아서 쓴다. 그러면 TESTER가 브라우저 DevTools나 직접 요청으로 isMaster=true를 보내면 어떻게 될까.

해보니 그대로 전체 데이터가 나왔다.

text
TESTER 계정 → ?isMaster=true 로 직접 요청 → 전체 수검자 조회됨

본인 것만 봐야 하는 검사자가 요청 파라미터 한 줄만 바꾸면 모든 수검자를 볼 수 있었다. 권한 격상(privilege escalation) 취약점이다. 의료 데이터라 더 민감했다.

백엔드는 isMaster를 받기만 했지, 이 요청을 보낸 사용자가 진짜 MASTER인지를 검증하지 않았다. JWT 안에 role이 들어있는데도 그걸 안 보고 클라이언트가 보낸 플래그를 믿은 거다.

왜 이렇게 됐나

곱씹어 보면 isMaster라는 이름 하나가 두 가지 다른 일을 하고 있었다.

  • 프론트에서는 UI용 boolean — 삭제 버튼이나 원자료 다운로드 버튼을 보여줄지 말지
  • 백엔드에서는 데이터 범위 플래그 — 전체를 조회할지 본인 것만 조회할지

같은 단어라 자연스럽게 "프론트에서 정해서 백엔드로 넘기면 되겠네"가 됐다. UI 가시성은 클라이언트가 정해도 된다. 안 보여도 화면만의 문제니까. 하지만 데이터 범위는 다르다. 그건 서버가 정해야 하는 보안 경계다. 두 개를 같은 이름으로 묶으면서 그 경계가 흐려졌다.

수정 — 서버에서 JWT로 교차검증

고치는 방향은 단순했다. 클라이언트가 보낸 isMaster를 믿지 않고, 서버가 요청자의 JWT role을 보고 직접 판단하게 했다.

kotlin
// 클라이언트 파라미터가 아니라 인증 컨텍스트의 role로 결정
val canViewAll = authUser.role == Role.MASTER || authUser.role == Role.SM
if (!canViewAll) {
    predicate.and(staff.id.eq(authUser.staffId))
}

이제 TESTER가 isMaster=true를 보내도 소용없다. 서버가 JWT에서 꺼낸 role을 보기 때문에, 요청 파라미터로는 권한을 바꿀 수 없다. UI 쪽 isMaster는 버튼 가시성용으로 그대로 두되, 데이터 범위 판단은 서버 인증 정보에서만 나오도록 분리했다.

곁다리 — 0건 미스터리

사실 이 작업은 다른 버그에서 시작됐다. "SM 계정인데 데이터가 0건으로 보인다"는 보고였다. 권한 코드가 잘못된 줄 알고 한참 봤다.

진단 로그를 찍어보니 엉뚱한 데 답이 있었다.

text
[records] role = TESTER | isMaster = false | staffId = hes250621-q000026

SM인 줄 알았던 그 계정이 실제로는 TESTER였다. 화면 우상단 라벨이 MASTER만 "관리자", 나머지는 다 "검사자"로 표시돼서 SM과 TESTER가 똑같이 "검사자"로 보였고, 그래서 로그인 계정의 진짜 role을 다들 착각하고 있었다. 코드는 정상이었다. 그 계정으로 등록한 수검자가 없어서 0건이 맞았다.

라벨이 role을 1:1로 반영하지 않으면 이런 착각이 생긴다. 이것도 같이 정리 대상으로 남겼다.

마치며

이번 일로 두 가지를 분명히 챙기게 됐다.

하나, 클라이언트가 보낸 값은 얼마든지 변조될 수 있다. DevTools든 직접 요청이든, 브라우저를 거치는 순간 그 값은 사용자가 마음대로 바꿀 수 있는 값이 된다. 그래서 권한 같은 보안 판단은 무조건 서버에서, 그것도 위변조가 불가능한 인증 정보(JWT)에서 나와야 한다. 클라이언트가 보낸 플래그를 그대로 신뢰하면 그건 권한 체크가 아니라 권한 체크처럼 생긴 코드일 뿐이다.

둘, role로 권한을 나눴으면 role마다 전부 테스트해야 한다. MASTER로 한 번 돌려보고 "잘 되네" 하고 넘어가면 TESTER가 파라미터를 바꿔 치는 경로는 영영 안 보인다. 이번 취약점도 결국 role별로 꼼꼼히 자동 테스트를 돌리다 걸린 거였다. 권한은 "되는 케이스"보다 "되면 안 되는 케이스"를 테스트할 때 구멍이 드러난다.

#보안#권한#인증#QA#Backend

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js