Reference/Effective-Java

[Effective-Java] Chapter12 #88. readObject 메서드는 방어적으로 작성하라

나죽못고나강뿐 2023. 8. 23. 13:18
✨ readObject 메서드를 작성하는 지침

• private여야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라.
• 모든 불변식을 검사하고, 어긋나면 InvalidObjectException을 던진다. (불변식 복사 → 불변식 검사)
• 역직렬화 후 그래프 전체 유효성 검사가 필요하다면 ObjectInputValidation 인터페이스를 사용하라
• readObject에서 직접적이든 간접적이든, 재정의 가능한 메서드를 호출하지 마라. (초기화 되기 전에 호출된다.)

 

🛡️ 역직렬화 방어적 수행

🟡 As-is

public final class Period implements Serializable {
    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(
                    this.start + "가 " + this.end + "보다 늦다.");
    }

    public Date start() { return new Date(start.getTime()); }
    public Date end() { return new Date(end.getTime()); }
    @Override public String toString() { return start + " - " + end; }

    // ... // 나머지 코드는 생략
}
  • readObject는 매개변수로 Byte stream을 받는, 실질적인 또 다른 public 생성자다.
  • byte stream의 불변식을 고의로 깨뜨린 데이터를 넘기면, 정상적으로 생성할 수 없는 객체를 만들어낼 수 있다.

 

더보기

책에서는 정상적인 byte stream을 임의로 수정한 serializedForm을 역직렬화한 결과를 보여주는데, 따라 해보려다가 한참을 고생했다;;

 

 

직렬화하고 대충 임의의 값을 아무거나 바꾸면 되는 건가 싶었는데, serialize한 byte 스트림 배열 포맷에 따라 잘 바꿔야지만 역직렬화가 먹힌다.

뭐, 당연한 건데 이걸 고민씩이나 한 게 너무 바보같아서 어이가 없다. ^^

여튼 serialize 포맷을 찾아보려고 했으나, 찾기도 쉽지 않았을 뿐더러 굳이 그렇게 까지 해야하나 싶어서 관뒀다.

 

🟡 To-be (아직 부족하다)

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
    s.defaultReadObject();

    // 불변식을 만족하는지 검사한다.
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
}
  • 역직렬화 직후, 불변식을 만족하는지 확인한다.
  • 이렇게 하면 허용되지 않는 Period 인스턴스 생성을 막을 수 있다.

정말 단순하지만, 이것만으로는 아직 안전하다고 할 수 없다.

 

🛡️ 가변 공격 방어 
 

Java Object Serialization Specification: 6 - Object Serialization Stream Protocol

A set of flags indicating various properties of the class, such as whether the class defines a writeObject method, and whether the class is serializable, externalizable, or an enum type

docs.oracle.com

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()));

            /*
             * 악의적인 '이중' 인스턴스를 만든다. (이전 객체 참조, 자바 객체 직렬화 명세 6.4절)
             * 직렬화된 바이트 스트림을 역직렬화하여 Period 인스턴스를 만든다.
             * 그리고 내부의 Date 필드를 훔쳐낸다.
             */
            byte[] ref = {0x71, 0, 0x7e, 0, 5}; // 참조 #5
            bos.write(ref); // 시작 필드
            ref[4] = 4; // 참조 #4
            bos.write(ref); // 종료 필드

            // 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;

        // 내부의 Date 필드를 훔쳐낸다.
        pEnd.setYear(78);
        System.out.println(p);

        // 내부의 Date 필드를 훔쳐낸다.
        pEnd.setYear(69);
        System.out.println(p);
    }
}

솔직히 직렬화 많이 안 써봐서 코드 이해하기 제법 힘들었다..

  • 정상 Period 인스턴스의 byte 스트림 끝에 private Date 필드로의 참조를 추가해 가변 Period 인스턴스를 만들 수 있다. 
    • 내 추측인데, ref에서 앞의 4개 byte가 "참조"와 관련된 내용이고 마지막 byte가 몇 번 째를 참조하는지 정하는 것 같다.
    • 즉 byte 스트림의 format만 파악할 수 있다면, 내부 참조를 추가하여 정보를 훔쳐낼 수 있게 된다.
객체를 역직렬화할 때는 Client가 소유해서는 안 되는 객체 참조를 갖는 필드를 반드시 모두 방어적으로 복사하라

즉, readObject에서는 불변 클래스 내부의 모든 private 가변 요소를 방어적으로 보가해야 한다.

 

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
    s.defaultReadObject();
    
    // 가변 요소를 방어적으로 복사한다.
    start = new Date(start.getTime());
    end = new Date(end.getTime());
    
    // 불변식을 만족하는지 검사한다.
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
}
  • 방어적 복사를 유효성 검사보다 앞에 둔다. (Item 50)
  • final 필드는 방어적 복사가 불가능하므로 한정자를 제거해야 한다.
    • 공격 위험에 노출되는 것보다 낫다.

 

✒️ final이 아닌 직렬화 가능 클래스의 경우

생성자와 마찬가지로 readObject도 재정의 가능 메서드를 호출해서는 안 된다. (Item 19)
해당 메서드가 재정의되면, 하위 클래스의 상태가 완전히 역직렬화되기 전에 하위 클래스의 재정의된 메서드가 실행되어 프로그램 오작동으로 이어진다.

 

📌 기본 readObject 메서드를 사용 가능 판단 방법
  • (transient 필드를 제외한)모든 필드 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮다면 가능하다.
    • 안 된다면 커스텀 readObject로 유효성 검사 & 방어적 복사를 수행하라.
    • 직렬화 프록시 패턴(Item 90)을 사용하는 방법도 있다.