</>PatchNote
목록으로

2024-01-20

angr를 이용한 이기종 악성코드 분석

SecuritySymbolic ExecutionangrBinary Analysis

학부연구생 시절, NSRI의 위탁과제(이기종 플랫폼 악성코드 대상 기호실행 적용)에 참여했었다. 내용은 하나의 플랫폼이 아니라 다양한 언어·런타임(C, .NET/C#, Delphi, Go) 으로 작성된 바이너리 — 특히 악성코드 — 를 기호실행으로 분석할 수 있는지 확인하는 것이었다.

여기서는 개념을 짧게 짚고, 언어별로 실제로 부딪힌 문제들을 정리한다.

기호실행이란

보통의 실행(concrete execution)은 구체적인 입력으로 하나의 경로를 따라간다. 반면 기호실행은 입력을 구체적인 값이 아니라 기호값(symbol) 으로 두고 프로그램을 따라가며, 각 변수와 표현식을 그 기호에 대한 식으로 표현한다. 분기를 만나면 각 분기가 성립하기 위한 제약조건(path constraint) 이 쌓이고, 경로 끝에서 이 제약을 Constraint Solver 로 풀면 "이 경로에 도달하려면 입력이 무엇이어야 하는가"를 역으로 구할 수 있다.

y = read();
z = y * 2;
if (z == 12) fail();   // 이 분기에 도달하려면 y == 6
else         printf("OK");

이렇게 버그(또는 악성 행위)를 일으키는 지점으로 가는 입력을 자동으로 역산할 수 있는 것이 기호실행이 취약점·악성코드 분석에 쓰이는 이유다. 다만 분기마다 경로가 갈라지므로, 프로그램이 커지면 경로가 기하급수적으로 늘어나는 경로 폭발(path/state explosion) 이 따라온다. 이번 연구에서 반복적으로 부딪힌 벽도 대부분 여기서 파생됐다.

기호실행 도구 조사

먼저 언어·타깃별로 사용 가능한 기호실행 도구를 조사했다. 대표적인 것만 추리면:

도구타깃
angrlibVEX 기반 — x86, x86-64, ARM, AArch64, MIPS, MIPS64, PPC, PPC64, Java
Pex / IntelliTest.NET Framework
KLEELLVM
S2Ex86, x86-64, ARM (유저·커널 모드)
Manticorex86-64, ARMv7, EVM
Symbolic PathFinderJava Bytecode
BINSECx86, ARM, RISC-V
Tritonx86, x86-64, ARM, AArch64

이 중 다양한 ISA를 폭넓게 지원하는 angr 를 주 도구로 삼아, 언어별로 실제 바이너리에 적용해봤다.

angr로 C 프로그램 분석

가장 먼저 네이티브 C 바이너리로 감을 잡았다. angr의 CFG 분석에는 두 가지가 있다.

  • CFGFast — 정적 분석. 빠르지만 시작 주소를 지정할 수 없고 CFG가 매우 복잡해진다.
  • CFGEmulated — 동적 분석. starts 로 시작 주소를 지정하고, keep_state 로 실행 상태를 유지하며 노드를 방문해 더 정확한 CFG를 만든다.

main 의 시작 주소는 IDA Pro로 확인해 넣었고, angrutilsplot_cfg 로 시각화했다.

import angr
from angrutils import plot_cfg

proj = angr.Project(binary_path, load_options={'auto_load_libs': True})
start_address = 0x00401100  # IDA로 확인한 main 주소
cfg = proj.analyses.CFGEmulated(
    keep_state=True,
    starts=[start_address],
    initial_state=proj.factory.blank_state(),
)
plot_cfg(cfg, cfg_path, asminst=True, vexinst=False)
  • auto_load_libs: 공유 라이브러리 자동 로드 여부 (이 샘플에선 차이가 없었다).
  • asminst=True: 노드에 어셈블리 명령어 표시 (해석 편의).
  • vexinst=False: VEX IR까지 표시하면 노드가 너무 복잡해져 껐다.

angr CFGEmulated로 생성한 C 프로그램의 CFG angr CFGEmulated 로 만든 C 프로그램의 CFG.

네이티브 C는 angr가 가장 잘 다루는 영역이라 큰 마찰 없이 경로를 뽑을 수 있었다. 문제는 그 다음, 런타임이 두꺼운 언어들 에서 시작됐다.

PEX / IntelliTest (.NET)

Pex 는 .NET 프레임워크 프로그램을 대상으로 하는 Parameterized Unit Test 입력 생성기 로, 현재는 Visual Studio Enterprise의 IntelliTest 에 통합되어 있다. 기호실행을 이용해 높은 커버리지를 만족하는 최소한의 입력과 그 결과(반환값·예외 메시지)를 생성해준다.

하지만 Pex는 화이트박스 테스트 도구 라서 소스 코드가 필요하다. 즉, 소스가 없는 바이너리 악성코드에는 그대로 쓸 수 없다. 그래서 dnSpy 로 .NET 실행 파일을 디컴파일한 뒤 Visual Studio로 옮겨 Pex(IntelliTest)를 돌려봤는데, 디컴파일 결과가 원본 소스와 매우 유사해 테스트 자체는 잘 동작했다. 다만 이는 "소스를 복원할 수 있을 때"라는 전제가 붙는다.

Delphi

Delphi 바이너리는 IDA Pro가 일부 함수를 인식하지 못하는 문제 가 있었다. 이때 IDR(Interactive Delphi Reconstructor) 를 쓰면 더 많은 함수가 인식되는 경우가 있었다.

테스트 프로그램(정수 123 입력 시 success, 아니면 wrong)을 분석하며 readln·iotest 를 후킹하고, 비교 대상 주소(0x00429CEC)에 symbolic 변수를 저장하도록 후킹 함수를 만들어 기호실행을 적용했다. 그런데 결과에서 재밌는 현상이 나왔다.

Delphi와 C의 angr 분석 결과 바이트 순서 차이 같은 로직을 Delphi와 C로 각각 작성해 angr로 풀었을 때, 해가 되는 바이트의 순서가 달랐다.

동일한 프로그램을 C로도 작성해 angr로 풀어보니, Delphi와 C의 바이트 순서가 달랐다 (엔디안 관련 문제로 추정). 실제 Delphi 악성코드인 Micropsia 를 분석했을 때는 테스트 프로그램만큼 인식 못하는 함수가 많지 않았고, IDA Pro 결과에 winapi가 다수 포함되어 있어 winapi에 대한 후킹 함수를 구현하면 상당 부분 풀릴 것으로 보였다.

C# (.NET)

C#은 컴파일하면 CIL(Common Intermediate Language) 을 담은 .NET 어셈블리(.exe/.dll)가 나오고, 이것이 CLR 위에서 JIT 로 기계어로 변환되며 실행된다. 그래서 IDA Pro로 열면 네이티브 코드가 아니라 CIL이 보이고, 아주 단순한 프로그램인데도 함수가 수백 개(대부분 sub_)로 잡히며 주소값을 특정하기 어렵다.

실제 C# 악성코드인 Quasar 를 angr로 분석하며, 결과에서 사용된 API를 찾아 후킹하는 방식으로 접근했다. GetProcAddress 등 관련 API를 후킹에 추가하고, 함수 이름 대신 주소로 후킹 하자 오류 메시지가 43개에서 16개로 줄었다.

주소 기반 후킹으로 오류가 43개에서 16개로 감소 proj.hook(0x6b81ceb0, MyLoadLibraryExA()) 처럼 주소로 후킹하자 errored 상태가 크게 줄었다.

그러나 끝까지 풀리지 않은 문제가 남았다.

  • SimulationManagerdeadended 에 상태가 추가되지 않고 경로 탐색이 종료되지 않음.
  • active 상태가 약 500개를 넘으면 VS Code가 강제 종료됨(메모리).
  • active 가 150개쯤부터 SimState가 0x1002eb55 를 벗어나지 못하고, step 디버깅으로 보면 같은 주소로 점프하는 무한 루프 였다. 해당 주소를 회피하면 종료는 되지만 deadended 에는 여전히 추가되지 않았다.
  • 남는 warning의 대부분은 ntdll.dll, mscoree.dll 같은 Windows/.NET 런타임 DLL 이었다.

결국 C#의 벽은 코드 로직이 아니라 런타임·환경을 어떻게 모델링하느냐 였다.

Golang

Go는 이번 연구에서 가장 까다로웠다. Go 바이너리는 main() 이 실행되기 전에 수많은 런타임 함수가 먼저 실행 되고, angr는 entry point에서 시작하면 이 런타임 구간을 빠져나와 main() 에 진입하지 못한 채 경고만 쏟아냈다.

angr가 Go 런타임을 빠져나오지 못하고 멈춘 결과 main() 이전 런타임(runtime.getHugePageSize() 등) 처리에서 막혀 경고만 반복하다 Killed.

접근은 이렇게 잡았다.

  1. main() 함수부터 시작 하도록 SimulationManager를 설정하고 fmt.Fprintln 을 후킹. → main부터는 실행되지만, 출력 처리에서 runtime.print 등이 정상 진행되지 않았다.
  2. 런타임에 대한 SimProcedure 가 필요하다고 판단해 newstack 등을 Procedure로 구현. → Procedure를 넣자 warning 없이 진행됐지만, state가 너무 많아 RAM 부족으로 OS가 종료 (state explosion). 즉 Procedure 방향 자체는 유효했다.
  3. Go의 시작 흐름(osinitschedinit …)과 GMP 스케줄러 를 분석. newstack 에서 스택이 새로 세팅되는 지점의 사이클이 경로 폭발의 원인이었다.

여기서 근본적인 한계가 드러났다. 대부분의 실제 Go 프로그램은 goroutine 을 쓰고, 그러면 Go 스케줄러(GMP: Goroutine·Machine·Processor·LRQ·GRQ) 가 동작하는데, 이 스케줄러를 SimProcedure로 구현하는 난이도가 매우 높다. 게다가 net/http(Python requests 와 Go net 패키지의 구조체 차이), crypto 같은 라이브러리까지 겹치면, 대부분의 Go 악성코드에 기호 실행을 그대로 적용하기는 어렵다 는 결론에 이르렀다.

Symbion: concrete와 symbolic을 교차하기

C#·Go에서 반복된 문제는 결국 하나로 요약된다 — 런타임/환경을 전부 기호로 모델링하기가 너무 비싸다. 이 지점에서 조사한 것이 Symbion(concolic, concrete + symbolic 교차 실행) 기법이다.

Symbion은 angr에서 실제 실행(concrete)기호실행(symbolic) 을 번갈아 수행한다. 디버거로 붙인 실제 프로세스에서 모델링하기 어려운 구간(런타임 초기화, 언패킹, 환경 의존 코드)을 그대로 concrete하게 통과 시킨 뒤, 관심 지점에서 그 상태를 angr의 SimState로 가져와 symbolic 탐색 으로 전환한다. 필요하면 다시 concrete로 동기화한다.

이 방식은 앞서 겪은 "main() 이전 런타임", "환경/런타임 DLL 모델링" 문제를 우회하는 실질적인 방향이었다. 모든 것을 기호로 풀려 하지 않고, 어려운 구간은 실제로 실행 하고 분석 가치가 있는 지점만 기호실행에 맡기는 것이다.

Symbion의 한계

물론 Symbion도 만능은 아니었다.

  • concrete 구간을 돌리려면 디버거로 붙인 실제 실행 타깃 이 필요하다. 분석 환경을 실제로 구동·계측해야 하므로 구성 비용이 든다.
  • concrete 실행은 단 하나의 경로만 따라간다. symbolic 입력을 concrete 구간으로 넘기려면 먼저 구체값으로 concretize해야 하고, 그만큼 그 구간의 경로 다양성은 포기하게 된다.
  • 악성코드의 anti-debugging/anti-analysis 는 붙인 디버거(concrete 타깃)를 탐지·방해할 수 있다.
  • 결정적으로 Windows 환경 이 문제였다. Symbion의 concrete 엔진은 avatar2/GDB 기반이라 사실상 Linux 중심이고, Windows 프로세스를 live로 붙여 concrete ↔ symbolic을 교차하기가 까다로웠다.

Windows 우회: 프로세스 덤프(DMP)를 angr 상태로 올리기

C#·Delphi처럼 Windows 바이너리 에서는 Symbion의 live concrete 실행을 쓰기 어려웠다. 그래서 "라이브 교차" 대신 스냅샷 방식 을 택했다 — 관심 지점까지 프로세스를 실제로 실행한 뒤, 그 순간의 프로세스 메모리 덤프(DMP) 를 떠서 그 메모리를 angr SimState에 통째로 올리는 것이다. 실제 로드된 DLL·런타임·힙이 그대로 담긴 메모리에서 기호실행을 시작하니, "Windows 런타임/환경을 전부 기호로 모델링" 하지 않아도 됐다.

DMP 얻는 법

  1. 디버거나 파일을 실행해 원하는 지점까지 진행한다.
  2. 작업 관리자에서 대상 프로세스를 우클릭 → 덤프 파일 만들기.
    • 디버거로 실행하면 프로세스가 둘 보일 수 있는데, 우클릭 → 속성 에서 실행 파일 경로가 같은 쪽을 고른다.

minidump로 파싱

덤프 파싱에는 skelsec/minidump 를 썼다(다만 약간 수정한 버전). minidump 디렉토리에서 python3 ./setup.py install 로 설치하고, 실행 시 minidump.py 가 안 되면 minidump 로 시도한다.

그리고 parsing.py 에 덤프의 메모리를 angr 상태로 복사하는 헬퍼를 만들어 썼다.

# 덤프에서 가상주소 seg_addr부터 seg_size만큼 읽어 angr state 메모리에 복사
parse_dump(filename, seg_addr, seg_size, state)

정확한 구현은 기억을 더듬어 재구성한 것이지만 역할은 분명하다 — DMP에서 특정 메모리 영역(가상주소 seg_addr, 크기 seg_size)을 읽어 angr state 의 같은 주소에 써넣는 것 이다. 대략 이런 흐름이었다.

def parse_dump(filename, seg_addr, seg_size, state):
    md = MinidumpFile.parse(filename)            # 덤프 로드
    reader = md.get_reader().get_buffered_reader()
    reader.move(seg_addr)                         # 해당 가상주소로 이동
    data = reader.read(seg_size)                  # 실제 메모리 바이트 읽기
    state.memory.store(seg_addr, data)            # angr 상태에 그대로 복사

이렇게 필요한 세그먼트들을 복사해 채우면, 실제 프로세스가 그 시점에 가졌던 메모리를 angr가 그대로 들고 기호실행을 이어갈 수 있다. Symbion의 "concrete로 통과" 를 한 번의 메모리 스냅샷 으로 대체한 셈이다.

정리

  • angr는 네이티브(C) 바이너리에서는 CFG 추출부터 경로 탐색까지 강력했다.
  • 반면 매니지드·런타임 헤비 언어(.NET/C#, Go) 에서는 코드 로직이 아니라 런타임과 환경을 어떻게 모델링하느냐 가 진짜 장벽이었다. C#은 CLR/DLL, Go는 런타임과 GMP 스케줄러가 그 벽이었다.
  • 언어·런타임마다 후킹, SimProcedure, 그리고 concolic(Symbion) 을 조합해야 했고, 반복적으로 만난 근본 문제는 결국 state explosion 이었다.
  • Windows에서는 Symbion의 live 교차가 어려워, 프로세스 덤프(DMP)를 angr 상태로 올리는 스냅샷 방식 으로 우회했다.

코드: github.com/jhgl0419/Symbolic_Execution