📌 Singleton 패턴과 직렬화
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leavingTheBuilding() {...}
}
- 위 클래스는 외부 생성자 호출을 막음으로써, 인스턴스가 오직 하나만 만들어짐을 보장하는 싱글턴 패턴이다.
- 하지만 `implements Serializable`을 추가하는 순간 더 이상 싱글턴이 아니게 된다.
- 기본 직렬화(Item 87)을 쓰지 않고, 명시적인 readObject(Item 88)을 사용해도 소용없다.
- writeObject()를 제공하더라도 소용이 없다.
🤔 과연 그럴까?
public class Main {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Elvis elvis = Elvis.getInstance();
byte[] serialized;
// 직렬화
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(elvis);
serialized = baos.toByteArray();
}
}
// 역직렬화
Elvis deserializedElvis;
try (ByteArrayInputStream bais = new ByteArrayInputStream(serialized)) {
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
deserializedElvis = (Elvis) ois.readObject();
}
}
assertEquals(elvis, deserializedElvis);
}
}
- 실제로 두 인스턴스는 같지 않다는 결과가 나온다. readObject 메서드로 인해 새로운 인스턴스가 생성되기 때문이다.
📌 readResolve
// 아직 개선의 여지가 있다.
public class Elvis implements Serializable {
private static final Elvis INSTANCE = new Elvis();
private Elvis() {}
public static Elvis getInstance() { return INSTANCE; }
// 인스턴스 통제를 위한 readResolve
private Object readResolve() {
return INSTANCE;
}
}
- readObject가 만들어낸 인스턴스를 받아 호출되는 함수
- readObject에서 어떤 객체를 만들었는지와 관계없이 readResolve가 만든 객체가 반환된다.
- 즉, 생성된 역직렬화된 객체는 GC에 던져버리고 내가 원하는 객체를 던져줄 수 있게 된다.
수정한 Elvis 객체로 이전 테스트를 실행하면 싱글톤 패턴을 유지할 수 있다.
📌 As-is. transient 필드
💡 readResolve를 인스턴스 통제 목적으로 사용한다면 객체 참조 타입 인스턴스 필드는 모두 transient로 선언하라.
- 어차피 역직렬화로 만들어지는 객체는 실데이터가 필요없으므로 모든 인스턴스 필드를 transient로 선언하라.
- 그러지 않으면 MutablePeriod 공격과 비슷한 방식으로 readResolve 메서드 실행 전에 역직렬화된 객체의 참조를 공격할 여지가 있다.
⚔️ 역직렬화 객체 공격
public class Elvis implements Serializable {
public static final Elvis INSTANCE = newElvis();
private Elvis() { }
// transient가 아닌 참조 필드 - 빈틈 발견!
private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"};
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
private Object readResolve() {
return INSTANCE;
}
}
// non-transient 참조 필드를 훔치는 도도도독(stealer) 클래스
public class ElvisStealer implements Serializable {
static Elvis impersonator;
private Elvis payload;
private Object readResolve() {
// resolve되기 전의 Elvis 인스턴스의 참조를 저장한다.
impersonator = payload;
// favoriteSongs 필드에 맞는 타입의 객체를 반환한다.
return new String[]{"A Fool Such as I"};
}
private static final long serialVersionUID = 0;
}
// 공격!
public class ElvisImpersonator {
// 진짜 Elvis 인스턴스로는 만들어질 수 없는 바이트 스트림
private static final byte[] serializedForm = {
// ... 어쩌구 저쩌구 엄청 긴 조작된 바이트 스트림
};
public static void main(String[] args) {
// ElvisStealer.impersonator를 초기화한 다음,
// 진짜 Elvis(즉 Elvis.INSTANCE)를 반환한다.
Elvis elvis = (Elvis) deserialize(serializedForm);
Elvis impersonator = ElvisStealer.impersonator;
elvis.printFavorites();
impersonator.printFavorites();
}
}
[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]
- 도둑 클래스를 작성하고 Elvis의 참조를 훔칠 수 있도록 bytestream 조작하여 역직렬화 시키면 된다.
- 직렬화된 stream에서 싱글턴의 비휘발성 필드들을 도둑의 인스턴스로 교체한다.
- 결국 싱글턴은 도둑을 참조하고, 도둑은 싱글턴을 참조하는 순환고리가 형성된다.
- 도둑의 readResolve가 먼저 호출되어, 역직렬화 도중인 싱글톤의 참조를 static 필드로 복사한다. (readResolve 후에도 참조가 가능해진다.)
- 원래 타입에 맞는 다른 값을 반환하도록 하면 끝난다. (이 과정을 안 하면 ClassCastException이 나서 도둑질 실패..🙄)
- 결과적으로 서로 다른 2개의 Elvis 인스턴스가 생성되어 싱글턴이 깨진다.
🛡️ transient 방어
- favoriteSons 필드를 transient로 선언하면 고칠 수 있는 문제다.
- 하지만 순간적으로 만들어진 역직렬화 인스턴스 접근 방어는 깨지기 쉽고, 신경을 많이 써야 한다. (휴먼에러 나기 십상)
- 그래도 컴파일 타임에는 어떤 인스턴스가 있을지 모르는 상황이면서 인스턴스를 통제해야 하는 경우엔 이 방법이 최선이다.
📌 To-be. enum type
public enum Elvis {
INSTANCE;
private String[] favoriteSongs = {"Hound Dog", "Heartbreak Hotel"};
public void printFavorites() {
System.out.printtn(Arrays.toString(favoriteSongs));
}
}
- 열거 타입은 선언한 상수 외의 다른 객체가 존재하지 않음을 자바가 보장해준다. (난 왜 앞에서 그 고생을..)
- AccessibleObject.setAccessible같은 특권(privileged) 메서드를 악용하면 뚫릴 수는 있는데, 애초에 네이티브 코드 수행 특권을 가로챈 공격자에겐 모든 방어가 무력화되니 논외라고 치자.
📌 readResolve의 접근성
- 반드시 private여야 한다.
- final이 아닌 클래스에선 private를 주면 상속이 불가능해지니 제한자를 바꿔야 할 텐데..(애초에 설계를 잘못한 게 아닐까?)
- public/protected면 반드시 하위 클래스에서 재정의해야 한다. 안 그러면 하위 클래스 인스턴스를 역직렬화할 때 상위 클래스의 인스턴스를 생성해 ClassCastException을 일으킬 수 있다.