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()); // 가변인 Date 클래스의 위험을 막기 위해 새로운 객체로 방어적 복사
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " after " + end);
}
public Date start() { return new Date(start.getTime()); }
public Date end() { return new Date(end.getTime()); }
public String toString() { return start + " - " + end; }
// 나머지는 생략
}
물리적 표현이 논리적 표현과 부합하므로 기본 직렬화 형태(#87)를 사용해도 무방할 것!
implements Serializable만 추가하면 되는건가?!
불변식이 깨지게 됩니다. 왜? readObject()가 또 다른 public 생성자이기 때문
readObject()도 생성자와 똑같은 수준으로 주의!
인수가 유효한지 검사해야 합니다. (#49)
필요하다면 매개변수를 방어적으로 복사해야 합니다. (#50)
readObject()를 왜 주의해야 하지?
readObject()는 쉽게 말해, 매개변수로 바이트 스트림을 받는 생성자라고 할 수 있습니다.
보통 바이트 스트림은 정상적으로 생성된 인스턴스를 직렬화해서 만들어집니다.
하지만, 불변을 깨뜨릴 의도로 만들어진 바이트 스트림을 받으면 문제가 생깁니다.
정상적인 방법으로는 만들어낼 수 없는 객체를 생성해낼 수 있기 때문!
만약 위의 Period 클래스에 implements Serializable만 추가했다면?
public class Item88Application {
// 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림
// 정상 Period 인스턴스를 직렬화한 후, 손수 수정한 바이트 스트림
private static final byte[] serializedForm = new byte[] {
(byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
0x50, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x40, 0x7e, (byte)0xf8,
0x2b, 0x4f, 0x46, (byte)0xc0, (byte)0xf4, 0x02, 0x00, 0x02,
0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
(byte)0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte)0xdf,
0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
0x77, 0x08, 0x00, 0x00, 0x00, (byte)0xd5, 0x17, 0x69, 0x22,
0x00, 0x78
};
// 상위 비트가 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);
}
}
}
Serializable하다고 선언한 것 만으로도 클래스의 불변성을 깨뜨리는 객체를 만들 수 있게 됩니다.
// 출력 결과
// end가 start보다 과거입니다. 즉, Period의 불변식이 깨짐!
Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984
어떻게 해결하지?
Period의 readObject가 defaultReadObject를 호출한 후, 역직렬화된 객체가 유효한지 검사해야 합니다!
이 유효성 검사에 실패한다면 InvalidOjbectException을 던지게 하여 잘못된 역직렬화를 막을 수 있습니다.
// 유효성 검사를 수행하는 readObject 메소드 - 아직 부족하다!
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// 불변식을 만족하는지 검사한다.
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + "after" + end);
}
하지만 아직 문제가 남아있다.
정상 Period 인스턴스에서 만든 바이트 스트림의 끝에 private Date 필드로의 참조를 추가하면,
가변 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.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); // 1978년
System.out.println(p);
// 60년대로 설정됨
pEnd.setYear(69); // 1969년
System.out.println(p);
}
}
// 출력 결과
// end의 연도가 변경됨!
Fri May 31 00:52:59 KST 2019 - Wed May 31 00:52:59 KST 1978
Fri May 31 00:52:59 KST 2019 - Sat May 31 00:52:59 KST 1969
이 문제의 근원은, Period의 readObject 메서드가 방어적 복사를 잘 안한 것!
역직렬화를 할 때는 클라이언트가 접근해서는 안되는 객체 참조를 갖는 필드는 모두 반드시 방어적으로 복사해야 합니다.
방어적 복사와 유효성 검사를 모두 적용한 후의 Period는 어떨까?
Period를 공격으로부터 보호하기 위해, (#50)
방어적 복사를 유효성 검사보다 앞서 수행
Date.clone은 사용하지 않았음!
// 방어적 복사와 유효성 검사를 수행하는 readObject 메소드
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// 가변 요소들을 방어적으로 복사한다.
// 방어적 복사를 위해서 start, end 필드에서 final을 제거해야 합니다.
start = new Date(start.getTime());
end = new Date(end.getTime());
// 불변식을 만족하는지 검사한다.
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + "after" + end);
}
// MutablePeriod의 main 메소드 출력 결과
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 메서드를 만들어서 모든 유효성 검사 + 방어적 복사를 수행해야 합니다.
혹은 직렬화 프록시 패턴(#90)을 사용해도 됩니다. 추천!
final이 아닌, Serializable 클래스라면 readObject와 생성자의 공통점이 하나 더 있습니다.
생성자처럼 readObject 메서드도 override 가능한 메서드를 호출해서는 안됩니다! (#19)
하위 클래스의 상태가 완전히 역직렬화되기 전에 하위 클래스에서 override된 메서드가 실행되기 때문입니다.
정리하자면
readObject 메서드를 작성할 때는 언제나 public 생성자를 작성하는 자세로 임하자.
readObject는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다.
방어적 복사를 사용하는 불변 클래스
방어적 복사를 이용한 #50 의 Period 클래스
implements Serializable
만 추가하면 되는건가?!readObject()
가 또 다른 public 생성자이기 때문readObject()
도 생성자와 똑같은 수준으로 주의!readObject()
를 왜 주의해야 하지?readObject()
는 쉽게 말해, 매개변수로 바이트 스트림을 받는 생성자라고 할 수 있습니다.만약 위의
Period
클래스에implements Serializable
만 추가했다면?어떻게 해결하지?
Period
의readObject
가defaultReadObject
를 호출한 후, 역직렬화된 객체가 유효한지 검사해야 합니다!이 유효성 검사에 실패한다면
InvalidOjbectException
을 던지게 하여 잘못된 역직렬화를 막을 수 있습니다.하지만 아직 문제가 남아있다.
Period
인스턴스에서 만든 바이트 스트림의 끝에private Date
필드로의 참조를 추가하면, 가변Period
인스턴스를 만들어낼 수 있습니다.Period
객체의 내부 정보를 얻을 수 있기 때문이죠.이 참조를 이용해
Date
인스턴스를 수정하게 된다면...Period
는 더이상 불변이 아니게 됩니다.Period
의readObject
메서드가 방어적 복사를 잘 안한 것!방어적 복사와 유효성 검사를 모두 적용한 후의
Period
는 어떨까?Period
를 공격으로부터 보호하기 위해, (#50)Date.clone
은 사용하지 않았음!// 가변 요소들을 방어적으로 복사한다. // 방어적 복사를 위해서 start, end 필드에서 final을 제거해야 합니다. start = new Date(start.getTime()); end = new Date(end.getTime());
// 불변식을 만족하는지 검사한다. if (start.compareTo(end) > 0) throw new InvalidObjectException(start + "after" + end); }
// MutablePeriod의 main 메소드 출력 결과 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
메서드는 언제 사용해도 좋을까?readObject
메서드를 만들어서 모든 유효성 검사 + 방어적 복사를 수행해야 합니다.readObject
와 생성자의 공통점이 하나 더 있습니다.readObject
메서드도 override 가능한 메서드를 호출해서는 안됩니다! (#19)정리하자면
readObject
메서드를 작성할 때는 언제나 public 생성자를 작성하는 자세로 임하자.readObject
는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다.readObject
메서드를 작성하는 지침InvalidObjectException
을 던져라.ObjectInputValidation
인터페이스를 사용하라. (책에서는 다루지 않음)