-
Notifications
You must be signed in to change notification settings - Fork 0
Description
방어적 복사를 사용하는 불변 클래스 - 직렬화한다면?
// 코드 50-1 기간을 표현하는 클래스 - 불변식을 지키지 못했다. (302-305쪽)
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(
this.start + "가 " + this.end + "보다 늦다.");
}
// 필드의 방어적 복사본을 반환한다.
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
public String toString() {
return start + " - " + end;
}
// ... 이하 생략
}-
아이템 50에서는 불변식을 지키고 불변을 유지하기 위해 생성자와 접근자에서 Date 객체를 방어적으로 복사했었다.
-
이 클래스를 직렬화하기로 결정했다고 하자. 단순히 클래스 선언에
impelemts Serializable을 추가한다면, 이 클래스의 주요한 불변식을 더는 보장하지 못하게 된다. -
왜?
readObject메서드가 실질적으로 또 다른 public 생성자이기 때문 -
따라서 다른 생성자와 똑같은 수준으로 주의를 기울여야 함. 그렇지않으면 공격자가 아주 손쉽게 이 클래스의 불변식을 깨뜨릴 수 있음
- readObject 메서드에서도 인수가 유효한지 검사해야하고(아이템 49)
- 필요하다면 매개변수를 방어적으로 복사해야 함(아이템 50)
-
쉽게 말해, readObject는 매개변수로 바이트 스트림을 받는 생성자라고 할 수 있다.
- 보통의 경우 바이트 스트림은 정상적으로 생성된 인스턴스를 직렬화해서 만들어진다.
- 하지만 불변식을 깨뜨릴 의도로 임의 생성한 바이트 스트림을 건네면 문제가 생긴다. 정상적인 생성자로는 만들 수 없는 객체를 생성해낼 수 있기 때문이다.
-
단순히 Period 클래스 선언에
impelemts Serializable를 추가했다고 가정하면, 다음의 괴이한 프로그램을 수행하면 종료 시각이 시작 시각보다 앞서는 Period 인스턴스를 만들 수 있다.public class BogusPeriod { // 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림 private static final byte[] serializedForm = { (byte)0xac, (byte)0xed, 0x00, 0x05, ... }; 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); } } }
-
이 프로그램을 실행하면 Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984를 출력한다.
- Period를 직렬화할 수 있도록 선언한 것만으로, 클래스의 불변식을 깨뜨리는 객체를 만들수 있음을 의미
문제 해결하기 1 - 객체 유효성 검사
-
이 문제를 고치려면 Period의 readObject 메서드가 defaultReadObject를 호출한 다음, 역직렬화 객체가 유효한지 검사해야한다.
-
유효성 검사에 실패하면
InvalidObjectException을 던지게하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다. -
유효성 검사를 수행하는 readObject 메서드(아직 부족하다)
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { s.defaultReadObject(); // 불변식을 만족하는지 검사한다. if (start.compareTo(end) > 0) throw new InvalidObjectException(start + "가" + end + "보다 늦다."); }
여전히 남은 문제
-
위 작업으로 공격자가 허용되지 않는 Period 인스턴스를 생성하는 일을 막을 수 있지만, 아직도 미묘한 문제가 하나 숨어있다.
- 정상 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 인스턴스를 직렬화한다. out.writeObejct(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(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
문제 해결 2 - 방어적 복사와 유효성 검사를 수행
-
위 예에서 Period 인스턴스는 불변식을 유지한 채 생성됐지만, 의도적으로 내부의 값을 수정할 수 있었다.
- 이 문제의 근원은 Period의 readObject 메서드가 방어적 복사를 충분히 하지 않은 데 있다.
-
따라서 readObject에서는 불변 클래스 안의 모든 private 가변 요소를 방어적으로 복사해야 한다.
-
방어적 복사와 유효성 검사를 수행하는 readObject 메서드
- 방어적 복사를 유효성 검사보다 앞서 수행하며, Date의 clone 메서드는 사용하지 않았음에 주목하자.
- 두 조치 모두 Period를 공격으로부터 보호하는 데 필요하다.
- 또한 start와 end 필드에서 final 한정자를 제거해야한다.
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 + "보다 늦다."); }
-
이제 앞의 공격 프로그램은 다음 내용을 출력한다.
Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 2017 Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 2017
참고) 기본 readObject 메서드를 써도 좋을지를 판단하는 간단한 방법
- transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮은가?에 대한 답이 ‘아니오'라면 → 커스텀 readObject 메서드를 만들어 모든 유효성 검사와 방어적 복사를 수행해야 함 (또는 직렬화 프록시 패턴-아이템90을 사용)
핵심 정리
- readObject 메서드를 작성할 때는 public 생성자를 작성하는 자세로 신중하게 임해야 한다.
- readObject는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어야 한다.
- 안전한 readObject 메서드를 작성하는 지침
- private이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
- 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야한다.
- 직접적이든 간접적이든, readObject에서는 재정의할 수 있는 메서드는 호출하지 말자.