아이템 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);
}
}
}
- 이 문제를 고치려면
Period
의readObject
메소드가defaultReadObject
를 호출한 다음 역직렬화된 객체가 유효한지 검사해야 한다. - 만약 유효성 검사에 실패하면
InvalidObjectException
을 던지게 하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다.
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
// 불변식을 만족하는지 검사한다.
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
}
- 하지만 아직 미묘한 문제가 하나 숨어 있다. 정상
Period
인스턴스에서 시작된 바이트 스트림 끝에 privateDate
필드로의 참조를 추가하면 가변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 + "보다 늦다.");
}
- 방어적 복사를 유효성 검사보다 앞서 수행하며,
Date
의clone()
을 사용하지 않았음에 주목하자. 두 조치 모두Period
를 공격으로부터 보호하는 데 필요하다. - 또한
final
필드는 방어적 복사가 불가능하니 주의하자. 이를 위해final
한정자를 제거하였다.
기본 readObject를 써도 좋을지 판단하는 방법
transient
필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮은지 확인하고, 그 답이 아니라면 커스텀readObject
메소드를 만들어 (생성자에서 수행했어야 할) 모든 유효성 검사와 방어적 복사를 수행해야 한다.- 혹은 직렬화 프록시 패턴을 사용하는 방법도 있다. 이 패턴은 역직렬화를 안전하게 만드는 데 필요한 노력을 상당히 경감해주므로 적극 권장한다.
기타 주의사항
- final이 아닌 직렬화 가능 클래스라면
readObject
메소드도 생성자와 마찬가지로 재정의 가능 메소드를 직간접적으로 호출해서는 안된다. - 이 규칙을 어긴 상태에서 해당 메소드가 재정의되면 하위 클래스의 상태가 완전히 역직렬화되기 전에 하위 클래스에서 재정의된 메소드가 실행된다.
핵심 정리
readObject
메소드를 작성할 때는 public 생성자와 마찬가지로 조심해야 한다.readObject
는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다. 바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해서는 안된다.- 안전한
readObject
메소드를 작성하는 지침은 다음과 같다.- private 이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
- 모든 불변식을 검사하여 어긋나는 게 발견되면
InvalidObjectException
을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다. - 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면
ObjectInputValidation
인터페이스를 사용하라. - 직접적이든 간접적이든 재정의할 수 있는 메소드는 호출하지 말자.
참고 자료
- Effective Java 3/E