Image by vectorjuice
개발을 시작한지 얼마되지 않았을 때는 프로그램에 버그가 생기면 관련된 부분에 로그를 찍거나, 디버그 모드를 통해 코드를 추적해서 원인을 찾아갔다. 돌이켜 보면 근거가 되는 구체적 사실로부터 문제의 원인인 일반적 명제를 유도하는 귀납적 방법만을 사용한 것인데, 그때는 그게 전부인 줄 알았다.
4년차 쯤 되었던 것 같은데 나에게 고난의 시간이 찾아왔다. 퇴사한 선배가 넘겨주고 간 프로젝트를 리딩하게 되었는데 미사일을 시험하는 임베디드 시스템이었다. 어느 날 그 시스템이 테스트 도중 재부팅되기 시작했는데 로그 한 줄 남기지 않고 그냥 재부팅되었다. C언어로 작성된 프로그램이었고 VxWorks라는 실시간 운영체제가 사용되고 있었다. 앞서 말한데로 나는 로그를 기반으로 문제의 단서를 찾아서 문제의 원인을 유도해 나가는데 로그가 없으니 어떻게 문제에 접근해야 할지 막막했다. 사태는 꽤 심각했는데 미사일이라는 크리티컬한 장비에 우리 시스템이 전원을 공급하고 있었고 이런 저런 테스트 절차를 진행하는 도중 미사일의 전원도 함께 내려가 버리기 때문이다. 고객사 입장에서는 컴퓨터를 안전하게 종료하지 않고 전원을 내리는 것과는 비교가 안될 정도의 불안정한 이벤트였다. 그때 부터 매일 일일보고를 하면서 디버깅을 시작했는데 몇 주 간 정말 고생을 했다. 사실 하드웨어적인 결함일 수 있었는데 고객사에서 무조건 소프트웨어 쪽 문제인 것 처럼 닥달하기 시작했다.
처음에는 계속해서 귀납적인 방법을 사용해 단서를 찾아 나섰다. 단서가 될 로그가 없으니, 서울에서 김서방 찾기 수준으로 막연히 소스코드를 이리 저리 뒤져 문제가 있을 만한 곳을 찾기 시작했다. 일일 보고를 해야하니 무엇이라도 해야 했다. “오늘 여기 기능 파트 코드를 보았는데 문제가 보이지 않는다” 와 같은 보고라도 해야 했던 것이다. 아무리 봐도 문제가 보이지 않았다. 뭔가 어설프게 작성되었는데 정작 실행에는 문제 없는 코드만 잔뜩 보였다.
몇 일을 그렇게 했는데 고객사 담당자가 회사로 찾아왔다. 이래서는 안되겠다고 새로운 제안을 해주셨다. 이름하여 “연역적 문제 접근법”을 소개해 주셨다. 지금처럼 코드를 막연히 살피며 단서를 찾지 말고 어떻게 하면 시스템이 재부팅될 수 있는지 이론적 배경을 먼저 찾고, 그 이론적 배경을 만족하는 코드가 있는지 역으로 찾아보자는 것이었다. 그때까지는 아무런 명제 없이 그냥 코드를 뒤졌다면 이제는 어떠한 명제를 가지고 그 명제를 가지는 코드가 있는지 찾자는 것이었다. 완전 새로운 시각이었다. 사실 그때 그분과 함께 일하는게 무척 힘들었는데 지금 돌아보면 무척 감사한 일이 되었다.
이후로 문제 분석의 방향이 완전히 바뀌었다. 이제 코드를 뒤지기 전에 언제 시스템이 소프트웨어적으로 재부팅될 수 있는지 명제를 찾아야 했다. 애석하게도 소프트웨어적으로 버그가 있어서 시스템이 재부팅되는 명제는 아무리 생각해도 생각나지 않았다. 다만 특정한 시스템 문서를 읽다보면 null 포인트 참조 같은 일이 있으면 시스템의 동작을 예측할 수 없다는 문구가 등장하는데 이런 문구에 의지해 보기로 했다. 그러니까 지금 당장 내가 null 포인터를 참조해도 시스템이 재부팅되지는 않지만 어떤 경우에는 예측할 수 없는 상황이 일어나 재부팅될 수도 있지 않을까 하는 기대를 한 것이다. 그래서 당장 생각나는 null 포인트 참조가 있다거나, Division-by-Zero 오류가 생기면 시스템이 재부팅될 수 있겠다는 불안정한 명제를 세웠다. 그리고 그런 일을 일으키는 코드가 있는지 목적을 가지고 코드를 살피기 시작했다. 예를 들면 포인터를 사용하는 모든 코드를 찾아서 null이 할당 될 수 있는 가능성이 있는지 찾는다거나, 나누기 연산이 있는 모든 코드를 찾아서 런타임에 0이 될 수 있는 가능성이 있는지 검토했다. 애석하게도 그런 코드는 없었다. 이 외에도 메모리를 할당하고 해제 하지 않는다거나 메모리를 두번 해제 하는 등의 시스템의 동작을 예측할 수 없다고 문서화되어 있는 몇몇 명제들을 찾아 관련된 코드가 있는지 찾아보았지만 그런 코드는 없었다. 이제 명제를 세우는데 한계가 왔다. 언제 소프트웨어가 크리티컬하게 죽을 수 있는지 생각나지 않았다. 그래서 소프트웨어 코드를 정적, 동적으로 분석해 주는 도구의 도움도 받아 보았다. 그들은 내가 가지지 않은 어떤 명제를 가지고 있을지도 모르니까. 그러나 아쉽게도 그들도 아무런 문제를 찾아 주지 못했다. 그때 느꼈다. ‘아! 이론적인 배경지식이 충분히 있어야 여러 명제를 세워서 코드를 다양한 시각으로 살펴 볼 수 있구나!’
그렇게 몇 일을 보냈다. 그 날도 역시 일일보고를 위해 명제를 고민하던 중, 갑자기 대학생 때 도서관에서 흥미롭게 읽었던 책(해킹 파괴의 광학)에서 본 해킹 기법 하나가 떠올랐다. 버퍼 오버플로우라는 단순한 해킹 기법인데 원리는 간단하다. 우리가 함수를 호출할 때 쓰레드 스택에 함수 실행에 필요한 매개 변수, 지역 변수, 반환 주소 등이 임시로 저장되는데 여기서 반환 주소 영역을 임의로 조작해서 함수 실행을 반환할 때 악성 코드가 실행되게 하는 방법이다. 만약 함수 지역 변수 중에 고정된 크기의 배열이 있고 해당 배열에 UI로 부터 어떤 값을 입력 받아 복사하는데 배열의 크기보다 큰 값의 복사를 허용하는 취약점이 있는 코드가 있으면 입력 값을 적절히 조작해서 스택의 반환 주소 영역을 조작하는 것이 가능하게 되는 것이다. 만약의 반환 주소를 시스템에서 제공하는 reboot 함수 주소로 바꾸면 시스템이 재부팅될 수 있다고 생각했다. 이 내용이 해킹이 아니라 코딩 실수로 일어날 수도 있다는 생각에 무릎을 쳤다. 이제 새롭고도 확실한 명제 하나가 세워졌다.
“함수의 지역 변수 중 고정 크기의 배열이 있고, 이 배열에 배열의 크기보다 큰 값을 복사하는 코드가 존재하며, 우연히 reboot 주소 값을 반환 주소 영역에 복사하면 시스템이 재부팅된다”
일단 관련된 코드를 찾기 전에 진짜 저런 일이 일어날 수 있는지 강제로 모의하는 코드를 만들어 보았다. C언어는 메모리 주소를 직접 스캔하는 것이 자유자재로 가능하기 때문에 스택에서 특정 함수의 반환 주소 영역을 찾는 것이 어렵지 않았다. 그리고 그 영역에 강제로 reboot 함수 주소를 입력하고 함수를 반환(return)해 보았다. 야호! 시스템이 재부팅되었다. 이제 이런 종류의 코드가 존재 할 수 있는 가능성을 찾기만 하면되었다. 이후 과정은 설명이 너무 길어서 간단히 정리한다. 코드를 살펴보니 스택에 할당된 배열에 수신한 대용량 로그를 복사하는 코드가 있었는데, 배열의 크기를 넘어 설 수 있었다. 그리고 수신된 로그 값이 아주 우연히 reboot 주소와 동일한 경우 그런 일이 일어날 수 있다고 합리적인 의심을 할 수 있었다. 드디어 찾았다. 이 내용을 고객사의 높으신 분들 앞에 설명했다. 알아들을 만한 사람이 거의 없었지만 뭔가 그럴듯 했는지 고개를 끄덕였다. 그러나 코드를 수정한 후에도 문제는 계속 발생했고 결국 하드웨어 전원부의 문제로 밝혀 졌었다.
정말 힘들었고, 의심 받아서 억울했지만 돌이켜보면 나에게 중요하고 큰 지혜를 준 프로젝트가 아닐 수 없다. 먼저 로그를 근거로 원인을 찾는 방법 밖에 모르던 나에게 이론적 배경을 기반으로 역으로 문제 코드를 찾는 방법을 가르쳐 주었다. 그래서 이론적으로 아는게 많아야 코드를 다양한 시선으로 바라볼 수 있고 어떤 문제는 해결할 수도 있다는 것을 깨달았다. 마지막으로 책을 읽으면 언젠가 관련 지식이 문득 사용될 수 있음도 깨달았다. 호기심에 읽었던 해킹 책이 보안 분야에 일하지 않는 나에게 사용될 줄 누가 알았겠는가.