Reference/Effective-Java

[Effective-Java] Chapter12 #89. 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라

나죽못고나강뿐 2023. 10. 4. 18:58
📌 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 필드

아니, 오랜만에 봤다고 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을 일으킬 수 있다.