...
Serializable 구현은 보안에 구멍이 생길 수 있다
보통 자바에서 인스턴스는 생성자를 이용해 만든는 것이 기본이다. 하지만 역직렬화는 언어의 기본 메커니즘을 우회하여 객체를 바로 생성하도록 한다. 직렬화된 파일이나 데이터만 있다면 readObject() 를 통해 생성자 없이 곧바로 인스턴스를 만들수 있기 때문이다.
즉, 역직렬화는 보이지 않은 생성자 이기도 한 것이다. 문제는 만일 어느 객체가 생성자를 통해 인스턴스화 할때 불변식이나 허가되지 않은 접근을 설정하였을 경우 이를 무시하고 생성된다는 점이다.
예를들어 아래 Member 클래스는 생성자로 나이 입력값을 음수를 넣으면 이를 걸러내는 로직이 있다고 한다.
class Member implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
public Member(String name, int age) {
if(age < 0){
throw new IllegalArgumentException();
}
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Member{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
아래 첨부파일은 예전에 누군가가 Member 객체를 직렬화하여 저장한 파일이라고 한다. 그런데 누군가 이를 가로채어 몰래 값을 조작했다.
우리 팀은 그 사실을 모르고 이를 역직렬화 하여 프로그램을 실행하였다.
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 역직렬화
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream("Student.ser")));
Member member = (Member) in.readObject();
in.close();
System.out.println(member);
}
이처럼 -100 이라는 옳지 않는 값으로 조작되어도, readObject() 로 역직렬화 하는 과정을 통한 객체 생성은 불변식을 무시하고 이상한 값이 들어가게 된다.
만약 이게 돈과 관련된 값이라면 해커의 조작에 의해 엄청나게 큰일 날 수 있는 위험한 상황이게 된다.
커스텀 직렬화 방어 기법
이를 방어하는 가장 간단한 기법은 readObject 메서드를 직렬화 구현체 클래스에 재정의하여 유효성 검사 로직을 손보는 것이다. 그러면 역직렬화의 readObject() 가 호출되면, 직렬화 클래스의 readObject 메서드가 대신 실행되게 된다.
이처럼 기본 직렬화 동작을 커스텀 한다는 점에서 커스텀 직렬화 라고 불리운다.
class Member implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
public Member(String name, int age) {
this.name = name;
checkPositive();
this.age = age;
}
private void checkPositive() {
if (this.age < 0) {
throw new RuntimeException(new InvalidObjectException("age 값이 옳지 않습니다."));
}
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject(); // 기본 역직렬화 로직 실행
checkPositive();
}
@Override
public String toString() {
return "Member{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
다시 역직렬화를 수행하면 그대로 checkPositive() 메서드의 유효성 검사 로직에 걸려 예외를 발생되는 것을 확인 할 수 있다.
정리하자면 역직렬화할때 중간에 어떠한 공격이 있을지 모르기 때문에 가져온 바이트 스트림이 진짜 직렬화된 데이터라고 믿으면 안되며, 어떤 바이트 스트림이 넘어오더라도 유효성 검사 등의 로직을 통해 유효한 인스턴스를 만들어 내야 된다는 점이다. 만일 어긋나다면 과감하게 InvalidObjectException 을 던진다.
하지만 이러한 형태는 나중에 유지보수 측면에서 감점으로 작용한다. 왜냐하면 같은 유효성 검사 로직을 중복해서 작성해야 했기 때문에 하드 코딩화 되기 때문이다. 그래서 나중에 개발자가 실수를 할 수 있다는 문제점이 나타난다.
물론 지금은 아주 간단한 로직이라 문제 생길 일은 없겠지만 여러개의 복잡한 로직일 경우 모르게 된다.
따라서 역직렬화 방어 기법에 가장 추천되는 것은 직렬화 프록시 패턴을 이용하는 것이다. 디자인 패턴의 프록시 패턴의 변형 패턴이며 한번 제대로 설정만 해주면 역직렬화를 안전하게 만드는 데 필요한 노력을 줄여준다.
직렬화 프록시 패턴
직렬화 프록시 패턴(Serialization Proxy Pattern)은 직렬화를 원본이 아닌 프록시 객체가 대신 직렬화되고, 역직렬화할때 프록시에서 원본 객체를 반환하여 인스턴스화 하는 기법이다.
직렬화 프록시를 구성하는 과정은 다음과 같다.
- 프록시 클래스 MemberProxy를 대상 Member 클래스의 정적 내부 클래스(static inner class)로 선언한다.
- Member 클래스에
writeReplace()메서드를 정의하여, Member를 직렬화시 프록시 객체를 반환하여 MemberProxy가 직렬화 되도록 제어한다. - 그리고 Member 클래스에
readObject()메서드를 정의하여 직접 직렬화를 하지 못하도록 에러를 던지도록 한다. 프록시가 대신 직렬화되니 원본이 직렬화 될 일이 없기 때문이다. - 마지막으로 MemberProxy 클래스 내에
readResolve()메서드를 정의하여, 프록시를 역직렬화할시 프록시 객체가 아닌 원본 Member 객체를 반환하도록 제어한다.
writeReplace()는 직렬화에 간섭하는 메서드라 보면되고,readResolve()는 역직렬화에 간섭하는 메서드라 보면 된다.
class Member implements Serializable {
private static final long serialVersionUID = 1L;
private final String name;
private final int age;
public Member(String name, int age) {
this.name = name;
checkPositive();
this.age = age;
}
private void checkPositive() {
if (this.age < 0) {
throw new RuntimeException(new InvalidObjectException("age 값이 옳지 않습니다."));
}
}
// 직렬화 프록시 (정적 내부 클래스)
private static class MemberProxy implements Serializable {
private static final long serialVersionUID = 2L;
private final String name;
private final int age;
// 생성자는 단 하나여야 하고, 바깥 클래스의 인스턴스를 매개변수로 받고 데이터를 복사
public MemberProxy(Member m) {
this.name = m.name;
this.age = m.age;
}
// 객체를 역직렬화 할때 호출되어, 역직렬화 결과를 readResolve 반환값으로 설정
private Object readResolve() {
return new Member(name, age); // 역직렬화되면 최종적으로 Member 객체를 반환
}
}
// 객체를 직렬화 할때 호출되어, 직렬화 대상을 writeReplace를 통해 프록시를 반환하도록 제어
private Object writeReplace() {
return new MemberProxy(this); // 프록시가 대신 직렬화
}
// 대상 객체(Member)을 역직렬화 하지 못하게 막는다.
// 애초에 프록시 객체로 직렬화하고 역직렬화하기 때문에 대상 객체가 역직렬화 될일이 없기 때문이다
private void readObject(ObjectInputStream ois) throws InvalidObjectException {
throw new InvalidObjectException("프록시가 필요해요.");
}
@Override
public String toString() {
return "Member{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
직렬화 프록시 과정
1. Member 인스턴스를 만들고 직렬화를 수행한다.
Member student1 = new Member("홍길동", 22);
// 직렬화
ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream("Student.ser")));
out.writeObject(student1);
out.close();
2. writeObject 가 호출되면 writeReplace 가 호출되는데, 이때 MemberProxy를 반환하여 프록시 객체가 직렬화 되게 된다. 그리고 프록시는 Member의 멤버값을 그대로 계승하여 가지고 있게 된다.
3. 역직렬화를 수행한다. 이때 직렬화 파일에는 프록시 객체가 바이트 스트림으로 들어 있다.
// 역직렬화
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream("Student.ser")));
Member member = (Member) in.readObject();
in.close();
System.out.println(member);
4. readObject 가 호출되면 프록시 객체가 생성되게 되면서, MemberProxy 내의 readResolve 가 호출되어 Member 객체를 반환하도록 한다. 이때 Member 생성자에서 역직렬화한 데이터에 대한 유효성 검증을 하게 된다.
5. 최종적으로 프록시를 통해 검증이 완료된 Member 인스턴스를 얻게 된다.
6. 전체 과정을 나열해보면 아래와 같이 표현할 수 있다.
직렬화 프록시 패턴 장단점
직렬화 프록시 장점
- 가짜 바이트 스트림 공격과 내부 필드 탈취 공격을 프록시 수준에서 차단해준다
- 필드들을 final로 선언해도 되어서 대상 클래스를 진정한 불변으로 만들어 준다.
- 역직렬화때 readObject 재정의로 일일히 유효성 검사를 하지 않아도 된다.
- 역직렬화한 인스턴스와 원래의 직렬화된 인스턴스의 클래스가 달라도 정상 작동한다.
- 직렬화 프록시는 readObject 의 방어적 복사보다 강력하다.
직렬화 프록시 한계
- 직렬화 & 역직렬화 과정이 상대적으로 느리다
- 클라이언트가 마음대로 확장할 수 있는 클래스에는 적용할 수 없다.
- 클래스의 필드 객체가 서로 참조하는 상황, 객체 그래프에 순환이 있는 클래스에도 적용할 수 없다.
역직렬화 필터링
만일 Serializable을 구현한 클래스만 지원하는 프레임워크를 사용해야 하거나 레거시 시스템 때문에 어쩔수 없이 클래스에 직렬화를 설정해야 할 경우, 그런데 역직렬화한 데이터가 안전한지 확신할 수 없을 경우, 객체 역직렬화 필터링 (Deserialization Filters) 을 통해 역직렬화할 인스턴스를 필터링하여 방어할 수 있다.
역직렬화 필터링은 데이터 스트림이 역직렬화되기 전에 필터 조건문을 수행하여 특정 클래스만 역직렬을 허용하거나 아예 제외하여 역직렬을 못하도록 솎아 낼 수 있다.
역직렬화 필터링 클래스인 java.io.ObjectInputFilter 는 JDK 9 버전에서 사용할 수 있다.
ObjectInputFilter는 함수형 인터페이스이다.
람다식을 통해 역직렬화하는 객체의 클래스명을 비교해서 ObjectInputFilter.Status.ALLOWED 와 ObjectInputFilter.Status.REJECTED 를 각각 반환하도록 설정하는 일급 함수를 만들고, setObjectInputFilter() 를 통해 필터를 ObjectInputFilter 객체에 등록한다. 그리고 역직렬화를 수행하면 블랙리스트에 걸린 클래스는 예외가 발생되게 된다.
// 역직렬화가 허용되는 클래스
class SuccessDeserializer implements Serializable {
}
// 역직렬화가 비 허용 되는 클래스
class NegativeDeserializer implements Serializable {
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
SuccessDeserializer successObj = new SuccessDeserializer();
NegativeDeserializer nagativeObj = new NegativeDeserializer();
String filename = "filter.ser";
// 직렬화 -------------------
ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(filename)));
out.writeObject(successObj);
out.writeObject(nagativeObj);
out.close();
// 역직렬화 -------------------
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(filename)));
// 1. 역직렬화 필터 만들기
ObjectInputFilter filter = (filterInfo) -> {
Class<?> classObj = filterInfo.serialClass();
// 화이트 리스트
if (classObj.getName().equals("SuccessDeserializer")) {
return ObjectInputFilter.Status.ALLOWED; // SuccessDeserializer 클래스일 경우 허용
}
System.out.println("Rejected :" + classObj.getSimpleName());
return ObjectInputFilter.Status.REJECTED; // 그 이외에는 거절
};
// 2. 역직렬화 필터 등록
in.setObjectInputFilter(filter);
// 3. 필터 적용된 채로 역직렬화
Object obj1 = in.readObject();
Object obj2 = in.readObject();
System.out.println("Class Name: " + obj1.getClass().getSimpleName());
System.out.println("Class Name: " + obj2.getClass().getSimpleName());
in.close();
}
역직렬화 필터링 분기 로직을 구성할때, 블랙리스트 방식보다는 화이트리스트 방식을 추천한다. 블랙리스트 방식은 이미 알려진 위험으로부터만 보호할 수 있기 때문이다.
또한 애플리케이션을 위한 화이트리스트를 자동으로 생성해주는 스왓(SWAT) 이라는 도구를 이용할 수도 있다.
객체 상속 직렬화 방어 기법
Serializable를 구현한 클래스는 자신을 상속한 자손 객체들 까지 직렬화가 가능하게 되므로, 직렬화의 문제를 고스란히 전파되기 때문에 상속용으로 설계된 클래스는 Serializable을 구현하면 안되며, 인터페이스도 Serializable을 확장하면 안된다.
그러나 환경적인 요인으로 어쩔수 없이 클래스의 인스턴스 필드가 직렬화와 확장이 모두 가능하게 구현해야 한다면 몇가지 주의점이 있다.
첫번째는, 인스턴스 필드의 값 중에 불변식을 보장해야할 게 있다면 반드시 하위 클래스에서 Object 최상위 클래스의 finalize() 메서드를 재정의하지 못하게 해야한다. 방법은 finalize를 오버라이딩 하면서 final 키워드를 메서드에 붙이면 된다. 이렇게 하지 않으면 finalizer 공격에 취약해질 수 있다.
두번째는, 인스턴스 필드중 기본값 int는 0, Object는 null 등 으로 설정되면 위배되는 불변식이 있다면 readObjectNoData() 메서드를 반드시 추가해야한다. readObjectNoData() 는 Java 4 버전 부터 지원하는 메서드로 기존 직렬화 가능 클래스에 직렬화 가능 상위 클래스를 상속하는 드문 경우를 위한 메서드이다. 이에 대해서 자세히 알아보자.
readObjectNoData 사용법
readObjectNoData 를 사용하는 상황은, 기존에 직렬화 가능한 A, B 두 클래스가 있는데, 갑자기 B가 A를 상속하는 상황이 발생할때 일어나는 데이터 불일치 문제를 해결하기 위해서이다.
예를 들어 Serializable를 구현하는 Person 클래스와 Student 클래스가 있다고 하자. 그중 Student 인스턴스만 직렬화하여 외부 파일로 저장했다고 한다.
class Person implements Serializable {
private static final long serialVersionUID = 1L;
String name;
long age;
public Person() {
}
public Person(String name, long age) {
this.name = name;
this.age = age;
}
}
class Student implements Serializable {
private static final long serialVersionUID = 2L;
String school;
String circles;
public Student(String school, String circles) {
this.school = school;
this.circles = circles;
}
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
Student student1 = new Student("세종대학교", "게임동아리");
// 직렬화
ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream("Student.ser")));
out.writeObject(student1);
out.close();
// 역직렬화
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream("Student.ser")));
Student student2 = (Student) in.readObject();
in.close();
System.out.println(student2.school);
System.out.println(student2.circles);
}
그러면 Student.ser 파일에 담겨있는 직렬화한 정보는 Student의 company 필드와 team 필드만 가지고 있게 된다.
그런데 갑자기 명세서가 바뀌어서 Student 클래스가 Person 클래스를 상속한다고 한다. 그러면 Student 클래스가 가용하는 필드들은 name, age, school, circles 총 4개가 되게 된다.
Person은 Serializable을 구현하고 있기 때문에 Student에서도 인터페이스를 구현할 필요가 없으니 코드에서 제거해준다.
class Student extends Person {
private static final long serialVersionUID = 2L;
String school;
String circles;
public Student(String school, String circles) {
this.school = school;
this.circles = circles;
}
}
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 역직렬화
ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream("Student.ser")));
Student student2 = (Student) in.readObject();
in.close();
System.out.println(student2.name);
System.out.println(student2.age);
System.out.println(student2.school);
System.out.println(student2.circles);
}
그러나 상속 관계를 설정한뒤 기존에 직렬화 데이터를 역직렬화 하게 되면, Person에서 가지고 있는 name과 age를 알 수 없으므로 각각 기본값을 할당받게 된다.
세상에 학생의 이름이 null 일수도 없고 나이가 0일수도 없다.
즉, 이렇게 하위 클래스로부터 불변 객체를 위반하는 경우가 생길 경우, readObjectNoData() 메서드를 상위 클래스인 Person 클래스 내에 정의함으로써 ObjectInputStream에서 직렬화 데이터에 없는 필드에 대해서 값을 정의할 때 호출되도록 설정한다.
상위 Person의 값들이 기본값이 아닌 다른 값으로 설정하는 것이기 때문에 readObjectNoData() 를 Person에 정의해야한다.
class Person implements Serializable {
private static final long serialVersionUID = 1L;
String name;
int age;
public Person() {
}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
private void readObjectNoData() {
this.name = "홍길동";
this.age = 25;
}
}
readObjectNoData() 를 정의하고 다시 역직렬화를 해보면 기존에 name 과 age에 메서드에 정의한 값이 그대로 들어온 걸 확인 할 수 있다.
싱글톤 역직렬화 방어
어플리케이션 내에서 유일하게 존재하는 싱글톤(Singleton) 객체를 다른 컴퓨터에 전송하는 과정에서 직렬화 & 역직렬화를 하게 되면 유일성이 깨져 더이상 싱글톤 객체가 아니게 되는 문제점이 있다.
이러한 싱글톤의 역직렬화의 대응 방안으로 직렬화 프록시에서 다루어 보았던 readResolve() 메서드를 정의하여 기존 싱글톤 객체를 반환하도록 제어해주면 된다. 그리고 싱글톤 클래스의 필드들을 모두 transient 제어자 설정을 하여 직렬화에 제외시키도록 하면 역직렬화에 완벽히 방어가 가능해진다.
자세한 과정을 아래 포스팅을 참고하길 바란다.
이렇게 역질렬화를 방어하는 여러가지 기법에 대해서 알아보았다.
하지만 착각하지 말아야 할것은 역직렬화 필터링이나 프록시를 사용해도 완벽하게 역직렬화 공격을 방어할 수 없다는 점이다. 그래서 관련된 모범 사례를 따라서 직렬화 가능 클래스들을 공격에 대비하도록 작성한다 하더라도 여전히 취약하기 때문에 가장 확실하고 안전한 방법은 어플리케이션에서 역직렬화를 아예 안하는 것이다.
# 참고자료
이펙티브 자바 Effective Java 3/E
https://youtu.be/3iypR-1Glm0
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.