
gdb를 사용하여 C 프로그램을 디버깅하던 중 stack smashing detected(지역 배열 범위를 넘어 쓰면서 스택이 망가진걸 런타임이 잡아낸 것)가 발생했다. 프로그램이 스스로 abort()를 호출했고, 그 결과 SIGABRT를 받아 종료되었다. 어디서 터졌는지 보려고 backtrace를 쳐봤다.

backtrace로 확인해본 결과 main함수가 끝나는 줄에서 오류가 발생하고 있었다.
근데 해당 줄은 메모리를 건드리지 않는 줄이므로 gdb옵션 watch를 사용해 실제로 배열 범위를 벗어나 쓰는 첫 지점을 감시할 필요가 있다고 생각했다.

함수에 break point를 걸고 run시켰더니 해당 줄에서 오류가 난다. 따라서 해당 줄의 범위를 수정하면 stack smashing detected 오류를 해결할 수 있다.
다만 이 글의 목적은 오류 해결 방법이 아니므로 여기까지만 적고, stack smashing detected 자체에 대해 이야기하려 한다.
프로그램은 어떻게 위험을 감지하고 스스로 종료할 수 있었을까?
스택 카나리 덕분이다. 카나리는 지역 배열이 있는 함수의 스택 프레임마다 하나씩 존재하며 지역배열과 복귀주소 사이에 위치한다. 들어가는 자리는 프레임마다 다르지만, 값은 프로세스 전체가 공유한다.

이처럼 배열의 마지막칸 바로 뒤에 카나리가 있으므로
배열의 할당된 크기를 넘어서 쓰면 카나리가 그 값으로 덮어씌워진다. 동작 순서는 다음과 같다.
- 함수 시작: 원본 카나리 값을 스택의 카나리 자리에 복사해 둔다.
- 함수 본문 실행: 배열이 넘치면 카나리 자리가 덮어써진다.
- 함수 복귀 직전: 스택의 현재 카나리 값과 원본을 비교한다.
- 값이 다르면
__stack_chk_fail을 호출해 stack smashing detected를 출력하고 프로그램을 abort한다.
이 비교 코드는 컴파일러가 컴파일할 때 함수의 프롤로그와 에필로그에 미리 끼워 넣어 두고 실제 검사는 런타임에서 이뤄진다.

19세기 탄광에서는 이렇게 광부들이 카나리아를 새장에 넣어 데리고 들어갔다. 카나리아는 일산화탄소 같은 유독가스에 사람보다 훨씬 민감하기 때문에 새가 먼저 쓰러지면 유독가스가 유출되고 있다는 뜻이었고 광부들은 미리 대피할 수 있었다.
스택 카나리도 이 어원에서 나온 것으로 프로그램보다 먼저 죽어서 위험을 알리는 존재이다. 복귀 주소가 덮어써지면 공격자가 원하는 곳으로 프로그램을 점프시킬 수 있다(스택 버퍼 오버플로 공격). 카나리가 있으면 복귀 주소에 닿기 전에 카나리부터 망가지므로 공격이 성공하기 전에 프로그램이 스스로 죽는다. 이처럼 스택 카나리는 공격자가 복귀 주소에 손대기 전에 울리는 경보장치인 셈이다.
이렇게 카나리에 대해 탐구하다 보니 궁금해졌다. 그럼 공격 코드가 배열을 넘치게 하면서 카나리 자리에 원래 값을 정확히 다시 써넣으면 검사를 통과하지 않을까?
불가능하진 않지만 쉽지 않다.
우선 카나리는 프로그램이 시작될때마다 새로 뽑는다. 64비트 환경에서 카나리는 8바이트이고, 그중 최하위 1바이트는 항상 0x00이다. 나머지 7바이트(56비트)만 무작위라 해도 찍어서 맞힐 수 있는 크기가 아니다.
최하위 바이트가 0x00인 데에도 이유가 있다. x86-64는 리틀 엔디언(메모리에 데이터를 저장시 가장 낮은 자릿수 바이트부터 낮은 주소에 먼저 저장)이라 최하위 바이트가 메모리상 가장 앞, 즉 배열 바로 뒤에 온다. 버퍼 오버플로는 보통 strcpy 같은 문자열 함수로 일어나는데 이 함수들은 '\0'을 만나면 복사를 멈춘다. 그래서 공격자가 카나리를 원래 값 그대로 써넣으려 해도 첫 바이트인 0x00에서 복사가 끊겨 그 너머의 복귀 주소까지 도달하지 못한다. 반대로 printf("%s", buf)처럼 배열을 문자열로 읽어 나갈 때도 이 0x00에서 멈추므로 카나리가 딸려 새어 나가지 않는다.
그렇다고 뚫을 방법이 아예 없는 건 아니다. 알려진 방법을 개념만 정리하면 이렇다.
fork()로 만든 자식 프로세스는 부모의 카나리를 그대로 물려받는다. 자식이 죽어도 다음 자식의 카나리는 같으니 한 바이트씩 최대 256번 시도해 맞혀 나갈 수 있다. 56비트를 한 번에 맞히는 문제가 바이트당 최대 256번으로 쪼개지므로 보다 풀기 쉬워진다.read()나 memcpy()는 길이만큼 복사하므로 0x00에서 멈추지 않는다. 이 경우 NULL 바이트 방어는 의미가 없다.이렇게 카나리는 값이 새는 순간 무력해지기 때문에 실제 시스템에서는 ASLR, NX 같은 다른 보호 기법과 함께 쓰인다.
이렇게 카나리에 대해 이해하니 처음에 backtrace를 했을때 메세지가 main의 마지막 줄을 가리켰던 것에 대한 의문도 풀렸다. 메모리를 건드리지도 않는 줄에서 왜 죽었나 고민했는데 카나리 검사는 함수가 복귀하기 직전에 돌기 때문이었다. 배열은 이미 중간에서 넘쳐서 망가졌는데 프로그램은 main이 끝날 때가 돼서야 그걸 눈치챈 것이다. 이제 stack smashing detected가 뜨면 backtrace의 줄 번호가 아니라 함수 안에서 지역 배열에 쓰는 코드부터 의심하고 넘치는 첫 지점을 gdb watch로 카나리 자리나 배열 바로 다음 주소를 감시해서 찾는 습관을 들여야겠다.