Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Item88. readObject 메서드는 방어적으로 작성하라 #87

Open
letmeloveyou82 opened this issue Sep 19, 2023 · 0 comments
Open
Assignees

Comments

@letmeloveyou82
Copy link
Member

letmeloveyou82 commented Sep 19, 2023

미리 읽는 결론

readObject 메서드를 작성할 때는 언제나 public 생성자를 작성하는 자세로 임해야 한다.

readObject는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어내야 한다.

바이트 스트림이 진짜 직렬화된 인스턴스라고 가정해선 안 된다. 이번 아이템에서는 기본 직렬화 형태를 사용한 클래스를 예로 들었지만, 커스텀 직렬화를 사용하더라도 모든 문제가 그대로 발생할 수 있다.

아이템 50에서는 불변인 날짜 범위 클래스를 만들 때, 가변인 Date 필드를 이용.

⇒ 불변식을 지키고 불변 유지하기 위해 생성자와 접근자에서 Date 객체를 방어적으로 복사하느라 코드 매우 길어짐.

// 방어적 복사를 사용하는 불변 클래스
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) {
        if (start.compareTo(end) > 0)
            throw new IllegalArgumentException(
                    start + "가 " + end + "보다 늦다.");
        this.start = start;
        this.end   = end;
    }

    public Date start() {
        return start;
    }
    public Date end() {
        return end;
    }

    public String toString() {
        return start + " - " + end;
    }

     // 나머지 코드는 생략
}

이 클래스를 직렬화하기로 결정했다고 해보자.

  • Period 객체의 물리적 표현이 논리적 표현과 부합 → 기본 직렬화 형태(아이템 87)를 사용해도 not bad
  • implements Serializable을 추가하는 것으로 모든 일 끝낼 수 있을 것 같음
  • 그러나, 이렇게 해선 이 클래스의 주요한 불변식을 더는 보장 못하게 됨

문제 : readObject 메서드가 실질적으로 또 다른 public 생성자

  • 다른 생성자와 똑같은 수준으로 주의를 기울여야 함
  • 보통의 생성자처럼 readObject 메서드에서도 인수가 유효한지 검사해야 하고, 필요하다면 매개변수를 방어적으로 복사해야 함
  • readObject가 이 작업을 제대로 수행하지 못하면 공격자는 아주 손쉽게 해당 클래스의 불변식을 깨뜨릴 수 있음

쉽게 말하자면 readObject는 매개변수로 바이트 스트림을 받는 생성자라 할 수 있다.

  • 보통의 경우 바이트 스트림 : 정상적으로 생성된 인스턴스를 직렬화해 만들어짐

  • 하지만, 불변식을 깨뜨릴 의도로 임의 생성한 바이트 스트림을 건네면 문제 생김. 정상적인 생성자로는 만들어낼 수 없는 객체를 생성해낼 수 있기 때문에.

    단순히 Period 클래스 선언에 implements Serializable만 추가했다고 해보자.

    그러면 다음의 괴이한 프로그램을 수행해 종료 시각이 시작 시각보다 앞서는 Period 인스턴스를 만들 수 있음.

// 허용되지 않는 Period 인스턴스를 생성할 수 있다.
public class BogusPeriod {
    // 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림.
    // 정상적인 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,
            // ... 생략
    };

    // 상위 비트가 1인 바이트 값들은 byte로 형변환 했는데,
    // 이유는 자바가 바이트 리터럴을 지원하지 않고 byte 타입은 부호가 있는(signed) 타입이기 때문이다.

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

이 프로그램을 실행하면 Fri Jan 01 12:00:00 PST 1999 - Sun Jan 01 12:00:00 PST 1984를 출력함

보다시피 Period를 직렬화할 수 있도록 선언한 것만으로 클래스의 불변식을 깨뜨리는 객체를 만들 수 있게 된 것.

문제 해결 방법

  • Period의 readObject 메서드가 defaultReadObject를 호출한 다음 역직렬화된 객체가 유효한지 검사해야 한다.
  • 이 유효성 검사에 실패하면 InvalidObjectException을 던지게 하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다.
// 유효성 검사를 수행하는 readObject 메서드 - 아직 부족하다.
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException{
    s.defaultReadObject();

    // 불변식을 만족하는지 검사한다.
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start +" after "+ end);
}
  • 공격자가 허용되지 않는 Period 인스턴스를 생성하는 일을 막을 수 있다.

    또 다른 문제 존재

    정상 Period 인스턴스에서 시작된 바이트 스트림 끝에 Private Date 필드로의 참조를 추가하면 가변 Period 인스턴스를 만들어낼 수 있다는 문제가 있다. 공격자는 ObjectInputStream에서 Period 인스턴스를 읽은 후 스트림 끝에 추가된 이 ‘악의적인 객체 참조’를 읽어 Period 객체의 내부 정보를 얻을 수 있다. 이제 이 참조로 얻은 Date 인스턴스들은 수정 가능 → Period 인스턴스는 더는 불변이 아니게 된다.

    // 가변 공격의 예
    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 필드로의 참조를 추가한다.
                 * 상세 내용은 자바 객체 직렬화 명세의 6.4절을 참고하자.
                 */
    		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(60);
        System.out.println(p);
    }
    • 이 예에서 Period 인스턴스는 불변식을 유지한 채 생성됐지만, 의도적으로 내부의 값을 수정할 수 있었다.
    • 이처럼 변경할 수 있는 Period 인스턴스를 획득한 공격자는 이 인스턴스가 불변이라고 가정하는 클래스에 넘겨 엄청난 보안 문제를 일으킬 수 있다.
    • 문제의 근원 : Period의 readObject 메서드가 방어적 복사를 충분히 하지 않았기 때문이다.
    • 문제 해결 방법 : 객체를 역직렬화할 땐 클라이언트가 소유해서는 안 되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다.
    • 따라서 readObject에서는 불변 클래스 안의 모든 private 가변 요소를 방어적으로 복사해야 한다.

✔ Period의 불변식과 불변 성질을 지켜내는 readObject 메서드

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

    // 1. 가변 요소들을 방어적으로 복사한다.
    start = new Date(start.getTime());
    end = new Date(end.getTime());

    // 2. 불변식을 만족하는지 검사한다.
    if (start.compareTo(end) > 0)
        throw new InvalidObjectException(start +" after "+ end);
}

이 새로운 readObject를 적용하면 MutablePeriod 클래스도 힘을 쓰지 못한다.

주목할 점

  • 방어적 복사를 유효성 검사보다 앞서 수행한다.
  • Date의 clone 메서드는 사용하지 않았다.

주의할 점

  • Final 필드는 방어적 복사가 불가능하다. 그래서 이 readObject 메서드를 사용하려면 start와 end 필드에서 final 한정자를 제거해야 한다. 아쉬운 일이지만 앞서 살펴본 공격 위험에 노출되는 것보다 낫다.
// start와 end 필드에서 final 한정자를 제거한 Period 클래스
public final class Period {
    private Date start;
    private Date end;

     /**
     * @param start 시작 시각
     * @param end 종료 시각; 시작 시각보다 뒤여야 한다.
     * @throws IllegalArgumentException 시작 시각이 종료 시각보다 늦을 때 발생한다.
     * @throws NullPointerException start나 end가 null이면 발생한다.
     */
    public Period(Date start, Date end) {
        if (start.compareTo(end) > 0)
            throw new IllegalArgumentException(
                    start + "가 " + end + "보다 늦다.");
        this.start = start;
        this.end   = end;
    }

    public Date start() {
        return start;
    }
    public Date end() {
        return end;
    }

    public String toString() {
        return start + " - " + end;
    }

    // 나머지 코드는 생략
}

기본 readObject 메서드를 써도 좋을지를 판단하는 간단한 방법

transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮은가?

  • ‘예’ ⇒ 기본 readObject 메서드 써도 된다.

  • ‘아니오’ ⇒ 커스텀 readObject 메서드를 만들어 (생성자에서 수행했어야 할) 모든 유효성 검사와 방어적 복사를 수행해야 한다. 혹은 *직렬화 프록시 패턴(아이템 90)을 사용하는 방법도 있다. 이 패턴은 역직렬화를 안전하게 만드는 데 필요한 노력을 상당히 경감해주므로 적극 권장한다.

    *직렬화 프록시 패턴(Serialization Proxy Pattern) : 객체를 직렬화하거나 역직렬화할 때 발생하는 문제를 해결하기 위한 디자인 패턴 중 하나. 이 패턴은 객체의 직렬화와 역직렬화 과정에서 발생하는 문제를 제어하고, 객체의 내부 상태를 숨기거나 변환할 수 있도록 도와줌. 주요 아이디어는 원본 객체의 직렬화 프로세스를 직접 제어하기보다는 직렬화 프록시를 통해 데이터를 다루는 것.

    직렬화 프록시 패턴은 두 가지 클래스로 구성된다.

    1. 원본 객체 (Original Object): 직렬화되어야 하는 원본 객체. 이 객체의 내부 상태를 직렬화하려고 하는데, 때로는 이 내부 상태를 숨기거나 보호해야 할 필요가 있다.
    2. 직렬화 프록시 (Serialization Proxy): 원본 객체를 대신하여 직렬화 및 역직렬화 프로세스를 제어하는 객체. 이 객체는 직렬화되어야 하는 데이터를 직렬화 가능한 형태로 변환하고, 역직렬화 시에는 이 데이터를 다시 원래 객체로 복원한다.

final이 아닌 직렬화 가능 클래스라면?

  • readObject와 생성자의 공통점이 하나 더 있다. 마치 생성자처럼 readObject 메서드도 재정의 가능 메서드를 (직접적으로든 간접적으로든) 호출해선 안 된다는 것이다.
  • 이 규칙을 어겼는데 해당 메서드가 재정의된다면, 하위 클래스의 상태가 완전히 역직렬화되기 전에 하위 클래스에서 재정의된 메서드가 실행되며 프로그램의 오작동으로 이어진다.

마치며

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

  • private이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
  • 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야 한다.
  • 역직렬화 후 객체 그래프 전체의 유효성을 검사해야 한다면 ObjectInputValidation 인터페이스를 사용하라(이 책에서는 다루지 X).
  • 직접적이든 간접적이든, 재정의할 수 있는 메서드는 호출하지 말자.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant