Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Item89. 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라 #85

Open
Mingadinga opened this issue Sep 19, 2023 · 0 comments

Comments

@Mingadinga
Copy link
Member

싱글톤과 같이 불변식을 지키기 위해 인스턴스를 통제해야 한다면 가능한 열거 타입을 사용하자. 여의치 않은 상황에서 직렬화와 인스턴스 통제가 모두 필요하다면 readResolve 메서드를 작성해 넣고, 그 클래스에서 모든 참조 타입 인스턴스 필드를 transient로 선언해야한다.

깨지는 싱글턴

생성자를 숨기는 싱글톤은 직렬화를 사용하는 순간 더 이상 싱글톤이 아니게 된다.

public class Elvis implements Serializable {
    private static final Elvis INSTANCE = new Elvis();

    private Elvis() { }

    public static Elvis getInstance(){
        return INSTANCE;
    }
}

public class ElvisTest {

    @Test
    public void 깨지는_싱글톤() throws IOException, ClassNotFoundException {
        Elvis originalSingleton = Elvis.getInstance();
        byte[] serializedObject = getSerializedObject(originalSingleton);

        try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(serializedObject);
             ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) {
            Elvis deserializedSingleton = (Elvis) objectInputStream.readObject();

            assertThat(deserializedSingleton).isEqualTo(originalSingleton);
						// 실패! readObject를 사용하면 싱글톤으로 만들어진 인스턴스와는 별개의 인스턴스를 반환한다
        }
    }

    private byte[] getSerializedObject(Elvis originalSingleton) throws IOException {
        byte[] serializedObject;

        try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
             ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream)) {
            Elvis elvis = Elvis.getInstance();
            objectOutputStream.writeObject(elvis);
            serializedObject = byteArrayOutputStream.toByteArray();
        }

        return serializedObject;
    }

}

readResolve로 싱글턴 깨지는 것 막기

image

  • readObject만으로는 불가능하다.
  • 대신 readResolve를 사용하면 readObject가 만들어낸 인스턴스(싱글톤이 아닌 인스턴스)를 다른 것으로 대체할 수 있다.
  • 이때 readObject가 생성한 객체의 참조는 유지되지 않으므로 바로 GC 대상이 된다.
public class Elvis implements Serializable {
    private static final Elvis INSTANCE = new Elvis();

    private Elvis() { }

    public static Elvis getInstance(){
        return INSTANCE;
    }
    
    private Object readResolve() {
        // 진짜 Elvis를 반환하고, 직렬화가 깨뜨린 가짜 싱글톤 Elvis는 GC에 맡김
        return INSTANCE;
    }

}

non transient 필드 공격

readResolve를 인스턴스 통제 목적으로 사용한다면, 객체 참조 타입 인스턴스 필드는 모두 transient로 선언해야 한다. 그렇지 않으면 readResolve가 수행되기 전에 역직렬화된 객체의 참조를 공격할 여지가 남는다.

역직렬화 공격방식

  • 싱글턴이 Transient가 아닌 참조 필드를 가지고 있다면, 그 필드 내용은 readResolve()가 실행되기 전에 역직렬화 된다.
  • 그렇다면 잘 조작된 스트림을 써서 해당 참조 필드의 내용이 역직렬화되는 시점에 그 역직렬화된 인스턴스의 참조를 훔쳐올 수 있다.
public class Elvis implements Serializable {
    private static final Elvis INSTANCE = new Elvis();

		// non transient 필드
    private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };

    public String[] getFavorites() {
        return favoriteSongs;
    }

    private Elvis() { }

    public static Elvis getInstance(){
        return INSTANCE;
    }

    private Object readResolve() {
        // 진짜 Elvis를 반환하고, 직렬화가 깨뜨린 가짜 싱글톤 Elvis는 GC에 맡김
        return INSTANCE;
    }

}

// 공격 클래스
public class ElvisStealer implements Serializable {
    static Elvis impersonator;
    private Elvis payload;
	
		// 싱글턴과 도둑 사이에 순환 참조가 있으므로
		// 싱글턴이 역질렬화될 때 도둑의 readResolve 메서드가 먼저 호출된다
		// 도둑의 readResolve 메서드가 수행될 때 도둑의 인스턴스 필드에는 역직렬화 도중인(그리고 readResolve가 수행되기 전인) 싱글턴의 참조가 담겨 있게 된다.
    private Object readResolve() {
        // resolve 되기 전의 Elvis 인스턴스(깨진 싱글톤) 참조를 저장한다.
        impersonator = payload;

        // favoriteSongs 필드에 맞는 타입의 객체 반환
        return new String[] { "A Fool Such as I" };
    }

    private static final long serialVersionUID = 0;
}

public class ElvisTest {
		// 조작된 스트림
		private static final byte[] serializedForm = {
            -84, -19, 0, 5, 115, 114, 0, 20, 107, 114, 46, 115,
            101, 111, 107, 46, 105, 116, 101, 109, 56, 57, 46, 69,
            108, 118, 105, 115, 98, -14, -118, -33, -113, -3, -32,
            70, 2, 0, 1, 91, 0, 13, 102, 97, 118, 111, 114, 105, 116,
            101, 83, 111, 110, 103, 115, 116, 0, 19, 91, 76, 106, 97,
            118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110,
            103, 59, 120, 112, 117, 114, 0, 19, 91, 76, 106, 97, 118,
            97, 46, 108, 97, 110, 103, 46, 83, 116, 114, 105, 110, 103,
            59, -83, -46, 86, -25, -23, 29, 123, 71, 2, 0, 0, 120, 112,
            0, 0, 0, 2, 116, 0, 9, 72, 111, 117, 110, 100, 32, 68, 111,
            103, 116, 0, 16, 72, 101, 97, 114, 116, 98, 114, 101, 97, 107,
            32, 72, 111, 116, 101, 108
    };

    @Test
    public void non_transient_공격() throws IOException, ClassNotFoundException {
        Elvis elvis = (Elvis) deserialize(serializedForm);
        Elvis impersonator = ElvisStealer.impersonator;

        assertThat(elvis.getFavorites()).isEqualTo(impersonator.getFavorites());
				// { "Hound Dog", "Heartbreak Hotel" }
				// { "A Fool Such as I" }
    }

}

열거 타입 싱글턴

transient 필드를 선언하고 readResolve 메소드를 구현해서 싱글톤이 깨지는 것을 막을 수 있지만, 사실 원소 하나짜리 enum으로 바꾸는 것이 더 나은 선택이다. (꽤 손이 많이 간다) 만약 컴파일 타임에 어떤 인스턴스가 있는지 알 수 없다면 enum보다는 readResolve를 구현하는 것이 낫다.

public enum Elvis {
    INSTANCE;
    private String[] favoriteSongs =
        { "Hound Dog", "Heartbreak Hotel" };
    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
}

readResolve의 접근성

  • final 클래스 : private
  • non final 클래스
    • private : 하위 클래스에서 사용 불가능
    • package private : 같은 패키지에 속한 하위 클래스에서만 사용
    • protected나 public : 재정의하지 않은 모든 하위 클래스에서 사용 가능
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant