자바 컴파일러 들여다보기

Posted 2008. 11. 30. 02:12

마이크로소프트웨어 2006년 7월 기고글입니다.


자바 개발자라면 누구나 자바 컴파일러(javac)를 사용한다. 하지만 상당수 개발자는 컴파일러가 어떻게 동작하는지 관심이 없다. 이들은 작성한 소스 코드가 오류 없이 컴파일되고 컴파일의 결과로 나온 클래스 파일이 원하는 기능을 수행하면 그만이라고 말한다. 하지만 컴파일러 작성자가 아니더라도 컴파일러가 소스 코드를 어떤 형태의 바이너리(자바의 경우 바이트코드)로 변환시키는지 알아두면 유용한 경우가 많다. 이 글에서는 몇 가지 예제를 중심으로 자바 컴파일러의 내부 동작을 엿보는 기회를 가지려고 한다.


T 업계의 종사하는 사람이라면 18개월마다 컴퓨팅 파워가 2배가 된다는 무어(Moore)의 법칙을 잘 알고 있을 것이다. 컴파일러에도 이와 비슷한 법칙이 있다.


Proebsting's Law

컴파일러 기술은 18년마다 컴퓨팅 파워(computing power)를 2배로 증가시킨다.


이 법칙이 정확한 예측치는 아니더라도 비약적으로 발전하는 하드웨어에 비해 컴파일러의 발전 속도가 상당히 더디다는 것만은 유추할 수 있다. 하지만 그렇다고 마냥 정체되어 있기만 한 것은 아니었다. 50-60년대에 최초의 컴파일러가 나오고 벌써 수십 년이 지났으므로, 지금 우리가 사용하는 컴파일러는 초창기에 비해 상당히 발전한 것이다.

따라서 개발자도 컴파일러를 블랙박스로만 생각할 것이 아니라, 어떤 일을 해주는지 조금은 알아둘 필요가 있다. 컴파일러가 변환하는 소스 코드와 바이너리의 관계를 코드 모양(code shape)이라고 하는데, 최적화 컴파일러의 복잡한 변환(transformation) 과정은 아니더라도, 이런 코드 모양을 몇 가지 숙지하고 있으면 좋다. 이 글에서는 자바의 표준 컴파일러인 javac을 통해 자바 언어와 바이트코드에 대한 이해의 폭을 넓혀 보려고 한다.


데드 코드(dead code)와 조건부 컴파일


C/C++ 개발자들이 자바 개발을 시작하면 가장 먼저 느끼는 불편함 중에 하나가 전처리기(preprocessor)의 부재일 것이다. C/C++에서는 #ifdef #endif를 이용해서 특정 코드를 선택적으로 컴파일할 수 있는데, 자바는 그런 전처리기가 없기 때문이다. 하지만 자바 컴파일러를 잘 이용하면 자바에서도 조건부 컴파일이 가능하다. 우선 자바 컴파일러의 배경 지식부터 살펴보자.

컴파일러는 바이너리를 생성하기에 앞서서 실행 흐름 분석(control flow analysis)을 통해 소스 프로그램을 분석하는데, 이 분석을 통해 하는 일 중에 하나가 데드 코드 제거(dead code elimination)이다. 자바 컴파일러도 프로그램 흐름상 절대로 수행될 수 없는 코드를 발견하면 이 부분은 바이트코드에 포함시키지 않는다. 다음 예제를 보자.


public class Foo {

     public static void main(String args[]) {

         if (false) {

             System.out.println("Never");

         }

     }

}

데드 코드 예제


자바 디컴파일러 javap

자바 개발 환경을 설치하면 javac, java와 함께 몇 가지 프로그램이 함께 설치되는데, 그 중 하나가 자바 디컴파일러인 javap이다. javap는 클래스 파일을 읽어서 클래스와 메쏘드, 그리고 각 메쏘드의 바이트코드를 보여주는 프로그램이다. 특히 자바 네이티브 인터페이스(Java Native Interface, JNI) 작성 시에 메쏘드 시그너처(signature)를 뽑아내는데 유용한 도구이다. -c 옵션을 주면 각 메쏘드의 바이트코드도 볼 수 있는데, 이 글의 디컴파일 결과는 모두 javap -c를 사용한 것이다.


위 코드에서 if (false) System.out.println("Never")은 수행될 수 없는 코드이다. 따라서 바이트코드에 포함되지 않는데, 자바 디스어셈블러인 javap를 이용해 Foo.class를 살펴보면 main 메쏘드에 return 문만 있는 것을 볼 수 있다.


public static void main(java.lang.String[]);

Code:

0: return

데드 코드 Foo의 디컴파일


물론 if (false)의 경우는 너무 당연해서 별로 쓸모가 있어 보이지 않는다. 하지만 자바 컴파일러가 데드 코드를 바이트코드에 포함시키지 않는다는 사실을 이용하면 C/C++ 전처리기리처럼 조건부 컴파일을 흉내 낼 수 있다. 다음 예제를 살펴보자.


class Configure {

     public static final boolean debug = false;

}

 

public class Foo {

     public static void main(String args[]) {

         if (Configure.debug) {

             System.out.println("Debug.");

         }

     }

}

조건부 컴파일 예제


[debug가 static final인 경우]

public static void main(java.lang.String[]);

Code:

0: getstatic #2; //Field java/lang/System.out:Ljava/io/PrintStream;

3: ldc #3; //String Debug.

5: invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V

8: return

조건부 컴파일 디컴파일


자바에서 static final로 선언된 필드는 상수(constant)이다. 따라서 위 조건부 컴파일 예제의 경우 Configure.debug라는 플래그가 false이면 main 메쏘드의 if (Configure.debug) 부분이 데드 코드가 되므로 바이트코드에 포함되지 않는다. 반대로 debug를 true로 바꿔주면, if (Configure.debug) 부분 코드가 컴파일되는데, debug는 static final로 선언되어 있으므로 항상 참이다. 따라서 javac은 if 문을 없애고 바로 System.out.println을 실행하도록 코드를 생성한다. 즉 flag를 true/false로 바꿔준 후에 새로 컴파일하면 조건부 컴파일이 되는 것이다.


[debug가 final이 아닌 경우]

public static void main(java.lang.String[]);

Code:

0: getstatic #2; //Field Configure.debug:Z

3: ifeq 14

6: getstatic #3; //Field java/lang/System.out:Ljava/io/PrintStream;

9: ldc #4; //String Debug.

11: invokevirtual #5; //Method java/io/PrintStream.println:(Ljava/lang/String;)V

14: return

final이 아닌 경우 디컴파일


만약에 debug가 final이 아니었다면 런타임에 값이 바뀔 수 있으므로, [debug가 false가 아닌 경우]의 3행에서처럼 ifeq로 debug 값을 테스트하는 부분이 들어갔을 것이다.


배열 초기화(array initialization)


C/C++ 프로그램을 개발할 때 바이너리 데이터를 메모리에 읽어오는 방법으로 배열 초기화를 사용하는 경우가 많다. 특히 바이너리 외에 별도의 파일 시스템이 없는 임베디드 시스템의 경우 어플리케이션의 그림(image)나 소리(sound) 데이터를 char[]에 저장한다. 로더(loader)가 프로그램을 로드할 때 배열의 값을 메모리의 데이터 영역으로 바로 복사해주기 때문에 효율적이다.

다음은 썬(Sun)에서 만든 J2ME의 Personal Basic Profile 소스 코드에서 발췌한 코드이다.


class IxcClassLoader extends ClassLoader {

/* fields */

private static byte[] utilsClassBody = { // The .class file

(byte) 0xca, (byte) 0xfe, (byte) 0xba, (byte) 0xbe,

(byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x2e,

(byte) 0x00, (byte) 0x3d, (byte) 0x0a, (byte) 0x00,

(byte) 0x11, (byte) 0x00, (byte) 0x1f, (byte) 0x07,

(byte) 0x00, (byte) 0x20, (byte) 0x07, (byte) 0x00,

(byte) 0x21, (byte) 0x0a, (byte) 0x00, (byte) 0x03,

(byte) 0x00, (byte) 0x22, (byte) 0x0a, (byte) 0x00,

(byte) 0x02, (byte) 0x00, (byte) 0x23, (byte) 0x0a,

(byte) 0x00, (byte) 0x02, (byte) 0x00, (byte) 0x24,

(byte) 0x07, (byte) 0x00, (byte) 0x25, (byte) 0x07,

(byte) 0x00, (byte) 0x26, (byte) 0x07, (byte) 0x00,

(byte) 0x27, (byte) 0x0a, (byte) 0x00, (byte) 0x08,

...

PBP의 IxcClassLoader 클래스


이 코드를 보면 utilsClassBody라는 byte[] 필드에 클래스 생성에 필요한 유틸리티 클래스의 바이트코드를 바이너리 형태로 넣어 놨음을 알 수 있다. static 필드이므로 클래스 정적 초기화(static initialization)시에 해당 필드가 초기화될 것이다. C/C++ 코드를 많이 작성해온 개발자라면 이런 형식의 배열 사용법에 익숙할 것이다. 자바에서 이런 배열 초기화의 문제점은 무엇일까? 대답에 앞서서 이 클래스를 한 번 디컴파일해보자.


static {};

Code:

0: sipush 1165

3: newarray byte

5: dup

6: iconst_0

7: bipush -54

9: bastore

10: dup

11: iconst_1

12: bipush -2

14: bastore

15: dup

16: iconst_2

17: bipush -70

19: bastore

20: dup

21: iconst_3

22: bipush -66

24: bastore

25: dup

26: iconst_4

27: iconst_0

28: bastore

29: dup

...

PBP의 IxcClassLoader 클래스 디컴파일


무엇이 문제인지 감이 잡히는가? 지면 관계상 일부만 표시했지만, byte[] utilsClassBody는 길이가 1165이다. C/C++이였으면 이 데이터를 곧바로 메모리에 로드하였겠지만, 자바의 클래스 로더는 그렇지 않다. 자바의 경우 클래스를 초기화할 때 1165 길이의 byte[]를 힙에 생성하고 각 원소를 하나하나 초기화해주어야 한다. 이 초기화를 위해서 무려 7738개의 바이트코드가 필요하다. 코드 길이도 문제지만, 클래스 로딩 시간 또한 무척 길어진다.

자바의 원산지인 썬에서 배포하는 코드에서 이런 문제점이 있다는 점을 생각해보면 언뜻 비슷해 보이는 두 언어의 차이점을 정확히 알고 쓰는 일이 쉽지 않음을 알 수 있다.


문자열 처리


자바는 문자열의 편리한 처리를 위해서 문자열 병합 연산자(+)를 제공한다. 따라서 우리는 "Hello" + "World"와 같이 문자열을 병합할 수 있고, "I am " + name + "."과 같이 중간에 변수를 삽입해서 문자열을 생성할 수도 있다.

하지만 자바의 String 클래스는 변경 불가능(immutable)한 클래스이다. 따라서 한 번 문자열을 생성하면 문자열의 값을 바꾸는 것은 불가능하다. 따라서 replace, replaceAll, toLowerCase 등의 메쏘드는 모두 기존의 문자열은 그대로 두고 새로운 문자열을 리턴한다. java.lang.String의 API를 유심히 읽어본 개발자라면 정답을 알고 있을 것이다.


◆ java.lang.String API에서 발췌

문자열 병합은 StringBuilder(혹은 StringBuffer) 클래스의 append 메쏘드를 통해 이루어진다. 추가적인 정보를 위해서는 자바 언어 명세서(Java Language Specification)을 참조하기 바란다.


즉 우리가 String a = "Hello"; a += " World"; 라고 프로그램을 작성하더라도 실제 병합은 StringBuilder 클래스를 통해 이루어짐을 의미한다. 역시 실제 프로그램을 컴파일해서 확인해보도록 하자.


public class Foo {

     public static void main(String args[]) {

         String h = "Hello";

         h += "World";

     }

}

 

public static void main(java.lang.String[]);

Code:

0: ldc #2; //String Hello

2: astore_1

3: new #3; //class java/lang/StringBuilder

6: dup

7: invokespecial #4; //Method java/lang/StringBuilder."<init>":()V

10: aload_1

11: invokevirtual #5; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;

14: ldc #6; //String World

16: invokevirtual #5; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;

19: invokevirtual #7; //Method java/lang/StringBuilder.toString:()Ljava/lang/String;

22: astore_1

23: return

String 병합


바이트코드를 잘 모르더라도 String 대신에 StringBuilder 클래스를 통해 문자열을 병합하고 이후에 toString 메쏘드를 이용해 문자열을 돌려줌을 쉽게 유추해 볼 수 있다. 이처럼 javac은 자바 개발자가 알게 모르게 여러 가지 일을 해주고 있는데, 바꿔 말하면 자바 개발자는 의도하지 않게 비효율적인 명령을 실행할 수도 있다는 뜻이다. 실제로 초창기 자바는 멀티 쓰레드 세이프한 StringBuffer의 append 메쏘드를 이용했는데, append는 매번 락을 잡았다 풀었다 하였기 때문에 심각한 속도 저하의 원인이 되기도 했었다.


Inner Class


이너 클래스가 있는 클래스를 컴파일하면, $ 표시가 붙은 클래스 파일이 생성되는 것을 보았을 것이다. 그리고 JVM 명세서를 잘 읽어보면 이너 클래스라는 게 존재하지 않는다는 사실도 알 수 있을 것이다. 그렇다면 다음과 같이 이너 클래스가 있는 클래스를 컴파일하면 어떤 일이 생기는 걸까?


public class Foo {

     static class Bar {

         private boolean flag = true;

     }

     public static void main(String args[]) {

         Bar bar = new Bar();

         System.out.println(bar.flag);

     }

}

Inner Class


위 예제의 경우 Foo 내부에 Bar라는 정적 이너 클래스를 선언하였다. Bar는 Foo의 이너 클래스이므로 private 멤버라도 직접 접근이 가능하다. 이 클래스를 컴파일하면 Foo.class와 Foo$Bar.class라는 두 개의 클래스 파일이 나타난다. JVM에는 이너 클래스의 개념이 없으므로, 자바 소스 코드 상에서는 Bar는 Foo의 이너 클래스이지만 바이트코드 상에서는 별도의 클래스가 되는 것이다.


public class Foo extends java.lang.Object{

     public Foo();

     public static void main(java.lang.String[]);

}

 

class Foo$Bar extends java.lang.Object{

     Foo$Bar();

     static boolean access$000(Foo$Bar);

}

Inner Class 디컴파일


또 하나 특이한 점으로는 access$000라는 메쏘드가 있다. 원래 Bar 클래스에는 없는 메쏘드인데 어째서 바이트코드에는 나타난 것일까? 그 이유는 자바 소스 코드에서는 Bar가 이너 클래스이므로 Foo의 main 메쏘드에서 Bar의 private field가 접근 가능해야 하는데, 실제 바이트코드 상에서는 별도의 클래스이므로, private 멤버의 값을 읽을 수 없게 된다. 이 문제를 해결하기 위해 자바 컴파일러는 access$000이라는 메쏘드를 생성해 Foo가 Bar의 필드 참조 시 access$000으로 대체하게 된다.



Enumeration


Java 5에는 타입 세이프 enum 강화된 for 루프, 제네릭스(generics), 오토박싱(autoboxing), 언박싱(unboxing), 가변인자(varargs), 정적 임포트(static import), 메타데이터(metadata) 등 여러 가지 언어 기능이 추가 되었다. 하지만 언어만 바뀌었을 뿐 기존의 JVM이 사용하던 바이트코드에는 차이가 없다. 이 말은 결국 이런 기능들은 전부 컴파일러가 해준다는 뜻이다. 타입 세이프 enum을 통해서 컴파일러가 어떤 바이트코드를 생성해 내는지 살펴보자.


public enum Week {

     MON, TUE, WED, THU, FRI, SAT, SUN

}

Week Enum


위 Week enum은 월요일부터 일요일까지 각각의 요일을 나타낸다. enum이 없던 시절에는 어떻게 구현했을까? 한 가지 방법은 int 상수를 이용하는 것이다.


public class Week {

     public static final int MON = 0;

     public static final int TUE = 1;

     public static final int WED = 2;

     public static final int THU = 3;

     public static final int FRI = 4;

     public static final int SAT = 5;

     public static final int SUN = 6;

}

int 상수를 이용한 Week Class


이 방법도 동작에는 전혀 문제가 없지만, MON, TUE, WED 같은 값들이 int이기 때문에 MON * 2 같이 의미 없는 연산을 하더라도 컴파일 타임에 타입 에러를 발견할 수 없었다. 이런 문제를 해결하기 위해 타입 세이프 enum 패턴이 널리 쓰였다.


public class Week {

     public static final Week MON = new Week();

     public static final Week TUE = new Week();

     public static final Week WED = new Week();

     public static final Week THU = new Week();

     public static final Week FRI = new Week();

     public static final Week SAT = new Week();

     public static final Week SUN = new Week();

}

타입 세이프 enum 패턴


이 방식은 타입 세이프하지만 각 필드가 오브젝트이므로 int를 사용한 방식과는 달리 switch 문에 사용이 불가능하다는 불편함이 있었다. 반면에 Java5에 도입된 타입 세이프 enum은 타입 세이프하면서 switch 문에서도 사용이 가능하다. 어떻게 구현한 것일까? Week enum을 디컴파일해보자.


public final class Week extends java.lang.Enum{

public static final Week MON;

 

public static final Week TUE;

 

public static final Week WED;

 

public static final Week THU;

 

public static final Week FRI;

 

public static final Week SAT;

 

public static final Week SUN;

 

...

 

static {};

Code:

0: new #4; //class Week

3: dup

4: ldc #7; //String MON

6: iconst_0

7: invokespecial #8; //Method "<init>":(Ljava/lang/String;I)V

10: putstatic #9; //Field MON:LWeek;

...

타입 세이프 enum 패턴


우리가 간단히 enum Week { MON, TUE, ... }로 써줬지만 실제로 컴파일하면 타입 세이프 enum 패턴처럼 각각이 public final static Week 필드로 들어가 있음을 알 수 있다. 즉 우리가 타입 세이프 enum 패턴에서 수동으로 구현해주던 것을 컴파일러가 대신해 주는 것이다. 클래스 초기화인 static{} 에서는 Week의 객체를 생성해서 각 필드를 초기화해주고 있다. 객체를 생성할 때 MON은 0, TUE는 1, WED는 2 하는 식으로 값을 주고, switch 문에서는 이 값을 얻어오는 ordinal() 메쏘드를 호출하여 switch 문에서도 사용할 수 있게 해준다.



정리


지금까지 자바 컴파일러(javac)가 해주는 몇 가지 일을 살펴보았다. 특히 자바 소스 코드의 특정 구문이 어떤 바이트코드로 변환되는지를 위주로 보았다. 우리는 javap를 이용해 결과를 살펴보았지만, JVM 지식이 있고 자바 언어 명세서를 꼼꼼히 읽은 개발자라면 이미 알고 있었을 내용일지도 모르겠다.

자바 개발자를 만나서 이야기해보면 깜짝 놀라는 일 중에 하나는, 자바의 가장 근간이 되는 자바 언어 명세서(Java Language Specification)와 자바 가상 머신(Java Virtual Machine) 명세서를 숙독한 개발자가 거의 없다는 점이다. 자바 튜토리얼(tutorial)이나 여러 자바 입문서로 시작하여 단 한 번도 명세서를 보지 않고 지금까지 프로그램을 작성해 온 개발자가 부지기수다.

모든 자바 컴파일러, 자바 가상 머신은 모두 이 명세서를 기준으로 만들어졌다. 썬에서 배포하는 레퍼런스 구현(reference implementation)도 결국 이 명세서를 바탕으로 만든 구현 중 하나에 지나지 않는다. 즉 자바 언어 명세서는 자바 세상의 성경인 셈이다. 명세서를 바탕으로 자바의 문법과 의미를 분명히 명확히 이해하고, 자바 코드가 어떤 바이트코드로 변환되는지를 숙지하는 것은 고급 개발자가 되기 위해 반드시 필요한 일이다.

이 과정에서 디컴파일러를 이용해 의문을 해소하는 일은 무척 유용하다. 앞서 언급하지는 않았지만 try, finally 구문은 바이트코드로 어떻게 표현되는지, synchronized 블록은 바이트코드로 어떻게 변환되는지 등을 살펴보면 자바 언어와 JVM에 대한 이해를 넓힐 수 있다. 자바의 경우 컴파일러에 대한 지식은 결국 언어 지식을 넓히는 길인 셈이다.


참고문헌


[1] Java Language Specification, Third Edition Gosling, Joy, Steele, Bracha Addison Wesley 2005

[2] Java Virtual Machine Specification, Second Eidtion Lindholm, Yellin, Addison-Wesley 1999

 



















코드 난독화(Code Obfuscation)

Posted 2008. 11. 30. 01:04

마이크로소프트웨어 2007년 12월에 기고한 글입니다.

요즘 보안 취약점 분석자들은 각종 보안 문제 분석에 역공학(reverse engineering) 기술을 적극 활용하고 있다. 역공학은 소스 코드 없이 윈도우즈 실행 파일(PE, Portable Executable)이나, 자바 바이트코드 등을 직접 분석해서 프로그램이 어떤 기능을 수행하는지 파악하여 취약점을 찾아내는 기술이다, 필요하면 직접 프로그램 바이너리를 수정해 불법적인 일을 수행하게 만들기도 한다. 이에 대한 대응으로 코드를 복잡하게 만들어 알아보기 힘들게 하는 코드 난독화(code obfuscation) 기술이 발전하였다. 이 글에서는 코드 난독화의 기본 원리를 알아보자.


역공학(reverse engineering)이란?


역공학의 개념


대부분의 개발자들은 컴파일된 바이너리를 완전한 블랙박스로 취급한다. 윈도의 PE 포맷이나 리눅스의 ELF 포맷을 이해하더라도 코드섹션(code section)에 들어있는 기계어(어셈블리어)를 이해하기란 무척 어렵다. 하지만 보안 취약점 분석가들의 주요 업무는 이런 바이너리 코드를 읽고 보안 취약점을 찾아내는 것이다. 이는 보안 전문가들이라 할지라도 쉽지 않은 일인데, 보통 컴파일된 바이너리를 다시 원래의 소스코드로 복구하는 프로그램인 디컴파일러(decompiler)의 도움을 받는다. 디컴파일러는 원래 소스 코드를 완벽히 복구하지는 못하지만, 어셈블리에 비해서는 훨씬 이해하기 쉬운 프로그래밍 언어(주로 C)의 소스 코드를 생성해준다. 또한 코드 섹션에 직접 브레이크를 걸고 실행시켜볼 수 있는 디버거(debugger)의 도움을 받는 경우도 많다.

 

이처럼 바이너리 코드를 분석해 유용한 정보를 뽑아내는 작업을 역공학(reverse engineering)이라 부른다. 최근 보안 취약점 분석의 상당 부분은 역공학과 관련되어 있다. 실제로 역공학은 윈도우처럼 소스 코드가 공개되지 않은 운영체제나, 어플리케이션의 보안 버그를 찾아내는데 적극적으로 활용되고 있다.

 

역공학의 위험성을 보여주는 예로, 각종 보안 프로그램들이 뚫린 사례를 들 수 있다. 예를 들어 도서관 같은 공용 컴퓨터에 불법적인 프로그램을 설치하는 것을 막기 위해 리부팅하면 하드가 리셋(reset)되는 시스템이 있다. 이 시스템은 추가적은 프로그램 설치를 위해 관리자 모드를 제공하는데, 4자리의 패스워드를 입력하도록 되어있다. 역공학을 이용해 패스워드를 검사하는 루틴을 찾아내면, 이 부분을 건너뛰게 만들 수도 있다.

 

수십 가지의 안전장치를 마련한 인터넷 뱅킹도 예외가 아니다. 안전한 공인 인증서를 이용한다고 해도, 입력받은 비밀번호와 공인인증서 비밀번호를 비교하는 코드를 찾아내서 해당 루틴을 건너뛰게 만든다면 공인인증서의 비밀번호(passphrase)는 의미가 없어진다. 아무리 많은 안전장치를 걸어놔도 윈도 머신에서 실행되는 바이너리라면, 그 내용을 분석해 해당 코드를 제거해 버리면 되기 때문이다.

 

현재 MS 윈도의 보안 모델에서는 이런 역공학에 의한 공격에 효과적인 대응책이 없다. 그나마 우리가 할 수 있는 차선책은 바이너리를 분석하기 어렵도록 복잡하게 만들어 주는 것이다. 코드 난독화의 필요성은 여기서 출발한다.


코드 난독화(code obfuscation)


코드 난독화는 프로그램을 변화하는 방법의 일종으로, 코드를 읽기 어렵게 만들어 역공학을 통한 공격을 막는 기술을 의미한다. 난독화는 난독화의 대상에 따라 크게 1) 소스 코드 난독화와 2) 바이너리 난독화로 나눌 수 있다. 소스 코드 난독화는 C/C++/자바 등의 프로그램의 소스 코드를 알아보기 힘든 형태로 바꾸는 기술이고, 바이너리 난독화는 컴파일 후에 생성된 바이너리를 역공학을 통해 분석하기 힘들게 변조하는 기술이다.

 

일단 소스 코드 난독화의 필요성을 먼저 이야기해보자. 첫 번째 경우는 부득이하게 소스 코드를 릴리즈(release)해야 하는 경우이다. 예를 들어 플래시 파일 시스템을 파는 A라는 회사가 있다고 하자. B 회사는 A 회사의 라이브러리를 구매해서 제품을 만들려고 한다. B 회사는 매우 많은 플랫폼을 가지고 있고, 여러 설정에 따라서 플래시 파일 시스템을 조금씩 다르게 컴파일해야할 필요가 있다고 하자. 이 경우 B 사가 일일이 해당 라이브러리를 빌드해서 릴리즈해주는 방법도 있지만, 난독화 도구를 이용해 코드를 적당히 알아보기 힘들게 만든 후에 A 사에 넘겨서 A사가 직접 빌드하도록 하는 것이 편리할 것이다.

 

또 다른 예로 최근 부각 받는 AJAX의 경우, 자바스크립트(JavaScript)로 작성된 코드가 브라우저에 그대로 노출되는 문제가 있다. 소스 코드를 공개하고 싶지 않은 개발자라면 AJAX의 이런 특성이 큰 부담이 될 것이다. 이 경우 자바스크립트 코드를 쉽게 알아보지 못하도록 난독화 도구를 사용할 수 있다. 다음은 JavaScript Obfuscator가 실제로 자바 스크립트를 난독화한 예제이다. 원래 코드와 난독화된 코드를 비교해보면, 난독화 과정에 대한 감을 잡을 수 있을 것이다.


<원래 코드>

//detect which browser is used

var detect = navigator.userAgent.toLowerCase();

var OS,browser,version,total,thestring;

if (checkIt('konqueror'))

{

browser = "Konqueror";

OS = "Linux";

}

else if (checkIt('opera')) browser = "Opera"

else if (checkIt('msie')) browser = "Internet Explorer"

else if (!checkIt('compatible'))

{

browser = "Netscape Navigator"

version = detect.charAt(8);

}

else browser = "An unknown browser";

//version of browser

if (!version) version = detect.charAt(place + thestring.length);

//client OS

if (!OS)

{

if (checkIt('linux')) OS = "Linux";

else if (checkIt('x11')) OS = "Unix";

else if (checkIt('mac')) OS = "Mac"

else if (checkIt('win')) OS = "Windows"

else OS = "an unknown operating system";

}

 

//check the string

function checkIt(string)

{

place = detect.indexOf(string) + 1;

thestring = string;

return place;

}


<난독화된 코드>

var e=navigator.userAgent.toLowerCase();var f,b,c,total,d;if(a('konqueror')){b="Konqueror";f="Linux";};else if(a('opera'))b="Opera";else if(a('msie'))b="Internet Explorer";else if(!a('compatible')){b="Netscape Navigator";c=e.charAt(8);};else b="An unknown browser";if(!c)c=e.charAt(g+d.length);if(!f){if(a('linux'))f="Linux";else if(a('x11'))f="Unix";else if(a('mac'))f="Mac";else if(a('win'))f="Windows";else f="an unknown operating system";};function a(string){g=e.indexOf(string)+1;d=string;return g;};


C/C++ 개발자에게는 코드 난독화가 생소한 개념일 수 있지만, 자바 개발자들은 수년전부터 코드 난독화 도구를 실용적인 목적으로 이용해 오고 있다. 자바의 바이트코드는 윈도 실행 파일보다 훨씬 많은 심볼(symbol)을 컨스턴트 풀(constant pool) 영역에 가지고 있기 때문이다. 난독화를 거치지 않은 자바 바이트코드를 디컴파일해보면, 원래 소스 코드의 대부분이 복구되는 것을 알 수 있다.


윈도 PE 분석에 가장 많이 사용되는 어셈블리 디버거인 OllyDbg



코드 난독화에 대한 재미난 사실은 이 기술이 바이러스나 웜 제작자들에 의해 처음 연구되었다는 사실이다. 바이러스나 웜은 백신의 스캔에 걸리지 않기 위해 자신의 바이너리를 교묘한 형태로 숨겨왔는데, 이렇게 바이너리를 변형해 정보를 숨기는 기술이 바이너리 코드 난독화의 목표이다. 반면에 백신 개발자들은 이렇게 변형된 웜을 탐지하기 위해, 소스 코드를 확인할 수 없는 바이너리 코드의 안정성과 취약점을 분석하는 기술을 발전시켜왔다, 코드 난독화와 바이너리 분석 기술은 서로 경쟁하는 기술인 셈이다.


기본적인 바이너리 코드 난독화 방법


가장 기본적인 코드 난독화는 바이너리에서 심볼 정보를 제거하거나 변경하는 것이다. C/C++로 컴파일된 바이너리의 경우 디버깅 옵션을 주면 바이너리에 심볼 정보가 포함되는데 난독화 도구는 이런 정보를 지워서, 바이너리에서 사람이 이해할 수 있는 정보를 최대한 제거하는 것이다. 자바의 경우는 심볼 정보가 프로그램 수행에 필요하므로, 심볼 정보를 제거할 수는 없다. 대신에 자바용 난독화 도구는 심볼 이름을 바꾸는 방법을 많이 사용한다. MyString이라는 클래스 이름보다는 M라는 클래스 이름이 알아보기 어렵고, find라는 메쏘드 이름보다는 f라는 이름이 공격자가 이해하기 어렵다는 점에 착안한 것이다.

 

그 외에도 다음과 같은 난독화 방법이 있다.

 

1. 필요 이상으로 복잡한 코드를 만들거나, 아무 것도 하지 않는 코드를 삽입한다.

 

이 난독화 기술은 실행되지 않는 함수를 추가하거나, 아무 것도 하지 않는 함수들을 중간 중간에 삽입하여 바이너리 코드 분석을 힘들게 만드는 것이다. 이런 일은 데드 코드(dead code)를 제거하고, 코드를 짧고 간단하게 만드는 최적화 컴파일러(optimizing compiler)가 하는 일에 역행하지만, 역공학을 어렵게 만드는 데는 효과적이다.

 

물론 이런 기술을 사용해도 공격자가 함수 호출 그래프(call graph)와 흐름 그래프(control flow graph)를 그리고, 세심하게 코드를 분석하면 취약점을 찾아내는 것은 시간문제이다. 하지만 그렇다고 이런 난독화 기술이 의미가 없지는 않다. 난독화의 목표는 역공학을 불가능하게 만드는 게 아니라, 충분히 어렵게 만들어서 공격자가 포기하고 다른 공격 대상을 찾게 만드는 데 있기 때문이다.

 

2. 코드를 여기저기로 복사하고, 옮긴다.

 

관련된 코드를 최대한 멀리 떨어진 곳에 배치하거나, 함수를 인라이닝(inlining)하고, 반복되는 몇 개의 구문(statement)을 합쳐서 익명의 함수를 만드는 등의 일을 할 수 있다. 똑같은 함수를 복사하여, 각기 다른 지점에서 다른 함수 이름을 사용하는 방법도 있다. 연달아 불리는 서로 관련 없는 함수를 하나의 함수로 묶어주는 방법도 있다. 성능을 심각하게 해치지 않는 범위에서 코드를 뒤섞는 모든 방법이 여기에 포함된다.

 

 

3. 데이터를 알아보기 힘들게 인코딩(encoding)한다.

 

취약점 분석가들이 바이너리 분석에서 가장 먼저 하는 일은 바이너리에 포함된 텍스트(text) 문자열을 찾아내는 것이다. 일례로, C 언어로 char passwd[] = "mypass"; 같은 코드가 컴파일되어 바이너리에 포함되어 있다면 strings 명령으로 바이너리를 스캔해 보는 것만으로도 암호를 발견해 낼 수 있다. 믿지 않겠지만, 실제로 이런 코드가 발견되는 경우가 매우 빈번하다.

 

해결책은 텍스트 문자열을 알아보기 힘든 방식으로 인코딩하고 필요할 경우만 디코딩(decoding)해서 사용하는 방법이다. 특히 외부로 노출되었을 경우 곤란한 정보인 경우, 이런 과정을 거치는 것이 좋다. 인코딩/디코딩하는 방법으로 암호 알고리즘을 사용하는 것도 한 방법이다. 하지만 암호 알고리즘도 역공학 앞에서는 완벽한 보호책이 되지 못한다. 복호화에 사용되는 키가 메모리 어딘가에 반드시 존재해야 하고, 복호화 직전에 이 키를 읽어와야만 하기 때문이다. 공격자는 언제든지 암호키를 읽을 수 있다.

 

앞서 살펴본 3가지 방법은 역공학을 어렵게 만들기는 하지만, 프로그램의 성능을 심각하게 떨어뜨릴 수 있다. 난독화 도구는 각종 난독화 방법을 적용할지 여부를 옵션으로 남겨놓는데, 시스템과 성능과 역공학에 대한 저항성 등을 상충되는 요구 사항을 잘 고려한 후에 결정해야할 문제다.



정리

 

앞서 이야기한 것처럼 역공학에 대한 100% 완벽한 대안은 없다. 바이너리를 분석하고 수정할 수 있다면 어떠한 안전장치도 통과할 수 있기 때문이다. 다만 전자 상거래나 보안 제품 등 높은 수준의 보안성을 요구하는 프로그램이라면, 약간의 성능 저하를 감수하고서라도 각종 코드 난독화 기술을 적용해볼 여지를 가지고 있다.

이 글에서는 역공학과 코드 난독화의 기본적인 개념과 간단한 메커니즘만 소개하였고, 세부적인 기술에 대한 논의는 지면 관계상 보류하였다. 관심 있는 독자는 "obfuscate"라는 단어로 좀 더 많은 자료를 찾아보길 바란다.

 

 

참고 문헌

[1] Building Secure Software, Viega, McGraw, Addison-Wesley







웹과 사용자 인터페이스

Posted 2008. 11. 29. 23:12

마이크로소프트웨어 2008년 9월호 박스 기사입니다.



GWT의 위젯이나 고급 Ajax UI 라이브러리는 HTML과 CSS 밖에 없는 브라우저 위에 버튼, 레이블, 메뉴 등을 쉽게 만들 수 있게 해준다. 대표적인 Ajax UI 툴킷은 Ext이다. 위 그림은 Ext로 MS 윈도 데스크톱 환경을 비슷하게 구현한 것이다.

 

웹 브라우저에 그림을 그리려면 HTML와 CSS를 사용해야 한다. 따라서 웹 UI 라이브러리는 자바 SWT와 달리 UI가 변할 때마다 HTML을 동적으로 생성하는 방식을 쓴다. 브라우저에서 데스크톱 이상의 UI 툴킷을 구현하는 것은 웹 표준이 의도한 바를 넘어선 해킹인 셈이다. 속도 향상을 위해 많은 최적화 방법이 사용되지만 여전히 답답할 만큼 속도가 느리다.

 

HTML5에서는 성능 문제가 보다 쉽게 해결될 수 있다. HTML5에 추가된 <canvas> 자바스크립트로 호출할 수 있는 2D 벡터 그래픽 API를 제공하기 때문이다. DOM에서 canvas 오브젝트를 얻어, GDI+나 Cairo와 유사한 2D 벡터 그래픽 API로 그릴 수 있다. 이 방식을 사용하면 UI 툴킷을 좀 더 효율적으로 구현할 수 있고 기존 UI 툴킷도 쉽게 포팅할 수 있으리라 기대된다.



« PREV : 1 : 2 : 3 : 4 : 5 : ··· : 82 : NEXT »