////
Search

12장 - 직렬화

Created
2022/10/18 12:17
Tags
Java

85. 자바 직렬화의 대안을 찾으라

1997년 자바에 처음으로 직렬화가 도입되었다.
프로그래머가 어렵지 않게 분산 객체를 만들 수 있다는 구호는 매력적이지만, 보이지 않는 생성자, API와 구현 사이의 모호해진 경계, 잠재적인 정확성 문제, 성능, 보안, 유지보수성 등 그 대가가 컷다.

자바 직렬화는 위험하다.

직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점이다.
ObjectInputStreamreadObject 메서드를 호출하면서 객체 그래프가 역직렬화되기 때문이다.
readObject 메서드는 Serializable 인터페이스를 구현했다면 클래스패스 안의 거의 모든 타입의 객체를 만들어낼 수 있는 마법 같은 생성자다.
바이트 스트림을 역직렬화하는 과정에서 이 메서드는 그 타입들 안의 모든 코드를 수행할 수 있다.
즉, 그 타입들의 코드 전체가 공격 범위 들어간다는 뜻이다.
직렬화의 모범 사례를 따르고 모든 직렬화 가능 클래스를 공격에 대비해도 여전히 취약할 수 있다.

가젯 (gadget)

공격자와 보안 전문가들은 라이브러리나 서드파티의 역직렬화 과정에서 호출되어 잠재적으로 위험한 동작을 수행하는 메서드를 찾아 가젯이라 부른다.
여러 가젯을 함께 사용하여 가젯 체인을 구성하면, 가끔씩 공격자가 기반 하드웨어의 네이티브 코드를 마음대로 실행할 수 있는 아주 강력한 가젯 체인도 발견되곤 한다.

역직렬화 폭탄 (Deserialization bomb)

static byte[] bomb() { Set<Object> root = new HashSet<>(); Set<Object> s1 = root; Set<Object> s2 = new HashSet<>(); for (int i=0; i < 100; i++) { Set<Object> t1 = new HashSet<>(); Set<Object> t2 = new HashSet<>(); t1.add("foo"); // t1을 t2과 다르게 만든다. s1.add(t1); s1.add(t2); s2.add(t1); s2.add(t2); s1 = t1; s2 = t2; } return serialize(root); }
Java
복사
시간이 오래 걸리는 짧은 스트림을 역직렬화하는 것만으로도 서비스 거부 공격에 쉽게 노출될 수 있다.
스트림 전체의 크기는 5744바이트지만, 역직렬화는 영원히 끝나지 않을 것이다.
HashSet에 지속적으로 자식을 더해 100개까지 증가시킨다.
이 구조를 직렬화 하려면 hashCode() 메서드를 21002^{100}번 넘게 호출해야 한다.
가장 큰 문제점은 무언가 잘못되었다는 신호조차 주지 않는다는 것이다.

직렬화 문제를 피하는 방법

자바 직렬화 위험을 회피하는 가장 좋은 방법은 역직렬화 하지 않는 것이다.
우리들이 작성하는 새로운 시스템에 자바 직렬화를 써야 할 이유가 전혀 없다.
객체와 바이트 시퀀스를 변환해주는 다른 메커니즘이 많이 있기 때문이다.

크로스-플랫폼 구조화된 데이터 표현

직렬화의 여러 위험을 회피하며, 다양한 플랫폼 지원, 우수한 성능, 풍부한 지원 도구 등을 제공하는 다른 방식의 매커니즘 방식을 크로스-플랫폼 구조화된 데이터 표현이라 한다.
대표적으로 JSONprotobuf가 있다.

장점

자바의 직렬화보다 훨씬 간단하다.
임의의 객체 그래프를 자동으로 직렬화/역직렬화 하지 않는다.
속성-값 쌍의 집합으로 구성된 간단하고 구조화된 데이터 객체를 사용한다.
간단한 추상화로 아주 강력한 분산 시스템을 구축하고, 자바 직렬화의 문제점을 회피할 수 있다.

객체 역직렬화 필터링 (ObjectInputFilter)

레거시 때문에 자바 직렬화를 배제할 수 없을 때의 차선책은 신뢰할 수 없는 데이터는 절대 역직렬화하지 않는 것이다.
만약 직렬화를 피할 수 없고 역직렬화한 데이터가 안전한지 확신할 수 없다면 객체 역직렬화 필터링(java.io.ObjectInputFilter)을 사용하자.
이 기능은 데이터 스트림이 역직렬화되기 전에 필터를 설치하는 기능이다.
블랙리스트에 기록된 클래스를 거부하거나, 화이트리스트에 기록된 클래스만 수용한다.
이미 알려진 위험으로부터만 보호할 수 있는 블랙리스트보다 화이트리스트를 사용하는 것이 좋다.

86. Serializable을 구현할지는 신중히 결정하라

어떤 클래스의 인스턴스를 직렬화할 수 있게 하려면 클래스 선언에 implements Serializable을 덧붙이면 된다.
직렬화를 지원하기란 짧게 보면 손쉬워 보이지만, 길게 보면 아주 값비싼 일이다.

문제점들

Serializable을 구현하면 릴리스한 뒤에는 수정하기 어렵다.

클래스가 Serializable을 구현하면 직렬화된 바이트 스트림 인코딩(직렬화 형태)도 하나의 공개 API가 된다.
그래서 이 클래스가 널리 퍼진다면 그 직렬화 형태도 (다른 공개 API와 마찬가지로) 영원히 지원해야 하는 것이다.
필드로의 접근을 막기 어려워 정보 은닉 또한 어려워진다.
뒤늦게 클래스를 손보면 구/신 버전의 인스턴스 역직렬화시 실패할 수 있다.
직렬화 형태를 잘 살계하더라도 클래스를 개선하는 데 제약이 될 수 있다.
모든 직렬화된 클래스는 고유 식별 번호(serialVersionUID)를 부여받는다.
이를 자동 생성되는 값에 의존하면 쉽게 호환성이 깨져버려 InvalidClassException이 발생할 수 있다.

버그와 보안 구멍이 생길 위험이 높아진다.

객체는 생성자를 사용해 만드는 게 기본이다.
즉, 직렬화는 언어의 기본 메커니즘을 우회하는 객체 생성 기법인 것이다.
기본 방식을 따르든 재정의해 사용하든, 역직렬화는 일반 생성자의 문제가 그대로 적용되는 '숨은 생성자'다.

해당 클래스의 신버전을 릴리스할 때 테스트할 것이 늘어난다.

직렬화 가능 클래스가 수정되면 신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화할 수 있는지, 그리고 그 반대도 가능한지를 검사해야 한다.
따라서 테스트해야 할 양이 직렬화 가능 클래스의 수와 릴리스 횟수에 비례해 증가한다.
양방향 직렬화/역직렬화가 모두 성공하고, 원래의 객체를 충실히 복제해내는지를 반드시 확인해야 한다.

추가적인 조언

Serializable 구현 여부는 가볍게 결정할 사안이 아니다.

객체를 전송하거나 저장할 때 자바 직렬화를 이용하는 프레임워크용으로 만든 클래스라면 선택의 여지가 없다.

상속용으로 설계된 클래스

상속용으로 설계된 클래스는 대부분 Serializable을 구현하면 안 되며, 인터페이스도 대부분 Serializable을 확장해서는 안 된다.

내부 클래스는 직렬화를 구현하지 말아야 한다.

단, 정적 멤버 클래스는 구현해도 된다.

87. 커스텀 직렬화 형태를 고려해보라

고려하기

먼저 고민해보고 괜찮다고 판단될 때만 기본 직렬화 형태를 사용하라.
객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태라도 좋다.
기본 직렬화 형태가 적합하다고 결정했더라도 불변식 보장과 보안을 위해 readObject 메서드를 제공해야 할 때가 많다.

이상적인 직렬화 클래스

public class Name implements Serializable { /** * 성. null이 아니어야 한다. * @serial */ private final Stirng lastName; /** * 이름. null이 아니어야 한다. * @serial */ private final String firstName; /** * 중간이름. 중간이름이 없다면 null * @serial */ private final String middleName; ... // 나머지 코드는 생략 }
Java
복사

기본 직렬화 형태에 적합하지 않은 클래스

public final class StringList implements Serializable { private int size = 0; private Entry head = null; private static class Entry implements Serializable { String data; Entry next; Entry previous; } }
Java
복사

직렬화의 문제 4가지

공개 API가 현재의 내부 표현 방식에 영구히 묶인다.
너무 많은 공간을 차지할 수 있다.
시간이 너무 많이 걸릴 수 있다.
스택오버플로를 일으킬 수 있다.

커스텀 직렬화

public final class StringList implements Serializable { private transient int size = 0; private transient Entry head = null; // 이제는 직렬화되지 않는다. private static class Entry { String data; Entry next; Entry previous; } // 지정한 문자열을 이 리스트에 추가한다. public final void add(String s) {...} /** * 이 {@code StringList} 인스턴스를 직렬화한다. * * @serialData 이 리스트의 크기(포함된 문자열의 개수)를 기록한 후 * ({@code int}), 이어서 모든 원소를(각각은 {@code String}) * 순서대로 기록한다. */ private void writeObject(ObjectOutputStream s) throws IOException { //기본 직렬화를 수행한다. s.defaultWriteObject(); s.writeInt(size); // 커스텀 역직렬화를 수행한다. // 모든 원소를 올바른 순서로 기록한다. for (Entry e = head; e != null; e = e.next) s.writeObject(e.data); } private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { //기본 역직렬화를 수행한다. s.defaultReadObject(); int numElements = s.readInt(); // 커스텀 역직렬화 부분 // 모든 원소를 읽어 이 리스트에 삽입한다. for(int i = 0; i < numElements; i++) { add((String) s.readObject()); } } }
Java
복사

추가적인 조언

어떠한 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 serialVersionUID 를 명시적으로 부여하자.
구버전으로 직렬화된 인스턴스들과 호환성을 끊으려는것이 아니면 serialVersionUID 를 바꾸지 말자.

88. readObject 메서드는 방어적으로 작성하라

안전한 readObject 메서드를 작성하는 지침

private이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라.
불변 클래스 내의 가변 요소가 여기 속한다.
모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다.
방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.
역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라
직접적이든, 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자.

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

문제점

implements Serializable을 구현하게 되는 순간 싱글턴이 아니게 된다.
기본 직렬화를 쓰지 않거나 명시적인 readObject 메서드를 제공하더라도 소용이 없다.
어떤 readObject 메서드를 사용하더라도 초기화될 때 만들어진 인스턴스와 다른 인스턴스를 반환하게 된다.
이때 readResolve 메서드를 이용하면 readObject 메서드가 만든 인스턴스를 다른 것으로 대체할 수 있다.
이때 readObject 가 만들어낸 인스턴스는 가비지 컬렉션의 대상이 된다.
만일 그렇지 않으면 역직렬화(Deserialization) 과정에서 역직렬화된 인스턴스를 가져올 수 있다.
즉, 싱글턴이 깨지게 된다.

해결법

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

90. 직렬화된 인스턴스 대신 직렬화 프록시 사용을 검토하라