방어적 복사를 사용하는 불변 클래스 - 직렬화한다면?
// 코드 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에서는 재정의할 수 있는 메서드는 호출하지 말자.
방어적 복사를 사용하는 불변 클래스 - 직렬화한다면?
아이템 50에서는 불변식을 지키고 불변을 유지하기 위해 생성자와 접근자에서 Date 객체를 방어적으로 복사했었다.
이 클래스를 직렬화하기로 결정했다고 하자. 단순히 클래스 선언에
impelemts Serializable을 추가한다면, 이 클래스의 주요한 불변식을 더는 보장하지 못하게 된다.왜?
readObject메서드가 실질적으로 또 다른 public 생성자이기 때문따라서 다른 생성자와 똑같은 수준으로 주의를 기울여야 함. 그렇지않으면 공격자가 아주 손쉽게 이 클래스의 불변식을 깨뜨릴 수 있음
쉽게 말해, readObject는 매개변수로 바이트 스트림을 받는 생성자라고 할 수 있다.
단순히 Period 클래스 선언에
impelemts Serializable를 추가했다고 가정하면, 다음의 괴이한 프로그램을 수행하면 종료 시각이 시작 시각보다 앞서는 Period 인스턴스를 만들 수 있다.이 프로그램을 실행하면 Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984를 출력한다.
문제 해결하기 1 - 객체 유효성 검사
이 문제를 고치려면 Period의 readObject 메서드가 defaultReadObject를 호출한 다음, 역직렬화 객체가 유효한지 검사해야한다.
유효성 검사에 실패하면
InvalidObjectException을 던지게하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다.유효성 검사를 수행하는 readObject 메서드(아직 부족하다)
여전히 남은 문제
위 작업으로 공격자가 허용되지 않는 Period 인스턴스를 생성하는 일을 막을 수 있지만, 아직도 미묘한 문제가 하나 숨어있다.
가변 공격의 예
다음 코드를 실행하면 이 공격이 실제로 이뤄지는 모습을 확인할 수 있다.
실행 결과
문제 해결 2 - 방어적 복사와 유효성 검사를 수행
위 예에서 Period 인스턴스는 불변식을 유지한 채 생성됐지만, 의도적으로 내부의 값을 수정할 수 있었다.
따라서 readObject에서는 불변 클래스 안의 모든 private 가변 요소를 방어적으로 복사해야 한다.
방어적 복사와 유효성 검사를 수행하는 readObject 메서드
이제 앞의 공격 프로그램은 다음 내용을 출력한다.
참고) 기본 readObject 메서드를 써도 좋을지를 판단하는 간단한 방법
핵심 정리