Android Race Condition Exploit - CVE-2022-22057
Root Cause Analysis
kgsl_timeline 객체는 IOCTL_KGSL_TIMELINE_CREATE /IOCTL_KGSL_TIMELINE_DESTROY ioctl을
통해 생성되고 파괴될 수 있다.
struct kgsl_timeline {
/** @context: dma-fence timeline context */
u64 context;
/** @id: Timeline identifier */
int id;
/** @value: Current value of the timeline */
u64 value;
/** @fence_lock: Lock to protect @fences */
spinlock_t fence_lock;
/** @lock: Lock to use for locking each fence in @fences */
spinlock_t lock;
/** @ref: Reference count for the struct */
struct kref ref;
/** @fences: sorted list of active fences */
struct list_head fences;
/** @name: Name of the timeline for debugging */
const char name[32];
/** @dev_priv: pointer to the owning device instance */
struct kgsl_device_private *dev_priv;
};
kgsl_timeline 객체는 dma_fence 객체의 리스트를 fences 필드에 저장한다.
IOCTL_KGSL_TIMELINE_FENCE_GET/IOCTL_KGSL_TIMELINE_WAIT ioctl은 dma_fence 객체들을 해당
리스트에 추가할 수 있다.
추가된 dma_fence 객체들은 refcount가 적용되는 객체이며 refcount는 dma_fence_put 함수를 통해 감소된다.
흥미로운 것은 dma_fence가 refcount를 갖는 것과 별개로,
이들이 저장되는 timeline→fences는 별도의 refcount를 갖지 않는다는 것이다.
대신 timeline→fences 필드 속 dma_fence가 해제되는 것을 방지하기 위해, timeline_fence_release라는 별도의 해제 함수를 만들어 dma_fence가 해제되기 전 timeline→fences로부터 제거한다.
static void timeline_fence_release(struct dma_fence *fence)
{
...
spin_lock_irqsave(&timeline->fence_lock, flags);
/* If the fence is still on the active list, remove it */
list_for_each_entry_safe(cur, temp, &timeline->fences, node) {
if (f != cur)
continue;
list_del_init(&f->node); //<----- 1. Remove fence
break;
}
spin_unlock_irqrestore(&timeline->fence_lock, flags);
...
kgsl_timeline_put(f->timeline);
dma_fence_free(fence); //<------- 2. frees the fence
}
kgsl_timeline→fences에 저장된 dma_fence의 refcount가 0이 될 때, kref_put 함수는 dma_fence 객체를 fences 리스트로부터 제거하기 위해 timeline_fence_release 함수를 호출한다.
dma_fence 객체는 제거된 이후 dma_fence_free를 통해 해제된다.
또한 spinlock을 통해 fences 리스트로부터의 제거를 레이스 컨디션으로부터 보호하고 있다.
하지만 IOCTL_KGSL_TIMELINE_DESTROY ioctl을 통해 dma_fence의 ref count가 0에 된 후에도 timeline_fence_release에서 제거되기 전 dma_fence에 대한 참조를 획득할 수 있다.
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
// critical section 1
spin_lock(&timeline->fence_lock);
list_for_each_entry_safe(fence, tmp, &timeline->fences, node)
dma_fence_get(&fence->base);
list_replace_init(&timeline->fences, &temp);
spin_unlock(&timeline->fence_lock);
// critical section 2
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
kgsl_timeline_put(timeline);
return timeline ? 0 : -ENODEV;
}
kgsl_ioctl_timeline_destroy 함수는 timeline 객체를 파괴하기 위해 다음과 같은 단계를 거친다.
- 앞에서 서술했듯
fences 리스트는 별도의 refcount를 갖지 않기 때문에 앞으로의 작업을 위해
fences를 순회하며 객체 내 모든 dma_fence 객체들의 refcount를 1 증가시킨다.
fences 리스트를 temp라는 새로운 리스트로 복사하며 fences를 초기화한다.
- 다시 순회하며 이번엔
temp속 dma_fence 객체들의 refcount를 1 감소시킨다.
- 마지막으로
timeline 객체의 refcount를 1 감소시키며 종료한다.
해당 로직에서도 spinlock을 통해 레이스 컨디션으로부터 작업들을 보호하고 있다.
하지만 만일 refcount가 0이 되었으나 timeline_fence_release 함수가 아직 dma_fence를 제거하지 않은 상태에서 2단계에 도달하게 된다면 fences 리스트가 temp로 복사될 것이다.
또한 kgsl_ioctl_timeline_destroy 함수가 복사에 앞서 refcount를 증가시켰더라도 이미 호출된 timeline_fence_release 함수가 뒤늦게 dma_fence를 제거한 후 이를 해제하므로 UAF가 발생하게 된다.
위 그림은 레이스 컨디션 상황을 나타내는 그림이다.
붉은 블록은 같은 lock을 보유하는, 즉 상호배타적인 블록이다.
따라서 Thread 2의 timeline_fence_release 함수는 Thread 1의 kgsl_ioctl_timeline_destroy 함수가 해당 함수의 븕은 블록, 즉 fences 필드를 temp로 복사한 후 lock을 해제할때까지 timeline_fence_release 함수의 붉은 블록을, 즉 dma_fence의 제거 및 해제를, 수행하지 못한다.
fences 리스트 속 dma_fence의 수를 늘림으로써 Thread 1의 kgsl_ioctl_timeline_destroy 함수가 fences 리스트를 순회하며 모든 dma_fence 객체들의 refcount를 증가시키는 시간을 늘릴 수 있다.
만일 Thread 1의 븕은 블록이 실행되는 동안, Thread 2에서 fences 리스트 속 마지막 dma_fence 객체의 refcount를 0으로 감소시킨다면 Thread 1에서 순회를 통해 마지막 dma_fence 객체에 도달하여 refcount를 증가시키기 전에 해당 객체의 refcount가 0이 되며 kref_put 함수가 콜백함수로써 timeline_fence_release 함수를 호출할 것이다.
Thread 2의 timeline_fence_release 함수의 코드에 해당하는 붉은 블록은
현재 Thread 1의 븕은 블록이 점유중인 timeline→fence_lock을 점유해야하기 때문에
Thread 1의 븕은 블록의 실행이 끝날 때까지 호출될 수 없다.
그 동안, Thread 1의 fences 리스트의 dma_fence 객체들은 temp 리스트로 옮겨지고 fences 리스트는 초기화된다. 따라서 Thread 2의 붉은 블록이 실행될 때, fences 리스트는 비어있는 상태가 되어 순회를 빠르게 끝내고 dma_fence_free 함수를 호출한다.
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
kgsl_timeline_put(timeline);
return timeline ? 0 : -ENODEV;
}
이후 다시 Thread 1에서 해제된 lock을 다시 점유하여 temp 리스트로 옮겨진 fences 리스트를 순회하며 리스트 속 dma_fence 객체들의 refcount를 1 감소시킨다.
이때 리스트 맨 마지막 객체는 이미 Thread 2에서 해제된 상태이므로 임의의 객체를 재할당하여 그 데이터를 조작할 수 있기 때문에 해당 객체의 refcount를 1로 설정한다면 refcount 감소시, refcount가 0이 되어 timeline_fence_release 함수를 통해 다시 해제하려 할 것이다.
따라서 fences 리스트 속 dma_fence 객체의 수를 늘리기만 한다면 어렵지 않게 race condition를 트리거 할 수 있으며 fences 리스트 속 마지막 dma_fence 객체의 refcount를 감소시킬 수만 있다면 UAF, 더 나아가 DFB를 트리거 할 수 있다.
Proof of Concept
Adding dma_fence
먼저 kgsl_timeline->fences 리스트에 dma_fence 객체들을 race condition을 발생시키기 충분하게 추가해야한다.
이를 수행하기 위한 두가지 방법이 있다.
IOCTL_KGSL_TIMELINE_FENCE_GET
long kgsl_ioctl_timeline_fence_get(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
timeline = kgsl_timeline_by_id(device, param->timeline);
...
fence = kgsl_timeline_fence_alloc(timeline, param->seqno); //<----- dma_fence created and added to timeline
...
sync_file = sync_file_create(fence);
if (sync_file) {
fd_install(fd, sync_file->file);
param->handle = fd;
} else {
put_unused_fd(fd);
ret = -ENOMEM;
}
out:
dma_fence_put(fence);
kgsl_timeline_put(timeline);
return ret;
}
위 함수는 kgsl_timeline_fence_alloc 함수를 호출함으로써 fence를 생성 및 초기화 후,
함수 내에서 다시 kgsl_timeline_add_fence 함수를 호출하여 kgsl_timeline 객체에 생성한 fence 객체를 연결한다.
이후 sync_file_create 함수를 통해 dma_fence 객체에 대한 sync_file의 fd를 얻는다.
또한 해당 fd를 닫는 것으로 dma_fence의 refcount를 감소시킬 수 있다.
IOCTL_KGSL_TIMELINE_WAIT
long kgsl_ioctl_timeline_wait(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
fence = kgsl_timelines_to_fence_array(device, param->timelines,
param->count, param->timelines_size,
(param->flags == KGSL_TIMELINE_WAIT_ANY)); //<------ dma_fence created and added to timeline
...
if (!timeout)
ret = dma_fence_is_signaled(fence) ? 0 : -EBUSY;
else {
ret = dma_fence_wait_timeout(fence, true, timeout); //<----- 1.
...
}
dma_fence_put(fence);
...
}
위 함수는 kgsl_timelines_to_fence_array 함수를 호출하여 위 함수와 마찬가지로 해당 함수 내부에서 kgsl_timeline_fence_alloc 를 다시 호출, 위 함수와 같은 과정으로 fence 객체를 kgsl_timeline 객체에 연결한다.
만일 timeout 값이 0이 아니라면 해당 함수는 dma_fence_wait_timeout 함수를 실행하여
timeout이 만료되거나 interrupt이 발생할때까지 대기한다.
따라서 만일 timeout 값을 매우 크게 설정한다면 interrupt가 발생할 때 timeline에 연결된 dma_fence 객체를 해제할 것이다.
kgsl_ioctl_timeline_fence_get 함수가 활용하기엔 편리하지만 해당 함수에서 dma_fence를
해제하려면 sync_file의 fd을 닫아야하므로 많은 오버헤드가 발생한다.
따라서 해제되어야 하는 마지막 dma_fence는 kgsl_ioctl_timeline_wait 함수로, 그 외에
race condition을 위해 자리를 채워줄 해제되지 않는 dma_fence들은 kgsl_ioctl_timeline_fence_get 함수를 통해 할당시킬 것이다.
Race Condition
Root Cause Analysis 에서 분석했던 내용을 다시 정리하면, Thread 1의 붉은 블록이 실행될 동안
fences 리스트 속 마지막 dma_fence 객체의 refcount를 0으로 만들어 timeline_fence_release
함수가 호출되도록 해야한다.
또한 앞에서 언급했던대로 fences 리스트에 dma_fence 객체를 충분히 연결하는 것으로 붉은 블록의 실행 시간을 늘릴 수 있다.
하지만 Thread 2의 붉은 블록 또한 Thread 1의 나머지 코드가 모두 실행되기 전에 완료되어야한다.
spinlock으로 인해 Thread 2의 붉은 블록의 실행이 끝나기 전까진 Thread 1의 나머지 코드가 실행되지 않냐고 할 수 있지만 가장 중요한 dma_fence_free 함수는 spinlock으로 보호되지 않으며
Thread 2의 순회에서는 fences 리스트가 비어있는 상태이므로 코드 실행이 매우 빠르게 끝나고, 결정적으로 dma_fence_free는 kfree_rcu함수를 사용하므로 객체에 대한 실제 해제가 스케쥴러에 의해
지연된다.
이와 같은 이유로 스케쥴러를 조작하지 않는 이상 적절한 시간내에 마지막 dma_fence 객체를 해제하는 것은 불가능에 가깝다.
이를 해결하기 위해 LSSEU2019 - Exploiting race conditions on [ancient] Linux 에서 소개되었던 기법을 사용할 것이다.
내용을 설명하자면 다음과 같다.
Race Condition in Tiny Race window
리눅스 커널은 각각의 task들의 충분한 CPU 점유 시간을 보장하기 위해 작업 도중 interrupt를 발생시키고 대기시켜 다른 task가 CPU를 점유하도록 할 수 있다.
이것을 선점(preemption)이라고 한다. 또한 task는 스스로 대기하고 다른 task가 CPU를 선점하도록 할 수도 있다. (e.g. I/O 입력을 위한 대기, sched_yield 함수 호출)
이러한 선점은 ioctl 호출과 같은 syscall 안에서도 발생 할 수 있다.
그리고 안드로이드 커널에서는 이러한 선점이 몇몇 특수한 상황(e.g. spinlock 점유)을 제외하고 발생할 수 있다.
이러한 동작은 CPU affinity와 task 우선순위를 통해 조작될 수 있다.
보통의 경우, task는 SCHED_NORMAL 우선순위로 동작한다.
하지만 더 낮은 우선순위인 SCHED_IDLE 또한 sched_setscheduler 함수 (Thread의 경우 pthread_setschedparam 함수)를 통해 적용될 수 있다.
또한 sched_setaffinity 함수를 통해 코드를 실행할 CPU를 지정할 수도 있다.
따라서 두 task를 하나의 CPU에 지정하고 하나는 SCHED_NORMAL 우선순위로,
하나는 SCHED_IDLE 우선순위로 설정하면, 다음과 같은 방법으로 선점 타이밍을 조작하는 것이
가능하다.
SCHED_NORMAL task는 CPU를 양보할만한 syscall을 호출한다. 예를 들어 빈 pipe를 읽는다면
데이터가 들어올때까지 대기하며 CPU를 양보한다. 이에 따라 양보받은 SCHED_IDLE task가 CPU를 점유한다.
SCHED_IDLE task가 SCHED_NORMAL task가 데이터를 기다리고 있는 pipe에 데이터를 전송한다.
이에 따라 더 높은 우선순위를 갖는 SCHED_NORMAL이 대기상태에서 깨어나 SCHED_IDLE task가 점유중인 CPU를 선점한다. 이에 따라 SCHED_IDLE task는 다시 대기한다.
SCHED_NORMAL은 작업을 계속하여 SCHED_IDLE task가 CPU를 넘겨받지 않도록한다.
PoC 코드의 경우엔 다음과 같다.
kgsl_ioctl_timeline_wait 함수를 한 thread에서 호출하여 dma_fence 객체를 kgsl_timeline 객체에 추가한다. timeout을 크게 설정하고 sched_setaffinity 함수를 SPRAY_CPU로 명명할 한 CPU에 이 작업을 고정한다.
IOCTL_KGSL_TIMELINE_WAIT 에서 분석했듯이 timeout이 설정되어 있다면 dma_fence 객체가 추가된 후 timeout이 만료되거나 interrupt가 발생할때까지 대기한다.
SCHED_NORMAL task를 생성하고 DESTROY_CPU 로 명명할 또 다른 CPU에 해당 작업을 고정한다.
이 task는 빈 pipe를 읽어 대기상태에 들어가 더 낮은 우선순위의 task에게 CPU를 양보한다.
이후 빈 pipe에 데이터가 들어오면 이 task는 작업을 계속하여 CPU가 양보되지 않도록 할 것이다.
SCHED_IDLE task를 생성하고 DESTROY_CPU에 고정한다.
이 task는 kgsl_ioctl_timeline_destroy 함수를 호출하여 1단계에서 추가한 dma_fence가 들어있는 kgsl_timeline 객체를 파괴한다. 2단계의 task가 대기상태이므로 DESTROY_CPU는 해당 작업을 먼저 실행한다.
- 다른 CPU에서
kgsl_ioctl_timeline_destroy 함수가 앞에서 언급했던 붉은 블록에서 fences 리스트를 순회 중일때, kgsl_ioctl_timeline_wait 함수를 실행 중인 task에 interrupt를 발생시켜 해당 task의 대기상태를 끝낸다.
- interrupt를 전달받은
kgsl_ioctl_timeline_wait 함수는 할당한 dma_fence에 대한 refcount를 감소시킨다. kgsl_ioctl_timeline_destroy 함수는 fences 리스트를 순회하며 아직 kgsl_ioctl_timeline_wait 이 추가한 dma_fence 객체의 refcount를 증가시키지 못했기 때문에
해당 dma_fence 객체의 refcount는 0이 되어 timeline_fence_release 함수를 호출한다.
물론 아직 spinlock은 kgsl_ioctl_timeline_destroy 가 점유중이므로 실질적 동작을 하진
못한다.
SCHED_NORMAL task가 데이터를 대기중인 빈 pipe에 데이터를 쓴다.
이에 따라 파이프의 데이터를 기다리며 대기하던 SCHED_NORMAL task는 대기상태에서 벗어나 SCHED_IDLE이 점유중인 CPU를 선점한다. 선점한 이후에는 작업을 계속하여 SCHED_IDLE이
CPU를 양보받지 않도록 한다.
SCHED_IDLE task에서 spinlock을 점유중이였던 kgsl_ioctl_timeline_destroy 함수는
fences 리스트의 temp 리스트로의 복사 및 초기화를 마치고 spinlock을 해제하자마자 SCHED_NORMAL task에 CPU를 뺏기고 대기한다.
kgsl_ioctl_timeline_destroy 함수가 spinlock을 해제했으므로 해당 spinlock을 기다리던
timeline_fence_release함수는 해당 spinlock을 점유하여 빈 fences 리스트에 대한 순회를 빠르게 끝내고 dma_fence_free 함수를 호출하여 kfree_rcu 함수로 kgsl_ioctl_timeline_wait 가 할당했던 dma_fence 객체를 해제하려한다. 물론 kfree_rcu 함수를 통한 해제는 실제 해제까지의 딜레이가 있지만 kgsl_ioctl_timeline_destroy 의 대기상태의 지속시간을 조절가능하므로 해당 딜레이는 문제가 되지 않는다.
kfree_rcu 함수의 해제가 완료된 후, SCHED_NORMAL task는 다시 SCHED_IDLE task에 CPU를
양보하여 kgsl_ioctl_timeline_destroy 함수의 나머지 코드를 실행한다.
Object Replacement
해제된 dma_fence 객체는 힙 스프레이에 자주 사용되는 sendmsg를 사용할 것이다.
어떤 객체든 필요한 부분에 임의의 데이터를 쓸 수만 있으면 되므로 그리 중요하진 않다.
이제 재할당된 객체를 어떻게 조작해야하는지 파악해보자.
long kgsl_ioctl_timeline_destroy(struct kgsl_device_private *dev_priv,
unsigned int cmd, void *data)
{
...
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
kgsl_timeline_put(timeline);
return timeline ? 0 : -ENODEV;
}
위 코드는 9단계에서 언급했던 kgsl_ioctl_timeline_destroy 함수의 나머지 코드이다.
위 코드는 현재 재할당된 객체인 fence를 인자로하여 dma_fence_set_error 함수, dma_fence_signal_locked 함수, dma_fence_put 함수를 호출한다.
dma_fence_set_error 함수는 fence 객체에 에러코드를 쓴다. 특정 객체의 특정 필드를 에러코드로 덮어써 익스플로잇에 활용할 수도 있겠으나 이번 PoC에서는 이러한 가능성에 대해 살펴보진 않을 것이다.
다음으로 dma_fence_signal_locked 함수의 코드는 다음과 같다.
int dma_fence_signal_locked(struct dma_fence *fence)
{
...
if (unlikely(test_and_set_bit(DMA_FENCE_FLAG_SIGNALED_BIT, //<-- 1.
&fence->flags)))
return -EINVAL;
/* Stash the cb_list before replacing it with the timestamp */
list_replace(&fence->cb_list, &cb_list); //<-- 2.
...
list_for_each_entry_safe(cur, tmp, &cb_list, node) { //<-- 3.
INIT_LIST_HEAD(&cur->node);
cur->func(fence, cur);
}
return 0;
}
해당 함수는 먼저 fence→flags에 DMA_FENCE_FLAG_SIGNALED_BIT 이 설정되어 있으면 fence 가 신호를 전달받은 것이므로 함수는 일찍 종료된다. (주석에 표시된 1번)
만일 아니라면 list_replaced 함수가 호출되어 fence→cb_list 속 객체들을 제거하고 이를 cb_list에 저장한다. (주석에 표시된 2번)
이후 cb_list 속 객체들의 함수포인터가 호출된다. ( 주석에 표시된 3번)
이를 이용해 임의의 함수를 호출 할 수 있다면 좋겠지만 kCFI가 적용되어 있으므로 관련 함수가 아니라면 호출할 수 없다. 또한 현재로썬 함수 주소를 알지 못하기 때문에 임의의 함수는 커녕 재할당된 객체를 조작하여 crash가 발생하지 않도록 정상적인 함수를 호출하게 할수도 없다.
따라서 fence→flags에 DMA_FENCE_FLAG_SIGNALED_BIT 을 설정하여 빠르게 함수를 종료시키는 수 밖에 없다.
마지막 함수는 dma_fence_put 함수이다. 앞에서 살펴봤던대로 해당 함수는 fence 객체의 refcount가 0이 되면 dma_fence_release 함수를 호출한다.
void dma_fence_release(struct kref *kref)
{
...
if (fence->ops->release)
fence->ops->release(fence);
else
dma_fence_free(fence);
}
dma_fence_release 함수가 호출되면, 언젠간 fence→ops 을 확인하고 fence→ops→release 함수를 호출한다. 이것은 두가지 문제를 발생시킨다.
첫번째 문제는 fence→ops가 유효한 메모리를 가르켜야한다는 것이다. 그렇지 않으면 dereference가
실패한다.
두번째 문제는 dereference가 성공한다고 하더라도 fence→ops→release가 0이 되거나 kCFI에 걸리지 않는 적절한 함수의 주소가 되어야한다.
이러한 문제점을 해결하기 위한 선택지는 두가지가 있다.
표준적인 방법대로 sendmsg 객체 대신 다른 객체를 사용하거나 dma_fence_put 및 dma_fence_set_error 함수가 제공하는 제한적인 쓰기 primitive를 활용해보며 dma_fence_signal_locked 또는 dma_fence_release 함수로 인해 커널이 충돌하는 것을
방지하기 위해 flags와 refcount 필드를 조작하려 해보는 것이다.
또는 다른 것을 시도해볼 수도 있을 것이다.
Android에서 ion 할당자는 커널 드라이버와 유저 프로세스가 같은 메모리를 공유하도록 하는 DMA를 위한 메모리 영역을 할당하는데 사용된다. 이 ion 할당자는 /dev/ion 파일을 통해 신뢰되지 않는 앱에서 접근 가능하며 ION_IOC_ALLOC ioctl을 통해 ion buffer을 할당받을 수 있다.
이 ioctl은 유저에게 fd를 반환하며, 이 fd는 mmap 함수을 통해 ion buffer의 backing store을 userspace에 매핑하는데 사용될 수 있다.
이 ion buffer가 특별한 이유는 유저가 Kernel Low Memory를 요청할 수 있다는 것이다.
커널 메모리는 Kernel Low Memory와 Kernel High Memory로 나뉜다.
이떄 Kernel Low Memory는 Kernel High Memory와 달리 물리주소와의 일대일매핑이 되어 있다.
따라서 해당 영역의 가상주소는 물리적 주소에 고정된 오프셋을 더한 값이다.
ion 드라이버는 연속적인 물리주소를 갖기 위해 부팅 초기에 해당 메모리 영역을 할당하고 이를 메모리 풀로 사용한다. 이 메모리 풀은 이후 요청 시 ion buffer을 할당하는데 사용된다.
물론 ion 장치의 모든 메모리 풀이 연속적인 것은 아니나 ION_IOC_ALLOC ioctl 사용시 특정 heap_id_mask를 전달하여 연속적 물리주소를 요청할 수 있다.
이러한 특징으로 인해 사용 빈도가 낮은 메모리 풀에서 ion buffer 할당을 요청한다면 해당 buffer의 주소를 예측 가능하며, mmap을 통해 해당 버퍼를 userspace로 매핑함으로써 언제든 예측 가능한 주소에 접근할 수 있다.
선행연구의 실험결과 user_contig_region 영역은 거의 사용되지 않으며 전체 영역을 userspace에 매핑할 수 있었다고 한다.
이제 제어할 수 있는 메모리 영역과 그 영역의 주소를 모두 얻었으니 문제를 해결할 수 있게 되었다.
fence→ops가 예측가능한 주소의 ion buffer를 가르키도록 하고 해당 버퍼를 0으로 초기화한다면
앞에서 언급했던 조건을 모두 만족하여 dma_fence_free 함수가 호출되며 fence에 대한 추가적인 DFB primitive를 얻을 수 있다.
하지만 해결해야할 문제가 하나 더 존재한다.
Escaping an infinite loop
spin_lock_irq(&timeline->lock);
list_for_each_entry_safe(fence, tmp, &temp, node) {
dma_fence_set_error(&fence->base, -ENOENT);
dma_fence_signal_locked(&fence->base);
dma_fence_put(&fence->base);
}
spin_unlock_irq(&timeline->lock);
앞에서 루프문 안에서 호출될 함수에 대한 대비는 했지만 문제는 루프문 자체이다.
list_for_each_entry_safe 는 next가 다시 temp를 가르킬 때까지 루프를 반복할 것이다.
struct kgsl_timeline_fence {
struct dma_fence base;
struct kgsl_timeline *timeline;
struct list_head node;
};
자동 변수 초기화로 인해 node 필드를 포함한 모든 필드가 초기화되었기 때문에 직접 유효한 list_head 구조를 만들어줘야한다. 이는 다음과 같은 조건을 만족해야함을 의미한다.
- next 포인터가 유효한 포인터를 가르켜야하며, 루프문 안의 함수들로 인한 크래시를 방지하기 위해선 적절한 fake object를 가르켜야한다.
- 결국 어느 한 next 포인터는 다시 커널 스택에 존재하는 temp를 가르켜야한다.
첫번째 조건은 예측가능한 주소의 ion buffer을 통해 해결할 수 있지만 두번째 조건은 꽤나 까다롭다.
잠시 루프문 안에서 호출되던 함수인 dma_fence_signal_locked 함수로 돌아가보겠다.
int dma_fence_signal_locked(struct dma_fence *fence)
{
...
if (unlikely(test_and_set_bit(DMA_FENCE_FLAG_SIGNALED_BIT, //<-- 1.
&fence->flags)))
return -EINVAL;
/* Stash the cb_list before replacing it with the timestamp */
list_replace(&fence->cb_list, &cb_list); //<-- 2.
...
list_for_each_entry_safe(cur, tmp, &cb_list, node) { //<-- 3.
INIT_LIST_HEAD(&cur->node);
cur->func(fence, cur);
}
return 0;
}
위 함수는 temp 리스트 안에 저장된 두 fence(sendmsg를 통해 재할당된 fence와 ion buffer로 연결된 fence)를 각각 하나씩 인자로 전달받아 호출될 것이다.
앞에서 언급했듯이 3번 코드로 진입하여 cur→func 함수를 호출할 시 크래시가 발생하기 때문에 이는 피해야한다.
따라서 이를 피하기 위해선 cb_list 리스트가 빈 리스트가 되어야 한다.
이를 위해선 자기 자신의 주소를 알아야 하기 때문에 sendmsg를 통해 재할당된 fence에서는 구현하기 힘들지만 예측가능한 주소의 ion buffer에 존재하는 fence에서는 가능하다.
따라서 next 포인터와 prev 포인터 모두 fence.cb_list를 가르키도록 설정한 후 2번 코드로 진입하게 될 경우, list_replace 함수가 먼저 호출되게 된다.
static inline void list_replace(struct list_head *old,
struct list_head *new)
{
//old->next = &(fence->cb_list)
new->next = old->next;
//new->next = &(fence->cb_list) => fence->cb_list.prev = &cb_list
new->next->prev = new;
//new->prev = fence->cb_list.prev => &cb_list
new->prev = old->prev;
//&cb_list->next = &cb_list
new->prev->next = new;
}
위 함수의 호출을 통해 ion buffer 내의 존재하는 필드인 fence→cb_list.prev에 커널 스택 변수인 cb_list의 주소가 쓰여진다.
ion buffer는 mmap 함수를 통해 userspace에 매핑하여 언제든 읽고 쓸 수 있는 메모리 영역이기에 해당 필드에 저장된 커널 스택 주소를 leak하여 temp 리스트의 주소를 계산할 수 있다.
temp 리스트의 주소를 구했으니 이제 이를 다시 ion buffer 속 fake object의 next 주소에 써넣는다면
두번째 조건까지 충족하며 루프를 탈출할 수 있다.
Freelist Hijacking
이제 객체 재할당으로 인해 발생하는 커널 크래시 문제는 모두 해결하였다.
이제 앞에서 언급했던 DFB Primitive를 이용할 차례이다.
DFB Primitive가 존재하므로 같은 메모리 청크에 대한 각각의 핸들러를 같는 두 객체를 할당한 상태에서 하나를 해제하면 해당 객체가 해제되어 freed chunk가 되며 첫 8바이트가 fd 포인터가 될 것이다.
이 상태에서 나머지 한 객체에 대한 접근을 통해 해당 fd 포인터를 덮어씀으로써 다음 동적 할당의 위치를 조작할 수 있다. 물론 여러가지 조건을 충족시켜야하지만 그리 어렵진 않다.
fd 포인터를 조작할 객체로는 signalfd 객체를 사용한다. 해당 객체는 mask를 저장하기 위해 8바이트의 객체를 할당하며, 해당 객체에 저장되는 mask값은 작은 제한으로 유저가 컨트롤할 수 있는 값이다.
객체의 해제 역시 signalfd file이 close 될때 발생하여 수명에 대한 컨트롤도 용의하다.
signalfd를 통해 fd 포인터를 ion buffer 영역 내의 주소로 설정하면 이후 할당되는 힙 메모리에 대한 자유로운 읽기 쓰기가 가능해지므로 exploit에 매우 용이하다.
이 과정의 주요 장애물은 kfree_rcu이다.
앞에서 살펴봤듯, next 포인터에 temp 주소가 쓰이기 전까지, 루프문은 계속 순회한다.
즉 dma_fence_put이 호출되어 kfree_rcu을 호출하고 dma_fence_put이 종료되도 kfree_rcu가 호출된 CPU는 계속 루프를 순회하고 있을 것이고, 해당 CPU가 spinlock을 점유한 상태로 바쁘게 순회를 돌고 있기 때문에 kfree_rcu는 다른 CPU에서 청크를 해제할 것이다.
어느 CPU에서 해제되었는지를 알아야 해제된 청크를 임의의 객체로 재할당할 수 있으므로 이는 exploit의 안정성에 큰 영향을 미친다.
이를 해결하기 위해서, 단순히 각 CPU에 약간의 딜레이를 준 상태로 heap spray를 수행한다.
또한 exploit 설계 초기에 사용했던 sendmsg 객체가 해제된 후에 곧바로 signalfd 객체를 할당하여 제어권을 잃지 않도록 하는 것으로 exploit 안정성을 향상시킬 수 있다.
Device Memory Mirroring Attack
커널 드라이버는 때때로 userspace에 메모리를 매핑해야하며 이를 위해 이들의 구조체가 page 구조체나 sg_table 구조체에 대한 포인터를 갖기도 한다.
이는 ion 드라이버에게도 해당되는데 앞에서 봤듯이 mmap을 통해 userspace에 메모리를 매핑하기 위해 ion_buffer 객체는 sg_table 구조체를 갖는다.
struct sg_table {
struct scatterlist *sgl; /* the list */
unsigned int nents; /* number of mapped entries */
unsigned int orig_nents; /* original size of list */
};
struct scatterlist {
unsigned long page_link;
unsigned int offset;
unsigned int length;
dma_addr_t dma_address;
#ifdef CONFIG_NEED_SG_DMA_LENGTH
unsigned int dma_length;
#endif
};
page_link 필드는 ion_buffer 구조체의 backing store를 가르키는 page 포인터의 인코딩된 형태이다.
int ion_heap_map_user(struct ion_heap *heap, struct ion_buffer *buffer,
struct vm_area_struct *vma)
{
struct sg_table *table = buffer->sg_table;
...
for_each_sg(table->sgl, sg, table->nents, i) {
struct page *page = sg_page(sg);
...
//Maps pages to user space
ret = remap_pfn_range(vma, addr, page_to_pfn(page), len,
vma->vm_page_prot);
...
}
return 0;
}
mmap 함수가 호출되면 다음과 같이 page_link가 가르키는 backing store가 userspace에 매핑된다.
page 포인터는 페이지의 물리적 주소를 시프트 연산한 값에 일정한 오프셋을 더한 값이다.
따라서 해당 구조체 속 page_link를 조작함으로써 원하는 커널 페이지를 userspace에 매핑할 수 있다.
KASLR은 고정된 물리주소와 연결되는 가상주소만을 랜덤화하며 많은 기기에서 커널 이미지는 고정된 물리주소에 매핑되므로 page_link를 조작할 수 있는 것만으로 exploit하기에는 충분하다.
하지만 삼성 기기는 KASLR이 물리주소에도 적용된다. (정확히 말하면, 커널이 인식하는 중간 물리주소는 실제 물리주소가 아닌 하이퍼바이저가 부여한 가상 주소이다.)
따라서 삼성 기기를 대상으로 하는 이번 exploit에서는 물리주소 또한 leak이 필요하다.
struct ion_buffer {
struct list_head list;
struct ion_heap *heap;
...
};
다행히도 ion_buffer는 ion_buffer의 backing store들을 할당하는데 쓰이는 ion_heap 필드를 갖고 있다.
struct ion_heap {
struct plist_node node;
enum ion_heap_type type;
struct ion_heap_ops *ops;
...
}
ion_heap 필드는 해당 객체에 대한 vtable을 가르키는 ops 필드를 갖고 있다.
해당 vtable은 커널 이미지의 global object이므로 다음과 같은 과정을 통해 KASLR을 우회할 수 있다.
struct ion_buffer {
struct list_head list;
struct ion_heap *heap;
unsigned long flags;
...
ion_buffer를 찾는다.
flags 필드를 활용하면 이를 쉽게 할 수 있다.
ION_IOC_ALLOC ioctl을 통해 ion_buffer 객체를 할당할 때, 4바이트의 임의의 데이터인 flags를 magic value로써 전달하여 각 ion_buffer에 고유한 ID를 부여할 수 있다.
이를 이용하여 fake object에 할당된 ion_buffer를 찾을 수 있다.
ion_buffer 객체가 특정되면, 해당 객체의 ion_heap 객체 포인터인 heap 포인터를 읽는다. 해당 포인터는 Kernel Low Memory의 주소이므로 물리주소와의 오프셋은 정적인 상수 오프셋이다.
따라서 손쉽게 해당 주소의 물리주소를 구할 수 있다.
heap 포인터가 가르키는 ion_heap 객체의 물리주소를 구하면, ion_buffer의 sg_table을 조작하여 backing store가 ion_heap이 존재하는 page를 가르키도록 한다.
ion_buffer의 fd로 mmap 함수를 호출하여 ion_heap이 존재하는 page가 userspace에 매핑되도록 한다. 따라서 ion_heap에 존재하는 ops 포인터에 userspace에서 직접적으로 접근할 수 있다.
앞에서 언급했듯 ops 포인터는 커널 이미지의 global object 주소이므로 이를 통해 base 오프셋을 구할 수 있다.
ion_buffer는 또한 다른 문제를 해결할 수 있다.
void kfree(const void *x)
{
struct page *page;
void *object = (void *)x;
trace_kfree(_RET_IP_, x);
if (unlikely(ZERO_OR_NULL_PTR(x)))
return;
page = virt_to_head_page(x);
if (unlikely(!PageSlab(page))) { //<-------- check if the page allocated is a single page slab
unsigned int order = compound_order(page);
BUG_ON(!PageCompound(page)); //<-------- check if the page is allocated as part of a multipage slab
...
}
...
}
만일 fake object의 객체가 해제되면, kfree는 객체를 담고 있는 page가 SLUB allocator의 single page slab인지 PageSlab를 통해 체크한다.
만일 아니라면 PageCompound는 page가 더 큰 slab의 일부인지 확인한다.
이러한 체크들이 페이지에 대한 메타데이터를 포함하는 page 구조체 자체에서 수행되므로, 객체가 해제되자마자 체크들이 실패하며 커널 크래시가 발생할 것이다.
이 문제는 현재 갖고 있는 AAR, AAW 프리미티브를 활용하여 page 구조체의 metadata를 변조함으로써 해결할 수 있다.
이때 물리주소와 대응되는 page 구조체의 주소는 물리주소의 쉬프트 연산과 고정 오프셋으로의 변환으로 쉽게 구할 수 있다.
하지만 만일 fake object의 객체가 해제되지 않도록 보장하면 더 쉽게 해결할 수 있을 것이다.
ion_buffer 구조체가 해제되기 전에, ion_buffer_destroy가 호출된다.
int ion_buffer_destroy(struct ion_device *dev, struct ion_buffer *buffer)
{
...
heap = buffer->heap;
...
if (heap->flags & ION_HEAP_FLAG_DEFER_FREE)
ion_heap_freelist_add(heap, buffer); //<--------- does not free immediately
else
ion_buffer_release(buffer);
return 0;
}
만일 ion_heap 구조체가 ION_HEAP_FLAG_DEFER_FREE 플래그를 갖는다면, ion_buffer는 바로 해제되지 않는다. 대신 ion_heap_freelist_add를 호출하여 ion_heap의 free_list에 추가된다.
ion_buffer 객체는 나중에 필요할때, 그리고 ION_HEAP_FLAG_DEFER_FREE 플래그가 설정됐을 때에만 해제된다.
보통의 경우, ION_HEAP_FLAG_DEFER_FREE 는 ion_heap 객체의 수명 동안 변하지 않는다.
하지만 AAW primitive를 활용하면, ION_HEAP_FLAG_DEFER_FREE를 ion_heap→flags에 추가하고, ion_buffer를 해제한 후, 다시 ION_HEAP_FLAG_DEFER_FREE를 제거하여 ion_heap 객체가 free_list에서 추가되기만 하고 해제되지 않도록 할 수 있다.
더 나아가, ion_heap 객체를 담은 page는 앞에서 KASLR 우회를 위해 userspace에 매핑되었으므로 flag 변조는 쉽게 수행될 수 있다.
fake object를 스프레이하여 ion_buffer와 종속 객체들로 채워, 이러한 객체들이 절대 해제되지 않아 커널 크래시를 발생시키지 않도록 할 수 있다.
SELinux Bypass
SELinux가 활성화되면, permissive 모드 혹은 enforcing 모드를 가질 수 있다.
permissive 모드에선 인가되지 않은 접근을 기록하기만 하고 막진 않는다.
SELinux의 모드는 selinux_enforcing 변수로 설정된다.
만일 해당 변수가 0이라면, SELinux는 permissive 모드로 동작한다.
보통, 보안에 중요한 변수들은 삼성 KDP(Kernel Data Protection)로 보호된다. 변수들은 __kdp_ro 또는 __rkp_ro attribute를 통해 read-only로 설정된다. 이러한 attributte들은 해당 변수들이 read-only page에 존재하고 이들에 대한 수정은 하이퍼바이저 호출로 보호된다. 하지만 본 커널 브랜치에서는 해당 보호기법이 적용되어 있지 않다(???)
따라서 단순히 selinux_enforcing을 0으로 덮어쓰는것으로 SELinux를 permissive 모드로 설정할 수 있다.
SELinux를 우회하는 보편적인 방법이 존재하나, 이번에는 더 간단한 방법이 있으므로 이를 사용한다.
Spawning Root Shell
삼성 기기에서 root 권한을 얻는데에 가장 큰 걸림돌은 RKP (Realtime Kernel Protection)이다. Android 기기에서 root 권한을 얻는 가장 흔한 방법은 exploit을 수행하는 프로세스의 cred 관련 객체를 덮어쓰는 것이다.
하지만 RKP는 이에 대한 변조를 보호하므로 이번엔 다른 방법을 사용한다.
kworker는 root 권한으로 workqueue의 함수들을 호출한다.
우리는 AAW Primitive가 존재하므로 해당 workqueue에 임의의 함수를 삽입하면 kworker는 root 권한으로 이를 호출할 것이다.
하지만 kCFI가 적용되어있으므로 호출할 수 있는 함수는 한정되어 있다.
다행히도 call_usermodehelper_exec_work 함수는 kworker가 호출할 수 있으며 쉘 명령어를 실행시킬 수 있는 함수이므로 이를 활용하하면 임의의 명령어를 root 권한으로 실행시킬 수 있다.
system_unbound_wq를 조작하여 call_usermodehelper_exec_work 함수 포인터를 담고 있는 엔트리를 추가함으로써 모든 보호기법을 우회하고 Root Shell을 실행시킬 수 있다.