Notice
Recent Posts
Recent Comments
Link
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Archives
Today
Total
관리 메뉴

전공공부

[아이템 88] readObject는 방어적으로 작성하라. 본문

Study/Java

[아이템 88] readObject는 방어적으로 작성하라.

monitor 2023. 7. 31. 23:52
readObject는 생성자

아이템 50에서 가변인 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) {
        this.start = new Date(start.getTime());
        this.end = new Date(end.getTime());
        if(this.start.compareTo(this.end) > 0) {
            throw new IllegalArgumentException(start + "가 " + end + "보다 늦다.");
        }
    }
    
    public Date start() { return new Date(start.getTime()); }
    public Date end() { return new Date(end.getTime()); }
    public String toString() { return start + "-" + end; }
}

위와 같은 코드에서 직렬화 진행하려면 implements Serializable을 단순히 추가하면 될 것 같지만 그렇지 않다.

 

readObject가 또 다른 public 생성자이기 때문이다.

 

이게 문제가 되는 이유는 아래에서 살펴 보자

 

readObject 사용 문제

readObject 메서드는 매개변수로 바이트 스트림을 받는 생성자라고 할 수 있다. 

 

정상적인 경우에는 바이트 스트림은 정상적으로 생성된 인스턴스를 직렬화해서 만들어진다.

 

그러나, 불변을 깨뜨릴 의도로 만들어진 바이트 스트림을 받으면 문제가 생긴다. 정상적인 방법으로는 만들어낼 수 없는 객체를 생성할 수 있기 때문이다.

 

public class BogusPeriod {
    // 진짜 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,
        ... 생략
}

    public static void main(String[] args) {
        Period p = (Period) deserialize(serializedForm);
        System.out.println(p);
    }

    static Object deserialize(byte[] sf) {
        try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(sf)) {
            try (ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) {
                return objectInputStream.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 을 출력한다.

 

이것을 막으려면...

// 방어적 복사를 사용하는 불변 클래스
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(start + "가 " + end + "보다 늦다.");
        }
    }
    
    public Date start() { return new Date(start.getTime()); }
    public Date end() { return new Date(end.getTime()); }
    public String toString() { return start + "-" + end; }

// 이 부분이 핵심임
    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();

        // 불변식을 만족하는지 검사한다.
        if (start.compareto(end) > 0) {
            throw new InvalidObjectException(start + " 가 " + end + " 보다 늦다.");
        }
    }
// readObject 호출 시 위 메서드가 실행 되므로 불변식 만족하는지 검사 할 수 있다.
}

그래서 이런 상황을 막기 위해서 유효성 검사를 진행하는 readObject를 사용해서 해결 할 수 있다.

 

readObject 사용 문제 2

Period 인스턴스에서 시작된 바이트 스트림 끝에 private Date 필드로의 참조를 추가하여서 가변 Period 객체를 만들 수 있다.

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.*;

// 가변 공격의 예
public class MutablePeriod {

    //Period 인스턴스
    public final Period period;
    
    public final Date start;
    public final Date end;

    class ObjectArrayOutputStream extends ObjectOutputStream {
        private ByteArrayOutputStream bos;

        public ObjectArrayOutputStream(ByteArrayOutputStream bos) throws IOException {
            super(bos);
            this.bos = bos;
        }

        public byte[] toByteArray() {
            return bos.toByteArray();
        }
    }

    public static 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(start + "가 " + end + "보다 늦다.");
            }
        }
        
        public Date start() { return new Date(start.getTime()); }
        public Date end() { return new Date(end.getTime()); }
        public String toString() { return start + "-" + end; }
    }

    public MutablePeriod() {
        try {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectArrayOutputStream out = new ObjectArrayOutputStream(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 -> 이거 써서 Period 객체에 참조가 가능한 것 자세하게 어떤 건지는 모름
            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(10);
        System.out.println(p);
    }
}

위와 같이 중간에 역직렬화 직전에 ByteArrayOutputStream 객체에 참조 값을 변경하면 private Date를 통해서 Period 객체를 가변적으로 변경 시킬 수 있다.

 

Period 객체는 결국 불변식을 유지하였으나 내부의 값을 수정할 수 있었다.

 

객체를 역직렬화 할 때는 클라이언트가 소유해서는 안되는 객체 참조를 갖는 필드를 모두 반드시 방어적으로 복사해야 한다. 

 

따라서, readObject에서는 불변 클래스 안의 모든 Private 가변 요소를 방어적으로 복사해야 한다.

 

이에 대한 해법으로 다음의 예시를 지켜보자

 

방어적 복사와 유효성 검사를 진행하는 readObject Method

private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
    s.defaultReadObject(); //이때 원래 생성되면서 Period 객체의 Date end 부분에 참조가 가능했다
    
    // 가변 요소들을 방어적으로 복사한다.
    start = new Date(start.getTime());
    end = new Date(end.getTime()); //객체를 방어적 복사 함으로써 접근을 막는다
    
    // 불변식을 만족하는지 검사한다.
    if(start.compareTo(end) > 0) {
       throw new InvalidObjectException(start + "가 " + end + "보다 늦다.");
    }
}

해당 코드를 Period 부분에 추가하고 Period 내부의 Date 객체에 final을 삭제 한 후 아까전의 main 코드를 실행시키면 방어적 복사가 실행되어서 직렬화 시에 객체 참조가 불가능하다.

 

기존에는 readObject 기존 메서드를 호출하면 

 

period = (Period) in.readObject();

이거 타고 들어가서 Period 객체 수정이 되었는데 위와 같이 가변 객체 막아 버리면 이미 한 번 만들어진 복사된 객체이므로 바깥에서 접근해서 MutablePeriod 객체의 Date end 값을 수정해도 같이 Period 값이 바뀌지 않음

 

Override 하지 않은 기본 readObject 메서드 써도 되는 때

- (@transient 필드 제외) 모든 필드의 값이 매개변수로 받아 유효성 검사 없이 필드에 입하는 public 생성자를 추가하여도 괜찮을 때는 사용해도 된다.

 

 

결론 
- readObject 메서드를 작성 할 때는 생성자를 작성하는 자세로 나아가야 한다.
- readObject 어떠한 바이트 스트림이 오더라도 유효한 인스턴스를 만들어 내야 한다.
 => 그런데, 그 인스턴스가 진짜 직렬화 된 인스턴스가 아닐 수 도 있다.
- private이여야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하여야 한다.