소스 코드가 없는 경우엔, 바이너리 코드를 본다?
보안에 관심 있는 사람은 누구나 바이너리(binary)에 대해서 들어보았을 것입니다. 해킹 경연대회(CTF)에서는 항상 바이너리 기반의 문제가 출제되죠. 소스 코드를 활용한 해킹대회 문제는 웹 해킹 쪽을 제외하고서는 매우 드물다 할 것입니다. 그뿐 아니라 메모리 취약점을 공격한다고 하면 누구나 다 바이너리를 기반으로 공격 코드(exploit)를 만듭니다.
그런데 왜 이렇게 보안에서는 바이너리가 중시되는 것일까요? 단순히 소스 코드가 없는 경우를 대비하기 위한 것일까요? 아니면 공격 코드를 만들기 위해서 메모리 구조를 이해해야 하기 때문일까요? 물론 둘 다 어느 정도 일리는 있으나, 이보다 근본적인 이유는 따로 있습니다.
바로 바이너리는 소프트웨어와 하드웨어를 연결하는 인터페이스이기 때문입니다. 좀 더 쉽게 말하자면, 소스 코드로 작성된 프로그램은 결국 바이너리라는 형태로 CPU에 전달되기 때문에, 프로그램의 동작을 완벽히 이해하기 위해서는 바이너리를 분석해야만 가능하다는 것입니다. 실제로 아무리 소스 코드를 분석해도 찾을 수 없는 취약점이나, 반대로 소스에는 명백히 존재해도 바이너리에는 존재하지 않는 취약점이 있을 수 있습니다. 실제 컴파일러가 어떤 형태로 바이너리를 만드느냐에 따라서 그 결과가 달라지는 것이죠.
여기서 중요한 가정이 하나 필요합니다. 바로 어디까지 신뢰할 것인가에 대한 가정입니다 [1]. 예를 들어 하드웨어를 신뢰하지만, 소프트웨어를 신뢰하지 않을 수도 있고, 하드웨어와 소프트웨어를 모두 신뢰할 수도 있겠죠. 만약 우리가 소프트웨어를 완전히 신뢰한다면, 컴파일러 또한 소프트웨어이므로, 굳이 바이너리를 보지 않더라도 소스 코드 단계에서 분석한 결과를 신뢰할 수 있을 것입니다. 만약 하드웨어까지는 신뢰하지만, 소프트웨어를 신뢰하지 않는다면? 이 경우에는 하드웨어로 직접 전달되는 바이너리 코드를 확인하지 않고서는 해당 프로그램을 온전히 이해했다고 말하기가 어렵게 됩니다.
소프트웨어 보안이라는 학문은 “소프트웨어에 대한 불신”에서 출발합니다. 즉 소프트웨어를 믿지 못하니 우리가 소프트웨어를 분석해서 오류와 취약점을 찾아내야만 하는 것이고, 그 분석의 대상은 우리가 믿을 수 있는 하드웨어에 전달되는 최종 매개체인 바이너리 코드가 될 수밖에 없죠. 따라서, 바이너리 분석은 소프트웨어 보안의 근간을 이루는 핵심 요소입니다 [2].
바이너리 분석은 소스 분석과는 완전히 다르다?
많은 사람이 바이너리 분석이라 하면 굉장히 특별한 무언가가 있다고 생각하지만, 어떻게 보면 소스 코드 분석과 크게 다르지 않습니다. 소스 코드 분석은 소스 코드를 컴파일하는 과정에서 얻어지는 중간언어(IR)를 기반으로 이루어집니다. 그리고 바이너리 코드 분석 또한, 바이너리 코드를 역변환(lifting)하여 얻어지는 중간언어를 기반으로 이루어집니다. 따라서 두 방법은 매우 흡사합니다.
물론 바이너리 코드를 역변환한 중간언어와 소스 코드를 컴파일하는 과정에서 나타난 중간언어는 그 모양과 구조가 매우 다릅니다. 전자는 대개 기계어와 비슷한 저수준의 연산을 포함하지만, 후자는 고수준의 연산이 주로 포함합니다.
아래의 C 소스 코드를 컴파일한 뒤, 컴파일과정에서 나타나는 중간언어와 컴파일된 바이너리를 다시 역변환(lifting)한 중간언어를 비교해보도록 합시다.
#include<stdio.h>
int main() {
puts("csrc");
return 0;
}
이제 위의 소스 코드(ex.c)를 LLVM 바이트코드(중간언어)로 변환해 봅시다.
$ clang -S -emit-llvm -o ex.ll ex.c
$ cat ex.ll
...
define i32 @main() #0 {
%1 = alloca i32, align 4
store i32 0, i32* %1, align 4
%2 = call i32 @puts(i8* getelementptr inbounds ([5 x i8], [5 x i8]* @.str, i32 0, i32 0))
ret i32 0
}
...
이제 동일한 소스코드를 바이너리로 만든 뒤 B2R2 [3] 를 활용해 중간언어로 변환해 봅시다.
$ clang -o ex ex.c
$ dotnet run --project B2R2/src/Utilities/BinDump -- -f auto -i ./ex
...
=== ISMark (4004D0)
T_77:I64 := RBP
RSP := (RSP - 0x8:I64)
[RSP] := T_77:I64
=== IEMark (pc := 4004D1)
=== ISMark (4004D1)
RBP := RSP
=== IEMark (pc := 4004D4)
=== ISMark (4004D4)
T_78:I64 := RSP
T_79:I64 := 0x10:I64
T_80:I64 := (T_78:I64 - T_79:I64)
RSP := T_80:I64
CF := (T_78:I64 < T_79:I64)
OF := (((T_78:I64 ^ T_79:I64) & (T_78:I64 ^ T_80:I64))[63:63])
AF := ((((T_80:I64 ^ T_78:I64) ^ T_79:I64) & 0x10:I64) = 0x10:I64)
SF := (T_80:I64[63:63])
ZF := (T_80:I64 = 0x0:I64)
T_81:I64 := (T_80:I64 ^ (T_80:I64 >> 0x4:I64))
T_82:I64 := ((T_80:I64 ^ (T_80:I64 >> 0x4:I64)) ^ (T_81:I64 >> 0x2:I64))
PF := (~ ((((T_80:I64 ^ (T_80:I64 >> 0x4:I64)) ^ (T_81:I64 >> 0x2:I64)) ^ (T_82:I64 >> 0x1:I64))[0:0]))
=== IEMark (pc := 4004D8)
=== ISMark (4004D8)
RDI := 0x400584:I64
=== IEMark (pc := 4004E2)
=== ISMark (4004E2)
[(RBP + 0xFFFFFFFFFFFFFFFC:I64)] := 0x0:I32
=== IEMark (pc := 4004E9)
=== ISMark (4004E9)
T_83:I64 := 0x4003D0:I64
T_84:I64 := 0x4004EE:I64
RSP := (RSP - 0x8:I64)
[RSP] := T_84:I64
Jmp T_83:I64
=== IEMark (pc := 4004EE)
...
B2R2로부터 만들어진 중간언어는 너무 길어서 일부만을 나타냈습니다만, 확실한 것은 이렇게 만들어진 중간언어가 소스로부터 만들어진 중간언어보다 현저히 길다는 것이죠. 그만큼 같은 의미(semantics)를 표현함에 있어 고수준의 중간언어는 좀 더 공간 효율적이고, 반면 저수준의 중간언어는 비효율적이라는 것을 알 수 있습니다.
그럼 바이너리 분석과 소스 분석의 차이는 단순히 그 효율성에 있는 것이 아닌가? 하는 질문을 할 수 있을 것 입니다. 하지만 안타깝게도 문제는 여기서 끝나지 않습니다. 바이너리 분석의 가장 큰 문제는 소스에서는 당연히 존재하던 정보가 사라진다는 것입니다. 예를 들면 변수의 타입정보, 스위치문의 구조 등은 당연히 소스 코드에 존재하는 정보이지만, 바이너리로 변환되면서 사라지게 됩니다. 그리고 사라진 정보를 온전히 복원하는 것은 매우 어려운 문제가 됩니다. 이에 대해서는 다음 포스팅에서 더욱 자세히 다루도록 하겠습니다.
바이너리에서 얻은 중간언어를 LLVM IR로 변환하면 기존 분석 적용이 가능하다?
물론 상식적으로 생각하면 하나의 중간언어를 다른 중간언어로 변환하는 것은 그리 어려운 것은 아닙니다. 하지만 언어의 표현 수준이 완전히 다른 경우에는 얘기가 달라집니다. 예를 들어 소스 코드상에서 func() 라고 하는 함수 콜이 있다면, 해당하는 바이너리에서는 그것이 “call 0xABCD” (0xABCD는 func의 주소) 형태로 나타날 것입니다. 우리는 이를 LLVM 중간언어로 바꿀 수는 있지만, 소스에서는 단순히 func라는 기호로 표현되던 함수가 실제 주소로 변환되었기 때문에 call (또는 callbr) 이라는 LLVM IR로 단순 변환할 수가 없는 것입니다. 왜냐하면, LLVM의 call 명령어에는 기호가 필요하니까요. func라고 하는 함수의 실체가 바이너리 코드에서는 더 이상 보이지 않게 되는 것이죠.
여기에서 핵심은 소스와 바이너리에서 나타내는 근본 의미가 아무리 같더라도, 표현 방식이 다르면 상호 변환이 매우 어렵고, 변환하더라도 의미 있는 형태가 나오기는 어렵다는 것입니다. 어떤 바이너리를 LLVM IR로 변환한 뒤 그것을 바로 LLVM에서 실행할 수 있는가? 만약 그게 가능하다면 변환이 성공적이었다고 할 수 있겠죠. 하지만 그것은 역어셈블이 완벽하다는 가정에만 가능하며, 완벽한 역어셈블 문제는 컴퓨터 과학에서 말하는 결정불가능한(undecidable) 문제입니다.
글을 마치며.
지금까지 저희 센터의 첫 블로그 포스팅으로 바이너리 분석에 대해 많은 사람이 갖는 의문점에 대해 간략히 짚어보았습니다. 다음 포스팅에서는 바이너리 분석에 있어 가장 어려운 문제 중 하나인 역어셈블 문제에 대해 다루도록 하겠습니다. 또한, 저희 센터에서는 다양한 내용의 글을 통한 소통의 장을 마련할 것입니다. 앞으로 많은 성원 부탁드립니다.
[1] Ken Thopmson, Reflections on Trusting Trsut, CACM 1984
[2] 차상길. (2018). 소프트웨어 보안과 바이너리 분석. 정보과학회지, 36(3), 11-16.
[3] B2R2, https://github.com/B2R2-org/B2R2