Posts [이펙티브자바] 아이템88-readObject 메서드는 방어적으로 작성하라
Post
Cancel

[이펙티브자바] 아이템88-readObject 메서드는 방어적으로 작성하라

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

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

readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문에 인수가 유효한지 검사해야 하고, 필요하다면 매개변수를 방어적으로 복사해야 한다.

불변인 날짜 범위 클래스인 Period 클래스가 있다고 가정해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 방어적 복사를 사용하는 불변 클래스
public final class Period implements Serializable {
  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; }

  // ... 생략
}
  • 기본적인 자바 역직렬화시엔 내부적으로 readObject 메서드가 호출되고 직렬화시엔 writeObject 메서드가 호출된다. 아래 예제 코드를 참고하면 좋다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class Address implements Serializable {

    private String name;

    public Address(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    @Override
    public String toString() {
        return "Test{" +
                "name='" + name + '\'' +
                '}';
    }

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

        s.defaultReadObject();
        System.out.println("[ADDRESS] invoked readObject!");
    }

    public static void main(String[] args) {
        System.out.println("== start ==");

        Address address = new Address("seoul pangyo");

        byte[] serializeResult = serialize(address);
        Object deserializeResult = deserialize(serializeResult);
        System.out.println("deserializeResult = " + deserializeResult);

        System.out.println("== end ==");
    }

    // 주어진 직렬화 형태(바이트 스트림)로부터 객체를 만들어 반환한다.
    static Object deserialize(byte[] sf) {
        try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(sf)) {
            try (ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) {
                return objectInputStream.readObject();
            }
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }

    static byte[] serialize(Object o) {
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
            try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
                oos.writeObject(o);
                // serializedMember -> 직렬화된 member 객체
                return baos.toByteArray();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

// console
== start ==
[ADDRESS] invoked readObject!
deserializeResult = Test{name='seoul pangyo'}
== end ==
  • readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문에 인수가 유효한지 검사해야 하고, 필요하다면 매개변수를 방어적으로 복사해야 한다.
    • 쉽게 말하면 readObject 는 매개변수로 바이트 스트림을 받는 생성자라 볼 수 있다.
  • 그렇지 않으면 공격자는 아주 손쉽게 해당 클래스의 불변식을 깨뜨릴 수 있다.
  • 어떤 의미일까? 공격자가 불변식을 깨뜨릴 의도로 임의 생성한 바이트 스트림을 건네면 역직렬화시 문제가 생길 수 있으며, 정상적인 생성자로는 만들어낼 수 없는 객체를 생성해낼 수 있기 때문이다.
  • 아래 예제 코드에서 Period 클래스에 단순하게 implements Serializable 만 추가했다고 가정했을때 종료 시각이 시작 시각보다 앞서는 Period 인스턴스를 만들 수 있게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class BogusPeriod {
    // 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림,
    // 정상적인 Period 인스턴스를 직렬화한 후에 손수 수정한 바이트 스트림이다.
    private static final byte[] serializedForm = {
        (byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
        0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
        ... 생략
    }

    // 상위 비트가 1인 바이트 값들은 byte로 형변환 했는데,
    // 이유는 자바가 바이트 리터럴을 지원하지 않고 byte 타입은 부호가 있는(signed) 타입이기 때문이다.

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

    // 주어진 직렬화 형태(바이트 스트림)로부터 객체를 만들어 반환한다.
    static Object deserialize(byte[] sf) {
        try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(sf)) {
            try (ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) {
                return objectInputStream.readObject();
            }
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalArgumentException(e);
        }
    }
}
  • 이 문제를 해결하려면 Period 의 readObject 메서드가 defaultReadObject 메서드를 호출한 다음 역직렬화된 객체가 유효한지 검사해야 한다.
  • 이 유효성 검사에 실패하면 InvalidObjectException을 던지게 하여 잘 못된 역직렬화가 일어나는 것을 막을 수 있다.
1
2
3
4
5
6
7
8
9
// 코드88-3 유효성 검사를 수행하는 readObject 메서드 - 아직 부족하다.
private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {

    // 불변식을 만족하는지 검사한다.
    if (start.compareTo(end) > 0) {
        throw new InvalidObjectException(start + "after" + end);
    }
}
  • 위 코드로 인해 공격자가 허용되지 않는 Period 인스턴스를 생성하는 일을 막을 수 있지만, 아직 미묘한 문제 하나가 숨어 있다.
  • 정상 Period 인스턴스에서 시작된 바이트 스트림 끝에 private Date 필드로의 참조를 추가하면 가변 Period 인스턴스를 만들어 낼 수 있게 된다.
    • 공격자는 ObjectInputStream에서 Period 인스턴스를 읽은 후 스트림 끝에 추가된 악의적인 객체 참조를 읽어 Period 객체의 내부 정보를 얻을 수 있다.
  • 이제 이 참조로 얻은 Date 인스턴스들을 수정할 수 있으니, Period 인스턴스는 더 이상 불변이 아니게 되는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 코드 88-4 가변 공격의 예
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) 필드

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

// 결과
Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1978
Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 1969
  • 위 예에서 Period 인스턴스는 불변식을 유지한 채 생성됐지만, 의도적으로 내부 값을 수정할 수 있었다.
    • 이처럼 변경할 수 있는 Period 인스턴스를 획득한 공격자는 이 인스턴스가 불변이라고 가정하는 클래스에 넘겨 엄청난 보안 문제를 일으킬 수 있다.
  • 이 문제의 근원은 Period의 readObject 메서드가 방어적 복사를 충분히 하지 않은데 있다.
    • 객체를 역직렬화할 때는 클라이언트가 소유해선 안되는 객체 참조를 갖는 필드를 모두 방어적으로 복사해야 한다.
  • 따라서 이에 대한 해결책은 readObject 메서드에선 불변 클래스 안의 모든 private 가변 요소를 방어적으로 복사해야 한다. 다음의 readObject 메서드라면 Period 의 불변식과 불변 성질을 지켜내기에 충분한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// 코드 88-5 방어적 복사와 유효성 검사를 수행하는 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 + " after " + end);
    }
}
  • 위 해결 코드에서 방어적 복사를 유효성 검사보다 앞서 수행하며, date 의 clone 메서드는 사용하지 않았음에 주목하자.
    • 두 조치 모두 Period 를 공격으로부터 보호하는데 필요하다.(아이템50)
  • 또한 final 필드는 방어적 복사가 불가능하니 주의하자.
  • 그 결과 앞서 공격 프로그램은 아래와 같은 내용을 출력하게 된다.
1
2
Fri May 31 01:01:06 KST 2019 - Fri May 31 01:01:06 KST 2019
Fri May 31 01:01:06 KST 2019 - Fri May 31 01:01:06 KST 2019

기본 readObject 메서드를 써도 좋은지 판단하는 간단한 방법

  • transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮지 않다면(유효성검사같은게 아예 없는) 커스텀 readObject 를 만들어 모든 유효성 검사와 방어적 복사를 수행해야 한다.
  • 혹은 직렬화 프록시 패턴을 사용하는 방법도 있다.
    • 이 패턴은 역직렬화를 안전하게 만드는데 필요한 노력을 상당히 경감해주므로 적극 권장하는 바이다.
  • final이 아닌 직렬화 가능 클래스라면 readObject와 생성자의 공통점이 하나 더 있다.
    • 마치 생성자처럼 readObject 메서드도 재정의 가능 메서드를 직간접적으로 호출해선 안된다.(아이템19)
    • 이 규칙을 어겼는데 해당 메서드가 재정의되면, 하위 클래스의 상태가 완전히 역직렬화되기 전에 하위 클래스에서 재정의된 메서드가 실행된다.
This post is licensed under CC BY 4.0 by the author.

[Architecture] 헥사고날아키텍처

[이펙티브자바] 아이템89-인스턴스 수를 통제해야 한다면 readResolve 보다는 열거 타입을 사용하라