열심히 만든 리버싱 문제를 날먹으로부터 지키는 방법

리버싱 문제를 어렵게 만들기 위해 해야 할 것은 무엇일까요?

디컴파일을 방해하거나, 수백개의 함수를 만들거나, 잘 알려지지 않은 언어나 아키텍처를 도입하거나, 혹은 리버싱에서 정말 많이 등장하는 요소인 VM을 도입할 수도 있습니다.

하지만 그런 기법들보다 훨씬 중요한 것은, 혼돈과 확산을 잘 구현한 암호화를 적용하는 것입니다. 암호화 자체가 취약하다면, 당신의 리버싱 문제는 바이너리 분석 없이 10분만에 풀릴 수도 있습니다.

이 글에서는 가상의 리버싱 날먹범 김날먹과 함께, 리버싱 문제를 날먹할 수 있는 2가지 기술을 살펴보고 그 대책을 알아보겠습니다.

날먹 기술 1 - Breakpoint Oracle

김날먹이 CTF에서 어떤 리버싱 바이너리를 열어봤더니 VM으로 플래그를 체크하는 문제였고, opcode가 17개, 바이트코드의 크기는 적당히 1.3KB 정도였습니다. 휴식시간에 잠깐 풀기 좋은 문제죠. 일반적인 풀이 과정은 아마 다음과 같을 것입니다.

  1. VM이 사용하는 컨텍스트 구조체, 메모리, 레지스터 등의 기본 구조를 파악합니다.
  2. Instruction의 디코딩 구조를 파악합니다.
  3. 각각의 opcode가 수행하는 동작을 분석합니다.
  4. 이를 바탕으로 파이썬으로 디스어셈블러 혹은 VM을 구현합니다.
  5. 디스어셈블된 코드나 VM의 로그를 바탕으로 암호화 연산을 분석합니다.
  6. 역연산을 작성하거나 z3-solver로 풀이합니다.

이건 아마도 출제자가 기대하는 정석적인 풀이과정일 것입니다. 하지만 김날먹의 목표는 바이너리를 분석하는 것이 아닌 플래그를 얻는 것입니다.

우선 김날먹은 명령어를 처리하는 VM의 while 루프의 시작 부분에 breakpoint를 걸고 hit한 횟수를 측정하는 GDB 스크립트를 작성했습니다. 이를 이용해 첫 번째 문자에 모든 출력 가능한 ASCII를 대입해보고 결과를 관찰해봤습니다.

A: 2963 hit
B: 2963 hit
C: 2963 hit
D: 2966 hit

D만 횟수가 3 큽니다! 김날먹은 D를 플래그의 첫 번째 문자로 간주하고 다음 문자로 넘어갑니다.

(...생략)
DE: 2966 hit
DF: 2966 hit
DG: 2966 hit
DH: 2969 hit

이번에는 H만 3큽니다. 플래그는 DH로 시작하는군요. 김날먹은 이를 이용하여 불과 몇 분 만에 전체 플래그를 얻어냈습니다.

무슨 일이 일어났을까요?

간단합니다. VM 위에서 동작하는 프로그램은 입력값을 암호화 하고, 마지막 부분에서 그 값을 반복문을 이용하여 검사했던 겁니다. 만약 암호화된 입력값의 첫 번째 바이트가 암호화된 플래그와 일치하지 않으면 즉시 Wrong을 출력하고 반복문을 탈출합니다. 그렇다면, 바이트가 일치하면 명령어의 실행횟수가 늘어날 겁니다.

고급 언어로 보면 이런 식입니다:

for (int i = 0; i < 32; i++) {
    if (encrypted_input[i] != encrypted_flag[i]) {
        puts("Wrong!");
        exit(1);
    }
}
puts("Correct!");

김날먹은 이 점을 이용하여 출제자가 열심히 만든 VM 문제를 사실상 전혀 분석하지 않고 해결했습니다. 물론 다음 전제에 대한 사전 증거는 전혀 없었습니다.

  1. 평문의 i번째 바이트는 암호문의 i번째 바이트에만 영향을 미침
  2. 암호문을 비교하기 전에 실행되는 명령어의 횟수는 항상 동일함

그러나 이 방법은 시도에 드는 비용이 매우 적습니다. 한번 템플릿 스크립트만 만들어두면 VM 문제를 만날 때 마다 루프 주소값만 바꿔서 돌릴 수 있으니까요. 틀렸다면 약간 아쉬운 정도입니다.

VM 문제가 아닌 경우에도 비슷하게 memcmp 혹은 암호문 비교 루프를 공략하면 문제를 쉽게 무력화시킬 수 있습니다.

날먹 기술 2 - Compare Oracle

리버싱 출제자들은 김날먹의 Breakpoint Oracle을 막기 위해 새로운 암호문 비교 방식을 도입했습니다. 이번에는 바이트가 일치하지 않으면 바로 Wrong을 출력하는 것이 아닌, 전체 바이트를 비교한 다음 CorrectWrong을 출력하게 만들었습니다. 이러면 명령어의 실행횟수에 차이가 없어지겠네요!

고급 언어로 보면 이런 식입니다:

int is_correct = 1;
for (int i = 0; i < 32; i++) {
    is_correct &= (encrypted_input[i] == encrypted_flag[i]);
}
if (is_correct) {
    puts("Correct!");
} else {
    puts("Wrong!");
}

하지만 이 정도로는 김날먹을 막을 수 없습니다. 김날먹은 IDA에서 비교 연산에 사용되는 CMP(==) 명령어를 처리하는 부분만 찾아서 비교되는 두 값이 일치한 횟수를 측정하는 GDB Script를 작성했습니다. 암호화된 바이트가 정답과 일치하면 CMP에서 두 값이 일치한 횟수가 더 많아질 것입니다. 명령어가 실행된 횟수가 아닌 비교연산이 옳았던 횟수를 이용하는 것입니다. 출제자들의 대책은 결국 미봉책에 불과했습니다.

비교연산을 CMP가 아닌 ANDXOR로 수행하는 경우는 어떨까요? 비교가 아닌 일반 연산에도 많이 사용되는 명령어이기 때문에 노이즈가 생겨서 어려울 수 있지만, 디버깅을 좀 하고 "마지막 n개의 XOR의 결과가 0이 된 횟수를 측정" 같은 식으로 얼마든지 풀어낼 수 있습니다.

암호화 자체를 개선하지 않는 이상 김날먹은 패턴을 파악하고 유연하게 대응할 수 있습니다.

대응방안

앞서 알아본 2개의 기술은 공격자가 시스템의 반응(오라클)을 이용해 비밀 정보를 추측하거나 유출하는 공격 기법인 Oracle attack에 해당합니다.

리버싱 문제에서 이를 방지하기 위한 방법은 대표적으로 혼돈(Confusion)과 확산(Diffusion)을 충분히 잘 구현한 암호화를 이용하는 것입니다.

  • 혼돈이란 키와 암호문(또는 평문) 사이의 관계를 복잡하게 만들어서, 키가 조금만 바뀌어도 암호문이 전혀 다른 형태가 되도록 하는 것을 말합니다.
  • 확산이란 평문의 각 비트가 암호문 전체에 고르게 퍼지도록 하여, 평문의 구조를 숨기는 것입니다.

쉽게 말하면, 평문과 암호문의 관계를 눈으로는 도저히 파악할 수 없게 만드는 것입니다. 앞서 김날먹이 푼 문제들은 단순히 평문의 첫 번째 바이트가 암호문의 첫 번째 바이트에만 영향을 미쳤습니다. 만약 평문이 1비트만 바뀌어도 암호문이 완전히 달라진다면 김날먹이 사용한 기술들은 완벽히 무력화될 것입니다.

대표적으로는 AES, DES, Blowfish 같은 블록 암호나 RC4, RC5, ChaCha20 같은 스트림 암호가 있습니다. 혹은 직접 만들거나 변형해도 좋습니다. 이러한 암호는 김날먹이 사용한 것과 같은 단순한 Oracle Attack을 효과적으로 방지할 수 있습니다.

현황

현재 많은 워게임 문제 및 CTF 문제가 Breakpoint Oracle 및 Compare Oracle에 취약한 상태입니다. 아무리 복잡한 난독화를 적용해도, 암호화 자체가 취약하면 그 문제는 매우 쉬운 문제가 되어버릴 수 있습니다. 앞으로 이런 설계 이슈가 줄어들어서 리버싱 문제들이 더 재미있어졌으면 좋겠습니다.

작성자 정보
삭제된 댓글
댓글의 내용을 확인할 수 없으며, 답글만 확인할 수 있습니다.
오래간만에 보는 드림핵의 정보? 팁인거 같습니다. 다음 글도 기대 됩니다