Introduction
컴퓨터 과학 분야에서 계측(instrumentation)이란 프로그램의 행위나 특정 변수(레지스터나 메모리 등)를 조사하는 기능을 의미한다.
퍼징을 포함한 바이너리 분석에서의 바이너리 계측(binary instrumentation)은 대부분 필연적으로 측정이나 분석을 위해 코드를 삽입하는 동작을 포함한다. 즉 분석을 위해 코드 등을 추가하는 것이 바이너리 계측이다.
바이너리 계측 기법은 삽입할 수 있는 코드의 양에 제한이 없고, 바이너리의 임의의 위치에 삽입이 가능하다. 또한 삽입한 코드를 이용해 바이너리의 행위를 관찰하고 동작을 수정할 수도 있다.
예를 들어 가장 많이 호출된 함수를 찾고자 한다면 모든 call 명령어를 계측하면 된다.
새로운 코드를 삽입하는 지점을 계측 지점(instrumentation point)이라고 하고, 추가한 코드를 계측 코드(instrumentation code)라고 한다.
계측은 바이너리의 행위를 관찰(observe)하는 용도지만, 이를 통해 행위를 조작(modify)할 수도 있다.
예를 들어 바이너리 내의 모든 간접 호출 명령어(call rax나 ret 등)를 계측해 목적지가 타당한지 검사할 수 있다. 만약 제어 흐름이 비정상적인 목적지로 향한다면 이를 종료할 수 있는데, 이런 기법을 제어 흐름 무결성(CFI, Conrtol-flow identity)이라 한다.
Static vs. Dynamic Binary Instrumentation
정적 바이너리 계측(SBI: Static Binary Instrumentation)은 바이너리를 덮어써서(binary overwrite) 프로그램 상태의 바이너리를 수정한다.
이와 대조적으로 동적 바이너리 계측(DBI: Dynamic Binary Instrumentation)은 바이너리를 관찰한 뒤 실행 시점(프로세스 상태)에 명령어를 삽입한다.
다음은 SBI와 DBI의 장단점이다.
- Trade-offs of Dynamic and Static Binary Instrumentation
DBI의 장점은 코드 재배치 문제를 고려하지 않아도 된다는 것이다. 또한 계측 코드를 실행 시에만 삽입하므로 바이너리의 실제 코드 섹션을 건들지 않는다. 하지만 SBI에 비해 속도 저하가 심하다.
DBI는 비교적 사용이 쉽다. 또한 모든 명령어에 대해 통계 측정이 되는데, 이는 바이너리와 바이너리가 사용한 라이브러리 관련 정보에도 적용된다. 반면 SBI는 라이브러리를 명시적으로 지정해 준 경우에만 계측이 가능하다.
DBI는 동적으로 생성된 코드도 처리할 수 있는데 SBI는 이런 JIT(Just-In-Time)-compiled 코드나 자체 수정 코드 등을 처리할 수 없다.
게다가 DBI는 디버거처럼 프로세스에 자유로운 attach/detach가 가능하다. 이는 프로세스의 동작 시간이 길 때 일부만을 관찰하고 싶은 경우 유용하다. 반면 SBI는 계측을 원한다면 모든 명령어를 계측해야 한다.
마지막으로 DBI는 상대적으로 오류 발생 가능성이 낮다. SBI는 바이너리를 먼저 디스어셈블해야 하는데, 이 과정에서 오류가 포함된다면 계측 결과에 영향을 미친다. 반면 DBI는 CPU상에서 처리되는 명령어이므로 정확하고 심벌 정보도 필요 없다.
Static Binary Instrumentation
잘 알려진 SBI 플랫폼으로는 PEBIL(심벌 정보 필요)과 Dyninst(심벌 정보 불필요, DBI 기능도 제공)가 있다.
SBI는 계측 코드를 삽입한 뒤에 기존에 존재하던 코드 및 데이터의 상호 참조 관계를 유지하는 것이 관건이다. 이를 해결하기 위한 int 3 방법과 트램펄린(trampoline) 기법을 살펴볼 것이다. 보통은 두 기술을 통합해 적용한다.
먼저 두 가지 기법의 필요성을 알아보기 위해 일반적이지 않은 접근 방식부터 알아보자.
A Naive SBI Implementation
코드를 추가적으로 삽입하면 재배치된 코드에 대한 모든 참조를 알맞게 수정해 줘야 한다. 따라서 다른 섹션이나 라이브러리 등 기존 공간에 코드를 삽입해야 할 것이다.
그 후 실행 흐름이 계측 지점에 도달하면 계측 코드를 수행하기 위해 제어권을 넘겨받아야 한다.
- A nongeneric SBI approach that uses jmp to hook instrumentation points
왼쪽 원본 코드의 [mov edx, 0x1] 명령어가 수행되기 전과 후에 계측을 하고자, 계측 코드를 추가하려는 상황이다➊.
새로운 코드를 삽입할 공간이 없으므로 대상 명령어를 jmp 명령어로 덮어쓰면 될 것이다➋. 계측 코드는 별도의 섹션이나 라이브러리에 위치하면 된다.
계측 코드에서는 사전 계측(pre-instrumentation) 코드➌, jmp 명령어로 덮어써버린 원래 명령어 코드인 [mov edx, 0x1]➍, 사후 계측(post-instrumentation) 코드 순으로 수행한다➎.
그리고 시작 지점으로 다시 점프해 되돌아온다➏.
기존 레지스터 값들을 변경할 것을 대비해 일반적으로 사전/사후 계측 코드 수행 전에 레지스터를 별도로 저장해둔다.
하지만 이 방법은 jmp 명령어가 여러 바이트 길이를 차지한다는 문제점이 있다. 원하는 계측 지점으로 점프하기 위한 jmp 명령어가 5바이트 크기이고, 여기에 32비트 오프셋의 opcode 한 개가 필요하다. 만약 계측하려는 명령어가 짧다면 jmp 명령어가 더 많은 코드로 덮어쓰게 될 것이다.
The int 3 Approach
int 3는 x86 아키텍처에서 디버거가 소프트웨어 breakpoint를 구현할 때 사용하는 명령어다. 이 명령어는 운영체제에 의해 전달된 SIGTRAP과 같은 시그널을 처리할 수 있도록 소프트웨어 인터럽트를 발생시킨다.
int 3 명령어는 0xCC로 1바이트만 차지하기 때문에 계측하려는 코드의 명령어 크기가 작거나 길이가 알맞지 않은 다중 바이트(multibyte)의 점프도 가능하다.
계측을 하려는 명령어의 첫 번째 바이트를 0xCC로 덮어쓰면 된다.
SIGTRAP이 발생하면 리눅스의 ptrace API를 사용해 인터럽트가 발생한 주소를 찾으면 되고, 그 주소가 계측 지점의 주소가 된다.
하지만 이런 소프트웨어 인터럽트는 오버헤드가 발생한다. 또한 이미 디버깅 중인 프로그램이 int 3로 breakpoint를 지정하면 호환되지 않는다.
The Trampoline Approach
트램펄린(trampoline) 기법은 원본 소스 코드를 복사한 뒤 복사본에 대해서만 계측을 수행한다. 덕분에 원본 바이너리의 코드 및 데이터의 상호 참조 관계가 변경되지 않는다.
트램펄린은 기존의 각 함수의 첫 번째 instruction을 trampolines
라는 일종의 jmp 명령어로 덮어쓴다. 이를 통해 원래 코드를 계측된 복사본으로 리디렉션하는 방식으로 계측을 수행한다.
즉 함수 호출이나 점프가 발생해 제어권이 원본 코드로 향할 때 해당 위치에 있는 트램펄린이 작동해 관련 계측 코드로 건너뛰게 한다.
- Static binary instrumentation with trampolines
원본 바이너리에 아래와 같은 함수 f1과 f2가 포함되어 있다고 가정하자.
<f1>:
test edi, edi
jne _ret
xor eax, eax
call f2
_ret:
ret
트램펄린 기법을 적용하면 SBI 엔진이 모든 함수를 복사해 오른쪽 그림과 같이 새로운 코드 섹션 .text.instrum에 적재한다.
그리고 앞서 언급했듯이 각 원본 함수의 첫 번째 instruction을 jmp 트램펄린으로 덮어써서 복사된 함수로 점프하게 한다.
예를 들어 f1 함수를 복사한 f1_copy 함수로 리디렉션 하는 과정은 다음과 같다.
<f1>:
jmp f1_copy
; junk bytes
trampoline instruction은 5바이트여서 이후 명령어 몇 개를 손상시킨다. 따라서 트램펄린 바로 뒤에 junk bytes를 생성하기도 하는데, 손상된 명령어는 실행되지 않는다.
Trampoline Control Flow
바이너리가 실행되다가 f1이 호출되면 트램펄린 점프가 발생해 f1의 계측을 위한 복사본 함수인 f1_copy가 호출된다➊. 그로 인한 junk bytes가 바로 뒤에 보이지만 이는 실행되지 않는다➋.
SBI 엔진은 f1_copy의 모든 계측 가능한 지점에 nop 명령어들을 삽입한다➌. nop 명령어 부분은 계측 코드로 jmp하거나 계측 코드를 call하도록 덮어쓸 수 있다. 이는 모두 정적으로 처리된다. 위 그림에서는 ret 명령어 직전의 마지막 nop을 제외한 모든 nop들은 모두 사용되지 않았다.
SBI 엔진은 명령어 삽입으로 인해 코드 위치가 뒤바뀌더라도 jmp의 정확성을 유지하기 위해 모든 relative jump(relative jump) 명령어의 오프셋을 패치한다.
이때 relative jump란 현재의 instruction pointer와 관련된 주소로 점프하는 것이다. 예를 들어 [jmp .+0x20]가 relative jump 명령어의 한 예시인데, 이때 dot(.)이 현재 주소를 의미한다.
또 SBI 엔진은 8비트 오프셋이 있는 모든 relative jump 명령어를 32비트 오프셋이 있는 5바이트 명령어로 바꾼다➍. 이는 jmp와 해당 대상 간의 오프셋이 8비트로 인코딩하기에 너무 클 수도 있기 때문이다.
마찬가지로 call f2와 같은 직접 호출도 instrumented function을 대상으로 하도록 수정한다➎. 이는 간접 호출을 수용하기 위해 모든 원본 함수의 시작 부분에 트램펄린이 필요하기 때문이다.
이제 SBI 엔진을 이용해 모든 ret 명령어를 계측하려 한다고 해보자.
이를 위해 앞서 작성한 nop 명령어들을 jmp나 call 명령어로 덮어써서 계측 코드를 호출한다➏. 여기서 계측 코드는 hook_ret인데, 이는 공유 라이브러리에 있으며, SBI 엔진이 계측 지점에서 call 명령어로 호출한다.
hook_ret은 레지스터 상태를 저장하고➐ 계측을 수행한 뒤 레지스터 상태를 복원한다➑.
그리고 ret을 이용해 계측 지점 다음 명령어로 돌아가 정상 실행을 재개한다.
Handling Indirect Control Flow
간접 점프로 인한 제어 흐름(indirect control flow)은 목적지 주소를 동적으로 계산하므로 정적 분석에서 이를 계산하기 힘들다. 따라서 트램펄린 기법은 간접 제어 전송(indirect control transfer)이 원래 코드로 전달되도록 한 뒤에 원래 코드에 미리 삽입한 트램펄린을 이용해 계측 코드로 리디렉션 하도록 한다.
- Indirect control transfers in a statically instrumented binary
왼쪽은 간접 함수 호출에 대한 예시다.
SBI 엔진은 주소를 계산해 코드를 수정하려는 시도를 하지 않았기 때문에 간접 호출의 목적지 주소는 원본 함수를 가리키고 있다➊. 원본 함수들의 첫 번째 명령어는 모두 트램펄린으로 대체되었기 때문에 제어 흐름은 계측된 함수로 적절하게 이동한다➋.
바이너리 수준에서 switch 구문은 모든 가능한 switch case들의 주소를 포함한 jump table을 사용하기 때문에 간점 점프의 경우가 더 복잡하다.
스위치는 점프 테이블의 인덱스를 계산하고 indirect jmp를 사용해 테이블에 저장된 주소로 이동한다➊. 이때 점프 테이블에 저장된 주소들은 모두 원본 함수의 주소를 가리키고 있다➋.
따라서 원본 함수의 중간으로 간접 점프해버리는 경우 트램펄린이 없는 곳에서 실행이 이어지는 문제가 발생할 수도 있다. 이런 문제를 해결하려면 기존 코드 주소를 모두 계산하거나 switch가 발생하는 모든 원본 코드에 트램펄린을 삽입해야 한다.
하지만 심벌 정보에 switch 문의 레이아웃 정보가 없기 때문에 트램펄린을 배치할 위치를 알아내기 힘들다. 또한 모든 트램펄린을 switch 문 사이에 넣을 만큼 공간이 충분하지 않을 수도 있다. 게다가 점프 테이블을 수정하다가 (유효한 주소이지만) 점프 테이블의 일부가 아닌 데이터로 잘못 변경할 위험도 있다.
Reliability of the Trampoline Approach
swtich 구문의 예시로 살펴봤듯이 트램펄린 기법은 오류가 발생하기 쉽다(error-prone).
5 바이트 크기의 트램펄린 jmp를 삽입할만한 충분한 공간이 없는 함수가 포함될 수도 있으므로 int 3 방법으로 대체할 필요가 있다.
또한 바이너리에 코드와 데이터가 섞여있는 경우 트램펄린이 실수로 데이터를 건드릴 수도 있다.
사실 모든 것은 디스어셈블이 정확하다는 가정 하에 이루어져야 한다.
안타깝게도 효율성과 정확성을 모두 충족하는 SBI 기법은 존재하지 않는다.
Trampolines in Position-Independent Code
트램펄린 기법을 기반으로 하는 SBI 엔진은 특정 load address에 의존하지 않는 PIE(Position-Independent Executables) 바이너리의 간접 제어 흐름을 위한 특별한 기능이 필요하다. PIE 바이너리란 위치 독립 코드(PIC: Position-Independent Code) 기법이 적용된 바이너리를 의미한다.
PIE 바이너리는 PC(Program Counter) 값을 읽어와서 주소 계산에 사용한다. 32비트 x86 아키텍처에서 PIE 바이너리는 PC 값을 읽어와서 call instruction을 실행하고, 스택에서 return address를 읽는다.
예를 들어 gcc 5.4.0은 함수 호출 명령어의 주소를 읽기 위한 다음과 같은 함수를 제공한다.
call:
<__x86.get_pc_thunk.bx>:
mov ebx, DWORD PTR [esp]
ret
이 함수는 return address를 ebx에 저장한 뒤 return한다. x64 아키텍처에서는 program counter인 rip 레지스터에서 값을 직접 읽을 수 있다.
PIE 바이너리는 계측 코드를 실행하는 동안에도 PC 값을 읽어서 주소 계산에 사용할 수 있으므로 주의가 필요하다. 이 경우 계측 코드의 레이아웃이 주소 계산에서 가정한 원래 레이아웃과 다르기 때문에 잘못된 결과가 도출될 수 있다.
이를 해결하기 위해 SBI 엔진은 PC가 원래 코드에서 가질 수 있는 값을 반환하도록 PC 값을 읽는 방식을 사용한다. 그러면 이후 주소 계산에서 원래 코드의 위치를 찾을 수 있고, 그 덕에 SBI 엔진이 트램펄린으로 제어권을 획득할 수 있다.
Dynamic Binary Instrumentation
DBI 엔진은 바이너리를 실행해 프로세스 상태인 명령어 스트림을 실행하고 계측한다. 따라서 디스어셈블이나 패치가 필요하지 않고 에러가 발생할 확률도 낮다.
다음 그림은 Pin이나 DynamoRIO와 같은 최신 DBI 시스템의 구조도다.
- Architecture of a DBI system
실제로는 SBI와 DBI를 복합적으로 적용해 트램펄린 등의 코드 패치 기법도 사용하는 Dyninst 플랫폼 등도 널리 쓰인다.
여기서는 순수한 DBI 시스템에만 초점을 맞출 것이다.
Architecture of a DBI System
DBI 엔진은 실행되는 명령어들을 모니터링하고 컨트롤하면서 동적으로 계측을 수행한다.
이를 위해 계측할 코드와 방법을 지정하는 사용자 정의 DBI 도구를 작성할 수 있는 API를 제공한다. 이 DBI 도구는 보통 DBI 엔진이 공유 라이브러리 형식으로 로드해 사용한다.
예를 들어 위 그림의 오른쪽에 DBI 도구가 기본 블록의 개수를 계측하기 위한 단순한 프로파일러 형태의 의사 코드로 구현되어 있다. 모든 기본 블록의 마지막 명령어가 함수의 카운터를 증가시키도록 callback으로 구현되어 있다.
DBI 엔진이 프로세스를 실행하기 전에 instrument_bb라는 함수를 엔진에 등록하면서 초기화를 수행한다➊. 이 함수가 모든 기본 블록을 어떻게 계측할지 DBI 엔진에게 알려준다. 이 경우 기본 블록의 마지막에 bb_callback이라는 콜백을 추가하는 방식이다.
초기화가 끝나면 프로세스를 시작하도록 알린다➋.
DBI 엔진은 대상 바이너리를 직접 프로세스화하는 대신 모든 계측 코드가 포함된 코드 캐시(code cache)를 실행한다.
처음에는 코드 캐시가 비어 있으므로 프로세스에서 코드 블록을 fetch해오고➌, DBI 도구에 의해 계측용➎ 코드를 계측한다➍. 지금은 예시일 뿐이고, DBI 엔진이 기본 블록 단위로 코드를 fetch해서 계측할 필요는 없다.
코드를 계측한 후 DBI 엔진은 JIT 컴파일러로 코드를 컴파일하는데➏, 이때 계측용 코드가 다시 최적화되고 코드 캐시에 저장된다➐. JIT 컴파일러는 제어 흐름 instruction을 rewrite하여 DBI가 제어권을 유지하도록 보장해 주고, 계측되지 않은 프로세스에서 실행되지 않도록 해준다.
이제 계측되고 JIt-컴파일된 코드는 새로운 코드를 fetch하거나 캐시에서 다른 코드 chunk를 찾는 명령어 전까지 코드 캐시에서 실행된다➑. 계측용 코드는 코드의 동작을 관찰하거나 수정하는 bb_callback과 같은 DBI 함수에 대한 콜백을 포함하고 있다➒.
참고 및 인용
[1] https://practicalbinaryanalysis.com/
[2] http://www.acornpub.co.kr/book/binary-analysis
[3] https://hexterisk.github.io/blog/posts/
Uploaded by Notion2Tistory v1.1.0