Windows XP, 2003 이하의 운영체제의 경우 처음으로 로그인하는 사용자의 user-mode 애플리케이션과 함께 서비스들이 모두 세션 0에서 실행되었다. 그 중에는 상승된 권한으로 실행되는 서비스들도 존재하였고 이 서비스들은 권한 상승을 노리는 공격자들의 타겟이 되었다.
이러한 문제를 해결하고자,그리고 세션 0에서 실행되는 서비스들은 상호작용 대상에서 제외시켰다. 이로 인해 더이상 서비스들은 다른 세션에서 실행되는 사용자 애플리케이션과 상호작용 할 수 없게 되었으며, 동시에 사용자 애플리케이션에 포함된 악성 코드로부터 보호되었다. 모든 서비스들과 user-mode 드라이버들은 세션 0에서 실행되도록 분리시키고, 사용자 세션은 세션 1 이후의 세션에서 실행되도록 하였다.
적용 대상
Windows Kernel 6 이상이 탑재되는 Windows 7 이후 운영체제부터 적용
영향
Session 0에서 실행되는 서비스가 UI와 같은 상호작용 기능을 수행할 경우, 서비스는 사용자의 Windows 세션과 다른 세션에서 독립적으로 실행되고 있기 때문에 정상적으로 서비스의 동작을 확인할 수 없다.
Window 메세지 기능을 이용하여 서비스와 애플리케이션 간의 통신을 수행하는 경우, 서비스와 애플리케이션이 서로 다른 메세지 큐를 갖게 되어 정상적으로 메세지 전달이 안된다.
Mitigation
Windows Vista에 내장된 mitigation을 사용할 경우, 특수 desktop 내에서 세션 0과 사용자 간의 상호작용 가능
Windows XP compatibility mode를 사용할 경우, 전역으로 생성된 오브젝트를 이용해 세션 0에서 실행되는 서비스와 사용자 애플리케이션 간의 작업 수행
해결 방안
Client - Server 모델을 적용하여 RPC나 명명된 파이프를 사용해서 서비스와 애플리케이션 간 통신하기
CreateProcessAsUser API를 사용하여 사용자의 세션에서 프로세스를 생성하고 상호작용 하기
간단한 메세지 박스의 경우 WTSSendMessage API를 사용하여 사용자 세션에 알림 전달하기
오브젝트에 명시적으로 Local\이나 Global\ 네임스페이스를 적용하여 서비스에서 사용할 수 있도록 하기
MSDN에서는 프로세스를 간단히 말해 “실행 중인 프로그램”으로 정의하고 있다. 하나의 프로세스는 하나 이상의 스레드(thread)를 갖는데, 이 중 첫 번째로 실행된 스레드를 primary 스레드라고 부른다. 프로세스의 주소 공간에 저장된 코드가 실제로 실행되기 위해서는 이를 실행하기 위한 스레드가 필요하다. 따라서 프로세스가 실행되면 주 스레드가 생성되고, 프로세스 내 모든 스레드가 종료될 경우 프로세스도 종료된다.
Process 구성 요소
프로세스는 크게 두 개의 컴포넌트로 구성된다.
프로세스 커널 오브젝트
사용할 코드와 데이터를 수용하기 위한 주소 공간
프로세스 커널 오브젝트는 프로세스 자체가 아니다. 프로세스를 관리하기 위한 목적으로 운영체제에서 사용되는 커널 오브젝트이며, 프로세스에 대한 통계 정보를 가지는 메모리 블록이다. 따라서 프로세스 생성과 종료 과정 시 가장 처음으로 생성되고, 가장 마지막으로 삭제된다.
각 프로세스는 프로세스 별 주소 공간을 가진다. 이 주소 공간에는 프로세스의 실행에 필요한 코드와 데이터가 저장될 공간이 있으며, 실행에 사용되는 Thread Stack이나 Heap 할당을 위한 메모리 공간도 포함된다.
Process 생성 과정
프로세스 생성 시 CreateProcess API를 이용하여 새로운 프로세스를 생성할 수 있다.
BOOL CreateProcess(
[in, optional] LPCTSTR lpApplicationName,
[in, out, optional] LPTSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCTSTR lpCurrentDirectory,
[in] LPSTARTUPINFOT lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
// T는 사용되는 문자 타입에 따라 None(ANSI)나 W(Unicode)가 될 수 있다.
이 API는 다음의 과정으로 Process를 생성한다.
프로세스 커널 오브젝트 생성
새 프로세스를 위한 가상 주소 공간 생성
생성된 가상 주소 공간에 실행 파일 코드와 데이터, 사용되는 DLL 파일 로드
새 프로세스의 primary 스레드에 대한 스레드 커널 오브젝트 생성
primary 스레드에 의해 C/C++ 런타임 시작 함수 실행
여기서 5번 단계를 거쳐 실행된 C/C++ 런타임 시작 함수는 다음의 작업을 수행한다.
C/C++ 런타임 라이브러리 초기화: 전역변수와 Heap 초기화
코드 상에서 선언된 전역 오브젝트, static 클래스 오브젝트 생성자 호출
이렇게 초기화 과정을 모두 완료하고 난 후 애플리케이션의 진입점 함수를 호출해 본 프로그램을 시작한다.
참고. Linker와 Loader
Linker와 Loader는 운영체제의 구성 요소로 프로그램을 개발하고 실행할 때 사용되는 요소이다. Linker는 프로그램 개발 시 컴파일 단계에서 생성된 여러 오브젝트 파일과 라이브러리 파일들을 하나의 실행 파일로 결합하기 위해 사용된다. Loader는 실행 파일의 코드와 데이터 등을 프로세스의 가상 주소 공간에 로드하기 위해 사용된다.
앞서 언급된 프로세스 생성 과정 중 Linker와 Loader가 기여하는 파트는 다음과 같다.
Linker는 프로세스 생성 과정 중 5번 단계에 사용되는 C/C++ 런타임 시작 함수를 결정한다. 이 때 결정의 기준이 되는 정보가 개발된 프로그램에 대한 Subsystem 링커 스위치이다. 이 링커 스위치는 실행 파일의 형태에 따른 필요 서브시스템 정보를 가지는데, CUI 프로그램일 경우 /SUBSYSTEM:CONSOLE 링커 스위치를 가지고 GUI 프로그램일 경우 /SUBSYSTEM:WINDOWS 링커 스위치를 가진다.
프로그램 빌드 단계에서 Linker는 이 서브시스템 정보를 확인한 후 CONSOLE 링커 스위치가 설정된 경우 프로그램 내 구현된 main이나 wmain 함수를 찾고, 이 함수가 존재하는 경우 mainCRTStartup이나 wmainCRTStartup 함수를 런타임 시작 함수로 설정한다. 만일 서브시스템 정보가 WINDOWS 링커 스위치로 설정된 경우 프로그램 내 WinMain이나 wWinMain 함수가 구현되었는지 확인하고, WinMainCRTStartup이나 wWinMainCRTStartup 함수를 런타임 시작 함수로 설정한다. 이렇게 설정된 런타임 시작 함수가 프로세스 생성 단계 5번에서 실행된다.
Loader는 3번 단계를 수행하는데 핵심적인 역할을 하며, 5번 단계에서 C/C++ 런타임 시작 함수가 실행되기 전 프로그램 실행에 필요한 환경을 구성하는 역할을 한다.
이 때에도 마찬가지로 프로그램의 서브시스템 정보를 확인한다. Windows의 PE 파일 헤더의 필드 중 subsystem 정보를 얻을 수 있는데, 이를 이용하여 실행에 필요한 서브시스템 정보를 얻을 수 있다. 그리하여 서브시스템 정보가 CONSOLE일 경우 프로그램 실행 전에 콘솔 윈도우를 생성하거나 기존에 열려있던 콘솔 윈도우를 사용하여 실행에 필요한 환경을 구성한다. 서브시스템 정보가 WINDOWS라면 곧바로 애플리케이션을 로드한다.
Process 종료 과정
프로세스는 여러 요인에 의해 종료될 수 있다. 정상적으로 프로그램이 시작하고 끝나면서 종료되는 경우도 있겠지만, 프로그램 내에서 호출된 ExitProcess 함수에 의해 종료될 수도 있고 다른 프로세스에서 호출된 TerminateProcess 함수에 의해서도 종료될 수 있다. 이 중 프로그램의 진입점 함수가 반환되면서 프로그램이 graceful 종료되는 상황에 대해 설명한다.
프로세스의 실행 과정을 보면 프로세스가 생성된 후 런타임 시작 함수가 호출되고, 런타임 시작 함수의 실행 과정에서 프로그램의 진입점 함수가 호출되며 프로그램이 실행되는 것을 알 수 있다. 그렇기에 함수의 실행 과정을 고려했을 때 진입점 함수가 반환 된 뒤 프로그램의 제어가 다시 런타임 시작 함수로 돌아올 것임을 알 수 있다. 그렇기에 진입점 함수가 반환 된 후 런타임 시작 함수로 제어가 돌아간 이후 단계부터 정리하였다.
진입점 함수가 반환되면 진입점 함수의 반환값을 인자로 갖는 C/C++ 런타임 라이브러리의 exit 함수를 호출한다. exit 함수는 다음의 작업을 수행한다.
_onexit 함수를 통해 프로그램 종료 시 호출되도록 등록해둔 루틴 수행
모든 전역 클래스 오브젝트와 static C++ 클래스 오브젝트의 파괴자 호출
진입점 함수의 반환값(종료 코드)을 인자로 갖는 ExitProcess 함수 호출
마지막 과정인 ExitProcess 함수를 호출하고 나면 운영체제에 의해 프로세스가 종료된다.
프로세스가 종료된 뒤에는 다음의 작업이 수행된다.
프로세스 내에 남아있는 스레드 종료
프로세스에 의해 할당되었던 모든 사용자 오브젝트와 GDI 오브젝트 삭제
프로세스에서 사용된 모든 커널 오브젝트에 대한 사용 카운트 1 감소
프로세스의 종료 코드를 STILL_ALIVE에서 반환된 종료 코드로 변경
프로세스 커널 오브젝트의 상태를 시그널 상태로 변경
프로세스 커널 오브젝트의 사용 카운트 1 감소
여기서 6번 단계 이후 프로세스 커널 오브젝트의 사용 카운트가 0이 되어야 프로세스의 커널 오브젝트까지 삭제되면서 프로세스의 모든 정보가 온전히 파괴된다. 만약 여전히 다른 프로세스에서 해당 프로세스의 정보를 이용하고자 프로세스 커널 오브젝트에 대한 핸들을 사용 중이라면 사용 카운트가 0이 되지 않아 곧바로 파괴되지 않을 것이다.
커널 오브젝트는 커널에 의해 할당된 메모리 블록으로, 커널에 의해서만 접근 가능한 객체이다. 이를 이용하여 시스템에서 사용되는 여러 자원들을 효과적으로 관리할 수 있다.
커널 오브젝트는 파일 오브젝트, Mutex 오브젝트, Process 오브젝트 등 여러 종류의 형태로 존재한다. 각 오브젝트는 보안과 안정성을 위해 커널에 의해서만 접근 가능하며, 이를 생성 및 조작하기 위해서는 Windows에서 제공하는 API를 이용해야 한다.
각 커널 오브젝트는 형태에 따라 서로 다른 정보를 가진다. 그럼에도 불구하고 다음의 두 정보는 커널 오브젝트 마다 공통적으로 사용된다.
1. 사용 카운트
: 해당 커널 오브젝트를 사용 중인 프로세스의 수. 0 이상의 정수값을 갖는다.
커널 오브젝트를 최초 생성할 경우 사용 카운트값으로 1을 갖는다.
커널 오브젝트를 사용하는 프로세스의 수가 증가할 때마다 사용 카운트값이 1씩 증가한다.
커널 오브젝트의 사용을 종료(프로세스의 종료 또는 CloseHandle(hObj) 호출)할 때마다 사용 카운트 값이 1씩 감소한다.
커널 오브젝트의 사용 카운트값이 0이 되면 해당 오브젝트가 삭제된다. (해당 커널 오브젝트에 대한 사용이 모두 종료되어야 삭제된다.)
2. 보안 디스크립터(SECURITY_ATTRIBUTES 구조체)
: 커널 오브젝트에 대한 소유자, 접근 권한에 대한 정보
커널 오브젝트와 유저 오브젝트/GDI 오브젝트를 구분하는 팁으로, 오브젝트 생성 API에 보안 특성을 지정하는 매개변수가 존재하는지를 확인하는 방법이 있다고 한다.
Handle & Process Handle Table
Windows API를 이용해 커널 오브젝트를 생성하는 데 성공하면 커널 오브젝트를 조작하는 데 사용 가능한 핸들(handle) 값을 얻을 수 있다. 이 핸들값을 다른 Windows API 매개변수로 전달하여 커널 오브젝트를 사용할 수 있다.
생성된 커널 오브젝트에 대한 핸들값은 각 프로세스마다 고유하다. 각 프로세스는 자신만의 독립된 프로세스 핸들 테이블을 갖기 때문에, 프로세스 내에서 생성된 핸들값은 해당 프로세스 내에서 생성된 모든 스레드에서 사용 가능하지만 다른 프로세스에서는 사용할 수 없다. 다음은 프로세스 핸들 테이블의 구조이다.
Process Handle Table
커널 오브젝트에 대한 핸들값은 프로세스 핸들 테이블에 매핑된 인덱스값과도 관련이 있는데, 가령 프로세스 A에서 프로세스 핸들 테이블 내 인덱스 2번을 가리키는 핸들값을 프로세스 B에서 동일하게 사용할 경우 프로세스 B의 프로세스 핸들 테이블 내 인덱스 2번에 다른 타입의 커널 오브젝트가 참조되어 있으면 잘못된 오브젝트를 참조하게 된다.
How to share Handles
앞서 설명한 바와 같이 두 프로세스 간 커널 오브젝트를 공유해야 할 경우 단순히 핸들값을 공유하는 방법은 옳지 않다. 그렇기에 이후 내용에서 서로 다른 두 프로세스 간에 커널 오브젝트를 공유하기 위한 방안을 정리하였다.
1. Kernel Object Handle 상속
이 방법은 커널 오브젝트를 공유하고자 하는 두 프로세스가 Parent - Child 관계일 경우 사용 가능하다. 이 방법은 크게 다음과 같은 단계로 진행된다.
Parent process에서 상속하고자 하는 핸들 설정: 핸들 생성 또는 핸들 속성 변경 시
Child process 생성 시 child process에게 핸들을 상속할 것인지 유무 설정
위 두 단계를 거치고 나면 Parent process의 process handle table 내 상속된 핸들에 대한 레코드가 새로 생성된 Child process의 process handle table 내 동일한 인덱스로 복사된다.
이러한 메커니즘으로 인해 핸들 상속 후 Parent process에서 대상 커널 오브젝트에 대해 사용 중이던 핸들의 사용을 종료하더라도, Child process의 process handle table 내에 커널 오브젝트에 대한 정보가 남아있으므로 Child process에서 상속된 핸들을 통해 여전히 커널 오브젝트에 접근할 수 있다. 또한 상속을 통해 대상 커널 오브젝트를 사용하는 프로세스의 수가 1 증가하였으므로 대상 커널 오브젝트의 사용 카운트값도 1 증가하게 된다.
2. Kernel Object의 이름 이용
이 방법은 커널 오브젝트 생성 시 고유한 이름을 부여하여, 다른 프로세스에서 커널 오브젝트의 이름을 이용해 대상 오브젝트에 대한 핸들을 얻는 방식이다. 이 방식이 가능한 이유는 커널에 의해 할당된 커널 오브젝트들은 타입에 상관없이 동일한 네임스페이스를 공유하기 때문에 서로 다른 프로세스라 하더라도 공유 중인 네임스페이스 내 정의된 커널 오브젝트의 이름을 이용하여 공유할 수 있다. 따라서 하나의 네임스페이스 내에서 명명되는 커널 오브젝트들은 고유한 이름을 가져야 하며, 다른 타입의 커널 오브젝트라 하더라도 서로 다른 이름을 가져야 한다.
만일 명명하고자 하는 커널 오브젝트의 이름이 이미 네임스페이스 내에서 사용 중인 경우 커널 오브젝트의 생성은 실패할 것이다. 이러한 메커니즘을 악용하면, 공유 중인 네임스페이스를 통해 공격 대상 프로세스에서 사용할 커널 오브젝트의 이름을 수집한 후 미리 해당 이름을 갖는 커널 오브젝트를 생성하여 공격 대상 프로세스의 정상 구동을 방해하는 공격(DoS 공격의 일종)에 취약해진다.
따라서 이를 막고자 Private 네임스페이스를 사용하여, 다른 프로세스에서 사용 중인 이름과 충돌되거나 사용되는 이름이 탈취되는 것으로부터 안전해질 수 있다. 이 때 Boundary descriptor를 함께 사용하여 private 네임스페이스에 대한 접근 권한을 설정하는 것으로 private 네임스페이스 자체에 대한 보안을 강화할 수 있다.
추가로, 터미널 서비스를 사용하는 경우에는 모든 클라이언트 세션으로부터 접근 가능한 Global 네임스페이스가 있고, 각 클라이언트 세션별 고유 네임스페이스(Local)가 존재한다. 기본적으로 터미널 서비스에서 사용되는 커널 오브젝트는 Global 네임스페이스 내 명명되고, 각 터미널 서비스 내 애플리케이션에서 사용되는 커널 오브젝트는 Local 네임스페이스에 명명된다. 그렇기 때문에 서로 다른 클라이언트 세션에서 동일한 애플리케이션을 실행하여도 충돌이 발생하지 않을 수 있다.
3. Kernel Object Handle 복사
이 방법은 source process의 process handle table 내에 기록된 커널 오브젝트에 대한 참조를 target process의 process handle table로 복사하는 것이다. 이를 위해 DuplicateHandle API를 사용하면 간편하게 복사할 수 있다.
이 함수를 호출하면 공유하고자 하는 target process에서 사용 가능한 커널 오브젝트의 핸들값을 얻을 수 있다. 만일 source process에서 위 함수를 사용하여 target process를 위한 핸들값을 구했다면 IPC와 같은 프로세스 간 통신 방법으로 target process에 핸들값을 전달하여 커널 오브젝트를 사용하도록 할 수 있다.
이 문제는 실행하면 아래와 같이 딱 중요한 부분만을 가려놓은 윈도우 GUI 프로그램을 제공한다.
문제 설명을 토대로 유추하면.. Graphic 함수를 이용하여 flag를 그리는데, 이 루틴 중 flag를 가리는 부분을 패치하여 플래그를 얻어내는 문제인 것으로 추정된다.
IDA를 통해 문제 파일을 열어보면 Windows GUI 프로그램 국룰 시작 지점인 WinMain 함수를 확인할 수 있다. 그런데 내 IDA는 서버와의 오류 때문에 hex-ray가 되지 않으니 IDA가 찾아준 WinMain의 위치를 이용하여 Ghidra에서 WinMain의 hex-ray 결과를 볼 것이다.
아래는 Ghidra에서 분석해준 WinMain 함수의 hex-ray 결과이다.
MSDN의 API 문서에서 검색하면 알 수 있겠지만..
32줄의 CreateWindowExW 함수를 이용하여 윈도우 창을 실행하고,
이에 앞서 30줄의 RegisterClassExW 함수를 이용하여 CreateWindowEx 함수에서 사용할 창 클래스를 등록한다. 그렇기에 RegisterClassExW의 매개인자로 들어가는 WNDCLASSEXW 타입의 local_a8 변수의 속성을 18~29줄에서 정의하고 있는 것을 알 수 있다.
처음에는 이렇게 창 클래스 정의하는 부분은 단순 속성이라 생각하고 CreateWindowExW 함수를 이용하여 창을 생성하고 난 이후 if 문 안에서 이루어지는 작업이 그래픽 작업을 하는 부분인줄 알고 헤맸던 것 같다. 그런데 원하는 그래픽 작업 흔적이 명확하게 발견되지 않아 찾는 방식을 바꿨다.
Ghidra의 Symbol Tree 부분을 보면 GDIPLUS.DLL 이라고 그래픽 작업 관련 함수를 포함하는 DLL를 발견할 수 있다. 본 프로그램에서 사용되는 dll과 함수라고 생각하고 이 부분이 참조되고 있는 부분을 찾아나갔다.
다 사용되는 것 같긴 했지만, 이 중 GdipDrawLineI 함수에 대한 Reference 내역을 먼저 조회했다.
많은 결과가 나오지만 이 중 가장 첫 번째 항목을 따라갔다.
추적 결과 FUN_140001240 함수에서 사용되고 있었으며,
이 부분의 Call Tree를 따라가보면
다음과 같이 윈도우 창 클래스를 정의하던 부분의 lpfnWndProc 속성에 정의되는 FUN_1400032f0 함수를 통해 Pen 작업이 이루어지고 있음을 알 수 있다.
WNDCLASSEXW클래스의 lpfnWndProc 속성의 경우 창 클래스에 대한 프로시저 포인터를 가진다. 여기에 들어가는 함수는 콜백 함수 형태를 가지며, 윈도우 창에서 사용자에 의한 상호작용이 발생할 경우 이를 어떻게 처리할 것인지를 정의한다. 그렇기 때문에 이 부분에서 창에 보여질 행위 중 하나인 draw 작업이 이루어진 것으로 생각한다.
FUN_1400032f0 함수 내부를 살펴보면
BeginPaint 함수와 EndPaint 함수가 있으며, 이 사이에서 GdipAlloc 후 어떤 작업을 수행하고 GdipFree까지 하고 있다. 그 과정에서 FUN_140002c40 함수가 호출되는데, 이 함수의 내부를 살펴보면
다음과 같이 반복적으로 특정 함수가 호출하는 작업이 이루어짐을 확인할 수 있다.
아래 코드를 보면 초반에는 FUN_140002b80 함수가 반복적으로 사용되고 있고, 그 이후에 서로 다른 함수가 호출되고 있다.
우선 FUN_140002b80 함수의 역할에 대해 보기 위해 내부를 분석했다.
이 함수에서는 GdipCreatePen1 함수 호출 이후 GdipDrawLineI 함수를 이용하여 한번의 직선을 그리고 GdipDeletePen 함수를 이용하여 Pen 작업을 끝낸다.
즉, FUN_140002b80 함수가 한번 실행될 때마다 이 param으로 들어온 값에 따라 한 개의 직선을 그리고 있는 것으로 생각된다.
FUN_140002b80 함수 이후에 차례로 나타나는 여러 함수들의 경우는 조금 다른 코드 패턴을 가진다.
그 중 FUN_140002b80 함수 이후로 가장 처음 나타나는 FUN_1400017a0 함수는 다음과 같이 여러 차례의 GdipCreatePen1 - GdipDrawLineI - GdipDeletePen 과정을 가지며, 여러 선을 그리고 있는 것으로 추정된다.
FUN_1400017a0 함수 이후에 나타나는 여러 함수들도 FUN_1400017a0 함수와 같이 여러 개의 선을 그리는 코드를 가진다.
따라서 FUN_140002b80 함수와 그 이후에 위치하는 FUN_1400017a0 함수와 같은 함수들이 각각 어떤 선을 그리는지 보기 위해 다시 IDA를 이용하여 해당 위치를 동적 분석하였다.
FUN_140002b80 함수와 FUN_1400017a0 함수에 각각 bp를 걸고 실행시켰다.
먼저 처음 호출된 FUN_140002b80 함수의 경우 아래와 같은 선을 그린다.
그리고 그 다음에 호출된 FUN_140002b80 함수 역시 다음과 같은 선을 그린다.
여러 차례 진행되는 FUN_140002b80 함수를 모두 호출하고 나면 다음과 같은 선들이 그려진다.
이 선들이 그려진 곳은 분명 플래그를 가리는 위치이다. 따라서 FUN_140002b80 함수는 플래그를 가릴 때 사용하는 함수로 여길 수 있다.
그리고 그 이후에 bp를 걸었던 FUN_1400017a0 함수를 실행하면 다음과 같이 플래그의 첫글자가 그려진 것을 확인할 수 있다.
이후의 함수들을 모두 호출하면 다음과 같이 모든 플래그가 그려지게 되는 것을 알 수 있다. (비록 가려져 있긴 하지만…)
이를 통해 플래그를 가리는 함수인 FUN_140002b80 함수가 실행되지 않도록 막으면 정상적으로 플래그를 확인할 수 있을 것임을 알았다.
패치를 하기 위해 다음과 같은 방안을 생각해보았다.
FUN_140002b80 함수 시작 위치 어셈을 ret로 패치
FUN_140002b80 함수를 call 하는 위치 어셈을 nop으로 패치
IDA로 패치하려니 계속 권한 오류가 떠서 x64dbg로 패치했다. x64dbg에서 패치하려고 하는 주소로 이동한 후 고안한 방법대로 패치를 진행한다.
1. FUN_140002b80 함수 시작 위치 어셈을 ret로 패치
→ 잘 된다.
2. FUN_140002b80 함수를 call 하는 위치 어셈을 nop으로 패치
→ 잘된다.
사실 두 방법 모두 FUN_140002b80 함수의 실행을 방해한다는 측면에서 같은 방식이긴 하다.
그러나 두번째 방법을 사용할 경우 FUN_140002b80 함수가 실행되는 모든 곳에 대해 패치를 해야하니 더 번거롭기 때문에 굳이 이렇게 할거면 첫 번째 방법이 좀 더 나은 선택지인 것 같다는 생각을 했다.
이렇게 패치를 해도 괜찮은 이유는 다음과 같다.
본 프로그램이 레지스터를 이용하여 인자를 전달하는 x64 프로그램이고, FUN_140002b80 함수 역시 함수 내에서 스택을 정리하는 fastcall 호출 규약을 사용한다. 그렇기 때문에 함수 실행과 관련하여 스택을 직접 손봐줘야할 필요가 없다. 다만 함수 내에서 ret를 이용하여 함수를 종료할 경우 함수 내에서 스택을 사용하기 전에 ret를 넣어줘야 가장 최근에 스택에 넣었던 ret 값을 그대로 다시 가져와 원래 위치로 돌아갈 수 있을 것이다.
FUN_140002b80 함수의 리턴값이 코드의 다른 부분에 사용되지 않기 때문에, 별도로 리턴값을 관리해줄 필요 없이 함수 실행만 막으면 된다.
1번과 관련하여 잘못 패치를 하는 상황을 재현해보았다.
여기에서는 FUN_140002b80 함수의 프롤로그 이후 사용할 스택 메모리를 확보한 상황에서 스택 정리 없이 곧바로 ret를 넣은 경우이다.
FUN_140002b80 함수의 프롤로그 실행 시 ret 값에 의하면 본 함수 종료 후 rip는 7FF63BC22C71 값을 가지며 함수를 call 했던 코드의 다음 코드로 돌아가야 한다.
그러나 지금과 같이 스택 프레임 확보를 위해 rsp값을 조정해주고 난 상황에서 ret를 이용하여 곧바로 pop rip를 수행하게 될 경우 dummy 값이 rip로 들어가 이후 정상적인 프로그램 실행에 문제를 일으키게 된다.
EnablePrefetcher의 값은 0~3 중 하나로 설정할 수 있으며, 각각의 값은 다음과 같은 의미를 가진다.
0: Prefetch 사용 안함
1: ALP만 사용
2: BP만 사용
3 (기본값): ALP와 BP 모두 사용
여기에서 확인할 수 있듯이 prefetch에는 다음의 두 가지 유형이 존재한다. 기본적으로 두 가지를 모두 사용하도록 설정되어 있으나, 필요에 따라 레지스트리의 값을 변경하여 원하는 옵션으로 사용할 수 있다.
ALP(Application-Launch Prefetching): 사용자가 자주 사용하는 응용프로그램의 정보를 prefetching 하는 것으로, 응용 프로그램의 실행 속도를 높일 수 있다.
BP(Boot Prefetching): 부팅 시 사용하는 파일이나 프로그램의 정보를 prefetching 하는 것으로, 부팅 속도를 높일 수 있다.
Prefetching된 데이터는 파일 형태로 저장되어 있다가, 부팅 시 저장해둔 prefetch 파일을 메모리에 로드해두고, 실제 프로그램 사용 시 메모리에서 해당 데이터를 불러와 사용할 수 있게 한다. 저장된 프리패치 파일은 %SystemRoot%\Prefetch 폴더에서 확인할 수 있다.
(Windows 10 기준) Prefetch 폴더 내 저장된 파일 및 폴더 유형은 다음과 같다.
ReadyBoot
Layout.ini
Prefetch Files (.pf)
ReadyBoot 폴더는 Boot Prefetch에 필요한 파일들을 저장하는데 사용된다. 매 부팅 시 Trace#.fx 이름으로 부팅에 필요한 파일 및 데이터 정보가 포함된 파일이 생성된다. 이 파일은 ReadyBoot 폴더 내에 생성되며 가장 최근에 생성된 파일을 기준으로 최대 5개까지 저장된다. 이외에도 1개의 rblayout.xin 파일이 ReadyBoot 폴더 내에 저장되는데, 이 파일을 이용하여 ReadyBoot 시 필요한 정보 및 캐시 파일을 관리한다.
Layout.ini 파일에서는 프리패치 버전과 프리패치 파일의 목록을 확인할 수 있다. Layout.ini 파일은 부팅이나 응용 프로그램 시 참조되는 순서대로 파일의 경로가 기록되며, 약 3일 마다 내용이 업데이트된다. 그렇기 때문에 실제 Prefetch 폴더에 저장된 prefetch 파일의 저장 여부와 일치하지 않을 수 있으며, 오래전에 실행된 적 있는 파일에 대한 항목도 존재할 수 있다.
Prefetch 파일은 한번 이상 실행된 적 있는 응용 프로그램에 대한 데이터가 저장된다. 이러한 점을 이용하여 PC에서 사용자의 응용 프로그램 사용 흔적 또는 악성 프로그램 실행 기록을 추적할 수 있다.
각 프리패치 파일에 포함되는 정보는 다음과 같다.
프리패치 파일의 MAC 타임스탬프
프리패치 파일의 크기
프리패치 파일에 해당하는 프로세스
파일이 실행된 volume 또는 논리 드라이브 경로
프로그램 실행 횟수
프로그램의 마지막 실행 시간에 대한 타임스탬프
프리패치 파일에 의해 로드된 추가 파일
여기에서 파일의 생성 시각과 수정 시각 정보는 각각 다음과 같은 의미로 해석될 수 있다.
파일의 생성 시각: exe 프로그램 최초 실행 시각
파일의 수정 시각: exe 프로그램의 마지막 실행 시각
프리패치 파일은 Prefetch 폴더 내에 모두 존재하며, prefetch 파일의 개수가 운영체제 별 최대 제한 개수에 도달하면 가장 오래전에 실행된 순으로 파일을 삭제한다. 운영체제별로 유지하는 최대 prefetch 파일 개수는 다음과 같다.
Windows XP, 7: 128개
Windows 8, 10: 1024개
Prefetch File Format
Prefetch 파일의 file format은 파일의 압축 여부에 따라 두 가지 형태를 가진다.
Prefetch 파일의 자세한 정보를 얻기 위해서는 압축된 내용을 풀어야 한다. Windows prefetch 압축에는 LZXPRESS Huffman 압축 방식이 사용되며, 원본 내용을 보기 위해 Github에서 공유되고 있는 압축 해제 코드를 이용하여 해제하였다.
압축되지 않은 prefetch 파일의 형식은 크게 File Header와 File Body로 구분할 수 있다. File Header는 운영체제에 관계없이 공통된 구조를 가지지만 Body의 경우 운영체제 버전에 따라 서로 다른 형식을 가진다.
File Header: offset 0x00 (84bytes)
File Body: offset 0x54 ~
먼저, 버전에 관계없이 공통되는 부분인 File Header는 다음과 같은 구조를 가진다.
Offset 0x00 (4bytes): Format Version(Little-Endian)
Offset 0x04(4bytes): File Signature (53 43 43 41)
Offset 0x0C(4bytes): File Size (Little-Endian)
Offset 0x10(60bytes): File Name (실행 파일 이름)
Offset 0x4C (4bytes): Prefetch Hash (Prefetch 파일 이름에 기재된 해시값)
Prefetch Hash는 실행 파일 경로에 대한 해시값을 가지며 Windows 버전에 따라 서로 다른 해시 함수를 사용한다.
Windows XP, 2003: SCCA XP hash function
Windows Vista, 10: SCCA Vista hash function
Windows 2008, 7, 2012, 8: SCCA 2008 hash function
Format Version으로 사용되는 값의 종류는 총 4가지이며, 각각은 다음과 같은 정보를 가리킨다.
0x11: Windows XP, Windows 2003
0x17: Windows Vista, Windows 7
0x1A: Windows 8.1
0x1E: Windows 10
Prefetch 파일은 기재된 format version에 따라 서로 다른 구조를 가진다. Windows 11도 Windows 10 비슷한 부분이 많은 운영체제임에 따라 0x1E version을 사용한다. 다음은 각 버전에 따른 전체 Format 구조이다.
0x11:Windows XP, Windows 2003
0x17:Windows Vista, Windows 7
0x1A:Windows 8.1
0x1E: Windows 10
위 format을 통해 알 수 있는 것과 같이, Windows 10과 8.1의 경우는 최근 실행 시간을 가장 최근 시간을 기준으로 8개까지 저장한다는 특징을 가진다.
여기까지가 각 운영체제별로 가지는 기본적인 prefetch의 파일 포맷이었다. 이후의 내용은 포맷 내 각 필드가 가리키는 값을 추적하는 과정을 정리하였다.
File metrics array
각 프리패치 파일별로, 해당 파일을 실행시키기 위해 필요한 다른 파일들을 함께 기록해두는데 이에 대한 내용은 File metrics array로 관리된다. File metrics array 내에 있는 각 entry를 탐색하기 위해서는 File metrics array offset과 Filename strings offset을 필요로 한다.
File metrics array는 File metrics array offset 필드에 정의된 offset 위치에서 시작한다. File metrics array의 각 entry는 일정한 형식을 갖추고 있는데, 이 형식은 운영체제에 따라 약간의 차이를 가진다.
0x11:Windows XP, Windows 2003
0x17, 0x1A, 0x1E:Windows Vista, Windows 7, Windows 8.1, Windows 10
Unknown의 경우는 확실하지 않은 값이므로 그 쓰임을 명시해두지 못했지만 Windows 7, 8, 10에서의 Unknown 간에는 다음과 같은 규칙을 가지고 있음을 확인하였다.
Unknown1은 초기값 0에서 시작하여 다음 entry로 넘어갈 때마다 Unknown2 만큼 더해진 값을 갖는다.
2번째 필드와 3번째 필드는 매 entry 내에서 서로 동일한 값을 갖는다.
이러한 필드를 제외한 채, Filename string offset과 Filename string number 필드값을 이용하면 각 entry에 해당하는 파일명을 찾을 수 있다. 다음은 Windows 10 환경의 프리패치 파일에서 직접 entry 내 파일명을 확인하기까지의 과정이다.
Volumes information
Prefetch 파일을 통해 이 실행 파일이 어느 볼륨에서 실행된 것인지 확인할 수 있으며, 파일에 할당된 file reference를 실행된 볼륨의 데이터와 매핑시켜볼 수 있다.
Volumes information 데이터 추적도 기본 file format의 필드로부터 시작한다. 파일의 시작 지점으로부터 offset 0x6C 위치에서 4바이트 크기의 volume information offset 정보를 확인할 수 있다. 이 offset을 따라가면 volume information entry를 찾을 수 있는데, 여기서의 entry 구조 역시 운영체제 버전에 따라 다르다.
0x11:Windows XP, Windows 2003
0x17, 0x1A:Windows Vista, Windows 7, Windows 8.1
0x1E: Windows 10
다음은 Windows 10 환경의 프리패치 파일에서 직접 entry 내 볼륨 정보를 확인하기까지의 과정이다.
요약
Windows의 Prefetch는 자주 사용될 프로그램들을 미리 메모리에 로드해두어 빠른 실행을 가능하게 하는 메모리 관리 기법 중 하나
실행된 적 있는 프로그램은 프리패치 파일로 남기 때문에 응용 프로그램(바이러스)에 대한 행위 연구, 포렌식 분석 활용 가능
Prefetch는 비활성화 설정도 가능하기 때문에 무조건 남는 아티팩트가 아니며, 안티 포렌식 목적으로 실행 후 프리패치 파일을 삭제할 수도 있음
최근 운영체제의 prefetch 파일의 경우 LZXPRESS Huffman 압축이 되어있는 경우가 많아 내용 확인 시 압축 해제 또는 전용 도구 필요
Windows Prefetch File (PF) format, Github, 2023.07, https://github.com/libyal/libscca/blob/main/documentation/Windows%20Prefetch%20File%20(PF)%20format.asciidoc
이후 진행한 테스트에서는 이 두 가지 아티팩트를 중심으로 어떤 식으로 남는지 확인하였고, Windows 7과 Windows 11 운영체제를 사용하여 테스트하였다.
대게 특정 프로그램을 자동 실행 프로그램으로 등록하기 위해, 프로그램 설치 단계에서 아래와 같은 체크박스 옵션을 사용하여 설정하고는 한다. 이러한 방식으로 자동 실행을 등록한 경우에는 어떤 식으로 아티팩트가 남을까?
Windows 7
Windows 7에서는 시스템 구성 창의 [시작 프로그램] 탭에서 설치한 프로그램에 대해 두 가지 항목을 기록하고 있는 것을 볼 수 있다.
하나는 HKLM key 하위에 있는 Run 레지스트리를 가리키는 항목이고, 다른 하나는 [시작 프로그램] 폴더를 가리키는 항목이다. 각각의 위치를 직접 확인하여 실제로 존재하는지도 확인하였다.
Windows 11
Windows 11에서는 자동 실행 프로그램을 확인하기 위해 시스템 구성 창이 아닌 작업 관리자로 가야한다. 이 곳에서 시작 프로그램으로 등록된 Everything을 확인할 수 있다.
그러나 Windows 11에서는 Windows 7에서와는 다르게 [시작 프로그램] 폴더 내에서 등록된 프로그램의 LNK 파일을 찾을 수 없었으며, Run 레지스트리에만 등록되어 있는 것을 볼 수 있었다.
[시작 프로그램] 폴더Run 레지스트리
정리
Windows 7: Run 레지스트리와 [시작 프로그램] 폴더 모두 남는다.
Windows 11: Run 레지스트리에는 남지만 [시작 프로그램] 폴더에는 남지 않는다.
그렇다면 설치 프로그램을 이용하지 않고 Run 레지스트리에 직접 기록할 경우, 이것이 시스템에 어떻게 인식이 되며 [시작 프로그램] 폴더와는 어떤 관계를 가질까?
Windows 7
Windows 7의 Run 레지스트리에 직접 등록하고자 하는 프로그램의 실행 경로를 등록하였다.
그러자 곧바로 시스템 구성 창에서 이를 확인할 수 있었다. 이 경우에는 레지스트리에만 등록한 것대로 레지스트리 경로에 대해서만 등록되어 있으며, [시작 프로그램] 폴더 내 LNK 파일과 이를 참조하는 시스템 구성 항목은 없었다.
그러나 이렇게만 등록되어 있어도 부팅 시 자동으로 프로그램이 실행되는데에는 문제가 없었다.
Windows 11
Windows 11에서도 Run 레지스트리에 직접 등록하고자 하는 프로그램의 실행 경로를 등록하였다.
이 경우에도 [시작 프로그램] 폴더는 빈 폴더인 상태 그대로였으나, 작업 관리자 내 등록된 시작 프로그램 목록 상에서는 레지스트리에 등록한 프로그램이 정상적으로 확인이 되었고, 부팅 시에도 이것이 적용되어 자동 실행되었다.
정리
Windows 7과 Windows 11에서 모두 직접 추가한 레지스트리만 유지되고 [시작 프로그램] 폴더 내 항목에는 변화가 없었다. 그럼에도 시스템에서 해당 프로그램을 자동 실행 프로그램으로 인식하는데에는 문제가 없었으며, 실제 부팅 시에도 정상적으로 자동 실행이 이루어졌다.
이번에는 [시작 프로그램] 폴더에만 LNK 파일을 추가하고 레지스트리는 수정하지 않는 방법으로 테스트하였다.
Windows 7
설치 프로그램에서 자동으로 추가한 LNK 파일과 같이 [시작 프로그램] 폴더 내에 이를 넣었다.
그러자 시스템 구성 창에서 이를 정상적으로 인식하고 참조하고 있음을 확인할 수 있었다.
그러나 Run 레지스트리에는 변화가 없었다. 그럼에도 불구하고 부팅 시 해당 프로그램이 자동으로 실행되는데에는 문제없었다.
Windows 11
Windows 7에서와 같이, 추가하고자 하는 프로그램의 LNK 파일을 [시작 프로그램] 폴더 내에 넣었다.
그러자 Windows 7에서와 마찬가지로, Run 레지스터에는 변화없이 시스템의 작업 관리자에서만 이를 인식하고 등록되어 있는 것을 확인할 수 있었다.
정리
Windows 7과 Windows 11에서 모두 직접 추가한 [시작 프로그램] 내 LNK 파일만 유지되고, Run 레지스트리 내 항목에는 변화가 없었다. 그럼에도 시스템에서 해당 프로그램을 자동 실행 프로그램으로 인식하는데에는 문제가 없었으며, 실제 부팅 시에도 정상적으로 자동 실행이 이루어졌다.
결과
자동 실행 프로그램을 등록하기 위해서는 프로그램 설치 단계에서 옵션을 설정하는 방법과 Run 레지스트리에 추가하는 방법, 그리고 [시작 프로그램] 폴더 내에 LNK 파일을 추가하는 방법을 사용할 수 있다.
설치 단계에서 옵션을 사용하여 설정하는 경우에는 Windows 7의 경우 Run 레지스트리와 [시작 프로그램] 폴더 모두에 흔적이 남았다. 그러나 Windows 11에서는 Run 레지스트리에만 흔적이 남고 [시작 프로그램] 폴더 내에는 흔적이 남지 않았다.
Run 레지스트리나 [시작 프로그램] 폴더 내에 직접 참조를 등록하는 방식으로 자동 실행 프로그램을 등록할 경우에는 어떠한 경우를 사용하더라도 이를 시스템에서 정상적으로 인식하고 동작하는데 문제가 없었다. 그러나 Run 레지스트리와 [시작 프로그램] 폴더 내 항목은 각각 독립적으로 작동하여, 서로의 위치에 자동으로 추가된 항목을 동기화하지 않는다. 따라서 수동으로 등록한 위치에만 흔적이 남고 그렇지 않은 위치에는 흔적이 남지 않는다.
이러한 이유로 사고 분석 시 자동 실행 프로그램 항목을 살피기 위해서, 레지스트리만 확인할 것이 아니라 [시작 프로그램] 폴더 수동 등록과 같은 다른 방법의 아티팩트도 함께 확인하고 교차검증 하는 것이 바람직해 보인다.