Equivalent of DLL_PROCESS_ATTACH in Go

107 views
Skip to first unread message

rudeus greyrat

unread,
Nov 10, 2024, 8:54:16 AMNov 10
to golang-nuts
I am trying to write DLL in go. I want it to execute some stuff when the DLL is attached to a process.

I thought init() will be the equivalent of onattach but It seems I am wrong.

I created this as a proof of concept:
```
package main

import "C"
import (
 "syscall"

 "golang.org/x/sys/windows"
)

//export RunMe
func RunMe() {

      windows.MessageBox(windows.HWND(0), syscall.StringToUTF16Ptr("RunMe"), syscall.StringToUTF16Ptr("RunMe"), windows.MB_OK)
}

func init() {
      windows.MessageBox(windows.HWND(0), syscall.StringToUTF16Ptr("DLL Loaded"), syscall.StringToUTF16Ptr("DLL Load"), windows.MB_OK)
}

func main() {}
```

I compile on Linux with:
```
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc go build -a -o ~/Desktop/dropamd64.dll -buildmode=c-shared cmd/dll64bit/main.go
```

I transfer on my Windows machine then here is the amazing result:
rundll32.exe Z:\dropamd64.dll ----->  Nothing happens
rundll32.exe Z:\dropamd64.dll,RunMe  ------> Runs RunMe() and init() and I get 2 MessageBox

My question is, how to get a DLL that runs stuff only when process is attached ?

There not a lot of official Golang doc about DLL (some few people write here and there some tutorials that are not always right and correct ...) So if you have some trustworthy doc please share.




Jason E. Aten

unread,
Nov 10, 2024, 10:14:52 AMNov 10
to golang-nuts
Use CGO in your .go file, and then there, or in an adjacent C file, actually do write a DllMain function;
https://learn.microsoft.com/en-us/windows/win32/dlls/dllmain

When fdwReason == DLL_PROCESS_ATTACH, then you know this is the first load of the DLL
into this process. I think your subject line acknowledges that you known this.

Note that the APIENTRY annotation on the DllMain is required, in order to get the
right calling convention.

Be aware that the go runtime has a major problem when used inside a DLL: it
provides no way to shut itself down. This means that you can never unload
a DLL that you have loaded, even though Windows processes routinely do
unload DLLs. 

On XP or later you can use GetModuleHandleEx with the 
GET_MODULE_HANDLE_EX_FLAG_PIN flag to prevent unloading of the DLL. 
This means that your process won't crash on unload, but also that the
DLL will not actually be unloaded. In order to update the DLL, you must kill the
process. 

Jason E. Aten

unread,
Nov 10, 2024, 11:15:52 AMNov 10
to golang-nuts
I'll add that the one way I've found to address the "Go runtime cannot shut itself down" problem
is to compile to wasm.  WebAssembly hosts
like the browser -- and running the wasm on a background web worker -- provide very nice ways of killing
a web worker thread. I believe (but have not tried it), that wasm runtimes like wazero
and wasmtime also provide means to pause and kill threads.

Runtimes like wazero compile the wasm to native code. These
days they can, very impressively, execute at native or near native speed. 
Moreover, if you use tinygo (llvm under the covers) you can actually 
get wasm code to go faster (sometimes _much_ faster) than the native go 
compiler, even though you are going through a wasm layer.

Since it would be vastly more portable than writing a DLL, you might
explore the wasm approach if the wasm runtimes provide for what you
are trying to do on Windows (which they well may not, but its worth exploring).

rudeus greyrat

unread,
Nov 10, 2024, 3:09:36 PMNov 10
to golang-nuts
Thanks for your response. It seems logic to do what you say (aka lets make DllMain in C if Go does not let us control it)

So I did a main.c:
```
#include <windows.h>

extern void RunMe();

BOOL APIENTRY DllMain(HMODULE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved) {
    switch (ul_reason_for_call) {
    case DLL_PROCESS_ATTACH:
        RunMe();
        break;
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}
```

Then a main.go that use main.c
```
package main

//#include "main.c"

import "C"
import (
"syscall"

"golang.org/x/sys/windows"
)

//export RunMe
func RunMe() {
    windows.MessageBox(windows.HWND(0), syscall.StringToUTF16Ptr("RunMe"), syscall.StringToUTF16Ptr("RunMe"), windows.MB_OK)
}

func main() {}
```

Then I compile by cd into the directory containing the main.go and main.c and running:
```
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc go build -a -o ~/Desktop/dropamd64.dll -buildmode=c-shared
```

However I get an error from x86_64-w64-mingw32-gcc that DllMain is defined multiple time. I am not sure the issue is from DllMain though, because If I change DllMain to TestRudeus I get also the error TestRudeus is defined multiple time...

rudeus greyrat

unread,
Nov 10, 2024, 3:27:15 PMNov 10
to golang-nuts
Update

So there is some lock put if there is an export in the go file which is causing the problem.


dllmain.go:
```
package main

/*
#include "dllmain.h"
*/
import "C"
```

dllmain.h:
```
#include <windows.h>

// Declare the function defined in Go that you want to call on DLL load

extern void RunMe();

BOOL APIENTRY DllMain(HMODULE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved) {
    switch (ul_reason_for_call) {
    case DLL_PROCESS_ATTACH:
        // Call the Go function when the DLL is loaded

        RunMe();
        break;
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}
```

main.go:
```
package main

import "C"
import (
"syscall"
"golang.org/x/sys/windows"
)

//export RunMe
func RunMe() {
    windows.MessageBox(windows.HWND(0), syscall.StringToUTF16Ptr("RunMe"), syscall.StringToUTF16Ptr("RunMe"), windows.MB_OK)
}

func main() {
   
}
```


Compile:
```
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc go build -a -o ~/Desktop/dropamd64.dll -buildmode=c-shared
```

I get a dll but:
rundll32.exe Z:\dropamd64.dll ----->  Nothing happens
rundll32.exe Z:\dropamd64.dll,RunMe  ------> Nothing happens
msiexec.exe /y Z:\dropamd64.dll ------->Nothin happens

Jason E. Aten

unread,
Nov 10, 2024, 3:42:44 PMNov 10
to golang-nuts
I would suggest divide and conquer next. This is a classic approach to debuggging.

First get something working in C, solely on Windows. 

Can you build a pure C host and pure C DLL that loads and shows your window?

I note that I never had much luck cross-compiling to windows, so I would recommend, initially, building and running your C host and C DLL on windows exclusively. This will also cut down on the number of moving parts.

Also, I would not use rundll32.exe to test, since it is 32 bit only and its docs say that it can only be used with DLLs that were specifically built for it: "Rundll32 can only call functions from a DLL explicitly written to be called by Rundll32."
Rather build your own host/main C program that imports your DLL.

The I would incrementally move towards your end goal. If you haven't realized the issue yet, then move to cross compilation without Go. If that works, then add in the Go code.

rudeus greyrat

unread,
Nov 10, 2024, 4:51:24 PMNov 10
to golang-nuts
Thanks, it works now.

I was missing the CreateThread in DLL Main
```

case DLL_PROCESS_ATTACH:

// Initialize once for each new process.

// Return FALSE to fail DLL load.

{

MyThreadParams* lpThrdParam = (MyThreadParams*)malloc(sizeof(MyThreadParams));

lpThrdParam->hinstDLL = _hinstDLL;

lpThrdParam->fdwReason = _fdwReason;

lpThrdParam->lpReserved = _lpReserved;

HANDLE hThread = CreateThread(NULL, 0, MyThreadFunction, lpThrdParam, 0, NULL);

// CreateThread() because otherwise DllMain() is highly likely to deadlock.

}
break;
```

Plus yeah testing with rundll32 was not adequate. I created a process that attach the DLL.

There is still one point I am missing is why DllMain deadlock if I don't run my function in a new thread ? Is it because I am not using goroutines ?

Jason E. Aten

unread,
Nov 10, 2024, 5:06:07 PMNov 10
to rudeus greyrat, golang-nuts
I never needed to do CreateThread(), so I doubt it is needed?

I would add printf debugging statements to see when things are happening. 

Since Windows processes do not normally have the concept of stdout however, 
you do have to write them to a file instead of "the console", which is not 
there by default.




--
You received this message because you are subscribed to a topic in the Google Groups "golang-nuts" group.
To unsubscribe from this topic, visit https://groups.google.com/d/topic/golang-nuts/ok8f90k2otg/unsubscribe.
To unsubscribe from this group and all its topics, send an email to golang-nuts...@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/golang-nuts/34c33139-984c-4f5e-8f7d-ef07f28c367fn%40googlegroups.com.

Jason E. Aten

unread,
Nov 10, 2024, 5:26:01 PMNov 10
to rudeus greyrat, golang-nuts
(asking your friendly neighborhood LLM to write the log-to-file code gives... )

#include <windows.h>
#include <string>

// Function to log messages to a file using Win32 API
void logMessage(const std::string& message) {
    HANDLE hFile = CreateFile(
        "debug_log.txt",                // Name of the log file
        FILE_APPEND_DATA,               // Open for writing and append data
        FILE_SHARE_READ,                // Share for reading
        NULL,                           // Default security
        OPEN_ALWAYS,                    // Open existing file or create new
        FILE_ATTRIBUTE_NORMAL,          // Normal file
        NULL                            // No template file
    );

    if (hFile == INVALID_HANDLE_VALUE) {
        return; // Failed to open or create the file
    }

    // Move the file pointer to the end of the file
    SetFilePointer(hFile, 0, NULL, FILE_END);

    // Write the message to the file
    DWORD bytesWritten;
    WriteFile(hFile, message.c_str(), message.length(), &bytesWritten, NULL);

    // Write a newline character
    const char newline = '\n';
    WriteFile(hFile, &newline, 1, &bytesWritten, NULL);

    // Close the file handle
    CloseHandle(hFile);
}

int main() {
    // Name of the DLL to load
    const char* dllName = "example.dll";

    // Load the DLL
    HMODULE hModule = LoadLibrary(dllName);
    if (hModule == NULL) {
        logMessage("Failed to load DLL: " + std::string(dllName));
        return 1;
    }

    logMessage("Successfully loaded DLL: " + std::string(dllName));

    // Perform any operations with the DLL here...

    // Unload the DLL
    if (FreeLibrary(hModule) == 0) {
        logMessage("Failed to unload DLL: " + std::string(dllName));
        return 1;
    }

    logMessage("Successfully unloaded DLL: " + std::string(dllName));

    return 0;
}

Also here is some example code provided by that same neighbor, which I
have run successfully just now. See below for compilation commands.

dll.go:

package main

/*
#include <windows.h>

// Exported function to be called from other languages
__declspec(dllexport) void HelloWorld() {
    MessageBox(NULL, "Hello from Go DLL!", "Hello", MB_OK);
}
*/
import "C"

// init function to initialize the Go runtime
func init() {
// Initialization code here
println("Go runtime initialized")
}

// main function is required to build a Go DLL
func main() {}



main.c:

#include <windows.h>
#include <stdio.h>

// Function prototype from the generated header file
void HelloWorld();

int main() {
    // Load the DLL
    HMODULE hModule = LoadLibrary("hello.dll");
    if (hModule == NULL) {
        printf("Failed to load DLL\n");
        return 1;
    }

    // Get the function address
    void (*HelloWorld)() = (void (*)())GetProcAddress(hModule, "HelloWorld");
    if (HelloWorld == NULL) {
        printf("Failed to get function address\n");
        FreeLibrary(hModule);
        return 1;
    }

    // Call the function
    HelloWorld();

    // Unload the DLL
    FreeLibrary(hModule);

    return 0;
}

compile and run:

jaten@DESKTOP-689SS63 ~/tmp $ gcc -o main main.c

jaten@DESKTOP-689SS63 ~/tmp $ go build -o hello.dll -buildmode=c-shared dll.go

jaten@DESKTOP-689SS63 ~/tmp $ lh

total 3.0M

drwxrwxr-x+ 1 Administrators SYSTEM    0 Nov  1 07:40 ../

-rw-r--r--+ 1 jaten          None    642 Nov 10 16:16 main.c

-rw-r--r--+ 1 jaten          None    402 Nov 10 16:16 dll.go

-rwx------+ 1 jaten          None    54K Nov 10 16:16 main.exe*

drwx------+ 1 jaten          None      0 Nov 10 16:17 ./

-rwx------+ 1 jaten          None   2.7M Nov 10 16:17 hello.dll*

jaten@DESKTOP-689SS63 ~/tmp $ ./main.exe

Go runtime initialized

jaten@DESKTOP-689SS63 ~/tmp $  # also the pop up window was seen.




Jason E. Aten

unread,
Nov 10, 2024, 7:26:10 PMNov 10
to golang-nuts
Not that you need it, since init() works, as above, but 
here is a successful over-ride of DllMain example (be sure to load hello2.dll instead of hello.dll in main.c)

file dll2.go:

package main

/*
#include <windows.h>

// Custom DllMain function

BOOL APIENTRY DllMain(HMODULE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved) {
    switch (ul_reason_for_call) {
    case DLL_PROCESS_ATTACH:
        MessageBox(NULL, "DllMain called with reason: DLL_PROCESS_ATTACH", "Attached", MB_OK);

        break;
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

// Exported function to be called from other languages
__declspec(dllexport) void HelloWorld() {
    MessageBox(NULL, "Hello from Go DLL!", "Hello", MB_OK);
}
*/
import "C"

// init function to initialize the Go runtime
func init() {
// Initialization code here
println("Go runtime initialized")
}

// main function is required to build a Go DLL
func main() {}

compiled as:

jaten@DESKTOP-689SS63 ~/tmp $ go build  -o hello2.dll -buildmode=c-shared dll2.go

jaten@DESKTOP-689SS63 ~/tmp $ gcc -o main main.c ## after replacing hello.dll with hello2.dll in the LoadLibrary() call.

jaten@DESKTOP-689SS63 ~/tmp $ ./main.exe


// attached: screenshot of "Attached" popup happening.
dll_attach.png

Jason E. Aten

unread,
Nov 10, 2024, 8:07:23 PMNov 10
to golang-nuts
I'm building under a cygwin bash shell (https://www.cygwin.com/), with this version of gcc: https://sourceforge.net/projects/mingw-w64/

jaten@DESKTOP-689SS63 ~/tmp $ gcc --version

gcc.exe (x86_64-posix-seh-rev0, Built by MinGW-W64 project) 8.1.0

Copyright (C) 2018 Free Software Foundation, Inc.

This is free software; see the source for copying conditions.  There is NO

warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

jaten@DESKTOP-689SS63 ~/tmp $ which gcc

/cygdrive/c/mingw-w64/x86_64-8.1.0-posix-seh-rt_v6-rev0/mingw64/bin/gcc

jaten@DESKTOP-689SS63 ~/tmp $ go version

go version go1.23.2 windows/amd64

Reply all
Reply to author
Forward
0 new messages