남의 것이 보인다: 남의 번호 조회, 대량 할당, SSRF를 서버에서 막기

아프로·2026년 11월 2일

남의 것이 보인다 표지

주문 상세 주소의 번호를 하나만 바꿨는데 남의 주문이 열립니다. 받는 사람 이름과 주소, 전화번호까지 보입니다. 서버는 로그인 여부를 확인했지만, 그 주문이 누구 것인지는 확인하지 않았습니다. 접근 제어(로그인한 사람이 무엇을 보고 바꿀 수 있는지 정하는 규칙)에서 가장 흔한 실수입니다.

이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 2-1, 2-2, 2-3, 2-4, 2-5

한눈에 보기

남의 번호 조회, 대량 할당, SSRF 세 빈틈과 각각을 막는 자리

위험어디서 생기나막는 법 한 줄OWASP
남의 번호 조회(IDOR)주문·배송지처럼 번호로 꺼내는 조회조회 조건에 로그인한 회원 번호를 넣는다A01
대량 할당요청 본문을 엔티티에 그대로 담는 수정 기능바꿔도 되는 칸만 둔 요청 전용 객체를 쓴다A01
SSRF사용자가 준 주소로 서버가 대신 요청하는 기능주소를 IP로 해석해 내부 대역을 거절하고, 나가는 길을 프록시 한 곳으로 모은다A01

세 가지 모두 OWASP Top 10:2025의 A01(접근 제어 실패)에 들어갑니다. 2021판에서 A10이었던 SSRF는 2025판에서 A01로 합쳐졌습니다. 세 빈틈은 공통점이 있습니다. 로그인은 확인했지만 "이 사람이 이 대상에 이 일을 해도 되는가"는 확인하지 않았다는 점입니다.

1. 남의 번호 조회(IDOR)

무엇이 문제인가

IDOR(Insecure Direct Object Reference)는 요청에 담긴 번호만 보고 데이터를 꺼내 주는 문제입니다. 인증은 "누구인가"를 확인하고, 인가는 "그 사람이 이 주문을 봐도 되는가"를 확인합니다. 인증만 하고 인가를 빠뜨리면, 로그인한 사람은 누구나 번호만 맞히면 남의 주문을 봅니다.

주문 번호가 1, 2, 3처럼 차례로 붙어 있으면 짐작하기도 쉽습니다. 번호를 무작위로 바꾸는 것만으로는 부족합니다. 번호가 어딘가에 노출되는 순간 같은 문제가 다시 생깁니다.

막는 코드

특정 회사 코드가 아니라 개념을 보여 주는 예시입니다. 값은 가짜입니다.

// 위험한 모양: 번호만 맞으면 누구 주문이든 돌려준다
@Select("SELECT * FROM oms_order WHERE id = #{id}")
Order findById(@Param("id") long id);
// 고친 모양: 로그인한 회원 번호를 조회 조건에 함께 넣는다
@Select("SELECT * FROM oms_order WHERE id = #{id} AND member_id = #{memberId}")
Order findMine(@Param("id") long id, @Param("memberId") long memberId);

@GetMapping("/orders/{id}")
public OrderResponse get(@PathVariable long id, @AuthenticationPrincipal LoginMember me) {
    Order order = orderMapper.findMine(id, me.id());
    if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
    return OrderResponse.from(order);
}

소유권 검사를 조회한 뒤 if로 따로 하면, 새 조회 메서드를 만들 때 빠뜨리기 쉽습니다. 조회 조건 안에 넣어 두면 내 주문이 아닌 번호는 결과가 비고, 서버는 없는 주문으로 처리합니다. 남의 주문에 403 대신 404를 돌려주면 그 번호의 주문이 있는지도 드러나지 않습니다.

관리자 기능도 같은 원리입니다. Spring Security에서 기본은 거부로 두고, 관리자 API에는 @PreAuthorize("hasRole('ADMIN')") 같은 메서드 보안을 붙입니다.

확인하는 법

bob으로 로그인해 alice의 주문 번호를 요청하면 404, 자기 주문은 200이 나오는지 테스트로 확인합니다. 운영에서는 한 계정이 연속된 번호를 훑는 조회를 탐지 규칙으로 잡습니다.

2. 대량 할당(Mass Assignment)

무엇이 문제인가

회원정보 수정 화면에는 닉네임과 도시 칸만 있습니다. 그런데 서버가 요청 본문을 회원 엔티티로 통째로 받으면, 화면에 없는 등급이나 포인트 칸도 요청에 실어 보낼 수 있습니다. 프레임워크는 받은 칸을 객체에 모두 채우고, 서버는 그대로 저장합니다.

화면에 칸이 없다는 사실은 보호 장치가 되지 못합니다. 요청은 화면을 거치지 않고도 만들 수 있기 때문입니다.

막는 코드

// 위험한 모양: 엔티티를 요청 본문으로 받는다
@PutMapping("/members/me")
public void update(@RequestBody Member member, @AuthenticationPrincipal LoginMember me) {
    member.setId(me.id());
    memberMapper.update(member);   // memberLevelId(등급), integration(포인트)도 저장된다
}
// 고친 모양: 바꿔도 되는 칸만 둔 요청 전용 객체
public record ProfileUpdateRequest(@Size(max = 20) String nickname,
                                   @Size(max = 30) String city) {}

@PutMapping("/members/me")
public void update(@Valid @RequestBody ProfileUpdateRequest req,
                   @AuthenticationPrincipal LoginMember me) {
    memberMapper.updateProfile(me.id(), req.nickname(), req.city());
}

요청 전용 객체(DTO)에는 등급과 포인트 칸이 없으니 그 값이 들어올 자리도 없습니다. 수정 기능마다 객체를 따로 만들고, 응답에도 엔티티 대신 응답 전용 객체를 쓰면 반대 방향의 과도한 노출도 함께 줄어듭니다.

확인하는 법

수정 요청에 등급 칸을 덧붙여 보내고, 저장된 등급이 그대로인지 테스트합니다. Spring Boot는 모르는 칸을 기본으로 무시합니다. 모르는 칸이 오면 400으로 거절하고 싶다면 spring.jackson.deserialization.fail-on-unknown-properties: true를 켭니다.

3. SSRF(서버 측 요청 위조)

무엇이 문제인가

상품 이미지를 주소로 받아 서버가 대신 내려받는 기능이 있다고 해 봅시다. 사용자가 준 주소가 회사 내부를 가리키면, 서버는 그 주소로 요청을 보냅니다. 요청을 보내는 쪽이 우리 서버라서 밖에서는 닿지 않는 관리 화면이나 클라우드 메타데이터 주소에도 요청이 갑니다.

호스트 이름만 검사하는 방어는 빈틈이 남습니다. 검사할 때 쓰는 URL 파서와 실제 요청을 보내는 라이브러리가 같은 주소를 다르게 읽을 수 있고(02편), 이름이 가리키는 IP도 바뀔 수 있습니다(04편).

막는 코드

// 고친 모양: 한 번 해석한 URI로 검사하고, 같은 URI로 요청한다
public byte[] fetchImage(String url) throws Exception {
    URI uri = URI.create(url).normalize();
    if (!"https".equals(uri.getScheme())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
    InetAddress ip = InetAddress.getByName(uri.getHost());
    if (ip.isSiteLocalAddress() || ip.isLoopbackAddress()
            || ip.isLinkLocalAddress() || ip.isAnyLocalAddress()) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST);   // 내부 주소면 요청하지 않는다
    }
    HttpRequest req = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(5)).GET().build();
    return httpClient.send(req, HttpResponse.BodyHandlers.ofByteArray()).body();
}

java.net.http.HttpClient는 기본값이 리다이렉트를 따라가지 않는 설정입니다. 이 설정은 그대로 둡니다. 허용한 주소가 내부 주소로 다시 보내는 길을 막기 위해서입니다.

이 코드에도 한계가 있습니다. 검사할 때 해석한 IP와 실제 접속할 때 다시 해석한 IP가 다를 수 있습니다. 그래서 운영에서는 나가는 요청을 이그레스 프록시(서버가 밖으로 나가는 요청이 모두 지나가는 관문) 한 곳으로 모으고, 거기서 IP 수준 허용 목록을 한 번 더 적용합니다. 강의에서는 이 검사를 SafeHttpClient라는 방어 부품으로 정리합니다. 강의에서 만드는 방어 부품 예시이고, 일반 HTTP 클라이언트 대신 이 빈을 주입해 쓰는 방식입니다.

확인하는 법

사설 대역 주소와 정상 이미지 주소를 각각 넣어, 앞은 400이고 뒤는 통과하는지 확인합니다. 운영에서는 프록시 로그에서 목적지가 사설 대역이나 메타데이터 주소(169.254.169.254)인 요청을 경보로 잡습니다.

점검 체크리스트

  • □ 번호로 꺼내는 조회(주문·배송지·쿠폰)마다 조회 조건에 로그인한 회원 번호가 들어 있다
  • □ 남의 번호를 요청하면 404가 나오는지 확인하는 테스트가 있다
  • □ 관리자 API는 기본 거부이고, 역할 검사가 메서드마다 붙어 있다
  • □ 수정 요청은 엔티티 대신 기능별 요청 전용 객체로 받는다
  • □ 사용자가 준 주소로 요청하는 곳은 해석한 IP로 내부 대역을 거절하고, 리다이렉트를 따라가지 않는다
  • □ 서버의 나가는 요청은 이그레스 프록시 한 곳을 지나고, 그 로그를 본다

정리

세 빈틈은 모두 로그인 확인만으로 끝낸 데서 생깁니다. 남의 번호 조회는 조회 조건에 회원 번호를 넣어 막고, 대량 할당은 요청 전용 객체로 들어올 칸을 정해 막습니다. SSRF는 서버가 대신 보내는 요청의 목적지를 IP로 확인하고, 나가는 길을 프록시 한 곳으로 모아 막습니다. 인증은 문 앞에서 한 번이면 되지만, 인가는 대상마다 확인해야 합니다.

함께 보면 좋은 글

보안을 더 알고 싶다면

오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.

실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩

  • AI로 자동화된 해킹 시대, 오픈소스 쇼핑몰 코드에서 실제 사고 22건을 재연하고 직접 취약점을 찾아 한 건씩 막습니다.
  • OWASP Top 10:2025 전체와 목록 밖 위험 20가지를 102개 수업으로 다루고, 취약점 37개를 모두 막은 최종 소스를 가져갑니다. Spring Boot와 Express 모두 다룹니다.
  • 이 글과 이어지는 수업: 2-1, 2-2, 2-3, 2-4, 2-5

서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.

백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지

  • 서버를 공격하는 흐름을 로그로 보고, 내 서버와 Spring·Express 코드에서 직접 막아 봅니다.
  • SSH 하드닝, 인젝션, 인증·인가, 시크릿과 개인정보 보호, 침해사고 첫 한 시간까지 8개 섹션 54개 수업입니다.
  • 이 글과 이어지는 수업: 2-6, 3-4, 4-1

출처와 확인 날짜

수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다.


이전 글 정규식 하나, 재시도 하나가 서비스를 멈춘다: ReDoS, HTTP/2 공격, 재시도 폭주
다음 글 입력이 명령이 될 때: SQL 바인딩, 정렬 허용 목록, 저장형 XSS, 파일 업로드
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록

profile
고인물 개발자(OG)

0개의 댓글