[angr] angr가 뭐죠?

배경
바이너리 분석 방법에는 크게 2가지가 있다.
수동: gdb 같은 디버거로 어셈 코드를 한줄씩 실행하면서 레지스터와 메모리의 변환를 추적하는 분석
자동: angr와 같은 자동화된 분석 프레임워크를 사용
이 2가지는 상호보완적이다.
이 글에서는 자동 바이너리 분석 프레임워크 angr에 대해서 알아보겠다.
소개
angr는 바이너리 분석을 위한 다재다능한 파이썬 기반 프레임워크이다. 중요한 철학은 바이너리 분석 작업을 프로그래밍 방식으로 자동화하는 것이다. API를 통해 복잡한 분석 스크립트를 쉽게 작성할 수 있다.
원리
아래 컴퍼넌트 들이 서로 상호작용하며 작동한다.
Loader: 분석할 바이너리를 메모리에 올림, 바이너를 파싱하고 각 세그먼트를 angr가 이해할 수 있는 주소 공간에 매핑한다.
State: 프로그램의 특정 시점 스냅샷이다. angr 분석의 기본 단위이며, 초기 상태(entry_state)에서 시작한다.
Claripy: 솔버다. 입력값처럼 알 수 없는 값을 심볼로 표현하고 프로그램에 가해지는 수학적 논리적 제약 조건들을 추적한다.
SimEngine: State를 입력 받아 코드 한 블록을 실행하고 그 결과로 다음 State들을 만들어낸다. 예를 들어 if문을 만나면 2개의 true, false State를 생성한다.
PathGroup: 탐색할 State를 관리하는 매니저이다.
이런 컴포넌트들이 상호작용하며 바이너리 로드 → 초기 상태 설정 → 심볼릭 실행으로 경로 탐색 → 목표 상태 도달 이라는 분석 과정을 자동화한다.
실습
아래와 같은 C언어 코드를 컴파일해서 angr로 분석해보자.
// gcc -g -o auth_check auth_check.c
#include <stdio.h>
#include <stdlib.h>
void success() {
printf("Success!\n");
}
void failure() {
printf("Failure.\n");
}
int main(int argc, char **argv) {
if (argc < 2) {
printf("Usage: %s <password>\n", argv[0]);
return 1;
}
int key = 0xdeadbeef;
int input_val = atoi(argv[1]);
if ((key ^ input_val) == 0x12345678) {
success();
} else {
failure();
}
return 0;
}
이 프로그램은 0xdeadbeef라는 key 값과 사용자가 입력한 input_val을 XOR해서 결과가 0×12345678과 같은지를 검사한다.
angr를 이용한 자동분석
import angr
import claripy
# 1. 바이너리 로딩
project = angr.Project('./auth_check', auto_load_libs=False)
# 2. 심볼릭 인자 설정 및 초기 State 생성
# argv[1]은 최대 10자리 숫자로 가정 (null 포함 11바이트)
# Claripy를 이용해 심볼릭 비트벡터(변수)를 생성한다.
sym_argv1 = claripy.BVS('sym_argv1', 10 * 8)
# entry_state가 아닌 full_init_state를 사용하면 argc, argv 설정이 더 용이하다.
state = project.factory.full_init_state(args=['./auth_check', sym_argv1])
# 3. SimulationManager 생성 및 탐색
simgr = project.factory.simulation_manager(state)
# success 함수의 주소를 찾아 'find' 대상으로, failure 함수 주소는 'avoid' 대상으로 설정
find_addr = project.loader.find_symbol('success').rebased_addr
avoid_addr = project.loader.find_symbol('failure').rebased_addr
simgr.explore(find=find_addr, avoid=avoid_addr)
# 4. 결과 추출 및 확인
if simgr.found:
found_state = simgr.found[0]
# success 경로에 도달하기 위한 구체적인 입력값을 solver를 통해 얻어낸다.
solution = found_state.solver.eval(sym_argv1, cast_to=bytes)
print("🚀 Success! Angr found the solution.")
# atoi() 함수는 숫자 부분만 인식하므로, null 바이트 이전까지 출력
print(f"🔑 Input value: {solution.split(b'\\x00')[0].decode()}")
else:
print("😥 Could not find the solution.")
위 스크립트를 실행하면 angr가 심볼릭 실행으로 (0xdeadbeef ^ atoi(sym_argv1)) == 0x12345678
라는 제약 조건을 만들고, 솔버가 방정식을 만족하는 값을 찾아준다.
🚀 Success! Angr found the solution.
🔑 Input value: -862328681
로우레벨 관점에서 본 심볼릭 실행
심볼릭 레지스터와 메모리: 레지스터와 스택 변수들은 angr의 State객체 안에서 관리된다. 값이 정해져 있으면 구체적인 값으로 알 수 없는 값이라면 Claripy가 만든 심볼릭 표현으로 저장된다. 그리고
state.reges.eax
같은 코드로 이 값에 접근을 할 수 있다.경로 분기와 제약 조건: cmp 어셈블리어를 만나면 State가 복제된다. fork한다.
한계
angr는 직접 추적하기 어려운 복잡한 경로를 자동으로 탐색하고 답을 구하는 강력한 도구이다.
하지만 아래와 같은 한계점이 있다.
- 상태 폭발: 프로그램이 복잡해지고 분기가 많아지면 탐색할 State의 수가 기하급수적으로 증가하고 분석에 필요한 시간과 메모리가 매우 커지게 된다.
Subscribe to my newsletter
Read articles from random6 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
