{
	"id": "634a7c92-8450-40f8-80fb-af4dece2dd3b",
	"created_at": "2026-04-06T00:13:53.475845Z",
	"updated_at": "2026-04-10T03:24:24.770113Z",
	"deleted_at": null,
	"sha1_hash": "f38c6b1a461290162e85cc99139911ca0d0b9142",
	"title": "Analyzing Malware with Hooks, Stomps, and Return-addresses",
	"llm_title": "",
	"authors": "",
	"file_creation_date": "0001-01-01T00:00:00Z",
	"file_modification_date": "0001-01-01T00:00:00Z",
	"file_size": 605049,
	"plain_text": "Analyzing Malware with Hooks, Stomps, and Return-addresses\r\nBy Arash's Security Thoughts n Stuff\r\nPublished: 2022-03-12 · Archived: 2026-04-05 14:03:38 UTC\r\nTable of Contents\r\n1. Introduction\r\n2. The First Detection\r\n3. The Module Stomp Bypass\r\n4. The Module Stomp Detection\r\n5. Final Thoughts\r\nIntroduction\r\nThis is the second post in my series on developing robust malware and their relevant detection's.  This post will\r\nfocus on an interesting observation I made when creating my heap encryption and how this could be leveraged to\r\ndetect arbitrary shell-code as well as tools like cobalt strike, how those detections could be bypassed and even\r\nnewer detections can be made.\r\nEDITED: Forgot the POC!  Here it is https://github.com/waldo-irc/MalMemDetect\r\nThe First Detection\r\nIf you recall in the first post, our method at targeting Cobalt Strikes heap allocations was to hook the process\r\nspace and manage all allocations made by essentially what was a module with no name.  Here is the code we had\r\nused as a refresher:\r\n#include \u003cintrin.h\u003e\r\n#pragma intrinsic(_ReturnAddress)\r\nGlobalThreadId = GetCurrentThreadId(); We get the thread Id of our dropper!\r\nHookedHeapAlloc (Arg1, Arg2, Arg3) {\r\n LPVOID pointerToEncrypt = OldHeapAlloc(Arg1, Arg2, Arg3);\r\n if (GlobalThreadId == GetCurrentThreadId()) { // If the calling ThreadId matches our initial thread id then\r\n \r\n HMODULE hModule;\r\n char lpBaseName[256];\r\nif (::GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCSTR)_ReturnAddress(), \u0026hM\r\n ::GetModuleBaseNameA(GetCurrentProcess(), hModule, lpBaseName, sizeof(lpBaseName));\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 1 of 15\n\n}\r\n std::string modName = lpBaseName;\r\n std::transform(modName.begin(), modName.end(), modName.begin(),\r\n [](unsigned char c) { return std::tolower(c); });\r\n if (modName.find(\"dll\") == std::string::npos \u0026\u0026 modName.find(\"exe\") == std::string::npos) {\r\n // Insert pointerToEncrypt variable into a list\r\n }\r\n }\r\n}\r\nThe magic lines lie here:\r\nif (::GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCSTR)_ReturnAddress(), \u0026hM\r\n ::GetModuleBaseNameA(GetCurrentProcess(), hModule, lpBaseName, sizeof(lpBaseName));\r\n }\r\nWhat we are trying to do here is take the current address our function will be returning to and attempting to\r\nresolve it to a module name using the function GetModuleHandleExA with the argument\r\nGET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS.  With this flag the implication is the address we are\r\npassing is: \"an address in the module\" (https://docs.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandleexa).  The module name will get returned and stored in the lpBaseName variable.\r\nWith the case of our thread targeted heap encryption this function actually returns 0, as it cannot resolve the return\r\naddress to a module!  This also means lpBaseName ends up containing nothing.\r\nAs always, let's see what this looks like in our debugger.  First, we'll start with a legitimate call.  I've gone ahead\r\nand hooked HeapAlloc using MinHook (https://github.com/TsudaKageyu/minhook) and am tracing the return\r\naddress of all callers.  Let's see who the first function to call our hooked malloc is:\r\nfig 1. Usage of _ReturnAddress intrinsic\r\nHere we can see within our code we use the Visual C++ _ReturnAddress() intrinsic\r\n(https://docs.microsoft.com/en-us/cpp/intrinsics/returnaddress?view=msvc-160) and store the value in a variable\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 2 of 15\n\nnamed \"data\".  We then pass this variable to GetModuleHandleExA in order to resolve the module name we will\r\nbe returning to.\r\nfig 2. Return address value\r\nTaking a look at data we can see it seems to have stored a valid address.  Now let's look at this address in our\r\ndisassembler.\r\nfig 3. Return address location\r\nAs you can see we are right at that \"mov rbx,rax\" instruction at the end of the screenshot based on the address.\r\n That means when our hooked function completes this is where it will return, and we can further validate this as\r\nthe correct assembly instruction we will return to as right before this is a call to RtlAllocateHeap, our hooked\r\nfunction!  Using this we now know we are in the function LdrpGetNewTlsVector, that our hooked\r\nRtlAllocateHeap was just ran, and on completion it'll continue within LdrpGetNewTlsVector right after the call as\r\nusual.  If we attempt to identify what module this function comes from we can clearly see it is from ntdll.dll.\r\nfig 4. Return address module resolved\r\nThis works because the function maps to a DLL we appear to have loaded from disk.  Because of this, Windows\r\nknows how to identify what module the function comes from.  What about our shellcode though?  Let's see what\r\nthat looks like.\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 3 of 15\n\nfig 5. Shellcode return address and failed resolution\r\nSo our base name is empty because the function fails to resolve the address to a module.  Lets see what that\r\naddress looks like in the disassembler:\r\nfig 6. Shellcode return address location\r\nThere's our address at \"test rax,rax\".  We actually know this is our shell-code based on the address:\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 4 of 15\n\nfig 7. Shellcode in process hacker\r\nfig 8. Shellcode region in process hacker\r\nWithin process hacker we can see our MZ header and that the location we are returning to is within the address\r\nspace of our shellcode.  We can also see unlike other modules like ntdll.dll, in ProcessHacker the \"use\" column is\r\nempty for our shellcode:\r\nfig 9. Use section for shellcode is empty\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 5 of 15\n\nfig 10. Use section for DLL's is filled\r\nThis is because our arbitrarily allocated memory does not map to anything on disk.  Because of this, when we\r\nattempt to resolve the return address to a module we get nothing returned as a result.  \r\nThat being said, we can see instances of RWX memory that don't map to disk in processes that use JIT compilers\r\nsuch as C# and browser processes as well.  You can see in stage 3 of the Managed Execution Process\r\n(https://docs.microsoft.com/en-us/dotnet/standard/managed-execution-process) that an additional compiler takes\r\nthe C# code a user creates and turns it into native code (which means our C# IL now becomes native assembly).\r\n For this process to take place a RWX region needs to be allocated for it to be able to write the new code and also\r\nbe able to execute it.  We can see these RWX regions in C# processes with ProcessHacker.\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 6 of 15\n\nfig 11. JIIT Compiler RWX sections\r\nAbove you can see a small sample of these RWX sections within my Microsoft.ServiceHug.Controller.exe\r\nprocess.  This means in theory we could see false positives from JIT compiler based languages that run any of our\r\nhooked functions from these memory regions.  Additionally, this means these sorts of processes can also be great\r\nspaces to hide your RWX malware, as Private Commit RWX regions are otherwise considered suspicious (as we\r\nhave executable memory that doesn't map to anything on disk).\r\nOutside of blending in with JIT processes though, let's discuss another simple bypass to this, one that exists within\r\nCobalt Strikes own C2 profile even.\r\nThe Module Stomp Bypass\r\nIf we think back to the original detection, we were able to observe exectuable memory calling our hooked\r\nfunctions that couldn't resolve to any module name.  A first thought may be \"what is a mechanism to bypass this\"\r\nas one must exist.  Several exist in fact, but we can start with a simple one, a mechanism called \"Module\r\nStomping\" (https://www.forrest-orr.net/post/malicious-memory-artifacts-part-i-dll-hollowing as well as\r\nhttps://www.ired.team/offensive-security/code-injection-process-injection/modulestomping-dll-hollowing-shellcode-injection).\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 7 of 15\n\nWhat this technique effectively does is load a DLL that our process doesn't currently have loaded and hollow out\r\nits memory regions to instead contain the data for a malicious DLL of ours instead.  This would make it so all our\r\ncalls now appear to be coming from this legitimate module!\r\nThe section in your malleable C2 profile (for Cobalt Strike) that you would have to edit is the following:\r\n set allocator \"VirtualAlloc\"; # HeapAlloc,MapViewOfFile, and VirtualAlloc.\r\n # Ask the x86 ReflectiveLoader to load the specified library and overwrite\r\n # its space instead of allocating memory with VirtualAlloc.\r\n # Only works with VirtualAlloc\r\n set module_x86 \"xpsservices.dll\";\r\n set module_x64 \"xpsservices.dll\";\r\nThese settings can be observed in the old reference profile here: https://github.com/rsmudge/Malleable-C2-\r\nProfiles/blob/master/normal/reference.profile.  By changing your allocator to \"VirtualAlloc\" and enabling the set\r\nmodule_x86 and x64 settings you can now allocate your Cobalt Strike payload to arbitrary modules you load\r\ninstead of arbitrarily allocated exectuable memory space.\r\nLet's change the setting and see what this looks like.  We will simply run an unstaged Cobalt Strike EXE and\r\nobserve for this experiment.\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 8 of 15\n\nfig 12. Cobalt Strike module stomp\r\nLet's go ahead and run this with our module name resolver and see what it looks like.  Since the name should\r\nalways resolve now we will change the logic a bit to monitor only xpsservices.dll.\r\nfig 13. New code to monitor xpsservices\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 9 of 15\n\nfig 14. Name resolved properly\r\nHere we can see the new stomped DLL calling our hooked malloc, and that our code can successfully resolve calls\r\nto this module.  If we look at the print statements we would also see all the calls from anything that dont't map to\r\nmodules have dissapeared.\r\nfig 15. Only module callers\r\nAnd finally we can see in the above screenshot that no callers without module names are observed anymore as all\r\nof Cobalt Strike's calls now map to a module on disk, a simple bypass.  So now we ask if this technique can be\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 10 of 15\n\ndetected as well, and of course, there's a few ways.\r\nThe Module Stomp Detection\r\nThere are several detections but we will delve into 2 here for module stomping.  One is due to a side effect of how\r\nCobalt Strike implements module stomping as well as general IOCs that can be observed when module stomping\r\nis performed.\r\nThe first is a detection created by Slaeryan (https://github.com/slaeryan/DetectCobaltStomp).  In short, this\r\ndetection works becasue a side effect of Cobalt Strike's implementation is that when loaded in memory, the region\r\nappears to be marked as a EXE internally and not a DLL.  For those that don't have cobalt strike, he also created a\r\ntool to mimic the implementation for people to play with and observe the detection.  I won't go into this one too\r\nmuch as he already has a POC and discusses this detection.\r\nThe other detection is a much more basic one.  Within any executable file, the section where executable code lives\r\nis the .TEXT section.  If we walk the .TEXT section of a DLL on disk and compare it to the .TEXT section of its\r\nequivalent offload in memory the sections in theory should always match, as the code should not change unless\r\nthe file is polymorphic.  The code for this is fairly basic.\r\n HMODULE lphModule[1024];\r\n DWORD lpcbNeeded;\r\n // Get a handle to the process.\r\n HANDLE = hProcess = OpenProcess(PROCESS_QUERY_INFORMATION |\r\n PROCESS_VM_READ,\r\n FALSE, processID);\r\n // Get a list of all the modules in this process.\r\n if (EnumProcessModules(hProcess, lphModule, sizeof(lphModule), \u0026lpcbNeeded))\r\n {\r\n for (i = 0; i \u003c (lpcbNeeded / sizeof(HMODULE)); i++)\r\n {\r\n char szModName[MAX_PATH];\r\n // Get the full path to the module's file.\r\n if (K32GetModuleFileNameExA(hProcess, lphModule[i], szModName,\r\n sizeof(szModName) / sizeof(char)))\r\n {\r\n // Do stuff\r\n }\r\n }\r\n }\r\n \r\nHere we simply start by iterating every module in the process.  \r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 11 of 15\n\n// Get file Bytes\r\n FILE* pFile;\r\n long lSize;\r\n //SIZE_T lSize;\r\n BYTE* buffer;\r\n size_t result;\r\n pFile = fopen(szModName, \"rb\");\r\n // obtain file size:\r\n fseek(pFile, 0, SEEK_END);\r\n lSize = ftell(pFile);\r\n rewind(pFile);\r\n // allocate memory to contain the whole file:\r\n buffer = (BYTE*)malloc(sizeof(BYTE) * lSize);\r\n // copy the file into the buffer:\r\n result = fread(buffer, 1, lSize, pFile);\r\n fclose(pFile);\r\n \r\n BYTE* buff;\r\n buff = (BYTE*)malloc(sizeof(BYTE) * lSize);\r\n _ReadProcessMemory(hProcess, lphModule[i], buff, lSize, NULL);\r\n PIMAGE_NT_HEADERS64 NtHeader = ImageNtHeader(buff);\r\n PIMAGE_SECTION_HEADER Section = IMAGE_FIRST_SECTION(NtHeader);\r\n WORD NumSections = NtHeader-\u003eFileHeader.NumberOfSections;\r\n for (WORD i = 0; i \u003c NumSections; i++)\r\n {\r\n std::string secName(reinterpret_cast(Section-\u003eName), 5);\r\n if (secName.find(\".text\") != std::string::npos) {\r\n break;\r\n }\r\n Section++;\r\n }\r\n \r\nWe then load the relevant module file on disk and store the bytes for comparing memory in the var buffer.  We\r\nthen also read from the base address of the module located in \"lphModule[i]\" and store all the bytes within the var\r\nbuff.  We then enumerate all the sections in the loaded module until we find the .TEXT section and break the loop.\r\n At this point the \"Section\" variable will contain all our relevant section data.\r\nTo be able to match the on disk file to the one in memory we need to use the Section offsets to find the .TEXT\r\nsection location on disk and in memory.  This actually will not match (usually).  The offset to the .TEXT section\r\nin memory generally gets relocated down a page, 4096 bytes.  The offset to the section on disk is usually 1024\r\nbytes in comparison.   But we say usually so we of course will simply use \"Section-\u003ePointerToRawData\" to get\r\nthe offset on disk and \"Section-\u003eVirtualAddress\" to get its offloaded address in memory to be 100% sure.\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 12 of 15\n\nLPBYTE txtSectionFile = buffer + Section-\u003ePointerToRawData;\r\n LPBYTE txtSectionMem = buff + Section-\u003eVirtualAddress;\r\n \r\nAt this point all you'd have to do is compare each memory region byte for byte and make sure they match.\r\n int inconsistencies = 0;\r\n for (int i = 0; i \u003c Section-\u003eSizeOfRawData; i++) {\r\n if ((char*)txtSectionFile[i] != (char*)txtSectionMem[i]) {\r\n inconsistencies++;\r\n }\r\n }\r\n \r\nNow of course we need to account for things like hooks and such, as we know many AV and EDR will perform\r\nhooks we know these will provide false positives.  As a result we take the amount of the differences and if it's\r\ngreater than a certain number only do we get concerned.\r\n if (inconsistencies \u003e 10000) {\r\n printf(\"FOUND DLL HOLLOW.\\nNOW MONITORING: %s with %f changes found. %f%% Overall\\n\\n\", szMo\r\n CHAR* log = (CHAR*)malloc(256);\r\n snprintf(log, 255, \"FOUND DLL HOLLOW.\\nNOW MONITORING: %s with %f changes found. %f%% Overal\r\n LogDetected(\u0026log);\r\n free(log);\r\n std::string moduleName(szModName, sizeof(szModName) / sizeof(char));\r\n std::transform(moduleName.begin(), moduleName.end(), moduleName.begin(),\r\n [](unsigned char c) { return tolower(c); });\r\n dllMonitor = moduleName;\r\n break;\r\n }\r\n \r\nWe arbitrarily pick 10000 as our amount simply because we know it'll certainly be a larger number than any\r\nnumber of hooks any utility would alter for the hooks as well as being small enough we know most raw malware\r\npayloads at least are much bigger.  This should reduce false positives substantially while finding any altered DLLs\r\nin memory.  The only caveat to this would be additional false positives from polymorphic DLLs who alter\r\nthemselves in memory.\r\nLet's run our new detector against our Cobalt Strike payload and the hollowed DLL and observe the results.\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 13 of 15\n\nfig 16. DLL Hollow Detection\r\nHere we can see a few false positives from our own hooks actually, where we alter 5 bytes to the prologue of each\r\nfunction, 2 functions being altered in each DLL.  Finally at the end we can see our hollowed xpsservices.dll and\r\nthe detection is observed with over 300k bytes altered.\r\nLet's go ahead and turn our tool into a DLL and inject it into everything to observe false positives:\r\nInjecting into everything and logging all data to files we can observe our detection:\r\nfig 17. Detection\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 14 of 15\n\nBUT!  Interestingly enough we do observe 1 false positive on what appears to be a polymorphic DLL after all...\r\nfig 18. False positive\r\nUnfortunately not enough bytes are altered to be useful for a hollow target though!\r\nHow do you bypass this detection?  Now the simple obvious solution is to restore the DLL bytes (per\r\nhttps://twitter.com/solomonsklash's idea) on sleep to prevent this sort of detection and next steps would be\r\nhooking those calls and detecting the restores if possible or the constant file reads etc.  As we all know Cyber\r\nSecurity is a never ending cat and mouse.  \r\nFinal Thoughts\r\nAs red teamers work on malware often we make discoveries that can lead to new detections too.  These\r\nobservations can be tremendously useful to the community while also pushing researchers to the cutting edge and\r\nforcing them to think outside of the box if they'd like this game to continue longer.\r\nAs we see above we find detections, make bypasses, find more detections, and the game will never end.\r\n Hopefully some interesting new insights could be made to make our defensive industry far more robust overall as\r\nwe work together towards a goal of secure internet usage.\r\nSource: https://www.arashparsa.com/catching-a-malware-with-no-name/\r\nhttps://www.arashparsa.com/catching-a-malware-with-no-name/\r\nPage 15 of 15",
	"extraction_quality": 1,
	"language": "EN",
	"sources": [
		"Malpedia"
	],
	"references": [
		"https://www.arashparsa.com/catching-a-malware-with-no-name/"
	],
	"report_names": [
		"catching-a-malware-with-no-name"
	],
	"threat_actors": [
		{
			"id": "610a7295-3139-4f34-8cec-b3da40add480",
			"created_at": "2023-01-06T13:46:38.608142Z",
			"updated_at": "2026-04-10T02:00:03.03764Z",
			"deleted_at": null,
			"main_name": "Cobalt",
			"aliases": [
				"Cobalt Group",
				"Cobalt Gang",
				"GOLD KINGSWOOD",
				"COBALT SPIDER",
				"G0080",
				"Mule Libra"
			],
			"source_name": "MISPGALAXY:Cobalt",
			"tools": [],
			"source_id": "MISPGALAXY",
			"reports": null
		}
	],
	"ts_created_at": 1775434433,
	"ts_updated_at": 1775791464,
	"ts_creation_date": 0,
	"ts_modification_date": 0,
	"files": {
		"pdf": "https://archive.orkl.eu/f38c6b1a461290162e85cc99139911ca0d0b9142.pdf",
		"text": "https://archive.orkl.eu/f38c6b1a461290162e85cc99139911ca0d0b9142.txt",
		"img": "https://archive.orkl.eu/f38c6b1a461290162e85cc99139911ca0d0b9142.jpg"
	}
}