Jay's Blog

Effective java Chapter 12 Serialization
이펙티브 자바 정리

Chapter 12. Serialization

Item 85: Java Serialization말고 같은 기능의 다른 것들을 더 선호하라.

1997년에 처음 Serialization이 나왔을때는, 위험하다고 알려져 있었다.

실제로 정확성, 성능, 보안, 유지보수 등에 잠재적 문제가 있다. 과거 지지자들은 이점이 더 많다고 했지만, 지금와서 보면 위험이 더 많다.



이 책의 2번째 판본에서 나왔던 보안 이슈들은 결국 2016년에 샌프란시스코 도시철도국에 랜섬웨어 공격(2일간 요금 징수 시스템 으로까지 이어졌다.

가장 큰 문제는 “공격 범위”가 너무 넓다는 것이다.

ObjectInputStream의 readObject 메소드로 deserialize된다.

deserialize 하는 과정에서 이 메소든는 이 해당 타입의 어떤 코드도 실행가능하다.



이제 더 이상 자바 Serialization을 사용할 이유는 없다.

이번 챕터에서는 크로스플랫폼 구조데이터(cross-platform structured-data representations)에 대해 다룬다.



가장 유명한 것은 JSON과 protobuf로 불리는 Protocol Buffer이다.

언어중립적(language-neutral)으로 불린다.



자바 serialization을 완전히 피할 수 없다면,(레거시 어플리케이션 등)

절대 믿을 수 없는 데이터를 deserialize해서는 안된다.

믿을 수 없는 source에서 RMI 트래픽을 허용해서는 안된다.



serialization을 피할 수 없고, 데이터의 안정성을 확신 할 수 없다면,

object deserialization filtering을 사용하면 된다(자바9에 추가)

deserialization하기 전에 데이터 스트림을 필터 할 수 있도롤 해준다.

화이트리스팅하는 편이 블랙리스팅하는 것 보다 낫다.

Serial Whitelist Application Trainer (SWAT)이라는 툴도 화이트 리스팅을 위해 사용할 수 있다고 한다.

필터링 하게 되면



불행히도 Serialization은 자바 에코시스템에 많이 존재하고 있다.

만약 그런 시스템을 유지보수 한다면, Json이나 Protobuf로 변경하는 것을 심각하게 고민해야 한다.

Serializable 클래스를 정확하고, 안전하고, 효율적으로 관리하는 것은 매우 주의가 필요하다.

이 챕터에서는 언제, 그리고 어떻게 할 수 있는지를 다룬다.



결론,

Serialization을 피할 수 있으면 피하고, 피할 수 없다면 화이트 리스트 등 최대한 안전하게 사용하도록 해야 한다.



Item 86: 아주 주의를 기울여서 Serializable을 구현하라

implement Serializable을 추가하는 정도로 특정 클래스의 객체를 Serialize하기 쉽다고 생각하는 사람들 이밚다.

하지만, 당장은 별로 큰 비용이 들지 않더라도, 장기적으로 꽤 많은 비용이 발생할 수 있다.



Serializable을 구현해서 생기는 가장 큰 비용은



default serialized form을 사용하고, 나중에 클래스의 내부를 바꾸려 한다면?



Serializable을 상속하면 생길 수 있는 문제점



Serializable을 상속하지 않기로 결정했다면,



내부 클래스는 default serialized form을 사용하면 Serializable을 상속해서는 안된다.



결론,

Serializable을 상속하기에는 단점이 많으니, 잘 고려해서 상속해야 한다.



Item 87: custom serialized form의 사용을 고려하라.

시간이 없는데 클래스를 작성해야 한다면, 최대한 API 설계에 집중하는 것이 적절하다.

만들어 놓고 다음 릴리즈에서 새로 구현해서 배포하면 된다고 생각되지만,

default serialization form을 사용하면 다음 릴리즈때 영원히 종속받게 된다..



default serialized form이 적절하다는 확신이 없으면 사용하면 안된다.



default serialized form

// Good candidate for default serialized form
public class Name implements Serializable {
    /**
     * Last name. Must be non-null.
     * @serial
     */
    private final String lastName;

    /**
     * First name. Must be non-null.
     * @serial
     */
    private final String firstName;
    /**
     * Middle name, or null if there is none.
     * @serial
     */
    private final String middleName;

    ... // Remainder omitted
}



default serialized form이 적절하다고 판단하더라도, 종종 readObject 메소드를 반드시 제공해야한다.



앞선 예제는 private 필드라도 공개 API가 되기 때문에 주석이 달려있다.

@serial 태그를 사용해서 java-doc이 serialized form에 관한 페이지를 만들 수 있게 한다.



// Awful candidate for default serialized form
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;
    }

    ... // Remainder omitted
}

위 예제는

default serialized form을 사용하면 모든 리스트를 복사해야한다.

physical, logical에 대한 차이가 많음에도 default serialzed form을 사용하면 4가지 단점이 있다.



// StringList with a reasonable custom serialized form
public final class StringList implements Serializable {
    private transient int size   = 0;
    private transient Entry head = null;

    // No longer Serializable!
    private static class Entry {
        String data;
        Entry  next;
        Entry  previous;
    }

    // Appends the specified string to the list
    public final void add(String s) { ... }

    /**
     * Serialize this {@code StringList} instance.
     *
     * @serialData The size of the list (the number of strings
     * it contains) is emitted ({@code int}), followed by all of
     * its elements (each a {@code String}), in the proper
     * sequence.
     */
    private void writeObject(ObjectOutputStream s)
            throws IOException {
        s.defaultWriteObject();
        s.writeInt(size);

        // Write out all elements in the proper order.
        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();

        // Read in all elements and insert them in list
        for (int i = 0; i < numElements; i++)
            add((String) s.readObject());
    }

    ... // Remainder omitted
}



어떤 serialized form을 사용하던, serial version UID는 명시적으로 정의하라.



결론,

클래스가 serializable해야한다면, serializable form을 어떻게 정의할지 깊게 고민해야한다.



Item 88: readObject 메소드는 방어적으로 작성하라.

// Immutable class that uses defensive copying
public final class Period {
    private final Date start;
    private final Date end;
    /**
     * @param  start the beginning of the period
     * @param  end the end of the period; must not precede start
     * @throws IllegalArgumentException if start is after end
     * @throws NullPointerException if start or end is 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; }

    ... // Remainder omitted
}

Item 50에서 mutable 클래스인 Date을 이용한 Immutable 클래스를 만들었다.

위 클래스는 Logical가 Physical 데이터가 같기 때문에 implement Serializable만 추가하면 Serializable하다.

그런데 이렇게 하면 클래스의 불변성을 장담할 수 없다.



원인은 readObject 메소드가 public 생성자로서 역할을 하기 때문이다.

위 두가지가 안되면, 공격자는 클래스의 불변성을 깨뜨릴 수 있다.



readObject 메소드



Period 예제에서는 시작 값이 끝 값보다 뒤에 있는 객체를 만들 수도 있따.

public class BogusPeriod {
  // Byte stream couldn't have come from a real Period instance!
  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);
  }

  // Returns the object with the specified serialized form
  static Object deserialize(byte[] sf) {
    try {
      return new ObjectInputStream(
          new ByteArrayInputStream(sf)).readObject();
    } catch (IOException | ClassNotFoundException e) {
      throw new IllegalArgumentException(e);
    }
  }
}

serializedForm은 바이트 배열을 만들어서 일부를 수정한 것이다.

실행하게 되면 결과는

Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984.

위와 같이 나오게 된다.



이런 문제를 해결하기 위해서는

// readObject method with validity checking - insufficient!
private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    // Check that our invariants are satisfied
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start +' after '+ end);
}

이렇게 하면 앞서 나온 공격은 막을 수 있다.

또 다른 문제가 있다.

public class MutablePeriod {
    // A period instance
    public final Period period;

    // period's start field, to which we shouldn't have access
    public final Date start;

    // period's end field, to which we shouldn't have access
    public final Date end;
    public MutablePeriod() {
        try {
            ByteArrayOutputStream bos =
                new ByteArrayOutputStream();
            ObjectOutputStream out =
                new ObjectOutputStream(bos);

            // Serialize a valid Period instance
            out.writeObject(new Period(new Date(), new Date()));

            /*
             * Append rogue 'previous object refs' for internal
             * Date fields in Period. For details, see 'Java
             * Object Serialization Specification,' Section 6.4.
             */
            byte[] ref = { 0x71, 0, 0x7e, 0, 5 };  // Ref #5
            bos.write(ref); // The start field
            ref[4] = 4;     // Ref # 4
            bos.write(ref); // The end field

            // Deserialize Period and 'stolen' Date references
            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;

    // Let's turn back the clock
    pEnd.setYear(78);
    System.out.println(p);

    // Bring back the 60s!
    pEnd.setYear(69);
    System.out.println(p);
}
Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 1978
Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 1969

결과는 위와 같이 나오게 된다.

기존에 주어진 레퍼런스에 대한 변경으로 객체에 영향을 미치게 된다.

이 이유는 PeriodreadObject 메소드가 방어적 복사를 하고 있지 않기 때문이다.

객체가 deserializae 될 때, 클라이언트가 가질 수 있는 레퍼런스 필드를 방어적으로 카피해야 한다.



// readObject method with defensive copying and validity checking
private void readObject(ObjectInputStream s)
        throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    // Defensively copy our mutable components
    start = new Date(start.getTime());
    end   = new Date(end.getTime());

    // Check that our invariants are satisfied
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start +' after '+ end);
}

유효성 체크 이전에 방어적 카피 하는 것에 주목해야 한다.

방어적 복사는 또한, final 필드에 대해서는 불가능하다.

readObject 메소드를 위해서는 start, end 필드를 모두 nonfinal로 해야한다.

serialization proxy pattern (Item 90)을 사용하면 readObject없이도 가능하긴하다.



nonfinal serializable 클래스에 적용되는 readObject 메소드와 생성자의 또 다른 공통점이 있다.



결론,

readObject 메소드를 작성할 때는 public 생성자와 같게 생각해야한다.



Item 89: 객체 제어를 위해서 readResolve보다 enum을 선호하라.

Item 3에서는 싱글톤 패턴에 대해 이야기했다.

public class Elvis {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() {  ... }

    public void leaveTheBuilding() { ... }
}

위 예제는 Item 3에서 이야기 했듯이, implements Serializable가 추가되면 더 이상 싱글톤의 역할을 하지 못한다.

이 경우, default serialized form을 사용하던,

custom serialized form을 사용하던,

클래스가 readObject 메소드를 직접 제공하던,

결과는 같다.



readObject 메소드에서는 새롭게 생성된 객체를 리턴한다. 이는 클래스가 생성될 때의 객체와 다르다.

readResolve 메소드는 readObject 메소드에서 생성된 객체를 다른 객체로 바꿀 수 있다.

Elvis 싱글톤 예제에서

// readResolve for instance control - you can do better!
private Object readResolve() {
    // Return the one true Elvis and let the garbage collector
    // take care of the Elvis impersonator.
    return INSTANCE;
}

위와 같이 사용하면 싱글톤 프로퍼티를 보장할 수 있다.

이 메소드는 deserialize된 객체를 무시하고, 클래스가 생성될 때 만들어진 Elvis 객체를 리턴한다.

readResolve 메소드에 의존하면, 객체 참조 타입의 모든 인스턴스 필드는 transient로 정의되어야 한다.

transient로 정의되지 않으면 공격자가 MutablePeriod의 공격처럼 공격할 수 있다.



// Broken singleton - has nontransient object reference field!
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() {
        // Save a reference to the 'unresolved' Elvis instance
        impersonator = payload;

        // Return object of correct type for favoriteSongs field
        return new String[] { 'A Fool Such as I' };
    }
    private static final long serialVersionUID = 0;
}
public class ElvisImpersonator {
  // Byte stream couldn't have come from a real Elvis instance!
  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) {
    // Initializes ElvisStealer.impersonator and returns
    // the real Elvis (which is Elvis.INSTANCE)
    Elvis elvis = (Elvis) deserialize(serializedForm);
    Elvis impersonator = ElvisStealer.impersonator;

    elvis.printFavorites();
    impersonator.printFavorites();
  }
}

이렇게 공격이 가능하다.



favoriteSongs 필드를 transient로 하면 해결할 수 있다.

그렇지만 Elvis를 single-element enum type으로 바꾸는 편이 더 낫다.

// Enum singleton - the preferred approach
public enum Elvis {
    INSTANCE;
    private String[] favoriteSongs =
        { 'Hound Dog', 'Heartbreak Hotel' };
    public void printFavorites() {
        System.out.println(Arrays.toString(favoriteSongs));
    }
}

인스턴스의 갯수를 컴파일 타임에 모른다면 Enum 타입으로 하기 힘들다.



readResolve 메소드의 접근자(accessibility)는 매우 중요하다.

결론,

enum type을 불변 객체 제어를 위해서 사용하자.



Item 90: serialized instances 대신 serialization proxy를 고려하라.

Serializable를 구현하는 것은 버그와 보안 문제가 생길 가능성을 높인다는 점을 이 챕터 내내 이야기 했다.

보이지 않는 생성자가 생기면서 발생하는 문제가 대부분이다.

serialization proxy pattern을 사용하면 그런 문제의 일부 해결이 가능하다.



serialization proxy pattern 만드는 법



// Serialization proxy for Period class
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; // Any number will do (Item  87)
}

앞선 예제의 Period 클래스에 대해서 proxy를 적용하면 위와 같다.

// writeReplace method for the serialization proxy pattern
private Object writeReplace() {
    return new SerializationProxy(this);
}

writeReplace 메소드를 위와 같이 추가하면 된다.

감싸고 있는 클래스말고 private 클래스를 리턴하게 된다. writeReplace 메소드는 감싸고 있는 객체를 private nested 클래스로 변환시켜준다.

// readObject method for the serialization proxy pattern
private void readObject(ObjectInputStream stream)
        throws InvalidObjectException {
    throw new InvalidObjectException("Proxy required");
}

readObject 메소드는 불필요하기 때문에 공격을 방지하기 위해서 위와 같이 Exception을 던지도록 하면 된다.

마지막으로 SerializationProxy 클래스에 readResolve 메소드를 제공해서 감싸고 있는 클래스와 논리적으로 동등한 클래스를 리턴하도록 하면 된다.

// readResolve method for Period.SerializationProxy
private Object readResolve() {
    return new Period(start, end);    // Uses public constructor
}



다른 방법보다 나은 점



proxy 패턴의 2가지 한계점



방어적 복사로 serialize하는 것에 비해서 성능 상 더 뛰어나지는 못하다. 저자의 컴퓨터에서 14퍼센트 정도 더 느렸다.



결론,

적절한 조건에서 serialization proxy pattern을 사용하면 매우 유용하게 사용될 수 있다.

*****
Written by Jay on 07 December 2020