아이템 85 - 자바 직렬화의 대안을 찾으라
직렬화란 뭘까요?
넓은 의미로 직렬화는 어떤 데이터를 다른 데이터의 형태로 변환하는 것을 말합니다.
이팩티브 자바에서 말하는 직렬화(Serializable
)란 바이트 스트림으로의 직렬화로 객체의 상태를 바이트 스트림으로 변환하는 것을 의미합니다.
반대로 바이트 스트림에서 객체의 상태로 변환하는 건 역직렬화(Deserializable
)라고 부릅니다.
그럼 바이트 스트림이란 뭘까요?
스트림은 데이터의 흐름입니다. 데이터의 통로라고도 이야기하는데요.
예를 들어, 웹 개발을 하다보면 클라이언트에서 서버에게 데이터를 보내는 일이 있습니다.
이처럼 스트림은 클라이언트와 서버같이 어떤 출발지와 목적지로 입출력하기 위한 통로를 말합니다.
자바는 이런 입출력 스트림의 기본 단위를 바이트로 두고 있고 입력으로는 InputStream
, 출력으로는 OutputStream
라는 추상클래스로 구현되어 있습니다.
왜 직렬화를 사용할까요?
일단 자바에서 표현된 객체를 목적지에 보냈다고 했을 때 그 곳에서 아 이게 자바 객체구나~하고 바로 알 수가 없습니다. 목적지에서 객체를 알 수 있는 방법이 없습니다. 그래서 모두 다 알 수 있는 것으로 변환을 해줘야 합니다.
여기서 변환을 도와주는 방법이 직렬화이며 직렬화를 통해서 바이트 스트림으로 변환해줄 수 있습니다.
왜 굳이 바이트 스트림으로 변환할까요?
바이트인 이유는 컴퓨터에서 기본으로 처리되는 최소 단위가 바이트이기 때문입니다. 완전 최소 단위로 가면 비트로도 처리할 수 있겠지만 표현할 수 있는 방법이 0과 1로 너무 적어 하나의 단위로 묶었다고 합니다.
이렇게 바이트 스트림으로 변환해야 네트워크, DB로 전송할 수 있고 목적지에서도 처리를 해줄 수 있습니다. 쉽게 생각하면 ‘출발지와 목적지 모두 알아들을 수 있는 byte라는 언어로 소통할 수 있도록’ 이라고 말할 수 있겠네요.
바이트 직렬화 예시
간단한 예시로 문자열을 서버로 보내는 TCP 클라이언트를 개발한다고 합시다.
이때 문자열 → 바이트 직렬화가 사용됩니다.
서버에 메시지를 보내기 위해 소켓에서 OutputStream
(출력 스트림)을 가져와 출력하고 싶은 문자열(message
)을 바이트로 바꿔 쓰게(write
)됩니다.
Socket socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 5001));
byte[] bytes;
String message;
OutputStream os = socket.getOutputStream(); // 출력 스트림
message = "이펙티브 자바 파이팅~";
bytes = message.getBytes(StandardCharsets.UTF_8); // 문자열 -> 바이트
os.write(bytes); // 출력 스트림에 바이트를 쓰고
os.flush(); // flush를 날리면 그 소켓으로 출력이 된다
문자열 직렬화 예시
웹 개발에서 자주 사용하는 데이터 형태인 JSON도 문자열 직렬화가 사용됩니다.
JSON 형태로 문자열 직렬화를 해줘야 합니다. Spring Boot에서는 Jackson2가 모두 해주고 있습니다.
@DisplayName("JSON 직렬화 테스트")
@Test
void jsonSerializable() {
Person person = new Person("bingbong", 21);
String json = String.format("{\"name\":\"%s\",\"age\":%d}", person.name, person.age);
assertThat(json).isEqualTo("{\"name\":\"bingbong\",\"age\":21}");
}
어떻게 객체에서 바이트 직렬화를 할까요?
객체에 Serializable
이라는 인터페이스를 구현하면 됩니다.
참고로 Serializable
은 안에 아무것도 선언되어있지 않습니다. 이를 마커 인터페이스라고 부르는데요.
말 그대로 직렬화를 할 수 있는 객체라고 알려주는 것입니다.
그리고 나서 목적지까지 가는 바이트 스트림에 writeObject를 사용해주시면 됩니다.
@DisplayName("Serializable을 구현한 Person 객체 직렬화 테스트")
@Test
void writeObjectTest() throws IOException {
Person person = new Person("bingbong", 21);
byte[] serializedPerson;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(person);
// 직렬화된 Person 객체
serializedPerson = baos.toByteArray();
}
}
// 요런 식으로 나옴
// -84, -19, 0, 5, 115, 114, 0, 57, 99, 111, 109, 46, 98, 105, 110, 103, 98, 111, 110, 103, 46, 101, 102, 102, 101, 99, 116, 105, 118, 101, 106, 97, 118, 97, 46, 105, 116, 101, 109, 56, 53, 46, 83, 101, 114, 105, 97, 108, 105, 122, 97, 98, 108, 101, 84, 101, 115, 116, 36, 80, 101, 114, 115, 111, 110, -126, 113, -121, -86, 125, 92, 57, 9, 2, 0, 2, 73, 0, 3, 97, 103, 101, 76, 0, 4, 110, 97, 109, 101, 116, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 120, 112, 0, 0, 0, 21, 116, 0, 8, 98, 105, 110, 103, 98, 111, 110, 103
assertThat(serializedPerson).isNotEmpty();
}
static class Person implements Serializable {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
그렇다면 역직렬화는?
@DisplayName("Serializable을 구현한 Person 객체 직렬화 후, 역직렬화 테스트")
@Test
void writeObjectTest2() throws IOException {
Person person = new Person("bingbong", 21);
byte[] serializedPerson;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(person);
// 직렬화된 Person 객체
serializedPerson = baos.toByteArray();
}
}
Person deSerializedPerson = null;
try (ByteArrayInputStream bais = new ByteArrayInputStream(serializedPerson)) {
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
// 역직렬화된 Person 객체
deSerializedPerson = (Person) ois.readObject();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
assertThat(deSerializedPerson).isNotNull();
assertThat(deSerializedPerson.name).isEqualTo("bingbong");
assertThat(deSerializedPerson.age).isEqualTo(21);
}
이런 직렬화가 왜 문제가 될까요?
직렬화의 근본적인 문제는 공격 범위가 너무 넓고 지속적으로 더 넓어져 방어하기 어렵다는 점입니다.
직렬화를 하고 나서 역직렬화를 할 때 문제가 됩니다.
- 객체를 읽는 readObject 메서드는 클래스 패스에 존재하는 거의 모든 타입의 객체를 만들어낼 수 있습니다.
- 반환 타입이 Object입니다
- 바이트 스트림을 역직렬화하는 과정에서 해당 타입 안의 모든 코드를 수행할 수 있습니다.
- 객체를 아예 불러올 수 있으므로 모든 코드를 수행할 수 있습니다.
- 그렇기에 타입 전체가 전부 공격 범위에 들어가게 됩니다.
- 용량도 다른 포맷에 비해서 몇 배 이상의 크기를 가집니다
- 또한 역직렬화 폭탄을 맞을 수도 있습니다.
바이트 스트림으로 직렬화하는데는 시간이 별로 걸리지 않지만 역직렬화를 잘못하면 안으로 들어가서 hashCode 메서드를 계속 호출해야하기때문에 시간이 엄청 오래걸립니다. 아래 예시가 있습니다.
@DisplayName("역직렬화 폭탄 테스트")
@Test
void deserializeBomb() {
byte[] bomb = bomb();
// 역직렬화를 하면 엄청 많은 시간이 걸린다.
deserialize(bomb);
assertThat(bomb).isNotEmpty();
}
static byte[] bomb() {
Set<Object> root = new HashSet<>();
Set<Object> s1 = root;
Set<Object> s2 = new HashSet<>();
for (int i = 0; i < 100; i++) {
Set<Object> t1 = new HashSet<>();
Set<Object> t2 = new HashSet<>();
t1.add("foo");
s1.add(t1);
s1.add(t2);
s2.add(t1);
s2.add(t2);
s1 = t1;
s2 = t2;
}
return serialize(root); // 직렬화 수행
}
그럼 어떻게 해야하나?
가장 좋은 방법은 아무것도 역직렬화하지 않는 것이라고 합니다. 직렬화를 피할 수 없고 역직렬화한 데이터가 안전한지 완전히 확신할 수 없다면 java 9에 나온 ObjectInputFilter를 사용하는 것도 방법입니다. 이는 데이터 스트림이 역직렬화되기 전에 필터를 적용해서 특정 클래스를 받아들이거나 거부할 수 있습니다.
요약
직렬화는 어떤 데이터를 다른 데이터의 형태로 변환하는 것이다
신뢰할 수 없는 역직렬화하는 것 자체가 스스로를 공격에 노출하는 행위다
역직렬화를 해야한다면 ObjectInputFilter를 사용하자