플립스위치: 새로운 시스콜 후킹 기술

FlipSwitch는 Linux 커널 방어를 우회하는 새로운 방법을 제시하여 사이버 공격자와 방어자 간의 지속적인 전투에서 새로운 기술을 공개합니다.

10분 읽기내부
플립스위치: 새로운 시스콜 후킹 기술

플립스위치: 새로운 시스콜 후킹 기술

특히 시스콜 핸들러에 대한 포인터를 덮어쓰는 시스콜 후킹은 디아모르핀이나 푸마킷과 같은 리눅스 루트킷의 초석으로, 자신의 존재를 숨기고 정보 흐름을 제어할 수 있게 해줍니다. ftrace 및 eBPF와 같은 다른 후킹 메커니즘도 존재하지만, 각각 장단점이 있으며 대부분 어떤 형태로든 제한이 있습니다. 함수 포인터 덮어쓰기는 여전히 커널에서 시스템 호출을 연결하는 가장 효과적이고 간단한 방법입니다.

그러나 Linux 커널은 움직이는 대상입니다. 커뮤니티는 새로운 릴리스가 나올 때마다 전체 멀웨어 클래스를 하룻밤 사이에 쓸모없게 만들 수 있는 변경 사항을 소개합니다. Linux 커널 6.9가 출시되면서 x86-64 아키텍처의 시스템 호출 디스패치 메커니즘에 근본적인 변화를 도입하여 기존의 시스템 호출 후킹 방식을 효과적으로 무력화시켰습니다.

벽이 가까워지고 있습니다: 고전적인 후킹 기법의 죽음

커널 6.9의 변경 사항의 중요성을 이해하기 위해 먼저 고전적인 방법인 시스템 호출 후킹을 다시 살펴봅시다. 수년 동안 커널은 sys_call_table 이라는 간단한 함수 포인터 배열을 사용하여 시스템 호출을 디스패치했습니다. 커널 소스에서 볼 수 있듯이 로직은 매우 간단합니다:

// Pre-6.9: Direct array lookup
sys_call_table[__NR_kill](regs);

루트킷은 메모리에서 이 테이블을 찾아 쓰기 보호를 비활성화하고 kill 또는 getdents64 같은 시스템 호출의 주소를 자신의 공격자 제어 함수에 대한 포인터로 덮어쓸 수 있습니다. 이를 통해 루트킷은 ls 명령의 출력을 필터링하여 악성 파일을 숨기거나 특정 프로세스가 종료되지 않도록 하는 등의 작업을 수행할 수 있습니다. 하지만 이 메커니즘의 직접성은 약점이기도 했습니다. Linux 커널 6.9에서는 직접 배열 조회가 보다 효율적이고 안전한 스위치 문 기반 디스패치 메커니즘으로 대체되면서 판도가 완전히 바뀌었습니다:

// Kernel 6.9+: Switch-statement dispatch
long x64_sys_call(const struct pt_regs *regs, unsigned int nr)
{
    switch (nr) {
    #include <asm/syscalls_64.h> // Expands to case statements
    default: return __x64_sys_ni_syscall(regs);
    }
}

이러한 변화는 미묘해 보이지만 기존의 시스템 호출 후킹에 치명적인 타격을 입혔습니다. sys_call_table 은 추적 도구와의 호환성을 위해 여전히 존재하지만 실제 시스템 호출 발송에는 더 이상 사용되지 않습니다. 수정 사항은 모두 무시됩니다.

새로운 길을 찾다: 플립스위치 기법

커널은 여전히 어떻게든 원래의 시스템 호출 함수를 호출해야 한다는 것을 알고 있었습니다. 로직은 여전히 존재하며, 단지 새로운 방향성 레이어 뒤에 숨겨져 있을 뿐입니다. 이로 인해 커널의 시스템 호출 디스패처의 컴파일된 머신 코드를 직접 패치하여 새로운 스위치 문 구현을 우회하는 기술인 FlipSwitch가 개발되었습니다.

작동 방식은 다음과 같습니다:

첫 번째 단계는 연결하려는 원래 syscall 함수의 주소를 찾는 것입니다. 아이러니하게도 지금은 없어진 sys_call_table 은 이를 위한 완벽한 도구입니다. 이 표에서 sys_kill 주소를 조회하여 원래 함수에 대한 신뢰할 수 있는 포인터를 얻을 수 있습니다.

커널 심볼을 찾는 일반적인 방법은 kallsyms_lookup_name 함수입니다. 이 함수는 내보낸 커널 심볼의 주소를 이름으로 찾는 프로그래밍 방식을 제공합니다. 예를 들어 kallsyms_lookup_name("sys_kill") 을 사용하여 sys_kill 함수의 주소를 얻을 수 있으므로 sys_call_table 을 직접 디스패치에 사용할 수 없는 경우에도 유연하고 안정적으로 함수 포인터를 얻을 수 있습니다.

kallsyms_lookup_name 은 일반적으로 기본적으로 내보내지 않으므로 로드 가능한 커널 모듈에 직접 액세스할 수 없다는 점에 유의해야 합니다. 이 제한은 커널 보안을 강화합니다. 그러나 kallsyms_lookup_name 에 간접적으로 액세스하는 일반적인 방법은 kprobe 을 사용하는 것입니다. 알려진 커널 함수에 kprobe 을 배치하면 모듈은 kprobe의 내부 구조를 사용하여 원래 프로브된 함수의 주소를 도출할 수 있습니다. 이로부터 kallsyms_lookup_name 에 대한 함수 포인터는 프로브된 함수의 주소와 관련된 주변 메모리 영역을 조사하는 등 커널의 메모리 레이아웃을 면밀히 분석하여 얻을 수 있는 경우가 많습니다.

/**
 * Find the address of kallsyms_lookup_name using kprobes
 * @return Pointer to kallsyms_lookup_name function or NULL on failure
 */
void *find_kallsyms_lookup_name(void)
{
    struct kprobe *kp;
    void *addr;

    kp = kzalloc(sizeof(*kp), GFP_KERNEL);
    if (!kp)
        return NULL;

    kp->symbol_name = O_STRING("kallsyms_lookup_name");
    if (register_kprobe(kp) != 0) {
        kfree(kp);
        return NULL;
    }

    addr = kp->addr;
    unregister_kprobe(kp);
    kfree(kp);

    return addr;
}

kallsyms_lookup_name 주소를 찾은 후 이를 사용하여 후크를 배치하는 프로세스를 계속하는 데 필요한 심볼에 대한 포인터를 찾을 수 있습니다.

대상 주소를 확보한 다음에는 시스템 호출 디스패치 로직의 새로운 홈인 x64_sys_call 함수에 주목합니다. 원시 머신 코드를 바이트 단위로 스캔하여 호출 명령을 찾기 시작합니다. x86-64에서는 호출 명령어에 특정 1바이트 연산자 코드가 있습니다: 0xe8. 이 바이트 뒤에는 4바이트의 상대 오프셋이 오는데, 이 오프셋은 CPU에 점프할 위치를 알려줍니다.

바로 여기서 마법이 일어납니다. 저희는 단순한 통화 지침을 찾는 것이 아닙니다. 4바이트 오프셋과 결합하면 앞서 찾은 원래 sys_kill 함수의 주소를 직접 가리키는 호출 인스트럭션을 찾고 있습니다. 0xe8 옵코드와 특정 오프셋의 조합은 x64_sys_call 함수 내에서 고유한 서명입니다. 이 패턴과 일치하는 인스트럭션은 하나뿐입니다.

/* Search for call instruction to sys_kill in x64_sys_call */
    for (size_t i = 0; i < DUMP_SIZE - 4; ++i) {
        if (func_ptr[i] == 0xe8) { /* Found a call instruction */
            int32_t rel = *(int32_t *)(func_ptr + i + 1);
            void *call_addr = (void *)((uintptr_t)x64_sys_call + i + 5 + rel);
            
            if (call_addr == (void *)sys_call_table[__NR_kill]) {
                debug_printk("Found call to sys_kill at offset %zu\n", i);

이 고유한 지침을 찾았다면 삽입 지점을 찾은 것입니다. 하지만 커널의 코드를 수정하려면 먼저 메모리 보호 기능을 우회해야 합니다. 이미 커널(링 0) 내에서 실행 중이므로 고전적이고 강력한 기술인 CR0 레지스터의 비트를 뒤집어 쓰기 보호를 비활성화할 수 있습니다. CR0 레지스터는 기본 프로세서 기능을 제어하며, 16번째 비트(쓰기 보호)는 CPU가 읽기 전용 페이지에 쓰지 못하도록 합니다. 이 비트를 일시적으로 지우면 커널의 메모리 일부를 수정할 수 있습니다.

/**
 * Force write to CR0 register bypassing compiler optimizations
 * @param val Value to write to CR0
 */
static inline void write_cr0_forced(unsigned long val)
{
    unsigned long order;

    asm volatile("mov %0, %%cr0" 
        : "+r"(val), "+m"(order));
}

/**
 * Enable write protection (set WP bit in CR0)
 */
static inline void enable_write_protection(void)
{
    unsigned long cr0 = read_cr0();
    set_bit(16, &cr0);
    write_cr0_forced(cr0);
}

/**
 * Disable write protection (clear WP bit in CR0)
 */
static inline void disable_write_protection(void)
{
    unsigned long cr0 = read_cr0();
    clear_bit(16, &cr0);
    write_cr0_forced(cr0);
}

쓰기 보호를 비활성화하면 호출 명령의 4바이트 오프셋을 자체 fake_kill 함수를 가리키는 새 오프셋으로 덮어씁니다. 사실상 "커널 자체 디스패처 내부의 스위치(" )를 뒤집어 시스템의 나머지 부분은 그대로 둔 채 하나의 시스템 호출을 악성 코드로 리디렉션했습니다.

이 기술은 정확하고 신뢰할 수 있습니다. 그리고 중요한 점은 커널 모듈이 언로드되면 모든 변경 사항이 완전히 되돌아가서 흔적이 남지 않는다는 것입니다.

플립스위치의 개발은 공격자와 방어자 간의 고양이와 쥐의 게임이 계속되고 있다는 증거입니다. 커널 개발자들이 Linux 커널을 계속 강화함에 따라 공격자들은 이러한 방어를 우회할 새롭고 창의적인 방법을 계속 찾아낼 것입니다. 이 연구를 공유함으로써 보안 커뮤니티가 한 발 앞서 나가는 데 도움이 되기를 바랍니다.

멀웨어 탐지

루트킷은 은밀하게 작동하고 보안 도구의 탐지를 회피하도록 설계되었기 때문에 일단 커널에 로드된 루트킷을 탐지하는 것은 매우 어렵습니다. 하지만 저희는 플립스위치의 개념 증명을 식별하기 위해 YARA 시그니처를 개발했습니다. 이 서명은 메모리 또는 디스크에 있는 FlipSwitch 루트킷의 존재를 감지하는 데 사용할 수 있습니다.

YARA

Elastic Security는 이 활동을 식별하기 위해 YARA 규칙을 만들었습니다. 아래는 플립스위치 개념 증명을 식별하기 위한 YARA 규칙입니다.

rule Linux_Rootkit_Flipswitch_821f3c9e
{
	meta:
		author = "Elastic Security"
		description = "Yara rule to detect the FlipSwitch rootkit PoC"
		os = "Linux"
		arch = "x86"
		category_type = "Rootkit"
		family = "Flipswitch"
		threat_name = "Linux.Rootkit.Flipswitch"
		
	strings:
		$all_a = { FF FF 48 89 45 E8 F0 80 ?? ?? ?? 31 C0 48 89 45 F0 48 8B 45 E8 0F 22 C0 }
		$obf_b = { BA AA 00 00 00 BE 0D 00 00 00 48 C7 ?? ?? ?? ?? ?? 49 89 C4 E8 }
		$obf_c = { BA AA 00 00 00 BE 15 00 00 00 48 89 C3 E8 ?? ?? ?? ?? 48 89 DF 48 89 43 30 E8 ?? ?? ?? ?? 85 C0 74 0D 48 89 DF E8 }
		$main_b = { 41 54 53 E8 ?? ?? ?? ?? 48 C7 C7 ?? ?? ?? ?? 49 89 C4 E8 ?? ?? ?? ?? 4D 85 E4 74 2D 48 89 C3 48 85 }
		$main_c = { 48 85 C0 74 1F 48 C7 ?? ?? ?? ?? ?? ?? 48 89 C7 48 89 C3 E8 ?? ?? ?? ?? 85 C0 74 0D 48 89 DF E8 ?? ?? ?? ?? 45 31 E4 EB 14 }
		$debug_b = { 48 89 E5 41 54 53 48 85 C0 0F 84 ?? ?? 00 00 48 C7 }
		$debug_c = { 48 85 C0 74 45 48 C7 ?? ?? ?? ?? ?? ?? 48 89 C7 48 89 C3 E8 ?? ?? ?? ?? 85 C0 75 26 48 89 DF 4C 8B 63 28 E8 ?? ?? ?? ?? 48 89 DF E8 }

	condition:
		#all_a>=2 and (1 of ($obf_*) or 1 of ($main_*) or 1 of ($debug_*))
}

참고 자료

위의 조사에서 참조한 내용은 다음과 같습니다:

이 문서 공유하기