-
[이펙티브 자바 스터디] 직렬화 - 2책/Effective Java 2024. 6. 19. 00:14
readObject 메서드는 방어적으로 작성하라
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 + " after " + end); } public Date start() { return new Date(start.getTime()); } public Date end() { return new Date(end.getTime()); } public String toString() { return start + " - " + end; } ... }
start 와 end 의 불변성을 지키기 위해 방어적 복사를 사용한 코드이다.
period 객체의 물리적 표현이 논리적 표현과 부합하므로 기본 직렬화 형태를 사용해도 괜찮아 보인다.
고로 implements Serializable을 하면 될 것같은데 그러면 불변성을 해친다고 한다.
readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문이라고 한다.
readObject에 보통의 생성자처럼 인수가 유효한지 검사해야 하고
필요하다면 매개변수를 방어적으로 복사해야 한다고 한다.
readObject 가 이 작업을 제대로 수행하지 못하면 공격자는 아주 손쉽게 해당 클래스의 불변식을 깨뜨릴 수 있다는데
아래의 코드를 보자.
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); } // 주어진 직렬화 형태(바이트 스트림)로부터 객체를 만들어 반환한다. static Object deserialize(byte[] sf) { try { return new ObjectInputStream(new ByteArrayInputStream(sf)).readObject(); } catch (IOException | ClassNotFoundException e) { throw new IllegalArgumentException(e); } } }
implements Serializable만 사용했다면 위의 코드를 통해서
end 가 start 보다 빠른 인스턴스를 만들 수 있다고 한다.
이 문제를 고치려면 Period의 readObject 메서드가 defaultReadObject를 호출한 다음
역직렬화된 객체가 유효한지 검사해야 한다고 한다.
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { s.defaultReadObject(); if (start.compareTo(end) > 0) throw new InvalidObjectException(start +" after "+ end); }
하지만 이런 조치에도 구멍이 있다고 한다.
정상 Period 인스턴스에서 시작된 바이트 스트림 끝에 private Date 필드로의 참조를 추가하면
가변 Period 인스턴스를 만들어낼 수 있다고 한다.
공격자는 ObjectInputStream에서 Period 인스턴스를 읽은 후
스트림 끝에 추가된 이 ‘악의적인 객체 참조’를 읽어 Period 객체의 내부정보를 얻을 수 있다고 한다.
그러면 참조로 얻은 Date 인스턴스들을 수정할 수 있으니 불변식이 깨지게 된다.
public class MutablePeriod { // Period 인스턴스 public final Period period; // 시작 시각 필드 - 외부에서 접근할 수 없어야 한다. public final Date start; // 종료 시각 필드 - 외부에서 접근할 수 없어야 한다. public final Date end; public MutablePeriod() { try { ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(bos); // 유효한 Period 인스턴스를 직렬화한다. out.writeObject(new Period(new Date(), new Date())); /* * 악의적인 '이전 객체 참조', 즉 내부 Date 필드로의 참조를 추가한다. */ 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); // 60년대로 회귀! pEnd.setYear(69); System.out.println(p); }
저 참조 5번을 보면
0x71은 ObjectInputStream에서 TC_REFERENCE를 나타내며,
이후의 바이트는 참조 ID를 지정한다.
0x7e, 0, 5는 참조 #5 (시작 날짜)를,0x7e, 0, 4는 참조 #4 (종료 날짜)를 나타낸다.
이렇게 악의적인 참조를 추가한 후, ObjectInputStream을 사용하여 객체를 역직렬화한다.
역직렬화 과정에서, Period 객체와 그 내부의 Date 객체에 대한 참조를 start와 end에 할당한다.이렇게 불변성을 깨뜨릴 수 있다.
이 문제의 근원은 Period의 readObject 메서드가 방어적 복사를 충분히 하지 않은 데 있다고 한다.
객체를 역직렬화할 때는 클라이언트가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다.
그니까 위의 readObject에 필드를 방어적 복사하는 라인을 추가하라는 것이다.
// 유효성 검사 메소드 전에 이 필드를 추가해준다. start = new Date(start.getTime()); end = new Date(end.getTime());
transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가하는 것은
위험하므로 readObject 메서드를 만들어 모든 유효성 검사와 방어적 복사를 수행해야 한다고 한다.
아니면 프록시 패턴을 사용해야 한다고 한다. 이는 아래에서 정리해 두려고 한다.
final이 아닌 직렬화 가능 클래스라면 readObject와 생성자의 공통점이 하나 더 있다.
마치 생성자처럼 readObject 메서드도 재정의 가능 메서드를 (직접적 으로든 간접적으로든) 호출해서는 안된다.
이 규칙을 지키지 않았는데 해당 메서드가 재정의되면, 하위 클래스의 상태가 완전히 역직렬화되기 전에
하위 클래스에서 재정의된 메서드가 실행된다. 결국 프로그램 오작동으로 이어 질 것이라고 한다.
readObject 메서드를 작성할 때는 언제나 public 생성자를 작성하는 자세로 임해야 한다. readObject는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다. 바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해서는 안 된다.
정리
- private이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
- 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.
- 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라.
- 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자.
인스턴스 수를 통제해야 한다면 readResolve
보다는 열거 타입을 사용하라readResolve 를 사용함으로 인해서 싱글톤 패턴으로 설계된 클래스가
악의적인 코드로 인해서 두 개의 인스턴스를 생성하도록 한 코드를 보자.
public class Elvis implements Serializable { public static final Elvis INSTANCE = new Elvis(); private Elvis() { } private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" }; public void printFavorites() { System.out.println(Arrays.toString(favoriteSongs)); } private Object readResolve() { return INSTANCE; } } 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 { private static final byte[] serializedForm = { (byte)0xac, (byte)0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05, 0x45, 0x6c, 0x76, 0x69, 0x73, (byte)0x84, (byte)0xe6, (byte)0x93, 0x33, (byte)0xc3, (byte)0xf4, (byte)0x8b, 0x32, 0x02, 0x00, 0x01, 0x4c, 0x00, 0x0d, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x53, 0x6f, 0x6e, 0x67, 0x73, 0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61, 0x76, 0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x4c, 0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x74, 0x00, 0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x3b, 0x78, 0x70, 0x71, 0x00, 0x7e, 0x00, 0x02 }; public static void main(String[] args) { // ElvisStealer.impersonator를 초기화한 다음, // 진짜 Elvis(즉, Elvis.INSTANCE)를 반환한다. Elvis elvis = (Elvis) deserialize(serializedForm); Elvis impersonator = ElvisStealer.impersonator; elvis.printFavorites(); impersonator.printFavorites(); } }
먼저 맨 위의 Elvis 라는 인스턴스는 private 생성자를 사용해서
오직 하나만 만들어짐을 보장했다.
근데 이 클래스는 선언시에 implements Serializable 를 추가하면
싱글턴이 아니게 된다고 한다.
결과적으로 readResolve를 인스턴스 통제 목적으로 사용한다면
객체 참조 타입 인스턴스 필드는 모두 transient 로 작성해야 한다는 것이다.
transient 를 쓰지 않았다면 readResolve 하기 전에 역직렬화되는데
저 readResolve 에 인스턴스를 통제하기 위한 코드가 있다 하더라도
하기 전에 역직렬화되니 조작된 스트림을 가지고 해당 참조 필드가
역직렬화 되는 시점에 인스턴스 참조를 훔쳐오는 방식이라고 한다.
이제 맨 아래 main 함수에서 출력될 내용은
[Hound Dog, Heartbreak Hotel] [A Fool Such as I]
이렇다. 이렇게 싱글톤 객체에서 서로 다른 2개의 인스턴스를 만들 수 있게 된 것이다!!
그래서 직렬화 가능한 인스턴스 통제 클래스를 열거 타입 그니까 enum 으로 구현하면
선언한 상수 외의 객체는 존재하지 않음을 보장한다고 한다.
public enum Elvis { INSTANCE; private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" }; public void printFavorites() { System.out.println(Arrays.toString(favoriteSongs)); } }
선언한 저 INSTANCE 라는 상수 외의 객체는 존재하지 않음을 보장하는 것이다.
정리
- 불변식을 지키기 위해 인스턴스를 통제해야 한다면 가능한 한 열거 타입을 사용하자.
- 여의치 않은 상황에서 직렬화와 인스턴스 통제가 모두 필요하다면 readResolve 메서드를
작성해 넣어야 하고, 그 클래스에서 모든 참조 타입 인스턴스 필드를 transient로 선언해야 한다.
직렬화된 인스턴스 대신 직렬화 프록시
사용을 검토하라Serializable을구현하기로 결정한 순간
언어의 정상 메커니즘인 생성자 이외의 방법으로
인스턴스를 생성할 수 있게 된다.
버그와 보안 문제가 일어날 가능성이 커진다는 뜻이다.
이를 직렬화 프록시라는 패턴으로 위험을 크게 줄일 수 있다고 한다.
private static class SerializationProxy implements Serializable { private final Date start; private final Date end; SerializationProxy(Period p) { this.start = p.start; this.end = p.end; } private static final long serialVersionUID = 234098243823485285L; // 아무 값이나 상관없다. } private Object writeReplace() { return new SerializationProxy(this); }
코드를 같이 보자.
바깥 클래스의 논리적 상태를
정밀하게 표현하는 중첩 클래스를 설계해 private static으로 선언한다.
이 중첩 클래스의 생성자는 Period p 를 받는 딱 하나이다.
이 메서드는 자바의 직렬화 시스템이 바깥 클래스의 인스턴스 대신
SerializationProxy의 인스턴스를 반환하게 하는 역할을 한다고 한다.
writeReplace 덕분에 직렬화 시스템은
결코 바깥 클래스의 직렬화된 인스턴스를 생성해낼 수 없다.
이후 readObject 를 바깥 클래스에 추가하면 불변식을 훼손하고자 하는 시도를 막을 수 있다.
private void readObject(ObjectInputStream stream) throws InvalidObjectException { throw new InvalidObjectException("프록시가 필요합니다."); }
보면 readObject 할 때 원본 클래스의 인스턴스가 직접 역직렬화되지 않도록 하고 있다.
프록시를 통해서만 역직렬화가 이루어지도록 강제한 것이다.
그리고 직렬화할 때 논리적 상태를 유지하는 필드만 직렬화되도록 강제한다.
무엇이 직렬화되고 어떻게 역직렬화되는지를 제어함으로써 원본 객체의 내부 필드에 대한 무단 접근을 차단하는 것이다.
정리
- 제3자가 확장할 수 없는 클래스라면 가능한 한 직렬화 프록시 패턴을 사용하자.
- 이 패턴이 아마도 중요한 불변식을 안정적으로 직렬화해주는 가장 쉬운 방법일 것이다.
'책 > Effective Java' 카테고리의 다른 글
[이펙티브 자바 스터디] 직렬화 (0) 2024.06.13 [이펙티브 자바 스터디] 제네릭 - 2 (2) 2024.06.07 [이펙티브 자바 스터디] 제네릭 (0) 2024.05.30