Item 88 : readObject 메소드는 방어적으로 작성하라
2021. 9. 25. 15:29
- 아이템 50에서 불변인 날자 범위 클래스를 만드는데 가변인 Date 필드를 이용했다.
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(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 객체의 물리적 표현이 논리적 표현과 부합하므로 기본 직렬화 형태를 사용해도 나쁘진 않다.
- 하지만 이렇게 하면 이 클래스의 주요한 불변식을 더는 보장하지 못하게 된다.
주의해야 할점
- readObject 메소드는 실직적으로 또 다른 public 생성자이기 때문에 다른 생성자와 똑같은 수준으로 주의를 기울여야 함
- 보통의 생성자처럼 readObject 메소드에서도 인수가 유효한지 검사해야 하고, 필요하다면 매개변수를 방어적으로 복사해야함
- readObject가 이 작업을 제대로 수행하지 못하면 공격자는 아주 손쉽게 해당 클래스의 불변식을 깨트릴 수 있음
단순히 implements Serializable만 추가한 경우
- 종료 시각이 시작 시각보다 앞서는 Period 인스턴스를 만들 수 있음
// 허용되지 않는 Period 인스턴스 생성
public class BogusPeriod {
private static final byte[] serializedForm = {
(byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x06,
...
...
...
}
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);
}
}
}
- 이 코드의 serializedForm에서 상위 비트가 1인 바이트 값들은 byte로 형변환 했는데, 이는 자바가 바이트 리터럴을 지원하지 않고 byte 타입은 signed 타입이기 때문이다.
- 위 프로그램은
Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984
을 출력 - Period를 직렬화 할 수 있도록 선언한 것만으로 클래스의 불변식을 깨트리는 객체를 만들 수 있음
문제 해결 방법
- 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 인스턴스에서 시작된 바이트 스트림 끝에 private Date 필드로의 참조를 추가하여 가변 Period 인스턴스를 만들어낼 수 있다는 점이다.
- 공격자는 ObjectInputStream에서 Period 인스턴스를 읽은 후 스트림 끝에 추가된 이 '악의적인 객체 참조'를 읽어 Period 객체의 내부 정보를 얻을 수 있다.
// 가변 공격의 예
public class MutablePeriod {
//Period 인스턴스
public final Period period;
//시작 시각 필드 - 외부에서 접근할 수 없어야 한다.
public final Date start;
//종료 시각 필드 - 외부에서 접근할 수 없어야 한다.
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectArrayOutputStream out = new ObjectArrayOutputStream(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);
System.out.println(p); // Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1978
//60년대로 회귀!
pEnd.setYear(60);
System.out.println(p); // Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1969
}
객체를 역직렬화 할 때는 클라이언트가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야함
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 + "보다 늦다.");
}
}
- 기본 readObject 메소드를 써도 좋을지를 판단하는 방법
- transient필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 되는 경우
- 직렬화 프록시 패턴을 사용
'책 > 이펙티브자바' 카테고리의 다른 글
Item 90 : 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라 (0) | 2021.09.27 |
---|---|
Item 89 : 인스턴스 수를 통제해야 한다면 readResolve 보다는 열거 타입을 사용하라 (0) | 2021.09.26 |
Item 87 : 커스텀 직렬화 형태를 고려해보라 (0) | 2021.09.24 |
Item 86 : Serializable을 구현할지는 신중히 결정하라 (0) | 2021.09.23 |
Item 85 : 자바 직렬화의 대안을 찾으라 (0) | 2021.09.22 |