Malware analysis – Hancitor Loader


Stage 1

In our case is very simple, so let’s quickly run through it. From the import table, we can see that there’s some work with file resources. I immediately thought there must be something there, so I checked it out, and sure enough, we see a base64 string. This is easy to spot because it almost always ends with a double equals sign “==”.

We also see in the import table an API for working with encryption; we should immediately check what is being passed to it, and very quickly we find a string that we can assume is used for decryption. And that’s exactly what happened. All we had to do was take this string and convert it into an SHA1 hash. This will be our decryption key. We take the first 5 bytes and pass them to the decryption function. Voilà, we see the standard PE file headers (MZ on the screenshot). This is our loader. The rest is a little more interesting.



Stage 2

Hancitor is a DLL embedded in malicious documents distributed via phishing emails. Infection typically occurs through a VBA macro that runs when the document is opened. Once dropped from the document, the initial packed DLL acts as an intermediate stage — it unpacks and exposes Hancitor’s main functionality. Based on collected information about the infected host, the malware decides which payload to deliver next. Hancitor then carries out the loading routine to install the final malicious content on the system.

## Technical summary

Configuration extraction: Hancitor contains an embedded configuration encrypted with RC4 using a hard-coded key. It uses the Microsoft Windows CryptoAPI to decrypt this data. The configuration includes the C2 addresses the malware will contact for further instructions.

Host profiling: The malware gathers host information to choose which payload to fetch and to generate a unique victim ID. Collected data may include OS version, IP address, domain trusts, computer name and username. For example, if the machine is joined to an Active Directory domain, conditions for deploying Cobalt Strike are considered met.

C2 communication: The victim profile is sent to the command-and-control server, which responds with a command encoded in base64 and additionally obfuscated with a single-byte XOR. The command specifies one of five available loading techniques and supplies a URL to download the next-stage malicious payload.

Payload download: Hancitor can retrieve a wide variety of payloads — from fully formed EXE or DLL files to carefully crafted shellcode. This flexibility makes it useful to different threat actors.

Malicious code execution: Whether by process injection or by dropping and executing files on disk, Hancitor can perform complex actions to ensure the downloaded malicious code runs on the victim machine.

## Host profiling

After unpacking the DLL and loading it into IDA Pro, we observed that the unpacked Hancitor sample exposes two exports that both forward to the same function — this is where static analysis begins. The malware’s workflow starts with collecting host information. The data it gathers includes the OS version, the victim’s IP address, domain names and DNS entries, the computer name and username, and whether the system is x86 or x64.

Hancitor queries network adapter details via GetAdaptersAddresses to collect MAC addresses and other network information. It then generates a unique victim identifier by XOR-ing the MACs of all connected adapters with the volume serial number of the Windows directory.

A routine called check_if_x64 determines the system architecture (x64 vs x86). After gathering all pieces, the malware builds a single concatenated string that contains the collected host data — this string is what gets sent to the C2. The mw_config_decryption call (explained elsewhere) extracts the embedded configuration, and parts of that config are included in the final host profile.

A useful detection indicator for YARA is the format string used when building the profile:
“GUID=%I64u&BUILD=%s&INFO=%s&EXT=%s&IP=%s&TYPE=1&WIN=%d.%d”.

Finally, the characteristics collected from the host drive the payload decision logic — for example, if the machine is joined to an Active Directory domain, the loader may fetch and deploy Cobalt Strike.

## Configuration extraction

Before finishing the host profile, the loader decrypts its embedded configuration and includes it in the data sent to the C2. The decryption routine references two global variables near the start of the .data section; from how the routine’s parameters are arranged, the 8 bytes at 0x5A5010 are identified as the decryption key, immediately followed by the encrypted config blob.

Hancitor stores its configuration encrypted with RC4 and uses a hard-coded key. The sample uses the Microsoft Windows CryptoAPI to perform decryption, but the key is first run through SHA-1 — only the first five bytes of that SHA-1 digest are actually used as the RC4 key.

A control parameter signals the RC4 key size: in this sample the value indicates a 40-bit key (5 bytes), so the implementation uses the first five bytes of the hashed key for RC4 decryption.

You can reproduce the decryption statically (for example, with CyberChef). In this sample the 8-byte key {5A 3F 67 79 C1 C7 33 CE} hashes to SHA-1 {b8 06 d9 27 af cc bb f6 46 65 ba cf ff 2d 45 74 17 cc 00 69}; taking the first five bytes of that digest yields the RC4 key used to decrypt the configuration. The decrypted configuration contains the C2 addresses the loader will attempt to contact (the sample shows three C2 servers).

Later in this report we demonstrate an automated method (Python) to extract the embedded configuration programmatically.



C2 communication

Hancitor pulls the C2 URLs from its decrypted configuration and communicates with them using Wininet.dll high-level APIs. The sample uses a hard-coded User-Agent string — “Mozilla/5.0 (Windows NT 6.1; Win64; x64; Trident/7.0; rv:11.0) like Gecko” — which mimics common browser traffic.

The malware sends the assembled host profile to the server via an HTTP POST request and then awaits a matching command from the C2 based on the reported victim data. The server response contains a command that is base64-encoded and additionally obfuscated with a single-byte XOR (key 0x7A). Hancitor decodes and XORs the response before parsing it.

A decoded command has four components separated by delimiters:

A single character from the set { ‘b’, ‘e’, ‘l’, ‘n’, ‘r’ } indicating the action to take.

A colon : as a separator.

The URL pointing to the malicious payload to download.

A pipe | as the trailing delimiter.

Like these: http://fruciand.com/8/forum.php|http://forticheire.ru/8/forum.php|http://nentrivend.ru/8/forum.php



Executing C2 commands

After the loader retrieves and decodes the C2 response, it validates the command and routes it to the handler that will download and run the indicated payload. The URL to fetch the payload is taken from the C2 string starting at offset 3. The malware uses the first character of the command to select one of several execution branches.

There are five possible command types; one (n) is a no-op, leaving four actionable paths.

b — inject into a new svchost.exe (CREATE_SUSPENDED)

This branch creates a new svchost.exe process in a suspended state and verifies that the downloaded buffer is a valid PE (DLL or EXE) before injecting it. Injection follows the classic pattern — allocate memory in the remote process (VirtualAllocEx), write the payload (WriteProcessMemory) — but the loader then modifies the suspended thread’s context so that the EAX register points at the injected module’s OEP. By replacing the thread context, execution in the new process begins at the injected binary’s entry point.

e — execute PE inside the current process

Unlike b, this path runs the downloaded PE within the existing process rather than spawning svchost.exe. Hancitor parses the PE header to obtain ImageBase and AddressOfEntryPoint, rebuilds the import table by resolving dependencies via LoadLibraryA and GetProcAddress, and then either creates a new thread to run the payload or calls the entry directly depending on configuration flags. Reconstructing imports prevents crashes caused by unresolved dependencies.

l — shellcode execution

This branch treats the download as shellcode (no PE validation). Based on function flags, the loader either injects the shellcode into a newly created suspended svchost.exe (then creates a thread to run it) or invokes the shellcode as a function inside the current process. Because only the main thread was suspended, the malware creates a new thread in the remote process for execution.

r — drop to disk and execute

This is the only path that writes the payload to disk. Hancitor saves the downloaded binary to %TEMP% under a randomized name with the BN prefix. If the file is an EXE it is launched as a new process; if it is a DLL, the loader executes it via rundll32.exe.

Yara rules:
Stage1
Code based

rule PEHeader_Map_Alloc_Copy
{

  strings:
    $pat = {
      55 8B EC 83 EC 30 A1 ?? ?? ?? ?? 33 C5 89 45 FC 53 56 57 8B D9
      6A 00 89 5D F8 FF 15 ?? ?? ?? ?? 8B 7B 3C 03 FB 6A 40 68 00 30 00 00
      89 7D F0 FF 77 50 FF 77 34 FF 15 ?? ?? ?? ?? FF 77 54 8B F0 2B 47 34
      53 56 89 75 F4 89 45 E0 E8 ?? ?? ?? ?? 8B 4D F0 33 C0 0F B7 7F 14
      83 C4 0C 83 C7 2C 33 DB 66 3B 41 06 73 2F
    }

  condition:
    uint16(0) == 0x5A4D and $pat
}
Enter fullscreen mode

Exit fullscreen mode

Stage2
Code and string based

import "pe"

rule UA_Ipify_GUID_Format_in_rdata
{

  strings:
    $ua    = "Mozilla/5.0 (Windows NT 6.1; Win64; x64; Trident/7.0; rv:11.0) like Gecko" ascii wide
    $ip    = "http://api.ipify.org" ascii wide
    $fmt64 = "GUID=%I64u&BUILD=%s&INFO=%s&EXT=%s&IP=%s&TYPE=1&WIN=%d.%d(x64)" ascii wide
    $fmt32 = "GUID=%I64u&BUILD=%s&INFO=%s&EXT=%s&IP=%s&TYPE=1&WIN=%d.%d(x32)" ascii wide

  condition:
    uint16(0) == 0x5A4D and
    for any i in (0 .. pe.number_of_sections - 1) :
      ( pe.sections[i].name == ".rdata" and
        $ua in  (pe.sections[i].raw_data_offset .. pe.sections[i].raw_data_offset + pe.sections[i].raw_data_size) and
        $ip in  (pe.sections[i].raw_data_offset .. pe.sections[i].raw_data_offset + pe.sections[i].raw_data_size) and
        ( $fmt64 in (pe.sections[i].raw_data_offset .. pe.sections[i].raw_data_offset + pe.sections[i].raw_data_size) or
          $fmt32 in (pe.sections[i].raw_data_offset .. pe.sections[i].raw_data_offset + pe.sections[i].raw_data_size) )
      )
}
Enter fullscreen mode

Exit fullscreen mode

import "pe"

rule C2_CommandDispatcher_Colon_Switch_Jumptable
{

  strings:
    $colon_check = { 0F BE 14 01 83 FA 3A 74 07 33 C0 E9 ?? ?? ?? ?? }

    $switch_core = {
      89 45 FC                // mov [ebp-4], eax        ; (var_4)
      8B 4D FC                // mov ecx, [ebp-4]
      83 E9 62                // sub ecx, 62h            ; 'b'
      89 4D FC                // mov [ebp-4], ecx
      83 7D FC 10             // cmp [ebp-4], 10h
      0F 87 ?? ?? ?? ??       // ja  default
      8B 55 FC                // mov edx, [ebp-4]
      0F B6 82 ?? ?? ?? ??    // movzx eax, byte ptr [edx+imm] ; selector
      FF 24 85 ?? ?? ?? ??    // jmp dword ptr [eax*4 + imm]   ; jumptable
    }

    $case_r = {
      8B 4D 08 83 C1 02 51    // mov ecx,[ebp+8]; add ecx,2; push ecx
      E8 ?? ?? ?? ??          // call  sub_6F631EF0
      83 C4 04                // add   esp,4
      8B 55 0C 89 02          // mov edx,[ebp+0C]; mov [edx],eax
      B8 01 00 00 00          // mov eax,1
    }

    $case_l = {
      6A 01 6A 01             // push 1; push 1
      8B 45 08 83 C0 02 50    // mov eax,[ebp+8]; add eax,2; push eax
      E8 ?? ?? ?? ??          // call sub_6F631F60
      83 C4 0C                // add  esp,0Ch
      8B 4D 0C 89 01          // mov  ecx,[ebp+0C]; mov [ecx],eax
      B8 01 00 00 00          // mov eax,1
    }

    $case_e = {
      6A 00                   // push 0
      8B 55 08 83 C2 02 52    // mov edx,[ebp+8]; add edx,2; push edx
      E8 ?? ?? ?? ??          // call sub_6F631E00
      83 C4 08                // add  esp,8
      8B 4D 0C 89 01          // mov  ecx,[ebp+0C]; mov [ecx],eax
      B8 01 00 00 00          // mov  eax,1
    }

    $case_b = {
      8B 55 08 83 C2 02 52    // mov edx,[ebp+8]; add edx,2; push edx
      E8 ?? ?? ?? ??          // call sub_6F631E80
      83 C4 04                // add esp,4
      8B 4D 0C 89 01          // mov ecx,[ebp+0C]; mov [ecx],eax
      B8 01 00 00 00          // mov eax,1
    }

  condition:
    uint16(0) == 0x5A4D and pe.machine == pe.MACHINE_I386 and
    $colon_check and $switch_core and
    @switch_core > @colon_check and (@switch_core - @colon_check) < 0x200 and
    2 of ($case_b, $case_e, $case_l, $case_r)
}
Enter fullscreen mode

Exit fullscreen mode

Scripts:
Auto unpacking for Stage 2:

#!/usr/bin/env python3
import sys, base64, hashlib
from pathlib import Path

import pefile
from arc4 import ARC4 

RESOURCE_ID = 0x67
PASSWORD = b"Just because you shot Jesse James, don\x92t make you Jesse James."
OUT_FILE = "stage2.bin"

def extract_text_resource(pe_path, res_id=RESOURCE_ID):
    pe = pefile.PE(pe_path, fast_load=True)
    try:
        pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
    except Exception:
        pass
    if not hasattr(pe, "DIRECTORY_ENTRY_RESOURCE"):
        raise RuntimeError("No resource directory found.")
    for type_entry in pe.DIRECTORY_ENTRY_RESOURCE.entries:
        tname = getattr(type_entry, "name", None) or getattr(type_entry, "id", None)
        if str(tname) == "TEXT":
            for id_entry in getattr(type_entry, "directory", []).entries:
                iid = getattr(id_entry, "name", None) or getattr(id_entry, "id", None)
                if iid == res_id:
                    for lang_entry in getattr(id_entry, "directory", []).entries:
                        off = lang_entry.data.struct.OffsetToData
                        size = lang_entry.data.struct.Size
                        fo = pe.get_offset_from_rva(off)
                        return pe.get_data(fo, size)
    raise RuntimeError(f"TEXT resource with ID 0x{res_id:X} not found")

def derive_rc4_key_from_password(password: bytes, key_bytes=5):
    h = hashlib.sha1(password).digest()  # 20 bytes
    return h[:key_bytes]

def main(pe_path):
    pe_path = Path(pe_path)
    if not pe_path.exists():
        print("File not found:", pe_path); return

    raw = extract_text_resource(str(pe_path), RESOURCE_ID)
    print(f"Extracted TEXT resource: {len(raw)} bytes")

    # strip trailing NULLs/newlines and remove whitespace (Base64 may contain newlines)
    s = raw.rstrip(b"\x00")
    b64txt = b"".join(s.split())
    try:
        bin_data = base64.b64decode(b64txt)
    except Exception as e:
        print("Base64 decode failed:", e)
        return

    print("Base64 decoded size:", len(bin_data))

    # sub_401000 in binary = memcpy -> just use bin_data as ciphertext
    ciphertext = bin_data

    # derive RC4 key: SHA1(password) and take first 5 bytes (40-bit) as in sample
    key = derive_rc4_key_from_password(PASSWORD, key_bytes=5)
    print("Derived key (hex):", key.hex())

    # decrypt with ARC4
    rc4 = ARC4(key)
    plaintext = rc4.decrypt(ciphertext)

    Path(OUT_FILE).write_bytes(plaintext)
    print("Decrypted saved to:", OUT_FILE)
    print("Preview (first 64 bytes):", plaintext[:64])

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python decrypt_with_arc4.py ")
        sys.exit(1)
    main(sys.argv[1])
Enter fullscreen mode

Exit fullscreen mode

Auto extract malware config stage 2

import pefile
import hashlib
import binascii
import arc4

def extract_data_section(pe_path):
    pe = pefile.PE(pe_path)
    for sec in pe.sections:
        if b".data" in sec.Name:
            return sec.get_data()
    return None

def rc4_decrypt_and_show(rc4_key_bytes, encrypted_blob):
    cipher = arc4.ARC4(rc4_key_bytes)
    plain = cipher.decrypt(encrypted_blob)
    config = plain[:200]
    try:
        print(config.decode('utf-8', errors="replace"))
    except Exception as e:
        print(f"Failed to decode config: {e}")
        print(config)

def run():
    path = input("Enter file path: ")
    data_sec = extract_data_section(path)
    if not data_sec:
        print("No .data section found.")
        return

    config_blob = data_sec[16:]
    key_bytes = config_blob[0:8]
    encrypted_data = config_blob[8:]

    sha1_hash = hashlib.sha1(key_bytes).hexdigest()
    rc4_key_hex = sha1_hash[0:10]

    rc4_decrypt_and_show(binascii.unhexlify(rc4_key_hex), encrypted_data)

if __name__ == '__main__':
    run()
Enter fullscreen mode

Exit fullscreen mode

HASHES:
F4C8221DA9FF96697C2B75EA17E75F7E8775E344849A32F4D6AB8F26CE417F5F – stage 1
9A6B2A199AF672934BC1DE34DD9C668BBE5106C3D6E4889CF2C8170AD4F9D2F6 – stage 2

email: serhii.ivashchenko024@gmail.com



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *