ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [이펙티브 자바 스터디] 직렬화
    언어/JAVA 2024. 6. 13. 03:09

     

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

     

    워게임이라는 영화에서 조슈아라는 컴퓨터는 이렇게 말한다고 한다.

    "승리하는 유일한 길은 전쟁하지 않는 것이다."

    자바 직렬화도 마찬가지 이다.

    "직렬화의 위험을 피하는 가장 좋은 방법은 아무것도 역직렬화하지 않는 것이다."

     

    먼저 직렬화란 자바가 객체를 바이트 스트림으로 인코딩하는 것이 직렬화이고

    그 바이트코드로 부터 객체로 재구성하는 것이 역직렬화이다.

     

    자바의 역직렬화는 명백하고 현존하는 위험이라고 한다.

    신뢰할 수 없는 스트림을 역직렬화하면 

    Remote Code Execution , Denial of Service 등의 공격으로 이어질 수 있다.

     

    2016년 샌츠란시스코 시영 교통국에서는 이런 직렬화의 보안 문제로 인해 

    랜섬웨어 공격을 받은 적이 있다고 한다.

     

    https://www.theguardian.com/technology/2016/nov/28/passengers-free-ride-san-francisco-muni-ransomeware

     

    Ransomware attack on San Francisco public transit gives everyone a free ride

    San Francisco Municipal Transport Agency attacked by hackers who locked up computers and data with 100 bitcoin demand

    www.theguardian.com

     

    감염 경로 가능성들은 다음과 같은데

     

    피싱 이메일: MUNI 직원이 악성 이메일을 열고 첨부 파일을 실행했을 가능성.
    네트워크 취약점: MUNI 네트워크에 보안 취약점이 있었을 가능성.
    기타 소프트웨어 취약점: MUNI 시스템에서 사용 중인 소프트웨어의 보안 취약점이 악용되었을 가능성. 

     

    저런 피싱 이메일의 경우 직렬화된 악성 바이트 코드가 들어있고

    사용자가 클릭해서 열어보는 순간 역직렬화 코드가 작동되어

    시스템 안의 파일을 락을 걸어 금전을 요구하는 경우라고 한다.

     

     역직렬화 과정에서 호출되어 잠재적으로 위험한 동작을 하는 것을 가젯이라 부른다.

    이 정도 수준의 악성 코드가 아니더라도

     

    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");
            
            s1.add(t1); 
            s1.add(t2);
            s2.add(t1); 
            s2.add(t2);
            
            s1 = t1;
            s2 = t2;
        } return serialize(root);
    }

     

    이 루프가 끝나면 객체의 그래프가 매우 복잡해 진다.

    각 반복에서 두 개의 HashSet 이 생성되고 이전 s1 과 s2 에 추가된다.

    이런 시긍로 객체들이 중첩되고 서로 참조하는 구조가 된다.

     

     

    이런 구조의 코드는 역직렬화 과정에서 JVM 이 이 복잡한 객체 그래프를 구성하게 만들고

    이 과정 동안 심각한 성능 문제가 발생하여 시스템이 중단이 될 수 도 있게 한다고 한다.

     

    그래서 cross platform structured data 를 써야 한다는 것인데 

    대표적으로 json 생각하면 된다. 

    리액트 서버 등 웹 서버와 통신할 때 json 을 쓰는 것처럼!

    또 프로토콜 버퍼라는 것이 있는데

    json 은 자바스크립트, 프로토콜 버퍼는 C++ 용으로 설계됐다고 한다.

     

    근데 레거시 시스템 때문에 자바 직렬화를 완전히 배제할 수 없을 때의 차선책은

    신뢰할 수 없는 데이터를 절대 역직렬화 하지 않는 것이다!

     

    자바의 공식 보안 코딩 지침에서는

     

     

    다음과 같이 강조해 두었다.

    신뢰할 수 없는 데이터의 역직렬화는 본질적으로 위험하므로 절대로 피해야 한다고 적혀있다.

     

    역직렬화한 데이터가 안전한지 완전히 확신할 수 없을 때엔 객체 역직렬화 필터링을 사용해야 한다.

    자바 9에 추가되었는데 이전 버전에서도 쓸 수 있게 해두었다.

     

    이 필터링은 데이터 스트림이 역직렬화 되기 전에 필터를 설치하는 기능이다.

    기본 거부 모드에서는 화이트리스트로 관리하기에 기본 거부 모드 사용을 추천한다고 한다.

     

    정리

    직렬화는 위험하니 피해야 한다.

    그래서 크래스 플랫폼 구조화된 데이터 표현으로 마이그레이션을 심각하게 고민해보자.

    신뢰할 수 없는 데이터는 역직렬화하지 말아야 하며, 해야만 한다면

    필터링이라도 사용하되, 이마저도 모든 공격을 막아줄 수는 없다.


     

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

     

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

     

    public class Person implements Serializable {
        private static final long serialVersionUID = 1L;
        private String name;
        private int age;
        public Person(String name, int age) {
            this.name = name;
            this.age = age;
        }
        public String getName() {
            return name;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    
        public int getAge() {
            return age;
        }
    
        public void setAge(int age) {
            this.age = age;
        }
    
        @Override
        public String toString() {
            return "Person{name='" + name + "', age=" + age + "}";
        }
    }

     

    코드만 보면 매우 단순해 보인다. 

    하지만 이를 구현하면 직렬화된 바이트 스트림 인코딩도 하나의 공개 API 가 된다고 한다.

     

    이 직렬화 형태는 영원히 지원해야 하고 

    커스텀 직렬화 형태를 하지 않고 자바의 기본 방식을 사용하게 된다면 직렬화 형태는 

    최소 적용 당시 클래스의 내부 구현 방식에 영원히 묶여버린다고 한다.

     

     

    직렬화가 클래스 개선을 방해하는 예를 보면 위의 코드에서도 보이듯 

    UID 이다.

     

    이 번호를 명시하지 않으면 런타임에 암호 해시 함수를 적용해서 

    자동으로 클래스 안에 생성해 넣는다고 한다.

    이 값을 생성하는데는 클래스 이름, 구현한 인터페이스들, 컴파일러가 자동으로 생성해 넣은 것을 포함한 대부분의 클래스 멤버들이 고려된다고 한다.

     

    그래서 나중에 편의 메서드를 추가하는 식으로 하나라도 수정되게 된다면 직렬 버전 UID 값도 변한다.

    그로 인한 호환성이 깨짐으로 런타임 에러가 발생한다고 한다.

     

    두 번째 문제는 버그와 보안 구멍이 생길 위험이 높아진다고 한다.

     

    기본 역직렬화를 사용하면 기본 메커니즘을 우회하여 객체를 생성하는 기법이 되기 때문이다.

     

    세 번째 문제는 해당 클래스의 신버전을 릴리스할 때 테스트할 것이 늘어난다는 것이다.

     

    신버전 인스턴스를 직렬화한 후 구버전으로 역직렬화할 수 있는지 그 반대도 가능한지 검사를 해야하기 때문이다.

     

    이러한 점들로 인해 Serializable 의 구현은 가볍게 결정한 사안이 아니다.

     

    그리고 또 중요한 점은 

     

    상속용으로 설계된 클래스는 대부분 Serializable을 구현하면 안되며, 인터페이스도 대부분 Serializable 을 확장해서는 안된다. 그리고 내부 클래스는 직렬화를 구현하지 말아야 한다.

     

    정리

    Serializable 은 구현하기 쉬워보이지만 이것은 눈속임이고 구현하게 된다면 매우 매우 신중하게 결정해야 한다.

     


     

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

     

    위에서 설명한 문제로 인해서 기본 직렬화 사용은 매우 신중한 고민 끝에 사용되어야 한다.

    BigInteger 같은 일부 자바 클래스가 이 문제에 시달리고 있다고 한다.

     

    public class Name implements Serializable {
        private final String lastName;
        private final String firstName;
        private final String middleName;
        
        ...
    }

     

    위의 코드와 같이 객체의 물리적 표현과 논리적 내용이 같다면 기본 직렬화 형태도 무방하다고 한다.

     

    Name 클래스에서는 lastName, firstName, middleName이 이름을 구성하는 모든 정보를 나타낸다.

    이 필드들만으로 이름 객체의 논리적 내용이 완전히 표현되기에 이 물리적 표현과 논리적 내용이 같다고 하는 것이다.

     

    객체의 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화 형태를 사용
    하면 크게 네 가지 면에서 문제가 생긴다.

    1. 공개 API가 현재의 내부 표현 방식에 영구히 묶인다

    2. 너무 많은 공간을 차지할 수 있다.

        직렬화시에 기본적으로 타입에 대한 정보 등 클래스의 메타 정보도 가지고 있기 때문에 상대적으로 다른 포맷에 비해서 용량이 큰 문제가 있다.

    연결 리스트를 예로 들면 직렬화를 하면 연결 리스트의 모든 엔트리와 연결 정보까지 기록을 했는데 이는 내부 구현에 해당되는 내용인데 이 정보까지 디스크에 저장되어야 하기 때문이다.

    3. 시간이 너무 많이 걸릴 수 있다.

        직렬화 로직은 객체 그래프의 위상에 관한정보가 없으니 그래프를 직접 순회해볼 수밖에 없다. 앞의

    4. 스택 오버플로를 일으킬 수 있다.

        기본 직렬화 과정은 객체 그래프를 재귀 순회하는데, 이 작업은 중간 정도 크기의 객체 그래프에서도 자칫 스
    택 오버플로를 일으킬 수 있다고 한다.

     

     

    직렬화할 때는 예를 들어 StringList 를 직렬화한다고 치면 물리적인 상세 표현은 배제한 채 논리적인 구성을 담아 담는 것이다. 

     

    transient 라는 한정자를 사용하여 해당 인스턴스 필드가 기본 직렬화 형태에 포함되지 않는다고 표시하면 된다

     

    private transient int size = 0;

    .

    그래서 물리적 표현과 논리적 표현의 차이가 있을 때 특정 필드를 직렬화하지 않도록 지정하면 된다.

    이 transient 는 논리적 상태와 무관한 필드라고 확신할 때만 사용해야한다고 한다.

     

    어떤 직렬화 형태를 택하든 직렬화 가능 클래스 모두에 직렬 버전 UID 를 명시적으로 부여하자.

    이러면 위에서 설명했던 UID 로 인한 호환성 문제가 사라진다.

    명시적으로 부여하지 않으면 런타임시에 이 값을 생성하기 위해 여러 암호 해시 함수를 써서 

    복잡한 연산을 수행하기 때문이라고 한다.

     

    직렬 버전 UID 선언은 각 클래스에 

     

    private static final long serialVersionUID = <무작위로 고른 long 값>;

     

    이 한 줄만 추가해주면 된다고 한다. 직렬 버전 UID가 꼭 고유할 필요는 없다고 한다.

    기본 버전 클래스와의 호환성을 끊고 싶다면 단순히 직렬 버전 UID의 값을 바꿔주면 된다. 

    이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화할 때
    InvalidClassException이 던져진다.

     

    근데 구버전으로 직렬화된 인스턴스들과 호환성을 끊으려는 경우 말고는 수정하지 말자!

     

    정리

    기본 직렬화는 물리적 표현과 논리적 표현이 같을 때만 쓰자.

    그러지 않을 때에는 커스텀 직렬화를 하는데 transient 로 논리적 상태와 무관한 필드를 제거하여 사용하자.

    UID 는 명시적으로 부여하고 구버전과 호환성을 끊을 때만 수정하자!

     

     

     

Designed by Tistory.