Java

직렬화 프록시 패턴이란

파미페럿 2022. 1. 28. 17:55

* 이 내용은 이펙티브 자바 12장 아이템 90의 내용을 토대로 작성되었다.

 

직렬화의 위험성

기본적으로 객체를 직렬화 한다면 implements Serializable을 많이 사용한다. implements Serializable을 사용해 자동으로 객체는 바이트 스트림으로 직렬화 하고 그것을 역직렬화 하는데 이 때 공격자는 바이트 스트림을 수정해 객체에서 외부에서 접근 불가하게 private으로 선언된 값을 참조할 수 있으며 수정해 불변식을 깨뜨릴 수 있다.

 

예를 들어 아래와 같이 기간을 저장하는 Period라는 클래스가 있다고 하자.

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

 

Period에서 end는 언제나 start보다 크거나 같아야 한다. 이를 위해 생성자에서는 end가 start보다 작을 경우 IllegalArgumentException을 발생시키고 있다. 생성자를 통해 생성할 경우 start, end에 대한 불변식을 체크하고 Period를 생성하지만 직렬화된 바이트 스트림을 통해 인스턴스를 역직렬화 해 생성하는 경우에는 생성자에 있는 start, end 값 체크를 하지 않는다. 즉, 공격자가 end가 start보다 작은 Period의 바이트 스트림을 생성해서 보낸다면 받는 쪽에서는 불변식이 깨진 것을 모르고 Period로 역직렬화 해 사용할 수 있다.

 

만일 역직렬화할 바이트 스트림을 보내주는 곳의 출처가 정확하고 역직렬화할 바이트 스트림이 잘못된 값이 아니라는 확신이 있다면 상관 없지만, 그러한 확신이 없고 꼭 직렬화를 해야하는 경우라면 어떻게 해야할까? 이럴 때 직렬화 프록시 패턴이라는 것을 사용해 공격자가 의도한대로 값을 변경 못하게 막을 수 있다.

 

직렬화 프록시 패턴

직렬화 프록시 패턴은 말 그대로 객체를 직렬화 하는데 실제 직렬화 되는 클래스를 이용하는 것이 아니라 별도 직렬화를 해주는 프록시 클래스를 둬서 그 클래스를 이용해 직렬화를 수행하는 패턴이다.

 

직렬화 프록시 패턴을 보통 직렬화를 하고자 하는 클래스의 inner 클래스로 생성한다. 또한 이 클래스는 직렬화에서만 사용하므로 private static으로 생성하며 마찬가지로 Serializable을 구현한다.

또한 직렬화에 사용될 것이므로 직렬화 하고자 하는 클래스에서 직렬화 하는 필드 값을 그대로 똑같이 같는다.

위의 Period의 직렬화 프록시를 만들어보면 아래와 같다.

public class Period implements Serializable {
   // 위의 코드랑 동일
   ...

    private static class SerializationProxy implements Serializable {
        private final Date start;
        private final Date end;

        public SerializationProxy(Period p) {
            this.start = p.start;
            this.end = p.end;
        }
    }
}

 

이렇게 만든 후 중요한 것은 Period에 직렬화/역직렬화할 때 호출하는 writeReplace(), readResolve()를 SerializationProxy를 사용하도록 정의해줘야한다는 것이다.

writeReplace, readResolve는 Serializable을 구현했을 시 정의할 수 있는 특수 메서드로 이 두 메서드 말고 readObjectNoData도 정의할 수 있다.

/**
...
 * <PRE>
 * ANY-ACCESS-MODIFIER Object writeReplace() throws ObjectStreamException;
 * </PRE><p>
 *
 * This writeReplace method is invoked by serialization if the method
 * exists and it would be accessible from a method defined within the
 * class of the object being serialized. Thus, the method can have private,
 * protected and package-private access. Subclass access to this method
 * follows java accessibility rules. <p>
 ...
  * <PRE>
 * ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException;
 * </PRE><p>
 *
 * This readResolve method follows the same invocation rules and
 * accessibility rules as writeReplace.<p>
 ...
 **/

 

writeReplace 정의

writeReplace 메서드는 직렬화할 때 원래 객체가 아닌 다른 객체를 직렬화할 수 있도록 하는 메서드로 반환되는 Object를 직렬화한다.

이 때 Period를 직렬화하는 것이 아닌 Period로 SerializationProxy를 만들어서 SerializationProxy를 직렬화 하도록 한다. 이는 추후 역직렬화를 Period가 아닌 SerializationProxy를 이용해서 하기 위함이다.

참고로 writeReplace는 직렬화할 떄만 사용하고 외부에서 호출을 안 하므로 private으로 선언한다.

public class Period implements Serializable {
    ...

    private Object writeReplace() {
        return new SerializationProxy(this);
    }


    private static class SerializationProxy implements Serializable {
       ...
    }
}

 

코드는 아주 단순하다. 그냥 현재 Period로 SerializationProxy를 생성해서 반환한다. 이렇게 정의하면 Period를 직렬화하면 실제로는 Period로 만든 SerializationProxy가 직렬화되는 것이다.

 

또한 이렇게 SerializationProxy로 직렬화하도록 정의하면 직렬화된 결과 바이트 스트림은 Period의 내부 정보 없이 SerializationProxy의 정보로 되어 있기에 공격자가 Period의 내부에 접근할까 걱정하지 않아도 된다. 따라서 직렬화 프록시 객체에는 원래 직렬화 하려고 했던 객체가 직렬화하는 필드를 제외한 정보를 넣지 말자.

 

readResolve 정의

writeReplace를 통해 Period 대신 SerializationProxy를 직렬화 했다면 이제 역직렬화할 때도 SerializationProxy를 이용하게 된다. 즉, SerializationProxy에서 역직렬화할 때 Period를 반환해주면 된다.

readResolve는 writeReplace와 반대로 역직렬화 할 때 원래 객체가 아닌 다른 객체로 직렬화할 수 있게 해준다.

SerializationProxy를 역직렬화해서 Period를 얻어야하므로 SerializationProxy에 readResolve를 정의한다.

readResolve 또한 외부에서 호출을 하지 않고 역직렬화에만 사용하므로 private으로 선언한다.

public class Period implements Serializable {
    ...


    private static class SerializationProxy implements Serializable {
        ...

        private Object readResolve() {
            return new Period(start, end);
        }
    }
}

 

코드는 writeReplace와 마찬가지로 매우 간단하다. SerializationProxy의 start, end를 이용해서 Period를 생성해서 넘겨 준다.

그렇다면 이게 왜 공격자가 불변식을 해치는 것을 막아줄까. 바로 Period 생성자를 통해 Period 객체로 변환해주기 때문이다. Peiord 생성자에는 이미 start, end에 대해 값을 체크하는 로직이 들어가 있다. 역직렬화에 대해 별도 체크하는 로직을 넣을 필요 없이 Period의 생성자를 통해 Period를 생성해 넘겨주는 것으로 간단하게 해결이 가능하다.

private Object readResolve() {
	return new Period(start, end);
}

 

 

물론 이 직렬화 프록시를 구현하지 않고 readResolve를 Period에 구현해서 생성자를 이용해 Peiord를 구현하게 할 수도 있지만 이 방법은 Period의 내부 정보를 직렬화한 바이트 스트림에 그대로 노출한다는 단점이 있다.

 

readObject로 프록시 객체 없이 역직렬화 불가하게 하기

그런데 만일 공격자가 바이트 스트림을 조작해서 직렬화 프록시인 SerializationProxy를 건너뛰고 바로 Period로 역직렬화 하려고 하면 어떡하나?

그 때는 Period를 역직렬화 할 때 프록시 없이는 역직렬화 할 수 없다는 에러를 발생시켜 Period를 직접적으로 역직렬화하는 것을 막아주면 된다.

이는 readObject 메서드를 구현해서 수행할 수 있다.

readObject는 역직렬화 과정에서 별도의 처리가 필요할 때 구현하는 메서드이다. 마찬가지로 외부 호출 없이 역직렬화할 때만 호출되는 메서드로 private으로 선언한다.

public class Period implements Serializable {
    ...

    private void readObject(ObjectInputStream stream) throws InvalidObjectException {
        throw new InvalidObjectException("프록시가 필요합니다.");
    }


    private static class SerializationProxy implements Serializable {
       ...
    }
}

 

/**
...
 * <PRE>
 * private void writeObject(java.io.ObjectOutputStream out)
 *     throws IOException
 * private void readObject(java.io.ObjectInputStream in)
 *     throws IOException, ClassNotFoundException;
 * private void readObjectNoData()
 *     throws ObjectStreamException;
 * </PRE>
 ...
 * <p>The readObjectNoData method is responsible for initializing the state of
 * the object for its particular class in the event that the serialization
 * stream does not list the given class as a superclass of the object being
 * deserialized.  This may occur in cases where the receiving party uses a
 * different version of the deserialized instance's class than the sending
 * party, and the receiver's version extends classes that are not extended by
 * the sender's version.  This may also occur if the serialization stream has
 * been tampered; hence, readObjectNoData is useful for initializing
 * deserialized objects properly despite a "hostile" or incomplete source
 * stream.
 ...
 **/

 

 

완성된 Period, SerializationProxy는 아래와 같다.

public class Period implements Serializable {
    private final Date start;
    private final Date end;

    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 + "보다 늦다.");
        }
    }

    private Object writeReplace() {
        return new SerializationProxy(this);
    }


    private static class SerializationProxy implements Serializable {
        private final Date start;
        private final Date end;

        public SerializationProxy(Period p) {
            this.start = p.start;
            this.end = p.end;
        }

        private Object readResolve() {
            return new Period(start, end);
        }
    }
}

 

 

 

 

✋ 이펙티브 자바 3판 github

https://github.com/WegraLee/effective-java-3e-source-code

 

GitHub - WegraLee/effective-java-3e-source-code: 『이펙티브 자바, 3판』(인사이트, 2018)

『이펙티브 자바, 3판』(인사이트, 2018). Contribute to WegraLee/effective-java-3e-source-code development by creating an account on GitHub.

github.com

 

✋ Serializable

https://docs.oracle.com/javase/7/docs/api/java/io/Serializable.html

 

Serializable (Java Platform SE 7 )

Serializability of a class is enabled by the class implementing the java.io.Serializable interface. Classes that do not implement this interface will not have any of their state serialized or deserialized. All subtypes of a serializable class are themselve

docs.oracle.com

 

 

 

 

 

반응형