[Item 88] readObject 메서드는 방어적으로 작성하라

둥그냥·2022년 7월 3일
0

Effective Java 독서

목록 보기
15/15

📚 [Item 88] readObject 메서드는 방어적으로 작성하라

readObject는 실질적 생성자다

  • 아이템 50에서 불변인 날짜 범위 클래스를 만드는 데 가변인 Date 필드를 이용했다.
  • 그래서 불변식을 지키고 불변을 유지하기 위해 생성자와 접근자에서 Date 객체를 방어적으로 복사하느라 코드가 상당히 길어졌다.
// 방어적 복사를 사용하는 불변 클래스
public final class Period {
    private final Date start;
    private final Date end;
    
    /**
     * @param start 시작 시각
     * @param end 종료 시각; 시작 시각보다 뒤여야 한다.
     * @throws IllegalArgumentException 시작 시각이 종료 시각보다 늦을 때 발생한다.
     * @throws NullPointerException start나 end가 null이면 발행한다.
     */
    public Period(Date start, Date end) {
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        if(this.start.compareTo(this.end) > 0) {
            throw new IllegalArgumentException(start + "가 " + end + "보다 늦다.");
        }
    }
    
    public Date start() { return new Date(start.getTime()); }
    public Date end() { return new Date(end.getTime()); }
    public String toString() { return start + "-" + end; }
}
  • 이 클래스를 직렬화 한다면?
    • 이 클래스 선언에 implements Serializable을 추가하면 될 것 같다.
    • 하지만 이렇게 해서는 이 클래스의 중요한 불변식을 더는 보장하지 못하게 된다.
    • 문제 ) readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문이다. 따라서 다른 생성자와 똑같은 수준으로 주의를 기울여야 한다.

    쉽게 말해 readObject는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다.

  • 보통의 경우 바이트 스트림은 정상적으로 생성된 인스턴스를 직렬화해 만들어진다.
  • 하지만 불변식을 깨뜨를 의도로 임의 생성한 바이트 스트림을 건네면 정상적인 생성자로는 만들어낼 수 없는 객체를 생성해 낼 수 있다.

문제를 해결하려면?

  • readObject 메서드가 defaultReadObject를 호출한 다음 역질렬화된 객체가 유효한지 검사해야 한다.
  • 이 유효성 검사에 실패하면 InvalidObjectException을 던지게 하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다.

이 해결법도 문제가 하나 또 있다

  • 정상 인스턴스에서 시작된 바이트 스트림 끝에 private Date의 필드로의 참조를 추가하면 가변 인스턴스를 만들어 낼 수 있다. 공격자는 ObjectInputStream에서 인스턴스를 읽은 후 스트림 끝에 추가된 '악의적인 객체 참조'를 읽어 객체의 내부 정보를 얻을 수 있다.
// 가변 공격의 예
public class MutablePeriod {
    //Period 인스턴스
    public final Period period;
    
    //시작 시각 필드 - 외부에서 접근할 수 없어야 한다.
    public final Date start;
    //종료 시각 필드 - 외부에서 접근할 수 없어야 한다.
    public final Date end;
    
    public MutablePeriod() {
        try {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectArrayOutputStream out = new ObjectArrayOutputStream(bos);
            
            //유효한 Period 인스턴스를 직렬화한다.
            out.writeObject(new Period(new Date(), new Date()));
            
            /**
             * 악의적인 '이전 객체 참조', 즉 내부 Date 필드로의 참조를 추가한다.
             * 상세 내용은 자바 객체 직렬화 명세의 6.4절을 참고하자.
             */
            byte[] ref = {0x71, 0, 0x7e, 0, 5}; // 참조 #5
            bos.write(ref); // 시작 start 필드 참조 추가
            ref[4] = 4; //참조 #4
            bos.write(ref); // 종료(end) 필드 참조 추가
            
            // Period 역직렬화 후 Date 참조를 훔친다.
            ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
            period = (Period) in.readObject();
            start = (Date) in.readObject();
            end = (Date) in.readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new AssertionError(e);
        }
    }
}
// 공격이 실제로 이뤄지는 모습
public static void main(String[] args) {
    MutablePeriod mp = new MutablePeriod();
    Period p = mp.period;
    Date pEnd = mp.end;
    
    //시간을 되돌리자!
    pEnd.setYear(78);
    System.out.println(p);
    
    //60년대로 회귀!
    pEnd.setYear(60);
    System.out.println(p);
}
  • 위 공격 예시로 Period 인스턴스는 불변식을 유지한 채 생성됐지만, 의도적으로 내부의 값을 수정할 수 있다

  • 이처럼 변경할 수 있는 Period 인스턴스를 획득한 공격자는 이 인스턴스가 불변이라고 가정하는 클래스에 넘겨 엄청난 보안 문제를 일으킬 수 있다.

  • 객체를 역직렬화 할 때는 클라이언트가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다.

  • 따라서 readObject에서는 불변 클래스 안의 모든 Private 가변 요소를 방어적으로 복사해야 한다.

방어적 복사와 유효성 검사를 수행하는 readObject 메서드

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
    s.defaultReadObject();
    
    // 가변 요소들을 방어적으로 복사한다.
    start = new Date(start.getTime());
    end = new Date(end.getTime());
    
    // 불변식을 만족하는지 검사한다.
    if(start.compareTo(end) > 0) {
       throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
    }
}

주목할 점

  • 방어적 복사를 유효성 검사보다 앞서 수행한다.
  • Date의 clone 메서드는 사용하지 않았다.

주의

  • final 필드는 방어적 복사가 불가능하다
  • 그래서 이 readObject 메서드를 사용하려면 start와 end필드에서 final 한정자를 제거해야 한다.
  • 아쉬운 일이지만 공격 위험에 노출되는 것보다 낫다.

기본 readObject를 써도 좋을지 판단하는 방법

  • transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮은가?
    • 아니오라면 커스텀 readObject를 만들어 (생성자에서 수행했어야 할) 모든 유효성 검사와 방어적 복사를 수행, 혹은 직렬화 프록시 패턴(아이템 90)을 사용

💡 핵심 정리

  • readObject 메서드를 작성할 때는 언제나 public 생성자를 작성하는 자세로 임해야 한다.
  • readObject는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야한다.
  • 바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해서는 안 된다.
  • 이번 아이템에서는 기본 직렬화 형태를 사용한 클래스로 예시를 들었지만 커스텀 직렬화를 사용하더라도 모든 문제가 그대로 발생할 수 있다.

안전한 readObject 메서드를 작성하는 지침

  • private이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
  • 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.
  • 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라(이 책에서는 다루지 않는다.)
  • 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자.

0개의 댓글