이전 장에서는 Holstein 모듈의 Use-after-Free를 악용하여 권한 상승을 수행했습니다. 삼세판이라더니, Holstein 모듈의 개발자는 세 번째 패치로 모듈을 수정하고 Holstein v4를 공개했습니다. 개발자 왈, 더 이상 취약점은 없으며 앞으로 업데이트도 중단한다고 합니다. 본 장에서는 최종 버전인 Holstein 모듈 v4를 exploit 해보겠습니다.
패치 분석
최종 버전 v4는 여기에서 다운로드할 수 있습니다. 먼저 v3와의 차이점을 살펴보겠습니다.
먼저 시작 스크립트 run.sh가 멀티 코어에서 동작하도록 변경되었습니다.
1 | - -smp 1 \ |
프로그램 쪽은 메모리 릭과 Use-after-Free가 수정되었습니다.
첫 번째는 open으로, 이미 누군가가 드라이버를 열고 있을 때는 변수 mutex가 1이 되어 open이 실패하도록 설계되어 있습니다.
1 | int mutex = 0; |
즉, open 중에 다시 드라이버를 여는 것은 불가능해졌습니다. 열려 있는 파일 디스크립터를 close하면 mutex가 0으로 돌아가고, 다시 open이 가능해집니다.
1 | static int module_close(struct inode *inode, struct file *file) |
취약점은 어디에 있을까요? 잠시 생각해 보세요.
Race Condition
이번 드라이버의 구현은 완벽해 보일지 모르지만, 사실 아직 여러 프로세스가 리소스에 접근하는 상황을 완전히 고려하지 못했습니다.
OS는 여러 프로세스(스레드)를 동시에 실행할 수 있도록 컨텍스트 스위칭을 구현하고, 멀티 스레드로 여러 프로그램을 실행할 수 있도록 프로세스를 관리합니다. 컨텍스트 스위칭이 발생하는 타이밍은 함수와 같은 큰 입도가 아니라 어셈블리 명령 단위에서의 전환[1]이 됩니다. 당연히 module_open 함수 실행 중에 컨텍스트가 전환될 가능성도 있는 것입니다.
이 장에서는 이러한 멀티 스레드/멀티 프로세스에서 발생하는 경합 문제(Race Condition)를 악용하여 exploit을 작성해 보겠습니다.
발생 조건
먼저 레이스 컨디션이 어떤 결과를 낳는지 생각해 봅시다. 예를 들어 다음과 같은 실행 순서로 컨텍스트가 전환된 경우를 생각해 보겠습니다.
처음에 mutex에는 0이 들어 있으므로 스레드 1의 조건 분기에서 점프가 발생하고, g_buf를 할당하는 경로에 도달합니다. 게다가 파란색 명령으로 g_buf에 주소가 들어갑니다.
다음으로 컨텍스트 스위칭이 발생하여 실행이 스레드 2로 전환됩니다. 스레드 2 단계에서는 mutex에 1이 들어 있으므로 조건 분기에서는 점프가 발생하지 않고, EBUSY를 반환하는 경로에 도달하여 open이 실패합니다.
따라서 이 예에서는 module_open이 개발자가 기대한 대로 작동하고 있습니다.
다음으로 아래 그림의 실행 순서를 생각해 봅시다.
앞서와 마찬가지로 스레드 1에서는 g_buf를 할당하는 경로에 도달합니다. 하지만 이번에는 mutex에 1을 넣기 전에 컨텍스트 스위칭이 발생합니다.
그러면 스레드 2의 조건 분기 단계에서는 아직 mutex에 0이 들어 있기 때문에 g_buf를 할당하는 경로에 도달합니다. 그리고 파란색 명령으로 g_buf에 할당된 주소가 들어갑니다.
당연히 그 후 컨텍스트 스위칭이 발생하여 스레드 1로 실행이 전환되는데, 스레드 1은 버퍼를 할당하고 빨간색 명령으로 주소를 g_buf에 저장합니다.
그러면 어느 스레드에서도 open이 성공해 버리고, 스레드 1이 할당한 주소를 양쪽 스레드에서 모두 사용할 수 있는 상태가 됨을 알 수 있습니다.
이처럼 커널 공간의 코드를 설계할 때는 항상 멀티 스레드를 고려하여 설계하지 않으면 버그가 발생하게 됩니다.

변수 mutex의 읽기/쓰기에 atomic한 연산을 사용하지 않은 것이 원인이 되어 발생한 충돌이네.
open이 2회 성공하면 한쪽에 대해 close가 호출되더라도 g_buf는 해제된 포인터를 가리킨 채로 있으므로, 앞 장과 동일한 Use-after-Free를 일으킬 수 있습니다.
Race Condition 성공시키기
먼저 open의 Race Condition이 정말 가능한지 코드를 작성하여 알아봅시다.
여러 스레드에서 open을 연달아 호출하면 쉽게 Race Condition이 발생하지만, Race Condition에 성공했는지를 판정하지 않으면 루프를 빠져나올 수 없습니다. 판정 방법은 다양하지만, 기본적으로는 2개의 스레드에서 read하여 둘 다 성공하면 Race Condition에 성공했다고 판단하는 방법이 타당할 것입니다. 또한 이번에는 불필요한 read 호출을 줄이기 위해 파일 디스크립터를 확인하기로 했습니다. 왜냐하면 2개의 스레드에서 open에 성공한 경우, 반드시 어느 한쪽의 파일 디스크립터는 4가 될 것이기 때문입니다.
필자는 다음과 같이 동일한 함수를 2개의 스레드에서 실행하여 Race Condition을 만들 수 있는 코드를 작성했습니다. 물론 메인 스레드에서 루프를 돌려도 상관없으며, 판정 방법은 여러분이 원하는 대로 설계해도 됩니다. 또한 컴파일 옵션에 -lpthread를 추가하여 libpthread를 링크하는 것을 잊지 않도록 주의합시다.
1 | void* race(void *arg) { |
이제 거의 100% 확률로 Race에 성공하는 것을 알 수 있습니다. Race 성공까지 걸리는 시간도 밀리초 단위로, primitive로서 충분히 사용할 수 있는 수준이 되었습니다.
데이터 경쟁이란 메모리 내의 특정 위치의 데이터를 2개의 스레드가 동시에(비동기적으로) 접근하는(적어도 한쪽은 쓰기) 상태를 말합니다. 따라서 데이터 경쟁은 미정의 동작(undefined behavior)을 일으킵니다. 데이터 경쟁은 적절한 배타 제어나 아토믹 연산을 통해 해결할 수 있습니다.
반면 경쟁 상태는 멀티 스레드의 실행 순서에 따라 다른 결과가 생성되는 상태를 말합니다. 경쟁 상태는 로직 버그 등과 마찬가지로 「프로그래머가 그렇게 작성했기 때문에 그렇게 동작하는」 것일 뿐이며, 예상치 못한 동작(unexpected behavior)은 발생하지만 미정의 동작(sound behavior)이 발생하는 것과는 관계가 없습니다. 멀티 스레드로 인해 프로그래머의 의도에 반하는 결과가 발생한다면, 그때는 경쟁 상태 버그가 있다고 할 수 있습니다.
이번 드라이버에는 구현 실수로 인한 경쟁 상태가 있으며, 버퍼 포인터에서의 데이터 경쟁이 발생합니다.
CPU와 Heap Spray
이번처럼 멀티 스레드에서 Race Condition exploit을 구현하는 일은 자주 있지만, 이때 주의가 필요한 점이 있습니다.
여러 스레드에서 Race Condition을 일으키고 있다는 것은 공격 시에 여러 CPU 코어가 사용되고 있다는 뜻입니다. 그러면 당연히 어느 한쪽 CPU 코어에서 module_open이 호출되어 kzalloc으로 메모리 영역이 할당됩니다.
여기서 이전에 Heap Overflow 장에서 설명했던 SLUB 할당자의 특징을 떠올려 봅시다. SLUB 할당자에서는 객체 할당에 사용하는 slab을 CPU별 메모리 영역에 관리합니다.
즉, 지금 main 함수가 실행 중인 스레드와 다른 CPU 코어에서 할당된 g_buf가 kfree되면, 당연히 할당 시의 CPU 코어에 대응하는 slab에 링크됩니다. 그러면 그 후에 main 스레드에서 Heap Spray를 수행해도 kfree된 g_buf와 겹치지 않습니다.
따라서 이번과 같은 상황에서는 여러 스레드에서 Heap Spray를 실행하도록 주의합시다.
또한 /dev/ptmx를 열면 새로운 파일 디스크립터가 생성되지만, 하나의 프로세스가 생성할 수 있는 파일 디스크립터의 수에는 제한이 있으므로, 대량의 spray가 필요할 때는 spray가 성공한 시점에서 관계없는 파일 디스크립터를 닫는 등의 요령도 필요합니다.
1 | void* spray_thread(void *args) { |

sched_setaffinity 함수를 사용하면 스레드가 사용하는 CPU를 제한할 수 있으니, 코어 수가 늘어나도 2코어일 때와 비슷한 동작이 돼.
권한 상승
이제 지금까지와 동일한 절차로 권한 상승을 수행하면 됩니다.
데이터 경쟁으로 인해 Use-after-Free를 일으키고, 그곳에 Heap Spray로 tty_struct를 올리는 일련의 흐름을 함수로 만들면, 여러 번 Use-after-Free를 일으키는 것을 쉽게 작성할 수 있습니다.
샘플 exploit은 여기에서 다운로드할 수 있습니다.
Race Condition exploit은 디버깅이 어렵기 때문에, 초기 단계에서 이론을 실현할 수 있는지, 그리고 높은 확률로 안정적으로 race를 일으킬 수 있는 primitive를 만들 수 있는지가 exploit 개발의 핵심이 됩니다.
CPU에 따라서는 최적화를 위해 명령의 실행 순서가 바뀌는 등 더 낮은 입도의 이야기도 있지만, 이번에는 관련이 없으므로 설명하지 않습니다. ↩︎