Mass Assignment 취약점: 회원정보 수정으로 권한 상승되는 원리

Mass Assignment 취약점이 회원정보 수정 API에서 role·isAdmin 변경으로 이어지는 원리와, Express·Prisma·Spring·Django 코드에서 허용 필드 화이트리스트로 고치는 방법을 비교 코드와 체크리스트로 정리했습니다.

보안 점검 의뢰 팀 17분 분량

회원정보 수정 요청 본문에 "role": "admin"이나 "isAdmin": true를 하나 끼워 넣었더니 실제로 권한이 바뀌었다면, 그 원인은 거의 항상 같습니다. 서버가 요청 본문 전체를 DB 엔티티에 그대로 바인딩하기 때문입니다. User.update(req.body)나 @RequestBody User user 같은 코드는 클라이언트가 보낸 키 중 모델에 존재하는 것이라면 무엇이든 저장합니다. 화면에 role 입력란이 없다는 사실은 아무것도 막아주지 못합니다. 요청은 브라우저 화면이 아니라 HTTP로 들어오기 때문입니다. 이것이 Mass Assignment 취약점입니다.

고칠 곳도 분명합니다. 요청을 받는 지점에서 "이 API가 바꿔도 되는 필드" 목록(화이트리스트)을 명시하고, 그 밖의 필드는 버리거나 거부하면 됩니다. 실무에서는 수정 전용 DTO(요청 객체)를 따로 만들고, 엔티티에는 DTO의 값을 필드 단위로 옮겨 담는 방식이 가장 안정적입니다. 권한·포인트·인증 상태처럼 사용자가 직접 바꾸면 안 되는 필드는 별도의 관리자 API나 서버 내부 로직으로만 바꾸도록 경로를 나눕니다.

이 글에서는 Express(Mongoose), Prisma, Spring Boot, Django REST framework에서 자주 보이는 취약한 코드와 고친 코드를 나란히 보여주고, 코드 리뷰 때 바로 쓸 수 있는 점검 기준을 정리합니다.

Mass Assignment 취약점이란: IDOR와 무엇이 다른가

OWASP API Security Top 10 2023에서 Mass Assignment는 API3:2023 Broken Object Property Level Authorization(BOPLA) 항목에 통합되었습니다. 2019년판의 Excessive Data Exposure와 Mass Assignment를 묶어, "객체 단위 접근은 맞게 검사했지만 객체 안의 속성(필드) 단위 권한은 검사하지 않은 문제"로 정의합니다.

IDOR 취약점과 비교하면 차이가 분명해집니다.

  • IDOR(BOLA): /orders/1002를 /orders/1003으로 바꿔 남의 객체에 접근하는 문제입니다. "이 객체가 네 것이냐"를 검사하지 않은 것입니다.
  • Mass Assignment(BOPLA): /me처럼 내 객체를 정상적으로 수정하면서, 원래 수정할 수 없어야 하는 필드까지 바꾸는 문제입니다. "이 필드를 네가 바꿔도 되느냐"를 검사하지 않은 것입니다.

그래서 IDOR 점검(소유자 검사)을 꼼꼼히 해둔 서비스에서도 Mass Assignment는 그대로 남아 있는 경우가 많습니다. 본인 확인은 통과하니 로그나 모니터링에서도 정상 요청처럼 보입니다. OWASP 예시에도 영상 설명을 수정하면서 blocked 필드를 함께 보내 차단을 푸는 경우, 예약 승인 요청에 price 필드를 끼워 넣는 경우가 나옵니다. 회원 등급, 포인트 잔액, 이메일 인증 여부, 소속 조직 ID, 구독 만료일도 같은 유형에 들어갑니다.

왜 role 필드가 그대로 들어가는가: 자동 바인딩의 구조

프레임워크는 개발 편의를 위해 요청 데이터를 객체에 자동으로 채워 줍니다. JSON 키 이름과 객체 필드 이름이 같으면 값을 넣는 방식입니다. 문제는 이 자동 바인딩이 "모델에 존재하는가"만 보고 "이 사용자가 바꿔도 되는가"는 보지 않는다는 점입니다.

PATCH /api/me HTTP/1.1
Host: api.example.com
Content-Type: application/json

{
  "nickname": "새닉네임",
  "bio": "안녕하세요",
  "role": "admin"
}

프론트엔드 폼에는 nickname과 bio만 있지만 요청 본문은 클라이언트가 자유롭게 바꿀 수 있습니다. 필드 이름은 생각보다 쉽게 드러납니다. 회원정보 조회 API 응답에 role, isAdmin, tier가 그대로 내려오면 수정 요청에 넣어볼 이름을 서버가 알려주는 셈입니다. OWASP Mass Assignment Cheat Sheet도 민감 필드 이름을 추측할 수 있거나 소스 코드를 볼 수 있을 때 악용 가능성이 높아진다고 설명합니다. 같은 Cheat Sheet는 2012년 GitHub가 이 유형의 문제로 침해된 사례를 함께 소개합니다.

흔한 오해 하나도 짚고 갑니다. Mongoose의 strict 모드나 Prisma의 타입 검사는 스키마에 없는 필드를 걸러줄 뿐입니다. role은 스키마에 정의된 정상 필드이므로 이런 기능으로는 막히지 않습니다.

프레임워크별 취약한 코드와 고친 코드

아래 예시는 모두 가상의 코드입니다. 공통 원칙은 하나입니다. 요청 본문을 엔티티로 직접 넘기지 말고, 허용한 필드만 골라낸 뒤 넘긴다.

Express + Mongoose

// 취약: 요청 본문 전체를 그대로 업데이트
app.patch('/api/me', requireLogin, async (req, res) => {
  const user = await User.findByIdAndUpdate(req.user.id, req.body, { new: true });
  res.json(user);
});
// 수정: 허용 필드만 스키마로 검증하고 추출
const { z } = require('zod');

const UpdateMeSchema = z.strictObject({
  nickname: z.string().min(1).max(30).optional(),
  bio: z.string().max(200).optional(),
});

app.patch('/api/me', requireLogin, async (req, res) => {
  const parsed = UpdateMeSchema.safeParse(req.body);
  if (!parsed.success) return res.status(400).json({ error: 'invalid_fields' });

  const user = await User.findByIdAndUpdate(
    req.user.id,
    { $set: parsed.data },
    { new: true, runValidators: true }
  );
  res.json({ nickname: user.nickname, bio: user.bio });
});

Zod의 z.object()는 기본적으로 모르는 키를 조용히 제거하고, z.strictObject()는 모르는 키가 있으면 에러를 냅니다. 운영 중인 클라이언트가 불필요한 필드를 섞어 보내고 있다면 처음에는 제거 방식으로 적용하고, 정리가 끝나면 거부 방식으로 바꾸는 순서를 권합니다. 응답도 필요한 필드만 골라 내려주면 필드 이름 노출이 줄어듭니다. 미들웨어 전반은 Express 보안 설정 가이드에서 함께 점검할 수 있습니다.

Prisma (TypeScript)

// 취약: 타입이 통과해도 role, points, emailVerified까지 수정 가능
export async function updateMe(req: Request, res: Response) {
  const user = await prisma.user.update({
    where: { id: req.user.id },
    data: req.body,
  });
  res.json(user);
}
// 수정: 필드를 하나씩 명시해 data를 구성
const UpdateMeSchema = z.object({
  nickname: z.string().min(1).max(30).optional(),
  bio: z.string().max(200).optional(),
});

export async function updateMe(req: Request, res: Response) {
  const input = UpdateMeSchema.parse(req.body);

  const user = await prisma.user.update({
    where: { id: req.user.id },
    data: { nickname: input.nickname, bio: input.bio },
    select: { id: true, nickname: true, bio: true },
  });
  res.json(user);
}

Prisma에서는 data: req.body, data: { ...req.body }, data: { ...input, updatedAt }처럼 스프레드로 넘기는 패턴을 특히 조심해야 합니다. Prisma의 data에는 관계 필드의 중첩 쓰기(nested write)도 들어갈 수 있어서, 사용자 객체를 통째로 받으면 연결된 레코드까지 영향받을 수 있습니다. select로 응답 필드를 제한하면 비밀번호 해시 같은 값이 응답에 섞여 나가는 문제도 함께 막을 수 있습니다.

Spring Boot

// 취약: JPA 엔티티를 요청 본문으로 직접 받음
@PatchMapping("/api/me")
public UserResponse updateMe(@AuthenticationPrincipal LoginUser login,
                             @RequestBody User request) {
    User user = userRepository.findById(login.getId()).orElseThrow();
    BeanUtils.copyProperties(request, user, "id", "password"); // 블랙리스트 방식
    return UserResponse.from(userRepository.save(user));
}
// 수정: 수정 전용 DTO + 명시적 변경 메서드
public record UpdateMeRequest(
    @Size(min = 1, max = 30) String nickname,
    @Size(max = 200) String bio
) {}

@PatchMapping("/api/me")
public UserResponse updateMe(@AuthenticationPrincipal LoginUser login,
                             @Valid @RequestBody UpdateMeRequest request) {
    User user = userRepository.findById(login.getId()).orElseThrow();
    user.updateProfile(request.nickname(), request.bio()); // 엔티티에 의도된 변경만 노출
    return UserResponse.from(user);
}

엔티티에 setRole() 같은 public setter를 두지 않고 updateProfile(), changeRole()처럼 의도가 드러나는 메서드로 변경을 제한하면, 실수로 바인딩되는 경로가 구조적으로 줄어듭니다. 위 취약 코드의 copyProperties(..., "id", "password")는 제외할 필드를 나열하는 블랙리스트 방식이라, 나중에 role이나 tier 필드가 추가되면 그대로 열립니다.

폼 데이터를 @ModelAttribute로 받는 경우는 Spring의 DataBinder가 동작합니다. Spring 공식 문서는 보안상 웹 바인딩 전용 객체를 쓰거나 생성자 바인딩만 쓰라고 권하고, 프로퍼티 바인딩을 써야 한다면 allowedFields 패턴으로 바인딩할 필드를 제한하라고 안내합니다.

@InitBinder("profileForm")
public void initBinder(WebDataBinder binder) {
    binder.setAllowedFields("nickname", "bio");
}

Spring Security 설정 쪽 실수는 Spring Security 설정 실수 7가지에서 따로 다뤘습니다.

Django REST framework

# 취약: 모든 모델 필드를 쓰기 가능하게 노출
class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = "__all__"

class MeView(generics.RetrieveUpdateAPIView):
    serializer_class = UserSerializer
    permission_classes = [IsAuthenticated]

    def get_object(self):
        return self.request.user
# 수정: 수정 전용 Serializer에 허용 필드만 명시
class MeUpdateSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["nickname", "bio"]

class MeReadSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "nickname", "bio", "date_joined"]
        read_only_fields = fields

class MeView(generics.RetrieveUpdateAPIView):
    permission_classes = [IsAuthenticated]

    def get_serializer_class(self):
        if self.request.method in ("PUT", "PATCH"):
            return MeUpdateSerializer
        return MeReadSerializer

    def get_object(self):
        return self.request.user

DRF 공식 문서는 fields 속성으로 직렬화할 필드를 명시적으로 지정할 것을 강력히 권장합니다. 모델이 바뀌었을 때 의도치 않게 데이터가 노출될 가능성을 줄이기 위해서입니다. fields = "__all__"이면 is_staff, is_superuser, groups까지 쓰기 가능한 필드가 됩니다. 소유자나 작성자처럼 서버가 정해야 하는 값은 요청에서 받지 말고 serializer.save(owner=self.request.user)처럼 뷰에서 주입합니다. Django의 ModelForm에서도 fields = "__all__"이나 exclude보다 fields 목록을 명시하는 것이 같은 원리입니다.

블랙리스트보다 화이트리스트여야 하는 이유

"role만 빼면 되지 않나?"라는 질문을 자주 받습니다. OWASP Cheat Sheet는 블랙리스트를 유일한 방어 수단으로 쓰지 말라고 명시합니다. Spring의 disallowedFields도 같은 이유로 취약한 방식으로 분류됩니다. 이유는 시간이 지나면 드러납니다.

  • 6개월 뒤 누군가 User에 tier, creditLimit, organizationId를 추가합니다. 블랙리스트는 이 필드들을 모르므로 그대로 열립니다.
  • 화이트리스트는 반대로 동작합니다. 새 필드는 누군가 의도적으로 허용 목록에 추가하기 전까지 닫혀 있습니다. 실수했을 때 기능이 안 되는 쪽으로 실패하므로 보안 사고로 이어지지 않습니다.

판단 기준은 간단합니다. "새 필드를 추가했을 때 기본값이 열림인가, 닫힘인가?" 열림이라면 구조를 바꿔야 합니다.

권한 변경은 별도 경로로 설계하기

화이트리스트를 적용한 뒤에도 "그럼 role은 어디서 바꾸느냐"는 문제가 남습니다. 권장 구조는 다음과 같습니다.

  1. 사용자 본인 API(PATCH /me): 프로필성 필드만 허용합니다.
  2. 관리자 API(PATCH /admin/users/{id}/role): 관리자 권한을 서버에서 검사하고, 변경 가능한 값의 범위(예: 관리자 승격은 최상위 관리자만 가능)를 따로 검증하며, 누가 언제 무엇을 바꿨는지 감사 로그를 남깁니다.
  3. 서버 내부 로직: 포인트, 결제 상태, 인증 여부는 클라이언트 입력이 아니라 결제 검증·이메일 인증 같은 서버 이벤트로만 바뀌게 합니다.

관리자 API 쪽 통제는 관리자 페이지 보안 체크리스트와 함께 보면 좋습니다.

회귀 테스트도 하나 넣어 두면 리팩터링 중에 다시 열리는 일을 막을 수 있습니다.

it('일반 사용자는 PATCH /me로 role을 바꿀 수 없다', async () => {
  const res = await request(app)
    .patch('/api/me')
    .set('Authorization', `Bearer ${userToken}`)
    .send({ nickname: 'tester', role: 'admin' });

  expect([200, 400]).toContain(res.status);
  const user = await User.findById(userId);
  expect(user.role).toBe('user');
});

우리 코드 점검 체크리스트

코드 리뷰나 출시 전 점검 때 아래 항목을 grep과 함께 확인해 보세요.

  • req.body, request.data, **kwargs가 ORM의 create·update에 그대로 전달되는 곳이 없는가 (update(req.body), data: req.body, {...req.body}, Model(**data))
  • Spring에서 @RequestBody·@ModelAttribute의 타입이 JPA 엔티티인 곳이 없는가
  • BeanUtils.copyProperties, Object.assign(entity, body), ModelMapper 같은 일괄 복사 코드가 요청 데이터에 쓰이지 않는가
  • DRF Serializer와 Django ModelForm에 fields = "__all__"이나 exclude만 쓴 곳이 없는가
  • 회원가입 API에서도 role, isVerified, points를 받지 않는가 (수정 API만큼 자주 놓칩니다)
  • 소유자·조직 ID처럼 서버가 정해야 하는 값을 요청 본문에서 받지 않는가
  • 조회 API 응답에 내부 필드(role, isAdmin, 비밀번호 해시)가 불필요하게 포함되지 않는가
  • 권한·등급 변경이 별도 관리자 API로 분리되어 있고 감사 로그가 남는가
  • 민감 필드를 끼워 넣은 요청에 대한 회귀 테스트가 있는가

자동 스캐너는 어떤 필드가 "바뀌면 안 되는 필드"인지 비즈니스 맥락을 모르기 때문에 이 유형을 잘 찾지 못합니다. 위 항목은 사람이 직접 확인하는 편이 정확합니다.

자주 묻는 질문

프론트엔드에서 role 입력란을 없앴는데도 위험한가요?

네, 위험합니다. 요청은 개발자 도구나 프록시 도구로 얼마든지 수정할 수 있습니다. 클라이언트 화면 구성은 보안 통제가 아니며, 허용 필드 검증은 반드시 서버에서 해야 합니다.

TypeScript 타입이나 DTO 클래스가 있으면 자동으로 막히나요?

아닙니다. TypeScript 타입은 컴파일할 때만 존재하고 런타임의 req.body를 걸러주지 않습니다. 런타임 스키마 검증(Zod 등)으로 실제 값을 골라내야 합니다. Spring DTO도 엔티티와 같은 필드를 전부 가진 채 그대로 복사하면 의미가 없으므로, 수정 가능한 필드만 담아야 합니다.

모르는 필드가 들어오면 무시해야 하나요, 에러를 내야 하나요?

둘 다 안전합니다. 에러를 내면 공격 시도나 클라이언트 버그를 빨리 발견할 수 있다는 장점이 있습니다. 다만 기존 클라이언트와의 호환이 걱정되면 처음에는 무시(제거)하면서 로그를 남기고, 정리가 끝난 뒤 거부로 전환하는 방법이 현실적입니다.

정리

  • Mass Assignment 취약점은 남의 데이터가 아니라 내 데이터의 수정 불가 필드를 바꾸는 권한 상승 문제이고, 원인은 요청 본문을 엔티티에 직접 바인딩하는 코드입니다.
  • 고칠 곳은 요청을 받는 지점입니다. 수정 전용 DTO·Serializer·스키마에 허용 필드를 명시하고, 블랙리스트에만 의존하지 않습니다.
  • role·포인트·인증 상태는 별도의 관리자 API나 서버 내부 로직으로만 바뀌게 분리하고, 회귀 테스트로 고정합니다.
  • 직접 점검이 어렵다면 무료 분석 신청으로 회원정보 수정 API의 바인딩 구조를 함께 확인해 볼 수 있습니다.

참고 자료

함께 읽으면 좋은 글