하나의 클래스를 직접 직렬화 하면서 무엇을 주의해야 하는지, Serializable 이 보안적인 측면에서 무엇을 주의해야 할지 다뤄본다. Serializable 을 지양하는 이유중에 하나가 보안인데 이 item 은 특히 이 부분을 대응하기 위해 어떻게 방어적인 패턴을 적용할지 살핀다.
88. Write readObject methods defensively
item 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) {
if (start.compareTo(end) > 0)
throw new IllegalArgumentException(start + "가 " + end + "보다 늦다.");
this.start = start;
this.end = end;
}
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
}
Java
복사
논리적 구현과 물리적 구현이 일치하여 기본 implements Serializable 만 붙여도 되겠다고 생각할 수 있지만 그렇지 않다.
이유는 이 클래스의 핵심적인 불변식을 보장하지 못하기 때문이다.
readObject 는 public API 이다.
•
접근 제어자가 private 이지만 역직렬화 과정에서 이것은 생성자와 동일하다.
•
따라서 생성자에서 수행하는 validation check 가 동일하게 수행되어야 한다.
public class BogusPeriod {
// 진짜 Period 인스터느에서는 만들어질 수 없는 바이트 스트림
private static final byte[] serializedForm = {
(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
};
public static void main(String[] args) {
Period p = (Period) deserialize(serializedForm);
System.out.println(p);
}
// Returns the object with the specified serialized form
static Object deserialize(byte[] sf) {
try {
return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject();
} catch (IOException | ClassNotFoundException e) {
throw new IllegalArgumentException(e);
}
}
}
Java
복사
위 역직렬화가 내 컴퓨터에선 진행이 되지 않는데 아마도 package 이름이 일치하지 않아서 그런것 같다.
하지만 결과는 end < start 로 불변식이 깨지게 된다.
즉, 클래스가 지켜야하는 규칙을 무시하고 Period 가 생성된다.
이 문제를 고치기 위해 다음과 같은 코드가 추가 되어야 한다.
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
if (start.compareTo(end) > 0) {
throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
}
}
Java
복사
악의적인 공격, 비잔틴 결함
start, end 는 final 변수이다. 하지만 이 참조에 접근하여 Date 인스턴스들을 수정할 수 있다.
public class MutablePeriod {
public final Period period;
public final Date start;
public final Date end;
public MutablePeriod() {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(bos);
out.writeObject(new Period(new Date(), new Date()));
byte[] ref = { 0x71, 0, 0x7e, 0, 5};
bos.write(ref);
ref[4] = 4;
bos.write(ref);
ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
period = (Period) in.readObject();
start = (Date) in.readObject();
end = (Date) in.readObject();
} catch (IOException | ClassNotFoundException | SecurityException 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);
pEnd.setYear(69);
System.out.println(p);
}
}
Java
복사
이처럼 변경할 수 있는 Period 인스턴스를 획득하면 공격적는 이 인스턴스가 불변이라고 가정하는 클래스에 값을 수정하여 엄청난 보안 문제를 일으킬 수 있다.
이것이 위험한 이유는 String 도 불변이라는 사실에 기댄 class 가 존재하기 때문이다.
객체를 역직렬화 할 때는 client 가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다.
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 + "보다 늦다.");
}
}
Java
복사
방어적 복사와 유효성 검사를 수행한다.
이때 start, end 는 final 을 제거해야 하는데 그럼에도 불구하고 같은 공격에 값이 변경되지 않는다.
final 필드들은 방어적 복사가 불가능하니 주의해야 한다.
그래서 readObject 를 사용하면 final 을 제거해야 하는데 아쉽지만 공격 위험에 노출되는 것 보다 낫다.
결론
transient 를 제외하고 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 객체를 생성해도 괜찮은가?
만약 아니라면 custom readObject 를 만들어 유효성 검사와 방어적 복사를 수행해야 한다.
이 방어적 복사가 들어간 코드는 override 가 되지 않도록 해야한다.
만약 상속받은 곳에서 재정의가 가능하다면 위의 규칙이 깨진채로 역직렬화 되어 객체가 생성될 것이다.
•
readObject 는 public 생성자를 작성하는 수준으로 설계해야 한다.
•
어떤 byte stream 이 넘어오더라도 진짜 역직렬화 인스턴스라고 가정하지 말아야 한다.
•
private 이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라 불변 클래스 내의 가변 요소가 여기 속한다.
•
모든 불변식을 검사하여 어긋나면 InvalidObjectException 을 던지고, 방어적 복사 다음에 불변식 검사를 수행해야 한다.
•
역직렬화 이후 그래프 전체의 유효성이 필요하면 ObjectInputValidation 을 사용해야 한다(이 책에서는 다루지 않는다)
•
직접적이든 간접적이든, override 할 수 있는 method 는 호출하지 않는다