Skip to content

[아이템 88]-readObject 메서드는 방어적으로 작성하라 #90

@easyfordev

Description

@easyfordev

방어적 복사를 사용하는 불변 클래스 - 직렬화한다면?

// 코드 50-1 기간을 표현하는 클래스 - 불변식을 지키지 못했다. (302-305쪽)
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(
                    this.start + "가 " + this.end + "보다 늦다.");
    }
		
		// 필드의 방어적 복사본을 반환한다.
		public Date start() {
        return new Date(start.getTime());
    }

    public Date end() {
        return new Date(end.getTime());
    }

    public String toString() {
        return start + " - " + end;
    }
		
		// ... 이하 생략
}
  • 아이템 50에서는 불변식을 지키고 불변을 유지하기 위해 생성자와 접근자에서 Date 객체를 방어적으로 복사했었다.

  • 이 클래스를 직렬화하기로 결정했다고 하자. 단순히 클래스 선언에 impelemts Serializable을 추가한다면, 이 클래스의 주요한 불변식을 더는 보장하지 못하게 된다.

  • 왜? readObject 메서드가 실질적으로 또 다른 public 생성자이기 때문

  • 따라서 다른 생성자와 똑같은 수준으로 주의를 기울여야 함. 그렇지않으면 공격자가 아주 손쉽게 이 클래스의 불변식을 깨뜨릴 수 있음

    1. readObject 메서드에서도 인수가 유효한지 검사해야하고(아이템 49)
    2. 필요하다면 매개변수를 방어적으로 복사해야 함(아이템 50)
  • 쉽게 말해, readObject는 매개변수로 바이트 스트림을 받는 생성자라고 할 수 있다.

    • 보통의 경우 바이트 스트림은 정상적으로 생성된 인스턴스를 직렬화해서 만들어진다.
    • 하지만 불변식을 깨뜨릴 의도로 임의 생성한 바이트 스트림을 건네면 문제가 생긴다. 정상적인 생성자로는 만들 수 없는 객체를 생성해낼 수 있기 때문이다.
  • 단순히 Period 클래스 선언에 impelemts Serializable를 추가했다고 가정하면, 다음의 괴이한 프로그램을 수행하면 종료 시각이 시작 시각보다 앞서는 Period 인스턴스를 만들 수 있다.

    public class BogusPeriod {
     // 진짜 Period 인스턴스에서는 만들어질 수 없는 바이트 스트림
     private static final byte[] serializedForm = {
      (byte)0xac, (byte)0xed, 0x00, 0x05, ...
     };
    
     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를 직렬화할 수 있도록 선언한 것만으로, 클래스의 불변식을 깨뜨리는 객체를 만들수 있음을 의미

문제 해결하기 1 - 객체 유효성 검사

  • 이 문제를 고치려면 Period의 readObject 메서드가 defaultReadObject를 호출한 다음, 역직렬화 객체가 유효한지 검사해야한다.

  • 유효성 검사에 실패하면 InvalidObjectException을 던지게하여 잘못된 역직렬화가 일어나는 것을 막을 수 있다.

  • 유효성 검사를 수행하는 readObject 메서드(아직 부족하다)

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
    	s.defaultReadObject();
    
    	// 불변식을 만족하는지 검사한다.
    	if (start.compareTo(end) > 0)
    		throw new InvalidObjectException(start + "가" + 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.writeObejct(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(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

문제 해결 2 - 방어적 복사와 유효성 검사를 수행

  • 위 예에서 Period 인스턴스는 불변식을 유지한 채 생성됐지만, 의도적으로 내부의 값을 수정할 수 있었다.

    • 이 문제의 근원은 Period의 readObject 메서드가 방어적 복사를 충분히 하지 않은 데 있다.
  • 따라서 readObject에서는 불변 클래스 안의 모든 private 가변 요소를 방어적으로 복사해야 한다.

  • 방어적 복사와 유효성 검사를 수행하는 readObject 메서드

    • 방어적 복사를 유효성 검사보다 앞서 수행하며, Date의 clone 메서드는 사용하지 않았음에 주목하자.
    • 두 조치 모두 Period를 공격으로부터 보호하는 데 필요하다.
    • 또한 start와 end 필드에서 final 한정자를 제거해야한다.
    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 + "보다 늦다.");
    }
  • 이제 앞의 공격 프로그램은 다음 내용을 출력한다.

    Wed Nov 22 00:21:29 PST 2017 - Wed Nov 22 00:21:29 PST 2017
    Wed Nov 22 00:21:29 PST 2017 - Sat Nov 22 00:21:29 PST 2017

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

  • transient 필드를 제외한 모든 필드의 값을 매개변수로 받아 유효성 검사 없이 필드에 대입하는 public 생성자를 추가해도 괜찮은가?에 대한 답이 ‘아니오'라면 → 커스텀 readObject 메서드를 만들어 모든 유효성 검사와 방어적 복사를 수행해야 함 (또는 직렬화 프록시 패턴-아이템90을 사용)

핵심 정리

  • readObject 메서드를 작성할 때는 public 생성자를 작성하는 자세로 신중하게 임해야 한다.
    • readObject는 어떤 바이트 스트림이 넘어오더라도 유효한 인스턴스를 만들어야 한다.
  • 안전한 readObject 메서드를 작성하는 지침
    1. private이어야 하는 객체 참조 필드는 각 필드가 가리키는 객체를 방어적으로 복사하라. 불변 클래스 내의 가변 요소가 여기 속한다.
    2. 모든 불변식을 검사하여 어긋나는 게 발견되면 InvalidObjectException을 던진다. 방어적 복사 다음에는 반드시 불변식 검사가 뒤따라야한다.
    3. 직접적이든 간접적이든, readObject에서는 재정의할 수 있는 메서드는 호출하지 말자.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions