아이템 88. readObject 메소드는 방어적으로 작성하라. - ksw6169/effective-java GitHub Wiki

Period 클래스를 직렬화할 수 있게 해보자.

다음은 불변식을 지키고 불변을 유지하기 위해 생성자와 접근자에서 Date 객체를 방어적으로 복사한 Period 클래스이다.

public 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;
    }

    ... // 나머지 코드는 생략
}

Period 클래스의 물리적 표현이 논리적 표현과 부합하므로 기본 직렬화 형태를 사용해도 나쁠 것 같지 않다. 따라서 Serializable 을 붙이는 것만으로 충분하다고 생각할 수 있으나, 그렇게 하면 이 클래스의 주요 불변식을 더는 보장하지 못하게 된다.

readObject 메소드는 또 다른 public 생성자이므로 주의해야 한다.

  • readObject 메소드는 실질적으로 또 다른 public 생성자이므로 생성자만큼 주의를 기울여야 한다.
  • 생성자처럼 인수가 유효한지 검사해야 하고 필요하다면 매개변수를 방어적으로 복사해야 한다.
  • readObject 가 이 작업을 제대로 수행하지 못하면 공격자는 아주 손쉽게 해당 클래스의 불변식을 깨뜨릴 수 있다.
  • 쉽게 말해, readObject 는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다.
  • 다음 코드는 직렬화를 이용해 허용되지 않는 Period 인스턴스를 생성하는 예제다.
/**
 * 허용되지 않는 Period 인스턴스를 생성할 수 있다.
 */
public class BogusPeriod {
    // 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림
    private static final byte[] serializedForm = {
            (byte)0xac, (byte)0xed, ...
    };

    public static void main(String[] args) {
        Period p = (Period) deserialize(serializedForm);
        System.out.println(p);
    }

    // 주어진 직렬화 형태(바이트 스트림)로 부터 객체를 만들어 변환한다.
    static Object deserialize(byte[] sf) {
        try {
            return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject();
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }
}
  • 이 문제를 고치려면 PeriodreadObject 메소드가 defaultReadObject 를 호출한 다음 역직렬화된 객체가 유효한지 검사해야 한다.
  • 만약 유효성 검사에 실패하면 InvalidObjectException 을 던지게 하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다.
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    // 불변식을 만족하는지 검사한다.
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
}
  • 하지만 아직 미묘한 문제가 하나 숨어 있다. 정상 Period 인스턴스에서 시작된 바이트 스트림 끝에 private Date 필드로의 참조를 추가하면 가변 Period 인스턴스를 만들어낼 수 있다. 공격자는 ObjectInputStream 에서 Period 인스턴스를 읽은 후 스트림 끝에 추가된 이 ‘악의적인 객체 참조’ 를 읽어 Period 객체의 내부 정보를 얻을 수 있다. 이제 이 참조로 얻은 Date 인스턴스들을 수정할 수 있으니, Period 인스턴스는 더는 불변이 아니게 되는 것이다.
/**
 * 가변 공격의 예
 */
public class MutablePeriod {
    // Period 인스턴스
    public final Period period;

    // 시작 시각 필드 - 외부에서 접근할 수 없어야 한다.
    public final Date start;

    // 종료 시각 필드 - 외부에서 접근할 수 없어야 한다.
    public final Date end;

    public MutablePeriod() {
        try {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream out = new ObjectOutputStream(bos);

            // 유효한 Period 인스턴스를 직렬화한다.
            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(69);
    System.out.println(p);
}
// 실행 결과
Web Nov 22 00:21:29 PST 2017 - Web Nov 22 00:21:29 PST 1978
Web Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 1969

객체를 역직렬화할 때는 객체 참조를 갖는 필드를 모두 방어적으로 복사해야 한다.

  • 객체를 역직렬화할 때는 클라이언트가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 방어적으로 복사해야 한다.
  • 따라서 readObject 에서는 불변 클래스 안의 모든 private 가변 요소를 방어적으로 복사해야 한다.
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 + "보다 늦다.");
}
  • 방어적 복사를 유효성 검사보다 앞서 수행하며, Dateclone() 을 사용하지 않았음에 주목하자. 두 조치 모두 Period 를 공격으로부터 보호하는 데 필요하다.
  • 또한 final 필드는 방어적 복사가 불가능하니 주의하자. 이를 위해 final 한정자를 제거하였다.

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

  • transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮은지 확인하고, 그 답이 아니라면 커스텀 readObject 메소드를 만들어 (생성자에서 수행했어야 할) 모든 유효성 검사와 방어적 복사를 수행해야 한다.
  • 혹은 직렬화 프록시 패턴을 사용하는 방법도 있다. 이 패턴은 역직렬화를 안전하게 만드는 데 필요한 노력을 상당히 경감해주므로 적극 권장한다.

기타 주의사항

  • final이 아닌 직렬화 가능 클래스라면 readObject 메소드도 생성자와 마찬가지로 재정의 가능 메소드를 직간접적으로 호출해서는 안된다.
  • 이 규칙을 어긴 상태에서 해당 메소드가 재정의되면 하위 클래스의 상태가 완전히 역직렬화되기 전에 하위 클래스에서 재정의된 메소드가 실행된다.

핵심 정리

  • readObject 메소드를 작성할 때는 public 생성자와 마찬가지로 조심해야 한다.
  • readObject 는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다. 바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해서는 안된다.
  • 안전한 readObject 메소드를 작성하는 지침은 다음과 같다.
    • private 이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
    • 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException 을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.
    • 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라.
    • 직접적이든 간접적이든 재정의할 수 있는 메소드는 호출하지 말자.

참고 자료

  • Effective Java 3/E