목차

0. 개요

1. UWP 앱 수정시 주의사항

2. 런타임 디버깅

3. 프로그램 구현

4. 결과


0. 개요


이번에 리버싱 연습 삼아서 윈도우 계산기를 패치해 봤습니다. (런타임 앱 수정; 모딩)


그런데 윈도우 10에서 사용하는 기본 계산기 프로그램은 윈도우 7 이전에 쓰이던 그런 계산기 프로그램이랑 좀 달라요.


윈도우 10은 계산기 같은 기본 프로그램조차도 개발 프레임워크를 대대적으로 뜯어 고치고 앱이라는 이름을 붙여서 그것도 멀티 플랫폼으로 좀 화려하게 만들어 놨습니다.


소위 말하는 그런 윈도우 '앱'은 UWP 앱이라고 부릅니다.


UWP는 "Universal Windows Platform"의 약자로서, 윈도우 10에서 새로 도입된 개념이에요.


UWP 앱이란, 쉽게 말하면 뭔가 새로 나온 윈도우(Windows)의 개발 프레임워크를 이용해서 개발했고 '마이크로소프트 스토어'에서 제공하는 어플리케이션입니다.



그러니까 마이크로소프트 스토어에 올라오는 앱을 UWP 앱이라고 합니다.[각주:1]


기존의 리버싱 관련 자료들은 윈도우 7이나 XP 시절을 기준으로 하고 있어서 64비트가 대중화된 요즘 환경에는 그대로 적용하기 힘든 점이 많습니다.


그래서 이런 앱을 한 번 리버싱을 해 보는 것은 그 작동 원리를 알아볼 수 있기 때문에 트렌드에 뒤쳐지지 않는 기술을 나름대로 연마하기에 좋다고 생각해요.


1. UWP 앱 수정시 주의사항


UWP 앱은 UWP 샌드박스 안에서 작동하기 때문에, 여러 가지 디버깅 테크닉을 쓰기에 앞서서, 필요한 권한을 설정한다든지, 또는 약간의 우회 동작이 필요합니다.


그런 것은 어렵지는 않지만 약간 걸리적거리는 정도고, 몇 가지 주의사항만 알아 두면, UWP 앱의 리버싱과 모딩은, 그밖의 나머지는 기존 윈도우 프로그램에 대해서 하는 것과 전혀 다르지 않습니다.


그 노하우는 아래 링크가 가리키는 웹페이지에 거의 모두 정리되어 있습니다.



UWP 앱 수정시 주의사항 중 가장 간단하며 중요한 것 두 가지만 보겠습니다.


1) 올바른 프로세스 찾기


UWP 앱 프로세스를 열 때는 절대로 윈도우(창)의 이름으로 프로세스를 찾아서는 안 됩니다.


UWP 프로세스는 전통적인 윈도우 프로그램이랑 다르게 직접 창을 관리하지 않거든요.


ApplicationFrameHost.exe라는 껍데기 프로세스가 함께 실행되면서 화면이나 윈도우(창) 같은 것들의 처리를 대신 해 줍니다.


윈도우의 제목으로 프로세스를 찾아서 열어 버리면, 그 사람은 원본 프로그램의 프로세스가 아니라 ApplicationFrameHost.exe 안에서 삽질을 하게 되죠.


기존에 잘 쓰던 캡쳐 툴들이 최근 들어 UWP 앱에 대해서만 제대로 동작하지 않는 경우가 있는데, 그 이유가 바로 이것입니다.


그러니까 ApplicationFrameHost 프로세스 말고 진짜 프로세스를 찾아서 여는 것이 첫 단계입니다.


예를 들어 계산기 프로세스를 열어보고 싶다면, 아래 이미지 예시에서 보이듯이 창 제목이 "계산기"인 0x415C의 ApplicationFrameHost가 아닌 0x2118의 Caculator를 열어야 합니다.



2) UWP 앱에 대한 접근 권한 얻기


UWP 앱에 대한 DLL을 개발할 때 주의사항입니다.


리버서들은 DLL Injection이라는 이름으로 런타임에 다른 프로세스에 임의의 (동적) 모듈을 적재시킨 뒤 그 프로세스의 가상 메모리 공간 안에서 그 모듈의 코드를 실행시키고 싶을 때가 있습니다.


UWP 앱에 대해서 DLL Injection을 수행하실 때는 주입(Injection)하고자 하는 런타임 라이브러리 모듈(DLL)에 "ALL APPLICATION PACKAGES" 그룹의 접근을 허용해야 합니다.


UWP 앱은 실행을 위해서 기본적으로, UWP 프로그램의 모든 것이 담겨 있다고 전해지는, "ALL RESTRICTED APPLICATION PACKAGES"라는 그룹에 속하는 파일들에 대한 접근 권한을 가지고 있습니다. ("ALL RESTRICTED APPLICATION PACKAGES" = 그냥 UWP 앱들을 뜻함)[각주:2]


반면에 UWP 프로세스가 신뢰하고 접근할 수 있는 해당 경로에 위치하지 않은 그밖의 외부 DLL은, 프로세스가 (파일 시스템의) 접근 권한이 없으므로 로드할 수 없으며, 로딩을 시도해도 실패합니다.


따라서 UWP 앱을 타겟으로 하는 DLL Injection 전에 인젝션할 DLL의 파일 속성에서 UWP 앱에 대한 접근 권한을 설정해 줄 필요가 있습니다.


파일을 우클릭하고, 파일 속성의 보안 탭에서 ALL_APPLICATION_PACKAGES 그룹을 추가하여 아래 이미지에서 보이는 것처럼 권한을 설정합니다.



또는 아래 예제처럼 해당 파일 속성을 자동으로 설정하도록 프로그래밍할 수도 있습니다.


DWORD SetPermissions(std::wstring wstrFilePath)
{
    PACL pOldDACL = NULL, pNewDACL = NULL;
    PSECURITY_DESCRIPTOR pSD = NULL;
    EXPLICIT_ACCESS eaAccess;
    SECURITY_INFORMATION siInfo = DACL_SECURITY_INFORMATION;
    DWORD dwResult = ERROR_SUCCESS;
    PSID pSID;
 
    // Get a pointer to the existing DACL
    dwResult = GetNamedSecurityInfo(wstrFilePath.c_str(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, NULL, NULL, &pOldDACL, NULL, &pSD);
    if (dwResult != ERROR_SUCCESS)
        goto Cleanup;
 
    // Get the SID for ALL APPLICATION PACKAGES using its SID string
    ConvertStringSidToSid(L"S-1-15-2-1", &pSID);
    if (pSID == NULL)
        goto Cleanup;
 
    ZeroMemory(&eaAccess, sizeof(EXPLICIT_ACCESS));
    eaAccess.grfAccessPermissions = GENERIC_READ | GENERIC_EXECUTE;
    eaAccess.grfAccessMode = SET_ACCESS;
    eaAccess.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
    eaAccess.Trustee.TrusteeForm = TRUSTEE_IS_SID;
    eaAccess.Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
    eaAccess.Trustee.ptstrName = (LPWSTR)pSID;
 
    // Create a new ACL that merges the new ACE into the existing DACL
    dwResult = SetEntriesInAcl(1, &eaAccess, pOldDACL, &pNewDACL);
    if (ERROR_SUCCESS != dwResult)
        goto Cleanup;
 
    // Attach the new ACL as the object's DACL
    dwResult = SetNamedSecurityInfo((LPWSTR)wstrFilePath.c_str(), SE_FILE_OBJECT, siInfo, NULL, NULL, pNewDACL, NULL);
    if (ERROR_SUCCESS != dwResult)
        goto Cleanup;
 
Cleanup:
    if (pSD != NULL)
        LocalFree((HLOCAL)pSD);
    if (pNewDACL != NULL)
        LocalFree((HLOCAL)pNewDACL);
 
    return dwResult;
}


위 예제 코드는 제가 작성한 것은 아니고 아래 웹 사이트에서 빌려 왔습니다.



2. 런타임 디버깅


계산기 패치의 목표는 계산기에서 숫자 대신 다른 글자가 출력되게 하는 것입니다.


예를 들어 표시되는 숫자가 "458,793"이라면 아라비아 숫자 대신 "사오팔,칠구삼"이나 "四五八,七九三"이 찍히는 거죠.


이승원님이 저술하신 '리버싱 핵심 원리'에서 윈도우 XP~7의 x86 환경을 기준으로 구현했던 그 예제입니다.


우선 밑그림은, UI가 숫자를 표현하는 부분에서 그 숫자를 가로채서 내가 출력하고 싶은 문자열로 갈아치우는 것입니다.


다음, 런타임 디버깅은 그냥 눈에 보이는 것만 그대로 쫓아가면 되죠.


해당 처리 부분이 어디 있는지 찾기 위해서 처음에는 치트엔진으로 숫자를 검색했는데 안 나오더라고요.


의심 가는 범용 API에 브포를 여럿 걸어 봤으나 전혀 수확이 없고 옛날 계산기에서 쓰던 방식처럼 SetWindowText()에 답이 있는 것도 아닌 것 같습니다.


그래서 이리저리 건드려 보다가 주목한 이벤트가 '오버플로'입니다.


제곱을 막 눌러서 계산할 숫자를 키우다 보면 일정 자릿수 이상에서 숫자 대신 "오버플로"라는 문자열을 출력하더라고요.



치엔으로 문자열 "오버플로"를 스캔해 보면, UTF-16 문자열 하나가 걸리는데, 오버플로라는 글자가 화면에 찍힐 때만 한 번씩 액세스가 발생하는 것이 관찰됩니다.


그걸 보고 문제는 포맷이었구나 하면서 UTF-16 문자열 형식이라는 옵션을 줘서 UI에 보이는 숫자를 다시 검색했더니 쉼표를 포함한 숫자 문자열 하나랑 쉼표가 빠진 숫자 문자열 두 개 정도가 보입니다.


그런 뒤 버튼을 누를 때마다 쉼표가 있는 문자열에 접근하는 인스트럭션을 식별하고, 그 근처를 이 잡듯이 뒤졌습니다.


치엔 디버거에는 지정한 어떤 인스트럭션이 수행되는 시점의 근처를 자동으로 트레이싱하고 로그로 찍어 주는 기능이 있는데 이 기능을 이용해서 인스트럭션 1만 개 정도를 몇 번 찍어 보면 대충 밑그림이 나옵니다.


이제 "Dissect code"라는 치엔의 코드 분석 기능을 이용해서 어떤 함수들이 언제 어디로 무엇에 의해서 뛰는지 확인한 다음, 의심 가는 콜 명령을 하나씩 NOP 처리했다가 풀었다가 하면서 확인해 봅니다.



첫 번째로 찾은 것은 Calculator.exe+2BC94의 함수인데, 세 번째 인자로 쉼표가 있거나 없는 숫자의 문자열의 포인터를 받아서 이 함수가 뭔가를 처리하고 나면, 계산기 UI의 숫자가 변하는군요.


mov r8,rdi에서 세 번째 인자로서 넘기는 문자열의 주소는 스택에 할당된 임시 변수를 가리키는 것으로 보입니다.


Calculator.exe+2BC26 - 4C 8B C7              - mov r8,rdi
Calculator.exe+2BC29 - 49 89 37              - mov [r15],rsi
Calculator.exe+2BC2C - 48 8B D3              - mov rdx,rbx
Calculator.exe+2BC2F - 48 8B CE              - mov rcx,rsi
Calculator.exe+2BC32 - E8 5D000000           - call Calculator.exe+2BC94


그래서 "Calculator.exe+2BC94"에 위치한 함수는 개인적으로 OnNumberUpdate()라고 명명했습니다.



두 번째로 주목한 것은 내부에서 OnNumberUpdate()를 호출하는 Calculator.exe+2BBC8라는 함수입니다.


OnNumberUpdate()는 쉼표가 있는 문자열도 받고 없는 것도 받습니다.


하지만 "Calculator.exe+2BBC8"의 함수가 OnNumberUpdate()를 호출할 때만은 세 번째 인자로 '쉼표가 있는' 문자열이 전달됩니다.


Calculator.exe+2BBC8를 대충 작명해서 OnDisplayUpdate()라고 부릅니다.


이렇게 해서 화면에 출력할 문자열에 관여하는 OnDisplayUpdate()와 OnNumberUpdate()의 위치를 찾았습니다.


이 함수가 실행되는 중간에 브포를 걸고 오토 어셈블러로 해당 문자열을 고쳐 보면 변경 사항이 적용되면서 UI에 그대로 잘 반영되는 것이 드러납니다.


이제 OnDisplayUpdate()와 OnNumberUpdate()라는 두 함수의 작동을 후킹해서 중간에 인자로 넘기는 문자열을 매번 훔쳐 보고 내가 원하는 데이터를 대신 덮어씌울 수 있을 것으로 보입니다.


3. 프로그램 구현


API를 후킹한 뒤의 동작의 정의는 어떤 함수의 형태로 프로그래밍해서 구현할 필요가 있습니다.


어떤 API에 훅을 건 뒤에 그 API 대신 실행시킬 우회 함수를 프로그래밍하려고 하는데, 나는 어셈블러 같은 저수준 언어는 잘 쓸 줄 모르기 때문에, 그런 우회 함수를 정의하고 프로세스의 코드를 패치하는 프로그램은 C언어 같은 고수준 언어로 작성하고 싶습니다.


그 프로그래밍에는 요즘 핫한 컴파일 언어인 Go를 써 보겠습니다.


그리고 그렇게 고수준 언어로 프로그램을 작성한 결과물로서 생성된 바이너리를 다른 프로세스의 가상 메모리 주소 공간 내에서 실행시키기 위해서는, 대중적인, 인기 있는 테크닉으로 손꼽히는 것 중 하나인 동적 모듈 주입 (DLL Injection) 기법을 이용하겠습니다.


DLL Injection 기법을 이용함으로써, 기계어 대신 고급 언어로 우회 함수를 편하게 작성할 수 있을 뿐만 아니라, 내가 만든 프로그램(바이너리)이 타겟 프로세스의 메모리를 읽고 쓰고 여러 가지 짓을 하게 하기가 매우 용이합니다.


마지막으로, 목표하는 런타임 모듈을 아래처럼 작성하고 빌드한 뒤 윈도우 계산기 프로세스에 적재시킵니다.


DLL Injector도 직접 구현해 보면 재미는 있겠지만 그건 이미 다른 사람들이 많이 구현해 두었으니 누군가가 만들어 둔 것을 간단히 그대로 가져다 쓰는 편이 좋아 보입니다.


package main

import (
	"fmt"
	"log"
	"strings"
	"syscall"
	"unicode/utf16"
	"unsafe"

	"github.com/nanitefactory/gominhook"
	"github.com/nanitefactory/winmb"
	"github.com/zetamatta/go-outputdebug"
)

// ----------------------------------------------------------------------------

/*
#include <windows.h>

// Due to lack of my knowledge in reversing I literally have no idea what the return type of these functions would be though.
// Arguments could be guessed; 64-bit integers because they always pass in R8, RDX, RCX in order.

// Gateway functions in C.
DWORD64 OnDisplayUpdate(DWORD64, DWORD64, DWORD64);
DWORD64 OnNumberUpdate(DWORD64, DWORD64, DWORD64);

*/
import "C"

var isInMiddleOfOnDisplayUpdate bool

var fpDisplayUpdate *func(arg1, arg2, arg3 uintptr) (ret uintptr)
var fpNumberUpdate *func(arg1, arg2, arg3 uintptr) (ret uintptr)

//export OnDisplayUpdate
func OnDisplayUpdate(arg1, arg2, arg3 uintptr) (ret uintptr) {
	isInMiddleOfOnDisplayUpdate = true
	ret, _, _ = syscall.Syscall6(uintptr(unsafe.Pointer(fpDisplayUpdate)), 3, arg1, arg2, arg3, 0, 0, 0)
	return
}

//export OnNumberUpdate
func OnNumberUpdate(arg1, arg2, arg3 uintptr) (ret uintptr) {
	// See if it's hooked well. // arg3 points to the stack where our string is stored.
	outputdebug.String("OnNumberUpdate(): " + fmt.Sprintf("Arguments passed: 0x%X 0x%X 0x%X", arg1, arg2, arg3))

	// If this OnNumberUpdate() is not called from OnDisplayUpdate(), don't do nothing and fallthrough.
	if !isInMiddleOfOnDisplayUpdate {
		ret, _, _ = syscall.Syscall6(uintptr(unsafe.Pointer(fpNumberUpdate)), 3, arg1, arg2, arg3, 0, 0, 0)
		return
	}
	isInMiddleOfOnDisplayUpdate = false

	// Convert our UTF16 string (WSTR) to which our arg3 points to a plain Go string.
	strArg3 := lpwstrToString((C.LPCWSTR)(unsafe.Pointer(arg3)))
	outputdebug.String("original text: " + strArg3)

	// Make some changes to this copied string.
	strArg3 = strings.Replace(strArg3, "0", "空", -1)
	strArg3 = strings.Replace(strArg3, "1", "一", -1)
	strArg3 = strings.Replace(strArg3, "2", "二", -1)
	strArg3 = strings.Replace(strArg3, "3", "三", -1)
	strArg3 = strings.Replace(strArg3, "4", "四", -1)
	strArg3 = strings.Replace(strArg3, "5", "五", -1)
	strArg3 = strings.Replace(strArg3, "6", "六", -1)
	strArg3 = strings.Replace(strArg3, "7", "七", -1)
	strArg3 = strings.Replace(strArg3, "8", "八", -1)
	strArg3 = strings.Replace(strArg3, "9", "九", -1)
	// strArg3 = "야옹, 멍멍, 귀여워! <" + strArg3 + "> by 코코넛 xD 냠냠"
	outputdebug.String("modified text: " + strArg3)

	// Get another copy of that modified string with a ptr to it.
	newArg3 := syscall.StringToUTF16Ptr(strArg3)
	sizeNewArg3 := len(syscall.StringToUTF16(strArg3)) * 2 // size as byte array
	outputdebug.String("arg3: " + fmt.Sprintf("0x%X -> 0x%X -> 0x%X", arg3, strArg3, newArg3))

	// ----------------------------------------------------------------------------
	// Copy this new string to where arg3 points to.

	// Going to read & write memory with kernel32 API.
	kernel32 := syscall.NewLazyDLL("kernel32.dll")

	hProcess, _, _ := syscall.Syscall(
		kernel32.NewProc("GetCurrentProcess").Addr(),
		0, 0, 0, 0,
	)

	var oldProtect C.DWORD
	ret, _, err := syscall.Syscall6(
		kernel32.NewProc("VirtualProtectEx").Addr(),
		5, hProcess, arg3, uintptr(sizeNewArg3), C.PAGE_EXECUTE_READWRITE, uintptr(unsafe.Pointer(&oldProtect)), 0,
	)
	outputdebug.String(fmt.Sprint(ret, err)) // ret: return code. err: return detail.

	ret, _, err = syscall.Syscall6(
		kernel32.NewProc("WriteProcessMemory").Addr(),
		4, hProcess, arg3, uintptr(unsafe.Pointer(newArg3)), uintptr(sizeNewArg3), 0, 0,
	)
	outputdebug.String(fmt.Sprint(ret, err)) // ret: return code. err: return detail.
	// ----------------------------------------------------------------------------

	// Call the original function.
	ret, _, _ = syscall.Syscall6(uintptr(unsafe.Pointer(fpNumberUpdate)), 3, arg1, arg2, arg3, 0, 0, 0)
	return
}

func lpwstrToString(cwstr C.LPCWSTR) string {
	const maxRunes = 1<<30 - 1
	ptr := unsafe.Pointer(cwstr)
	sz := C.wcslen((*C.wchar_t)(ptr))
	wstr := (*[maxRunes]uint16)(ptr)[:sz:sz]
	return string(utf16.Decode(wstr))
}

// ----------------------------------------------------------------------------

// GetProcessModuleHandle returns the base address of .exe module.
//export GetProcessModuleHandle
func GetProcessModuleHandle() (hModule uintptr) {
	hModule, _, _ = syscall.Syscall( // (HANDLE)GetModuleHandle(NULL);
		syscall.NewLazyDLL("kernel32.dll").NewProc("GetModuleHandleW").Addr(),
		1, 0, 0, 0,
	)
	outputdebug.String(fmt.Sprintf("GetProcessModuleHandle(): 0x%X", hModule))
	return
}

// GetHookPoint in our process.
//export GetHookPoint
func GetHookPoint(offset uintptr) (hookPoint uintptr) {
	base := GetProcessModuleHandle()
	hookPoint = base + offset
	outputdebug.String(fmt.Sprintf("GetHookPoint(): 0x%X = 0x%X + 0x%X", hookPoint, base, offset))
	return
}

// OnProcessAttach is an async callback (hook).
//export OnProcessAttach
func OnProcessAttach(
	hinstDLL unsafe.Pointer, // handle to DLL module
	fdwReason uint32, // reason for calling function
	lpReserved unsafe.Pointer, // reserved
) {
	// Initialize minhook
	err := gominhook.Initialize()
	if err != nil {
		outputdebug.String(err.Error())
		log.Fatalln(err)
	}

	// Clean-up minhook
	defer func() {
		// Unhook
		err := gominhook.DisableHook(gominhook.AllHooks)
		if err != nil {
			outputdebug.String(err.Error())
			log.Println(err)
		}
		// Uninitialize
		err = gominhook.Uninitialize()
		if err != nil {
			outputdebug.String(err.Error())
			log.Println(err)
		}
	}()

	// ----------------------------------------------------------------------------

	// Create a hook for OnDisplayUpdate(). // Calculator.exe+2BBC8 - 48 89 5C 24 08        - mov [rsp+08],rbx
	err = gominhook.CreateHook(GetHookPoint(0x2BBC8), uintptr(C.OnDisplayUpdate), uintptr(unsafe.Pointer(&fpDisplayUpdate)))
	if err != nil {
		outputdebug.String(err.Error())
		log.Fatalln(err)
	}

	// Create a hook for OnNumberUpdate(). // Calculator.exe+2BC94 - 48 89 5C 24 08        - mov [rsp+08],rbx
	err = gominhook.CreateHook(GetHookPoint(0x2BC94), uintptr(C.OnNumberUpdate), uintptr(unsafe.Pointer(&fpNumberUpdate)))
	if err != nil {
		outputdebug.String(err.Error())
		log.Fatalln(err)
	}

	// Enable the hook.
	err = gominhook.EnableHook(gominhook.AllHooks)
	if err != nil {
		outputdebug.String(err.Error())
		log.Fatalln(err)
	}

	// ----------------------------------------------------------------------------

	// Block this routine.
	<-ch
	outputdebug.String("OnProcessAttach(): Exit")
}

var ch = make(chan int)

// Unhook everything. This will restore the target process to its original state.
//export Unhook
func Unhook() {
	ch <- 1
}

// ----------------------------------------------------------------------------

//export MessageBoxTest
func MessageBoxTest() {
	winmb.MessageBoxPlain("export Test", "export Test")
}

//export Test
func Test() {
}

const title = "TITLE"

var version = "undefined"

func main() {
	// nothing really. xD
}


위 코드의 핵심은 이것입니다.





  1. https://docs.microsoft.com/ko-kr/windows/uwp/ [본문으로]
  2. https://social.technet.microsoft.com/Forums/windows/en-US/db38c481-e3fe-42b5-9dc5-7a49fe0ff6cd/folder-permissions-include-quotall-restricted-application-packagesquot?forum=win10itprosecurity [본문으로]
Top