6.1 정적 디스어셈블
1.
디스어셈블 = 정적 디스어셈블
2.
동적 디스어셈블 = 실행 추적 (execution tracing)
정적 디스어셈블러의 목적 및 작업
1.
목적
a.
모든 기계어 코드를 해석해 사람이 이해할 수 있는 수준으로 번역
b.
컴퓨터로 처리할 수 있는 형식으로 만드는 것 (다른 분석 목적을 위해)
2.
작업
a.
파일 처리를 위해 바이너리 로더가 필요함
b.
바이너리 내부의 모든 기계어 명령을 찾아 낼 수 있어야 함
c.
찾아낸 명령어를 사람 또는 컴퓨터가 이해가 가능한 수준으로 번역할 수 있어야 함
2.b 작업을 최대한 오류없이 수행하기 위하여 선형 디스어셈블 구조와 재귀적 디스어셈블 구조를 가진다.
6.1.1 선형 디스어셈블
인라인 데이터를 잘못 판단해 명령어로 해석할 경우 이후 모든 코드가 해석이 잘못된다.
objdump가 선형 디스어셈블 기법을 사용한다.
6.2.2 재귀적 디스어셈블
재귀적 디스어셈블 기법은 바이너리 내부 EntryPoint를 시작점으로 그곳부터 실행 흐름을 재귀적으로 따라가면서 코드를 찾아낸다.
단점은 프로그램의 모든 제어 흐름을 따라가기 힘들다.
사실상 표준으로 사용되는 기법이며, IDA Pro 디스어셈블러가 사용하고 있다.
간접 제어 흐름
switch 구문의 x64 방식 구현은 점프 테이블을 기반으로 작동한다.
해당 소스를 확인하면 0x4438f9 에서 rax에 rax*8+0x49e840 값을 저장한 후 rax값으로 점프한다.
그러면 rax값은 case 값, 0x49e840 값이 테이블 시작 주소임을 알 수 있다.
위처럼 진행되는 방식을 간접 제어 흐름이라고 하며, 재귀적 디스어셈블에서 어려운 문제 중 하나이다.
실 업무를 수행할 때도 저런 동적 호출 방식이 상당히 많이 쓰이며, 동적 분석을 강요하는 요소 중 하나이기도 합니다.
6.2 동적 디스어셈블
정적 디스어셈블이 가지는 문제(데이터와 코드 구분하기 힘든 문제, 간접 호출 문제)를 해결할 수 있는 방식이다.
이러한 방식을 실행 추적, 명령어 추적이라고 부른다
단점으로는 코드 커버리지 문제를 가지고 있다.
6.2.1 예제: gdb로 바이너리 실행 추적하기
6.2.2 코드 커버리지 전략
동적 방식은 해당 바이너리가 실행되는 동안 만나는 명령어만 분석을 진행한다.
그러므로 논리 폭탄(특정 시간 이후에 실행되는 악성코드), 난독화가 진행된 코드 등의 경우 동적으로 분석하기가 매우 힘들다.
또한 소프트웨어의 버그를 테스트 하고자 할 때 동적 방식을 이용하지만, 실행되는 코드 외에 다른 코드에서 버그가 발생하지 않는다는 보장은 없다.
악성코드
대부분의 악성코드는 안티 디버깅, 패킹 등 분석가에게 시간을 더 소모하게 하는 모든 기술을 적용한다.
코드 커버리지를 증진시키는 몆 가지 방법을 설명하겠다.
1.
테스트 모음집
코드 커버리지를 늘리는 가장 대표적인 방법이다.
분석 대상 바이너리에 사전 협의된 입력값을 주입해 최대한 많은 기능이 사용되도록 테스트 하는것이다.
장점으로는 분석 단계에 테스트 모음집을 전달해 실행하는 것 만으로 좋은 코드 커버리지를 달성할 수 있다.
단점으로는 상용 소프트웨어, 악성코드의 경우 테스트 모음집을 구할 수가 없다.
2.
퍼징
흔히 퍼저(fuzzer)라고 부르는 도구들이 있으며, 퍼저는 주어진 바이너리에 대해 새로운 코드 경로를 찾아내기 위한 입력값을 자동으로 생성하는 도구다
널리 알려진 퍼저로는 AFL, MS의 Springfield, Google의 OSS-Fuzz 등이 있다.
또한 퍼저는 입력값을 생성하는 방법에 따라 크게 두 가지 종류로 구분된다
a.
생성 기반 퍼저: 이 방법은 입력값을 만들 때 무에서 유를 창조한다(보통 예상되는 입력값의 형식에 대한 정보를 토대로 작업을 진행한다)
b.
변이 기반 퍼저: 이 방법은 알려진 특정 유효 입력값을 토대로 변이를 가함으로써 새로운 값을 생성한다.
AFL 퍼저를 매우 추천한다.
3.
기호 실행
상세한 내용은 12,13장에서 자세히 다룬다.
기호 실행은 코드 커버리지 뿐만 아니라 더 다양한 목적을 위해 사용할 수 있는 광범위한 기법이다.
기호 실행은 애플리케이션을 실행할 때 구체적인 값을 사용하지 않고 기호화된 값을 사용한다
아래는 예시이다.
x = int(argv[0])
y = int(argv[1])
z = x + y
if (x<5)
foo(x,y,z)
else
bar(x,y,z)
C
복사
해당 코드의 경우 제약 조건은 "x가 5보다 무조건 작다"이다.
이 조건이 만족이 안될 경우 해당 분기문으로는 절대 진입을 하지 못한다.
기호 실행의 경우 코드 커버리지에서
"주어진 경로 제약 조건의 목록이 있을 때 그 조건을 모두 만족할 수 있는 구체적인 입력값이 존재하는지를 점검" 하는 것에 있다.
해당 입력값을 찾는 프로그램을 제약 조건 풀이기 라고 부른다.
6.3 디스어셈블된 코드와 데이터를 구조화하기
해당 파트에서는 코드와 데이터를 구조화 함으로써 디스어셈블 도구가 바이너리 분석을 용이하게 하는 기법들을 살펴보겠다.
6.3.1 코드 구조화
코드를 분석하기 쉽게 구조적으로 정리하는 방법이다.
1.
구획화(compartmentalizing): 코드들을 각각 논리적 조각의 연결로 구분해 쪼개는 것이다.
이를 통해 각 조각들의 행위와 상호 연관 관계를 쉽게 파악할 수 있다.
2.
제어 흐름 파악: 코드의 제어 흐름을 시각화하여 좀 더 쉽고 빠르게 코드의 제어 흐름을 파악할 수 있다.
1.
함수
함수의 정의
일련의 코드 구문들을 논리적 단위로 묶고, 그룹화해 하나의 블록 단위로 정리하고자 함수 개념을 사용함.
C
복사
디스어셈블된 명령어들을 함수 단위로 묶는 작업을 함수 탐지라고 한다.
함수 탐지의 경우 분석가가 분석을 용이하게 할수 있을 뿐만 아니라 자동화된 분석 도구에도 큰 이점을 제공한다.
심볼이 제공될 경우 함수 탐지는 상당히 간단하나, 대부분의 소프트웨어들이 stripped 되어 있어 매우 힘들다.
또한 하나의 함수 내의 정보가 연속적인 위치에 존재하지 않을수도 있으며, 특정 부분을 여러 함수 사이에서 공유하여 사용할 수도 있다.(겹친 코드 블록 / overlapping code block)
그리하여 함수의 시그니처를 이용하여 함수를 탐지한다.
함수 시그니처란 함수의 시작과 끝에 자주 발견되는 특이한 패턴을 말한다.
꼬리 호출
함수 A가 함수 B를 호출하고 하고 종료되는 경우
함수 A가 컴파일러에 의해 최적화 되어 call이 아닌 jmp로 함수 B를 호출하고
함수 B가 정상적으로 함수 에필로그를 수행하였을 경우 함수 A는 정상적인 함수 에필로그를 수행하지 않아도 된다.
C
복사
또한 최근에는 함수 코드 자체의 구조를 확인하여 함수를 탐지하는 기법이 활용되고 있다.
Binary Ninja도구에서 채택하였으며, IDA Pro에서도 연동할 수 있는 플러그인이 존재한다.
2.
제어 흐름 그래프 (Control-Flow Graph)
함수 내의 블럭단위로 실행되는 플로우를 보여준다.
보안인의 경우 Control Flow Guard를 CFG로 부르는 경우가 훨씬 많으므로 혼동하지 말도록 하자
3.
호출 그래프
호출 그래프의 경우 함수간의 호출을 그래프로 보여준다.
4.
객체 지향형 코드
객체 지향 소스코드를 컴파일 할 때, 각 함수 포인터를 관리하는 vtable을 이용하여 함수 포인터를 관리한다.
6.3.2 데이터 구조화
1.
기본적인 함수의 파라미터 이름을 통해 유추
2.
구조체나 배열의 경우 정의하기 매우 힘듬
3.
IDA Pro에서 제공하는 구조체 정의를 이용하여 분석하면 편리함
실제로 분석할 경우 IDA Pro에서 알고 있는 구조체를 정의하여 사용하는게 상당히 편리합니다.
6.3.3 디컴파일
컴파일 과정의 역변환으로 어셈블리 > C 언어 코드로 변환을 해주는 도구이다.
IDA Pro의 Hex-Rays 플러그인이 대표적인 디컴파일 도구이며, x64dbg에도 다른 명칭으로 디컴파일 플러그인이 포함되어 있다.
사용하다 보면 알겠지만 디컴파일을 수행할 때 마다 다르게 변환되기도 한다.
늘 어셈블리어를 기반으로 디컴파일을 참고용으로 사용하길 바란다.
6.3.4 중간 언어 표현식
중간 언어 표현식이란, 하위 레벨 기계어 코드에 대해 추상화해 표현할 수 있는 언어다.
어셈블리의 sudo 코드 정도의 의미로 이해됨
C
복사
1.
장점
a.
상당해 많은 어셈블리를 IR을 이용하여 간단하게 변환할 수 있다.
2.
단점
a.
표현식이 훨신 덜 간결하다.
// 어썸하지만 add rax,rdx 명령어를 VER IR로 변환한 결과이다.
IRSB {
t0:Ity_I64 tl:Ity_I64 t2:Ity_I64 t3:Ity_I64
00 ............IMark(0x40339f, 3, 0) ...............
t2 = GET:I64(rax)
tl = GET:I64(rdx)
t0 = Add64(t2,tl)
PUT(cc_op) = 0x0000000000000004
PUT(cc_depl) = t2
PUT(cc_dep2) = tl
PUT(rax) = t0
PUT(pc) = 0x00000000004033a2
t3 = GET:I64(pc)
NEXT: PUT(nip) = t3; Ijk_Boning
C
복사
1.
t0~t3까지 I64임을 선언
2.
t0에 t2(rax), t1(rdx)를 add 한 값을 저장
3.
put(cc_op)에서 상태 플레그 변경
4.
rax에 t0 저장
5.
program counter 증가 (다음 실행할 주소값)
6.
t3에 pc값 입력 후 다음으로 진행
6.4 주요 분석 방법론
6.4.1 바이너리 분석 요소
바이너리 코드를 분석할 때 함수 단위로 보는것이 직관적이고 수월하다.
그렇기 때문에 대다수 디스어셈블러가 함수 정보를 복원하는 기능을 제공하고 있다.
1.
프로시저 간 분석과 프로시저 내부 분석
a. 프로시저 간 분석
함수 내에 분기문이 늘어날수록 실행 경로의 수는 2의 제곱에 비례해 증가한다.
현실적으로 상용 프로그램에 적용이 불가능하기 때문에 프로시저 내부 분석을 사용한다
b. 프로시저 내부 분석
함수 하나만을 분석하는 방식으로 프로시저 간 분석에 비해 시간이 상당히 단축된다.
그러나 굉장이 특이한 함수 호출 루틴으로 호출 되어야만 발생하는 버그같은 경우는 프로시저 내부 분석 방식으로 탐지할 수 없다.
//예제 코드
#include <stdio.h>
static void dead(int x)
{
if( x == 5)
printf("Never reached \n");
}
}
int main(int argc, char *argv[])
{
dead(4);
return 0;
}
C
복사
위의 예제 코드의 경우 프로시저 간 분석을 적용할 경우 컴파일러에서 무의미한 코드 제거를 수행하여 dead(4); 함수 호출 부분 및 dead 함수 자체를 바이너리에서 삭제한다.
그러나 프로시저 내부 분석을 통할 경우 main 함수에서는 의미 없이 호출하나, 다른 함수에서 호출 하는 여부를 알지 못하기 때문에 해당 코드는 그대로 바이너리로 들어가게 된다.
2.
흐름 위주 방법
x = (unsigned int)(argv[0]) // x는 0~무한대
x = x + 5 // x는 5~무한대
x = x + 10 // x는 15~무한대
C
복사
위의 예제 소스를 보면 흐름 위주로 분석 하였을 경우 x 값을 특정할 수 있다.
흐름 무관의 분석 방식보다 특정 부분에 더 세부적으로 분석 할 수 있으며, 좀 더 정확하게 분석할 수 있다.
3.
문맥 위주 방법
솔직히 문장이 너무 이해가 안됨.
6.4.2 제어 흐름 분석
제어 흐름 분석, 데이터 흐름 분석이 바이너리 분석의 목적이다.
1.
반복문 탐지
a.
소스코드 수준에서는 구분하기 매우 편리하나, 바이너리 (어셈블리) 수준에서는 매우 어렵다. 반복문이 jmp문으로 구분되어 있기 때문이다.
컴파일러 이론 부분의 반복문 탐지 알고리즘
일반적인 프로그래머가 직관적으로 이해하는 상황과는 다른 반복문을 정의한다.
이것을 자연 반복문(natural loop) 라고 부르며, 분석 및 최적화가 더 쉬운 특정 형태의 속성을 갖는 반복문만을 의미한다.
그 외에도 다른 알고리즘이 존재하는데, 이는 자연 반복문의 정의를 엄격하게 따르지 않는 단순 사이클 형태를 cfg상에서 모두 찾아내는 알고리즘이다.
지배 트리
반드시 A를 통과해야만 B로 갈수 있는 경우, A가 B를 지배한다 라고 표현한다.
BB1은 BB2,3,4,6,7을 지배하고 BB3은 BB5를 지배한다
CFG를 지배 트리로 묘사한 것이 옆의 지배 트리이다.
그러면 A가 B를 지배하는 상황에서, B 와 A 사이의 역행 간선을 유도하면 자연 반복문을 찾을 수 있다.
BB5는 BB3으로 가는 역행 간선이 존재하며, BB3이 BB5를 지배하기 때문에 BB3과 BB5를 감싸는 자연 반복문이 존재한다.
2.
사이클 탐지
그림 6-10의 그래프에서 또 다른 역행 간선을 발견할 수 있는데, 이는 BB7에서 BB4를 잇는 간선이다.
자연 반복문은 아니지만 일종의 사이클을 형성하고 있다.
탐지하는 방법은 블록을 스택에 저장(push)하다가 DFS를 역행할 때 스택에서 꺼내면 된다.
예제 6-9를 보면 5번과 10번은 진행하다 동일한 블럭이 발견되는 것을 확인 할 수 있다.
이럴 경우 사이클이 탐지되었다고 본다.
5번의 경우 BB3으로 되돌아가는 사이클이 탐지되어 제거 후 재진행 하였고, 10번에서 다시 BB7로 되돌아가는 사이클을 탐지하였다.
6.4.3 데이터 흐름 분석
1.
도달 정의 분석
도달 정의 분석이란 '해당 프로그램에서 정의된 데이터가 특정 지점에 도달할 수 있는가?' 에 대한 충족 여부를 보는것이다.
데이터가 특정 지점에 '도달'한다의 의미
특정 변수(레지스터 또는 메모리 주소)에 할당된 값이 다른 작업 수행하는 동안 덮어씌워지지 않고
해당 위치까지 유지될 수 있는것을 의미
C
복사
해당 분석 방법은 각각의 개별 기본 블록에 대해 해당 블록을 생성(generate)하는 것과 소멸(kill)하는 것을 기반으로 정의한다.
BB3 블록의 x와 z는 6,8라인(gen[BB3] = {6,8})에서 생성되며,
1,3,4 라인(kill[BB3] = {1,3,4})에서 소멸된다
이후 기호부분 이해가 안감;
2.
use-def 체인
use-def 체인이란 프로그램 내 어떤 변수가 사용됐다면, 어딘가에는 분명히 해당 변수의 정의가 먼저 나왔을 것이라는 점에서 착안한 기법이다.
그림 6-12에서 x는 1,6라인에서 정의된다(ud[x] = {1,6} ).
y는 2,7라인에서 정의된다 (ud[y] = {2,7})
z의 use-def가 존재하지 않는 이유는 3라인에서 정의만 되고 사용되지 않기 때문이다.
해당 기법은 디컴파일, 상수 전파(constant propagation)에서 주로 사용된다.
디컴파일 시에 cmp x,5와 je 이라는 명령어를 확인 한 후 이를 if(x==5)라고 판단하며,
상수 전파시에 특정 변수가 하나로만 결정 된다면, 상수로 처리해 버리는 기능이다.
c.
프로그램 슬라이싱
슬라이싱이란 프로그램의 특정 지점에서 선택된 변수 집합에 기여하는 모든 명령어를 추출하는것이 목적인 데이터 흐름 분석 기법이다.
해당 기법은 선제적인 연구 주제로 다뤄지고 있다. 해당 기법에 더 깊이있게 살펴보고 싶다면 역공학 프레임워크인 angr을 참조하길 바란다.
예제 6-10에서 14라인의 y값에 기여하는 슬라이스는 1,2,5,6,7라인이다.
6라인의 x의 경우 반복문에 기여하여 슬라이스에 포함된다.
해당 기법과 반대로 수행되는 역방향 슬라이싱도 존재한다.
6.5 디스어셈블 시 컴파일러 설정 효과
컴파일러는 코드 최적화 기능을 가지고 있어 최적화 적용 시 바이너리의 실행 시간, 크기 등을 단축시켜 준다.
최적화 적용 된 바이너리는 아닌 바이너리보다 분석에 더 어려움을 가지고 있다.