DEV ℧ Developer Diary

[EffectiveJava] item88 - readObject 메서드는 방어적으로 작성하라

Item50에서는 불변인 날짜 범위 클래스를 만드는데 가변인 Date 필드를 이용했다.

방어적 복사를 사용하는 불변 클래스

public final class Period implements Serializable {
  private static final long serialVersionUID = 1L;
  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 객체의 물리적 표현이논리적 표현과 부합하므로 기본 직렬화 형태를 사용해도 나쁘지 않다.

그러니 이 클래스 선언에 implements Serializable을 추가하는 것으로 모든 일을 끝낼 수 있지만 주요한 불변식을 더는 보장하지 못한다.

readObject

readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문에, 다른 생성자와 똑같은 수준으로 주의를 기울여야 한다. 보통의 생성자처럼 readObject 메서드에서도 인수가 유효한지 검사해야 하고 필요하다면 매개변수를 방어적으로 복사해야 한다.

readObject는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다. 만약 불변식을 깨뜨릴 의도로 임의 생성한 바이트 스트림을 건네면 정상적인 생성자로는 만들어 낼 수 없는 객체를 생성해낼 수 있는 문제가 생긴다.

앞서 Period 클래스를 이용해 readObject를 사용할 경우를 가정해보자.

아래는 잘못된 ByteStream을 건네 잘못된 Period을 생성하는 예시이다.

허용되지 않는 Period 인스턴스 생성

public class BogusPeriod {
    /** 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림 */
    private static final byte[] serializedForm = {
        -84, -19, 0, 5, 115, 114, 0, 31, 99, 111, 109, 46, 101,
        102, 102, 101, 99, 116, 105, 118, 101, 74, 97, 118, 97,
        46, 105, 116, 101, 109, 56, 56, 46, 80, 101, 114, 105, 111,
        100, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 2, 76, 0, 3, 101, 110,
        100, 116, 0, 16, 76, 106, 97, 118, 97, 47, 117, 116, 105,
        108, 47, 68, 97, 116, 101, 59, 76, 0, 5, 115, 116, 97, 114,
        116, 113, 0, 126, 0, 1, 120, 112, 115, 114, 0, 14, 106, 97,
        118, 97, 46, 117, 116, 105, 108, 46, 68, 97, 116, 101, 104,
        106, -127, 1, 75, 89, 116, 25, 3, 0, 0, 120, 112, 119, 8, 0,
        0, 1, -118, 17, -55, -125, 58, 120, 115, 113, 0, 126, 0, 3,
        119, 8, 0, 0, 1, -118, 17, -55, -125, 58, 120
    };

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

이 프로그램을 실행하면 아래와 같은 결과가 나온다. end의 값이 start보다 더 과거인 잘못된 Perid 객체를 볼 수 있다.

Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984

해결법

이 문제를 고치려면 Period의 readObject 메서드가 defaultReadObject를 호출한 다음 역직렬화된 객체가 유효한지 검사해야 한다.

유효성 검사를 수행하는 readObject 메서드

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    /** 불변식을 만족하는지 검사한다. */
    if (start.compareTo(end) > 0)
        throw new invalidObjectExeption(start + "가" + end + "보다 늦다.");
}

Period의 가변 공격

위의 로직으로 허용되지 않는 Period 인스턴스를 생성하는 일을 막을 수 있지만, 아직 미묘한 문제가 숨어있다. 정상 Period 인스턴스에서 시작된 바이트 스트림 씉에 private 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 인스턴스르 직렬화 한다. */
      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 RuntimeException(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.toString());

    /* 50년대로 회귀! */
    pEnd.setYear(55);
    System.out.println(p.toString());
}
Sat Aug 19 12:36:58 KST 2023-Sat Aug 19 12:36:58 KST 1978
Sat Aug 19 12:36:58 KST 2023-Fri Aug 19 12:36:58 KDT 1955

Period 인스턴스는 불변식을 유지한 채 생성됐지만, 의도적으로 내부의 값을 수정할 수 있었다.

가변 공격의 원인

Period의 readObject 메서드가 방어적 복사를 충분히 하지 않은 데 있다.

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

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

private void readObject(ObjectInputStream s) throws IOExcepion, ClassNotFoundException {
    s.defaultReadObject();

    /* 가변 요소를 방어적으로 복사한다. */
    start = new Date(start.getTime());
    end = new Date(end.getTime());

    /* 불변식을 만족하는지 검사한다. */
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
}

clone 메서드 또한 방어적 조치를 위해 사용하지 않아야 한다.

start와 end에 final 한정자를 제거하고 readObejct에서 방어적 복사를 진행한다.

이제 다시 위의 코드를 실행하면 아래의 결과를 출력한다.

Sat Aug 19 12:58:19 KST 2023-Sat Aug 19 12:58:19 KST 2023
Sat Aug 19 12:58:19 KST 2023-Sat Aug 19 12:58:19 KST 2023

기본 readObject 메서드 사용 판단 여부

기본 readObject 메서드를 써도 좋을지 판단하는 간단한 방법을 알아보자.

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

final이 아닌 직렬화 가능 클래스

마치 생성자 처럼 readObject 메서드도 재정의 가능 메서드를 호출해서는 안된다.

하위 클래스의 상태가 완전히 역직렬화되기 전에 하위 클래스에서 재정의된 메서드가 실행되어 오작동으로 이어지기 때문이다.