1. 개요
readObject 메서드의 역할을 이해하고, 방어적으로 작성해야하는 이유를 알아보자.
2. 직렬화, 역직렬화 예제
일반적으로 Object를 직렬화할 땐 ObjectOutputStream을, 역직렬화 할땐 ObjectOutputStream 인스턴스 인스턴스를 사용하고, 각각 writeObject(), readObject() 메서드를 호출하여 실제 데이터를 처리한다. 간단한 예제를 통해 직렬화와 역직렬화의 흐름을 먼저 이해해보자.
1) Period 인스턴스에 대한 직렬화
public final class Period implements Serializable {
private Date start;
private 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 + " after " + end);
}
}
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
}
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
Date startDate = df.parse("2024-11-14");
Date endDate = df.parse("2024-11-15");
try(
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)){
oos.writeObject(new Period(startDate, endDate));
byte[] serialized = baos.toByteArray();
for(byte b : serialized){
System.out.print(b +" ");
}
}
결과화면은 다음과 같다.
2) 직렬화된 byte[] 데이터에 대한 역직렬화
public static void main(String[] args) throws IOException, ParseException {
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
Date startDate = df.parse("2024-11-14");
Date endDate = df.parse("2024-11-15");
try(
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)){
oos.writeObject(new Period(startDate, endDate)); // 직렬화
byte[] serialized = baos.toByteArray();
Period period = deserializePeriod(serialized); // 역직렬화
System.out.println(period.start());
System.out.println(period.end());
}
}
public static Period deserializePeriod(byte[] bytes) throws IOException {
try(
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(bais)){
Object object = ois.readObject();
return (Period) object;
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
결과 화면은 다음과 같다.
3. readObject() 메서드의 문제 #1
readObject() 메서드가 수행하는 역할을 보면 byte[] 데이터를 기반으로 인스턴스를 생성하는 역할을 한다. 즉, 생성자의 역할을 하는 것이다. 일반적으로 생성자에는 validation을 통해 데이터의 불변식을 보장한다. 하지만 바이트 데이터가 조작되어 불변식이 깨진 Object에 대한 byte[] 데이터가 들어온다면 readObject() 메서드를 호출했을 때 별다른 validation 체크를 하지 않기때문에 불변식을 보장하지 못하게 된다.
public class BogusPeriod {
// 정상적이지 않은 바이트스트림
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,
0x2b, 0x4f, 0x46, (byte)0xc0, (byte)0xf4, 0x02, 0x00, 0x02,
0x4c, 0x00, 0x03, 0x65, 0x6e, 0x64, 0x74, 0x00, 0x10, 0x4c,
0x6a, 0x61, 0x76, 0x61, 0x2f, 0x75, 0x74, 0x69, 0x6c, 0x2f,
0x44, 0x61, 0x74, 0x65, 0x3b, 0x4c, 0x00, 0x05, 0x73, 0x74,
0x61, 0x72, 0x74, 0x71, 0x00, 0x7e, 0x00, 0x01, 0x78, 0x70,
0x73, 0x72, 0x00, 0x0e, 0x6a, 0x61, 0x76, 0x61, 0x2e, 0x75,
0x74, 0x69, 0x6c, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x68, 0x6a,
(byte)0x81, 0x01, 0x4b, 0x59, 0x74, 0x19, 0x03, 0x00, 0x00,
0x78, 0x70, 0x77, 0x08, 0x00, 0x00, 0x00, 0x66, (byte)0xdf,
0x6e, 0x1e, 0x00, 0x78, 0x73, 0x71, 0x00, 0x7e, 0x00, 0x03,
0x77, 0x08, 0x00, 0x00, 0x00, (byte)0xd5, 0x17, 0x69, 0x22,
0x00, 0x78
};
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);
}
}
}
출력하면 다음과 같이 start보다 end가 더 이르다는 것을 확인할 수 있다. 생성자를 사용하지 않기 때문에 validation 체크가 이루어지지 않고, 최종적으로 생성된 Period 객체는 불변식이 깨져버렸다.
4. readObject() 메서드 커스텀
이를 해결하기 위해 readObject() 메서드를 생성해야한다. 역직렬화할 클래스 내에 private 접근 제어자로 readObject() 메서드가 정의되어 있을 경우 ObjectInputStream.readObject() 메서드 호출 시 새로 정의한 메서드를 호출하기 때문이다. (리플렉션 기반)
단, 반드시 privcate 접근 제어자로 선언해야하고, 메서드 내에서 defaultWriteObject(), defaultReadObject()와 같은 기본 직렬화, 역직렬화 메서드를 한번 호출해줘야한다.
public final class Period implements Serializable {
private Date start;
private 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 + " after " + end);
}
}
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException{
s.defaultReadObject();
if (this.start.compareTo(this.end) > 0) { // 유효성 검사
throw new InvalidObjectException(start + " after " + end);
}
}
}
그 후, 불변식이 깨진 byte[] 데이터를 넣어 역직렬화를 해보면 유효하지 않는 오브젝트로 판단하여 역직렬화에 대한 예외를 발생시킨다.
5. readObject() 메서드의 문제 #2
하나의 문제가 더 있다. 직렬화된 스트림에 객체 참조 관련 바이트코드를 추가하면, 가변 Period 인스턴스를 만들 수 있다. Period 인스턴스를 가변 인스턴스로 사용할 수 있게 하는 MutablePeriod 클래스를 만든다.
public class MutablePeriod {
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()));
/*
* 악의적인 start, end 로의 참조를 추가.
*/
byte[] ref = { 0x71, 0, 0x7e, 0, 5 }; // 악의적인 참조
bos.write(ref); // 시작 필드
ref[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);
}
}
여기서 직렬화한 바이트 코드에 [0x71, 0, 0x7e, 0, 5], [0x71, 0, 0x7e, 0, 4] 코드를 추가한다. 이 값은 Java 직렬화 프로토콜에서 객체 참조를 나타내는 바이트 코드이다.
public static void main(String[] args) {
MutablePeriod mp = new MutablePeriod();
Period mutablePeriod = mp.period; // 불변 객체로 생성한 Period
Date pEnd = mp.end; // MutablePeriod 클래스의 end 필드
pEnd.setYear(78); // MutablePeriod 의 end 를 바꿨는데 ?
System.out.println(mutablePeriod.end()); // Period 의 값이 바뀐다.
pEnd.setYear(69);
System.out.println(mutablePeriod.end());
}
결과적으로 이러한 방법을 사용하게 되면, 불변 객체로 생성되어야할 Period 인스턴스의 end 값이 수정됨을 알 수 있다.
이를 해결하기 위해서는 readObject() 메서드 재정의 시 방어적 복사 코드를 추가해야한다.
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 +" after "+ end);
}
정리
이런 문제는 누군가 고의적으로 바이트코드를 위조한 상황을 가정했을 때 발생하는 문제들이다. 직렬화한 코드를 아무나 접근할 수 있는 외부 서비스에 보냈을 때 불변식이 깨지고, 객체 참조까지 되는 것을 확인할 수 있다.
앞선 챕터에서 Serializable은 신중하게 작성하라라는 내용이 있었다. 직렬화된 클래스가 외부로 나갔을 때 발생할 수 있는 여러 문제들이 있기 때문이다.
이번 챕터 역시 이러한 문제를 겪지 않으려면 직렬화를 신중히 하고, 만약 직렬화가 필요하다면, 외부에서 접근할 수 없는 환경에 저장하고, 로드할 수 있도록 하는 것이 중요하다는 것을 다시 한번 느끼게 되었다.
'공부 > Effective Java' 카테고리의 다른 글
[Effective Java] Item 84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라 (0) | 2024.10.10 |
---|---|
[Effective Java] Item 82. 스레드 안전성 수준을 문서화하라 (0) | 2024.10.03 |
[Effective Java] Item 73. 추상화 수준에 맞는 예외를 던져라 (0) | 2024.08.22 |
[Effective Java] Item 72. 표준 예외를 사용하라 (0) | 2024.08.15 |
[Effective Java] Item 70. 복구할 수 있는 상황에는 검사 예외를, 프로그래밍 오류에는 런타임 예외를 사용하라 (0) | 2024.07.04 |